如果你的工作里写过任何多线程程序,那你大概率遇到过这种情况:数据莫名其妙错乱、程序偶尔卡死、压测一上来日志就乱套。我在某公司做服务端开发的那几年,代码评审里最常见的争议,就是“这个共享变量加锁没有”“这两个锁的顺序对不对”。而所有这些问题,在操作系统课程里都有同一个名字——经典同步问题。
经典同步问题这四个字,几乎出现在每一本操作系统教材的进程管理章节里。我刚开始学的时候觉得它不过是几道需要背答案的题目,直到后来在真正的线上环境里翻了几次车,才发现这些问题就是并发编程的“沙盘推演”——生产者与消费者、读者与写者、哲学家进餐,每一个都能在真实项目里找到原形。
这篇文章我不打算照搬教材,而是用实际写代码和排查问题的方式,把这几个经典同步问题重讲一遍:它们到底在解决什么、每个答案背后的理由是什么、代码怎么写、工程上用什么样的替代方案。适合正在学操作系统的人、准备面试的开发者,以及那些写并发程序时总觉得不对又说不清哪里不对的人。
1. 先搞明白:同步问题到底在防什么
1.1 两个线程同时写一个变量,会发生什么
先看一个最基础的例子。两个线程各自执行count++一百次,理论上最后count应该是 200。但实际跑下来,结果可能是 199、198,甚至更少。
原因在于count++在机器指令层面不是一步完成的。它至少拆成三件事:把count从内存读到寄存器、在寄存器里加一、把新值写回内存。如果线程 A 刚读完count=10,还没写回,线程 B 也读了count=10,两个线程都加一,最终写回 11,但实际上应该加两次。这就是竞争条件,也叫竞态。
教科书里会用“丢失更新”这个词,我更喜欢叫它“覆盖现场”——后写的人把先写的人的成绩冲掉了。这个问题在单核时代还不算严重,因为单核下线程切换靠时钟中断,临界区很短时被打断的概率不高。但现在是多核时代,两个线程可以在两个物理核心上同时读同一个内存地址,概率事故就变成了常态事故。
同步机制存在的意义,就是让这类“同时读写同一份数据”的操作变成原子操作:要么不做,要么一次做完,中间不许别人插队。
1.2 临界区、互斥与同步:概念到底什么意思
课程里有一串术语:临界资源、临界区、互斥、同步。很多人被绕晕,其实用大白话串一遍就清楚了。
- 临界资源:同一时间只能让一个线程用的东西,比如打印机、数据库连接、一段共享内存。
- 临界区:访问临界资源的那段代码。比如“读余额 → 扣款 → 写回余额”这三行代码就是临界区。
- 互斥:多个线程不能同时进临界区,进了就只能一个一个来。
- 同步:多个线程之间要讲先后顺序。比如生产者没产东西,消费者就得等着;读者正在写数据,读者就不能读。
互斥解决的是“能不能同时进”,同步解决的是“谁该等谁”。这俩经常混在一起,但心里要分清楚:锁解决互斥,信号量/条件变量解决同步,现实中往往两样都要用。
我试过一个特别直观的比喻:互斥就是厕所的门锁,一个人进去了,别人只能排队;同步就是红绿灯,不是不让你过,而是告诉你“现在该你过还是该你等”。操作系统里的同步工具,就是在门锁基础上加了一套红绿灯系统。
1.3 从锁到信号量:为什么需要这么多工具
初学的时候容易问:既然锁能互斥,为什么还要发明信号量?
因为锁只能解决“同不同时”的问题,解决不了“你先我后”的问题。设想一个场景:缓冲区满了,消费者还没取走数据,生产者再想往里放。这时候你给生产者加个锁,它确实进不去,但等它抢到锁的时候,缓冲区可能还是满的——它没有被告知“缓冲区有空位了”。它只能反复抢锁、检查、放弃,效率极差。
信号量本质是一个带等待队列的计数器。它做的事很简单:P操作(也叫wait)把计数器减一,如果减完是负数就挂起等待;V操作(也叫signal)把计数器加一,如果加完不大于零就唤醒一个等待者。这个“等待队列 + 计数器”的组合,天然就支持线程之间传递“资源有没有、够不够”的信息。
后来工程里更常用的是互斥锁加条件变量,但不管工具怎么变,底层思想都是同一套:共享状态要保护区,等待要排队,通知要轻量。把信号量吃透了,后面看条件变量、读写锁、无锁队列,都会觉得是在同一个思想上做优化。
2. 四大经典同步问题:问题背景、解法思路与代码落地
2.1 生产者-消费者问题:有界缓冲区的经典考验
这个问题描述很简单:一个生产者线程负责生产数据,一个消费者线程负责消费数据,两者通过一个固定大小的缓冲区传递数据。生产者的规则是“没空位就等”,消费者的规则是“没数据就等”。
难点有两层。
第一层是互斥:缓冲区是共享的,生产者和消费者同时操作缓冲区的指针和位置信息,必须互斥。
第二层是同步:生产者只有在缓冲区有空位时才能放,消费者只有在缓冲区有数据时才能取。这两条规则必须通过信号量来传递状态。
教科书解法是用三个信号量:mutex保护缓冲区访问,empty记录空位数,full记录数据数。初始值分别是 1、N、0。
semaphore mutex = 1; semaphore empty = N; semaphore full = 0; void producer() { while (1) { item = produce_item(); P(empty); // 申请一个空位 P(mutex); // 进入临界区 buffer[in] = item; in = (in + 1) % N; V(mutex); // 离开临界区 V(full); // 增加一个数据 } } void consumer() { while (1) { P(full); // 申请一个数据 P(mutex); // 进入临界区 item = buffer[out]; out = (out + 1) % N; V(mutex); // 离开临界区 V(empty); // 增加一个空位 consume_item(item); } }这里面最容易踩坑的地方在信号量的顺序。为什么必须先P(empty)再P(mutex),反过来行不行?
实测下来,反过来的后果非常严重:如果先P(mutex)再P(empty),当缓冲区满时生产者拿着mutex等在empty上,消费者想取数据却进不了临界区,因为mutex被生产者占着。双方就这样互相等对方——生产者等空位,消费者等锁,死锁当场形成。
我后来总结了一个记忆方式:申请资源的信号量要放在申请锁之前。因为锁是“进入临界区的门票”,而资源信号量是“我进去能不能干活的前提”。千万不要拿着门票在门口排队,把想干活的人都挡在外面。
2.2 读者-写者问题:公平性博弈
这个问题的场景是:多个读者可以同时读同一份数据,但只要有一个写者在写,读者和其他写者都必须等待。典型应用是配置文件的热更新、缓存重建、共享内存数据读取。
难点在于“读者之间可以并行,但写者必须独占”。如果简单粗暴地给所有读操作都加互斥锁,性能会很差;如果完全不加控制,读者会读到写了一半的脏数据。
教科书解法分两种策略。
读者优先的策略用两个信号量和一个计数器:read_count记录当前读者的数量,rw_mutex保护这个计数器,data_mutex保护共享数据。
semaphore rw_mutex = 1; semaphore data_mutex = 1; int read_count = 0; void reader() { while (1) { P(rw_mutex); read_count++; if (read_count == 1) P(data_mutex); // 第一个读者锁住数据 V(rw_mutex); // 读数据... P(rw_mutex); read_count--; if (read_count == 0) V(data_mutex); // 最后一个读者释放数据 V(rw_mutex); } } void writer() { while (1) { P(data_mutex); // 写数据... V(data_mutex); } }这个方案逻辑清楚,但有个隐蔽的问题:如果读者络绎不绝,写者可能永远等不到锁。这叫写者饥饿。反过来,如果写者频繁到达,读者也会被阻塞,形成读者饥饿。真正公平的写法需要再加一个“排队信号量”,让后到的读者排在写者后面,避免读者反复插队。
这个问题的价值不在代码本身,而在于它逼你思考“公平性”这个工程指标。我在做读写锁选型时发现,很多语言标准库里的读写锁默认偏向写者,就是为了避免写者饿死。你在设计并发策略时,一定要先想清楚:这个场景是读多写少,还是写多读少,谁能接受等待,谁不能接受等待。
2.3 哲学家进餐问题:死锁与资源分配
这是所有同步问题里最好记的一个:五个哲学家围一张圆桌,每两个人之间放一根筷子,哲学家只有同时拿到左右两根筷子才能吃饭。问题是,怎么设计拿筷子的逻辑,才能让大家都能吃上饭,而不是集体饿死。
最容易想到的办法是:每个人都先拿左边的筷子,再拿右边的筷子。但这里藏着一个教科书级的死锁:如果五个人同时拿了左边的筷子,那么所有人都在等右边的筷子,而右手边的筷子又被右边那个人拿着。所有人都等别人放下筷子,谁也吃不上饭。
解决思路有几条路,我挨个说。
第一条:限制同时拿筷子的哲学家数量,最多四个人同时抢筷子。这样至少会有一个人能拿到两根筷子,打破“所有人互等”的僵局。
第二条:改变拿筷子的顺序,让奇偶编号的人拿筷子的方向相反。比如奇数编号先拿左再拿右,偶数编号先拿右再拿左。这样就不会形成“每个人都在等右边人手里的筷子”的环形等待。
void philosopher(int i) { while (1) { think(); if (i % 2 == 1) { P(chopstick[i]); P(chopstick[(i + 1) % 5]); } else { P(chopstick[(i + 1) % 5]); P(chopstick[i]); } eat(); V(chopstick[i]); V(chopstick[(i + 1) % 5]); } }第三条:把拿筷子整体变成一个原子操作,要么一次拿到两根,要么一根都不拿。这在工程上对应“一次性申请多个资源”的能力,很多操作系统和并发库都提供了类似的“多锁原子获取”接口。
我见过不少人在真实项目里把锁嵌套穿成环,最后线上死锁。哲学家进餐问题的核心教训是:多个资源需要同时持有的时候,一定要考虑获取顺序的一致性。要么所有人按同一顺序拿锁,要么就设置超时和回退机制,别让线程拿一把锁等另一把锁等到天荒地老。
2.4 吸烟者问题:被低估的信号量进阶练习
吸烟者问题在经典同步问题里地位特殊,因为它考察的不是简单的资源计数,而是“三种资源两两组合”的调度逻辑。
场景是这样:三个吸烟者,一个人有烟草,一个人有纸,一个人有火柴。桌子上放着三种原料,代理商每次随机放两种原料到桌上。拥有第三种原料的那个人,就可以取走这两种原料卷一支烟,抽完后再通知代理商放下一批。
这个问题的巧妙之处在于:代理商和三个吸烟者之间不是一对一的同步,而是一对多的“条件唤醒”。代理商放下的资源组合,决定了该唤醒哪个人。
解法通常用三个信号量分别表示三种资源是否齐备:tobacco、paper、match,再加一个总信号量表示“抽烟完成”。代理商随机选择一组资源,V 对应的两个信号量;吸烟者 P 自己缺的那个信号量和总信号量。这里还要小心并发唤醒的顺序,防止两个吸烟者同时被唤醒同时抢资源。
我实际写这个题目的时候最大的体会是:这种问题没有固定的模板,必须把“谁在等什么资源”的依赖图画清楚。面试的时候如果被问到,不要慌,先在纸上画三列:资源、持有者、等待者,把 V/P 操作对应的依赖关系标出来,答案就自然出来了。
3. 从课堂走向工程:方案选型、风险规避与真实项目经验
3.1 信号量与锁的工程定位:为什么生产环境很少裸用信号量
经典同步问题里到处是信号量,但我在真实项目里几乎没有直接用过裸信号量。为什么?
因为信号量太底层,容易犯错,而且错误形态非常隐蔽。比如 P 操作和 V 操作必须成对出现,漏一个就可能导致线程永久挂起;信号量的计数含义完全靠程序员自己维护,一旦理解不一致,整个并发模型就崩了。
工程上更通用的做法是用更高级的封装:
- 互斥锁直接对应临界区保护,用
trylock和超时机制避免死锁。 - 条件变量配合互斥锁使用,替代“满/空”信号量的语义。
- 读写锁解决读者写者问题,但要注意公平策略。
- 选型时要考虑是 CPU 密集场景还是 IO 密集场景。如果临界区非常短,自旋锁比互斥锁快;如果临界区有 IO 操作或系统调用,老老实实用会休眠的锁,别让线程白白烧 CPU。
我见过一个性能事故:某团队用一个自旋锁保护一个里面做了网络请求的临界区,结果是每个线程都在空转等锁,CPU 被打满,吞吐却没有提升。后来把自旋锁换成互斥锁,CPU 立刻降到正常水平。
结论很简单:锁和信号量不是越底层越好,也不是性能越高越好。选型的核心是匹配临界区的特点。
3.2 优先级反转、死锁、活锁与饥饿:识别与规避
课堂上的同步问题只讲死锁,但工程里的并发故障比死锁种类多得多,而且更难排查。
优先级反转是实时系统里最头疼的问题:一个高优先级线程在等一个低优先级线程持有的锁,而低优先级线程又被中等优先级线程抢占,导致高优先级线程迟迟无法运行。解决思路是优先级继承:当高优先级线程等待锁时,临时把持锁线程的优先级提升到相同水平,让它赶紧执行完释放锁。
死锁的四大条件是:互斥、持有并等待、不可剥夺、循环等待。对应规避手段正好是这四条的逆操作:让线程需要资源时一次性申请所有资源,或者给等待加超时,或者强制按固定顺序拿锁。
活锁比死锁更隐蔽:线程没有阻塞,一直在互相谦让,结果谁也做不成事。比如两个线程同时检测到冲突,各自释放锁,回退重试,然后又同时抢锁,又同时谦让,形成一个永不终止的循环。这种问题最常出现在自旋锁加重试逻辑的代码里。解决方法是引入随机退避时间,避免两个线程总是同频重试。
饥饿则是低优先级线程永远拿不到资源。读写锁的写者饥饿就是典型。工程上一般用“排队公平”来解,让先来的请求排在前面,后来的排在后面,用 FIFO 保证每个请求最终都能得到服务。
3.3 并发程序的测试与验证:怎么复现竞态、怎么压测
并发代码最难的一点是:它出错是概率性的。测试环境里跑一百次都没问题,线上高并发一跑就炸。所以并发项目的验证方法跟普通功能测试完全不同。
第一个手段是加压力。用更高的并发数、更短的临界区操作时间,把线程切换的频率提上去,让竞态更容易被触发。很多竞态问题在单线程或低并发下根本不会暴露,只有把线程数拉到核心数的几倍才现出原形。
第二个手段是加随机性。在代码里人为插入延迟、随机调整线程调度顺序,破坏原有的时间巧合。这样能暴露那些“恰好先执行了 A 再执行 B 才没问题”的隐藏依赖。
第三个手段是启用专门的竞态检测工具。这类工具的原理是在每次内存访问时记录线程 ID,如果发现两个线程访问同一个地址且至少一个是写操作,就报告竞争。它对堆变量、全局变量、成员变量都能检测,非常强大。
我个人的习惯是:写并发模块之前画一张“共享变量表”,把每个全局变量、每个会导致跨线程访问的对象都列出来,标注哪段代码在什么锁保护下访问它。这个表看起来笨,但能提前发现“同一个变量被两把锁保护”这类低级错误。
4. 常见问题排查速查与实操心得
4.1 经典同步问题高频报错与排查对照表
下面这张表是我在处理并发问题时的排查路线,覆盖了经典同步问题在工程实现中最常出现的几类症状。
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 程序随机崩溃,数据错乱 | 竞态条件,缺少互斥锁保护共享变量 | 启用竞态检测工具;检查所有共享变量访问点,补锁或原子化 |
| 线程卡死,CPU 占用为 0 | 死锁,多个线程互相等待锁 | 抓线程堆栈,看每个线程阻塞在哪把锁上,检查锁获取顺序是否一致 |
| CPU 占用高,吞吐低 | 活锁或自旋等待过长 | 检查是否有重试循环;看锁竞争激烈程度,考虑改用会休眠的锁 |
| 某类任务迟迟不执行 | 饥饿,高优先级线程反复抢占 | 检查调度策略和读写锁公平性;引入等待队列或 FIFO 策略 |
| 生产者或消费者线程永久等待 | 信号量计数错误或 V 操作遗漏 | 检查所有信号量 P/V 是否成对;打印信号量当前值验证状态 |
排查工具上,线程堆栈是最直接的线索。死锁时jstack或gdb会把每个线程当前持有的锁和正在等待的锁都列出来,一眼就能看出循环等待链。
另一个高频问题是“锁的范围太大”导致性能下降,这不是功能错误但影响很大。优化思路是缩小锁粒度:能用局部变量就不用全局变量,能用读写锁就不用普通互斥锁,能分段加锁就别锁整个集合。
4.2 几个值得长期记住的实操经验
第一个经验:锁的顺序要有规则,最好全项目统一。我参与过一个项目,模块 A 总是先锁 X 再锁 Y,模块 B 总是先锁 Y 再锁 X。两个模块一旦在同一个线程里交汇,死锁概率立刻飙升。后来我们把规则改成“按变量名的字母序加锁”,死锁问题从此基本消失。
第二个经验:不要在持有锁的时候做耗时的外部操作。我曾经看到一段代码,在持锁状态下对远程服务做超时请求,单次请求要几百毫秒。结果这个锁被占住,别的线程全部排队,整个服务的响应时间被拖垮。锁里面只放内存操作和短同步逻辑,这是铁律。
第三个经验:写并发代码前先画图。我每次设计一个新的并发模块,都会先画一张线程角色图,画清楚谁是生产者、谁是消费者、共享资源在哪、锁保护哪段区域。这个习惯帮我避开了很多线上事故。
第四个经验:测试并发程序要有耐心。不要因为跑了几百次没出问题就觉得代码是对的。并发 bug 的触发依赖极小的时序窗口,往往要跑几万次才暴露。如果想确认锁是否真正生效,用工具抓检测报告,比靠感觉靠谱得多。
最后说一个个人体会。我每次评审并发代码,都会下意识地问三个问题:这个共享变量出现在几处?它的锁和保护是同一把吗?如果锁被占住了,等待方有没有超时兜底?把这三个问题过一遍,大约七成的同步隐患都能被提前暴露。经典同步问题的价值,说到底不是让你背诵答案,而是训练你形成这种“先画资源、再画流程、最后查死锁”的思维习惯。多线程的世界里,运气从来不是一种策略,真正的底气来自把每个临界区都看见、想清、锁好。