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

难度 ★★★★☆ · 前置:线程同步:两个线程各加一百万,为什么不是两百万 | 一句话结论:线程的瓶颈不是「创建慢」,是每个线程常驻占用内存,且数量超过核心数之后调度开始互相踩;线程池把这份成本固定下来,但真正决定成败的是池多大、队列有没有上限。

卡住的那次压测

2024 年春天我接手了一个内部服务,代码朴素得像教科书,主线程 accept 一个连接就 pthread_create 一个线程,处理完线程自己退出,写这段的人还在注释里夸了一句简单直接,我看的时候也觉得没毛病,因为在我本机上压五十个并发它跑得飞快,响应时间稳定在两毫秒,直到我们用三千并发去撞它,服务就再也不动了,accept 还在返回,但客户端一个字节都收不到,ss -s 打出来几千个 TIME-WAIT 和几百个 ESTAB,进程还在,top 里 CPU 也不高,那种状态特别像一个人睁着眼睛但不说话。

1
2
3
4
5
6
while (1) {
int fd = accept(listen_fd, NULL, NULL);
pthread_t t;
pthread_create(&t, NULL, handle, (void *)(intptr_t)fd);
pthread_detach(t);
}

dmesg 里有一行很长的东西。

1
[12345.678] cgroup: fork rejected by pids controller in /user.slice/...

或者更常见的另一种,程序自己把错误打出来了。

1
pthread_create: Resource temporarily unavailable

EAGAIN,errno 11。

我一开始以为是压测机不行,换了一台更好的机器继续压,报错一模一样,这就排除了机器性能问题,剩下能怀疑的只有限制本身,于是我一条一条去翻。

1
2
3
ulimit -u
cat /proc/sys/kernel/threads-max
cat /sys/fs/cgroup/pids.max

三个数字,任何一个先撞上都会给你同样的 EAGAIN,而报错本身不会告诉你是哪一个,这就是它难查的地方。更麻烦的是另一件事,即使没到上限,两千个线程在 32 核机器上跑得比两百个还慢,因为调度器要在两千个候选里挑下一个,每个线程被切走的时候 L1 和 L2 里塞的东西都会被别人冲掉,切回来等于从冷缓存重新开始,这里面的浪费比省下来的并行收益大得多。(那次排查完之后我在群里说了句,线程不是越多越快,被同事回了句这不废话吗,可他当时也没想到要去看 pids.max。)

到这一步通常的结论是得有线程池,但线程池本身不神奇,它只是把开线程这个动作提前做掉然后重复使用,真正要算清楚的是池开多大、队列要不要设上限,这两件事错了,线程池只会让故障来得更晚更隐蔽。

池子是常驻线程加一个队列

它解决三个问题,按重要性从低往高排。

问题 有没有池的差别
线程数量不可控 有池 → 恒定 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
2
// 这个队列在过载时会吃掉所有内存
queue = create_queue(INT_MAX);

生产上正确的形状是有界,满了以后必须做选择。

1
2
3
4
5
6
7
8
9
queue = create_queue(1024);            // 有界
if (queue_push(q, task) == FULL) {
// 三种策略:
// 1. 阻塞(背压传导给调用方)
// 2. 拒绝(返回 503)
// 3. 丢弃最老的(只适合日志类)
respond_503(fd);
close(fd);
}

这三种策略对应三种系统设计哲学,阻塞是让上游自己慢下来,适合内部服务链,拒绝是明确告诉调用方现在不行,大多数 HTTP 服务该选这个,比让调用方超时好,因为对方能立刻重试或者降级,丢弃只适合丢了也无所谓的场景,比如指标和日志。没有有界队列的线程池,等于没有线程池。

worker 那几十行

最常用的是互斥量加条件变量,写出来只有二十来行,但每一行都有讲究。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void *worker(void *arg) {
while (1) {
pthread_mutex_lock(&q.lock);
while (q.count == 0 && !q.shutdown) {
pthread_cond_wait(&q.not_empty, &q.lock);
}
if (q.shutdown && q.count == 0) {
pthread_mutex_unlock(&q.lock);
break;
}
task_t *t = q.head;
q.head = t->next;
q.count--;
pthread_cond_signal(&q.not_full);
pthread_mutex_unlock(&q.lock);

t->fn(t->arg); // 注意:在锁外面执行
free(t);
}
return NULL;
}

两个细节最容易写错。

一是 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
2
3
4
5
6
perf stat -e task-clock,context-switches python3 -c "
import threading
def f(): pass
for _ in range(10000):
t = threading.Thread(target=f); t.start(); t.join()
"

一万次创建销毁大约 0.3~0.5 秒,也就是 30~50 微秒一次。

而一次 accept 加 read 加 write 加 close 大约 50~100 微秒,所以每连接开线程的额外开销和实际工作差不多同一个量级,这不是有点浪费,是接近两倍成本。(这个数字我测出来的时候是有点意外的,我本来以为创建线程要几百微秒,毕竟听起来挺重的一件事。)

冷启动要预热

线程池的第一个任务会触发 clone 系统调用、内核栈分配、用户态栈的缺页、malloc 分配器初始化,glibc 的 arena 在多线程下每个线程可能拿一个,这些事加在一起,第一批请求能比后面的慢一个数量级。这个现象在压测里特别容易误判,JMeter 一开跑就几千毫秒的尾延迟,看着像代码有 bug,其实是冷启动,跑到第 30 秒曲线自己就平了。所以线程池要在服务启动时就预热,先跑一轮空任务,把栈都摸热,别把首批真实请求当试验品。

1
2
3
4
5
for (int i = 0; i < POOL_SIZE; i++) {
task_t *t = malloc(sizeof *t);
t->fn = noop;
queue_push(&q, t);
}

不预热的话第一批请求会明显慢于后续请求,这经常被误诊为服务冷启动慢,然后大家去查磁盘、查 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
2
3
4
5
echo "ulimit -u:        $(ulimit -u)"
echo "threads-max: $(cat /proc/sys/kernel/threads-max)"
echo "pids.max: $(cat /sys/fs/cgroup/pids.max 2>/dev/null || echo 'not found')"
echo "pid_max: $(cat /proc/sys/kernel/pid_max)"
free -h | head -2

四个数字取最小值,还要受内存限制,每个线程约 10KB 内核内存,再加上栈的实际占用。

在容器里 ulimit -u 经常是 1048576,大得像个摆设,但 pids.max 可能只有 4096,我第一次被这个坑到,就是在一个限制写得很宽松的容器里,怎么调 ulimit 都没用,因为内核压根不看那个数。看真实服务的线程数,nginx 是个好样本。

1
2
3
4
systemctl status nginx | head -5
NGINX=$(pgrep -o nginx)
ps -eLf | awk -v p="$NGINX" '$2 == p {n++} END {print "threads:", n}'
ps -o nlwp,pid,comm -p "$NGINX"

NLWP 就是线程数,nginx 主进程通常一两个线程,worker 进程各自单线程,它选了多进程加每进程单线程,而不是线程池,这是模型选择的一个活例子,nginx 不需要线程池,因为它用 epoll 异步,每个 worker 能扛上千连接。对比 MySQL 就是另一个极端。

1
2
MYSQLD=$(pgrep -o mysqld)
ps -o nlwp,pid,comm -p "$MYSQLD"

通常是几十个,因为 MySQL 用的是每连接一线程,你随便起几百个连接就能把这个数字拉上去。

压测两种模型之前先把每连接一线程的退化看一遍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 一个极简的 per-connection-thread 服务器
cat > /tmp/thr_server.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>

static void *handle(void *arg) {
int fd = (int)(intptr_t)arg;
char buf[1024];
ssize_t n;
while ((n = read(fd, buf, sizeof buf)) > 0) {
write(fd, buf, n);
}
close(fd);
return NULL;
}

int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_port = htons(9001);
a.sin_addr.s_addr = INADDR_ANY;
bind(lfd, (struct sockaddr *)&a, sizeof a);
listen(lfd, 512);
for (;;) {
int fd = accept(lfd, NULL, NULL);
pthread_t t;
pthread_create(&t, NULL, handle, (void *)(intptr_t)fd);
pthread_detach(t);
}
return 0;
}
EOF
gcc -O2 -pthread /tmp/thr_server.c -o /tmp/thr_server
/tmp/thr_server &
SRV=$!
sleep 0.5

开五百个连接上去看看它变成什么样。

1
2
3
4
5
6
7
8
9
10
11
python3 -c "
import socket, time
socks = []
for i in range(500):
s = socket.create_connection(('127.0.0.1', 9001))
socks.append(s)
print('connected', len(socks))
time.sleep(5)
" &
sleep 2
ps -o nlwp,pid,comm -p $SRV

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
2
cat /sys/fs/cgroup/pids.current
cat /sys/fs/cgroup/pids.events

如果 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 那一层开始,到三次握手时两边内核各改了哪些字段。