从硬件原子操作到信号量:彻底搞懂进程同步与并发控制

从硬件原子操作到信号量:彻底搞懂进程同步与并发控制 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原来为falseTS指令返回false同时把lock置为true这个进程就进入临界区了。如果lock原来已经为trueTS指令返回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变成falsekeyfalse说明拿到锁了如果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操作本身里的检查S0和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一个初始value1的互斥信号量第一个进程wait后value变0进入临界区第二、第三个进程依次waitvalue先变-1再变-2此时等待队列里有2个进程排队。进程代码里随便打一条调试日志把value打出来马上就能知道有多少进程堵在临界区门口。更重要的是被阻塞的进程通过block操作主动让出了CPU这完美满足了前面说的第四条条件——让权等待。CPU不会被白烧系统整体效率就上来了。3.3 信号量的两副面孔互斥锁和多资源计数信号量按照value的取值分成两种常见形态二值信号量Binary Semaphorevalue只能取0或1。本质上就是一个互斥锁Mutex用来保证临界区同时只有一个进程进入。它的语义和互斥锁基本一致很多操作系统里Mutex就是二值信号量的特化版本。计数信号量Counting Semaphorevalue初始化为某个正整数表示有多个同类资源可用。比如系统有5台打印机就设value55个进程可以同时各占一台第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、看线上偶发卡顿、看分布式系统的锁服务都会有豁然开朗的感觉。