进程、线程、协程,这三个词大概是操作系统面试里出场率最高的概念。可我能说句实话吗:能把定义背熟的人很多,真到线上出问题还能稳住的,靠的是对调度和切换机制的理解。这篇是操作系统系列第三十六篇,也是“进程、线程、协程”专题的第二篇。上一篇把基础概念铺了一遍,这篇我直接往深了写——进程和线程到底怎么被管理的、协程为什么能那么轻、同步与死锁有哪些实战坑、生产环境里到底该怎么选型。全文不按照教科书思路来,就是围绕实际开发和排障中必须搞懂的那些细节展开,适合正在学操作系统的学生,也适合想补足并发底子的工程师。
1. 三个概念的本质区别:从调度单位看操作系统
1.1 进程是资源容器,线程是执行单元
进程不只是“一个正在运行的程序”。从操作系统的角度看,进程是资源分配的基本单位:它拥有一套完整的私有地址空间、页表、文件描述符表、信号处理函数、环境变量、工作目录等。而线程是CPU调度的基本单位:一个进程内的多个线程共享进程的地址空间和大部分资源,但各自有一套独立的寄存器和栈。
这个区分解释了为什么进程和线程的“重量”不同。创建进程时,操作系统要分配地址空间、初始化页表、复制文件描述符表,这些操作都涉及内核数据结构的管理;而创建线程主要就是分配一块栈空间、初始化线程控制块,成本低一个数量级。进程间通信更要跨内核边界,比如管道、消息队列、共享内存;而同进程的线程之间天然共享堆内存和全局变量,通信成本接近零。
很多新手会混淆的一点是“一个进程崩溃会不会影响其他进程”。进程有地址空间隔离,一个进程访问非法内存只会触发它自己的段错误,不会污染别的进程——这也是为什么浏览器、多租户服务要用多进程隔离,而不是一个进程开一堆线程。但线程没有这层保护,一个线程写坏了堆内存,整个进程的所有线程都会遭殃。
1.2 协程:用户态调度单元,协作式运行的轻量执行流
协程和进程、线程不在一个维度上。进程和线程的创建、切换、销毁,都由操作系统内核负责,这是抢占式调度——内核随时可能把一个正在运行的线程切走。而协程的调度完全在用户态完成,运行中的协程主动让出(yield)或者被调度器挂起,操作系统根本感知不到协程的存在。
正因为协程不由内核调度,它的创建成本极低。以Go的goroutine为例,初始栈可能只有几KB,切换只是函数调用级别的寄存器保存和恢复,所以一台普通服务器开几十万甚至上百万个goroutine都很常见。换成一个内核线程,默认栈通常8MB,开1万个线程就意味着约80GB的虚拟内存空间占用(虽然按需提交物理内存,但地址空间和内核数据结构都是实打实的开销),性能完全不是一个量级。
这里有个容易踩的坑:因为协程是协作式的,如果一个协程长时间占用CPU不主动让出,轻则拖慢同一线程上的其他协程,重则让整个调度器“卡死”。所以写协程代码时,耗时计算要主动分片让出,阻塞IO要走异步接口,这两条纪律必须刻在脑子里。
1.3 一张表看穿三者的差别
把关键维度放在一起对比会更直观:
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | 用户态程序/运行时 |
| 调度方式 | 抢占式 | 抢占式 | 协作式 |
| 地址空间 | 独立 | 共享进程空间 | 共享所属线程空间 |
| 切换成本 | 高 | 中 | 极低 |
| 隔离性 | 强 | 弱 | 无 |
| 创建成本 | 高 | 中 | 低 |
| 并发规模 | 百~千 | 千~万 | 十万~百万 |
注意表格里的切换成本是一个数量级概念,具体数值因平台差异很大,别拿它当精确指标。我见过有人拿一次协程切换只有几十纳秒来吹捧某种语言,实际上协程没有IO时切换确实快,但一旦涉及系统调用,成本就回到了内核态,该花的时间一分不少。真正的优势在于:协程可以在等待IO时把CPU让给别的协程,而不是让整个线程阻塞。
这一节讲清楚了“是什么”,下一节进入操作系统内部,看看进程和线程到底是怎么被管理的。
2. 进程与线程的管理细节:PCB、TCB与三种线程模型
2.1 PCB和TCB:操作系统凭什么管理它们
操作系统能调度进程和线程,靠的是每个进程和线程在内核中都有一个对应的控制块。进程对应的是进程控制块(PCB),里面记录着:进程ID、进程状态(就绪、运行、阻塞等)、程序计数器、寄存器保存区、调度优先级和调度队列信息、内存管理信息(页表指针、段表指针)、打开的文件表引用、信号处理信息、CPU使用统计等。线程对应线程控制块(TCB),结构类似,但只保存执行上下文相关的内容,资源相关的字段直接指向所属进程。
进程控制块和线程控制块不是孤零零的结构体,它们会被组织成链表、哈希表或树,挂在内核的调度队列里。调度器选择下一个要运行的执行流时,做的就是遍历就绪队列、比较优先级和时间片、切换上下文这几件事。
这里讲一个实际中会影响排查思路的点:进程状态里的“阻塞”和“睡眠”有时候会让新手困惑。进程可能因为等IO、等锁、等信号进入睡眠状态,此时CPU并不会分配给它,但在top里看到的还是“S”状态。了解PCB里的状态机,才能在排查“CPU不高但服务没响应”这类问题时想到:问题很可能出在大量线程阻塞在某个锁或IO上,而不是CPU不够。
2.2 三种线程模型:1:1、N:1与N:M的取舍
线程模型是操作系统的经典设计决策,直接决定了并发性能的上限和实现复杂度。
第一种是用户级线程(N:1模型)。线程的创建、调度、切换都在用户态的一个线程库中完成,内核看到的还是单个进程。优点是切换完全不进内核,非常快;缺点是如果某个用户线程发起阻塞系统调用,整个进程都会被阻塞,因为内核根本不知道还有别的线程可以运行。这种模型早期在纯用户态线程库中比较常见,现在已经很少单独使用了。
第二种是内核级线程(1:1模型),也是Linux上pthread的实际实现。每个用户线程都对应一个内核线程,操作系统直接调度它们,多核利用率高,一个线程阻塞不影响同进程的其它线程。缺点是每次切换都要陷入内核态,成本较高;而且每个线程都有不小的内核资源开销。
第三种是混合模型(N:M模型),多个用户级线程映射到较少数量的内核线程上,由用户态调度器在用户线程间切换,内核只负责调度内核线程。理论上既保留了用户态切换快的优点,又能利用多核,但实现复杂,调度器的决策稍有不慎就会引入性能回退。实际产品的选择通常是:Linux线程走1:1,协程运行库走N:M的变体(把一个协程调度器绑到若干线程上)。
我曾调试过一个奇怪的性能问题:替换了线程库之后吞吐反而下降,查到最后发现是混合模型下的用户态调度器在锁竞争激烈时频繁让出,又无法感知内核线程的调度状态,导致全局调度失衡。这件事给我的教训是:线程模型的优劣不能只看切换成本,还要看应用的实际阻塞特征。
2.3 线程栈与线程资源:8MB意味着什么
每个线程都有独立的栈空间,Linux上线程栈默认大小是8MB。注意这只是虚拟地址空间的映射大小,实际物理内存会根据栈的使用情况按页分配,所以“开1万线程就占80GB内存”这种说法并不准确。但它确实占用了80GB的虚拟地址空间,而且每个线程还要分配独立的TCB、内核栈、信号掩码、线程局部存储等资源。
64位系统虚拟地址空间大,开几万线程不至于直接被拒;但32位系统下,进程的虚拟地址空间通常只有3GB左右,默认8MB的线程栈意味着最多也就能开几百个线程,再往上就会遇到“无法创建线程”的错误。这是我早期在32位环境排查线上问题时踩过的大坑。
协程之所以能开得多,根本原因也在栈:协程初始栈只有几KB,而且可以按需增长(比如Go初始栈2KB,栈满后通过复制或分段方式扩容)。这里提醒一句,使用有栈协程时如果递归写得不小心,栈增长带来的拷贝开销和GC压力会很明显,性能反而比线程还差,后面第4节会细讲。
3. 上下文切换的真实代价:进程、线程与协程差在哪
3.1 一次进程切换远不止“保存寄存器”
很多人以为上下文切换就是“保存寄存器、恢复寄存器”,听起来太轻松了。实际上,一次完整的进程切换要做的远不止这些:把当前进程的寄存器值保存到PCB;切换到内核态运行调度器代码;选择下一个运行的进程;恢复新进程的寄存器值;切换地址空间,要更新页表基址寄存器(如CR3),刷新TLB(进程切换时TLB条目基本全部失效);如果是跨核调度,还有一堆缓存同步的问题。光是TLB失效,就会让后续一段时间的内存访问全部变成慢速页表遍历,这是进程切换“贵”的核心来源之一。
至于进程切换的具体开销,不同硬件上差异很大,但远比线程切换贵。线程切换不需要切换地址空间,所以TLB不需要全部失效,这是线程比进程切换快的最主要原因。但线程切换仍要陷入内核态,保存和恢复寄存器、栈指针,还要经过调度器的排队,整体开销在微秒量级。
3.2 协程切换为什么便宜:大多数时候不动内核
协程切换在用户态完成:保存当前协程的执行现场(寄存器、栈指针、程序计数器),恢复目标协程的现场,然后就继续执行了,完全不经过系统调用和内核调度器。因为没有内核态切换,也就没有中断处理、调度器排队这些开销。这就是协程切换成本低的根本原因。
但这里有一个容易忽视的隐含前提:协程只有在不使用阻塞系统调用时才能享受这个红利。一旦协程调用read、write这类可能阻塞的系统调用,它所在的线程就会进入内核等待,线程被挂起,其他协程也没法运行。所以现代的协程运行库都要求把阻塞IO替换成异步IO(比如epoll、io_uring),配合事件循环让出CPU。换句话说,协程的高并发能力不是凭空来的,是“异步IO + 用户态调度”这组搭配共同实现的。
有一个很形象的生活类比:线程切换就像你在一家公司上班,每次换岗位都要重新办入职流程,打卡换门禁(内核态切换);协程切换就像在公司内部换个工位,站起来换个椅子就行,不需要重新办手续。但这个类比有个bug——如果你去上厕所要出楼门(阻塞IO),那整个楼的人都要等你回来,所以聪明人都会把上厕所安排在不用出楼的地方(异步接口)。
3.3 压测视角:切换成本如何暴露为性能瓶颈
我做过一个简单的压测对比:同一台机器上,用一个多线程模型和一个协程模型各实现一个简单的HTTP转发服务,压力请求都设计成大量短连接。结果线程模型在2000并发连接时CPU占用已经接近100%,而协程模型撑到2万并发时CPU占用才60%左右。差异主要来自两部分:创建线程的系统调用开销,以及频繁阻塞导致的上下文切换开销。
压测时如果观察vmstat里的cs(context switches)列,会看到线程模型每秒切换次数高出几个数量级。这个数据很有用——当线上服务性能上不去时,先看cs和CPU的比值,如果每秒上下文切换好几万次,几乎可以断定瓶颈在调度而不在业务逻辑。这个经验帮我定位过不止一次问题。
4. 协程运行机制深挖:栈、事件循环与调度器
4.1 有栈协程与无栈协程,到底差在哪
协程的实现大致分成两类:有栈协程和无栈协程。
有栈协程的典型代表是Go的goroutine和Lua的协程。每个协程都保留独立的调用栈,切换时保存和恢复整个执行链路(也就是寄存器和栈指针)。好处是可以随时让出,也可以随时从让出点继续执行,代码写起来和同步代码几乎没有区别;坏处是栈内存开销比无栈大,切换时可能涉及栈数据的拷贝或指针调整。
无栈协程的典型代表是JavaScript的async/await、C#的async/await、Python的asyncio。这类“协程”本质上是一个状态机:编译器把async函数拆成多个状态,每个await点是一个状态转换,函数所在线程的栈上并不保留协程自己的完整调用栈。好处是内存占用极小,切换就是状态机的跳转;坏处是协程自己不能主动让出,只能在遇到await挂起点时暂停,而且不能用递归或者深层函数调用构造一个很长的事件链。
选择哪种实现,更多是语言运行时决定的事。在你写业务代码时不需要关心这个,但排查问题时会有影响:无栈协程的异常堆栈往往被压缩成一个状态机转换,很难看出原来的调用关系;而有栈协程可以保留相对完整的栈回溯,调试体验好很多。
4.2 事件循环与调度器:谁在背后安排协程
协程虽然不由内核调度,但总得有个“管理员”来决定什么时候运行哪个协程。这个管理员就是事件循环或协程调度器。
最经典的架构是:一个线程运行一个事件循环,事件循环维护一个“就绪队列”和一组“等待事件”。当某个协程发起异步IO时,它把IO请求注册到事件监听器(Linux上就是epoll),然后让出CPU;IO完成后,事件循环发现对应的事件就绪,就把这个协程重新放回就绪队列。协程之间用yield主动让出,让出后调度器选择下一个就绪协程继续执行。
以Go为例,它的调度器是GMP模型:G是goroutine,M是操作系统线程,P是处理器(逻辑CPU)。P的数量默认等于CPU核数,每个P维护一个本地运行队列。M必须持有一个P才能运行G。当某个G阻塞在系统调用上时,Go会让M释放P,唤醒或新建一个M接管这个P继续运行其他G,从而避免整个线程被拖住。调度器还会从其他P的本地队列偷任务来执行,这就是work stealing(工作窃取)。这个模型就是Go能在并发性能和编程体验上取得平衡的核心。
理解调度器模型对性能调优有直接帮助。比如限制GOMAXPROCS会直接决定同时运行的G数量;如果程序里充斥着会阻塞M的系统调用,调度器就要频繁地销毁唤醒M,效率反而变差。所以Go写阻塞IO逻辑时还是应该用标准库封装的异步版本,不要随便直接调用syscall。
4.3 手写一个极简协程调度器
用C的ucontext库或者汇编可以实现真正的有栈协程,但篇幅有限,我用Python的生成器来展示协作式调度的核心结构。生产级调度器远比这复杂,但这个例子能清楚说明调度器的骨架。
import time from collections import deque class SimpleScheduler: def __init__(self): self.ready = deque() self.last_id = 0 def new(self, coro): self.last_id += 1 self.ready.append((self.last_id, coro)) return self.last_id def run(self): while self.ready: cid, coro = self.ready.popleft() try: # 运行协程直到下一个yield点 result = next(coro) if result == "sleep": # 模拟挂起一段时间后重新入队 time.sleep(0.1) self.ready.append((cid, coro)) else: self.ready.append((cid, coro)) except StopIteration: pass def worker(name, step): for i in range(3): print(f"{name} 运行到第 {i} 步") yield "sleep" sched = SimpleScheduler() sched.new(worker("任务A", 1)) sched.new(worker("任务B", 2)) sched.run()这段代码的运行结果会体现协作调度的特点:任务A运行一步,让出,任务B运行一步,让出,然后回到任务A。两个任务交替推进,谁也不能独占CPU。和内核线程的抢占式调度相比,这里的切换点都是显式的yield,调度器完全信任协程会主动让出。
真实场景里,调度器还要处理优先级、超时唤醒、阻塞IO注册、取消任务、异常传播,复杂度高得多。但核心骨架就是:维护一个就绪队列,循环取任务执行,遇到挂起点就保存现场并让出,遇到完成就移除。
5. 同步与竞争:锁、条件变量与无锁编程的底层逻辑
5.1 锁锁住的是共享资源的访问权
先纠正一个常见误解:锁锁住的不是“代码”,而是“对共享资源的访问权”。之所以要用锁,是因为多个执行流并发读写同一个共享变量时,可能产生竞态条件。比如两个线程同时对count执行自增,底层是读、加、写三步,两步之间被打断就会丢更新。
互斥锁(mutex)是最基本的同步原语:同一时刻只有一个线程能持有锁,其他线程试图获取时会阻塞。自旋锁则是不阻塞、原地循环等待锁释放,适合临界区极短的场景。Linux的互斥锁底层实现已经做了优化:锁空闲时直接原子指令获取;有竞争时先用自旋等待一小段时间,再进入睡眠,减少线程睡眠唤醒的开销。这就是为什么很多时候互斥锁性能并不差。
读写锁(rwlock)是另一种常用锁,允许多个读者同时持有,但写者和读者、写者和写者互斥。适合读多写少的场景,比如配置表、路由表。但使用读写锁要小心写者饥饿问题:如果读者一直到来,写者可能长时间获得不了锁。有些实现(如pthread_rwlock)可选写者优先模式来规避。
5.2 条件变量与信号量:等待和通知的哲学
互斥锁解决了“互斥”问题,但不解决“等待”问题。经典的生产者-消费者模型里,消费者要等队列里有数据再取,如果只是用锁轮询检查,CPU浪费严重。条件变量(condition variable)就是用来等“某个条件成立”的。
条件变量的标准用法几乎总是配一个互斥锁:
pthread_mutex_lock(&mtx); while (queue_empty(&q)) { pthread_cond_wait(&cond, &mtx); } // 此时队列非空,取出数据 data = pop(&q); pthread_mutex_unlock(&mtx);提示:等待条件必须用while循环而不是if。因为wait返回后条件不一定真的成立,可能是虚假唤醒,或者在等待期间其他线程抢先消费了数据。用if判断的偶发bug极难排查。
信号量(semaphore)解决的是“资源数量”问题。初始化为N的信号量,允许多达N个线程同时通过,每通过一个信号量减一,释放时加一。它既能当互斥锁用(N=1),也能当计数门闩用。不过实际工程里,信号量用的场景比条件变量少得多,因为语义不够明确,排查问题时难以推断状态。能用互斥锁+条件变量表达的,优先用后者,代码意图更清晰。
5.3 无锁编程:什么时候能用,什么时候千万别用
无锁编程通过原子操作(如CAS,比较并交换)直接操作共享数据,避免加锁阻塞。原子操作由CPU硬件保证,多核环境下通过缓存一致性协议协调。
无锁编程的最大诱饵是“性能好、不死锁”,但坑也深。CAS的ABA问题(值从A变成B再变回A,CAS会误以为没变)、内存序问题(不同CPU核上指令重排导致看到不一致的数据)、无锁数据结构的内存回收难题,每一个都能让经验丰富的工程师头疼。商业级无锁队列的代码一般人都读不下来,就是因为这些细节。
我的建议很朴素:能用锁的地方先用锁,等 profiling 证明锁是瓶颈,再考虑读写锁、原子变量、分段锁,最后才考虑无锁数据结构。压测里我见过很多“为了无锁而无锁”的代码,性能并没有比mutex好多少,代码却复杂到没人敢动。锁本身不是性能杀手,锁竞争才是。一个没有竞争或竞争很短的锁,成本可以低到忽略不计。优化重点永远是缩短临界区、降低竞争频率,而不是一上来就上无锁。
6. 死锁:识别、排查与工程化解
6.1 死锁四条件在工程代码里长什么样
死锁发生的四个必要条件:互斥、持有并等待、不可抢占、循环等待。任何一条不成立,死锁就不会发生。在工程代码里,最常见的死锁场景是多个锁的加锁顺序不一致。
举一个经典的例子,线程A持有锁L1等待锁L2,线程B持有锁L2等待锁L1,两者谁都不让,就形成了循环等待。另一个常见场景是重入型死锁:线程在持有一个非递归锁的情况下再次尝试获取同一把锁,直接把自己卡死。排查时如果发现线程栈停在pthread_mutex_lock而且锁的owner就是自己,往往就是这个原因。
还有一类隐蔽的死锁来自“锁 + 条件变量”的错误使用:持锁后在等待条件成立,而让条件成立的那一方恰恰需要同一把锁才能推进,两边互相等待。这种死锁不会立刻暴露,往往在高并发下偶发,极其难查。
6.2 用工具定位死锁:从gdb到转储分析
Linux上排查C/C++死锁,核心操作是:先找到卡住的线程,再看线程栈,最后分析锁的持有关系。
常见步骤:
- gdb attach到进程,执行thread apply all bt,看所有线程栈。
- 如果发现多个线程阻塞在pthread_mutex_lock或futex系统调用上,记下它们等待的锁地址。
- 通过锁地址找到持有该锁的线程,确认是否形成循环等待环。
- 如果进程没有响应,可以用gcore生成core dump,离线分析,避免线上attach影响业务。
Java的jstack命令输出会直接打印“Found one deadlock”或者显示线程等待的锁持有方,比如“waiting for monitor”“waiting to lock <0x...>”,分析思路类似。Go的runtime还内置了死锁检测,程序发生全局死锁时会打印所有goroutine栈并直接panic,定位非常快。
6.3 工程上规避死锁的几条硬规矩
经验法则主要有几条:
- 锁顺序:如果必须获取多个锁,所有线程都按同一个全局顺序加锁,循环等待就断了。
- 减少持锁时间:临界区内不做IO、不调用外部服务、不执行可能阻塞的代码,把持锁时间控制在微秒级。
- trylock + 超时:用带超时的锁获取接口,获取不到就释放已有锁、重试,避免无限等待。但注意重试本身可能引入活锁,需要加上随机退避。
- 合并锁/拆分锁:有些场景可以把多个锁合并成一个,减少锁间交互;有些场景恰恰相反,需要把一个热点锁拆成多个分段锁,减少竞争。
- 专门写死锁检测器:如果系统不能承受死锁,可以对锁调用做封装,记录每个线程持有锁的路径,周期性检测是否有循环等待。
这一节的内容在实际生产里价值很大,因为死锁不是“写错代码才会遇到”,高并发下即使逻辑正确,只要锁顺序和超时设计有隐患,就会以极低概率触发,线上事故往往出现在最忙的时候。
7. 真实项目里的选型:进程、线程还是协程
7.1 三类典型场景的选型逻辑
经常有人问“到底该用线程还是协程”,其实没有银弹,只有约束条件。
第一类场景是CPU密集型任务,比如视频转码、科学计算、图像处理。这类任务几乎不阻塞,跑满CPU就是目标。此时线程数是关键参数,通常设置成CPU核心数附近,协程的优势反而用不上。多进程并行也是常见选项,尤其是要利用多机时。
第二类场景是IO密集型任务,比如网关、消息推送、Web API。这类任务大量时间在等待网络和磁盘,并发单位并不占用CPU。传统用线程池,每个线程同时只处理一个请求,连接数稍微大一点线程就吃紧;改用协程加异步IO后,一个线程就能管理成千上万个连接,成本大幅下降。这是协程最闪耀的应用场景。
第三类场景是强隔离需求,比如多租户服务、插件运行环境。即使线程再方便,只要一个模块可能拖垮整个进程,就应该用进程隔离。浏览器架构就是典型:渲染进程崩了,浏览器主进程和其他标签页不受影响。
7.2 一个网关项目的选型复盘
我复盘过一个具体项目的选型过程,当时要做一个消息推送网关,目标要支撑10万长连接。
第一版用了多线程模型:每个连接分一个线程,连接数一上来就发现线程栈占用的虚拟内存和上下文切换太多,CPU花在调度上的时间占比高得吓人。第二版改成固定线程池加epoll事件驱动,性能好了不少,但业务代码要改成状态机式写法,不同事件的回调拆得七零八落,开发效率和可维护性很差。第三版用协程模型:业务代码用同步写法,底层由运行时完成异步化,既保住了10万长连接的支撑能力,代码又可读。
这个项目让我意识到一个判断原则:选型的本质是在性能、开发效率、运维复杂度之间做权衡。如果连接数只有几千,线程池加事件驱动完全够用,引入协程还多一套运行时的复杂度。先量化目标(并发量、延迟、吞吐),再评估团队对运行时的熟悉度,最后才定方案,并且留好切换余地。
7.3 混合架构才是大型系统的常态
现实里大型系统很少只用一种并发模型。常见形态是:主进程负责任务调度和故障隔离,内部用多个工作线程分别承担不同业务模块,每个工作线程内部再跑一组协程处理高并发的IO请求。
这有点像一个公司:进程是独立的子公司(资产隔离),线程是子公司的部门(各司其职),协程是部门里的员工(手上同时处理好几件事)。分层治理,各司其长。
混合架构也带来新的挑战:跨进程通信的序列化成本、跨线程共享数据的锁成本、协程调度器与线程池之间的亲缘性配置,都成了新的性能敏感点。每加一层,排查问题的复杂度就上一个台阶。我的建议是:除非性能压力明确,否则尽量简化模型,并发模型越简单,线上问题越少。
8. 常见并发问题排查实录
8.1 线程数暴涨,服务大面积超时
现象:一个服务原来200并发很稳,某天流量上来后线程数冲到几千,CPU不高但请求大面积超时。
排查路径:先看线程栈分布(jstack),发现大量线程阻塞在远程调用的等待上。进一步看调用链,发现是依赖的一个外部服务响应变慢,调用方配置的超时时间又太长,导致线程被卡住。线程数不断增长的新请求又不断涌入,最终线程池撑满,服务彻底不可用。
这类问题本质是线程模型下的“阻塞传染”。解决思路除了依赖方恢复之外,还要在代码层面改造:外部调用加严格超时、引入熔断降级、用有界线程池限制并发量。对比之下,如果当初用协程模型,阻塞IO不会占满线程,同一个问题的表象会轻很多,但也别指望协程能解决依赖变慢的本质问题——该做的超时和熔断一样都不能少。
8.2 goroutine只增不减,内存缓慢爬升
现象:一个Go服务运行几天后内存涨得很高,服务变慢,重启后恢复正常,过几天又涨回去。
排查时用Go自带的pprof,先看goroutine数量,再用pprof goroutine dump看每个goroutine的栈。常见泄漏模式是:goroutine等在一个永远不会到达的channel消息上,典型的场景是select里只有send或recv分支,没有超时分支;或者启动了goroutine但外层函数提前return,channel无人消费。修复方法也比较标准:所有channel的使用要考虑关闭时机和超时,启动goroutine的时候就要想清楚它什么时候结束。
对于Python的asyncio也有类似问题:Task挂着不cancel,资源永远不释放。异步编程里“谁创建谁负责回收”这个原则必须贯彻到底。
8.3 压测吞吐卡死,锁竞争白热化
现象:对接口做压测,吞吐量到某个值后无论怎么加压都不再提升,CPU消耗却一直上涨。
用perf或火焰图观察,很容易看到热点集中在锁的获取与释放上。再深入看,是某个全局计数器或热点的共享结构成了瓶颈。优化路径通常是:
- 先用原子变量替换纯计数的锁,成本立降;
- 再用分段计数或线程本地计数合并,减少共享写;
- 复杂数据结构则考虑读写锁、copy-on-write、甚至分片索引。
有一类优化是降低锁粒度:把“一个大锁保护所有数据”拆成“多个小锁保护不同数据区间”。比如一个哈希表的bucket锁,不同bucket的读写就不再互相阻塞。这个改造收益很大,但要注意死锁风险——两个线程需要同时持有多个bucket锁时,必须保持一致的加锁顺序,否则第6节说的死锁就找上门了。
我个人在实际排障里最深的体会是:并发问题很少靠猜,压测数据、线程栈、锁竞争三样看明白,问题基本就浮出水面了。如果看完这篇能让你在遇到线上抖动时多一条排查思路,或者在设计高并发系统时少走一次弯路,那我花这么多篇幅也值了。