从阻塞 IO 到 epoll:把 Linux 网络 IO 一次讲透


🎉博主首页: 有趣的中国人

🎉专栏首页: 操作系统原理

🎉其它专栏: C++初阶 | C++进阶 | 初阶数据结构


文章目录

  • [从阻塞 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 为什么慢:从内核结构看本质)
      • [5.1 fd 并不是直接被 select / poll 检查](#5.1 fd 并不是直接被 select / poll 检查)
      • [5.2 "等待队列"在哪里](#5.2 “等待队列”在哪里)
      • [5.3 select 和 poll 的区别到底在哪](#5.3 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));

这个过程,本质上包含两步:

  1. 等待数据准备好
  2. 把数据从内核空间拷贝到用户空间

很多 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 多路复用

这就是 selectpollepoll 的核心思路:

不要对每个 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可读
    }
}

它的思想不复杂:

  1. 把你关心的 fd 集合传给内核
  2. 内核检查这些 fd 是否就绪
  3. 如果没有就绪,就让当前线程睡眠
  4. 有事件后唤醒线程,再告诉你哪些 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(),核心逻辑可以概括成这样:

  1. 遍历所有 fd
  2. 对每个 fd 调用 file->f_op->poll()
  3. 看这个 fd 当前是否就绪
  4. 如果没有任何 fd 就绪,就让当前线程睡眠
  5. 某个 fd 有事件时,把线程唤醒
  6. 线程再次遍历全部 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

它做了两件事:

  1. 把"我关心哪些 fd"长期保存在内核里
  2. 谁真的就绪了,就把谁单独记录下来

于是就把问题拆成了两个集合:

  • 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);

内核大致会做这些事:

  1. 找到 eventpoll
  2. 根据 fd 找到对应的 struct file
  3. 创建 epitem
  4. epitem 插入红黑树(interest list)
  5. 调用一次 file->f_op->poll()
  6. 在这个过程中,通过 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);

进入内核后,核心逻辑可以概括为:

  1. 先看 ready list 是否为空
  2. 如果不为空,直接取出 ready 的 epitem
  3. 如果为空,就让当前线程睡眠在 eventpoll 的等待队列上
  4. 某个 fd 真的有事件时,再把对应 epitem 放入 ready list
  5. 唤醒 epoll_wait() 所在线程
  6. 线程醒来后只处理 ready list

所以 epoll 的关键不是"它不检查状态",而是:

它只检查 ready 的那些,不检查全部监听 fd


8.2 事件来了以后,内核怎么通知 epoll

这个过程非常关键。

假设某个 socket 收到数据:

  1. 数据进入 socket 接收队列
  2. 内核唤醒这个 socket 的等待队列
  3. 等待队列里挂的不是 task_struct,而是 eppoll_entry
  4. eppoll_entry 对应的回调函数会执行
  5. 回调函数把对应的 epitem 放入 ready list
  6. 再唤醒阻塞在 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_DELclose(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,这些设计就会顺很多。

把底层原理吃透,框架设计就不再只是"会用",而是真正知道它为什么这样写。

相关推荐
Dynadot_tech2 小时前
完成注册的域名可以做什么?
网络·域名·dynadot·网站域名
有代理ip2 小时前
动态IP的安全性优化:策略升级与隐私保护实战指南
网络·网络协议·tcp/ip
书到用时方恨少!2 小时前
Linux 常用指令使用指南:从入门到“救命”
linux·运维·服务器
CDN3602 小时前
高防 IP 回源 502/504 异常?源站放行与健康检查修复
网络·网络协议·tcp/ip
说实话起个名字真难啊2 小时前
Docker 入门之网络基础
网络·docker·php
默|笙2 小时前
【Linux】线程同步与互斥_同步(1)
linux
Deitymoon2 小时前
linux——条件变量
linux
LSL666_2 小时前
计算机网络——网络模型和TCP
网络·计算机网络
wwj888wwj2 小时前
Ansible基础(复习2)
linux·运维·服务器·ansible