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

服务器并发:从 fork 到 epoll,四条路各自死在哪
隔壁老康难度 ★★★★★ · 前置:TCP 的 11 个状态:TIME_WAIT 是好的,CLOSE_WAIT 才是 bug | 一句话结论:四种并发模型的边界都是同一个数字,每连接的成本。
fork是几毫秒加一个进程的调度开销,线程是几十微秒加 8MB 虚拟地址,select是每次轮询扫全部 fd,epoll是每次只取就绪的。知道成本在哪,才知道几百连接和十万连接该用哪个。
并发涨了十倍,三条报错一起来
2025 年夏天我给一个内部服务做扩容,平时二百并发跑得好好的,搞了一次活动,并发涨到三千,然后监控墙上同时亮了三盏灯,三种错误三种颜色,看着像三件完全不同的事。
1 | Apr 12 10:23:41 web-02 kernel: cgroup: fork rejected by pids controller in /system.slice/app.service |
三条报错背后是三个不同的限制,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 | while (1) { |
它死在三件事上。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 | while (1) { |
三个数字卡住它。虚拟地址上,Linux 默认线程栈 8MB(ulimit -s),3000 个线程就是 24GB 虚拟地址,虽然是虚拟的不实际占用物理内存,但 64 位地址空间也不是无限的,更现实的问题是内核要为每个线程维护 mm_struct 的引用、task_struct、内核栈。线程数上限是三层限制层层收紧。
1 | ulimit -u # 用户的进程/线程数上限 |
容器里最常见的死法是第三层,Deployment 里没写 pids limit 的话,某些运行时默认值很小。上下文切换也躲不掉,每个线程阻塞在 read() 上等数据,内核要把它从 TASK_RUNNING 挪到 TASK_INTERRUPTIBLE,数据到了再挪回来,vmstat 的 cs 列一旦超过每秒几万,就能明显感到响应变慢。但线程模型在 IO 密集型下表现并不差,Nginx 作者说过,如果不考虑内存,一连接一线程是可用的,问题就是内存,每线程 8MB 虚拟地址乘以一万连接等于 80GB 虚拟地址,光页表就要几十 MB。
select 和 poll 为什么绕不开 O(n)
select 的用法是把所有关心的 fd 塞进一个位图,然后交给内核等。
1 | fd_set rfds; |
每次调用 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 | int ep = epoll_create1(0); |
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 | // fork_bench.c |
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 | // thread_bench.c |
1 | gcc -O2 -pthread thread_bench.c -o thread_bench && time ./thread_bench |
1000 次大约 20~30 毫秒,即每次 20~30 微秒。
线程比进程快 20~50 倍,但两者都是每连接一次的成本,连接数一多,都不可接受。
观察线程模型的死法
写一个最朴素的一连接一线程服务,把连接数压上去看它长什么样。
1 | # thr_server.py |
然后压 5000 个连接(用 wrk 或简单的 Python 脚本),观察进程的线程数和内存。
1 | ps -o nlwp,rss,vsz -p $(pgrep -f thr_server.py) |
1 | NLWP RSS VSZ |
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 | # ep_server.py |
1 | # 先抬到足够高,再起服务——ulimit 只影响当前 shell 及其子进程 |
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 | ls /proc/$(pgrep -f ep_server.py)/fd | wc -l |
/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 字节这类经典坑。








