
🎉博主首页: 有趣的中国人
🎉专栏首页: 操作系统原理
文章目录
- [从阻塞 IO 到 epoll:把 Linux 网络 IO 一次讲透](#从阻塞 IO 到 epoll:把 Linux 网络 IO 一次讲透)
-
- [一、先把 IO 的本质想明白](#一、先把 IO 的本质想明白)
-
- [1.1 一次读操作到底在做什么](#1.1 一次读操作到底在做什么)
- [1.2 为什么会阻塞](#1.2 为什么会阻塞)
- [二、几种常见 IO 模型](#二、几种常见 IO 模型)
-
- [2.1 阻塞 IO](#2.1 阻塞 IO)
- [2.2 非阻塞 IO](#2.2 非阻塞 IO)
- [2.3 IO 多路复用](#2.3 IO 多路复用)
- [2.4 信号驱动 IO 和异步 IO](#2.4 信号驱动 IO 和异步 IO)
- [三、IO 多路复用到底在复用什么](#三、IO 多路复用到底在复用什么)
- [四、从 select 和 poll 开始理解多路复用](#四、从 select 和 poll 开始理解多路复用)
-
- [4.1 select 的基本用法](#4.1 select 的基本用法)
- [4.2 poll 的基本用法](#4.2 poll 的基本用法)
- [4.3 select / poll 的底层到底在干什么](#4.3 select / poll 的底层到底在干什么)
- [五、select / poll 为什么慢:从内核结构看本质](#五、select / poll 为什么慢:从内核结构看本质)
- [六、epoll 到底解决了什么问题](#六、epoll 到底解决了什么问题)
- [七、epoll 的核心对象和整体流程](#七、epoll 的核心对象和整体流程)
-
- [7.1 eventpoll、epitem、ready list](#7.1 eventpoll、epitem、ready list)
- [7.2 epoll_create 干了什么](#7.2 epoll_create 干了什么)
- [7.3 epoll_ctl(ADD) 干了什么](#7.3 epoll_ctl(ADD) 干了什么)
- [八、epoll_wait 的底层调用逻辑](#八、epoll_wait 的底层调用逻辑)
-
- [8.1 epoll_wait 并不遍历所有 fd](#8.1 epoll_wait 并不遍历所有 fd)
- [8.2 事件来了以后,内核怎么通知 epoll](#8.2 事件来了以后,内核怎么通知 epoll)
- [poll / select](#poll / select)
- epoll
- [8.3 ready list 中的 epitem 什么时候移除](#8.3 ready list 中的 epitem 什么时候移除)
- [九、LT 和 ET:为什么一个简单,一个高效但容易踩坑](#九、LT 和 ET:为什么一个简单,一个高效但容易踩坑)
-
- [9.1 LT(水平触发)](#9.1 LT(水平触发))
- [9.2 ET(边沿触发)](#9.2 ET(边沿触发))
- [9.3 "读一半死锁"到底是什么意思](#9.3 “读一半死锁”到底是什么意思)
- [十、惊群问题和多线程 epoll](#十、惊群问题和多线程 epoll)
-
- [10.1 什么是惊群](#10.1 什么是惊群)
- [10.2 常见解决方式](#10.2 常见解决方式)
-
- [方式一:主线程负责 epoll_wait,工作线程处理业务](#方式一:主线程负责 epoll_wait,工作线程处理业务)
- [方式二:一个线程一个 epoll](#方式二:一个线程一个 epoll)
- [十一、结合工程看:为什么高并发服务器几乎都选 epoll](#十一、结合工程看:为什么高并发服务器几乎都选 epoll)
-
- [1. 监听关系只建立一次](#1. 监听关系只建立一次)
- [2. 唤醒后只处理活跃 fd](#2. 唤醒后只处理活跃 fd)
- [3. 更适合海量连接](#3. 更适合海量连接)
- 十二、最后把整条链再串一遍
-
- [poll / select](#poll / select)
- epoll
- 结语
从阻塞 IO 到 epoll:把 Linux 网络 IO 一次讲透
做 Linux 网络编程,绕不开 IO。刚开始学的时候,总会遇到一堆看起来分散的概念:阻塞、非阻塞、IO 多路复用、select、poll、epoll、LT、ET、等待队列、ready list......如果把这些知识点一个个孤立地记,很容易越学越乱。
其实这些内容背后有一条很清晰的主线:
当一个文件描述符暂时不能读、不能写时,内核是怎么让进程等待;当它可以读、可以写时,内核又是怎么通知进程的。
这篇文章就沿着这条主线,从最基础的阻塞 IO 讲起,一直到 epoll 的内核实现,把整套逻辑串起来。
一、先把 IO 的本质想明白
1.1 一次读操作到底在做什么
以 socket 为例,用户调用:
cpp
ssize_t n = read(fd, buf, sizeof(buf));
这个过程,本质上包含两步:
- 等待数据准备好
- 把数据从内核空间拷贝到用户空间
很多 IO 模型的区别,主要就在第 1 步:
等待数据准备好的过程中,线程是一直卡住,还是可以先去做别的事。
1.2 为什么会阻塞
如果当前 socket 的接收缓冲区里没有数据,而用户又立刻调用 read(),内核没法立即返回有效数据,只能让当前线程先睡眠,等数据到了再把它唤醒。
所以阻塞不是 read 故意"卡你",而是:
你要的数据现在还没准备好,内核只能让你先等着。
二、几种常见 IO 模型
2.1 阻塞 IO
最传统、也最容易理解的模型。
cpp
char buf[1024];
int n = read(fd, buf, sizeof(buf));
如果没有数据,线程就会阻塞在 read() 上。它的特点是实现简单,但一个线程通常只能盯一个连接,连接一多就扛不住。
2.2 非阻塞 IO
把文件描述符设成非阻塞后,再去读:
cpp
fcntl(fd, F_SETFL, O_NONBLOCK);
如果没有数据,read() 不会睡眠,而是直接返回:
cpp
-1, errno = EAGAIN
这说明"现在还不能读"。
但问题也很明显:如果你一直循环去试,就变成了忙等,CPU 利用率会很差。
2.3 IO 多路复用
这就是 select、poll、epoll 的核心思路:
不要对每个 fd 单独阻塞等,而是让一个线程统一等多个 fd。
谁准备好了,内核再告诉我。
这也是高并发网络服务器最常见的方案。
2.4 信号驱动 IO 和异步 IO
这两种平时面试会问,但工程里没有前面几种用得多。
- 信号驱动 IO:内核准备好数据后,给进程发信号
- 异步 IO :用户甚至不用主动
read,内核把数据准备好并拷贝完成后再通知
平时 Linux 服务端开发里,最核心还是:
阻塞 / 非阻塞 / IO 多路复用 / epoll
三、IO 多路复用到底在复用什么
"多路复用"这个名字一开始很抽象,其实它复用的不是数据通路,而是:
一个线程同时等待多个文件描述符的就绪事件
例如一个线程可以同时监听:
- listenfd:有没有新连接到来
- connfd1:有没有数据可读
- connfd2:发送缓冲区是否可写
- timerfd:定时器是否到期
- eventfd:是否有异步任务唤醒
这样一个线程就不需要为每个 fd 单独阻塞等待,而是把"等待"这件事统一交给内核处理。
四、从 select 和 poll 开始理解多路复用
4.1 select 的基本用法
cpp
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);
FD_SET(connfd, &readfds);
int maxfd = std::max(listenfd, connfd);
int nready = select(maxfd + 1, &readfds, nullptr, nullptr, nullptr);
if (nready > 0) {
if (FD_ISSET(listenfd, &readfds)) {
// 有新连接
}
if (FD_ISSET(connfd, &readfds)) {
// connfd可读
}
}
它的思想不复杂:
- 把你关心的 fd 集合传给内核
- 内核检查这些 fd 是否就绪
- 如果没有就绪,就让当前线程睡眠
- 有事件后唤醒线程,再告诉你哪些 fd 就绪
4.2 poll 的基本用法
cpp
pollfd fds[2];
fds[0].fd = listenfd;
fds[0].events = POLLIN;
fds[1].fd = connfd;
fds[1].events = POLLIN;
int nready = poll(fds, 2, -1);
if (nready > 0) {
if (fds[0].revents & POLLIN) {
// listenfd可读
}
if (fds[1].revents & POLLIN) {
// connfd可读
}
}
和 select 比,poll 只是把 fd 管理方式从"位图"换成了"数组",思想是一样的。
4.3 select / poll 的底层到底在干什么
这部分是理解 epoll 的前提。
用户调用 poll() 后,内核会进入 do_poll(),核心逻辑可以概括成这样:
- 遍历所有 fd
- 对每个 fd 调用
file->f_op->poll() - 看这个 fd 当前是否就绪
- 如果没有任何 fd 就绪,就让当前线程睡眠
- 某个 fd 有事件时,把线程唤醒
- 线程再次遍历全部 fd,找出就绪的那些
伪代码可以理解成:
cpp
while (true) {
bool ready = false;
for (每个fd) {
调用 file->f_op->poll();
if (该fd就绪) {
ready = true;
}
}
if (ready) {
return;
}
睡眠等待唤醒;
}
所以 select / poll 的核心问题很明显:
每次被唤醒后,都要重新遍历所有 fd
这就是它们扩展性差的根本原因。
五、select / poll 为什么慢:从内核结构看本质
5.1 fd 并不是直接被 select / poll 检查
用户态拿到的是整数 fd,但内核真正操作的是 struct file。
大致关系是:
cpp
fd
↓
struct file
↓
private_data
↓
struct socket
↓
struct sock
对于 socket 来说,最终会走到 socket 的 poll 函数,比如:
cpp
file->f_op->poll()
↓
sock_poll()
↓
tcp_poll()
然后内核判断:
- 接收队列是否非空 → 可读
- 发送缓冲区是否有空间 → 可写
- 对端是否关闭 → 异常 / 挂断
5.2 "等待队列"在哪里
如果遍历一轮下来没有任何 fd 就绪,内核需要让当前线程睡眠。
睡在哪里?不是睡在一个全局地方,而是把当前线程关联到各个 fd 对应的等待队列上。
对于 socket 来说,核心等待队列在 struct sock 里,通常可以理解为:
cpp
struct sock {
wait_queue_head_t sk_sleep;
};
当 poll_wait() 被调用时,会把当前线程对应的等待项挂到 sk_sleep 上。
所以对 select / poll 来说,逻辑是:
- 遍历 fd
- 给这些 fd 注册等待项
- 如果还没有事件,就让线程睡眠
- 某个 socket 有数据时,唤醒线程
- 线程重新回到
do_poll()/do_select()再扫一遍所有 fd
5.3 select 和 poll 的区别到底在哪
它们的核心等待机制其实差不多,主要区别在 fd 管理形式:
select
- 用位图表示 fd 集合
- 要传 3 组集合:读、写、异常
- 有 fd 数量上限
- 每次都要重新设置集合
poll
- 用
pollfd数组表示 - 没有 select 那种固定上限
- 只传一个数组,使用更方便
但是无论是 select 还是 poll,本质问题都没变:
每次唤醒都要重新遍历所有 fd
六、epoll 到底解决了什么问题
epoll 的设计目标非常直接:
不要每次都重新注册 fd,也不要每次都重新遍历全部 fd
它做了两件事:
- 把"我关心哪些 fd"长期保存在内核里
- 谁真的就绪了,就把谁单独记录下来
于是就把问题拆成了两个集合:
- interest list:我关心的 fd 集合
- ready list:已经就绪的 fd 集合
这样 epoll_wait() 就不需要再扫描所有 fd,而是只处理 ready list 里的项。
七、epoll 的核心对象和整体流程
7.1 eventpoll、epitem、ready list
epoll 在内核里可以抓住三个关键对象:
eventpoll
表示整个 epoll 实例,里面有:
- 红黑树:保存 interest list
- ready list:保存已经就绪的 epitem
- 等待队列:供
epoll_wait()睡眠
epitem
表示"某个被 epoll 监听的 fd"
里面记录:
- fd
struct file*- 用户关心的事件
- ready list 节点
eppoll_entry
表示"epoll 挂到目标 fd 等待队列上的等待项"
这个结构是 epoll 区别于 select / poll 的关键。
7.2 epoll_create 干了什么
cpp
int epfd = epoll_create1(0);
这一步会在内核里创建一个 eventpoll 对象,然后再给它分配一个 fd,也就是我们用户态看到的 epfd。
所以:
cpp
epfd
↓
struct file
↓
private_data
↓
struct eventpoll
7.3 epoll_ctl(ADD) 干了什么
cpp
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
内核大致会做这些事:
- 找到
eventpoll - 根据 fd 找到对应的
struct file - 创建
epitem - 把
epitem插入红黑树(interest list) - 调用一次
file->f_op->poll() - 在这个过程中,通过
poll_wait()把eppoll_entry挂到目标 socket 的等待队列上
这一步非常重要:
eppoll_entry 只在 ADD 时建立一次,之后长期存在
这和 select / poll 每次都重新注册等待项完全不同。
八、epoll_wait 的底层调用逻辑
8.1 epoll_wait 并不遍历所有 fd
用户调用:
cpp
epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
进入内核后,核心逻辑可以概括为:
- 先看 ready list 是否为空
- 如果不为空,直接取出 ready 的 epitem
- 如果为空,就让当前线程睡眠在
eventpoll的等待队列上 - 某个 fd 真的有事件时,再把对应 epitem 放入 ready list
- 唤醒
epoll_wait()所在线程 - 线程醒来后只处理 ready list
所以 epoll 的关键不是"它不检查状态",而是:
它只检查 ready 的那些,不检查全部监听 fd
8.2 事件来了以后,内核怎么通知 epoll
这个过程非常关键。
假设某个 socket 收到数据:
- 数据进入 socket 接收队列
- 内核唤醒这个 socket 的等待队列
- 等待队列里挂的不是
task_struct,而是eppoll_entry eppoll_entry对应的回调函数会执行- 回调函数把对应的
epitem放入 ready list - 再唤醒阻塞在
eventpoll.wq上的epoll_wait()线程
这就是 epoll 和 poll/select 的本质差别:
poll / select
cpp
socket有事件
↓
直接唤醒task_struct
↓
重新遍历所有fd
epoll
cpp
socket有事件
↓
先回调epoll逻辑
↓
epitem加入ready list
↓
再唤醒epoll_wait线程
↓
只处理ready list
8.3 ready list 中的 epitem 什么时候移除
当 epoll_wait() 遍历 ready list 时,epitem 会从 ready list 中移除。
注意:
- 从 ready list 移除 ,不等于 取消监听
epitem还在红黑树里eppoll_entry还挂在目标 socket 的等待队列上
也就是说:
ready list 是临时的,就绪一次进一次;
interest list 和等待关系是长期的,除非
EPOLL_CTL_DEL或close(fd)才会删除。
九、LT 和 ET:为什么一个简单,一个高效但容易踩坑
9.1 LT(水平触发)
LT 的特点是:
只要 fd 仍然处于就绪状态,就会不断通知
例如接收队列里有 4KB 数据,你这次只读了 1KB,剩下 3KB 还在,那下一次 epoll_wait() 仍然会返回这个 fd。
LT 的优点是简单,不容易漏事件。
9.2 ET(边沿触发)
ET 的特点是:
只在状态从"不就绪"变成"就绪"时通知一次
例如:
- 原来接收队列为空
- 来了一批数据,变成非空
- 触发一次通知
如果你这次没有把数据读空,下次 epoll_wait() 不一定还会通知你,因为"非空"这个状态没有发生新的边沿变化。
所以 ET 模式下必须:
- 把 socket 设为非阻塞
- 循环读到
EAGAIN为止
典型写法:
cpp
while (true) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 继续读
continue;
}
if (n == -1 && errno == EAGAIN) {
// 本轮数据读完了
break;
}
if (n == 0) {
// 对端关闭
break;
}
// 其他错误
break;
}
9.3 "读一半死锁"到底是什么意思
严格说它不是真正的死锁,而是:
你只收到一次 ET 通知,却没有把数据读干净,后面也没有新的状态变化,于是程序一直等不到下一次通知。
看起来就像"卡住了"。
所以 ET 最核心的要求就一句话:
一次就绪后,要一直读到返回 EAGAIN。
十、惊群问题和多线程 epoll
10.1 什么是惊群
如果多个线程同时对同一个 socket 或同一个监听对象执行 epoll_wait(),当事件到来时,可能会把多个线程都唤醒,但最后真正能处理的往往只有一个,其他线程白白被唤醒,这就是惊群。
问题在于:
- CPU 浪费
- 锁竞争加重
- 调度开销上升
10.2 常见解决方式
方式一:主线程负责 epoll_wait,工作线程处理业务
这是最常见、也最稳定的模式。
cpp
主线程epoll_wait
↓
拿到活跃fd
↓
分发任务
↓
工作线程处理业务逻辑
方式二:一个线程一个 epoll
每个 IO 线程维护自己的 epoll 实例和连接集合,线程之间基本不抢同一个 fd。
muduo、nginx 这类高性能网络库 / 网络框架,核心思想都和这个方向接近。
十一、结合工程看:为什么高并发服务器几乎都选 epoll
站在工程角度看,epoll 的优势非常清晰:
1. 监听关系只建立一次
不像 poll/select 每次调用都要重新注册等待关系。
2. 唤醒后只处理活跃 fd
不是把全部 fd 重扫一遍,而是只看 ready list。
3. 更适合海量连接
特别是"连接很多,但真正活跃的不多"的场景,epoll 优势非常明显。
这也是为什么现在 Linux 上大多数高性能服务端框架都基于 epoll。
十二、最后把整条链再串一遍
如果把整条链路压缩成最核心的一版,其实就是下面这样:
poll / select
cpp
用户调用 poll/select
↓
内核遍历全部 fd
↓
调用 file->f_op->poll()
↓
通过 poll_wait 注册当前线程等待项
↓
没有事件则睡眠
↓
某个 fd 有事件
↓
唤醒线程
↓
线程重新遍历全部 fd
↓
找到就绪 fd 返回
epoll
cpp
epoll_ctl(ADD)
↓
创建 epitem
↓
插入红黑树
↓
创建 eppoll_entry
↓
挂到目标 fd 等待队列
epoll_wait
↓
检查 ready list
↓
为空则睡眠在 eventpoll.wq
↓
某个 fd 有事件
↓
执行 epoll 回调
↓
epitem 加入 ready list
↓
唤醒 epoll_wait 线程
↓
线程只遍历 ready list
↓
返回活跃 fd
所以 epoll 真正高效的根本原因,不是"它判断状态更快",而是:
它把"遍历全部 fd 找 ready"这件事,变成了"谁 ready 谁主动登记到 ready list 里"。
结语
IO 模型、select、poll、epoll 这些知识点,单独看都不算特别复杂,真正难的是把它们串起来。
一旦你把这条主线理顺:
- 阻塞和非阻塞到底在等什么
- 多路复用到底复用了什么
- select / poll 为什么每次都要重扫
- epoll 为什么只处理 ready list
- LT / ET 为什么行为不同
后面再去看 muduo、Reactor、线程池、one loop per thread,这些设计就会顺很多。
把底层原理吃透,框架设计就不再只是"会用",而是真正知道它为什么这样写。