线程池:为什么不用一个连接一个线程

线程池:为什么不用一个连接一个线程
隔壁老康难度 ★★★★☆ · 前置:线程同步:两个线程各加一百万,为什么不是两百万 | 一句话结论:线程的瓶颈不是「创建慢」,是每个线程常驻占用内存,且数量超过核心数之后调度开始互相踩;线程池把这份成本固定下来,但真正决定成败的是池多大、队列有没有上限。
卡住的那次压测
2024 年春天我接手了一个内部服务,代码朴素得像教科书,主线程 accept 一个连接就 pthread_create 一个线程,处理完线程自己退出,写这段的人还在注释里夸了一句简单直接,我看的时候也觉得没毛病,因为在我本机上压五十个并发它跑得飞快,响应时间稳定在两毫秒,直到我们用三千并发去撞它,服务就再也不动了,accept 还在返回,但客户端一个字节都收不到,ss -s 打出来几千个 TIME-WAIT 和几百个 ESTAB,进程还在,top 里 CPU 也不高,那种状态特别像一个人睁着眼睛但不说话。
1 | while (1) { |
dmesg 里有一行很长的东西。
1 | [12345.678] cgroup: fork rejected by pids controller in /user.slice/... |
或者更常见的另一种,程序自己把错误打出来了。
1 | pthread_create: Resource temporarily unavailable |
EAGAIN,errno 11。
我一开始以为是压测机不行,换了一台更好的机器继续压,报错一模一样,这就排除了机器性能问题,剩下能怀疑的只有限制本身,于是我一条一条去翻。
1 | ulimit -u |
三个数字,任何一个先撞上都会给你同样的 EAGAIN,而报错本身不会告诉你是哪一个,这就是它难查的地方。更麻烦的是另一件事,即使没到上限,两千个线程在 32 核机器上跑得比两百个还慢,因为调度器要在两千个候选里挑下一个,每个线程被切走的时候 L1 和 L2 里塞的东西都会被别人冲掉,切回来等于从冷缓存重新开始,这里面的浪费比省下来的并行收益大得多。(那次排查完之后我在群里说了句,线程不是越多越快,被同事回了句这不废话吗,可他当时也没想到要去看 pids.max。)
到这一步通常的结论是得有线程池,但线程池本身不神奇,它只是把开线程这个动作提前做掉然后重复使用,真正要算清楚的是池开多大、队列要不要设上限,这两件事错了,线程池只会让故障来得更晚更隐蔽。
池子是常驻线程加一个队列
graph LR A["主线程<br/>accept 连接"] --> B["任务队列<br/>(有界,带锁)"] B --> C1["worker 1"] B --> C2["worker 2"] B --> C3["worker 3"] B --> C4["worker N"] C1 --> D["取一个任务 → 执行 → 回来取下一个"] C2 --> D C3 --> D C4 --> D
它解决三个问题,按重要性从低往高排。
| 问题 | 有没有池的差别 |
|---|---|
| 线程数量不可控 | 有池 → 恒定 N 个;无池 → 随连接数涨 |
| 创建销毁的开销 | 有池 → 一次;无池 → 每连接一次 |
| 突增流量压垮系统 | 有池 + 有界队列 → 排队或拒绝;无池 → 直接 OOM |
第三个才是生产上最关键的,线程池真正的价值是把无限变成有限,让过载表现为变慢,而不是崩溃,所以它不是性能优化,是资源上限管理,这两个说法听着差不多,落到设计上完全是两回事。
贵在常驻,不贵在创建
很多人以为线程池的价值在于 pthread_create 慢,其实并不慢,实测二三十微秒就回来了,比一次 read 走网络还便宜。
真正的成本是常驻占用。
| 项目 | 每个线程的成本 |
|---|---|
| 内核栈 | 8KB 或 16KB(CONFIG_KASAN 下更大) |
task_struct |
约 2KB |
| 用户态栈(虚拟) | 8MB(ulimit -s) |
| 用户态栈(实际) | 常见 8~64KB |
| 调度器结构 | 几百字节 |
虚拟地址那 8MB 是大头,一万个线程就是 80GB VIRT,虽然 RSS 只有几百 MB,但监控会因此告警,fork 的时候页表要复制,某些内核路径还要遍历 VMA,比如 OOM killer 算 oom_score 就得扫页表,你线程越多,它算得越慢,出故障的时候系统反而更迟钝。所以线程看起来不占内存是个错觉,占的是地址空间,账单晚一点才来。我第一次意识到这件事是在一台跑着几千线程的机器上,free 显示还剩很多内存,VIRT 却已经上百 G,当时我笃定是某个进程泄漏了,翻了半天 pmap 和 smaps 才明白那些匿名映射就是线程栈。
数量超过核心数之后
CPU 密集型场景下,线程数大于核心数只有坏处没有好处,多出来的线程在轮转,每个线程的 L1 和 L2 缓存都会在切走期间被别的线程冲掉,切回来等于缓存全冷,你花在并行上的钱还不够赔缓存的钱。IO 密集型可以超过核心数,因为大部分线程在阻塞,accept 的时候在等,read 的时候在等,write 的时候也在等,但它们也不能无限,阻塞的线程照样占着 8MB VIRT 和一份内核栈。
判断标准就一个数字,阻塞时间占比。如果线程 95% 的时间在等 IO,理论上核心数除以 0.05 也就是二十倍核心数能把 CPU 跑满,但这个公式在真实系统里很少成立,因为内存带宽和 IO 带宽会先饱和,大量线程同时醒来会引起惊群,而且每个请求的处理时间并不均匀,长尾会把平均值拖得很难看。
实践经验大概是这样。
| 场景 | 线程数 |
|---|---|
| CPU 密集(计算、压缩、加解密) | 核心数 ± 1 |
| 普通 IO 密集(HTTP 请求、数据库查询) | 核心数 × 2~4 |
| 高延迟 IO(磁盘、外部 API) | 核心数 × 8~16,但要配合监控 |
| 阻塞读网络(每连接一线程) | 不要这样做,用 epoll |
Go 的 GOMAXPROCS 默认就是核心数,背后的道理和这里一样。
队列比线程数更容易出事
无限队列等于无限内存,这个结论很多人知道,但写代码的时候还是顺手 new LinkedBlockingQueue() 不带参数。
下面这个写法在过载时会吃掉所有内存(create_queue 和 queue_push 是我为了说明结构编出来的名字,不是标准库 API,别去头文件里找)。
1 | // 这个队列在过载时会吃掉所有内存 |
生产上正确的形状是有界,满了以后必须做选择。
1 | queue = create_queue(1024); // 有界 |
这三种策略对应三种系统设计哲学,阻塞是让上游自己慢下来,适合内部服务链,拒绝是明确告诉调用方现在不行,大多数 HTTP 服务该选这个,比让调用方超时好,因为对方能立刻重试或者降级,丢弃只适合丢了也无所谓的场景,比如指标和日志。没有有界队列的线程池,等于没有线程池。
worker 那几十行
最常用的是互斥量加条件变量,写出来只有二十来行,但每一行都有讲究。
1 | void *worker(void *arg) { |
两个细节最容易写错。
一是 while 不是 if,因为条件变量会虚假唤醒,我第一次写的时候用的就是 if,单线程压测一切正常,两个消费者一起来就偶尔卡死,查了两天。二是 t->fn() 必须在锁外面执行,放进锁里就等于把整个池串行化了,池白建,而且这种写法在小流量下完全看不出来,只有压起来才会发现加了池跟没加一样。队列满的时候生产者 pthread_cond_wait(&q.not_full, &q.lock),消费者取走一个后 signal 唤醒它。
要不要允许动态扩缩
大多数生产系统不需要,动态扩缩带来的状态和 bug 比它解决的问题多,什么时候缩,缩的时候正在跑任务怎么办,线程数波动导致缓存和工作集抖动,而且同一个服务在不同时间有不同线程数,排查的时候你连基线都没有。Netty、Tomcat、Go 的 runtime 都选了固定大小,或者极简单的动态策略。常见的折中是核心线程数加最大线程数加空闲超时,Java 的 ThreadPoolExecutor 就是这个模型,核心 10 个,队列满了再扩到最大 50,超过 60 秒空闲的降回核心数。这套参数在生产上被配错的比例极高,因为默认的 LinkedBlockingQueue 是无限的,队列永远不会满,于是永远扩不到最大,那三个数字里的后两个等于白写。
它和 epoll 的边界
| 每连接一线程 | epoll + 少量线程 | |
|---|---|---|
| 1 万连接 | 1 万个线程,太重 | 4 个线程,轻松 |
| 每个连接有大量计算 | 天然并行,好 | 计算会占住事件循环,要分出去 |
| 长连接 + 低频消息 | 浪费(线程在睡) | 完美 |
| 短连接 + 快处理 | 可以接受 | 也行 |
| 编程难度 | 低(线性逻辑) | 高(回调/状态机) |
判断标准是连接数乘以平均空闲时间,这个乘积越大,越该往 epoll 那边靠。
连接很多但大部分时间没数据,比如长轮询、WebSocket、即时通讯,epoll 是唯一答案,短连接且每个请求都吃 CPU,线程池反而更简单也更快。真实系统常常两个都用,epoll 收数据,线程池算结果,中间隔一个有界队列,这样两头的好处都能拿到一点。
创建销毁到底多贵
1 | perf stat -e task-clock,context-switches python3 -c " |
一万次创建销毁大约 0.3~0.5 秒,也就是 30~50 微秒一次。
而一次 accept 加 read 加 write 加 close 大约 50~100 微秒,所以每连接开线程的额外开销和实际工作差不多同一个量级,这不是有点浪费,是接近两倍成本。(这个数字我测出来的时候是有点意外的,我本来以为创建线程要几百微秒,毕竟听起来挺重的一件事。)
冷启动要预热
线程池的第一个任务会触发 clone 系统调用、内核栈分配、用户态栈的缺页、malloc 分配器初始化,glibc 的 arena 在多线程下每个线程可能拿一个,这些事加在一起,第一批请求能比后面的慢一个数量级。这个现象在压测里特别容易误判,JMeter 一开跑就几千毫秒的尾延迟,看着像代码有 bug,其实是冷启动,跑到第 30 秒曲线自己就平了。所以线程池要在服务启动时就预热,先跑一轮空任务,把栈都摸热,别把首批真实请求当试验品。
1 | for (int i = 0; i < POOL_SIZE; i++) { |
不预热的话第一批请求会明显慢于后续请求,这经常被误诊为服务冷启动慢,然后大家去查磁盘、查 DNS、查连接池,其实只是栈还没碰过。
线程、协程、事件循环
很多叫线程池的东西实际换成了协程池或者事件循环,但计价方式没变,还是那句话,单位并发成本是多少。
| 模型 | 单位成本 | 适合 |
|---|---|---|
| 每请求一线程 | 线程(约 20KB + 8MB VIRT) | 简单,低并发 |
| 线程池 | 线程(固定数量) | 通用,中等并发 |
| 协程(Go、Rust async) | 协程(约 2~8KB) | 高并发,IO 密集 |
| 事件循环(nginx、Redis) | 几乎零 | 极高并发,回调可接受 |
协程便宜是因为它不占内核栈,切换也不需要内核参与,Go 的 goroutine 初始 2KB 栈,可以开上百万个。
但协程不是万能的,CPU 密集的任务还是会占满 OS 线程,Go 的 runtime 也要靠 GOMAXPROCS 限制并行度,把一万个计算型 goroutine 丢进去,该排队还是排队。
几种变体
| 变体 | 特点 |
|---|---|
| 固定大小 | 最简单,参数少,推荐默认 |
| 核心 + 最大 + 队列 | Java ThreadPoolExecutor,参数多易错 |
| 分片(sharded) | 每个 worker 有自己的队列,减少锁竞争(如 Go 的 P) |
| work-stealing | worker 空了去偷别人的任务(Java ForkJoinPool、Go) |
| 定时/延迟 | 加时间轮或者优先队列 |
分片和 work-stealing 都在解决同一个问题,所有 worker 抢同一个队列的锁。
实测八个 worker 抢一个全局队列,大约 30% 的时间花在锁上,分成八个队列之后降到 5% 以下。代价是任务分布不均,一个 worker 的队列满了其他还空着,所以还得有 stealing 机制把这个不均补回来。
先把线程数顶到上限
线程数上限到底是多少,四个地方各报一个数。
1 | echo "ulimit -u: $(ulimit -u)" |
四个数字取最小值,还要受内存限制,每个线程约 10KB 内核内存,再加上栈的实际占用。
在容器里 ulimit -u 经常是 1048576,大得像个摆设,但 pids.max 可能只有 4096,我第一次被这个坑到,就是在一个限制写得很宽松的容器里,怎么调 ulimit 都没用,因为内核压根不看那个数。看真实服务的线程数,nginx 是个好样本。
1 | systemctl status nginx | head -5 |
NLWP 就是线程数,nginx 主进程通常一两个线程,worker 进程各自单线程,它选了多进程加每进程单线程,而不是线程池,这是模型选择的一个活例子,nginx 不需要线程池,因为它用 epoll 异步,每个 worker 能扛上千连接。对比 MySQL 就是另一个极端。
1 | MYSQLD=$(pgrep -o mysqld) |
通常是几十个,因为 MySQL 用的是每连接一线程,你随便起几百个连接就能把这个数字拉上去。
压测两种模型之前先把每连接一线程的退化看一遍。
1 | # 一个极简的 per-connection-thread 服务器 |
开五百个连接上去看看它变成什么样。
1 | python3 -c " |
NLWP 会变成五百多,再往上加就能看到 EAGAIN 或者内存增长。
1 | kill $SRV 2>/dev/null |
同样的压测,线程池在五百连接下 NLWP 还是固定的 8,这就是全部区别,它把随负载增长变成了恒定。
用 pidstat 看上下文切换能进一步确认。
1 | pidstat -w -t -p $(pgrep -f thr_server) 1 3 |
cswch/s 是主动切换,nvcswch/s 是被动切换,主动切换太高说明线程在睡觉等 IO,那它就是 IO 密集型,线程可以多给一些。
最后看 cgroup 里有没有被限流过。
1 | cat /sys/fs/cgroup/pids.current |
如果 pids.events 里有 max N,说明撞过上限,这个数字比等 dmesg 里冒出那行早得多,应该拿来做告警,而不是等它变成故障。
线程池提的不是性能
线程池能提高性能这句不准确,它提高的是稳定性和可控性,用 100 个线程处理 100 个并发请求,比开 100 个线程略快一点,省了创建开销,但快不了多少,真正提升性能的是避免线程数爆炸,那个提升是几十倍量级的。线程池越大越好也错,超过核心数之后 CPU 密集型的性能会下降,因为切换和缓存失效的成本超过了并行收益。
无限队列可以吸收突发流量这句话最危险,它只是把崩溃从拒绝请求换成了 OOM,而且 OOM 的时候更难查,因为进程不是被你的代码弄死的,是被内核杀的,core 都没有。pthread_create 很贵所以要用池这个理解也偏了,二三十微秒,对短请求不算便宜,但真正的成本是常驻内存,不是创建时间。线程池里的线程应该处理各种任务这条也得改,长任务会占住 worker 导致短任务排队,生产上常需要按任务类型分池,比如 IO 池和 CPU 池分开,否则一个慢查询能把整个服务堵死。Go 就不用考虑线程池这句话不成立,goroutine 确实便宜,但 runtime.NumCPU() 仍然是并行的上限,CPU 密集的 goroutine 开一万个也没用。
ulimit -u 是单用户的进程数这个说法已经过时了,进入 systemd 和 cgroup 时代之后,实际的限制是 TasksMax 和 pids.max,ulimit -u 可能根本不生效。线程池线程设为守护线程就能安全退出也是错的,守护线程在 main 返回时被杀,正在持锁或者写文件时被杀会留下脏状态,要优雅退出得先 shutdown 队列,再 join 所有 worker。
有界,才是池子
- 一个连接一个线程的问题不是创建慢,是每线程 8MB 虚拟栈加内核栈,且数量随负载线性增长。
- 线程池的核心价值:把线程数固定下来,让过载表现为变慢而不是崩溃。
- 池大小经验值:CPU 密集约等于核心数,普通 IO 是核心数的 2~4 倍,高延迟 IO 是 8~16 倍。
- 队列必须有界。三种溢出策略(阻塞、拒绝、丢弃)对应三种设计哲学,大多数 HTTP 服务选拒绝。
- 队列实现的两个坑:
while不是if,任务必须在锁外执行。 - 8 个 worker 抢一个队列约 30% 时间在锁上;分片或 work-stealing 能降到 5%。
- 连接很多但大部分空闲(长连接、推送、WebSocket)就别用线程池,用 epoll。
走到这里,前面那些结论你都能用一条命令自己验证;而页、缓存行、调度队列这些看不见的东西,也都在后面几篇里交代过了。再往下就是网络,那是能重新用眼睛看见的部分,从 socket 那一层开始,到三次握手时两边内核各改了哪些字段。









