learn.ochresign
阶段 0 · 补前置

I/O 模型:从阻塞到 epoll

阻塞与非阻塞,select、poll、epoll 的区别,以及 LT 和 ET 两种触发方式。这是 Reactor 的地基。

问题:一个线程怎么同时服务很多连接

阻塞版 echo server 卡在 read 上时,什么别的事都做不了。解决思路有两种:

  1. 每个连接开一个线程:写法简单,但一万个连接就需要一万个线程。每个线程默认要占用几 MB 的栈空间,线程之间来回切换的开销也很大。
  2. I/O 多路复用:用一个系统调用同时盯住很多 fd,哪个 fd 有事件就处理哪个。select、poll、epoll 都属于这一类。

阻塞和非阻塞

阻塞 fd 非阻塞 fd
没数据时 read 一直等到有数据为止 立刻返回 -1,errno 为 EAGAIN
缓冲区满时 write 一直等到能写为止 能写多少写多少,一点都写不进去时返回 EAGAIN
设置方法 默认 fcntl(fd, F_SETFL, flags \| O_NONBLOCK)

多路复用要配合非阻塞 fd 使用:epoll 告诉你“可读”,你就把数据一直读到 EAGAIN 为止,绝不会卡住。

select、poll、epoll 对比

select poll epoll
fd 数量上限 1024(FD_SETSIZE) 无硬上限 无硬上限
每次调用 把整个 fd 集合拷进内核 同 select fd 只在 epoll_ctl 时注册一次
找出就绪的 fd 遍历全部,O(n) 遍历全部,O(n) 内核直接返回就绪列表,O(就绪数)
平台 到处都有 到处都有 仅 Linux(BSD/macOS 用 kqueue)

epoll 快的原因在于:内核会在 fd 就绪时主动把它放进就绪队列,epoll_wait 只需要把这个队列拿出来。一万个连接里只有十个活跃时,epoll 只处理那十个,select 和 poll 却要把一万个全扫一遍。

epoll 的三个函数

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

epoll_event ev{};
ev.events = EPOLLIN;                               // 关注“可读”
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);      // 2. 注册 / 修改(MOD) / 删除(DEL)

epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);        // 3. 等待,-1 表示一直等
for (int i = 0; i < n; ++i) { /* 处理 events[i] */ }

常用的事件有:EPOLLIN(可读,或者有新连接)、EPOLLOUT(可写)、EPOLLRDHUP(对方关闭了写端)、EPOLLERR 和 EPOLLHUP(出错或挂断,即使没有注册也会自动上报)。

LT 和 ET

水平触发 LT(默认) 边缘触发 ET(EPOLLET)
什么时候通知 只要缓冲区里还有数据,每次 epoll_wait 都会通知 只在状态变化时(数据从无到有)通知一次
读的写法 读一次就行,没读完下次还会通知 必须循环读到 EAGAIN,否则剩下的数据再也收不到通知
优点 写法简单,不容易丢事件 通知次数少
谁在用 muduo、Redis Nginx

初学先用 LT。另外要注意:用 LT 时,只在输出缓冲区里有数据时才关注 EPOLLOUT,写完就取消。否则 socket 几乎总是可写的,epoll_wait 会不停返回,CPU 直接被吃满。

惊群

多个线程或进程同时在同一个监听 fd 上等待,来一个连接,所有等待者都被唤醒,但只有一个能 accept 成功,其余的白白醒来一次。阶段 1 采用“只有主线程 accept,再把连接分给子线程”的设计,从根本上避免了这个问题。另外,EPOLLEXCLUSIVE 和 SO_REUSEPORT 也是常见的解决办法。

自测

ET 模式下只 read 一次会出什么问题?

如果这次没把数据读完,缓冲区里剩下的数据不会再触发通知(状态没有变化),直到对方发来新数据为止。这条连接看起来就像“卡住了”。

为什么 LT 模式下不能一直关注 EPOLLOUT?

socket 的发送缓冲区大多数时候都有空间,也就是一直处于可写状态。LT 会在每次 epoll_wait 时都报告它可写,导致忙循环,占满 CPU。

为什么用 epoll 时要把 fd 设成非阻塞?

epoll 报告“可读”之后,在你真正 read 之前,状态可能已经变了(比如数据被别的线程读走了,或者连接出错了)。如果 fd 是阻塞的,read 就会卡住整个事件循环。ET 模式还要求循环读到 EAGAIN,这只有非阻塞 fd 才做得到。

面试常问

Linux 有哪几种 I/O 模型?

参考回答: 五种:阻塞 I/O、非阻塞 I/O、I/O 多路复用(select / poll / epoll)、信号驱动 I/O、异步 I/O(POSIX AIO、io_uring)。一次读操作分两个阶段:等数据就绪,和把数据从内核拷到用户空间。前四种在第二阶段都要进程自己调用 read 去拷,所以都算同步 I/O;只有异步 I/O 是内核拷完再通知你。

可能的追问: 多路复用为什么能提高并发?因为一个线程可以同时等很多 fd,哪个就绪处理哪个,不用为每个连接开一个线程。

select、poll、epoll 的区别是什么?

参考回答: select 用位图存 fd,默认上限 1024 个,每次调用都要把整个集合从用户态拷到内核,返回后还要遍历全部 fd 才知道哪些就绪,复杂度 O(n),而且集合会被修改,每次都要重新设置。poll 用数组代替位图,去掉了数量上限,但拷贝和遍历的问题还在。epoll 把“注册”和“等待”分开:epoll_ctl 注册一次就一直有效,epoll_wait 只返回就绪的 fd,开销只和活跃连接数有关,适合大量连接但同时活跃的不多的场景。

可能的追问: epoll 一定比 select 快吗?不一定,连接数很少并且几乎都活跃时,差别不大;epoll 的优势在连接多、活跃少。

epoll 底层是怎么实现的?

参考回答: epoll_create 在内核里创建一个 eventpoll 对象,里面主要有两个结构:一棵红黑树,存所有注册的 fd,增删改都是 O(log n);一个就绪链表。epoll_ctl 添加 fd 时,会在这个 fd 的等待队列上挂一个回调函数,网卡收到数据、socket 变成可读时,内核调用这个回调,把对应的节点加进就绪链表。epoll_wait 只要看就绪链表是否为空,不为空就把就绪的事件拷给用户态。

注意: 网上常说 epoll 用 mmap 共享内存来避免拷贝,这是错的,就绪事件还是通过 copy_to_user 拷出来的。面试时别这么说。

LT 和 ET 有什么区别?你的项目用的哪个,为什么?

参考回答: LT(水平触发)只要 fd 上还有数据没读完,每次 epoll_wait 都会报告它;ET(边缘触发)只在状态变化时报告一次,比如新数据到达。用 ET 必须把 fd 设成非阻塞,并且一次就循环读到返回 EAGAIN,否则剩下的数据就再也收不到通知了。ET 的系统调用次数少一些,但更容易写出 bug。

我的项目用的是 LT,和 muduo、Redis 一样:逻辑简单,不容易漏数据,性能差距在我的压测里不明显(可以拿自己的压测数据说话)。Nginx 用的是 ET。

可能的追问: LT 模式下写数据要注意什么?只有输出缓冲区里有数据没发完时才关注 EPOLLOUT,发完就取消,不然 socket 一直可写,epoll_wait 会不停返回,CPU 打满。

Reactor 和 Proactor 有什么区别?

参考回答: Reactor 是“就绪通知”:I/O 多路复用告诉你哪个 fd 可以读写了,由你自己调用 read / write 去完成 I/O,然后处理业务,epoll 网络库、Redis、Netty 都是这种。Proactor 是“完成通知”:你先发起异步读写,内核把 I/O 做完后通知你结果,你只处理数据,Windows 的 IOCP 是典型,Linux 上可以用 io_uring 实现。

什么是惊群?epoll 怎么解决?

参考回答: 多个进程或线程同时在等同一个事件,事件来了全被唤醒,但只有一个能处理成功,其余的白白醒来又睡回去,浪费 CPU。accept 的惊群在 Linux 2.6 就已经解决了。多个线程各自用 epoll 监听同一个 listen fd 时还会有惊群,解决办法有:只让一个线程负责 accept 再分发连接(muduo 的做法);Linux 4.5 之后的 EPOLLEXCLUSIVE;或者用 SO_REUSEPORT 让每个线程有自己的 listen socket,由内核做负载均衡。

原文与延伸