计算机网络-epoll和IO多路复用的比较说明

74 阅读16分钟

本文属于以下系列:

计算机网络-epoll和IO多路复用的比较说明
juejin.cn/post/751571…

计算机网络-epoll 支持 UDP 吗?
juejin.cn/post/757202…


目录

背景

epoll 是 Linux 系统的一个调用, 是一个高级的方便好用的数据调用, 是一个IO复用的概念.
然而,在我阅读游双的 Linux 高性能服务器编程的时候, 我发现这个书里面有很多东西还是讲的不清楚, 所以我单独再补充一下自己的理解. 帮助网路编程的新手朋友们学习.

函数列表

epoll 系统调用共有三个函数, 来写作完成epoll这个机制.

epoll_create

过于简单,简单介绍 int epoll_create(int size);

  • int size: 告诉内核, 安排多少监听的位子

  • 返回值 int: epoll机制专用的文件描述符, 但是也表现为一个文件描述符. 毕竟在 linux中, 一切皆为文件, 这样的抽象使得很多东西简单了很多.

epoll_ctl

int epoll_ctl(int efd, int op, int fd, struct epoll_event* event);

功能描述

用于控制 epoll 实例(由 efd 标识)上注册的文件描述符(fd)的监听行为,包括添加、修改或删除事件监听。

  • efd: epoll机制专用的文件描述符, 本质是一个注册在内核中事件表对象, 是一种数据结构(红黑树), 使用 epoll_create函数创建.

  • op: 操作类型, 有 add, mod, del

  • fd: 监听的socket对象

  • event: 如果一个fd要被监听,那么它要被监听哪些事件呢? event就是用来定义这个的.
    event的类型是 struct epoll_event, 可以指定用户自定义的回调函数, 还可以保存与之关联的监听socket的值, 也就是fd.

  • 返回值 int: 操作是否成功

需要说明的是, 参数event 指向的值最终是被拷贝到参数efd 指向的内核里的数据结构了, 所以这个参数往往可以被复用.

epoll_wait

int epoll_wait(int efd, struct epoll_event* events, int max_events, int timeout);

  • efd: epoll机制专用的文件描述符, 本质是一个注册在内核中事件表对象, 使用 epoll_create函数创建.

  • events: 一个数组, 一般来说, 这个数组就是用来存放哪些监听socket有事件发生了, 这个参数是用来回写的, 会写完成后, 内核会对events 进行排序,保证前面的元素都是有事件发生的

  • max_events: 需要监听的socket的数量, 它表示, 只需要监听 events数组前 max_events-1 个位置的socket 所以epoll只用扫描前 max_events 个数据, 避免了扫描events数组全部的元素 这类似于你定义了一个函数 int f1(int * arr, int len) 一样

  • timeout: 等待时间, -1表示一直阻塞

  • 返回值 int: 本次调用实际监听到了几个有事件发生的socket.

IO多路复用的功课补充

poll、epoll 和 select 都是 Linux/Unix 系统提供的 I/O 多路复用(I/O Multiplexing) 机制,用于同时监控多个文件描述符(如 socket、管道等)的可读、可写或异常状态。它们的主要区别在于 性能、可扩展性和实现方式。

多路复用,multiplexing,翻译比较拗口, 多路指的是多个客户端和服务器之间的连接,复用指的是使用一个机制,一个进程给管理起来,比如上面提到的那三个。

1. select(最早的多路复用机制)

特点

基于数组:使用 fd_set(固定大小的位图)存储文件描述符,通常限制为 FD_SETSIZE(默认 1024)。 线性扫描:每次调用 select() 都需要遍历所有文件描述符,检查状态变化。 跨平台支持:几乎所有操作系统都支持 select。

缺点

性能差:每次调用 select() 都需要重新传递所有文件描述符,内核需要重新扫描。 可扩展性差:FD_SETSIZE 限制了最大监控的文件描述符数量(通常 1024)。 效率低:每次调用都需要内核和用户空间之间复制整个 fd_set。

适用场景

小规模并发(如 <1000 连接)。 需要跨平台兼容性(如 Windows 也支持 select)。

需要补充的是,fd_set 并不是一个指针数组,而是一个 位图(Bitmask),即用 二进制位(bit) 来表示文件描述符(fd)是否存在。

关键点

每个文件描述符(fd)对应 fd_set 中的一个 bit: 如果 fd 在 fd_set 中,则对应的 bit 设为 1。 如果 fd 不在 fd_set 中,则对应的 bit 设为 0。 fd_set 的大小固定(通常 1024 位,即 FD_SETSIZE=1024),因此最多只能监控 1024 个 fd。

fd_set 的内存布局

fd_set 通常是一个 无符号长整型数组(unsigned long 数组),每个 unsigned long 占 64 位(64 位系统)或 32 位(32 位系统)。

计算 fd_set 所需的 unsigned long 数量

64 位系统: 每个 unsigned long = 64 bit FD_SETSIZE=1024 → 需要 1024 / 64 = 16 个 unsigned long 因此 fd_set 通常定义为 unsigned long fds_bits[16]

32 位系统: 每个 unsigned long = 32 bit FD_SETSIZE=1024 → 需要 1024 / 32 = 32 个 unsigned long 因此 fd_set 通常定义为 unsigned long fds_bits[32]

示例(64 位系统) typedef struct { unsigned long fds_bits[16]; // 16 * 64 = 1024 bits } fd_set;

如果 fd=5,则 fds_bits[5 / 64] 的第 (5 % 64) 位设为 1。 如果 fd=1023,则 fds_bits[15] 的第 63 位设为 1(因为 1023 / 64 = 15,1023 % 64 = 63)

2. poll(改进版 select)

在 Linux 的头文件 <poll.h> 中,struct pollfd 的定义如下:

struct pollfd { int fd; // 文件描述符(如 socket) short events; // 关注的事件(如 POLLIN、POLLOUT) short revents; // 实际发生的事件(由内核填充) }; fd:int 类型(通常 4 字节,32 位系统;或 4 字节,64 位系统,因为 int 在 Linux 中固定为 32 位)。 events 和 revents:short 类型(通常 2 字节)。

poll() 的函数原型如下:

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

fds:一个 struct pollfd 类型的数组,用于存储要监控的 fd 及其事件。 nfds:数组中有效 fd 的数量(不是数组总大小)。 timeout:超时时间(毫秒)。

关键点:

pollfd 结构体:每个 fd 的监控信息(fd 编号 + 关注的事件)都封装在这个结构体里。 每次调用 poll() 时,程序需要重新构造这个 fds 数组(但不需要重新初始化内核数据结构,因为 poll() 内部会维护状态)。

struct pollfd 的大小计算

(1)32 位系统 int fd:4 字节 short events:2 字节 short revents:2 字节 总大小:4 + 2 + 2 = 8 字节 (注:由于内存对齐,可能实际占用 8 字节,但无额外填充。)

(2)64 位系统 int fd:4 字节(int 在 Linux 中始终是 32 位,即使 64 位系统) short events:2 字节 short revents:2 字节 总大小:4 + 2 + 2 = 8 字节 (64 位系统下,struct pollfd 仍然通常是 8 字节,因为 int 和 short 的大小不变。)

如果有1万个连接,使用poll要传递8万个字节大小的连接数据,快80KB了。

特点

基于链表:使用 pollfd 结构体数组存储文件描述符,没有固定数量限制(仅受内存限制)。 仍需线性扫描:和 select 类似,每次调用 poll() 都需要遍历所有文件描述符。 无 FD_SETSIZE 限制:可以监控任意数量的文件描述符(但性能仍会下降)。

缺点

性能仍然较差:每次调用 poll() 都需要重新传递所有文件描述符,内核需要重新扫描。 不适合高并发:当文件描述符数量很大时(如 10万+),性能会显著下降。

适用场景

中等规模并发(如 1000~10万连接)。 需要比 select 更大的文件描述符监控能力。

3. epoll(Linux 特有的高效机制)

特点

基于事件驱动:使用红黑树存储文件描述符,仅监控发生变化的 fd,避免全量扫描。 回调机制:当文件描述符状态变化时,内核直接通知应用程序,无需轮询。 支持边缘触发(ET)和水平触发(LT):

LT(Level Triggered,默认):只要 fd 可读/可写,就会重复通知。 ET(Edge Triggered):仅在 fd 状态变化时通知一次,需一次性处理完数据。

优点

高性能:适合大规模并发(如 10万+ 连接),因为内核只返回变化的 fd。 低 CPU 占用:避免了 select/poll 的全量扫描问题。 无文件描述符数量限制:仅受系统内存限制。

缺点

仅限 Linux:Windows 和 macOS 不支持 epoll(Windows 使用 IOCP,macOS 使用 kqueue)。

适用场景

高并发服务器(如 Nginx、Redis、Netty 等)。 需要极致性能的 Linux 环境。

如何选择?

小规模并发(<1000 连接):select 或 poll 均可(select 兼容性更好)。 中等规模并发(1000~10万连接):poll 比 select 更好(无 FD_SETSIZE 限制)。 大规模并发(10万+ 连接):必须使用 epoll(Linux 特有)。 跨平台需求:select 或 poll(Windows 不支持 epoll)。

代码示例

select 示例

fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(sockfd, &read_fds);

struct timeval timeout = {5, 0}; // 5秒超时
int ready = select(sockfd + 1, &read_fds, NULL, NULL, &timeout);
if (ready > 0 && FD_ISSET(sockfd, &read_fds)) {
    // sockfd 可读
}

poll 示例

struct pollfd fds[1];
fds[0].fd = sockfd;
fds[0].events = POLLIN;

int ready = poll(fds, 1, 5000); // 5秒超时
if (ready > 0 && (fds[0].revents & POLLIN)) {
    // sockfd 可读
}

epoll 示例

int epfd = epoll_create1(0);
struct epoll_event ev, events[10];
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

int ready = epoll_wait(epfd, events, 10, 5000); // 5秒超时
for (int i = 0; i < ready; i++) {
    if (events[i].data.fd == sockfd) {
        // sockfd 可读
    }
}

结论

select:古老、兼容性好,但性能差,适合小规模并发。 poll:改进版 select,无 FD_SETSIZE 限制,但仍需全量扫描。 epoll:Linux 高性能 I/O 多路复用,适合大规模并发(如 Nginx、Redis)。

poll的更详细的性能损耗

poll() 函数的第一个参数 struct pollfd *fds 确实只是传递了一个指针(引用),而不是把整个 pollfd 数组拷贝到内核空间。

但它的底层实现机制比表面看起来更复杂,涉及用户态和内核态的数据交互:

  1. poll() 的参数传递机制 (1) 用户态传递的是指针

fds 是一个指向 struct pollfd 数组的指针,nfds 是数组中有效 fd 的数量。 当调用 poll() 时,用户态只是把指针 fds 和 nfds 的值传递给内核,而不是拷贝整个数组。

(2) 内核如何访问用户态数据?

内核通过 fds 指针直接访问用户态的内存,读取 pollfd 数组的内容。 这需要用户态和内核态之间的内存映射,通常通过 copy_from_user()(Linux 内核函数)实现。

  1. 为什么看起来像“只传指针”?

从用户态代码看:确实只传递了一个指针和 nfds,没有显式拷贝数据。 从内核态实现看:内核需要主动读取用户态内存中的 pollfd 数组,而不是内核自己维护一份副本。

  1. 内核态的 poll() 实现关键点 在 Linux 内核中,poll() 的系统调用最终会调用 do_sys_poll() 函数,其核心逻辑如下:

(1) 内核如何获取用户态的 fds 数据?

// 伪代码:内核态的 do_sys_poll() 核心逻辑
int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds, ...)
{
    struct pollfd *fds; // 内核临时缓冲区
    // 1. 从用户态拷贝 fds 数组到内核临时缓冲区(仅第一次调用时)
    if (copy_from_user(fds, ufds, nfds * sizeof(struct pollfd)))
        return -EFAULT;

    // 2. 遍历 fds 数组,检查每个 fd 的状态
    for (int i = 0; i < nfds; i++) {
        // 检查 fds[i].fd 的可读/可写状态
        // ...
    }

    // 3. 将结果(revents)写回用户态的 fds 数组
    if (copy_to_user(ufds, fds, nfds * sizeof(struct pollfd)))
        return -EFAULT;

    return ready_fds_count;
}

copy_from_user():将用户态的 fds 数组拷贝到内核临时缓冲区(仅第一次调用时需要)。 copy_to_user():将内核计算出的 revents 结果写回用户态的 fds 数组。

  1. 为什么是每次调用都全量拷贝?

第一次调用 poll():内核需要从用户态拷贝 fds 数组到内核空间(因为内核需要知道监控哪些 fd)。 后续调用 poll():如果 fds 数组没有变化(用户态程序没有修改它),理论上可以复用之前的数据。

但:

poll() 是无状态的:内核不会记住上一次调用的 fds 数组,每次调用都必须重新检查用户态的 fds(因为用户态程序可能在两次调用之间修改了 fds)。 安全性要求:内核不能假设用户态内存内容不变,必须每次重新读取。

  1. 性能影响

数据拷贝开销:每次 poll() 调用都需要从用户态拷贝 fds 数组到内核态(即使内容没变),这会带来一定的 CPU 和内存开销。 全量扫描开销:内核需要遍历整个 fds 数组检查每个 fd 的状态,无法跳过未变化的 fd。

这也是为什么 epoll 被发明出来:它通过内核维护 fd 状态,避免了每次全量拷贝和扫描。

  1. 对比 select() 的类似机制 select() 的参数传递方式类似:

用户态传递 fd_set 指针(位图)。 内核通过 copy_from_user() 读取用户态的 fd_set。 但 select() 的 fd_set 有固定大小限制(FD_SETSIZE=1024),而 poll() 无此限制。

  1. 总结

关键点 / 说明

用户态传递的是指针/ poll() 的 fds 参数是一个指针,用户态只传递指针和 nfds,不拷贝整个数组。

内核需要主动读取用户态/ 内存内核通过 copy_from_user() 从用户态拷贝 fds 数组到内核临时缓冲区。

每次调用都需重新读取/ 由于 poll() 无状态,内核无法记住上一次的 fds,必须每次重新读取用户态数据。

性能瓶颈/ 数据拷贝 + 全量扫描导致 poll() 在高并发场景下性能较差。

epoll的优化/ 内核维护 fd 状态,避免全量拷贝和扫描,适合大规模并发。

  1. 附加问题:为什么 poll() 不直接在内核态维护 fds?

设计目标不同:poll() 是一个简单的多路复用机制,目标是轻量级,而不是高性能。
用户态灵活性:允许用户态程序在两次 poll() 调用之间动态修改 fds 数组(如新增或删除 fd)。
历史原因:poll() 出现较早,当时硬件性能较低,复杂的内核态维护机制可能得不偿失。

相比之下,epoll 通过牺牲一定的复杂性(内核维护红黑树和就绪链表),换取了更高的性能。

epoll到底比poll改进了什么呢?

epoll 不需要像 poll 那样每次调用都传递全量的监听文件描述符列表,这是 epoll 相比 poll 和 select 的核心优势之一。

到底什么是阻塞?

当进程/线程执行某个 I/O 操作(如 read()/write())时,如果数据未就绪或缓冲区不可用,进程会 停止执行(挂起) ,直到 I/O 操作完成或条件满足**。

阻塞是针对当前正在执行的 I/O 操作,也就是线程或者进程卡在read/write那一步了,而不是整个文件描述符(fd)或程序。

ET模式 VS LT模式

ET(Edge Triggered,边缘触发)模式是一种事件通知机制,它与 LT(Level Triggered,水平触发)模式相对。

ET 模式适合以下场景:

  1. 高性能、高并发的网络服务器**

ET 模式在高并发、高吞吐量的场景下表现更好,因为它减少不必要的 epoll_wait 唤醒,从而降低 CPU 开销。

当你的程序可以自己保证处理完所有数据你不需要被反复提醒,选择ET模式(如一次性读取完 socket 缓冲区的数据)

  1. 适合事件驱动架构

ET 模式更适合事件驱动编程模型,因为它只在状态变化时通知一次,程序需要主动轮询或使用缓冲区管理来确保数据完整处理。

  1. 低延迟实时系统(如游戏服务器)

LT 模式可能因为频繁唤醒导致事件处理延迟

ET严格的一次性通知,确保事件不会被“淹没”在无效唤醒中。

虽然ET提醒的少,但是ET把复杂的逻辑交给了下游自行处理,是比较复杂的。

为什么 epoll 默认不用 ET 模式?

ET模式下,在监听文件描述符时,数据来的时候只提醒一次,除非第二次数据到来。所以必须把数据读干净,这并不容易,就想搬运东西一样,一样不落不容易!

尽管 ET 模式性能更高,但 默认使用 LT 模式 的原因在于:

LT 模式更容错,适合大多数场景

  • LT 模式下,即使程序只读取部分数据,epoll_wait 也会再次通知,确保数据最终被处理。
  • 编程更简单:开发者不需要严格保证“一次性读完”,减少出错概率。
  • 适用于不可控环境:比如网络抖动、数据分片到达等情况,LT 模式能自动适应。

ET 模式对编程要求苛刻

  • 必须非阻塞 I/O + 循环读取,否则可能丢数据或阻塞。
  • 必须处理所有错误情况,否则可能导致连接泄漏或数据损坏。

一次性读完数据很难吗?

“一次性读完数据”  看起来可能很简单(比如循环调用 read() 直到返回 EAGAIN),但在实际编程中,它可能比想象中更复杂,尤其是在高性能服务器、复杂协议处理、或高并发场景下。

数据可能分多次到达(TCP 流式特性)。 TCP 是流式协议,数据可能被拆分成多个包传输,也可能合并多个逻辑消息到一个包。

即使 read() 返回大量数据,也不代表所有数据都已到达(可能只是当前 TCP 窗口允许的数据量)。

如果程序假设 read() 一次就能读完所有数据,可能会导致: (1)消息截断(如一个完整的 HTTP 请求被拆分成多次 read())。(2) 数据处理错误(如解析协议时漏掉部分数据)。

缓冲区管理复杂。有的时候真的读不干净,这个是相对于TCP更上级的应用程序的问题。

参考

[1] 游双, 《Linux 高性能服务器编程》