资讯动态

并发编程经典同步问题全解析:从信号量到死锁预防

发布时间:2026/10/16 14:51:14 来源:尧图企业网站定制
刚带新人调一个并发任务队列的bug排查到最后问题出在一个再经典不过的地方两个线程同时往队列里写数据因为没有做同步跑着跑着数据丢了。那一刻我突然意识到操作系统课上学的那几个“经典同步问题”其实每天都在真实代码里重演。生产者-消费者、读者-写者、哲学家就餐、理发师睡觉这些题目看似是考试专用实际上就是并发编程的“最小可复现样例”。这篇文章我想把这几类经典同步问题放在一起完整拆一遍。每个问题我会说清楚它到底在争抢什么资源、卡在哪个环节以及用信号量和条件变量分别怎么实现最后落到真实系统里对应的场景。不管你是还在啃操作系统的学生还是写服务端代码时被并发问题折磨过的开发者这篇都值得花点时间读完。读完之后你再遇到“数据错乱”“死锁”“饥饿”这些词脑子里能立刻浮现出模型而不是靠瞎猜。1. 经典同步问题到底在解决什么1.1 一次数据错乱让我重新认识了临界区先说个最直观的场景。假设两个线程同时执行counter用伪代码看就是// 线程A temp counter; temp temp 1; counter temp; // 线程B temp counter; temp temp 1; counter temp;如果两个线程交错执行完全可能出现这种情况A先读取 counter10B也读取 counter10A把11写回去B也把11写回去。最终 counter 还是11但实际上执行了两次自增应该是12。这种问题叫竞态条件根因是“先读取、再计算、后写入”这个三步操作不是原子的。临界区就是真正需要互斥保护的代码段。在并发环境里多个线程可能同时进入临界区但它们操作的是共享数据一旦交错就出错。同步问题的本质就是设计一套规则保证同一时刻最多只有一个线程在临界区里做关键操作同时还要避免因为互相等待而卡死。这个道理听起来简单真正实现的时候坑非常多。比如有人会想到“我进入临界区前加个锁出来后再解锁”但如果一个线程在持锁状态下把自己阻塞了其他线程就会永远等下去。经典同步问题里的每一个案例几乎都是围绕这类矛盾展开的。1.2 三个核心概念互斥、同步、死锁要理解经典同步问题先把三个词刻在脑子里。互斥同一时刻只允许一个线程访问临界资源。比如两个线程不能同时修改同一个内存变量。同步不同线程之间存在先后顺序约束。比如必须先生产后消费消费者不能跑去消费空缓冲区里的数据。死锁两个或多个线程各自持有一部分资源又都在等待对方释放资源结果谁也没法前进。用生活化的类比解释互斥就像一间只有一个座位的厕所有人进去后其他人就得等着同步就像先买单才能取餐顺序不能乱死锁就是两个人都站在狭窄的过道里你让我先过、我让你先过最后谁都没动。经典同步问题之所以经典就是因为它们把这三件事组合出了各种复杂形态。生产者-消费者本质是“同步互斥”的组合哲学家就餐本质是“死锁如何产生、如何避免”理发师睡觉本质是“条件等待与唤醒”。把这几个模型吃透后面看任何并发框架的源码都会觉得特别眼熟。2. 生产者-消费者问题最基础的同步样板2.1 问题描述与信号量模型生产者-消费者问题的场景是这样的有一个固定大小的缓冲区生产者往缓冲区里放产品消费者从缓冲区里取产品。规则是缓冲区满了生产者必须等待缓冲区空了消费者必须等待任何时刻只有一个线程能操作缓冲区。这个问题的经典之处在于它同时包含了互斥和同步。缓冲区本身是共享资源需要互斥保护缓冲区满/空的状态变化需要同步机制通知对方。用信号量来建模非常清晰。设缓冲容量为 N定义三个信号量信号量初始值作用mutex1保护缓冲区的互斥访问emptyN当前空余槽位数生产者每放入一个产品就减一full0当前已占用槽位数消费者每取出一个产品就减一信号量的Pwait操作会让计数减一如果计数为负则阻塞Vsignal操作会让计数加一如果有线程被阻塞就唤醒其中一个。这套机制天然适合表示“资源数量”的模型。2.2 完整实现与关键参数说明下面是一份基于信号量的生产者-消费者伪代码伪代码里用 P 和 V 表示 wait 和 signalsemaphore 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永远大于0wmutex就一直被占用写者会长时间等不到机会。这就是典型的写者饥饿。实际场景里日志系统如果长期被大量读操作霸占写日志的线程就可能被饿死。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[(i1)%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 时就知道它们有多重要了。经典同步问题说到底考验的不仅是你对信号量、条件变量的掌握更是你建模和写可维护代码的能力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑