Marst·Zhang头像
(Unix)epoll-reactor事件循环 1 万连接、几百个活跃封面图

(Unix)epoll-reactor事件循环 1 万连接、几百个活跃

1 万连接、几百个活跃:C10K 的正解不是更快的 I/O,是让空闲连接几乎不花钱

请添加图片描述

1999 年,Dan Kegel 在自己主页上写了一段话,大意是:

一台 1000 MHz、2 GB 内存、千兆网卡的机器,市价约 1200 美元。要伺候两万个客户端,平均每个客户端只分到 50 kHz 的 CPU、100 KB 内存、50 Kbit/s 带宽。这点资源够不够?够 —— 它只要每秒从磁盘读 4 KB 再发出去而已。硬件早就不是瓶颈了,瓶颈在软件。

这段话后来成了一个专有名词:C10K 问题 —— 单机同时处理 1 万个并发连接。

注意,它说的是"并发连接",不是"并发请求"。这两个词的差别,是整篇文章的关键。


一、C10K 难的不是"1 万个在干活",是"1 万个在挂着"

大多数人对 C10K 的想象是这样的:1 万个客户端同时发请求,服务器得同时处理 1 万件事。

真实的流量模型不是这样。

除了一小部分确实繁忙的服务,绝大多数长连接场景 —— IM 在线状态、推送通道、物联网设备、WebSocket —— 都是连接很多,但同一时刻活跃的极少。

Kegel 自己那篇文档里的场景是给两万个客户端每秒发一次 4 KB 的数据;而一个千万级在线的推送系统,同一秒真正有数据往来的连接,可能只占百分之几。

〔分析〕所以 C10K 的真正难点不是"算不过来",而是"养不起":

  • 1 万个连接,每个都要占内核内存、fd(文件描述符,进程持有的一个整数句柄,代表一个打开的连接)、定时器
  • 但其中 9900 个此刻什么都不做,只是在等
  • 而你为"等"这件事付出的代价,取决于你用哪种模型去等

这就是全部问题的来源。三种模型,三张完全不同的账单。


二、三种模型,三张账单

在往下看之前,先约定一个词:就绪(readiness)。它指某个 fd 此刻"能立刻做点什么而不用等"—— 有数据可读、发送缓冲区可写、或者有新连接等着被 accept。这个词后面会反复出现。

先看最直觉的那种:一个连接一个线程。

模型1 万连接的代价问题在哪
一连接一线程线程栈默认 8 MB,1 万 × 8 MB = 80 GB 虚拟内存光栈就把地址空间吃光,还没算上下文切换(线程间切换的代价)
select / poll每次调用把 N 个 fd 拷进内核,内核线性扫 N 个开销随"总连接数"增长,不管有几个活跃
epoll注册一次;每次只取回就绪的那 k 个开销随"就绪数"增长

第一行值得停一下。8 MB 线程栈是 Linux 的默认值(ulimit -s 查得到)。虽然它是虚拟内存、按需分配,但 1 万个线程光是内核里的 task 结构和栈页就够呛。

更致命的是上下文切换:每个线程阻塞在 read() 上时,内核要把它挂起、等数据到了再唤醒。1 万个线程就是 1 万次挂起和唤醒的潜在开销,而其中绝大多数时候,唤醒后发现"哦,只来了 3 个字节"。

第二行是这篇文章要拆的核心。select 的接口长这样:

select(nfds, &readfds, &writefds, NULL, &timeout);

readfds 是一个位图(bitmap,每一位代表一个 fd 关不关心),最大 FD_SETSIZE = 1024 —— 这就是那个著名限制的来源。poll 把它换成数组,去了 1024 的限制,但接口形状没变:

poll(struct pollfd *fds, nfds_t nfds, int timeout);

每次调用,你都要把"我关心哪些 fd"完整地说一遍。

〔分析〕这句话是理解 epoll 的全部钥匙。想想你每天用 select 的方式:在一个循环里,每次循环开头把所有 fd 重新塞进 fd_set,调用,返回后遍历整个位图找出哪几位被置位,处理,然后再来一遍。

你每秒要重复一万次"我关心这 1 万个 fd"这句话。而其中 9999 次,回答都是"没有"。


三、epoll 拆开的到底是什么

epoll 把这一句话拆成了两句,分别放进两个系统调用:

epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);   // 一次:我关心这个 fd
epoll_wait(epfd, events, maxevents, -1);   // 反复:现在谁就绪了?

第一句只在连接建立时调用一次。之后就是无限循环地只问第二句。

内核里对应的结构是这样(简化自 fs/eventpoll.c)。两个关键结构:一棵红黑树(自平衡二叉查找树,插入删除都是 O(log N))装全部注册项,一个双向链表装已经就绪的项:

        struct eventpoll
        ┌──────────────────────┐
        │  rbr  ──▶ 红黑树      │  所有注册进来的 fd,按 fd 号排序
        │                      │  插入/删除 O(log N)
        ├──────────────────────┤
        │  rdlist ─▶ 就绪链表   │  已经有事件的 fd,挂在双向链表上
        └──────────────────────┘
                 ▲
                 │ 内核在注册时,往这个 fd 的等待队列上挂一个回调
                 │ ep_poll_callback():一有事件就把这条记录塞进 rdlist

这里说的"回调",就是一段你注册给内核、由内核在事件发生时替你调用的函数 —— 你不需要盯着它,它自己会来。

于是 epoll_wait 的工作变成了:看一眼 rdlist 空不空。空了就睡,非空就把链表上的 k 个事件拷给用户态。

它不再需要知道总共有多少个 fd。

这个"拆开"的思路不是 Linux 拍脑袋想出来的。

1998 年,Banga 和 Mogul 在 USENIX ATC 上发了一篇讲服务器扩展性的论文,指出 select() 和 fd 分配算法都随连接数扩展得很差。改掉这两处之后,Web 代理和 Web 服务器的吞吐最多提升了 58%。

次年,同一批作者提出了更彻底的接口划分:declare_interest() 声明兴趣,get_next_event() 取回事件 —— 兴趣集合在内核里增量维护,事件显式排队交付。

这就是 epoll_ctl + epoll_wait 的原型。 epoll 是这条研究线索在 Linux 上的工程落地(2002 年,内核 2.5.44)。

〔分析〕所以"epoll 是一个更快的 poll"这个理解,从方向上就错了。它们解决的根本不是同一个问题:poll 问的是"现在这 N 个里面谁就绪",epoll 问的是"从我上次问完之后,谁变成就绪了"。前者是一次性查询,后者是状态订阅。

机制之上:reactor 与 proactor

epoll 只解决了"谁就绪了"。怎么用这个答案,是另一层的事 —— 这层叫 reactor(反应器)模式,骨架只有三行:

while (running) {
    n = epoll_wait(epfd, events, MAX, -1);   // 问:谁就绪了
    for (i = 0; i < n; i++)                   // 答:一个个分派
        dispatch(&events[i]);                 // 调这个 fd 注册的回调
}

epoll 是内核机制,reactor 是用户态的组织方式。 前者负责"通知",后者负责"通知之后叫谁" —— 这两件事常被混着说,但它们不在同一层。

Redis 是最干净的实例:单线程跑这个循环,所有命令都在 dispatch 里同步执行完。它敢说"单线程还这么快",是因为每条命令都短到不影响下一次循环。

Nginx 走的是另一条路:master 不处理连接,只 fork 出若干 worker,每个 worker 各自跑一个 epoll 循环。一个 epoll 实例扛不住多核,那就起多个实例 —— 用进程数换核数,而不是用线程数换连接数。

reactor 的反面叫 proactor(前摄器):reactor 给"就绪通知"(可以读了,你自己读),proactor 给"完成通知"(我已经替你读完,结果放这儿了)。Windows 的 IOCP 是 proactor,Linux 的 io_uring 也是。

〔分析〕这个区别解释了为什么 io_uring 是一次范式升级而不只是性能优化:reactor 模型里那个 dispatch 内部必然藏着一次同步的 read(),proactor 里没有这一步。


四、但"epoll 是 O(1)"是句误传

几乎所有讲 epoll 的文章都会写"epoll 是 O(1),select 是 O(N)"。这句话流传太广,以至于成了面试标准答案。

它不严谨。真实情况分三段:

操作复杂度说明
epoll_ctl 增删改O(log N)红黑树插入/删除
fd 就绪时挂进链表近似 O(1)内核回调 ep_poll_callback
epoll_wait 返回 k 个事件Θ(k)只处理就绪链表,与总注册数无关

而且还有一笔常被忽略的成本:从就绪链表里取出 k 个事件之后,内核还要对每个 fd 重新核对一次当前状态(因为从"标记就绪"到"你真正来取"之间,状态可能又变了)。这一步同样是 Θ(k)。

所以准确的说法是:

epoll 的开销与"就绪数 k"相关,与"注册总数 N"无关。

〔分析〕这个区分不是抠字眼,它决定了你什么时候不该用 epoll。如果所有连接都一直活跃(k ≈ N),那 epoll 那个"回调 + 链表 + 重新核对"的开销,反而比 poll 的一次线性扫描更亏。epoll 赢在 N 大而 k 小,不是赢在"更快"。

这也解释了为什么 Redis、Nginx 这类"连接多、事件少"的服务用 epoll 收益巨大,而一个只有几十个连接的小工具,用 poll 反而更简单也更快。


五、代价清单

5.1 内存:省下的 CPU,换成了常驻内存

一个空闲的 TCP 连接,在内核里要占:struct socket + struct sock + struct tcp_sock + 收发缓冲区。

综合下来,业界普遍的估算是每个空闲连接 2–4 KB 内核内存。有人在 Linux 上实测过 10 万个纯空闲连接:应用进程本身占了不到 1 MB,但系统内存少了约 200 MB —— 折合每连接 2 KB 左右。

按这个数算 100 万连接:内核侧就是 2–4 GB 常驻,加上 epoll 为每条注册维护的记录节点、应用层的连接状态,工程上通常要按 16 GB 级别准备内存。

〔分析〕这就是"一个线程扛百万连接"这个说法的真实含义:它维持得住,但不是免费维持。省下的是线程栈和上下文切换,付出去的是每个连接一份常驻内存。这笔账在连接数少的时候完全看不出来,到百万级才变成主成本。

顺带一提 fd 数量:默认 ulimit -n 是 1024,一百万连接要把它和 fs.file-max 一起调上去 —— 这是 C1000K 实践里第一道坎,比代码难。

5.2 水平触发和边缘触发:一个会丢事件的开关

epoll 有两种通知模式:

  • LT(水平触发,默认):只要 fd 还可读,每次 epoll_wait 都告诉你。写起来安全,忘了处理下次还有机会。
  • ET(边缘触发,EPOLLET):只在状态从"不可读"变成"可读"的那一刻通知一次。之后你不再处理,它就不说了。

ET 更快(通知次数少),但它有个硬要求:必须配非阻塞 fd,而且必须循环读到返回 EAGAIN("此刻没数据了"的错误码)为止。

原因是内核只告诉你一次"有新数据",但那次可能来了 10 KB,你只读了 4 KB 就回去等了 —— 剩下的 6 KB 就在缓冲区里躺着,而内核认为"我已经通知过了",不会再叫你。

〔分析〕这是 epoll 最经典的生产事故:程序看起来完全正常,压测也没问题,但在流量突增时偶发"消息卡住不处理"。根因就是 ET 模式下少写了一个 while。

5.3 惊群:N 个线程同时被叫醒,只有 1 个能干

多线程或多进程同时 epoll_wait 同一个实例时,一个新连接到来会唤醒所有等待者,但只有一个能 accept 成功。其余白跑一趟 —— 这叫惊群(thundering herd)。

Linux 4.5 加了 EPOLLEXCLUSIVE 标志,注册时带上它,内核就只唤醒一个。

〔分析〕惊群这个问题的本质是:"事件发生了"和"谁去处理它"是两件事,而 epoll 的接口只表达了前一件。 这是它抽象里缺的那一块,只能靠后来的标志位补。

5.4 一个线程扛住,业务代码被切碎

这是最容易被低估的代价,因为它不在系统层,在人的层面。

线程模型里,你的代码是顺序的:读请求 → 查数据库 → 写响应。事件循环里,这三步必须拆成三个回调,中间状态存在一个连接上下文对象里。查数据库这种阻塞操作,要么改成异步,要么丢进线程池 —— 一旦丢进线程池,你就又有了线程,只是数量少了。

〔分析〕回调地狱不是"代码难看"这么简单。它真正的问题是错误处理被打散:一个请求的生命周期跨了五个回调,任何一步失败,你都要在那个回调里重新构造上下文、决定怎么回滚、怎么通知上一层。

这也是为什么大多数语言最终都给了你 async/await —— 目的就是把回调重新缝成看起来顺序的代码。

5.5 不是所有场景都该上 epoll

场景更合适的方案
连接数少(几十到几百),且大多活跃poll 更简单,性能差距可忽略
需要跨平台(BSD/macOS/Windows)kqueue / IOCP,或直接用封装库
大量磁盘 I/Oepoll 只擅长网络就绪,磁盘文件永远"就绪"

最后一行是个陷阱:把普通文件 fd 加进 epoll,它会立刻返回可读 —— 因为磁盘文件没有"数据还没到"这个概念。想异步读磁盘,得用 io_uring 或者 AIO。


六、那又怎样

一、epoll 的贡献是"拆开",不是"加速"。

它把两件事分开了:声明兴趣(注册一次)和取回事件(每次只取变化的)。省下的全部性能,都来自"不再重复陈述那些没变化的事实"。

〔分析〕这个模式和这个系列前几篇讲的是同一件事:

机制被拆开的两件事
管道程序的功能 与 程序的连接方式
一切皆 fd资源的名字 与 资源的句柄
fork / exec进程的创建 与 程序的加载
虚拟内存地址的名字 与 物理位置
epoll对什么的兴趣 与 此刻的事件

五次拆解,五次都让系统在某个维度上从 O(N) 掉到 O(1) 附近。这不是巧合 —— 复杂度的爆炸,几乎总来自"每次都要重新陈述一遍全部状态"。

二、"一个线程扛百万连接"是个准确的描述,但被误读了。

准确的部分:单线程的事件循环,确实能管理百万级连接。
被误读的部分:它并不"同时处理"百万个请求。

真相是:这一百万个连接里,同一瞬间只有极少数就绪,事件循环只处理那几个。线程数从 1 万降到 1,不是因为一个线程变强了,是因为另外 9999 个"等待"被交给了内核。

〔分析〕这个区别在容量规划上是致命的。如果你按"1 个线程能处理 100 万请求"去设计,上线就会崩;正确的模型是"1 个线程维持 100 万连接,每秒处理其中 k 个就绪事件",而 k 决定了你需要几个 worker。

三、下一步已经来了。

epoll 解决的仍然是"就绪通知":它告诉你"可以读了",读还得你自己调 read(),还是同步拷贝。

2019 年进入内核的 io_uring 干的是下一层拆解:把"提交请求"和"收割结果"也拆开,中间那层系统调用直接省掉。这是同一个思路的延长线。


写在最后

回到 Kegel 那句话:硬件早就不缺了,瓶颈在软件。

二十多年过去,这句话依然成立,但瓶颈的位置变了。1999 年缺的是"一个能同时看住一万个 fd 又不用每次重述的接口";今天缺的是"别让那一百万个沉默的连接,每个都吃掉 4 KB 内存"。

而 epoll 留给我们的,其实不是一个系统调用,是一种提问方式:

别问"我要怎么更快地处理这么多连接"。先问"这堆连接里,此刻真正需要处理的,有几个?"

epoll 没有让网络变快。它只是让那 9900 个沉默的连接,几乎不再花钱。


参考资料

  1. Dan Kegel, The C10K problem, 1999(kegel.com/c10k.html)—— C10K 的原始提出、两万客户端的资源估算、以及"硬件不再是瓶颈"的判断
  2. G. Banga, J. C. Mogul, Scalable Kernel Performance for Internet Servers Under Realistic Loads, USENIX ATC 1998 —— select() 与 fd 分配算法的扩展性问题,改写后吞吐最多提升 58%
  3. G. Banga, J. C. Mogul, D. P. Druschel, USENIX ATC 1999 —— 提出 declare_interest() / get_next_event() 的接口划分,是 epoll_ctl + epoll_wait 的直接前身

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/marst437730201/article/details/169239151

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--