服务器并发:从 fork 到 epoll,四条路各自死在哪

难度 ★★★★★ · 前置:TCP 的 11 个状态:TIME_WAIT 是好的,CLOSE_WAIT 才是 bug | 一句话结论:四种并发模型的边界都是同一个数字,每连接的成本。fork 是几毫秒加一个进程的调度开销,线程是几十微秒加 8MB 虚拟地址,select 是每次轮询扫全部 fd,epoll 是每次只取就绪的。知道成本在哪,才知道几百连接和十万连接该用哪个。

并发涨了十倍,三条报错一起来

2025 年夏天我给一个内部服务做扩容,平时二百并发跑得好好的,搞了一次活动,并发涨到三千,然后监控墙上同时亮了三盏灯,三种错误三种颜色,看着像三件完全不同的事。

1
2
3
Apr 12 10:23:41 web-02 kernel: cgroup: fork rejected by pids controller in /system.slice/app.service
Apr 12 10:23:41 web-02 app[2210]: pthread_create: Resource temporarily unavailable
Apr 12 10:23:42 web-02 app[2210]: Exception in thread "http-nio-8080-Acceptor": java.lang.OutOfMemoryError: unable to create native thread

三条报错背后是三个不同的限制,fork rejected by pids controller 是 cgroup 的进程数上限(pids.max),pthread_create: Resource temporarily unavailable 是 ulimit -u 的线程数上限或者内存不够(每线程要 8MB 虚拟地址),unable to create native thread 是 Java 侧的包装,本质是同一个。这三个都是一连接一线程模型在并发涨十倍时的必然结果,它不是代码写错了,是模型选错了,而模型选错的代价,要等到并发涨上来那天才付。(那天我在工位上算了一下午,最后发现改任何一行代码都没用,必须换模型,这种感觉挺挫败的)有意思的是,如果这个服务当初就用 epoll,3000 并发连 1% 的 CPU 都用不到。

它其实是一道算术题

服务器并发这个词容易让人以为是一个技术,它其实是一道算术题,给定 N 个并发连接,每种模型每个连接要花多少成本,什么时候这个成本超过机器的供给。四条路,成本结构完全不同。

模型 每连接成本 主要瓶颈 什么时候撑不住
fork / accept + 子进程 一次 fork()(0.1~1ms)+ 一个进程(内核 8~10KB + 栈 + 页表) PID 数、内存、调度开销 几百
一连接一线程 pthread_create(20~30μs)+ 8MB 虚拟地址 + 两个内核栈 虚拟地址、线程数、上下文切换 几千
select / poll 每次调用扫全部 fd(O(n)) CPU、FD_SETSIZE 1024 几百到一千
epoll 每次只取就绪的(O(就绪数)) fd 上限、内存 十万

最后一行写的不是十万能撑,而是十万才开始有压力。

fork 死在三个地方

fork 模型长这样看起来非常优雅,每个连接一个独立进程,隔离彻底,一个崩了不影响别的,代码里不用考虑任何并发。

1
2
3
4
5
6
7
8
9
while (1) {
int cfd = accept(lfd, NULL, NULL);
if (fork() == 0) {
close(lfd);
handle(cfd);
_exit(0);
}
close(cfd);
}

它死在三件事上。fork 本身不便宜,COW 让 fork 只复制页表,但页表是 O(地址空间大小) 的,一个用了 100MB 内存的进程,25600 个 PTE(100MB / 4KB)意味着要复制约 50 个 PTE 页(25600 / 512)再加它们的上一级,实测 0.1~1 毫秒,1000 并发意味着每秒最多 1000~10000 次 fork,光是这一步就消耗可观。每个进程都有常驻开销,task_struct 约 8~10KB,内核栈 8KB 或 16KB,用户栈按需但页表结构是固定的,几千个进程的调度开销(vmstat 的 cs 列)会让 CPU 的相当一部分花在切换上。一连接一进程还意味着每个进程都要单独建立数据库连接、读配置、加载缓存,内存放大非常严重。但它不是一无是处,PHP-FPM 至今用这个模型,理由是隔离,一个请求跑飞了不会影响其他请求,内存泄漏随进程退出自动清理,在每个请求都很重、并发不高的场景下它依然合理。(我第一次看到 PHP-FPM 的进程树时还以为配置写错了,一个站点几百个子进程,后来才明白这就是它的正常形态)

线程便宜,但便宜得不够

线程版的 accept 循环省事得多,创建完 detach 掉就不用管了。

1
2
3
4
5
6
while (1) {
int cfd = accept(lfd, NULL, NULL);
pthread_t tid;
pthread_create(&tid, NULL, worker, (void *)(intptr_t)cfd);
pthread_detach(tid);
}

三个数字卡住它。虚拟地址上,Linux 默认线程栈 8MB(ulimit -s),3000 个线程就是 24GB 虚拟地址,虽然是虚拟的不实际占用物理内存,但 64 位地址空间也不是无限的,更现实的问题是内核要为每个线程维护 mm_struct 的引用、task_struct、内核栈。线程数上限是三层限制层层收紧。

1
2
3
ulimit -u                        # 用户的进程/线程数上限
cat /proc/sys/kernel/threads-max # 系统全局上限
cat /sys/fs/cgroup/pids.max # cgroup 上限(容器里几乎总是这个)

容器里最常见的死法是第三层,Deployment 里没写 pids limit 的话,某些运行时默认值很小。上下文切换也躲不掉,每个线程阻塞在 read() 上等数据,内核要把它从 TASK_RUNNING 挪到 TASK_INTERRUPTIBLE,数据到了再挪回来,vmstat 的 cs 列一旦超过每秒几万,就能明显感到响应变慢。但线程模型在 IO 密集型下表现并不差,Nginx 作者说过,如果不考虑内存,一连接一线程是可用的,问题就是内存,每线程 8MB 虚拟地址乘以一万连接等于 80GB 虚拟地址,光页表就要几十 MB。

select 和 poll 为什么绕不开 O(n)

select 的用法是把所有关心的 fd 塞进一个位图,然后交给内核等。

1
2
3
4
5
6
7
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(lfd, &rfds);
for (int i = 0; i < nfd; i++) FD_SET(conns[i], &rfds);
int n = select(maxfd + 1, &rfds, NULL, NULL, NULL);
for (int i = 0; i < nfd; i++)
if (FD_ISSET(conns[i], &rfds)) handle(conns[i]);

每次调用 select 都要把整个 fd 集合从用户态拷到内核态(1024 位等于 128 字节,看起来小,但每次都拷),内核遍历一遍,返回后再遍历一遍用户数组,1000 个连接里只有 1 个有数据,你也要扫 1000 次。poll 改进了两点,用 struct pollfd 数组代替位图(去掉了 1024 限制),并且把监听的事件和发生的事件分开(events / revents),但 O(n) 没变。select 那个 1024 是个编译时的宏 FD_SETSIZE,改它要重编 glibc,而且改了之后内核的 select 实现仍然是 O(n)。poll/select 在几十个连接的场景下完全够用,而且代码简单,一个内部工具、一个管理端口,用它没问题。

epoll 省掉的到底是什么

三个系统调用构成了它全部的门面。

1
2
3
int ep = epoll_create1(0);
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &ev); // 注册一次
epoll_wait(ep, events, MAX, timeout); // 只返回就绪的

epoll_ctl 只在你添加或删除连接时调用一次,epoll_wait 返回的是就绪的 fd 数组,不是扫描结果。核心机制是内核在数据到达时,直接把事件挂到 epoll 实例的就绪链表上,所以 epoll_wait 的时间复杂度是 O(就绪数),不是 O(总连接数)。三个接口的完整签名、fd_set 的内部结构、LT 与 ET 的差别都放在 select、poll、epoll 那一篇,这里只看它们在并发模型里的位置。四个关键事实值得记住。

事实 含义
红黑树存注册的 fd 增删是 O(log n),但只在 epoll_ctl 时发生
就绪链表存已就绪的 fd epoll_wait 从里面取,O(就绪数)
事件回调在中断/软中断里挂载 不需要用户态参与
用 copy_to_user 拷回 只有就绪的那几十个事件要拷,不是整个 fd 集合

epoll 是 O(1) 这个说法不准确,准确的说法是 epoll_wait 的开销只跟就绪连接数相关。这也意味着如果所有连接都很活跃,epoll 相对 poll 没有优势,它的优势在海量连接、少数活跃,这正是 Web 服务的特征。

Reactor 还是 Proactor

epoll 本身只是告诉我哪些 fd 就绪了,怎么用它,是另一层设计。Reactor 是就绪通知,epoll_wait 告诉你 fd 3 可读,你自己去 read(),read() 可能阻塞(不过就绪了通常不会),读到多少算多少。Proactor 是完成通知,你提交把 fd 3 的数据读到 buffer 里,内核读完了通知你,这就是 io_uring。Linux 上绝大多数框架是 Reactor,Nginx、Redis、libuv、Go 的 netpoll、Java 的 Netty 都在这个阵营里。Proactor 在 Linux 上的代表就是 io_uring(5.1 引入),它两个环形队列都放在用户态和内核共享的内存里,提交队列(SQ)和完成队列(CQ),你往 SQ 里塞一个 SQE 描述把 fd 3 读到这个 buffer,内核做完往 CQ 里放一个 CQE,中间不需要系统调用,一次 io_uring_enter 可以批量提交几十个请求,也可以设成 IORING_SETUP_SQPOLL 让内核线程自己轮询 SQ,发起方零 syscall。它比 epoll 强的两个地方,一是能批量(epoll 一次 epoll_wait 只能收一批就绪事件,每个事件的 read/write 还是各一次 syscall),二是天然支持异步的文件 IO(用 epoll 做文件读写永远是就绪即阻塞,因为普通文件不可 poll)。但 2026 年它还没到无脑换上去的程度,内核 5.1 刚出来时功能残缺,5.4~5.15 一路在补,很多发行版默认禁用非特权用户使用(/proc/sys/kernel/io_uring_disabled,6.6 起),容器的 seccomp 白名单经常把它拦掉,所以目前能见到它的场景主要是数据库、存储引擎、高性能代理,不是通用 Web 服务。知道它存在、知道它解决什么就够了。

LT 和 ET,只差一个字母

这是 epoll 最容易出错的地方。

模式 行为 后果
水平触发 LT(默认) 只要缓冲区还有数据,每次 epoll_wait 都返回 安全,可以不一次读完
边缘触发 ET(EPOLLET) 只在状态变化时返回一次 必须循环读到 EAGAIN,否则丢数据

ET 的正确写法是循环读到 EAGAIN,把它当正常退出条件,不当错误。我第一次写 ET 就是没读到 EAGAIN,本地压测一切正常,上线之后偶尔丢一条消息,查了整整一个下午。完整的循环代码和踩坑细节在select/poll/epoll 那篇。为什么还要用 ET,因为它减少 epoll_wait 的返回次数,高连接数场景下能省可观的开销,但绝大多数业务系统用 LT 就够了,出错成本低得多。

三种架构组合

epoll 不等于单线程,生产上常见三种组合。

架构 代表 什么时候用
每个 worker 一个 epoll Nginx(worker_processes auto) 默认选择。无锁,连接分布到各个 worker
单 epoll + 线程池处理业务 Redis 6+ 的 IO 线程、多数 Go 服务 业务逻辑重的时候
多 epoll + 分片 Envoy、DPDK 类应用 十万以上连接

Nginx 的做法值得说,master 进程 fork 出 N 个 worker,每个 worker 自己调 epoll_create、自己 accept(SO_REUSEPORT 或共享 listen fd),没有跨进程的锁,连接天然分散。共享 listen fd 的老写法有个副作用叫惊群,一个新连接到达,所有 worker 都被唤醒,只有一个 accept 成功,其余白跑一趟,内核给了两个解法,SO_REUSEPORT(4.x 起,每个 worker 一个 socket,内核按四元组哈希分流,还支持平滑重启),或者 EPOLLEXCLUSIVE(4.5 起,共享同一个 socket 但只唤醒一个 worker),现代 Nginx 默认用前者的思路,老代码想改最小,加 EPOLLEXCLUSIVE 就够。这就是为什么 Nginx 的 CPU 利用率能线性涨到 32 核。

一连接一线程真的过时了吗

没有,三个场景它依然是对的。连接数少(小于 1000)、每个请求很重,比如图像处理、办公文档转换,用线程池控制并发比用 epoll 简单得多。代码里有大量阻塞调用也是同理,读数据库、调第三方 API,这些阻塞在 epoll 里是要命的,一个 worker 卡住所有连接,要么用异步驱动,要么退回线程。还有一种更朴素的,团队只有两个人,epoll 的状态机(半包、连接状态、超时)要写几百行并且必须测试到位,线程池的 bug 暴露得快,epoll 的 bug 暴露得慢。(这句话是我们组长说的,当时我不太服,后来自己维护过一个状态机才服气)把 2026 年的答案说得朴素一点,IO 多路复用解决等,线程池解决算,两者组合。Go 的运行时把这两件事都做了(netpoll + goroutine 调度),所以你不必选,但在 C / C++ / Java 里,你还是得自己选。

上了 epoll 也不是没有上限

即使上了 epoll,上限也不是无限的,四个硬限制。

限制 查看 典型值
进程 fd 上限 ulimit -n 1024(软)/ 524288(硬,systemd 抬的;内核原生只有 4096)
系统 fd 上限 /proc/sys/fs/file-max 按内存算,几十万到几百万
端口范围 ip_local_port_range 28000(出向连接)
内存 每连接的内核缓冲 net.ipv4.tcp_rmem / tcp_wmem

每连接的内核缓冲是大头,tcp_rmem 默认第三个数是 6MB,tcp_wmem 是 4MB,这是内核算出来的,不是拍脑袋,tcp.c 的 tcp_init() 里写的是 max_rshare = min(6MB, limit)、max_wshare = min(4MB, limit),其中 limit = nr_free_buffer_pages() << (PAGE_SHIFT - 7),也就是内存数据的 1/128,机器小的时候这两个上限会被压得更低。不管标称多少,实际都是按需增长,十万连接如果每个都涨到 1MB 缓冲,就是 100GB 内核内存。这就是为什么 Nginx 和 Envoy 都提供 keepalive 缓冲调整,也是为什么高并发服务通常限制单连接的 SO_RCVBUF。

测 fork 的真实成本

这条路会带上 exec 的开销,所以先用 shell 里的空命令粗测一遍。

1
time (for i in $(seq 1 1000); do /bin/true; done)

1000 次 fork + exec + 退出,大约 1~3 秒(包含 exec 的开销)。

单纯的 fork 更快。

1
2
3
4
5
6
7
8
9
10
11
12
// fork_bench.c
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
int main(void) {
for (int i = 0; i < 1000; i++) {
pid_t p = fork();
if (p == 0) _exit(0);
waitpid(p, NULL, 0);
}
return 0;
}
1
gcc -O2 fork_bench.c -o fork_bench && time ./fork_bench

1000 次 fork 加 wait 大约 0.5~1.5 秒,即每次 0.5~1.5 毫秒。换成 pthread_create,代码结构几乎一样,只是把 fork 换成了建线程。

1
2
3
4
5
6
7
8
9
10
11
12
// thread_bench.c
#include <pthread.h>
#include <stdio.h>
static void *noop(void *a) { return a; }
int main(void) {
for (int i = 0; i < 1000; i++) {
pthread_t t;
pthread_create(&t, NULL, noop, NULL);
pthread_join(t, NULL);
}
return 0;
}
1
gcc -O2 -pthread thread_bench.c -o thread_bench && time ./thread_bench

1000 次大约 20~30 毫秒,即每次 20~30 微秒。

线程比进程快 20~50 倍,但两者都是每连接一次的成本,连接数一多,都不可接受。

观察线程模型的死法

写一个最朴素的一连接一线程服务,把连接数压上去看它长什么样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# thr_server.py
import socket, threading
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 9400))
s.listen(1024)

def worker(c):
try: c.recv(65536)
except Exception: pass
finally: c.close()

while True:
c, _ = s.accept()
threading.Thread(target=worker, args=(c,), daemon=True).start()

然后压 5000 个连接(用 wrk 或简单的 Python 脚本),观察进程的线程数和内存。

1
ps -o nlwp,rss,vsz -p $(pgrep -f thr_server.py)
1
2
NLWP      RSS      VSZ
5003 182340 41238520

5003 个线程,虚拟地址 41GB。这就是撑不住的样子,RSS 才 182MB,但虚拟地址已经 41GB,64 位地址空间能撑,但如果每线程栈不是 8MB 而是按需分配的,这个数字会小很多,不过内核仍要为每个线程维护 task_struct 和内核栈。

清理就一行。

1
pkill -f 'python3 thr_server.py'    # -f 是匹配整个命令行,写宽了会误杀,先 pgrep -af 看一眼再动手

看 epoll 的实际效果

用 Python 的 selectors 模块(底层是 epoll)写一个 echo 服务,代码短到一屏能看完。

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
# ep_server.py
import selectors, socket
sel = selectors.DefaultSelector()
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('0.0.0.0', 9500))
srv.listen(10000)
srv.setblocking(False)
sel.register(srv, selectors.EVENT_READ, data=None)

def accept(s):
c, _ = s.accept()
c.setblocking(False)
sel.register(c, selectors.EVENT_READ, data=b'')

def read(s):
try: data = s.recv(65536)
except Exception: data = b''
if data:
s.sendall(data)
else:
sel.unregister(s)
s.close()

while True:
for key, mask in sel.select(timeout=None):
(accept if key.data is None else read)(key.fileobj)
1
2
3
4
5
6
# 先抬到足够高,再起服务——ulimit 只影响当前 shell 及其子进程
ulimit -n 65535
python3 ep_server.py &
sleep 1
# 用任意压测工具打 5000 并发连接
top -b -n1 -p $(pgrep -f ep_server.py) | tail -2

5000 并发下 CPU 通常在个位数百分比,RSS 几十 MB。对比前面的一连接一线程,同样是 5000 连接,一个吃 41GB 虚拟地址,一个吃几十 MB。

数一数上下文切换

1
vmstat 1 5

关注 cs 列(context switches per second),空闲机器几千,一连接一线程的高负载机器几万到几十万。再往下拆到进程和线程粒度。

1
pidstat -w -p ALL 1 3

cswch/s 是自愿切换(等 IO),nvcswch/s 是非自愿(时间片用完),非自愿切换多说明 CPU 真的不够了,而不是模型问题。

最后看 epoll 实例的内部状态。

1
2
ls /proc/$(pgrep -f ep_server.py)/fd | wc -l
grep -c . /proc/$(pgrep -f ep_server.py)/fdinfo/4 2>/dev/null

/proc/PID/fdinfo/N 里能看到 epoll 实例的信息(tfd 行列出所有注册的 fd),这个文件行数约等于注册的连接数,它是排查连接泄漏的一个入口。

epoll 不是 O(1)

有人说 epoll 是 O(1) 的。不准确,epoll_wait 是 O(就绪数),epoll_ctl 是 O(log n),如果所有连接都活跃,它和 poll 差不多。有人说 select 的 1024 限制可以调大。FD_SETSIZE 是编译时宏,改了要重编 glibc,而且内核里的 select 实现仍然是 O(n),改大了只是更慢。有人说一连接一线程一定比 epoll 慢。在连接数少、请求重的场景下,线程池的吞吐和延迟往往更好,因为代码简单、没有状态机 bug。也有人说 Go 用的是 epoll 所以不会有一连接一线程的问题,这就更绕了,Go 的 goroutine 不是线程,但如果 goroutine 里做阻塞的 C 调用(cgo),会占住一个系统线程,runtime.LockOSThread 用多了同样会卡。用了 epoll 就等于高并发也是常见误解,还有四个上限在等着,fd、端口、内存、内核缓冲,十万连接要调的 sysctl 有十几项,光换模型不够。SO_REUSEPORT 也不是负载均衡的银弹,它按哈希分到各个 worker,连接数分布可能不均(同一个客户端 IP 总落到同一个 worker),Nginx 的 reuseport 还有长连接抖动问题。最后两个我听得最多。一个是 fork 已经被淘汰了,PHP-FPM、PostgreSQL(每连接一个后端进程)、Git 的 git-http-backend 都在用,隔离性有时候比性能重要。另一个是线程池大小应该设成 CPU 核数,这只对 CPU 密集型成立,IO 密集型的经验是 核数 × (1 + 等待时间/计算时间),实测往往是核数的 2~16 倍。

四种模型,同一道算术

  • 四种并发模型的边界都是每连接的成本,不是抽象的先进程度。
  • fork:0.5~1.5 毫秒一次,加每进程约 8~10KB 内核和页表。几百连接就到顶,但隔离性无可替代。
  • 一连接一线程:20~30 微秒一次,加 8MB 虚拟地址每线程。几千连接就撞 pids.max 或内存。
  • select/poll:O(n) 扫描,select 还有 1024 硬限制。几百到一千连接够用,代码最简单。
  • epoll:O(就绪数),靠内核就绪链表加 copy_to_user 拷回。十万连接级别才需要考虑。
  • ET 模式必须循环读到 EAGAIN;LT 模式安全但 epoll_wait 返回更频繁。业务系统默认用 LT。
  • 2026 年的现实答案:IO 多路复用解决等,有界线程池解决算。
  • 真正的并发上限是 fd、端口、内存、内核缓冲四件套,不是模型本身。

这几条我背了很久才真的用得上,刚入职的时候只知道 epoll 比 select 快,出了那次事故才知道快是相对什么快。(这篇里的 tcp_init() 那几行是从内核源码里翻出来的,文档里只写结果不写来历,我对着 tcp_rmem 的默认值核了半天)接下来把 epoll 单独拆开,select、poll、epoll 三个接口的完整签名、LT 与 ET 的实测差异,以及 epoll_wait 返回了可读却 read 到 0 字节这类经典坑。