2026/10/22 14:55:24

并发编程六大概念与三大死锁陷阱:从进程线程到协程管程

并发编程六大概念与三大死锁陷阱:从进程线程到协程管程 并发编程十个新手九个栽在“概念混搭”上。今天要拆的就是六个高频词线程、进程、纤程、协程、管程外加三个容易混淆的失败模式——死锁、活锁、锁饥饿。很多同学都能背出“进程是资源分配单位线程是调度单位”这句话但真到排查线上故障时还是挠头为什么一堆线程卡住时进程还活着为什么协程能扛住上万个连接而线程早就栈溢出为什么明明加了锁还能跑成死锁这些问题如果只记住教科书定义根本没法落地。这篇文章不打算重抄概念表而是把六类原语按“数量、调度、成本、语义”重新摆一轮再用工程现场里最常见的坑把死锁、活锁、锁饥饿讲透。适合已经有基本编程经验、正在啃并发、或者准备系统设计面试的读者读完你能对着一个卡死进程说出它到底卡在哪一类问题上也能在写代码时提前把这三类坑从设计上避开。1. 先认清本质为什么能用线程解决的问题还会冒出协程和纤程1.1 进程与线程一个管资源一个管执行进程是操作系统分配资源的最小单位。每个进程有自己独立的地址空间、文件描述符表、信号处理表、环境变量等。线程是进程内部的执行流同一个进程里的线程共享地址空间和各种资源但各自持有独立的程序计数器、栈和寄存器上下文。两者的本质区别不在于“进程大、线程小”而在于隔离边界。进程之间默认谁也不能碰谁的内存一个进程崩溃通常不会直接带走另一个进程线程则不同一个线程踩了非法内存整个进程都会跟着崩。这也是为什么很多服务宁可多开几个进程来做故障隔离也不愿意把所有功能塞进一个进程里的原因。但线程也不是免费的午餐。线程的创建和切换都发生在内核态每次切换要保存和恢复寄存器、程序计数器、栈指针还要处理缓存失效。更重要的是线程栈是预分配的。以主流语言运行时为例一个线程默认栈空间动辄几兆看起来不多但当你开启数千个线程时光栈内存就能吃掉几个G甚至更多。举个例子。假设你写了一个简单的HTTP服务来一个请求就开一个线程处理。在双核机器上压到几百并发可能CPU还没满内存先被线程栈和内核线程描述符吃光了。更麻烦的是线程调度由内核统一管理线程数量越大上下文切换的开销越离谱。当活跃线程数远超CPU核数时系统大部分时间都在做切换而不是做业务。这引出第一个关键结论线程确实能并发但它的成本锚点是“内核”。所有需要内核参与创建、调度、切换的资源都会有一个你绕不开的瓶颈。这也是协程和纤程出现的直接原因。1.2 协程与纤程把调度权从内核手里夺回来协程本质是用户态调度下的轻量执行流。它的栈不依赖内核栈而是由语言运行时或框架在堆上分配。一个协程的栈可以很小需要时动态增长切换时不进入内核只做寄存器级别的保存和恢复所以成本比线程低一到两个数量级。拿Python的asyncio来说事件循环在一个线程里驱动成千上万个协程协程之间的切换靠await让出控制权。再比如某些系统语言引入的goroutine它们由语言运行时调度可以把大量并发任务映射到少数几个操作系统线程上做到“百万级并发任务”而内核毫无感知。纤程和协程概念上高度重叠都在强调“应用层主动调度”但语义稍有差异。纤程通常更强调由运行时调度循环来驱动一般一个线程上可以跑多个纤程纤程之间是协作式切换。协程则可以看作是纤程思想在语言层的标准化产物。很多新语言甚至直接内建协程原语不需要程序员手动管理调度循环。有人会问既然协程这么好为什么还要线程因为协程解决的是I/O密集型并发也就是大量时间在等待网络、磁盘、锁的场景。如果是CPU密集型任务协程并不能突破物理核数限制最终还是需要多线程配合多核。而且协程栈虽然轻但一旦某个协程内部调用了阻塞的系统调用它占用的那个操作系统线程会被一起卡住如果调度器没做好隔离后面的协程全部排队。所以在实际工程里通常是“线程池 协程”混合使用线程负责处理阻塞边界协程负责大量轻量任务。为了把定位摆清楚我把四类执行单元放在同一张表里对照执行单元调度者栈空间切换成本适合场景典型代表进程内核独立地址空间很高故障隔离、多租户服务实例线程内核独立栈共享堆高CPU并行、阻塞型任务线程池服务协程语言运行时动态增长极低I/O密集型高并发事件循环纤程应用层调度循环动态分配极低特殊场景局部队列私有运行时这张表不是为了让你做选择题而是提醒你并发原语的选型本质是在“隔离性、并行能力、并发规模、开发成本”之间做取舍。没有任何一种能通吃所有场景。2. 管程到底管住什么并发同步问题的经典解2.1 管程思想锁和条件变量焊在一起管程的英文是Monitor翻译成中文有两种常见叫法管程或监视器。它是并发编程里一个非常关键的抽象概念。很多程序员第一次见到这个词是在操作系统教材里但没意识到自己每天都在用管程。管程的核心思想是把“互斥锁”和“条件变量”封装成一个整体。进入管程的代码路径必须先拿到锁拿不到就等在门外一旦在内部发现某个条件不满足就通过条件变量把自己挂起同时释放锁等条件成立时由别的线程通过信号唤醒。这个模型同时解决了两个问题数据竞争和等待协作。用生活类比来理解。互斥锁就像公共厕所的门闩你进去了别人就得等条件变量就像后厨的出菜通知厨师把菜做完摇铃服务员听到铃声才来取餐。如果只有门闩没有摇铃你进厕所后不知道别人什么时候用完只能不停推门试探如果只有摇铃没有门闩所有人都挤进厨房抢菜秩序全乱。管程就是门闩加摇铃的组合。在Java里每个对象内置一个监视器锁synchronized关键字本质上就是获取对象内置管程的使用权。当你进入synchronized代码块时相当于进入了管程调用wait()就是挂起并释放监视器调用notify()或notifyAll()就是发通知。ReentrantLock配合Condition使用本质上也构建了一个管程只是比内置监视器提供了更多功能比如公平性策略、超时等待、多个等待队列。管程的巧妙之处在于它把并发的两大难题“互斥访问”和“条件等待”统一在一个模型里程序员只需要关心“什么时候等待什么时候唤醒”不用直接操作底层的锁和信号量组合。这也是为什么现代语言大多以内建管程或类管程机制作为并发同步的主推方案。2.2 用管程搞定生产者消费者光说理论不过瘾直接看一个典型例子生产者消费者模型。这个模型简直是并发教材里的常青树它能完整展示管程的互斥和条件同步能力。public class MonitorDemo { private final Object lock new Object(); private final java.util.QueueInteger queue new java.util.LinkedList(); private final int maxSize 10; public void produce(int value) throws InterruptedException { synchronized (lock) { while (queue.size() maxSize) { lock.wait(); } queue.offer(value); lock.notifyAll(); } } public int consume() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } int value queue.poll(); lock.notifyAll(); return value; } } }这段代码里有几个细节新手经常写错。第一个细节是等待条件必须放在while循环里不能放在if里。为什么因为wait()返回之后你并不知道是条件真正成立了还是被虚假唤醒打断了。所谓虚假唤醒是指即使没有线程显式调用notify操作系统层面也可能出于某些原因把等待线程唤醒。就算你相信Java不会出这种诡异问题还有一种更常见的情况多个消费者被唤醒但队列里只有一个数据前一个消费者把数据抢走了后一个消费者醒来一看队列又空了。如果用的是if它就不会重新检查条件直接去队列里取数据取到null或者空对象程序就炸了。用while循环每次醒来都重新检查确保条件真实成立才继续走。第二个细节是唤醒时用notifyAll而不是notify。notify只随机唤醒一个等待线程听起来更高效但问题在于你没法保证被唤醒的线程就是你想要的那一类。比如队列满了多个生产者都在等待此时消费者只唤醒了一个生产者这个生产者醒来后发现自己仍然抢不到锁或者是被别的生产者抢先了它可能再次wait。极端情况下所有生产者都被“假唤醒”却都无法推进而消费者又没被通知到整个系统就僵住了。notifyAll把所有等待线程都叫醒代价是稍微多一些竞争但换来了正确性。工程上优先保证对再考虑性能优化。第三生产者和消费者都使用同一把锁这是管程的核心约束。如果生产者用锁A保护队列消费者用锁B保护同一个队列那互斥性就完全失效了两个线程可以同时修改队列内存数据直接错乱。这是我推荐的入门写法。生产环境里大多数团队会改用阻塞队列这类现成的高层组件底层已经封装好了管程逻辑比如容量限制、等待与通知。但无论如何理解while循环的意义都是基本功因为一旦你直接操作Condition这个坑依然存在。3. 死锁四个条件缺一不可实战排查思路3.1 从哲学家就餐到真实代码复现死锁这个问题教材喜欢用哲学家就餐来讲。五位哲学家围着一圈餐桌每两人之间放一根筷子哲学家需要拿到左右两根筷子才能吃饭。如果每个人都先拿右手边的筷子同时等待左手边的筷子那么谁都有可能拿不到第二根筷子所有人都在等待但谁都不会放弃手里的那根于是整体僵死。这个故事在工程里一点都不抽象。两个线程各自持有一把锁然后试图获取对方手里的锁就是教科书死锁的现场版。直接上代码import threading import time lock_a threading.Lock() lock_b threading.Lock() def task_a(): with lock_a: time.sleep(0.02) # 给task_b一点时间持有lock_b print(task_a holding lock_a, waiting lock_b) with lock_b: print(task_a got lock_b) def task_b(): with lock_b: time.sleep(0.02) print(task_b holding lock_b, waiting lock_a) with lock_a: print(task_b got lock_a) threading.Thread(targettask_a).start() threading.Thread(targettask_b).start()你运行这段脚本大概率会看到两个输出打印出来然后程序就一直卡着不动。task_a拿到了lock_atask_b拿到了lock_b两边都在等对方释放谁都不让。这里用sleep刻意制造了时间差让两把锁被分别持有的概率拉满。实际业务里不需要这么完美的时序只要两个线程在多把锁之间交错运行运气不好就撞上。死锁之所以难排查是因为它不像空指针异常那样直接抛错而是表现为“进程还在运行业务却没有任何进展”。如果发生在核心接口上外部表现就是请求全部超时但CPU占用率还不一定高因为线程都在等锁而不是在算数。3.2 四个必要条件与工程化破局手段判断是不是死锁或者设计时预防死锁必须回到死锁的四个必要条件。这四个条件缺一不可只要打破其中一个死锁就不再成立。必要条件含义工程化解法互斥资源一次只能被一个线程使用无法根除锁天然互斥可通过无锁算法绕开持有并等待线程持有资源同时又等别的资源一次性申请所有需要资源或者进入临界区前统一加锁不可剥夺资源只能由持有者主动释放设置锁超时或使用可抢占锁循环等待形成A等B、B等A的环路给所有锁编号强制按顺序获取四个条件里“互斥”是锁存在的意义没法直接去掉所以我们把精力放在后面三个。工程上最实用的第一个手段是锁排序。所有需要多把锁的代码路径都按照同一个全局顺序加锁。比如规定先锁order再锁payment那么任何线程都不会出现“先持payment再抢order”的情况循环等待就不可能形成。这要求团队在代码评审时盯死这个约束因为只要有一个新同事把顺序写反整个担保就失效了。我见过生产事故就是因为锁排序不一致引起的调试了两天才定位到是两段代码加锁顺序恰好相反。第二个手段是给锁加超时。Java里的ReentrantLock提供了tryLock(timeout, unit)获取不到锁时不会永久挂起而是等一段时间后放弃。Python的acquire()也支持timeout参数。超时机制把“不可剥夺”这个条件打破了我拿不到你手里的锁但我不死等我先把手里的释放掉回头重试。注意超时之后业务要处理好“我只拿到一半资源”的状态不能直接往下走。第三个手段是死锁检测。业务逻辑复杂时光靠锁排序很难保证万无一失这时候就要靠工具兜底。JVM的线程转储thread dump能直接检测到死锁并打印出完整的等待环操作系统级别的工具也能看到类似信息。检测到之后通常做法是强制重启进程或杀掉某个线程然后回到代码里修加锁顺序。检测是最后一道防线不是救命稻草。还有一个理论上很漂亮但实现代价较高的解法银行家算法。它通过动态判断“当前剩余资源能否满足某个进程的最大需求”来决定是否分配资源像银行家判断贷款是否会导致资金链断裂一样。这算法在真实业务系统中很少直接实现因为业务线程对资源的需求量很难提前精确上报。知道有这回事就行面试可以聊落地时优先选锁排序和超时。4. 活锁和锁饥饿偏离直觉的两个陷阱4.1 活锁一直在让路就是没有进展死锁的典型特征是“大家一起等”而活锁的典型特征是“大家一起让但谁也做不成事”。它是并发里比较反直觉的一种失败模式很多同学第一次遇见时完全反应不过来自己碰上了活锁。活锁的表现是线程们并没有被阻塞它们在不断改变自己的状态不断尝试、谦让、重试系统看起来非常热闹但业务进度始终为零。就像两个人在狭窄过道里迎面相遇你往左让他往右让结果还是面对面你再往右让他又往左让还是堵住两人都觉得“我一直在让路啊怎么对方还在挡我”。在锁竞争的语境里活锁更阴险。拿一个简化的例子来看两个线程都想获得两把锁但获取第二把锁失败时都立刻释放自己手头的锁然后重试import threading import time lock_a threading.Lock() lock_b threading.Lock() done {flag: False} def worker_a(): while not done[flag]: if lock_a.acquire(timeout0.001): time.sleep(0.001) if lock_b.acquire(timeout0.001): done[flag] True print(worker_a done) lock_b.release() else: lock_a.release() # 主动让出避免死锁 time.sleep(0.001) else: time.sleep(0.001) def worker_b(): while not done[flag]: if lock_b.acquire(timeout0.001): time.sleep(0.001) if lock_a.acquire(timeout0.001): done[flag] True print(worker_b done) lock_a.release() else: lock_b.release() time.sleep(0.001) else: time.sleep(0.001) threading.Thread(targetworker_a).start() threading.Thread(targetworker_b).start()这段代码的意图是避免死锁worker_a拿不到第二把锁就主动让出worker_b也一样。但残酷的是两个线程经常同步进入“拿第一把锁、拿不到第二把锁、释放第一把锁”的循环反复横跳谁也无法完成任务。为什么会出现这个问题因为重试策略太整齐了两边都用相同的等待时间于是一次次撞车。解决办法也比较粗暴打破对齐。最经典的策略是引入随机退避双方在释放锁之后各自sleep一个随机的时长让下一次尝试的时机错开。这个思路和网络重传里的指数退避如出一辙。更稳妥的做法是给其中一个线程更高的优先级让它在冲突时更有底气先把第二把锁拿到另一个线程则选择放弃并让位。工程上如果识别出活锁在持续发生优先考虑重新设计锁粒度避免出现频繁的“拿A等B拿B等A”结构。4.2 锁饥饿没死锁但写线程被饿死锁饥饿听起来像理论概念但在高并发服务里非常真实。定义很直白多个线程在竞争同一把锁时锁总会被某个或某几个线程抢走而另一些线程虽然一直处于可运行状态却始终轮不到执行。这和死锁完全不同。死锁的双方是“谁也动不了”而饥饿只是“一方一直能拿到资源另一方一直被冷落”系统整体还在运行被冷落的那一方业务迟迟没有进展。最容易制造锁饥饿的场景是非公平锁加短临界区。以Java的ReentrantLock为例默认是非公平模式。它允许新到达的线程在锁释放的瞬间直接“插队”而不是按照先来后到的顺序排队。设想一个频率极高的读请求场景读线程每秒请求锁上千次写线程每次拿到锁后需要做些计算耗时略长把锁释放时新的读线程已经等在锁门口。由于非公平锁允许新线程直接抢占这个写线程就被反复挤到队尾排队越长越难轮到它最后导致写操作几秒甚至几十秒都完成不了一次。如果换成公平锁也就是等待时间最长的线程优先获取锁写线程虽然还是会等待但至少不会无限期被挤掉。公平锁的实现代价是吞吐量稍有下降因为不该插队的线程不允许插队排队线程被迫唤醒上下文切换多一些。但在对延迟敏感的写场景里公平性往往比那一点吞吐交换更值得。锁饥饿并不只在Java里出现。任何带锁的地方只要调度策略允许抢占、恢复、优先级反转就有饥饿风险。数据库行锁竞争、分布式锁重试、线程池里的任务调度都可能产生类似的“少数线程永远抢不到”的现象。解决锁饥饿的核心思路是从策略上给饥饿方一个最低保障。常用手段包括改公平锁使用读写锁时开启写优先模式给任务设置超时重新排队或者干脆把竞争频繁的临界区拆细降低锁竞争烈度。还有一招是在线程池中保证提交顺序避免后来任务总是插到队首。失败模式表现关键词典型触发条件死锁所有相关线程阻塞在等待锁等待环多把锁嵌套且顺序不一致活锁线程反复让出锁但无进展谦让循环冲突后重试策略过于统一锁饥饿部分线程长期拿不到锁不公平调度新线程不断插队抢占这张对照表建议存一下。线上遇到“卡死”类问题先按三个方向套症状如果所有线程都卡在锁等待先怀疑死锁如果系统很忙但任务零进展怀疑活锁如果任务有进展但特定任务长期不成功怀疑饥饿。5. 实操线上出了并发问题怎么快速定位5.1 线程转储与死锁定位处理死锁问题最标准的第一步是拿线程转储thread dump。以JVM生态为例直接使用JDK自带的线程转储工具强制快照出所有线程当时的栈信息和锁状态。输出里会有一段类似这样的描述Found one Java-level deadlock: Thread-1: waiting to lock 0x00000000ffb3b920 (a java.lang.Object), which is held by Thread-0 Thread-0: waiting to lock 0x00000000ffb3b930 (a java.lang.Object), which is held by Thread-1这段信息直接告诉你谁持有什么、谁在等什么。看到“Found one Java-level deadlock”这个关键字基本就是铁证了。拿到之后重点是找到两个锁对应的代码位置顺着栈帧回到源码检查这两段代码的加锁顺序。绝大多数死锁都是因为同名锁在不同模块里获取顺序不统一导致的。Python环境里可以用faulthandler的dump_traceback_later机制在进程卡死时打印所有线程栈。即使没有这个机制也可以用调试工具在系统层挂上进程查看操作系统线程栈。无论哪一种目标都一样把“谁在等谁”的依赖环画出来。拿到转储后有个经验技巧不要只看锁的地址还要看栈帧的方法名。很多时候死锁是三层甚至四层嵌套导致的表面上是A方法在等实际上是被B、C方法层层调出来的。顺着完整的栈帧从下往上读才能还原真实的持锁路径。5.2 指标观察和常见问题速查线程转储能定位死锁但活锁和锁饥饿不是靠一次转储就能看出来的它们的特征是动态的要靠指标观测。活锁的典型特征是系统CPU占用率可能很高业务指标却纹丝不动。观察线程状态时会发现线程不断在RUNNABLE和TIME_WAITING之间切换但业务计数器停止增长。这时候需要再抓两次线程转储做对比如果两次抓取之间同一个线程的栈帧文本几乎一样不同线程之间还在来回变化那就很可能是活锁。锁饥饿判断起来更隐蔽。最直接的方式是看业务侧的延迟分布。如果某个任务的平均时延突然暴涨但系统整体吞吐没有明显下降那就值得怀疑是饥饿而不是死锁。抓转储时如果看到某个线程长期处于BLOCKED状态而其他线程频繁地成功获取同一把锁连续抓几次都是同一个线程被堵在门外基本就是饥饿没跑。按症状套工具大概可以整理成下面这张速查表症状可能原因先做什么进程存活但请求全部超时死锁抓线程转储找deadlock关键字CPU高但业务无进展活锁连续抓两次转储对比栈是否循环单任务长时间延迟锁饥饿抓多次转储确认同一线程反复被阻塞线程数飙升但空闲率低锁粒度太粗/上下文切换过多看上下文切换指标核对临界区代码上下文切换是个非常值得盯的指标。在类Unix系统上用通用性能工具观察进程的上下文切换次数。如果切换频率高到离谱比如每秒几十万次即使没有死锁系统性能也会被调度开销拖垮。上下文切换的暴涨往往和锁粒度细碎无关反而更像是锁竞争太激烈每个线程都抢不到锁只能扑腾一下然后被调度器换走。预防始终大于排查。写并发代码时养成几个小习惯能减少大部分低级问题第一能别自己管理锁就尽量别管理优先用线程安全的队列、并发容器和现成的同步工具。第二必须管理多把锁时强制锁排序并且在代码注释里写清楚“全局锁顺序A - B - C”。第三任何阻塞型获取都要设计超时机制绝对不能无条件的无限等待。第四代码评审时把“如果一个线程持有A锁去获取B锁会不会有别的线程同时持有B锁去获取A锁”当成固定提问这句提问曾经帮我挡掉过好几次线上事故。早年在做内部订单系统的维护工作时我亲眼见过一次因为锁排序不一致引发的活锁两个线程在高峰期互相谦让了很久业务量明明不高服务却像死机一样没有任何响应。那次排查到最后改掉的只是十几行代码但也说明一个道理——并发的坑从不因为你用的是高级语言就自动消失理解了执行单元的成本差异和失败模式你才有能力在事故冒头的第一个小时就判断出方向。