并发编程,十个新手九个栽在“概念混搭”上。今天要拆的就是六个高频词:线程、进程、纤程、协程、管程,外加三个容易混淆的失败模式——死锁、活锁、锁饥饿。
很多同学都能背出“进程是资源分配单位,线程是调度单位”这句话,但真到排查线上故障时,还是挠头:为什么一堆线程卡住时进程还活着?为什么协程能扛住上万个连接而线程早就栈溢出?为什么明明加了锁还能跑成死锁?这些问题如果只记住教科书定义,根本没法落地。这篇文章不打算重抄概念表,而是把六类原语按“数量、调度、成本、语义”重新摆一轮,再用工程现场里最常见的坑把死锁、活锁、锁饥饿讲透。适合已经有基本编程经验、正在啃并发、或者准备系统设计面试的读者,读完你能对着一个卡死进程说出它到底卡在哪一类问题上,也能在写代码时提前把这三类坑从设计上避开。
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.Queue<Integer> 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(target=task_a).start() threading.Thread(target=task_b).start()你运行这段脚本,大概率会看到两个输出打印出来,然后程序就一直卡着不动。task_a拿到了lock_a,task_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(timeout=0.001): time.sleep(0.001) if lock_b.acquire(timeout=0.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(timeout=0.001): time.sleep(0.001) if lock_a.acquire(timeout=0.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(target=worker_a).start() threading.Thread(target=worker_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锁”当成固定提问,这句提问曾经帮我挡掉过好几次线上事故。
早年在做内部订单系统的维护工作时,我亲眼见过一次因为锁排序不一致引发的活锁:两个线程在高峰期互相谦让了很久,业务量明明不高,服务却像死机一样没有任何响应。那次排查到最后,改掉的只是十几行代码,但也说明一个道理——并发的坑从不因为你用的是高级语言就自动消失,理解了执行单元的成本差异和失败模式,你才有能力在事故冒头的第一个小时就判断出方向。