想象一家咖啡店在午高峰只剩 5 份燕麦奶。收银员看到库存是 5,卖出一份,准备把库存改成 4;几乎同一时刻,后厨补进一份,也看到库存是 5,准备把库存改成 6。两个人都没有算错,可如果后厨先写入 6,收银员随后又写入 4,系统最终会显示 4。真实库存明明仍是 5,一次并发就把数据弄丢了。
这类错误最难查的地方,是它不一定每次出现。只要两次操作的先后顺序稍微变化,结果就可能恢复正常。测试时跑一百次都没事,上线后压力一大却突然出错,开发者看到的往往只是已经损坏的结果,看不到当时那几条机器指令怎样交错。
进程同步要解决的正是这个矛盾:我们希望许多执行流同时推进,充分利用多核和 I/O 等待时间;但只要它们共享数据,就必须在某些位置建立秩序。同步不是简单地“让程序排队”,而是明确三件事:哪些状态属于共享,哪一段操作不能被拆开看,等待者什么时候才有资格继续。

并发关注的是多个任务在一段时间内都有进展,并行关注的是多个任务在同一时刻由不同处理器执行。单核上的线程切换也会产生竞态,多核只会让可能的交错更多。判断是否需要同步,关键不是“有没有多核”,而是“共享状态能否被多个执行流同时访问,并且其中至少一个访问会修改它”。
高级语言里的 count++ 看起来只有一行,处理器执行时通常要经历“从内存或缓存读取、在寄存器中计算、把结果写回”几个动作。两个线程都可能在写回之前读到同一个旧值,于是各自基于旧值计算,后写入的人会覆盖先写入的结果。这叫丢失更新,是竞态条件最直观的一种表现。
假设共享计数器初值为 5,线程 P 执行加一,线程 C 执行减一。下面这组交错完全可能发生:
从业务角度看,一次增加和一次减少应该互相抵消,最终仍为 5。程序却得到 4,原因不是算术错误,而是“读取—修改—写回”这个整体被拆开了。
只要程序结果取决于不可控的执行时序,就出现了竞态条件。常见情形包括:
这里需要分清两个概念。数据竞争描述的是缺少同步的冲突内存访问;竞态条件的范围更广,即使每次内存读写都是原子的,先检查再执行的逻辑仍可能因为时序而出错。比如“如果余额足够就扣款”包含一次检查和一次修改,把这两步分别做成原子操作仍不够,整个判断与扣款必须处在同一个受保护的事务里。
只要一段代码会读取或修改共享状态,而且必须把其中几步作为一个整体观察,这段代码就是临界区。一个反复工作的线程可以抽象成四部分:
for (;;) {
entry_section(); /* 申请进入 */
critical_section(); /* 访问共享状态 */
exit_section(); /* 释放进入资格 */
remainder_section(); /* 处理线程自己的工作 */
}进入区负责争取权限,退出区负责交还权限,剩余区不碰受保护的共享状态。边界画得太小,相关的不变量可能在锁外被破坏;边界画得太大,其他线程会为无关工作一起等待。
以前面的扣库存为例,真正需要保护的不是“把一个整数写入内存”这一瞬间,而是读取库存、判断是否足够、计算新库存、写回这整组动作。如果订单落库也必须与库存扣减保持一致,它也应纳入同一个更高层的原子事务,或者通过可恢复的事务协议协调。

任一时刻,最多只有一个参与者执行同一组受保护的临界区。这保证安全性:不该发生的状态不会发生。互斥并不要求整个程序只能运行一个线程,临界区之外仍然可以并发。
如果临界区空闲,且有人正在申请进入,系统不能让与这次竞争无关的线程永久拖延决定。它保证系统不会明明有工作可做却一直停住。
一个线程发出进入请求后,其他线程抢先进入的次数应该有上界。它约束的是公平性,避免某个参与者一直被插队。
这三项不能随意互相替代。只允许编号最小的线程进入,互斥成立,却可能让其他线程永远等不到;让所有线程都在某个标志为假时进入,可能有进展,却破坏互斥。真实同步原语还要面对线程取消、超时、优先级、进程崩溃等工程条件,因此“能挡住第二个人”只是起点。
“进展”不等于“绝不会死锁”,“有限等待”也不等于严格先来先服务。具体原语能提供多强的公平保证,要看接口语义和调度器。没有明确承诺时,不要把唤醒顺序想当然地当成先进先出。
实践中更稳妥的思考方式,是先写出共享数据的不变量,再决定锁的范围。例如环形缓冲区满足:
这里的数组、读指针、写指针和计数器必须由同一套同步协议协调。只给计数器加锁,却让指针在锁外更新,仍然可能把缓冲区状态撕裂。所谓“同一把锁保护哪些数据”,本质上是在声明:检查和恢复这些不变量的代码必须串行观察。
对两个参与者,可以用两个意愿标志和一个让步变量构造经典的软件互斥思路。进程 i 先声明自己想进入,再把优先权让给对方 j;如果对方也想进入且当前轮到对方,自己就等待:
flag[i] = true;
turn = j;
while (flag[j] && turn == j) {
/* 忙等 */
}
critical_section();
flag[i] = false;它最有价值的地方不是让我们在项目里手写锁,而是展示了互斥算法的推理方式:意愿标志避免“没人知道我想进”,让步变量解决双方同时举手时谁先走。只要采用顺序一致的内存模型,可以证明两者不会同时进入,也不会在临界区空闲时都永远退让。
现代编译器和处理器会重排互不依赖的读写,普通共享变量也不自动带来跨核心可见性。直接照搬上面的普通变量代码,读到的顺序可能不再符合证明前提。因此生产代码应使用语言提供的原子类型和规定好的内存序,或者直接使用成熟的锁。算法证明总是依赖某个执行模型;模型一变,证明不能原封不动搬过来。
处理器通常提供能够以不可分割方式读改写某个内存位置的指令,常见抽象包括“测试并设置”和“比较并交换”。
比较并交换可以理解成带条件的提交:
bool compare_exchange(int *addr, int expected, int desired) {
atomically {
if (*addr == expected) {
*addr = desired;
return true;
}
return false;
}
}线程先观察锁值为 0,再尝试原子地改成 1。只有一个线程能成功,失败者重新观察或进入等待。关键在于“比较”和“写入”由硬件作为一个不可分割的整体完成,否则两个线程仍可能同时看到 0。
测试并设置也能做出简单自旋锁:
while (test_and_set(&locked)) {
/* 仍被占用,继续尝试 */
}
critical_section();
clear(&locked);这种写法解决了互斥,却没有自动解决公平和等待成本。如果锁持有时间较长,失败线程会不断占用执行单元、读取缓存行,还会让缓存行在多个核心之间来回转移。原子指令是搭积木,不是完整的调度策略。
原子性回答“这个更新会不会被拆开”,内存有序性回答“别的核心会按什么顺序看到相关更新”。假设线程 A 先初始化对象内容,再把 ready 设为真;线程 B 看到 ready 后读取对象。若没有合适的发布与获取关系,编译器或处理器可能让 B 看到标志已更新,却仍读到对象的旧内容。
内存屏障会约束屏障两侧操作对其他观察者呈现的先后关系。获取锁和释放锁通常自带相应的内存同步语义,因此锁保护的不只是“门口一次只能过一个”,还建立了前一个持锁者写入与后一个持锁者读取之间的可见关系。
屏障不是越多越好。过强的顺序限制会压缩处理器重排和流水线优化空间,还可能造成额外通信。正确做法是先确定需要建立的先行关系,再选择足够而不过度的内存序。业务代码一般优先使用锁和标准并发库,只有在确实需要无锁结构时才直接操纵细粒度内存序。

“缓存一致”不等于“执行顺序自动正确”。一致性协议让各核心最终对同一缓存行达成一致,内存模型则规定一个核心的多个读写可以怎样被其他核心观察。同步原语需要同时用到原子操作和有序性保证。
互斥锁最适合表达“这段共享状态一次只能由一个线程修改”。成功加锁的线程成为所有者,通常必须由同一个线程解锁。竞争不激烈时,加锁可能只需要一次用户态原子操作;竞争出现后,等待线程可以被挂起,把 CPU 让给其他工作。
pthread_mutex_lock(&mutex);
/* 检查并更新由 mutex 保护的共享状态 */
update_shared_state();
pthread_mutex_unlock(&mutex);互斥锁的优点是语义直接,容易围绕不变量推理。代价包括竞争时的阻塞与唤醒、可能的上下文切换,以及临界区过大造成的并行度下降。持锁期间不要做不可控的长耗时工作,例如等待网络响应;否则所有需要同一把锁的线程都会被外部延迟绑在一起。
自旋锁拿不到时不休眠,而是在 CPU 上反复检查。它省去了阻塞和唤醒,但持续消耗处理器时间,因此适合非常短的临界区,或者中断等不能睡眠的内核上下文。
在普通任务上下文里,如果预计等待会超过一次调度和唤醒的成本,睡眠锁通常更合适。持有自旋锁时也不能调用可能阻塞的操作,否则持锁者睡下去,其他核心会白白自旋,某些上下文组合甚至会死锁。
内核里还要区分“防同一 CPU 上的中断打断”和“防其他 CPU 并发访问”。只关闭本地中断不能保护跨 CPU 共享数据;只使用普通自旋锁,又可能被本地中断处理程序抢占后重复申请同一把锁。选择原语必须把进程上下文、中断上下文、软中断和抢占状态一起纳入分析。
信号量保存一个非负计数。等待操作在计数大于 0 时取走一个许可并继续,否则阻塞;释放操作归还一个许可,并可能唤醒等待者。
wait(semaphore); /* 申请一个许可 */
use_resource();
post(semaphore); /* 归还一个许可 */计数初值为 1 时,它表面上像二元锁,但语义不同。互斥锁强调所有权,谁加锁谁解锁;信号量强调许可数量,一个执行流可以发出许可,另一个执行流消费许可。连接池有 20 条连接,就可以用初值为 20 的信号量限制同时进入者。
信号量还可表达事件数量。例如生产者每放入一项就增加“已有数据”信号量,消费者每取走一项就减少它。这里释放者和等待者本来就不是同一个线程,使用互斥锁替代反而会扭曲含义。
互斥锁保证检查共享状态时不被打断,条件变量让线程在条件不成立时睡眠。等待操作必须把“释放互斥锁并进入等待”做成一个不可分割的衔接,否则可能出现这样的丢失唤醒:
标准条件等待会原子地释放相关互斥锁并休眠,被唤醒后再重新获取锁。正确模板必须用 while 循环重查谓词:
pthread_mutex_lock(&mutex);
while (queue_is_empty()) {
pthread_cond_wait(¬_empty, &mutex);
}
item = queue_remove();
pthread_mutex_unlock(&mutex);用 while 而不是 if 有两个原因。第一,唤醒不保证条件此刻仍然成立,别的线程可能先拿到锁并消耗了资源;第二,实现可能出现额外唤醒。通知的含义只是“状态可能变了,请重新检查”,不是把资源直接交给某个等待者。
管程是一种更高层的组织方式:共享数据只能通过管程提供的过程访问,同一时刻只有一个线程在管程内主动执行;条件变量负责让暂时无法继续的线程等待。许多语言的同步对象和并发容器都带有这种思想。
管程最实际的价值是缩小协议的散布范围。调用者不需要记住“先拿 A 锁,再改 B 字段,最后通知 C 条件”,这些规则由对象内部统一维护。它仍然可能写出死锁或错误谓词,但至少让锁和不变量靠得更近。
不同管程对“发出通知后谁继续运行”有不同语义。一种做法是立即把管程交给刚被唤醒的线程,通知者暂时让开;另一种更常见的做法,是只把等待者变成可运行状态,通知者继续执行到离开管程,等待者以后重新竞争锁。后一种语义下,从通知发生到等待者真正拿回锁之间,共享状态还可能再次变化,这也解释了为什么条件等待必须循环检查谓词,而不能把一次通知理解成资源已经预留给自己。

生产者把数据放进固定容量的环形缓冲区,消费者从中取走数据。这里同时存在两类约束:
可以用三个信号量表达:empty 初值为容量,表示空槽数;full 初值为 0,表示可取数据数;mutex 初值为 1,保护缓冲区内部结构。
/* 生产者 */
for (;;) {
item = produce();
wait(empty);
wait(mutex);
insert(item);
post(mutex);
post(full);
}/* 消费者 */
for (;;) {
wait(full);
wait(mutex);
item = remove();
post(mutex);
post(empty);
consume(item);
}顺序不能随便换。生产者若先拿 mutex,再等 empty,缓冲区满时它会拿着互斥许可睡眠;消费者需要同一个 mutex 才能取走数据并制造空槽,于是双方卡住。一般原则是先等待“资源存在”,再进入保护资源内部结构的临界区。

许多共享结构的读取远多于修改,例如配置表和路由表。读写锁允许多个读者同时进入,但写者需要独占。它比普通互斥锁多保留了一部分并行度,却引入了新的选择:
读写锁并不保证一定比互斥锁快。临界区很短、写入频繁或核心竞争激烈时,维护读者状态和缓存一致性的成本可能抵消并行收益。选择前应测量真实读写比例和临界区长度。
五个参与者围成一圈,每人需要左右两件资源才能继续。如果所有人都先拿左边,再等待右边,系统可能形成一个等待环:每个人占有一件资源,同时等待下一个人手里的资源。
几种常见破环办法各自体现一种工程思路:
全局顺序最容易证明不会形成环,但要求整个系统都遵守同一规则。统一仲裁便于控制公平,却增加中心瓶颈。失败回退避免永久占有,却可能让多个线程一起反复退让,形成活锁。
死锁通常需要四个条件同时存在:
破坏任意一个条件都能消除这一类死锁,但代价不同。把资源全部改成可共享通常做不到;一次性申请全部资源会降低利用率;强行回收需要状态可恢复;统一加锁顺序最常用,却需要跨模块协作。
死锁处理有四种路线。预防是在设计阶段破坏必要条件;避免是在每次分配前判断是否仍处于可安全完成的状态;检测允许死锁发生,再扫描等待图;恢复则终止任务、回滚或抢占资源。通用操作系统很少能理解所有应用资源的恢复语义,所以应用层仍要自己建立顺序、超时和补偿。
饥饿时,系统整体仍在推进,但某个线程长期得不到资源。严格偏向读者的读写锁可能让写者饥饿,不公平的锁也可能让刚被唤醒的线程反复输给新到达者。
活锁时,参与者没有睡死,反而一直在积极响应对方。例如两个人在走廊相遇,同时向左让,又同时向右让,动作很多却谁都过不去。并发算法里常用随机退避、指数退避或仲裁者打破同步节奏。
高优先级线程 H 需要低优先级线程 L 持有的锁,只能等待。此时中优先级线程 M 不需要该锁,却不断抢占 L。表面上是 M 在压制 L,实际结果是 H 被 M 间接拖住,优先级关系反了过来。
优先级继承让 L 在持锁期间临时继承 H 的优先级,尽快完成临界区并释放锁,随后恢复原优先级。它缩短的是由锁依赖造成的无界拖延,不会修复过大的临界区,也不能让所有外部 I/O 突然变快。实时系统仍需要控制锁嵌套和最坏持锁时间。

非抢占式内核中,一个任务进入内核后,通常会运行到退出内核、主动阻塞或显式让出处理器,才切换到另一个任务。这样减少了同一 CPU 上内核路径被任务切换打断的可能,实现较简单,但长系统调用会拖高响应延迟。
抢占式内核允许更高优先级任务在合适的内核位置抢占当前任务,交互和实时响应更好。代价是内核共享结构可能在更多位置被并发访问。即便暂时关闭本 CPU 的抢占,多核机器上的另一个 CPU 仍能同时进入内核,所以还需要跨 CPU 的锁或其他同步机制。
中断又多出一层关系。普通内核代码可能持锁时被本地中断打断,而中断处理程序恰好也申请同一把锁。中断处理程序不能等待那个被自己打断的任务继续运行,结果会原地自旋。因此内核锁接口常带有禁用本地中断、保存并恢复中断状态等变体。这里保护的不是“代码看起来短”,而是所有可能到达共享数据的执行上下文。
如果等待者可以被调度出去,互斥锁让其睡眠通常能节省 CPU。如果当前上下文不能睡眠,就只能使用自旋锁、原子操作或重新设计数据路径。
选择时可以沿着三个问题判断:
任何一个问题答不清,都不应先凭习惯放一把锁。同步方案必须覆盖所有访问者;只在写路径加锁、读路径裸读,除非读路径使用了明确的无锁协议,否则保护仍不完整。
现代系统常把锁的无竞争路径留在用户态:线程用原子指令把锁从“空闲”改成“占用”,成功就直接进入,不必执行系统调用。只有发现竞争时,才请求内核把自己放入等待队列;解锁者发现存在等待者时,再进入内核唤醒。
Linux 的 futex 机制就是这种分工的基础之一。它本身不是完整互斥锁,而是提供“当某个用户地址仍等于期望值时睡眠”和“唤醒等待该地址的线程”等能力。线程库在上面组合出互斥锁、条件变量等更高层原语。无竞争时避开内核,竞争时又不必一直烧 CPU。
Windows 同样既有可跨进程等待的内核同步对象,也有面向单进程线程的轻量同步原语。平台接口名字不同,背后的取舍相近:所有权还是许可、用户态快路径还是内核阻塞、等待一个条件还是等待一个对象。
拿到一段并发代码时,可以按下面的顺序整理:
列出会被多个执行流接触的状态,包括对象字段、容器、文件偏移、设备寄存器和“是否已经初始化”这类标志。不要只盯着显眼的计数器。
为每组状态写出必须始终成立的不变量,例如“余额不能为负”“队列长度等于已占用槽位数”。不变量决定哪些操作需要被一起观察。
枚举所有访问上下文:普通线程、回调、信号处理、中断、其他进程。同步协议只有在所有参与者都遵守时才成立。
选择能表达约束的最低复杂度原语。保护单一不变量先考虑互斥锁;限制并发数量考虑信号量;等待状态变化使用条件变量;不能睡眠的极短内核路径才考虑自旋。
缩小临界区能提高并发度,但前提是不拆散不变量。常见的优化顺序是:
锁越细,潜在并行度越高,但锁数量、嵌套关系和验证成本也会上升。有时复制一份只读快照、通过消息队列转移所有权,或者让单线程串行处理某类状态,比继续拆锁更容易维护。
一个好同步方案通常能用一句话说清:哪把锁保护哪些不变量,谁可以持有它,等待什么条件,唤醒后为什么必须重查。如果这句话说不清,代码里往往还藏着未定义的边界。
读完这一章,你真正需要带走的不是某几个函数名,而是一套判断顺序:先找共享状态,再写不变量;先确定等待的含义,再选原语;先保证安全和活跃,再谈更细的并行度。同步的代价始终存在,但没有边界的并发,代价通常会以最难复现的方式回来。
统一多把锁的申请顺序,并明确超时、取消、异常退出时怎样释放或恢复。正常路径能跑通不代表失败路径安全。
用压力测试、线程检测工具和故意插入延迟扩大交错窗口。测试只能增加发现问题的概率,最终仍要靠同步协议和不变量证明正确性。