刚带新人调一个并发任务队列的bug,排查到最后,问题出在一个再经典不过的地方:两个线程同时往队列里写数据,因为没有做同步,跑着跑着数据丢了。那一刻我突然意识到,操作系统课上学的那几个“经典同步问题”,其实每天都在真实代码里重演。生产者-消费者、读者-写者、哲学家就餐、理发师睡觉,这些题目看似是考试专用,实际上就是并发编程的“最小可复现样例”。
这篇文章,我想把这几类经典同步问题放在一起,完整拆一遍。每个问题我会说清楚它到底在争抢什么资源、卡在哪个环节,以及用信号量和条件变量分别怎么实现,最后落到真实系统里对应的场景。不管你是还在啃操作系统的学生,还是写服务端代码时被并发问题折磨过的开发者,这篇都值得花点时间读完。读完之后,你再遇到“数据错乱”“死锁”“饥饿”这些词,脑子里能立刻浮现出模型,而不是靠瞎猜。
1. 经典同步问题到底在解决什么
1.1 一次数据错乱让我重新认识了临界区
先说个最直观的场景。假设两个线程同时执行counter++,用伪代码看就是:
// 线程A temp = counter; temp = temp + 1; counter = temp; // 线程B temp = counter; temp = temp + 1; counter = temp;如果两个线程交错执行,完全可能出现这种情况:A先读取 counter=10,B也读取 counter=10,A把11写回去,B也把11写回去。最终 counter 还是11,但实际上执行了两次自增,应该是12。这种问题叫竞态条件,根因是“先读取、再计算、后写入”这个三步操作不是原子的。
临界区就是真正需要互斥保护的代码段。在并发环境里,多个线程可能同时进入临界区,但它们操作的是共享数据,一旦交错就出错。同步问题的本质,就是设计一套规则,保证同一时刻最多只有一个线程在临界区里做关键操作,同时还要避免因为互相等待而卡死。
这个道理听起来简单,真正实现的时候坑非常多。比如有人会想到“我进入临界区前加个锁,出来后再解锁”,但如果一个线程在持锁状态下把自己阻塞了,其他线程就会永远等下去。经典同步问题里的每一个案例,几乎都是围绕这类矛盾展开的。
1.2 三个核心概念:互斥、同步、死锁
要理解经典同步问题,先把三个词刻在脑子里。
- 互斥:同一时刻只允许一个线程访问临界资源。比如两个线程不能同时修改同一个内存变量。
- 同步:不同线程之间存在先后顺序约束。比如必须先生产后消费,消费者不能跑去消费空缓冲区里的数据。
- 死锁:两个或多个线程各自持有一部分资源,又都在等待对方释放资源,结果谁也没法前进。
用生活化的类比解释:互斥就像一间只有一个座位的厕所,有人进去后其他人就得等着;同步就像先买单才能取餐,顺序不能乱;死锁就是两个人都站在狭窄的过道里,你让我先过、我让你先过,最后谁都没动。
经典同步问题之所以经典,就是因为它们把这三件事组合出了各种复杂形态。生产者-消费者本质是“同步+互斥”的组合;哲学家就餐本质是“死锁如何产生、如何避免”;理发师睡觉本质是“条件等待与唤醒”。把这几个模型吃透,后面看任何并发框架的源码,都会觉得特别眼熟。
2. 生产者-消费者问题:最基础的同步样板
2.1 问题描述与信号量模型
生产者-消费者问题的场景是这样的:有一个固定大小的缓冲区,生产者往缓冲区里放产品,消费者从缓冲区里取产品。规则是:缓冲区满了生产者必须等待;缓冲区空了消费者必须等待;任何时刻只有一个线程能操作缓冲区。
这个问题的经典之处在于,它同时包含了互斥和同步。缓冲区本身是共享资源,需要互斥保护;缓冲区满/空的状态变化,需要同步机制通知对方。
用信号量来建模非常清晰。设缓冲容量为 N,定义三个信号量:
| 信号量 | 初始值 | 作用 |
|---|---|---|
| mutex | 1 | 保护缓冲区的互斥访问 |
| empty | N | 当前空余槽位数,生产者每放入一个产品就减一 |
| full | 0 | 当前已占用槽位数,消费者每取出一个产品就减一 |
信号量的P(wait)操作会让计数减一,如果计数为负则阻塞;V(signal)操作会让计数加一,如果有线程被阻塞,就唤醒其中一个。这套机制天然适合表示“资源数量”的模型。
2.2 完整实现与关键参数说明
下面是一份基于信号量的生产者-消费者伪代码,伪代码里用 P 和 V 表示 wait 和 signal:
semaphore mutex = 1; semaphore empty = N; // 空槽位数量 semaphore full = 0; // 已占用槽位数量 void producer() { while (1) { item = produce(); P(empty); // 请求一个空槽位,如果没有空槽则阻塞 P(mutex); // 进入临界区 add(item); // 写入缓冲区 V(mutex); // 离开临界区 V(full); // 增加一个满槽位,唤醒可能等待的消费者 } } void consumer() { while (1) { P(full); // 请求一个有产品的槽位,没有则等待 P(mutex); // 进入临界区 item = remove(); V(mutex); // 离开临界区 V(empty); // 空槽位加一,唤醒可能等待的生产者 consume(item); } }这里有一个很容易忽略但非常关键的点:两个 P 操作的顺序不能随意调换。如果把P(empty)和P(mutex)调换,生产者先拿到 mutex,再等待空槽位;而消费者想释放空槽位,必须先拿到 mutex。结果就是生产者拿着锁等待消费者释放槽位,消费者拿着槽位等待生产者释放锁,两边互相等待,直接死锁。
正确的顺序原则是:先申请“资源信号量”(empty/full),再申请“互斥信号量”(mutex)。因为资源信号量表示的是一个前置条件是否满足,前置条件不满足时,线程根本不该开始操作缓冲区。
2.3 真实系统中的对应场景
你可能会觉得这种模型太简单,实际上线程池的任务队列就是生产者-消费者。往队列里提交任务的是生产者,从队列里取任务执行的是 worker 线程,就是消费者。队列有界的时候,满了之后提交方要阻塞,这就是empty信号量的作用;任务进来后要唤醒 worker,这就是full信号量的作用。
我见过不少线上事故,就是因为在队列满时直接丢弃任务,或者用一个无限增长的队列把内存打爆。回头去看,无非是把生产者和消费者的“速率匹配”问题处理坏了。理解了信号量模型,你再去看线程池的workQueue和execute()方法,会觉得思路特别清晰。
3. 读者-写者问题:读写锁的由来
3.1 问题描述与优先级策略
读者-写者问题是另一个经典模型:多个读者可以同时读取共享数据,但写者必须独占访问,也就是说读者和写者不能同时操作,写者和写者也不能同时操作。
如果把读者也当作写者来互斥,实现确实简单,但代价是并发度太低。实际系统里读操作往往远比写操作频繁,比如配置中心、缓存、计数器统计,都是读多写少的场景。如果读之间也互斥,性能会大打折扣。因此读者-写者问题的核心就是:允许读读并发,只保证读写互斥、写写互斥。
这里还衍生出两种优先级策略。读者优先:只要有读者在读取,后来的读者可以继续进入,写者必须等待所有读者退出。写者优先:一旦有写者等待,新读者禁止进入,避免写者被“饿死”。两种策略各有取舍,没有绝对的对错,看系统需求。
3.2 读者优先的信号量实现
读者优先的经典实现用两个信号量加一个读者计数器:
semaphore rmutex = 1; // 保护读者计数器的互斥 semaphore wmutex = 1; // 控制写者互斥 int readers = 0; void reader() { while (1) { P(rmutex); if (readers == 0) P(wmutex); // 第一个读者把写者挡在门外 readers++; V(rmutex); read_data(); P(rmutex); readers--; if (readers == 0) V(wmutex); // 最后一个读者离开,放写者进入 V(rmutex); } } void writer() { while (1) { P(wmutex); write_data(); V(wmutex); } }这里巧妙的地方在于:第一个读者进入时申请wmutex,最后一个读者退出时释放wmutex。中间的所有读者都只操作readers计数,不需要碰wmutex。所以多个读者能同时待在自己的“读临界区”里。
缺陷也很明显,如果读者源源不断地来,readers永远大于0,wmutex就一直被占用,写者会长时间等不到机会。这就是典型的写者饥饿。实际场景里,日志系统如果长期被大量读操作霸占,写日志的线程就可能被饿死。
3.3 写者优先与公平性的改进思路
要改进写者饥饿,需要在“写者等待时,阻止新读者进入”上做文章。常见做法是再加一组计数器和信号量:
semaphore readLock = 1; // 控制读者是否能进入 semaphore writeLock = 1; // 写者互斥 semaphore rmutex = 1; // 保护读者计数 semaphore wmutex = 1; // 保护写者计数 int readers = 0; int writers = 0; void reader() { P(readLock); // 如果写者在等待,这里会阻塞新读者 P(rmutex); if (readers == 0) P(writeLock); readers++; V(rmutex); V(readLock); read_data(); P(rmutex); readers--; if (readers == 0) V(writeLock); V(rmutex); } void writer() { P(wmutex); writers++; if (writers == 1) P(readLock); // 第一个写者锁住读者入口 V(wmutex); P(writeLock); write_data(); V(writeLock); P(wmutex); writers--; if (writers == 0) V(readLock); // 最后一个写者放开读者入口 V(wmutex); }这套逻辑会更复杂,但思路是:写者排队时先锁住readLock,新读者进不来;已经进入的读者可以继续读,等它们读完释放writeLock,写者再进入。这样写者不会无限期被后来的读者插队。
现代系统里的读写锁(rwlock)基本就是在这两种策略之间选择。写偏向锁更强调写操作的及时性,读偏向锁更追求读操作的吞吐量。你去看很多开源项目里ReadWriteLock的实现注释,能直接看到“读者优先”或“写者优先”这类的设计说明。
4. 哲学家就餐问题:死锁与资源分配
4.1 经典场景与为什么难
哲学家就餐问题是并发领域最有名的一道题:五个哲学家围坐在圆桌旁,每两个人中间有一根叉子,哲学家只有同时拿起左右两边的叉子才能吃饭。叉子就是共享资源,哲学家就是线程。
直观的写法是:每个哲学家先拿左手叉子,再拿右手叉子,吃完后依次放下。伪代码如下:
semaphore fork[5] = {1, 1, 1, 1, 1}; void philosopher(int i) { while (1) { P(fork[i]); // 拿左边叉子 P(fork[(i + 1) % 5]); // 拿右边叉子 eat(); V(fork[i]); V(fork[(i + 1) % 5]); think(); } }看起来很合理,但一旦五个哲学家同时拿起左边的叉子,每个人都等着拿右边的叉子,而右边叉子都在旁边的哲学家手里,于是谁也吃不上饭,系统假死。这就是教科书式的死锁:互斥、占有并等待、不可剥夺、循环等待四个条件全部满足。
我一开始学到这里也很不理解,觉得“哪有这么巧,正好五个线程同时卡在同一个地方”。但真实系统里,这种“巧”在高并发、周期性的压力下很容易出现。不是每个人都时刻拿着叉子,但某一瞬间大家正好都抢到了左锁,就会卡住。
4.2 三种经典解决方案对比
方案一:限制同时就餐人数。最多允许四个哲学家同时拿叉子,这样至少有一个哲学家能同时拿起左右两叉,吃完释放资源,打破循环等待。实现方式就是加一个容量为 4 的信号量:
semaphore dining = 4; void philosopher(int i) { P(dining); P(fork[i]); P(fork[(i + 1) % 5]); eat(); V(fork[(i + 1) % 5]); V(fork[i]); V(dining); think(); }方案二:奇数哲学家先拿左叉,偶数哲学家先拿右叉。破坏循环等待条件。这个方案的核心是编号奇偶交错,让相邻哲学家不会同时处于“等你那边的叉子”的状态。
方案三:用一次性的原子操作拿两把叉子。将 fork[i] 和 fork[(i+1)%5] 作为一个整体,用一个全局互斥锁保护“取叉子”的代码段。虽然并发度降低,但思路最简单:
semaphore fork[5]; semaphore mutex = 1; void philosopher(int i) { P(mutex); P(fork[i]); P(fork[(i + 1) % 5]); V(mutex); eat(); V(fork[i]); V(fork[(i + 1) % 5]); think(); }三种方案没有绝对优劣。方案一胜在简单,方案二保留了更多并发度,方案三的原子性思想最接近现代编程里的“一次获取多个锁必须避免交错”原则。实际工程中,我更推荐先想清楚要破坏死锁四个条件里的哪一个,再决定用哪套方案。
4.3 工程里的死锁预防经验
哲学家就餐问题在数据库领域特别常见。比如两个事务都要更新 A、B 两张表,事务1先锁A再锁B,事务2先锁B再锁A,结果两个事务互相等待,数据库只能靠死锁检测去回滚其中一个。解决思路和哲学家问题一模一样:所有事务都按固定顺序获取锁(比如按表名或主键排序),这样就不会形成循环等待。
写并发代码时我养成了一个习惯:给锁排顺序。如果多个线程需要获取多把锁,就把锁的 ID 按统一规则排序,每次都从小的拿大的,破坏循环等待。这个习惯在写分布式锁、多租户状态机时帮我省了无数排查时间。再看回哲学家就餐,能设计出“五个人围着圆桌”这种模型,本质上就是把锁顺序冲突这个点放到了最大。
5. 理发师睡觉问题:条件变量与等待队列
5.1 问题描述:理发师和顾客的状态机
理发师睡觉问题的场景很生活化:理发店有一把理发椅和若干等待椅。没有顾客时,理发师在理发椅上睡觉;顾客来了发现理发师在睡觉,就唤醒他;如果理发师正忙着,顾客在等待椅上排队;如果等待椅满了,顾客直接离开。整个过程里有两个关键条件:理发师需要等待“有顾客来”,顾客需要等待“轮到自己理发”。
这个问题和前几个模型的最大区别是:它更自然地对应用条件变量而不是信号量。因为线程的阻塞条件是某个“状态”是否成立,例如“没有顾客”或“等待队列非空”。条件变量允许线程在一个条件上阻塞,并在该条件可能变为真时被通知唤醒。
5.2 条件变量实现与信号量实现对比
用条件变量实现时,典型的伪代码如下。这里用两个条件变量分别表示“顾客到来”和“理发师空闲”:
mutex_t mutex; cond_t cond_ready_chair; // 有顾客在等待椅上等待 cond_t cond_barber_done; // 理发师完成一次服务 int waiting = 0; // 等待椅上的顾客数 int barber_busy = 0; void barber() { while (1) { lock(&mutex); while (waiting == 0) { wait(&cond_ready_chair, &mutex); // 没有顾客,睡觉 } waiting--; unlock(&mutex); cut_hair(); lock(&mutex); barber_busy = 0; broadcast(&cond_barber_done, &mutex); unlock(&mutex); } } void customer() { lock(&mutex); if (waiting >= CHAIRS) { unlock(&mutex); leave(); // 等待椅满,直接离开 } waiting++; signal(&cond_ready_chair, &mutex); // 唤醒理发师 unlock(&mutex); }注意wait操作要放在while循环里,而不是if里。原因是“虚假唤醒”的存在:即使无人 signal,线程也可能因为系统内部原因醒来。如果只用if判断一次,醒来后发现条件还是不满,仍然会继续执行错误逻辑。用while重新检查条件,才是安全写法。
如果换成信号量实现,可以将理发师和顾客看成一类生产者-消费者的问题。理发师消费“顾客”资源,顾客消费“理发完成”的资源。但信号量天然不擅长处理“条件为真才继续”这种带状态的判断,代码里容易出现多减一或漏唤醒的问题。实际写并发代码时,我优先选择互斥锁加条件变量,它和人的思考方式更贴近。
5.3 线程池、连接池里的影子
理发师问题对应的真实系统非常多。最典型的就是线程池:线程池里的 worker 线程在队列为空时阻塞等待,相当于理发师睡觉;外部提交任务时唤醒 worker,相当于顾客叫醒理发师。控制等待队列长度避免任务堆积,对应“等待椅有限”的约束。
另一个对应是数据库连接池。应用线程向连接池申请连接,如果池里没有空闲连接,就得等连接归还;连接池满时新请求可能要排队或拒绝。面试里常问的“连接池大小怎么设置”,本质上就是在调节生产者和消费者的速率匹配,以及在“直接拒绝”和“让调用方等待”之间做权衡。
理解理发师问题后,你再去看这些框架,会意识到它们没有多高深,只是在有限的共享资源上,实现了“等待-唤醒-状态判断”这套逻辑。
6. 经典问题在现代系统里的影子
6.1 同步原语选型:锁、条件变量、信号量
学完这些经典问题,你可能会问:那我写代码到底该用哪个?
我的建议是:优先用互斥锁加条件变量,信号量只在明确需要计数资源时用。原因很简单,信号量把计数和排队逻辑揉在了一起,稍不留神就把 PV 调用顺序写错;而锁加条件变量带来的状态判断代码更直白,也更容易让后来接手的人读明白。
| 场景 | 推荐原语 | 理由 |
|---|---|---|
| 保护一段临界区 | 互斥锁 | 简单,开销小 |
| 等待某个条件成立 | 条件变量 + 互斥锁 | 表达能力强 |
| 控制有限资源数量 | 信号量 | 天然带计数 |
| 读多写少 | 读写锁 | 提高并发度 |
| 获取多把锁 | 按顺序加锁 | 避免死锁 |
需要注意的是,锁和条件变量只能解决单进程内的线程同步。跨进程甚至跨机器的分布式场景,需要引入更复杂的分布式锁。不过底层思路还是没变:互斥、顺序、超时、重试,经典同步问题的模型依然适用。
6.2 实战排查:复现、定位、根因分析
排查并发问题有个很实用的“三步走”方法。
第一步,复现。并发bug最重要的就是找到稳定复现的路径。可以通过提高线程数、增加循环次数、加日志、在关键位置插入随机 sleep 来放大竞争窗口。比如复现生产者-消费者数据丢失,可以把缓冲区调小,让生产消费速率更接近,问题就更容易暴露。
第二步,定位。拿到线程转储后,看每个线程阻塞在哪里,到底在等哪把锁。如果能画出“谁持有锁”和“谁在等待锁”的关系图,死锁和饥饿会很快暴露。我常用的方式是直接在转储文件里搜waiting for、blocked on lock这类关键词,然后把相关线程的行号列出来。
第三步,修复。修复的原则是尽量小改动,不要在同一个临界区里做耗时的 IO 操作。很多线上问题不是同步原语用错了,而是临界区范围过大,导致所有线程挤在同一个锁里排队。把大锁拆成小锁,或者将“读数据”和“处理数据”分开,往往比换同步原语更有效。
6.3 我个人踩过几次坑之后的体会
写并发代码这几年,我最大的体会是:经典同步问题不是用来背的,是用来建模的。
遇到任何并发场景,先别急着写锁,先画出资源图:哪些是共享资源,哪些线程在竞争,哪些条件不满足时线程必须等待。把图画出来,再对应到某个经典模型上,实现路径就清晰了。比如“多个worker抢任务”就对应生产者-消费者,“多人同时改一个配置项”就对应读者-写者,“多个事务互相等锁”就对应哲学家就餐。
还有一个很实用的经验:所有的同步代码必须带着“超时兜底”思想。等待锁、等待条件、等待队列位置,都有可能因为一个异常导致永远不会被唤醒。工程代码里给等待加超时时间,即使出现极端情况也只是超时返回,而不是永久卡死。这个习惯救了我好几次。
最后再分享一个小技巧:写同步逻辑时,在注释里写清楚每个锁保护的是哪份数据,每个条件变量等待的是什么状态。这些注释在项目刚启动时看起来多余,但几个月之后,当别人(甚至你自己)面对一堆 PV 调用和 wait/signal 时,就知道它们有多重要了。经典同步问题说到底,考验的不仅是你对信号量、条件变量的掌握,更是你建模和写可维护代码的能力。