前段时间接手一个日志归并的小模块,单线程跑一批任务要四十多分钟,加机器没有意义,于是有人提议"上多线程"。改成十六个线程之后,耗时不但没降,反而涨到了五十多分钟,CPU 使用率看着是上去了,吞吐量纹丝不动。最后定位到问题出在共享队列上的一把大锁,十六个线程里绝大多数时间都在排队等锁,真正干活的时间少得可怜。这类事情我遇到过不止一次,所以想认真聊聊多线程模型这件事——不是讲 API 怎么调用,而是讲清楚"线程到底由谁创建、由谁调度、和内核里那个真正能被 CPU 执行的单元之间是什么对应关系"。这个底层关系决定了你的程序是能跑满八个核,还是只能在一个核上空转;决定了你开两千个线程是轻松愉快,还是直接把内存撑爆。
这篇内容偏底层,但我会尽量用具体的数字和场景来讲,适合已经会写多线程代码、但总觉得"性能上不去又说不出为什么"的同学,也适合刚开始接触并发、想把概念一次理顺的新手。看完之后,你至少应该能做到:拿到一个任务,先判断它属于哪类负载,再决定用线程池、协程还是事件循环,以及线程数大概该配多少。
1. 从一次吞吐量不升反降的事故切入,看清线程模型到底在管什么
先把那次事故的复盘讲完,因为它几乎把线程模型的几个关键约束全暴露了。那个模块的逻辑很简单:从磁盘读一批文件,解析每一行,按 key 做聚合,最后写出去。单线程时代瓶颈很明显在磁盘 IO,所以第一反应是多线程并行读。改造方案是开一个固定大小十六的线程池,所有任务往一个共享的阻塞队列里塞,工作线程从队列里取。听起来毫无问题,实测却更慢了。
原因拆开有三层。第一层是锁争用,队列本身是同步的,取任务这个动作要加锁,十六个线程抢一把锁,临界区虽然短,但抢的频次太高,锁的交接成本直接把收益吃掉了。第二层是 IO 并没有真的并行,那块盘是机械盘,随机读的并行度提升极其有限,多线程只是让磁头来回跳得更欢。第三层才是真正跟"模型"相关的:线程池开的是十六个,但这台机器只有八个物理核,十六个线程意味着内核调度器要在它们之间来回切换,每个线程的时间片被切得很碎,缓存局部性变差,切换本身还要消耗 CPU。
1.1 线程模型说的其实是"三层之间的映射关系"
很多人把"线程模型"理解成"用 Thread 还是用线程池",这差得有点远。真正的线程模型,描述的是三个层次之间的对应关系。
最上面一层是你写的代码里那个"线程"对象,语言标准库或者运行时提供的,你 new 它、start 它、join 它。中间一层是运行时或者线程库内部的调度实体,有的运行时这一层是空的(直接透传),有的运行时在这一层做了一套完整的调度器。最下面一层是操作系统内核管理的调度单元,只有这一层的实体才会被真正分配到 CPU 时间片上。
所谓"模型",说的就是这三层怎么对齐:上面一层的一千个线程,是对应下面一层的一千个内核调度单元,还是只对应八个?如果是一千对一千,那中间层的调度工作基本交给了内核;如果是一千对八,那中间层就必须自己实现一套调度算法,决定哪一百二十五个"用户态线程"共享一个内核单元。
1.2 用包工头、工人和工单来类比这三层
我习惯这么类比:内核调度单元是"工人",数量固定,就是你的核数(超线程算两个,但别真当两个用);你代码里创建的线程是"工单";运行时的调度器是"包工头"。
一对一模型就是每张工单配一个专属工人,工单多了就得招人,招人有成本(内存、创建开销),而且工人太多之后,工头派活本身也成了负担。多对一模型是所有工单都塞给同一个工人,工人干活的时候不能被打断,一张工单卡住了所有工单都卡住。多对多模型是包工头手里有若干工人,工单池里有很多活,谁空了谁去取,但包工头得自己判断哪张工单该给谁、某张工单阻塞了要不要临时再叫一个工人顶上。
这个类比能解释很多现象。比如为什么多对一模型下"一个线程阻塞导致全部阻塞"——因为只有一个工人,他跑去等电梯了,其他活全得等着。为什么多对多模型实现起来特别复杂——因为包工头不知道工人什么时候会被外部因素打断(内核抢占),也不知道某张工单到底是真的在干活还是在傻等(阻塞式系统调用)。
1.3 判断要不要深究模型的三个信号
不是所有场景都需要关心这些。我一般看三个信号。
第一个是线程数量级。几十个线程,用哪种模型差别不大,直接上一对一最省心。上了几千个,一对一就会开始难受,光是栈空间就能吃掉几个 G,这时候必须考虑多对多或者异步模型。
第二个是阻塞比例。任务里如果充满了网络请求、数据库查询、文件读写这类等待,那"线程被阻塞时占不占着内核资源"就成了核心问题。一对一模型下阻塞一个线程就浪费一个内核调度单元,多对多模型下运行时可以把被阻塞的线程"让"出去,让别的活先跑。
第三个是切换频率。如果任务都极短(比如几毫秒就结束),那么创建和切换的成本占比会非常高,此时线程池的价值远大于线程模型本身;反之如果任务都是长任务,切换成本可以忽略,模型的选择空间就大得多。
把这三个信号想清楚,后面所有的选型其实都是水到渠成的事。
2. 三种映射模型的真实代价,别只看教科书上的优缺点
教科书上通常列出多对一、一对一、多对多三种,然后各打五十大板。但实际工程里,每种模型的代价体现在非常具体的地方,得掰开算。
2.1 多对一:创建快到离谱,但一个阻塞就全盘停摆
多对一模型下,所有用户态线程共享一个内核调度单元,线程的创建、切换、销毁全部在用户态完成,不涉及系统调用。这个成本有多低?低到可以在一秒内创建销毁上百万个"线程"——因为本质上就是分配一个结构体、切换一下栈指针和寄存器。
代价同样极端。第一,完全用不上多核。不管你有多少个核,这个模型下同一时刻只有一个线程在真正执行,CPU 密集型任务放进来就是白给。第二,阻塞是致命的。只要有一个线程执行了阻塞式系统调用,整个进程的所有线程全部停摆,因为内核眼里只有一个执行流,它去等了,就没有别人能跑。
早期的用户态线程库基本都栽在这两点上。它适合的场景非常窄:大量短小的、纯计算、彼此之间不需要等待的任务,而且必须在单核上跑。放到今天,它更适合作为一种"轻量任务调度"的思路存在,而不是主流的并发方案。
2.2 一对一:语义最直白,代价藏在内存和切换里
一对一模型是绝大多数操作系统线程实现的方式,你在代码里创建的每个线程,内核里都有一个对应的调度实体。好处是不需要解释:线程阻塞了就阻塞了,其他线程照跑;八个线程跑在八个核上就是真并行;调试器抓线程栈能直接看到每个线程在干什么。
代价主要在三个地方。栈空间:每个线程都要预留一块栈,默认大小通常在 1MB 到 8MB 之间(不同系统不同运行时差别很大),一千个线程就是 1G 起步,而且这部分是虚拟内存,虽然不一定全部实占,但页表开销和实际触碰的页是实打实的。创建成本:一次线程创建涉及内核结构分配、栈映射,量级在几十微秒,比用户态线程高两三个数量级。切换成本:线程切换要走内核,保存恢复寄存器、切换页表(如果跨进程)、可能污染各级缓存,量级在微秒级。
这些数字单看都不大,但乘上频率就吓人了。假设一次切换平均两微秒(这个值跟硬件、缓存状态强相关,只能当量级参考),如果一个系统每秒发生十万次线程切换,那就是 0.2 秒的 CPU 时间纯用于切换,占单核的 20%。而这个切换往往是"无效切换"——线程被唤醒发现没活干又睡回去,纯属浪费。
2.3 多对多:理论最优,但调度器要自己扛下所有难题
多对多模型试图两头占好:用户态线程数量可以很多(便宜),同时映射到若干个内核调度单元上(能用多核)。运行时的调度器负责把 M 个用户态线程调度到 N 个内核单元上,M 远大于 N。
听起来完美,但实现难点全在细节里。阻塞调用怎么处理?如果某个用户态线程执行了一个会阻塞的系统调用,它占着的那个内核单元就废了,运行时要能感知到这一点,并且临时"借"一个新的内核单元来顶上(通常叫 handoff,把剩下的可运行线程迁移过去)。但运行时怎么知道它阻塞了?只能靠包装——把所有可能阻塞的调用都换成非阻塞版本加事件通知,或者在调用前后打标记,这两种做法都有覆盖不全的风险。内核抢占怎么办?内核调度器随时可能把某个内核单元从你手里抢走,运行时并不知情,这会导致调度决策失准。栈怎么管理?用户态线程的栈要能动态增长,否则深递归就爆了,这需要编译器或者运行时的配合。
所以多对多模型在工程上真正落地的案例并不多,且都是"运行时自己造了一套完整机制"的重型方案。你如果自己手写一个多对多调度器,大概率会在某个边缘场景上翻车。
下面这张表是我自己整理的经验对照,重点关注"什么时候会咬你"这一列:
| 模型 | 创建成本量级 | 单进程可支撑线程数 | 多核利用 | 典型咬人场景 |
|---|---|---|---|---|
| 多对一 | 百纳秒级 | 十万级以上 | 完全不能用 | 任意一个阻塞调用 |
| 一对一 | 几十微秒 | 数千(受栈限制) | 完整 | 线程数上千后栈和切换爆炸 |
| 多对多 | 百纳秒到微秒 | 十万级以上 | 完整 | 阻塞调用未被运行时接管 |
3. 线程池开多大不是拍脑袋,从负载特征反推参数
线程池是绝大多数人实际接触到的"线程模型",它的参数配置直接决定了你落到哪种模型的实际效果上。池子开得太小,并发度不够;开得太大,等价于退化成一堆互相抢 CPU 的线程,还不如小一点。
3.1 CPU 密集型和 IO 密集型的两套算法
CPU 密集型任务的线程数,经验公式是核数加一。为什么加一?因为即使全是计算任务,也总会有偶发的缺页、系统调用导致某线程短暂停顿,多一个线程能把这个空隙填上。加太多没意义,因为可运行的线程数超过核数之后,多出来的只会增加切换。
IO 密集型任务的公式是:核数乘以(1 加等待时间除以计算时间)。举个具体的例子,八核机器,一个任务平均耗时 100 毫秒,其中 90 毫秒在等下游接口,10 毫秒在本地计算。那么等待与计算之比是 9,线程数就是 8 乘以 10,等于 80。
这个 80 只是理论上限,实际配之前还要过三道闸。第一道是下游能不能扛住,八十个并发打过去,对方要是只有二十个连接的处理能力,你只是把排队从自己这边挪到了对方那边,还可能触发限流。第二道是内存,80 个线程按每个 1MB 栈算就是 80MB,不算多,但如果你的运行时栈更大,就得重新算。第三道是连接池大小,线程数超过连接池上限的部分会全部堵在获取连接上,等于白开。
3.2 无界队列是掩盖问题的温柔陷阱
很多线程池默认用无界队列,任务提交不进去就排队,永远不拒绝。这在演示代码里很舒服,在生产里是灾难:上游流量突增时,队列会无限膨胀,内存一路涨到 OOM,而且任务在队列里等待的时间越来越长,等排到的时候可能早就超时没意义了。
我的做法是把队列设成一个明确的长度,通常是线程数的两到四倍,并且配上明确的拒绝策略。拒绝不等于丢任务,而是把压力反馈给调用方——要么调用方自己降速(背压),要么走降级逻辑。这一点很关键:队列长度本质上是一个背压信号,设成无限就等于放弃了这个信号。
拒绝策略的选择也有讲究。直接抛异常适合那些"绝对不能静默丢失"的场景,让调用方明确感知。丢弃最老的适合实时性要求高的场景,比如行情推送,旧数据没价值。调用方自己执行则是一种天然的减速阀,提交任务的线程被迫亲自干活,自然就慢下来了,但要注意它可能造成死锁(如果提交方持有锁)。我一般优先选带计数的丢弃加告警,让问题可见但不至于雪崩。
3.3 量化上下文切换,别靠感觉判断
判断线程池是否开大了,最直接的办法是看切换次数。Linux 下/proc/stat里有一行ctxt,是系统启动以来的上下文切换总数,隔几秒取两次做差就能得到切换速率。vmstat 1输出的cs列也是这个意思,同时看r列(运行队列长度),如果r长期大于核数,说明可运行线程过多,正在互相抢。
我一般会设一条经验线:每秒切换次数超过十万次,就要怀疑是不是线程开多了或者锁用错了。这个数字不是硬标准,得结合业务量看——如果你的 QPS 本来就是十万级,那切换多也正常,关键看"每次请求对应多少次切换",这个比值才能说明问题。
还有一个更隐蔽的指标是 CPU 的sy(系统态)占比。如果用户态代码没什么计算量,但系统态占用很高,大概率是切换或者系统调用过于频繁。我在排查那次日志归并事故时,就是看到sy比us还高,才意识到问题不在算法而在线程调度。
4. 各家运行时的取舍:把模型差异落到具体的坑上
同样是"多线程",不同运行时的实现差异极大,同一个写法在不同语言里的效果可能完全相反。这里挑几个最典型的讲。
4.1 全局解释器锁之下的多线程还能干什么
有些脚本语言运行时为了简化内存管理,在解释器层面加了一把全局锁,同一时刻只允许一个线程执行字节码。这意味着无论你开多少个线程,CPU 密集型任务都不会有任何加速,甚至因为切换开销而变慢。
但这把锁并不是在所有时候都持有。当线程执行阻塞式 IO(网络读写、文件操作)时,运行时会释放它,让别的线程进来跑。所以在这类运行时里,多线程的正确用途是重叠 IO 等待,而不是并行计算。这个区别非常要命:很多人写了多线程爬虫觉得效果不错,就以为多线程能加速一切,结果拿去做图像处理,性能反而下降,然后一头雾水。
要在这种运行时里做真正的并行计算,路子只有三条:开多进程(每个进程有自己的解释器实例)、把热点计算下沉到原生扩展里(在原生代码中可以主动释放锁)、或者换一个没有这个限制的运行时。我个人的偏好是第一条,多进程虽然进程间通信麻烦,但隔离性好、心智负担低。
4.2 虚拟机线程与用户态调度的回归
传统虚拟机实现的基本是一对一:每个语言层线程对应一个系统线程,栈默认大小通常在几百 KB 到 1MB。这套模型跑几千个线程就开始吃力,因为栈内存和切换成本都上去了。于是很多团队的心智模型变成"线程很贵,要省着用",进而演化出各种连接池、线程池的复杂配置。
近些年出现的方向是把多对多模型搬回来:语言层的"线程"变成廉价的用户态实体,由运行时调度到少量载体线程上。核心难点还是那个老问题——当语言层线程执行阻塞操作时,运行时必须能把它从载体线程上摘下来,让载体线程去跑别的。这套机制如果做对了,开发者就可以用最直白的"一个请求一个线程"的同步写法,同时获得接近异步模型的吞吐。
这里有个容易踩的坑:如果代码里存在"synchronized 块里做阻塞调用"或者"依赖线程本地变量做缓存"这类写法,在用户态调度下语义会发生变化。前者的后果是载体线程被长时间占住,其他语言层线程无法调度上去,整个调度器的并行度骤降;后者的后果是线程本地变量不再一一对应,因为多个语言层线程可能先后跑在同一个载体线程上。迁移时必须逐条审查这些代码。
4.3 协程调度器的核心:work stealing 与阻塞感知
有一类运行时的做法是内建一套用户态调度器,把大量轻量协程复用到少量系统线程上。它的调度队列通常是每个执行单元一个本地队列,加一个全局队列。本地队列空了就去全局队列拿,全局队列也空了就去别的执行单元的本地队列"偷"一半过来,这就是 work stealing。偷一半而不是偷一个,是为了减少偷取这个动作本身的频率。
这套机制能跑得好的前提是所有阻塞点都被运行时接管。网络读写、定时器、通道操作这些都在运行时的掌控之内,它能挂起协程并让出执行单元。但如果你调用了原生库里的阻塞函数(比如某些同步文件操作、某些 C 库的调用),运行时就感知不到了,只能靠"检测到某个执行单元长时间没响应就临时新建一个"来补救。这属于兜底手段,成本不低,所以在这类运行时里做性能优化,第一原则是避免在热点路径上调用未托管的阻塞函数。
4.4 事件循环为什么对同步阻塞零容忍
事件循环模型是把所有 IO 注册到多路复用器上,一个线程循环地取就绪事件、执行回调。它的优点是内存占用极低、切换完全在用户态、单线程无锁。缺点也非常明确:任何一段同步阻塞代码都会卡死整个循环。
我见过最典型的翻车是"在回调里做了一次同步数据库查询"。这一查,整个事件循环停了 200 毫秒,期间所有其他连接的请求全部积压。更麻烦的是这类问题很难在测试环境发现,因为测试环境并发低,积压不明显。
事件循环下判断一段代码是否安全,标准只有一个:它会不会让出控制权。CPU 密集的循环即使不阻塞,也会霸占循环,所以长计算必须切片或者丢到线程池去。这一点在写业务代码时很容易忘,尤其是加了一个"看起来很快"的加解密或者序列化操作。
5. 并发不等于并行,共享状态的坑比线程模型本身更难缠
选对了模型只解决了"跑得快"的问题,还有一半问题在"跑得对"。共享可变状态是并发编程里最深的坑,而且它跟线程模型正交——无论你选哪种模型,只要存在共享写,就得面对。
5.1 竞态、可见性和伪共享,三种不同性质的问题
竞态最好理解,多个执行流对同一数据的读改写交错执行,结果依赖调度顺序。经典的计数器自增不是原子操作,就是这一类。
可见性更隐蔽。由于各级缓存和编译器优化的存在,一个线程写进去的值,另一个线程可能长时间看不到。很多人以为加了锁就万事大吉,但只在读端加锁、写端不加锁,或者用了一个普通的布尔标记做退出信号,都可能出问题。正确做法是让这个标记具备明确的同步语义(各种语言里都有对应的机制,比如声明为具有同步语义的变量,或者用原子类型)。
伪共享是性能问题不是正确性问题,但杀伤力巨大。现代 CPU 的缓存行通常是 64 字节,如果两个线程分别修改同一个缓存行里的不同变量,硬件为了保证一致性会让这个缓存行在两个核之间来回失效,性能可能下降一个数量级。解决办法是把热点变量按缓存行对齐或者加填充,让它独占一行。这个技巧在计数器数组、环形队列的生产者消费者指针上用得最多。
我做过一个很朴素的对比实验:八核机器上,八个线程各自累加一个独立的计数器,如果这八个计数器紧挨着放在一个数组里,总耗时比重试很多;把它们各自按 64 字节间隔摆放之后,耗时明显下降。这个差距完全不是算法带来的,纯粹是缓存行打架。
5.2 一次死锁的完整排查链路
死锁的问题在于它不报错,只是整个服务卡住。我通常按这个顺序走。
第一步先确认是不是死锁而不是慢。看线程栈,如果大量线程停在获取锁的位置,且长时间不动,基本可以确认。运行时有导出线程栈的能力,这一步信息量最大,一定要先抓。
第二步找等待环。把所有"持有锁 A 等待锁 B"的线程和"持有锁 B 等待锁 A"的线程对上,环就出来了。手动看往往很乱,我的习惯是把每条边写成"线程 X:持 A 等 B"的格式列出来,环会自己浮出来。
第三步定位代码。找到环之后,去看这几处加锁的代码,通常是加锁顺序不一致导致的。比如一个方法是先锁订单再锁库存,另一个方法是先锁库存再锁订单,两个并发跑就锁死了。
第四步修复。最稳的办法是定义全局的锁获取顺序,所有代码统一遵守,通常按业务对象的唯一标识排序。次选是给锁加超时,拿不到就放弃并回滚,避免无限等待。最不推荐的是扩大锁范围(比如用一把大锁把所有操作串起来),那等于放弃并发。
这里有个血泪教训:加锁顺序这种东西,靠代码评审是守不住的,因为新人不知道这条约定。我的做法是把"需要同时持有的锁"封装成一个复合操作函数,调用方只能通过这个函数来同时获取,物理上杜绝了顺序不一致的可能。
5.3 锁粒度越细越好是个误解
"减小锁粒度"这句话被滥用了。锁粒度太细会带来两个新问题:一是加锁次数变多,每次加锁解锁本身就是原子操作,有成本;二是一致性被破坏,原本一次原子完成的操作被拆成两次,中间可能被别的线程插入,产生新的竞态。
我见过把一个转账操作拆成"锁账户 A 扣款、释放、锁账户 B 加款",结果中间被查询线程读到了总数不一致。正确的做法是这笔操作必须原子,锁要么覆盖两个账户(按序加锁),要么用乐观锁加版本号重试。
真正该做的不是"把锁变小",而是"把不该共享的东西变成不共享"。线程本地化、按 key 分片(同一个 key 永远路由到同一个线程或同一个分片)、不可变对象,这些手段从根上消除了竞争,比任何锁优化都有效。我在高并发计数器上的首选方案一直是分片累加再汇总,几乎没有锁。
6. 我自己的选型清单和几条踩出来的经验
聊了这么多模型和坑,落到实际动手,我一般是这么走一遍的。
先问三个问题:任务里等待占比多少?峰值并发多少?下游能扛多少?如果等待占比高、并发量大、下游还能扛,就走轻量并发(协程或者用户态线程),代码用同步写法最省心。如果等待占比低、纯计算,那就按核数配线程,别多想。如果并发量只有几十上百,直接一对一加线程池,配置简单、调试友好,没必要为了省那点内存引入复杂模型。
再说几条具体的经验。第一,先测量再优化。我见过太多人凭感觉把线程池从 10 调到 200,结果慢了。先抓一遍切换次数、运行队列长度、系统态 CPU 占比,数据会告诉你瓶颈在哪。
第二,别在锁里做任何可能阻塞的事情。IO、日志刷盘、远程调用,只要在锁里,就等于把这个锁的持有时间从微秒级拉到了毫秒级,并发度直接掉三个数量级。这条规则的违反频率高得离谱,而且往往藏在日志框架的同步 appender 里。
第三,并发问题优先用结构消除,而不是用锁解决。能在设计上做到线程不共享状态,就不要引入锁。如果必须共享,优先不可变对象,其次原子类型,最后才是互斥锁。
第四,调试期的线程数降下来。生产配 80 个线程,本地调试先配 4 个,因为很多竞态问题在小并发下复现不了——这话听着反直觉,但确实是:一些小并发下永远走不到的穿插顺序,在大并发下才暴露。所以定位偶发问题时,反而是要把并发压上去,用压力测试跑几万次。
第五,也是我最近越来越重视的一点:别为了赶时髦换模型。异步化、协程化确实能提升吞吐,但它会改变整个代码库的写法,包括错误处理、上下文传递、调试体验。如果现有的一对一线程池还能撑住,且瓶颈在别处(比如数据库慢查询),换模型带来的收益远小于引入的复杂度。我见过一个团队花三个月把服务改成全异步,最后发现瓶颈是缺索引。
最后提一个容易被忽略的细节:线程池的线程命名。给每个池子起一个能看出用途和分组的名字,排查问题的时候线程栈一眼就能分清谁是谁。这个成本几乎为零,收益极大。我在任何项目里第一件事就是把默认的线程工厂换掉,不换的话,线上抓下来一堆长得一样的线程名,光分类就要花掉半小时。