1 同步与并发要解决什么
在 shell 中把日志压缩放到后台,同时运行目录扫描,2 个进程的执行时间就发生了重叠。它们可能轮流占用 1 个 CPU,也可能在多个 CPU 上同时运行。前者已经构成并发,后者进一步构成并行。并发首先描述任务之间的关系,不承诺速度提升。磁盘繁忙时增加压缩进程,反而可能让交互命令响应更慢。
只有任务相互影响时,同步问题才变得具体。2 个线程更新同一个连接计数,2 个进程改写同一份状态文件,1 个线程关闭套接字而另一个线程仍在发送,都需要说明谁可以在什么时刻访问什么资源。资源不只是内存地址,还包括文件内容、文件描述符、目录项以及对象的生命周期。同步的目标是让允许的执行顺序保住业务约定。
例如,监控程序要求“当前活动连接数不能为负”,文件发布程序要求“读者只能看到完整的旧版本或完整的新版本”。这类始终需要成立的条件叫不变量。先写不变量,再选择机制,通常比先给函数加锁有效。只保护计数加法,未保护关闭连接时的重复扣减,仍然会破坏不变量;只锁住写文件,读者不遵守约定,仍可能读到中间状态。
数据竞争有更严格的含义:多个线程并发访问同一内存位置,其中至少有写入,而且缺少语言规则认可的同步。竞争条件则更广,表示结果依赖不受控制的时序。2 个进程先检查文件不存在,再分别创建文件,就可能出现竞争条件,即使它们没有共享用户态内存。区分这 2 个概念,有助于选择线程检测工具或文件系统接口。

同步通常同时追求安全性与活性。安全性关心“不该发生的事不会发生”,例如同一连接不会被释放 2 次;活性关心“该发生的事最终有机会发生”,例如等待日志锁的线程能够继续运行。锁能帮助维护安全性,却可能因错误顺序破坏活性。观察程序时,既要看结果是否正确,也要看是否持续前进、等待是否有界。
本课把观察范围放在应用与操作系统接口的交界处。我们会用线程、互斥量、文件锁和进程观测命令理解约定,不要求读者掌握调度器或网络栈实现。每遇到共享资源,都先回答 3 个问题:谁拥有它,什么操作需要作为整体完成,其他参与者通过什么事件知道操作已经完成。
2 锁、原子与内存序直觉
看起来很短的 count++ 通常包含读取、计算、写回。若 2 个线程都读到 7,各自计算 8,再分别写回,最后得到 8,而预期是 9。这称为丢失更新。关键不在于源代码占几行,而在于多个参与者是否能穿插进入必须完整执行的操作。需要排他保护的这段操作叫临界区。
互斥锁提供进入临界区的约定:获得同一把锁的线程才能访问受保护状态,离开时释放锁。使用 POSIX 线程接口时,常见形式是 pthread_mutex_lock(&mu)、访问状态、pthread_mutex_unlock(&mu)。锁应对应明确的不变量,而不只是某个函数名。读操作若依赖受保护状态,也必须遵守约定;“我只读不写”不会自动使访问安全。
锁还承担交接作用。线程 A 在持锁期间完成的写入,可以通过释放锁、线程 B 随后获得同一把锁的同步关系交给 B。这里的可见性是程序可以依赖的规则,不是“等 CPU 把缓存刷一下”的猜测。编译器与处理器都可能调整实际执行方式,正确程序要依靠接口和语言规定的关系,而不是用运行多次的经验推断。
原子操作适合把某个状态访问作为不可分割的操作。例如 C11 的 _Atomic int 配合 atomic_fetch_add 可以避免该计数上的丢失更新。但“每个字段都是原子的”并不意味着多个字段一起更新。活动连接数与连接表必须对应时,分别原子修改它们,观察者仍可能看见中间组合。需要整体维护的约束,往往用锁表达更清楚。
内存序可以先理解为“除了这个值,还要交接哪些先前工作”。发布数据的一方先写内容,再用 release 操作发布就绪标记;接收方用 acquire 操作读取并观察到对应发布后,才使用内容。这需要正确配对,并满足对象生命周期等条件。入门代码可以先采用原子的默认顺序;不要仅凭基准测试就改成 relaxed,更不要把普通布尔变量当作发布接口。

等待“队列非空”这样的条件时,条件变量比不断查询更合适。典型写法是在持锁状态下使用 while (queue_empty()) pthread_cond_wait(&cv, &mu)。等待接口会按约定释放互斥量,并在返回前重新获得它;返回后仍须重新检查条件,因为可能有其他消费者先取走了数据,也可能出现虚假唤醒。通知表示值得重新检查,不能代替状态本身。
同步还包括线程结束时的交接。主线程通过 pthread_join 等待工作线程完成,再读取其结果,是常见的完成边界。固定休眠 sleep(1) 没有这种保证:机器负载变化后,工作可能尚未完成。把“开始”“发布”“完成”分别对应到明确接口,能让并发代码在慢机器和繁忙机器上保持同样的含义。
3 无锁结构的适用边界
无锁不等于没有同步,也不等于每个线程都立即成功。它通常指一种进展保证:在参与者持续执行的前提下,系统整体能够不断完成操作,但某个线程仍可能反复重试。普通互斥量让竞争者等待,无锁算法常用比较并交换等原子操作判断状态是否仍符合预期;失败后重新读取并尝试,代价并没有消失。
适合先考虑原子的场景是相互独立的简单统计。例如每个工作线程增加一个完成计数,而展示页面只需要近似的即时总量。这里不涉及根据计数释放对象,也不要求计数与另一张表瞬时一致。若统计特别频繁,还可以让各线程累计局部值,再周期性汇总,直接减少共享写入。减少共享通常比更换同步原语更容易解释和维护。
共享链表、连接对象和缓存节点复杂得多。某线程读到节点地址后,另一个线程可能删除并释放节点;即使指针读取是原子的,前者仍可能访问已释放内存。地址后来被重新使用,还会使“地址没变”的判断产生误导。解决这些问题需要专门的内存回收协议,本课只识别风险,不展开算法。优先采用经过验证、文档明确的库结构。
选择无锁结构应有证据:锁等待确实占据显著时间,操作语义足够清晰,所用实现给出了适用条件,团队也能覆盖生命周期和失败路径。日志输出慢、磁盘写入慢、网络对端不接收,都不能靠把锁改成原子解决。在锁外完成耗时准备,再在短临界区内提交结果,常常已经足够。
这里还要区分性能指标。吞吐量表示单位时间完成多少工作,延迟表示某次工作耗时多久。增加线程可能提高吞吐量,却让排队请求的尾部延迟变大。交互式 shell、网络服务的管理接口和日志采集有不同目标。滑块展示的是简化排队模型,用于认识等待增长;真实选择仍需要记录负载、临界区耗时分布和机器环境。
4 动手:复现一次数据竞争
下面在自己的练习目录创建 race.c,让 2 个线程更新同一计数。需要 C 编译器和 POSIX 线程支持。程序故意包含数据竞争,只用于实验;不要把它加入实际服务。我们先通过检测器识别非法访问,再观察结果,避免误以为“只要最后数字正确就没有问题”。
#include <pthread.h>
#include <stdio.h>
enum { N = 1000000 };
static int count = 0;
static void *worker(void *arg) {
(void)arg;
for (int i = 0; i < N; ++i) {
count++;
}
return NULL;
}
int main(void) {
pthread_t a, b;
if (pthread_create(&a, NULL, worker, NULL) != 0)
return 1;
if (pthread_create(&b, NULL, worker, NULL) != 0) {
pthread_join(a, NULL);
return 1;
}
pthread_join(a, NULL);
pthread_join(b, NULL);
printf("count=%d expected=%d\n", count, 2 * N);
return 0;
}
cc -O0 -g -pthread race.c -o race
./race
cc -O1 -g -fsanitize=thread -pthread race.c -o race-tsan
./race-tsan
第 1 次运行可能得到小于 2000000 的数,也可能恰好等于它。C 中这种数据竞争导致未定义行为,因此不能把示意性的交错当成程序所有结果的完整解释。降低优化等级只是方便观察,不会让程序合法。ThreadSanitizer 在支持的环境中通常会报告冲突访问及线程创建位置;若运行时不受支持,应记录环境限制,不能把无法运行检测器解释成通过。
接着在文件作用域增加 static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;,并把循环体改为下面的形式。教学片段假设锁接口成功;实际工程应按统一方式处理接口错误。这里每次增量都加锁,是为了清楚对应受保护操作,并不代表这是最快的累计方式。
pthread_mutex_lock(&mu);
count++;
pthread_mutex_unlock(&mu);
重新编译普通版本和检测版本后,计数应为 2000000,原来的竞争报告应消失。也可以把计数声明改为 _Atomic int,包含 <stdatomic.h>,并用 atomic_fetch_add(&count, 1) 替换增量;这适用于当前只有独立计数的不变量。若以后还要同步更新连接列表,就需要重新设计,不能机械照搬原子版本。
文件竞争可以用另一个短实验认识。在支持 flock 的 Linux 环境中,终端 A 执行 flock /tmp/s25-demo.lock sleep 20,终端 B 随后执行 flock -n /tmp/s25-demo.lock true,再用 echo $? 查看退出码。B 获取不到锁时会失败。把 -n 去掉则会等待。练习结束后,锁随相关文件描述符关闭而释放,不需要靠删除锁文件“解锁”。
这个文件锁是协作约定,其他进程若直接改写目标文件,并不会被自动拦住。所有参与者需要锁定同一个稳定的锁对象。不要运行期间删除再创建锁文件,否则相同路径可能对应不同对象,让参与者分别持有看似同名的锁。跨主机或网络文件系统上的锁语义还需要查对应文件系统文档,不能从本机实验外推。
5 死锁、活锁与优先级反转
死锁最直观的例子是线程 A 持有日志锁,等待连接锁;线程 B 持有连接锁,等待日志锁。双方都无法走到解锁位置。常见死锁同时涉及排他占用、持有资源继续等待、资源不能被任意抢走、等待关系形成环。分析时不必背定义,可以直接画出“谁持有什么,正在等什么”的图,检查是否出现闭环。
避免这类问题的常用办法是统一锁顺序,例如规定连接锁总在日志锁之前获得,释放时按相反方向处理。更好的设计可能让日志在释放连接锁后输出,从而根本不需要嵌套持锁。顺序规定必须覆盖回调、错误处理和清理分支;主路径遵守顺序,异常路径反过来取锁,仍然可能死锁。
活锁表现不同:线程仍在运行,也在释放和重试,却没有完成有用工作。例如 2 个线程各自取得部分资源,发现另一资源繁忙就都退让,然后又同步重试。CPU 使用率可能很高,完成计数却不增加。退避、限制重试次数或重新安排资源获取方式可以改善,但不能随意把等待循环改成忙重试,否则可能把低 CPU 的阻塞变成高 CPU 的停滞。
饥饿表示某个参与者长期得不到执行或资源机会,而其他参与者仍能前进。优先级反转则是高优先级任务等待低优先级任务持有的锁,而其他任务又拖延了锁持有者的运行。对于有实时要求的系统,需要结合线程调度策略和支持的互斥量协议设计;本课先记住“提高等待者优先级不一定能缩短等待”,具体机制留待后续。
超时是发现等待过长、触发降级的一种手段,不是自动修复。超时返回时,线程可能仍持有先前获取的资源,必须明确释放责任与状态是否已经修改。取消线程同样涉及清理协议。把超时、取消、重试与成功路径放在同一张状态图上,才能避免“故障处理代码”本身制造锁泄漏和重复操作。
6 常见坑:过度加锁、错误假设可见性
过度加锁常发生在把整个请求处理函数包进全局互斥量时。函数中可能包含文件读取、域名解析、网络发送和日志输出,这些操作的等待时间不由当前线程控制。其他线程即使只想查询一个计数,也被挡在同一把锁后面。先测量临界区内实际做了什么,再把无共享状态的准备工作移出去,比直接增加线程数更有针对性。
把锁移出去也有边界。若先在锁内取得连接指针,解锁后立刻发送,另一个线程可能已经关闭或释放该连接。需要通过所有权转移、明确的引用管理或消息传递保证使用期间对象仍然有效。文件描述符是可复用的整数:旧连接关闭后,同样的数字可能代表新文件。只检查“描述符大于等于 0”不能证明它仍指向原来的对象。
另一个常见错误是用 volatile 修复线程通信。在 C 中,它不提供互斥,也不建立所需的线程同步关系。普通变量作为停止标记,工作线程循环读取,主线程写入后再等它退出,可能形成数据竞争。应采用原子标记或锁,并用线程等待接口完成回收。增加打印、插入休眠后现象消失,只说明时序改变了,不能证明问题解决。
文件系统也有“检查后使用”的时间窗口。test ! -e target 后再写入 target,期间其他进程可能已创建它。若需要独占创建,应使用具备对应语义的接口,例如 open 的 O_CREAT | O_EXCL。若要发布完整文件,可以先写同一文件系统内的临时文件,再用重命名切换名称;这解决的是名称可见性的原子切换,崩溃后的持久性还要另行考虑。
网络接口的成功也容易被误解。send 返回正数通常表示本次有相应字节被本地接口接受,不能据此断言对端应用已经处理。多个线程写同一条字节流,即使每次调用本身成功,也不能自动维护应用消息边界和顺序。常见设计让 1 个发送者拥有套接字,其他线程把完整消息放入受同步保护的队列,并处理部分发送、错误和关闭状态。

排查时,先用 ps -L -p PID -o pid,tid,stat,wchan:24,comm 看线程状态,再结合 top -H -p PID 看是否存在忙循环。具有相应权限时,strace -f -p PID -e trace=futex 可辅助观察某些同步等待;gdb -p PID 后执行 thread apply all bt 可采集线程栈。附加调试器会暂停进程,生产环境应按现场流程操作。单个 futex 等待并不证明死锁,必须关联持锁方、等待链和持续时间。
7 案例:锁顺序颠倒导致的生产死锁
考虑一个经过简化的日志采集守护进程。接收线程维护连接表,轮转线程按大小切换输出文件。高峰时,进程仍在,端口仍可连接,但日志文件不再增长,已接收任务的完成计数停住。CPU 占用很低,磁盘空间与 inode 都充足。此时直接扩线程或重启虽然可能暂时恢复,却无法解释停滞原因。
接收线程的错误路径先获得 connections_mu,从连接表删除失效连接,再调用日志函数。日志函数会获取 log_mu。轮转线程先获得 log_mu,切换文件后,为记录当前连接数又获得 connections_mu。2 条路径单独运行都正常,只有错误清理与文件轮转重叠时才形成等待环,因此日常轻负载测试不容易触发。
现场连续采集的线程栈显示:接收线程停在日志函数的锁等待处,轮转线程停在读取连接统计的锁等待处。代码审阅确认前者已持有连接锁,后者已持有日志锁。还需要排除“只是短暂等待”:间隔采样发现相同调用位置保持不变,完成计数也不增长。线程栈、资源持有关系和时间上的停滞共同支撑死锁判断。

修复首先改变职责边界:接收线程在连接锁内只更新连接表、保存需要输出的错误信息,解锁后再记录日志。轮转线程在获取日志锁之前读取所需统计快照,允许该数字略有滞后。这样 2 条路径都无需同时持有这 2 把锁。若某处确实必须嵌套,则统一规定连接锁先于日志锁,并检查所有调用者。
验证要覆盖原来的触发条件。测试环境可以用屏障等受控同步,让错误清理与轮转在指定位置相遇,确认修复后的操作都能结束;不要仅靠休眠碰运气。随后重复连接断开、文件轮转和关闭流程,观察完成计数持续增长,确认日志仍完整、没有丢失清理。竞争检测器帮助找数据竞争,但不负责证明所有死锁都不存在。
事故记录应留下锁顺序图、2 条调用路径、现场栈摘要和修复后的不变量。“偶发卡死,重启恢复”无法指导下一次排查;“连接清理持连接锁进入日志模块,轮转持日志锁读取连接表”则能让评审者发现同类问题。这里的关键收益来自可复述的资源关系,而不是某个调试命令本身。
8 把并发约定写进代码规范
可维护的并发规范要让读代码的人知道责任边界。为共享结构写清楚保护它的锁、哪些字段只能在持锁时访问、哪些方法要求调用者已经持锁。名称可采用团队统一的后缀或注释,但约定必须有明确含义。若某字段采用原子访问,还应说明它是独立统计、停止信号还是数据发布标记,避免后续调用者推导出不存在的保证。
锁顺序应表达为允许的方向,例如 connections_mu → log_mu,并尽量限制嵌套深度。规范同时列出持锁期间禁止执行的操作:不可控的网络等待、长时间磁盘访问、调用未知回调,以及可能再次进入当前模块的用户代码。禁止项需要结合实际设计解释原因,否则开发者很容易在异常日志或析构路径中绕过约定。
对象生命周期也必须可审查。连接由哪个线程关闭,队列停止后是否继续接受提交,退出时先停止生产者还是先回收消费者,都应写清。一个实用的关闭流程是停止接收新任务,发布关闭状态并唤醒等待者,按约定处理已入队任务,等待线程退出,最后释放共享对象。具体顺序可以不同,但不能让等待者永远收不到通知,或让仍运行的线程访问已释放内存。
接口返回值应进入并发协议。获取失败时是否持有其他锁,发送部分成功后从哪里继续,等待超时后任务是否仍可能完成,都要有确定答案。把“失败就重试”写成默认规则,可能导致重复提交或无限重试。对于可重试操作,应说明重试上限、退避方式和如何判断前一次操作的结果,必要时选择明确返回失败。
评审和测试可以围绕不变量组织:相同资源是否总用同一把锁,读路径是否也被覆盖,锁外使用的对象是否仍有效,退出路径是否与正常路径兼容。结合竞争检测、受控交错测试和负载观测,比只要求“多跑几次”更能揭示问题。测试通过是当前覆盖范围内的证据,规范则帮助后续修改继续维持这些约定。
9 从同步走向内核机制(S26)
应用中的锁等待最终会与操作系统发生联系。线程可能在用户空间完成一次无竞争的获取,也可能需要等待并让出运行机会。我们现在只需理解:等待线程是否继续消耗 CPU,何时被唤醒,以及唤醒后能否立即运行,是不同问题。被通知不等于条件仍成立,被唤醒也不等于已经获得处理器和目标锁。
文件操作同样跨越边界。进程中的互斥量只约束约定范围内的线程,不能自动协调另一个独立进程;文件锁又有自己的对象和生命周期规则。网络发送涉及应用缓冲、接口返回与对端进度,应用应先建立所有权和状态机,再借助内核接口完成传输。把接口契约说清楚,才能避免用“内核会处理”代替并发设计。
进入 S26 时,可以带着 3 个具体问题继续学习:线程阻塞时系统保存和改变了什么状态;文件描述符如何关联到可被多个执行路径使用的对象;应用一次系统调用为什么可能等待。下一课从这些可观察现象走向 Linux 内核机制,不在这里提前展开调度算法、协议栈实现或驱动细节。
面对一个新的停滞问题,也可以先按本课的方法定位边界:任务是否完成,哪个资源被共享,谁在等待,持有者能否前进,交接事件是否真实发生。能把这些事实讲清楚之后,后续学习内核等待与唤醒才有落点;否则更多内部名词只会遮住最初那个尚未说明的共享约定。
放进方案里
假设要在 Linux 主机部署一个收集本地日志并向远端发送的守护进程,方案先明确共享资源:待发送队列、连接状态、输出文件和退出标记。为每个资源指定所有者。采集线程只提交完整记录,发送线程独占套接字,轮转模块负责文件切换;队列使用互斥量与条件变量协调。完成计数可以独立采用原子操作,但不得据此判断某条日志已经被对端处理。
容量设计写出队列上限与拥塞策略。远端持续变慢时,选择暂停采集、落盘或按规则丢弃,并记录相应指标;不能让内存无限增长。临界区只修改队列和状态,网络发送、文件写入以及可能阻塞的日志输出放在明确的执行边界上。取出记录后,其所有权转给发送线程,直到成功处理或进入失败处置流程才释放。
关闭流程也要成为方案正文:停止新提交,设置关闭状态并通知等待者,按规定排空或保存剩余记录,等待工作线程退出,再关闭描述符和释放队列。规定锁顺序,禁止持连接锁进入日志模块;超时必须说明剩余任务归属,不能把超时等同于任务已取消。文件发布若依赖重命名,应单独说明可见性和崩溃恢复要求。
验收同时观察正确性与进展。测试并发提交、断连重试、轮转和关闭重叠,检查记录归属、完成计数及内存边界,并在支持环境运行竞争检测。上线保留队列长度、失败数、完成速率和等待时间,准备线程栈采集流程。若速率停住而进程仍存活,先还原等待链,再决定恢复动作。这样方案中的每个同步选择都有对应不变量、失败策略和可观察证据。
10 小结:下一步 Linux 内核机制(S26)
同步把共享资源的访问、状态交接和生命周期变成明确约定。锁适合维护整体不变量,原子适合语义清楚的状态操作,无锁结构需要额外的进展与回收证明。正确结果、持续前进和可接受的等待时间,需要分别观察。
可以用 3 个问题自测:为什么 count++ 需要同步?为什么条件变量返回后还要检查条件?为什么 2 个原子字段不能自动组成一个整体事务?若能结合线程交错、状态交接和不变量解释,就可以继续进入 S26,学习 Linux 如何支撑这些等待、唤醒与资源访问。