背八股背到 IO 多路复用的时候,select、poll、epoll 的区别背得滚瓜烂熟。面试官要是问"epoll 为什么比 select 快",我能答出红黑树、就绪链表、回调机制。

但有一天突然被问了一句:fd 是什么?

我愣住了。“文件描述符……是一个整数……代表一个文件?“说完自己都觉得心虚。接着又想——IO 到底是什么意思?不就是"输入输出"吗?但"输入输出"到底是个什么动作?

发现自己一直在背结论,但底层的东西完全没搞清楚。这篇从头捋一遍:一切皆文件 → fd → IO → 阻塞 → 多路复用。


一、一切皆文件

Unix 的核心设计哲学

1974 年,Dennis Ritchie 和 Ken Thompson 在那篇著名的 The UNIX Time-Sharing System 论文里,提出了一个影响深远的设计:把所有资源都抽象成文件

什么意思?在 Unix/Linux 里,不只是磁盘上的 .txt.cpp 是文件,下面这些东西在内核看来也都是"文件”:

资源对应的"文件”例子
普通文件磁盘上的数据/home/user/code.cpp
目录特殊的文件/home/user/
键盘、显示器字符设备文件/dev/tty
磁盘、U盘块设备文件/dev/sda
网络连接socket 文件没有路径,内核内部的对象
进程间管道pipe 文件ls | grep foo 中的 |
内核信息伪文件系统/proc/cpuinfo/sys/

Linus Torvalds 后来把这个哲学说得更直白:“Everything is a file descriptor or a process.” 关键不在于什么都叫"文件",而在于你可以用同一套接口(read/write/close)操作所有这些不同的东西

内核怎么做到的?VFS

不同的资源底层实现完全不同——读磁盘文件走的是 ext4 文件系统,读网络数据走的是 TCP/IP 协议栈,读 /proc/cpuinfo 根本不涉及磁盘。怎么让它们都支持同一个 read() 调用?

答案是 VFS(Virtual File System,虚拟文件系统)。它是内核里的一层中间抽象,定义了一组统一的接口(struct file_operations),里面有一堆函数指针:

struct file_operations {
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    __poll_t (*poll)(struct file *, struct poll_table_struct *);
    // ... 还有很多
};

不同类型的"文件"各自实现这些函数。ext4 实现了自己的 read/write,TCP socket 实现了自己的 read/write,/proc 也实现了自己的。当你调用 read(fd, buf, size) 时,VFS 根据 fd 找到对应的 file_operations,调用里面的 read 函数指针——你不需要知道背后是磁盘还是网卡。

这就是"一切皆文件"的真正含义:不是说什么都存在磁盘上,而是什么都可以通过 fd + read/write 来操作。


二、文件描述符(fd)

fd 就是一个下标

理解了"一切皆文件"之后,fd 就好说了:fd(file descriptor)是进程打开文件表里的一个整数下标,仅此而已。

每个进程在内核中有一个 task_struct,里面有个 files_struct,再里面有个 fdtable——这就是文件描述符表,本质上就是一个数组。你拿到的 fd 就是这个数组的索引。

用 POSIX 的术语说:fd 是一个"file descriptor",它引用一个"open file description"(内核中的 struct file)。注意这两个概念不一样——fd 是进程级的索引,struct file 是内核级的对象。

三层映射

层级数据结构说明
用户态fd(一个 int)你拿到的"票号",比如 3、4、5
进程级fdtable(数组)fd 是下标,指向 struct file *
内核级struct filestruct inode打开文件的状态(偏移量、读写模式)→ 文件的元数据(大小、权限、数据位置)

打个比方:fd 就像电影院的座位号。你拿着"3号"这张票(fd=3),找到座位(struct file),座位上放着一部电影的胶片(struct inode)。两个人可以拿不同的票(不同的 fd)坐到同一个座位上(同一个 struct file,比如 dup() 之后),甚至不同座位放的可能是同一部电影(同一个 struct inode,比如硬链接)。

0、1、2 的约定

每个进程启动时,fd 表里已经预装了三个:

fd名称连接的设备
0stdin(标准输入)通常是键盘
1stdout(标准输出)通常是终端
2stderr(标准错误)通常也是终端

所以你 open() 一个新文件,拿到的 fd 从 3 开始——因为 0、1、2 已经被占了。内核总是分配当前最小的可用 fdget_unused_fd_flags() 的实现就是这么做的)。

重定向的秘密

shell 里的 > 重定向,原理就是 close(1) 关掉 stdout,然后 open("output.txt")——因为 1 刚被释放,它就是当前最小的可用 fd,所以 open 返回 1。于是 printf(往 fd 1 写)就写到了文件里。没有什么魔法,就是利用了 fd 分配的规则。

实际上 shell 用的是 dup2(fd, 1),更安全。效果一样:把 fd 1 指向新的 struct file


三、IO 是什么

不是一个抽象概念

IO = Input/Output。但它不是一个形容词或者概念,它是一个具体的数据搬运动作

当你写 read(fd, buf, 4096) 的时候,你在干一件很具体的事:让内核把数据从它那边(内核缓冲区),拷贝到你这边(用户空间的 buf)

读文件时发生了什么

read(fd, buf, size) 读一个磁盘文件为例:

  1. 用户态调用 read(),触发系统调用,CPU 切到内核态
  2. 内核根据 fd 查 fdtable,找到 struct file,再找到 struct inode,知道数据在磁盘的哪个位置
  3. DMA 把数据从磁盘搬到内核的 page cache(这一步不需要 CPU 参与)
  4. CPU 把数据从 page cache 拷到用户空间的 bufcopy_to_user()
  5. 返回用户态

注意第 3 步和第 4 步:一次 read() 涉及两次数据搬运——磁盘到内核(DMA完成),内核到用户(CPU完成)。这就是传统 IO 的基本路径。

page cache 是内核在内存中维护的文件数据缓存。如果你连续读同一个文件两次,第二次直接从 page cache 拿,不用再访问磁盘。

网络 IO 也一样

网络 IO 的路径几乎一模一样,只是数据来源从磁盘变成了网卡:

  1. 网卡收到数据包,DMA 把数据写入内核的 socket 接收缓冲区sk_buff 结构体)
  2. 用户调用 read(socket_fd, buf, size)
  3. 内核通过 tcp_recvmsg() 把数据从 socket 缓冲区拷到用户空间的 buf
  4. 返回用户态

看到了吗?不管是读文件还是读网络,本质上都是同一件事:从内核缓冲区拷数据到用户空间。这就是 IO。

IO 的本质:两个阶段

W. Richard Stevens 在 Unix Network Programming 第 6.2 节里,把 IO 操作拆成了两个阶段:

阶段在干什么谁在等
第一阶段:等数据到达数据从外部设备(磁盘/网卡)到内核缓冲区进程可能在睡觉
第二阶段:拷数据走数据从内核缓冲区到用户空间CPU 在忙

不同的 IO 模型,区别就在于这两个阶段的处理方式不同。 阻塞 IO 是两个阶段都等;非阻塞 IO 是第一阶段不等(没数据就立刻返回错误);IO 多路复用是把第一阶段的"等"集中到一个地方统一管理。

理解了这两个阶段,后面的内容就水到渠成了。


四、阻塞 IO 的困境

一个连接一个线程

前面说了 IO 分两个阶段。而默认情况下,read()阻塞的——第一阶段数据还没到,进程就被挂起了,干等着,直到数据来了才醒过来。

这对于单个连接来说没什么问题。但如果你要写一个网络服务器,同时处理成百上千个客户端呢?

最直觉的方案:每来一个连接,就 fork 一个子进程(或者 create 一个线程)去处理它

while (1) {
    int conn_fd = accept(listen_fd, ...);  // 阻塞等新连接
    if (fork() == 0) {
        // 子进程
        while (1) {
            int n = read(conn_fd, buf, sizeof(buf));  // 阻塞等数据
            if (n <= 0) break;
            write(conn_fd, buf, n);  // 处理完写回去
        }
        close(conn_fd);
        exit(0);
    }
    close(conn_fd);  // 父进程关掉这个连接,继续 accept
}

这就是经典的 per-connection-per-process 模型,早期的 Apache(prefork 模式)就是这么干的。

为什么撑不住

听起来很合理,但当并发连接数上去之后,问题就来了:

问题原因
内存爆炸每个线程默认分配 8 MB 的栈空间。1000 个连接 = 8 GB,光栈就吃完内存了
上下文切换开销大1000 个线程抢 CPU,调度器疲于奔命。线程之间切换需要保存/恢复寄存器、刷 TLB,每次大约几微秒——数量一多就是噩梦
大部分线程在睡觉网络连接大多数时间是空闲的(等用户发消息),线程 99% 的时间在 read() 上阻塞着,白白占着资源

C10K 问题

1999 年,Dan Kegel 写了一篇影响深远的文章 The C10K Problem,提出了一个问题:怎么让一台服务器同时处理 10,000 个并发连接?

他说了一句很到位的话:

“It’s time for web servers to handle ten thousand clients simultaneously, don’t you think? After all, the web is a big place now.”

当时的硬件其实够用——1 GHz CPU、2 GB 内存、Gbps 网卡——瓶颈不在硬件,在操作系统的 IO 模型。一个连接一个线程的方案,在 10K 并发面前根本扛不住。

这个问题推动了 IO 多路复用技术的演进。

问题的根源

退一步想:阻塞 IO 的核心问题是什么?

一个线程在 read() 上阻塞了,它就废了,干不了别的事。 你想同时等 10000 个连接的数据,就得开 10000 个线程,每个都在 read() 上傻等。

有没有一种方式,让一个线程同时监视多个 fd,哪个有数据了再去处理哪个?

有。这就是 IO 多路复用(I/O Multiplexing)


五、IO 多路复用

核心思想

具体怎么做到"一个线程监视多个 fd"?回忆前面说的 IO 两阶段——问题出在第一阶段:你调了 read(),数据还没到,你就被挂起了。

IO 多路复用的思路是:把第一阶段的"等"从 read() 里拆出来,交给一个专门的系统调用去统一管理。

你告诉内核:“我关心这 10000 个 fd,帮我盯着,哪个有数据了告诉我。” 内核说:“行,你先睡吧。” 等有 fd 就绪了,内核把你叫醒:“3 号和 47 号 fd 有数据了。” 然后你再去 read() 这两个 fd——这时候数据已经在内核缓冲区了,read() 不会阻塞(或者说只有第二阶段的拷贝耗时)。

一个线程,监视多个 fd,哪个就绪处理哪个。 这就是"多路复用"——“多路"是多个 fd,“复用"是复用同一个线程。

Linux 里提供了三种 IO 多路复用机制:selectpollepoll。它们的核心思想一样,区别在于怎么告诉内核你关心哪些 fd,以及内核怎么通知你哪些 fd 就绪了


六、select

历史

select() 是最古老的 IO 多路复用系统调用,1983 年随 4.2BSD 发布,后来被纳入 POSIX.1-2001 标准。几乎所有类 Unix 系统都支持它。

接口

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

// fd_set 操作宏
FD_ZERO(&set);      // 清空集合
FD_SET(fd, &set);   // 把 fd 加入集合
FD_CLR(fd, &set);   // 把 fd 从集合中移除
FD_ISSET(fd, &set); // 检查 fd 是否在集合中

使用方式:

  1. FD_SET 把你关心的 fd 放到 readfds/writefds/exceptfds
  2. 调用 select(),进程阻塞
  3. 有 fd 就绪时返回,readfds 等被修改为只包含就绪的 fd
  4. 遍历检查哪些 fd 就绪了,去读/写

fd_set 是什么?

fd_set 本质上就是一个位图(bitmap)。在 glibc 里,它是一个 1024 位的数组(FD_SETSIZE 硬编码为 1024)。fd=5 在集合中?就看第 5 位是不是 1。FD_SET(5, &set) 就是把第 5 位置 1,FD_ISSET(5, &set) 就是检查第 5 位。

一次 select 调用的完整流程

sequenceDiagram
    participant U as user
    participant K as kernel

    Note over U: fd_set = {3, 5, 7}
    U->>K: copy_from_user(fd_set)
    Note over K: for i = 0..7: poll(fd[i])
    Note over K: no ready fd, sleep...
    Note over K: fd 5 ready! scan all again
    K->>U: copy_to_user → fd_set = {5}
    Note over U: 3 and 7 gone!
    Note over U: must rebuild fd_set

    Note over U: fd_set = {3, 5, 7}
    U->>K: copy_from_user(fd_set)
    Note over K: scan all again...

每次调用 select(),内核做了这些事(实现在 fs/select.cdo_select() 中):

  1. 把 3 个 fd_set 从用户空间拷贝到内核空间
  2. 从 0 到 nfds-1 逐个遍历,对每个在集合中的 fd 调用它的 poll() 方法,问一句"你有数据吗?”
  3. 如果没有任何 fd 就绪,把当前进程挂到每个 fd 的等待队列上,然后睡觉
  4. 被某个 fd 唤醒后,再遍历一遍所有 fd——因为内核不知道是谁唤醒的
  5. 把 3 个修改后的 fd_set 拷贝回用户空间

注意几个关键词:逐个遍历所有 fd。即使你监视了 1000 个 fd,只有 1 个就绪,内核也要检查全部 1000 个。这就是 O(n) 的来源——n 是 fd 总数,不是就绪数。

而且每次调用都是 6 次拷贝(3 个 fd_set 进去,3 个出来)、2 次全量遍历。

select 的三大硬伤

1. fd 数量上限 1024

FD_SETSIZE 在 glibc 中硬编码为 1024,不能监视 fd 号 ≥ 1024 的描述符。man page 原话:“an unreasonably low limit for many modern applications”

2. 每次调用都要全量拷贝和遍历

上面已经说了——O(n) 的全量扫描,加上 fd_set 在用户空间和内核空间之间反复拷贝。

3. fd_set 是"值-结果"参数——每次调用前必须重建

这是 select 最坑的设计。fd_set 既是输入(“我关心哪些 fd”),又是输出(“哪些 fd 就绪了”)。select 返回时,会直接修改你传进去的 fd_set,把没就绪的位清零,只留下就绪的。

举个例子:你监视 fd 3、5、7 的可读事件。

  • 调用前:readfds 的第 3、5、7 位是 1
  • select 返回:只有 fd 5 有数据,于是 readfds 变成只有第 5 位是 1,第 3 位和第 7 位被清掉了
  • 你想再次调用 select?必须重新 FD_ZERO + FD_SET(3) + FD_SET(5) + FD_SET(7) 把集合重建一遍

每次循环都要重建。select(2) 的 man page 把这称为 “a design error”,并建议:“All modern applications should instead use poll(2) or epoll(7)"


七、poll

改进了什么

poll() 最早出现在 System V,Linux 从 2.1.23 版本 开始支持,同样被纳入了 POSIX.1-2001 标准。

它针对 select 的前两个硬伤做了改进:

#include <poll.h>

struct pollfd {
    int   fd;       // 要监视的文件描述符
    short events;   // 关心的事件(输入)
    short revents;  // 实际发生的事件(输出)
};

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

两个关键改进:

1. 没有 FD_SETSIZE 限制。 poll 用的是 pollfd 结构体数组,不是位图。你想监视多少个 fd 就传多大的数组,fd 号多大都行。

2. 输入和输出分开了。 events(你关心什么)和 revents(实际发生了什么)是两个独立的字段:

fdevents(调用前)revents(调用前)events(返回后)revents(返回后)
3POLLIN0POLLIN0
5POLLIN0POLLINPOLLIN
7POLLIN0POLLIN0

events 列完全没变,只有 revents 被填上了就绪事件。

poll 返回后只修改 reventsevents 不受影响——你不用像 select 那样每次循环前重新 FD_ZERO + FD_SET 重建集合。这修复了 select 被 man page 吐槽的 “design error”

没解决的根本问题

poll 的内核实现在同一个文件 fs/select.cdo_poll() 中。打开一看你会发现,它和 select 的 do_select() 结构几乎一模一样——还是那个套路:遍历所有 fd,逐个调用 poll() 方法问"你有数据吗?",没有就睡觉,醒了再遍历一遍。O(n) 的全量遍历,一点没变。

改进了没改进
去掉了 1024 的限制每次调用仍然 O(n) 遍历所有 fd
输入输出分离,不需要重建集合每次调用仍需要把整个 pollfd 数组从用户空间拷到内核空间
内核没有记忆——上次注册了哪些 fd 全忘了,每次都重新来

实测数据(来自 Julia Evans 的测试):

fd 数量poll 耗时select 耗时epoll 耗时
100.610.730.41
1,00035350.53
10,0009909300.66

10000 个 fd 时,poll 和 select 都慢了上千倍,而 epoll 几乎不变。差距之大,就像步行和坐高铁。


八、epoll

三个系统调用

epoll 由意大利程序员 Davide Libenzi 开发,2002 年 10 月被合并进 Linux 2.5.44。它不像 select/poll 那样只有一个系统调用,而是拆成了三个:

1. epoll_create — 创建一个 epoll 实例

int epfd = epoll_create1(0);  // 返回一个 fd

是的,epoll 实例本身也是一个 fd——“一切皆文件”嘛。

2. epoll_ctl — 告诉内核你关心哪些 fd

struct epoll_event ev;
ev.events = EPOLLIN;       // 关心可读事件
ev.data.fd = sock_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev);  // 注册

三种操作:EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除)。

3. epoll_wait — 等待事件发生

struct epoll_event events[MAX_EVENTS];
int nready = epoll_wait(epfd, events, MAX_EVENTS, -1);
// 返回就绪的 fd 数量,events 数组里只有就绪的 fd

注意和 select/poll 的区别:你不需要遍历所有 fd 去找谁就绪了,返回的数组里全是就绪的。

怎么用

// 创建 epoll 实例
int epfd = epoll_create1(0);

// 把 listen_fd 加入监视
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

// 事件循环
struct epoll_event events[MAX_EVENTS];
while (1) {
    int nready = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nready; i++) {
        if (events[i].data.fd == listen_fd) {
            // 有新连接
            int conn_fd = accept(listen_fd, ...);
            ev.events = EPOLLIN;
            ev.data.fd = conn_fd;
            epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
        } else {
            // 某个连接有数据
            read(events[i].data.fd, buf, sizeof(buf));
            // 处理数据...
        }
    }
}

一个线程,一个循环,就能处理成千上万个连接。nginx、Redis、Node.js 底层都是这么干的——事件驱动(event-driven) 模型。

和 select/poll 的本质区别

现在读者已经知道三种机制的 API 长什么样了,可以来看本质区别。

select 和 poll 是无状态的系统调用——调用结束后,内核不保留任何信息。虽然 poll 在用户空间不需要重建数组(events 字段不会被覆盖),但每次调用时,整个 fd 列表仍然要从用户空间重新拷贝到内核空间,内核仍然要从头到尾遍历一遍。每次都像第一次。

epoll 把这件事拆成了两步:epoll_ctl 负责注册(一次性),epoll_wait 负责等待(反复调用)。 注册过的 fd 内核一直记着,等待时不需要重新传,也不需要全量遍历。

select/poll:每次调用都把 fd 列表拷进内核,内核全量扫描。

sequenceDiagram
    participant U as user
    participant K as kernel

    U->>K: select({3,5,7})
    Note over K: scan 0,1,2..7
    K->>U: {5}

    U->>K: select({3,5,7})
    Note over K: scan 0,1,2..7
    K->>U: {}

    U->>K: select({3,5,7})
    Note over K: scan 0,1,2..7
    K->>U: {3,7}

epoll:注册一次,内核回调通知,只返回就绪的。

sequenceDiagram
    participant U as user
    participant K as kernel
    participant HW as hardware

    Note over U,K: register (once)
    U->>K: epoll_ctl(ADD, 3)
    U->>K: epoll_ctl(ADD, 5)
    U->>K: epoll_ctl(ADD, 7)
    Note over K: rbtree = {3, 5, 7}

    Note over U,K: wait (repeat)
    U->>K: epoll_wait()
    Note over K: ready list empty, sleep
    HW->>K: NIC interrupt! fd 5 has data
    Note over K: callback → add 5 to ready list
    K->>U: {5}

    U->>K: epoll_wait()
    Note over K: ready list empty, sleep
    Note over K: no rescan, no recopy

为什么 epoll 这么快

一句话:空间换时间。 select/poll 不在内核里维护任何状态,省了内存,但每次调用都要重新拷贝、重新遍历。epoll 在内核里常驻一棵红黑树和一个就绪链表,多占了内存,但换来了不需要重复拷贝和遍历的性能。

具体怎么做到的?这要深入到内核实现。

epoll 在内核中(fs/eventpoll.c)维护了两个核心数据结构

红黑树:存放所有被 epoll_ctl(ADD) 注册的 fd,增删查都是 O(log n)。这就是"内核记住了你关心哪些 fd"的实现——fd 注册一次就挂在树上,不需要每次拷贝。

就绪链表:存放当前有事件就绪的 fd。epoll_wait 只需要看这个链表——有东西就返回,没有就睡觉。

关键问题来了:就绪链表是怎么被填上的?

select/poll 的做法是内核主动去问每个 fd:“你有数据吗?"——O(n) 逐个轮询。

epoll 的做法完全反过来:fd 有数据了自己来报到。 当你 epoll_ctl(ADD) 一个 fd 时,内核给它挂上一个回调函数 ep_poll_callback。以后这个 fd 有数据了(比如网卡收到包、对端发来数据),回调会自动触发,把这个 fd 加入就绪链表,再唤醒 epoll_wait

完整的触发链条:网卡中断 → 内核协议栈处理 → 数据放入 socket 缓冲区 → 触发回调 → fd 加入就绪链表 → 唤醒 epoll_wait → 只返回就绪的 fd。

整个过程中没有任何遍历。 就绪的 fd 通过回调主动"报到”,而不是内核挨个"点名”。这就是 epoll 快的秘密。

复杂度对比:

操作select/pollepoll
注册 fd每次调用都重新传 O(n)epoll_ctl 一次 O(log n)
等待事件遍历所有 fd O(n)只看就绪链表 O(k),k = 就绪数
fd 就绪通知内核轮询发现回调自动加入链表 O(1)

当 n=10000 但只有 3 个 fd 就绪时,select 检查了 9997 个不相干的 fd,而 epoll 只处理那 3 个。

顺便提一个并发细节:epoll_wait 返回时要把就绪链表拷贝给用户空间,但拷贝过程中可能又有新 fd 就绪。epoll 的解决方案是拷贝期间启用一个临时的溢出链表,新来的回调先挂到那里,拷贝结束后再合并回去。

LT 和 ET:两种触发模式

epoll 支持两种通知模式:

Level-Triggered(水平触发,默认):只要 fd 还有未读数据,每次 epoll_wait 都会报告它。和 poll 的语义一样——“数据还在,就一直告诉你。” man page 原话:“epoll is simply a faster poll(2)”

Edge-Triggered(边缘触发,EPOLLET:只在状态变化时报告一次。“从没数据变成有数据"报告一次,之后不管你读没读完,不会再报了,直到有新数据到达触发下一次状态变化。

内核实现的差异其实就一行逻辑(fs/eventpoll.c):LT 模式下,事件发送给用户空间后,把 fd 放回就绪链表,下次 epoll_wait 再查一遍;ET 模式下,不放回,所以不会再通知。

ET 模式性能更好(减少返回次数),但有两个硬性要求:

1. 必须循环读到 EAGAIN ET 只通知一次,如果你只读了一部分数据就回去 epoll_wait 了,剩下的数据不会再触发事件——你永远不会被通知。所以必须一口气读完:

// ET 模式的正确用法
while (1) {
    int n = read(fd, buf, sizeof(buf));
    if (n < 0) {
        if (errno == EAGAIN)
            break;  // 读完了
    }
    if (n == 0) break;  // 连接关闭
    // 处理数据...
}

2. 必须用非阻塞 socket。 上面的循环读到最后,缓冲区空了。如果 socket 是阻塞的,read() 会卡住,整个事件循环就死了。用非阻塞 socket,空了会返回 EAGAIN,你就知道读完了。

epoll(7) man page 原话:“An application that employs the EPOLLET flag should use nonblocking file descriptors to avoid having a blocking read or write starve a task that is handling multiple file descriptors.”

epoll 的局限

epoll 不是万能的:

局限说明
不支持普通文件epoll 只能用于 socket、pipe、eventfd 等有"等待"语义的 fd,不能用于普通磁盘文件(磁盘文件随时可读,不存在"就绪"的概念)
少量连接时未必更快epoll_ctl 每次操作是一个系统调用。如果连接数很少且变化频繁,epoll_ctl 的开销可能比直接用 poll 更大
只是 Linux 的macOS 用 kqueue,Windows 用 IOCP。跨平台项目一般用 libevent/libuv 封装

九、全景总结

从头到尾走一遍:

概念一句话
一切皆文件Unix 把所有资源抽象成文件,通过 VFS 提供统一的 read/write 接口
fd进程打开文件表的下标,通过它找到内核的 struct file
IO在内核缓冲区和用户空间之间搬运数据,分"等数据到"和"拷数据走"两个阶段
阻塞 IOread() 没数据就睡觉等,一个线程只能盯一个 fd
IO 多路复用一个线程同时监视多个 fd,谁就绪处理谁
select位图,1024 上限,O(n) 遍历,每次重建——1983 年的古董
poll结构体数组,去掉了上限,但还是 O(n) 遍历
epoll红黑树 + 就绪链表 + 回调,O(k) 只处理就绪的 fd——2002 年的质变

从 1983 年的 select 到 2002 年的 epoll,花了将近 20 年。核心的思路变化其实就一句话:从"我去问每个 fd 准备好了吗"变成"fd 准备好了自己来告诉我”。

这个从轮询到回调的思想转变,不只是 IO 的事——在计算机科学的很多领域你都能看到同样的模式。


参考资料