☰
进程同步与并发控制:从竞争条件、信号量到管程的完整指南
2026/10/21 14:41:17 网站建设 项目流程

1. 从一段不安全的共享内存代码说起:竞争条件到底有多可怕

很多操作系统教材把进程同步放在第二章,紧跟在进程描述之后。这个位置安排其实很有深意——它避开了调度、内存管理这些“外围”内容,直接把最核心的问题甩在你脸上:多个进程同时在跑,它们之间怎么不出乱子?

我先讲一个自己早年调过的真实 bug。当时在做一个模拟多线程售票的系统,我写了一段极其简单的代码:

int tickets = 100; void sell() { if (tickets > 0) { // 模拟网络延迟 usleep(100); tickets--; printf("剩余票数: %d\n", tickets); } }

开了 10 个线程去抢票,结果跑出来剩余票数是 99、98、98、97、96、96……最后卖出了 102 张票。我当时第一反应是“tickets 是共享变量,tickets--不是一条指令,它会被拆成读、减、写三步”。两个线程同时读到 tickets=100,各自减一,再各自写回,结果还是 99 而不是 98——两张票卖给了同一个人。

这种问题就是操作系统第二章里反复强调的竞争条件。它指的是多个进程并发访问共享数据时,最终结果取决于进程执行的精确时序,而时序又是不可预测的。比赛进行到一半一个进程被时钟中断打断,或者 CPU 缓存还没落盘另一个核就开跑了,都可能出问题。

你以为这只是老掉牙的考试题?错了。今天写多线程 Redis 客户端、并发扣库存、或者日志系统写入同一个文件时,只要共享了状态,竞争条件就不可能回避。而且现代 CPU 的缓存一致性协议、指令重排还会放大这个问题,教科书上那句“检查后执行不是原子的”在今天比三十年前更值得重视。

面对竞争条件,绕开它的唯一途径就是互斥:在同一时刻,只允许一个进程进入临界区。可“只允许一个”这四个字,真要做起来坑多得数不清。接下来我从最底层的实现手段讲起,逐步推到信号量、经典问题、管程,最后落到现代并发环境下的排错经验,把这一章吃透。

2. 互斥的底层手段:从 Peterson 算法到硬件原子指令

2.1 用两个旗子和一个牌子解决互斥

先说一个纯软件方案,很多教材用它来引出“互斥到底是什么”这个概念。

假设两个进程 P0 和 P1 要进入各自的临界区,我们约定:

  • 每进程有一个 flag 数组,flag[i]=true 表示“我想进临界区”;
  • 共享变量 turn 表示“当前轮到谁”。

Peterson 算法的核心代码是这样的:

// 进程 i, 对侧进程 j = 1 - i flag[i] = true; // 1. 我声明要进入 turn = j; // 2. 我先把“优先权”让给对方 while (flag[j] && turn == j) ; // 3. 对方也想进且轮到他,我就等着 // 临界区 flag[i] = false; // 4. 我退出,清掉声明

很多初学者第一次看到这段代码会懵:为什么要先把自己的 turn 设置成 j?这不是把优先权让出去了吗?

关键就在这一步。最后执行turn = j的那个进程,一定会在 while 循环里被拦住;先执行turn = j的进程则可能因为对方还没把 flag[j] 设 true 而直接进入。所以无论两个进程怎么交错,总存在一个“先执行 turn 赋值”的进程不得不等待,而另一个进程能进入。这从逻辑上保证了互斥:不可能两个进程同时拿到 turn 的使用权。

这个算法看起来很巧妙,但它的局限也很明显:只能用于两个进程,一旦进程变多,flag 数组和 turn 变量就难以推广;同时它靠忙等(while 空转)来等,CPU 被白白烧掉;最后它假设了“写变量最终对另一个进程可见”的顺序一致性,而在弱内存模型下,这段代码可能失效。

2.2 硬件指令:让“检查+修改”变成原子操作

软件方案终究有天花板,所以现代操作系统更多依赖 CPU 提供的原子指令。最有名的两个是 TestAndSet 和 CompareAndSwap。

// TestAndSet 的语义:返回旧值,同时把值设为 true boolean TestAndSet(boolean *target) { boolean old = *target; *target = true; return old; } // 基于 TAS 实现自旋锁 void lock(boolean *mutex) { while (TestAndSet(mutex)) // 如果有人已持有锁,就一直自旋 ; // 不做任何事,只反复尝试 }

这段代码的价值在于 TestAndSet 这一行是由硬件保证原子性的——它在一条指令里完成“读旧值、写新值、返回旧值”,CPU 会在总线上锁缓存行,其他核心对这个变量的读写在指令完成前无法插队。

你可能会问:为什么不用disable_interrupt()来保护临界区?

答案是:关中断只能关住当前 CPU。多核时代,你关了本核心的中断,另一个核心照样在跑,共享数据照样被并发访问;而且关中断时间长了,网络包、时钟中断全部积压,系统实时性直接崩掉。所以关中断只在内核自身的极短临界区里使用,比如调度器的内部队列操作,并且必须保证临界区内不调用任何可能阻塞的函数。

用原子指令实现互斥还有个现实问题:自旋等待。进程拿不到锁就原地打转,单核 CPU 下这叫“优先级反转的温床”,多核下则白烧功耗。这也是操作系统课程要引入信号量的核心动机——把“等”这件事从 CPU 里挪走。

3. 信号量的本质:为什么它是进程同步的“万能钥匙”

3.1 从忙等进化到睡眠等待

信号量的设计思想其实很朴素:既然“反复检查条件”浪费 CPU,那就让等待者主动睡一觉,等条件满足了再由别人把它叫醒。

教科书里信号量是一个整数 S,配上两个原子操作:

// P(S) / wait(S): 如果 S > 0 则 S--,否则睡眠等待 // V(S) / signal(S): S++,如果有进程在等待,唤醒一个

我用一个电梯的类比来讲:信号量 S 相当于电梯轿厢剩余空间。程序员每次坐电梯前先执行 P:有空间就进,没空间就站在门口等;出电梯时执行 V:释放一个空间,如果门口有人在等,就放一个人进来。

正因为 P 和 V 都是原子操作——它们在执行时不会被其他进程打断——所以信号量内部的判读和修改不会出现竞争。这个“原子性”由操作系统内核的调度机制保证:P 操作会进入内核态,内核关闭本地中断或用自旋锁保护这个短暂过程,然后再决定是让进程继续还是把它加入等待队列。

3.2 有界缓冲的生产者与消费者:一个最简示范

有了信号量,我们就可以解决一个典型的同步问题——生产者和消费者共享一个容量为 N 的环形缓冲区。

semaphore mutex = 1; // 保护缓冲区的互斥锁 semaphore empty = N; // 缓冲区空位数 semaphore full = 0; // 缓冲区产品数 // 生产者 void producer() { while (1) { item = produce_item(); wait(empty); // 等一个空位 wait(mutex); // 进入临界区 insert_buffer(item); signal(mutex); signal(full); // 产品数加一,唤醒可能的消费者 } } // 消费者 void consumer() { while (1) { wait(full); // 等一个产品 wait(mutex); item = remove_buffer(); signal(mutex); signal(empty); // 释放一个空位 consume_item(item); } }

这个代码看起来简单,但有个特别容易被忽略的顺序问题:为什么必须先wait(empty)再wait(mutex),而不是反过来?

如果先锁 mutex 再等 empty,当缓冲区满时,生产者会拿着 mutex 去睡眠,消费者想取产品却进不了临界区,也就无法执行 signal(empty) 来唤醒它——死锁就出现了。所以正确顺序是:先把资源信号量的 P 放在互斥锁 P 之前,保证持有锁时绝不会因为等某个资源而阻塞。

反过来,V 的顺序就随意了,signal(full)和signal(mutex)顺序不影响正确性。至于为什么,你可以自己推一下:signal 只会放资源不会阻塞,所以无论先后,最终消费者和下一个生产者都能得到通知。

3.3 整型信号量与记录型信号量的区别

很多教材强调“整型信号量”是没有进程阻塞的,它就是上面自旋锁的翻版;而“记录型信号量”才是真正带有等待队列的版本。这个区分不是废话,它引出一个重要权衡:信号量的等待策略该选睡眠还是自旋?

  • 如果临界区极短(就几条指令),自旋等待的开销可能比进程切换小得多,多核环境下自旋锁甚至更快;
  • 如果临界区较长或在单核 CPU 上,自旋就是灾难,必须用睡眠等待。

Linux 内核里两种都用:spinlock 用在中断上下文、短临界区;mutex / semaphore 用在可睡眠的进程上下文。操作系统考卷上让你分析“忙等与睡眠等待的优缺点”,本质也就是在考察这个工程权衡。

信号量一旦掌握了,各种经典同步问题就有了统一武器。下面进入这一章最出名的四个场景,我把每个问题的思路和推导过程拆开讲。

4. 四个经典进程同步问题的完整推导

4.1 生产者与消费者:从单对单到多对多

前面已经给了单生产者单消费者的代码。真正需要动脑的是多生产者和多消费者共享同一个缓冲区,这时“互斥”和“同步”两个维度彻底分开:

  • 同步靠 empty 和 full 这对计数信号量,它约束的是“缓冲区不能超卖”;
  • 互斥靠 mutex,它约束的是“同一时刻只有一个人能碰缓冲区的数据结构”。

举个例子:两个生产者同时向缓冲区的同一个位置写数据,数据就穿帮了——所以操作链表、数组索引的代码必须用 mutex 包起来。

我见过一个很典型的错误:把wait(mutex)放到wait(empty)前面,上面已说过原因;另一个错误是把 full 和 empty 的意义搞反,消费者等 full 却 signal full,最后程序直接 sleep 死。建议你自己手画一张“缓冲区形态变化表”,在每个 wait 和 signal 前后标出 empty/full/mutex 三个变量的值,跑通一次循环,基本就不会再混了。

4.2 多生产者多消费者:苹果与橘子问题的信号量设计

如果把缓冲区再细分出一种场景,很多考试喜欢出“桌上盘子,爸爸放苹果,妈妈放橘子,儿子吃橘子,女儿吃苹果”这种题目。它的核心是:两个生产者在不同的产品类型上竞争,两个消费者分别对特定类型感兴趣。

信号量可以这样设计:

semaphore mutex = 1; // 盘子互斥 semaphore empty = 1; // 盘子最多一个 semaphore apple = 0; // 盘子里苹果的数量 semaphore orange = 0; // 盘子里橘子的数量 // 爸爸 wait(empty); wait(mutex); put_apple(); signal(mutex); signal(apple); // 妈妈 wait(empty); wait(mutex); put_orange(); signal(mutex); signal(orange); // 儿子(吃橘子) wait(orange); wait(mutex); get_orange(); signal(mutex); signal(empty); // 女儿(吃苹果) wait(apple); wait(mutex); get_apple(); signal(mutex); signal(empty);

你仔细看会发现,这里每个生产者实际上占用了两种资源:空位和盘子互斥权,消费者也一样。苹果和橘子各用一个独立的信号量管理,而不是混用一个“水果计数”,这样消费者就能精准等待自己需要的那一类,不会出现儿子满心欢喜醒过来发现盘子里是苹果的尴尬。

这个模式可以推广到任意“类别化共享资源”场景:一台打印机服务彩色和黑白两种任务,队列里彩色任务和黑白任务各排一队,信号量的管理模式与苹果橘子问题完全一致。

4.3 读者与写者:优先策略的隐藏代价

读者写者问题的原始场景是:多个读者可以同时读同一份数据,写者必须独占,且读者在读时写者不能写,写者在写时读者不能读。

经典的“读者优先”方案这样实现:

semaphore rw_mutex = 1; // 控制写者互斥和读与写互斥 semaphore mutex = 1; // 保护读者计数 int readers = 0; // 写者 wait(rw_mutex); write(); signal(rw_mutex); // 读者 wait(mutex); readers++; if (readers == 1) wait(rw_mutex); // 第一个读者进门,把写者挡在外面 signal(mutex); read(); wait(mutex); readers--; if (readers == 0) signal(rw_mutex); // 最后一个读者出门,放写者进来 signal(mutex);

这个方案有个隐藏问题容易被忽略:读者优先意味着只要有一个读者还在读,后续读者可以不断涌入,而写者可能无限期等待。看代码:readers++后即使写者已经在 rw_mutex 上等着,新读者照样能进入,因为它们不需要重新竞争 rw_mutex(只有第一个读者需要抢它)。所以在高读者流量下,写者可能饿死。

“写者优先”方案需要额外的队列控制,常用两个互斥锁嵌套来实现:一个锁保证新写的读者不能排队抢在等待的写者前面;另一个锁保护写者队列。真实数据库里的读写锁(rwlock)都支持“写者偏向”或“公平模式”,正是为了应对这种饥饿问题。

学生在考研里看到这题,往往只背代码不推过程。我的建议是拿一张时间线图,分别写“写者等待队列”和“读者计数器”的变化,模拟两组场景:三读者连续到达、读者与写者同时等待,画出每个进程的阻塞和唤醒时刻,这样优先策略的全部逻辑就一目了然了。

4.4 哲学家就餐:死锁的四个条件在这里完美现形

五个哲学家围圆桌吃饭,每人面前一盘面,桌上有五只筷子,每人需要两支才能吃饭。经典的直接实现是:

// 每个哲学家 wait(chopstick[i]); // 拿左手筷子 wait(chopstick[(i+1) % 5]); // 拿右手筷子 eat(); signal(chopstick[i]); signal(chopstick[(i+1) % 5]);

这段代码最大的问题是:如果五个哲学家同时拿到左手筷子,就都拿不到右手筷子了——谁也不会放下手里的,于是全体饿死。这就是教科书上“循环等待”条件的教科书样本:每个进程持有一个资源,又在等别人手里的资源,形成环路。

解决思路有几种:

  1. 限制最多四个人同时拿筷子:用信号量 room=4,保证至少一个人能拿到两支筷子。这直接破坏“大于一”的占有等待环形成条件。
  2. 规定拿筷顺序:比如奇数号哲学家先拿左手,偶数号先拿右手。这样环路上总有一个哲学家的下一个资源被错开,打破循环等待。
  3. 用互斥锁保护整个“拿两支筷子”的动作:同一个时刻只允许一个哲学家拿筷子,拿齐了再放其他人。虽然降低并发度,但绝不会死锁。

我还想提一份更实际的考卷解法:把哲学家问题转换成资源分配图,要求考生判断某个状态是否死锁。判断方法是找“进程不等待任何资源”的节点——它可以完成并释放资源,然后从图中删掉它。循环删除能清空所有节点就说明没有死锁;如果剩下一个环且环里的进程都在等资源,那就是死锁。

经典问题的意义不只是应付考试,它几乎覆盖了并发编程所有基本模式:资源计数、类别匹配、读共享写独占、死锁避免。从这四个问题抽象出来的信号量组合方式,放到今天的消息队列、数据库事务、分布式锁里依然适用。但信号量作为编程原语有个明显的痛点——你需要自己保证 P/V 配对,人的注意力稍一分散就出错。所以现代语言和操作系统普遍转向管程,也就是加锁 + 条件变量的模式。

5. 从信号量到管程:现代系统为何抛弃了裸 P/V

5.1 管程的三个组成部分

管程(Monitor)是一种高级同步结构,它把互斥、共享数据、条件等待封装在一起。Java 的 synchronized 其实就是管程思想的直接体现。一个管程包含:

  • 一个互斥锁,保证同一时刻只有一个进程在管程内部活动;
  • 一组共享数据,只允许通过管程内的函数操作;
  • 若干条件变量,每个条件变量带一个等待队列,进程可以在上面等待事件发生。

对应的两个操作是 wait 和 signal。wait 让当前进程在某个条件变量上睡眠并释放管程锁;signal 唤醒在条件变量上等待的一个进程。注意“释放锁”这一步至关重要——这正是把 sleep 和 mutex 人为组合时需要小心翼翼、而管程自动完成的关键。

5.2 signal 的语义陷阱:Hoare 与 Mesa

管程里 signal 的语义有两种流派,理解它们的差异能解释很多现代并发库的行为。

Hoare 风格的管程:signal 执行后,唤醒的进程立刻接管管程,发送 signal 的进程被迫挂起。这种语义逻辑清晰,但实现复杂,实际系统极少用。

Mesa 风格的管程(现代主流)则宽松得多:signal 只是把某个等待进程放到就绪队列,发送者继续执行,被唤醒的进程需要重新竞争管程锁。这带来一个后果:被唤醒的进程醒来后,它所等待的条件可能已经不成立了,所以必须用 while 而不是 if 来重新检查条件。

// 正确写法 synchronized (monitor) { while (queue.isEmpty()) { monitor.wait(); // 循环等待 } // 安全地取数据 } // 错误写法:用 if 判断一次就 sleep,醒来后可能直接越界取数据 if (queue.isEmpty()) { wait(); }

我之前在实际的并发队列代码里就栽过这个坑:用 if 等待队列非空,线程被唤醒时条件仍不成立(另一个线程抢先把数据取走了),结果直接取空队列抛了异常。改成 while 后彻底稳定。

现代语言几乎都提供了类似抽象的武器:Java 的 wait/notify、Python 的 Condition、Go 的 channel。它们背后都是“锁 + 条件变量”的管程思想。理解了管程,再看这些 API 就容易触类旁通。

5.3 从经典问题到管程重写

用管程重写生产者-消费者,代码里不再有分散的 P/V,而是把缓冲区的操作直接封装在 Monitor 类的方法里:

class BoundedBuffer { private Queue<Integer> queue; private int capacity; public synchronized void put(int item) throws InterruptedException { while (queue.size() == capacity) { wait(); // 缓冲区满,等待 } queue.add(item); notifyAll(); // 唤醒所有等待的消费者 } public synchronized int take() throws InterruptedException { while (queue.isEmpty()) { wait(); // 缓冲区空,等待 } int item = queue.poll(); notifyAll(); // 唤醒所有等待的生产者 return item; } }

这段代码看起来优雅,但有两个点要注意。第一,为什么用 notifyAll 而不是 notify?因为 notify 只唤醒一个线程,如果唤醒的是同类线程(比如生产者唤起另一个生产者),对方检查条件发现缓冲区仍满,只能继续睡,真正的消费者就没被叫到——active notification 失效风险。第二,为什么 while 不 if?前文已经解释,这是 Mesa 语义的必然要求。

从这层意义上看,经典进程同步问题的意义,就是训练你从信号量角度学会“把自己放在等待队列里想问题”,而这个思维方式在管程时代依旧是核心。

6. 同步代码在真实环境中的排错经验

最后分享一些课本上不怎么会写、但我在实际代码里反复踩过的坑。这些经验价值不亚于前面任何一段原理推导。

6.1 常见死锁的三种面孔

面孔一:锁顺序不一致。两个进程都持有一把锁想去拿另一把,但在各自的代码路径里拿锁顺序相反。进程 A 拿着 lock1 等 lock2,进程 B 拿着 lock2 等 lock1,谁也不松手。解决铁律:如果代码里同时需要多把锁,必须保证全局锁获取顺序一致,最好用分层思想组织。

面孔二:信号量初始化错误。mutex 初始化为 0,第一个进程直接 sleep,谁也醒不来;empty 初始为 0,生产者永远不会放数据进来。调试时先打印所有信号量的初始值,对照“初始状态单调性”检查。

面孔三:P/V 配对遗漏。某个异常分支直接 return,漏掉了 V 操作,信号量被缓慢地“压扁”,资源越来越少,最终系统在某个低流量时刻悄悄卡住。这类死锁最难查,因为崩溃点离错误点很远。

6.2 用工具固定死锁现场

下面是我排查死锁问题时的固定套路:

  1. 让程序挂起后,用jstack(Java)或gdb(C/C++)打印所有线程的调用栈,重点找同时出现在两帧中的锁 ID。
  2. 检查每个线程最后一步在等哪把锁、已经持有哪把锁,通常环就出来了。
  3. 如果怀疑信号量泄漏,在每个 P/V 操作处打日志,带上时间戳和线程 ID,统计一段时间内每个信号量的净值变化。
  4. 复现不了的问题,尝试降低并发度和增大临界区时长,提高交错的概率,让 bug 更容易现身。

一个很有效的调试工具是ThreadSanitizer(TSan),它在编译时插桩,运行时检测数据竞争,能直接报告“两个线程分别在哪里读了、写了冲突变量”。我现在遇到难搞的并发 bug 都会先开着 TSan 跑一遍,比自己肉眼盯着代码快得多。

6.3 写并发代码前问自己的四个问题

每次写完一段同步代码,我会按这个清单自查:

  • 每个共享变量都被某个锁保护了吗?有没有变量暴露在锁之外?
  • 拿多把锁的顺序全局一致吗?
  • 等待条件用的是 while 还是 if?如果场景是 Mesa 语义,必须 while;
  • 会不会出现某个进程拿到锁后等一个永远没人 signal 的条件?

有一次面试里,面试官问“你如何保证代码正确性”,我答完这个清单后他直接给了正向反馈。这套自查思路确实是在无数次调试、死锁、分析 dump 里磨出来的,比反复背 P/V 定义有用得多。

进程同步这一章,表面上是几个算法和伪代码,实际上它训练的是“在不可预测的交错中设计出可预测结果”的思维方式。我在实际写并发服务时,最深刻的体会是:代码里每一处对共享数据的访问都值得停下来多问一句,这个变量的同步边界在哪;回答不清楚,bug 一定在路上了。这个思维习惯,比整套操作系统考试题都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询