从一连接一进程到 epoll:I/O 多路复用如何应对 C10K
C10K 讨论的是一台服务器如何同时维持一万条网络连接。这里的“一万”指并发连接数,不等同于每秒完成一万次请求。放到今天,这个数量并不夸张;但在“一条连接对应一个进程”的服务模型里,连接数增加意味着进程数也随之增加,服务器很快就会把大量资源花在连接之间的调度上。
I/O 多路复用改变的正是这一点:程序不再为每条连接准备一个独立进程,而是让少量线程同时观察大量文件描述符,只处理已经具备读写条件的连接。select、poll 和 epoll 都在解决这个问题,只是随着连接数上升,它们查找就绪事件的成本差别很大。
一条连接对应一个进程,为什么很难撑到一万条
早期网络服务常见一种直接的处理方式:主进程负责监听端口并接受连接,每当连接建立,就通过 fork() 创建一个子进程,由子进程负责这条连接后续的读写。监听和数据处理被分开以后,代码边界很清楚,单条连接的处理也相对独立。
问题出现在连接数持续增加之后。一万条连接可能意味着接近一万个子进程,而进程不是一个轻量的数字编号。每个进程都需要独立的任务结构、页表、栈和调度状态,系统还要在这些进程之间频繁切换。即使大量连接暂时没有数据可读,对应的进程依然占用系统资源。
这里有两个细节需要说准确。Linux 的 fork() 使用写时复制,创建子进程时不会立刻把父进程的全部内存完整复制一遍,但复制页表、创建内核任务结构仍有成本。子进程退出后也不会必然长期占用资源;只有父进程没有通过 wait() 一类调用回收其退出状态时,它才会以僵尸进程的形式保留少量信息。正确回收能解决僵尸进程问题,却不能消除大量进程本身的创建、调度和内存成本。
因此,单纯提高 CPU 和内存配置很难从根本上改善这个模型。连接越多,新增硬件中越大一部分会被进程管理消耗,垂直扩容的收益也会逐渐降低。
连接大多在等待,没必要各自占着一个进程
网络连接与持续运行的计算任务不同。很多时候,连接已经建立,却没有数据到达;如果此时直接执行阻塞式读取,执行线程只能停在那里等待。真正需要程序介入的时刻,是某个 socket 已经可读、可写,或者发生了异常。
在 Unix/Linux 中,socket 可以通过文件描述符表示。I/O 多路复用允许一个执行线程同时等待多个文件描述符:只要其中任何一个进入就绪状态,等待调用就返回,程序再处理对应连接。整体流程可以简化成:
1 | 注册或提交需要关注的文件描述符 |
select、poll 和 epoll 都提供了这样的能力。它们报告的是“现在执行某种 I/O 不会阻塞”这一就绪状态,并不会替应用程序完成业务读取、协议解析或响应发送。业务处理仍然要由用户空间完成。
select 能同时等待多个连接,但每轮都要检查全集
select 使用 fd_set 保存需要关注的文件描述符。调用时,程序把读、写和异常集合交给内核;内核检查指定范围内的描述符,返回时集合中只保留已经就绪的部分。由于集合会被修改,程序进入下一轮等待前通常还要重新构造它们。
这套机制解决了“一个进程只能阻塞等待一条连接”的问题,但在高并发下有两个明显限制。
第一个限制来自集合大小。在 Linux 的常见实现中,FD_SETSIZE 是 1024,select 只能监视编号小于这个值的文件描述符。它限制的是文件描述符编号,而不是简单地保证“最多有 1024 条连接”;标准输入、日志文件和监听 socket 等也会占用编号。对于要处理上万连接的服务,这个上限本身就不合适。
第二个限制是每轮等待的成本。应用程序每次调用都要提交整组描述符,内核按范围检查它们,返回后应用程序还要找出哪些描述符被标记为就绪。假设监视了一万个连接,而某一时刻只有十个连接有数据,检查工作仍然主要围绕那一万个候选项展开。并发规模越大、活跃连接比例越低,这部分无效工作越明显。
poll 去掉了固定集合,却没有去掉线性扫描
poll 与 select 的目标相同,只是它用一个动态的 pollfd 数组描述需要监视的文件描述符,不再受 fd_set 固定大小的约束。可监视数量仍会受到进程文件描述符上限和可用内存等系统资源限制,但不会卡在 FD_SETSIZE 这一接口约束上。
动态数组解决了容量问题,却没有改变每轮提交和检查整个关注集合的方式。随着数组增长,内核仍要线性检查其中的元素,应用程序也要从返回结果中找到发生事件的项。poll 因而可以承载比 select 更大的描述符集合,但当连接数量继续上升时,性能仍会受到扫描规模影响。
从 select 到 poll,变化主要是“集合能装多少”;要进一步改善高并发场景,还需要减少每次等待时对全部连接的重复检查。
epoll 把关注列表留在内核,只返回就绪事件
epoll 在内核中维护一个实例。从用户空间看,这个实例包含两类信息:一类是程序注册的关注列表,另一类是已经发生 I/O 活动的就绪列表。
程序先通过 epoll_ctl() 增加、修改或删除需要监视的文件描述符。等待事件时再调用 epoll_wait();如果当前没有事件,调用可以阻塞,一旦有连接就绪,内核返回就绪列表中的事件。关注集合没有发生变化时,程序不需要在每轮等待前把全部文件描述符重新提交给内核,也不需要从完整集合中逐个找出活跃项。
1 | ┌→ socket A |
这并不表示 epoll 的所有操作都是常数时间,也不表示连接增加后完全没有成本。注册和删除描述符、维护内核数据结构、复制已经就绪的事件都要消耗资源。它的关键优势在于:等待一批事件时,主要工作与实际就绪项相关,不必在每一轮重新扫描全部关注项。对于“连接很多、同一时刻只有少量连接活跃”的服务,这个差别尤其重要。
LT 和 ET 的区别,不只是通知次数多少
epoll 支持水平触发(Level-Triggered,LT)和边缘触发(Edge-Triggered,ET),默认使用 LT。
在 LT 模式下,只要文件描述符仍处于就绪状态,后续调用 epoll_wait() 时就可能继续收到通知。例如 socket 接收缓冲区里还有数据没有读完,它仍然是可读的。一次没有处理干净,下一轮还有机会继续处理,因此 LT 的行为更直观,容错空间也更大。
ET 关注的是状态变化。以读取为例,数据从“没有”变成“有”时产生通知;如果程序收到通知后只读取一部分,缓冲区仍处于可读状态,就不一定会再次出现一条新的边缘通知。实际使用 ET 时,通常需要把文件描述符设为非阻塞,并持续读取,直到返回 EAGAIN,表示当前数据已经取尽。
1 | LT:缓冲区仍有数据 → 仍可能继续通知 |
ET 可以减少重复通知,并让程序在一次唤醒中批量处理更多数据,但它不是脱离场景就必然更快的开关。处理循环更复杂,遗漏“读取到 EAGAIN”这一规则还可能让连接长时间得不到继续处理。是否选择 ET,要同时考虑事件频率、单次处理量和代码正确性。
使用 epoll 以后,C10K 也不是自动解决的
I/O 多路复用解决的是“如何高效发现哪些连接可以读写”。一个事件循环可以管理大量连接,不等于其中可以随意执行耗时操作。如果某个就绪事件触发了长时间计算、阻塞式磁盘访问或缓慢的下游请求,负责分发事件的线程仍可能被占住,其他已经就绪的连接只能等待。
实际服务还要面对文件描述符上限、每条连接的缓冲区和业务状态、接收与发送速度不匹配,以及工作线程如何分配等问题。epoll 去掉了“一条连接对应一个进程”带来的主要扩展障碍,却不会替应用程序处理这些边界。
C10K 值得记录的地方,不在“一万”这个数字,而在服务模型的变化:从给每条连接分配一个独立执行实体,转向集中等待就绪事件,再把有限的执行时间交给真正需要处理的连接。select 首先提供了同时等待多路 I/O 的能力,poll 放宽了集合容量,epoll 则通过内核中的关注列表和就绪列表,避免每轮都围绕完整连接集合做重复工作。