1. 并发程序的第一道坎:进程同步到底在解决什么
1.1 一个真实到不能再真实的竞态案例
先说个我早年调试时遇到的场景。当时在做一个多线程日志系统,多个工作线程同时往同一个日志缓冲区写数据,主线程负责把缓冲区刷到磁盘。代码逻辑看起来天衣无缝:先检查缓冲区剩余空间,不够就触发刷盘,够了就把数据拷进去。结果线上跑起来,日志经常出现半行数据、乱码串行,偶尔还会整块缺失。
问题出在哪?两个线程同时执行了"检查剩余空间"这个操作,都判断空间够,然后同时往同一个位置写数据,互相覆盖。这就是教科书上说的竞态条件(Race Condition)——多个进程或线程并发访问共享数据,最终结果取决于调度顺序,而调度顺序是不确定的。
这个案例暴露了一个操作系统最底层、也最绕不开的问题:当多个执行流需要同时访问共享资源时,怎么保证数据的一致性?进程同步这门学问,就是专门回答这个问题的。
1.2 临界区:保护共享资源的"单间"
理解了竞态,就自然引出**临界区(Critical Section)**的概念。每个进程里访问共享资源的那段代码,就是一个临界区。比如上面的日志写入逻辑,写缓冲区之前检查空间、写入数据、更新写指针,这段代码整体上应该是一个临界区。
判断一个同步方案是否合格,有四条经典的充分条件:
- 忙则等待:已经有进程在临界区里,其他试图进入的进程必须等待。
- 空闲让进:临界区空着的时候,必须允许一个申请进入的进程立即进入。
- 有限等待:等待的进程不能无限期等下去,必须在有限时间内进入。
- 让权等待:进程进不了临界区时,应该主动释放CPU(比如把自己阻塞),而不是死等。
第四条是现代操作系统特别看重的。早年很多硬件方案只做到前三条,导致CPU空转,这在单核时代还能忍,放到今天的多核高并发场景就是灾难。
2. 硬件同步机制:最接近机器的"硬核"解决思路
软件层面实现同步有个绕不开的痛点:检查"锁是否空闲"和"上锁"这两个动作之间,天然存在时间窗口,这个窗口就是竞态生长的土壤。要消灭窗口,只能靠硬件提供原子操作(Atomic Operation)——一条指令执行完,中间不可打断。
2.1 关中断:最朴素的互斥手段
思路简单到令人发指:既然进程切换是靠中断驱动的,那把中断关了,CPU就不会切走,临界区自然就安全了。
典型的实现思路是这样:
关中断(); 临界区代码... 开中断();但实际工程里,关中断的适用面非常窄。我总结了几个致命限制:
- 关中断的权力在内核态,用户态程序碰不到这个指令。
- 单核CPU上关中断确实能防止进程切换,但多核环境下,一个核关了中断,其他核照样可以访问共享内存。
- 长期关中断会导致系统响应迟缓,实时任务、外部IO中断全都进不来。
所以关中断只适合操作系统内核在最底层的极短操作里使用,比如更新进程链表、操作调度器内部状态。我在实际做驱动开发时,也只是在几微秒级的操作里用它。
2.2 Test-and-Set:一条指令搞定原子检查和置位
硬件同步的核心思路,是把"检查+修改"绑成一条原子指令。最经典的指令就是Test-and-Set(测试并设置),简写是TS指令。
它的逻辑是这样的:
boolean TestAndSet(boolean* lock) { boolean old = *lock; // 保存旧值 *lock = true; // 直接把锁置为true return old; // 返回旧值 }关键点在于,上面三行代码在CPU层面是一条指令完成的,执行期间不可中断。无论多少个核同时执行TS指令,硬件会保证内存总线上只有一个核在操作这个内存单元,其他核必须等这个操作完成。
用TS指令实现互斥锁,代码极其简洁:
// 所有进程共享这个锁变量 boolean lock = false; Process_i: while (TestAndSet(&lock)); // 循环测试,拿到锁就退出 // 进入临界区... 临界区代码... lock = false; // 释放锁我来拆解一下这个循环干了什么:
- 如果lock原来为false,TS指令返回false,同时把lock置为true,这个进程就进入临界区了。
- 如果lock原来已经为true,TS指令返回true,循环继续,进程原地自旋等待。
这个方案在单核、多核上都成立,因为TS是真正的原子操作。但它有一个肉眼可见的问题——忙等待(Busy Waiting / 自旋)。进程拿不到锁的时候,在while循环里空转,疯狂消耗CPU。临界区短的时候还好,临界区稍长一点,CPU资源就白烧了。这也是后来为什么会出现"自旋锁(Spinlock)"和"睡眠锁"的分野:自旋锁适合保护极短临界区,睡眠锁适合长临界区。
2.3 Swap指令:另一种实现原子交换的姿势
除了TS指令,还有一个经典硬件原语叫**Swap(交换)**指令,也叫XCHG。它做的事情是:原子地把一个寄存器里的值和内存里的值互换。
// 逻辑语义,实际上是单条原子指令 void Swap(boolean* a, boolean* b) { boolean temp = *a; *a = *b; *b = temp; }用它实现互斥的经典写法是这样:
// 所有进程共享 boolean lock = false; Process_i: boolean key = true; do { Swap(&lock, &key); // 原子交换 } while (key == true); // key变成true说明原来lock就是true,没拿到锁 // 进入临界区... 临界区代码... lock = false; // 释放锁这段逻辑第一次看有点绕,我当年也琢磨了好一阵。核心在于:Swap把lock的值和key的值互换。如果lock是false(锁空闲),Swap之后key变成false,key==false说明拿到锁了;如果lock是true(锁已被占用),Swap之后key还是true,继续自旋。整个判断动作被压缩在一条原子指令里,不存在"检查完了但还没加锁"的空档。
2.4 硬件机制的账本:得与失
把硬件方案放在一起盘点,优点和缺点都非常鲜明:
| 机制 | 原子性来源 | 优点 | 核心缺陷 |
|---|---|---|---|
| 关中断 | CPU中断屏蔽 | 实现极简,单核下可靠 | 多核无效,用户态不可用,影响系统响应 |
| Test-and-Set | 硬件原子指令 | 多核可用,自旋等待适合短临界区 | 忙等待浪费CPU,可能出现饥饿 |
| Swap | 硬件原子指令 | 同TS,实现同样简洁 | 忙等待,未解决互斥和同步的统一建模 |
硬件方案只是提供了"原子操作"这块砖,用这块砖能盖上锁,但盖不了整栋大楼。真正把同步推向工程化、体系化的,是信号量机制。它解决了三个硬件方案没解决的大事:让进程等待时主动让出CPU(让权等待)、把同步和互斥统一到一套原语里、允许用一个计数器管理多资源实例。
3. 信号量机制:Dijkstra给并发世界立下的规矩
信号量(Semaphore)的发明者是计算机科学巨擘Edsger Dijkstra,他当年在THE操作系统中首次提出了这个机制。今天我们用POSIX信号量、System V信号量,思想源头都是这一套。Dijkstra把信号量的两个操作命名为P操作(Proberen,荷兰语"测试")和V操作(Verhogen,荷兰语"增加"),后来在英语世界更常叫wait(等待)和signal(发出信号)。
3.1 整形信号量:先解决逻辑问题
最朴素的信号量就是一个非负整数S,它表示可用的资源数量。P操作和V操作的定义如下:
wait(S) { while (S <= 0); // 没资源就忙等 S = S - 1; // 占用一个资源 } signal(S) { S = S + 1; // 释放一个资源 }我自己的理解方式是:S类比停车场的空余车位。wait就是"找车位",没空位就原地等着,找到就占一个;signal就是"车开走",空出一个车位。这个类比贯穿整个信号量体系,后面所有变体都离不开这张基础图景。
但整形信号量有个一眼就能看出的问题:wait里用了忙等,和前面硬件方案的缺陷一模一样。更关键的是,wait操作本身里的"检查S<=0"和"S--"两个动作,在普通编程语言里不是原子的,所以整形信号量的实现必须靠底层的TS指令或关中断来保证原子性。它解决的是"逻辑建模"问题,还没解决"效率"问题。
3.2 记录型信号量:让等待真正"让权"
真正的工程级信号量,是记录型信号量(Record Semaphore),它把信号量升级成一个结构体,里面同时装着计数器和等待队列:
typedef struct { int value; // 资源计数 struct process_queue* list; // 等待该信号量的进程队列 } semaphore;对应的P/V操作:
void wait(semaphore* S) { S->value = S->value - 1; // 先减一 if (S->value < 0) { // 资源不够,把自己阻塞并挂到等待队列 block(S->list); // 让出CPU,进程进入等待态 } } void signal(semaphore* S) { S->value = S->value + 1; // 先加一 if (S->value <= 0) { // 还有进程在等,唤醒一个 wakeup(S->list); // 从等待队列移出一个进程,放入就绪队列 } }注意这里和整形信号量的细微差异:wait是先减一,减完发现小于0才阻塞;signal是先加一,发现还小于等于0才唤醒。这个设计的精妙之处在于:
- 当value的初始值为1时,value本身就是"还有几个进程能进入临界区"的余量。
- 当value为负时,value的绝对值正好等于当前排队等待的进程数量。
我一直觉得这是信号量设计里最漂亮的细节。举个例子:三个进程都在wait一个初始value=1的互斥信号量,第一个进程wait后value变0,进入临界区;第二、第三个进程依次wait,value先变-1再变-2,此时等待队列里有2个进程排队。进程代码里随便打一条调试日志,把value打出来,马上就能知道有多少进程堵在临界区门口。
更重要的是,被阻塞的进程通过block操作主动让出了CPU,这完美满足了前面说的第四条条件——让权等待。CPU不会被白烧,系统整体效率就上来了。
3.3 信号量的两副面孔:互斥锁和多资源计数
信号量按照value的取值,分成两种常见形态:
- 二值信号量(Binary Semaphore):value只能取0或1。本质上就是一个互斥锁(Mutex),用来保证临界区同时只有一个进程进入。它的语义和互斥锁基本一致,很多操作系统里Mutex就是二值信号量的特化版本。
- 计数信号量(Counting Semaphore):value初始化为某个正整数,表示有多个同类资源可用。比如系统有5台打印机,就设value=5,5个进程可以同时各占一台,第6个进程就得等。
这里有个很多人初学时会混淆的点:互斥和同步是两回事。互斥解决的是"多个进程不能同时进临界区",用的是P、V之间夹住一段临界区;同步解决的是"一个进程必须等另一个进程完成某个动作后才能继续",用一个初始值为0的信号量,让后执行的进程P,先执行的进程V,形成"先V后P"的配对关系。我后面在实战部分会具体演示怎么写。
3.4 信号量的扩展形态:AND型信号量和信号量集
记录型信号量已经能解决绝大多数场景,但碰到一次需要申请多个资源的情况就捉襟见肘了。典型场景:两个进程各自持有一个资源,然后都在等对方手里的资源——这就是死锁的温床。AND型信号量的思路是把P操作扩展成同时申请一批信号量:
wait_all(S1, S2, ..., Sn) { while (true) { 同时检查所有信号量都大于0; 若都满足,全部减1,返回; 否则当前进程阻塞在第一个不满足的信号量等待队列; } }更进一步的信号量集则允许一次申请多个同类型资源,并且设置不同的判断阈值。比如某进程需要一次占用3台打印机,且打印机总数不能少于5台才继续,就可以用信号量集来表达。这些扩展形态在原理层面理解即可,实际工程里更多的还是用基础信号量和条件变量配合。
4. 经典同步问题实战:从理论到代码
前面的内容偏原理,这一部分我们来动手。我把计算机操作系统课程里最经典的三个同步问题完整过一遍,每个都会给出可运行级的伪代码和详细解释。这些问题不是书斋里的空谈,它们每一种都对应着真实系统中的典型场景。
4.1 生产者-消费者问题:并发世界的"hello world"
场景:一个有限大小的缓冲区,生产者往里放数据,消费者从里面取数据。约束有两条:缓冲区满的时候生产者不能放,缓冲区空的时候消费者不能取。这是消息队列、IO缓冲、任务队列的基础模型。
先定义信号量和缓冲区:
int in = 0, out = 0; item buffer[N]; // N个槽位的环形缓冲区 semaphore mutex = 1; // 保护缓冲区的互斥信号量 semaphore empty = N; // 空槽位数量,初始全部为空 semaphore full = 0; // 有数据的槽位数量,初始为0生产者代码:
producer() { while (true) { 生产一个产品 item; wait(empty); // 申请一个空槽位 wait(mutex); // 进入临界区 buffer[in] = item; in = (in + 1) % N; signal(mutex); // 退出临界区 signal(full); // 数据槽位加1 } }消费者代码:
consumer() { while (true) { wait(full); // 申请一个数据槽位 wait(mutex); // 进入临界区 item = buffer[out]; out = (out + 1) % N; signal(mutex); // 退出临界区 signal(empty); // 空槽位加1 消费产品 item; } }这里有两个极其关键的细节,都是我踩过坑的地方:
第一,wait的顺序绝对不能乱。必须先wait(empty/full)再wait(mutex)。如果反过来,先拿mutex再等empty,缓冲区满的时候,生产者会握住mutex不放等待空位,而消费者想拿mutex进缓冲区取数据却被挡住,两边彻底死锁。
第二,信号量mutex、empty、full三者在语义上必须有分工。mutex管互斥(能否进临界区),empty和full管同步(缓冲区状态)。互斥信号量初始为1,同步信号量一个初始为N一个初始为0,任何两个互换都会导致逻辑错乱。
这里有一个很多教科书都不点破的规律:涉及同步的wait永远在外层,涉及互斥的wait永远在内层。我把这个规律记了十年,没过失手。这个顺序和临界区的关系也解释了为什么你要把"生产产品"放在P(empty)之前——生产动作本来就不占用缓冲区资源。
4.2 读者-写者问题:读写锁的祖师爷
场景:多个读者可以同时读共享数据,但写者必须独占,写的时候任何读者也不能读。这是数据库共享缓存、文件系统日志、配置中心读取等场景的原型。
一种经典的实现:
semaphore rw_mutex = 1; // 控制写者对共享数据的独占 semaphore count_mutex = 1; // 保护读者计数器 int reader_count = 0; // 当前读者数量 writer() { while (true) { wait(rw_mutex); // 请求独占访问 写共享数据... signal(rw_mutex); // 释放独占访问 } } reader() { while (true) { wait(count_mutex); // 保护读者计数 if (reader_count == 0) wait(rw_mutex); // 第一个读者要让写者不能进入 reader_count++; signal(count_mutex); // 释放计数保护 读共享数据... wait(count_mutex); reader_count--; if (reader_count == 0) signal(rw_mutex); // 最后一个读者离开,才允许写者进入 signal(count_mutex); } }这个方案的核心思路是:用reader_count记录读者的数量,只有第一个读者才去竞争rw_mutex,最后一个读者离开时才释放rw_mutex。中间的读者只维护计数器,不进rw_mutex,这样多个读者就可以同时读。
但这版实现有个著名的"缺点":写者可能饥饿。读者源源不断地进来,每次都有读者在读,写者就永远等不到rw_mutex。实际工程里,读写锁一般会加上"写者优先"的排队策略,在Linux内核的读写信号量(rw_semaphore)里就有对应的调度机制。你要是自己实现类似锁,一定要考虑公平性,否则线上会出现写延迟飙升的诡异现象。
4.3 哲学家进餐问题:死锁教学的经典模板
五个哲学家围坐圆桌,每个哲学家两件事交替做:思考和吃饭。桌子中央一盘意面,每人面前一只叉子,但吃面需要两支叉子。问题在于:如果每个哲学家都先拿左手边的叉子,再拿右手边的叉子,那么可能出现所有人都拿到左手叉子、都在等右手叉子的局面——死锁。
教科书给了好几种解法,我讲两个在工程上最有代表性的:
解法一:限制同时就餐人数。
semaphore chopsticks[5] = {1, 1, 1, 1, 1}; semaphore room = 4; // 同一时间最多4人拿起叉子 philosopher(int i) { while (true) { wait(room); // 申请进入"餐桌" wait(chopsticks[i]); // 拿左叉 wait(chopsticks[(i + 1) % 5]); // 拿右叉 吃饭; signal(chopsticks[(i + 1) % 5]); // 放右叉 signal(chopsticks[i]); // 放左叉 signal(room); // 退出"餐桌" 思考; } }这个解法精妙在:5个人最多只让4个人拿起叉子,无论怎么抢,总能保证至少有一个哲学家能同时拿到两支叉子。拿抽屉原理算一下:4个哲学家抢5支叉子,每人需要2支,最坏情况是4个人各拿1支,还剩1支,必然有人能凑齐。
解法二:奇数哲学家先拿左叉,偶数哲学家先拿右叉。
philosopher(int i) { while (true) { if (i % 2 == 0) { wait(chopsticks[(i + 1) % 5]); // 偶数先拿右 wait(chopsticks[i]); // 再拿左 } else { wait(chopsticks[i]); // 奇数先拿左 wait(chopsticks[(i + 1) % 5]); // 再拿右 } 吃饭; signal(chopsticks[(i + 1) % 5]); signal(chopsticks[i]); 思考; } }这个解法的底层的道理是:打破"所有进程都向同一个方向索取资源"的循环等待条件。根据死锁的四个必要条件,循环等待是其中之一,把这个环打破,死锁就起不来。
我个人的体会是,哲学家问题真正的工程价值不在于具体解法,而在于它教会你一种审视方式:凡是看到多个进程持有锁还想再要锁,第一反应就应该是检查死锁。这个习惯帮我排查过不少线上系统的"假死"故障。
5. 常见问题与排查教训实录
信号量相关的同步代码,bug率远高于普通业务代码,而且出错了还不容易复现——因为竞态问题往往要靠运气才能触发。我把自己这些年踩过的坑和看过的问题整理成清单,集中在下面几个章节里。
5.1 死锁的四个必要条件与破解之道
死锁要同时满足四个条件才会发生:
- 互斥:资源同一时刻只能被一个进程占用。
- 持有并等待:进程占着已有资源不松手,同时还在等别的资源。
- 不可剥夺:进程持有资源不能被系统强行抢走。
- 循环等待:进程之间形成一个资源等待环。
打破任何一个条件,死锁就解了。比如哲学家问题的解法一,限制就餐人数(本质是让资源总数大于最坏需求的消耗量),在资源分配上留出余量,是从"破坏循环等待"入手;让进程一次申请完所有资源,坏处是资源利用率极低;允许系统强抢部分资源(比如数据库的行锁升级机制),在工程上也常用。
排查死锁的手感,我自己总结了一套:先看日志里阻塞的调用栈,如果多个线程/进程的栈里都停在wait操作上,再把它们申请的资源编号画成等待图,画出来的环就是死锁所在。Linux的pstack、gdb的thread apply all bt、Java的jstack都是干这个的利器。
5.2 忘记signal:最隐蔽的致命失误
我见过太多案例,wait写对了,signal漏了,程序随机性地"卡住"。有一个特别容易翻车的地方:函数提前return。比如临界区里有个输入校验,校验失败直接return了,结果signal没执行,锁就永远不释放。这个问题的经典解法是RAII或者defer式的自动释放,把signal和资源生命周期绑定,函数怎么出去都会执行释放操作:
// C++风格的RAII思路 class ScopedLock { semaphore* sem; public: ScopedLock(semaphore* s) : sem(s) { wait(sem); } ~ScopedLock() { signal(sem); } };用这种封装之后,函数里随便return、抛异常,锁都会在析构时自动释放。好多老系统里神秘的偶发死锁,本质上都是某个异常路径把锁带走了。
还有一个signal顺序的错误值得单独拎出来说:在互斥锁内做耗时操作。我见过有同事把日志写入、网络请求这种毫秒级甚至秒级操作放在临界区里,结果整个系统吞吐量断崖式下跌。临界区只做必要的共享数据访问,能放出去的都放出去,这是并发性能的第一性原则。
5.3 优先级反转:一个真实世界的危险案例
**优先级反转(Priority Inversion)**是信号量使用中一个非常容易翻车的高级问题。场景是:低优先级进程持有一个信号量,高优先级进程在等这个信号量,中优先级进程(不依赖该信号量)一直抢占CPU,导致低优先级进程迟迟运行不完,高优先级进程也一直拿不到锁——高优先级被两个低优先级进程拖到天荒地老。
1997年火星探路者号在火星上遇到的重启问题,根源就是优先级反转。工程上的标准解法是优先级继承(Priority Inheritance):当高优先级进程等待一个被低优先级进程持有的锁时,把低优先级进程的优先级临时提升到高优先级进程的水平,让低优先级进程尽快跑完释放锁。Linux的rt_mutex、FreeRTOS的互斥量都内置了这个机制。
写业务代码的人可能觉得这离自己很远,但你在嵌入式系统、实时操作系统中用信号量做互斥时,如果不注意优先级继承,系统随时可能表现为"莫名其妙的周期性卡顿"。
5.4 信号量与自旋锁:怎么选才对
前面提到硬件同步机制里的TS指令和Swap指令通常用作自旋锁的基础,而记录型信号量会让进程睡眠。实际工程中选哪一个,取决于临界区长度:
- 临界区极短(几条指令、几十纳秒),用自旋锁,因为睡眠和唤醒的开销可能比自旋还大。Linux内核里大量使用自旋锁保护链表、哈希表操作。
- 临界区中等或长(涉及IO、数据复制、系统调用),用信号量或互斥锁,让出CPU给别的进程用,避免CPU空转。
我在用户态做多线程开发,标准姿势是:优先用pthread的mutex和cond变量,不要自己造轮子用裸信号量做互斥。因为pthread_mutex在glibc里已经针对不同的临界区长度做了futex优化,短临界区用自旋,长临界区进内核睡眠,比你自己实现的裸信号量高效得多。裸信号量常用的场景反而是跨进程同步,比如多个进程协同处理一批消息,配合shm共享内存使用。
5.5 信号量的"本色"使用:进程调度与生产者驱动
信号量的另一个独特用途是在进程间建立一对一的"驱动关系"。比如A进程生成数据,B进程消费数据,同时最多允许A领先B一个缓冲区长度。这种"领先量"的概念,用信号量的计数语义表达得特别自然。
我在做视频转码流水线时,用三个信号量精确控制"拉流-解码-编码"三个阶段之间的缓冲余量,每个阶段都严格遵循"先P同步、再P互斥、处理、V互斥、V同步"的骨架。这么做的好处是,不同阶段的运行速率可以各自波动,信号量的计数负责吸收瞬时抖动,整个流水线即使某个环节慢了一拍,也不会丢数据或者写坏共享内存。
6. 一点关于"效率"的额外思考
最后聊一个很多人学完信号量之后仍然困惑的点:既然信号量已经能解决所有同步问题,为什么现代系统还要搞出一堆别的工具?
答案在"效率"两个字。信号量本身是通用的,但通用性会带来开销和编码复杂性。比如你要表达"共享数据可读"这种状态,用信号量加读者计数的逻辑,代码量不小;而直接用一个读写锁API,一行就搞定。再比如信号量的计数语义在表述"条件成立才能继续"时很别扭,条件变量(Condition Variable)在语义表达上要直白得多。所以现代系统编程里,常用组合是:锁(互斥)+ 条件变量(等待条件)+ 信号量(资源计数),各司其职。
我个人做系统设计时的选择标准是这样的:需要排他访问共享数据,用互斥锁;需要等待某个条件成立(比如队列非空、任务完成),用条件变量;需要控制一份资源的多个实例或者做跨进程计数同步,用信号量。这个选择标准我用了很多年,踩过的坑也验证过它,分享出来供你参考。
进程同步从来不是一个"背概念"的知识点,它是一个牵动CPU指令、内核调度、用户态库、应用设计四个层面的系统工程。把这套机制想清楚了,你再去看ThreadSanitizer报出来的data race、看线上偶发卡顿、看分布式系统的锁服务,都会有豁然开朗的感觉。