19.【网络】多路转接poll、epoll、Reactor反应堆 - 修正版

Linux I/O 多路复用:poll、epoll、Reactor 与 eventfd(C++ 面试复习版)

学习主线: select → poll → epoll → Reactor → One Thread One Loop → eventfd。

适用:C++ / Linux 软件开发、网络编程、后端开发岗位的笔试和面试。

先记住: poll/epoll 负责等待文件描述符就绪 ,read/recv/accept/write/send 等负责实际 I/O 。就绪不等于 I/O 已经完成。

目录

  • [Linux I/O 多路复用:poll、epoll、Reactor 与 eventfd(C++ 面试复习版)](#Linux I/O 多路复用:poll、epoll、Reactor 与 eventfd(C++ 面试复习版))
    • [0. 先建立整体认识](#0. 先建立整体认识)
      • [0.1 为什么需要 I/O 多路复用?](#0.1 为什么需要 I/O 多路复用?)
      • [0.2 三个容易混淆的概念](#0.2 三个容易混淆的概念)
  • [1. poll:I/O 多路复用接口](#1. poll:I/O 多路复用接口)
    • [1.1 `poll()` 的概念与函数原型](#1.1 poll() 的概念与函数原型)
    • [1.2 `poll()` 返回值与错误处理](#1.2 poll() 返回值与错误处理)
    • [1.3 常用事件标志](#1.3 常用事件标志)
    • [1.4 socket 什么时候算"就绪"?](#1.4 socket 什么时候算“就绪”?)
    • [1.5 `poll()` 的执行过程](#1.5 poll() 的执行过程)
    • [1.6 `poll` 相比 `select` 的优势与局限](#1.6 poll 相比 select 的优势与局限)
      • [1.7 代码示例:使用 `poll` 监控标准输入](#1.7 代码示例:使用 poll 监控标准输入)
  • [2. epoll:Linux 高并发 I/O 多路复用](#2. epoll:Linux 高并发 I/O 多路复用)
    • [2.1 epoll 的概念](#2.1 epoll 的概念)
    • [2.2 epoll 的三个核心系统调用](#2.2 epoll 的三个核心系统调用)
    • [2.4 epoll 的内部工作原理](#2.4 epoll 的内部工作原理)
    • [2.5 LT(水平触发)与 ET(边缘触发)](#2.5 LT(水平触发)与 ET(边缘触发))
      • [(1)LT:Level Triggered,水平触发(默认)](#(1)LT:Level Triggered,水平触发(默认))
      • [(2)ET:Edge Triggered,边缘触发](#(2)ET:Edge Triggered,边缘触发)
      • [(3)LT 与 ET 对比](#(3)LT 与 ET 对比)
      • [2.6 为什么 ET 通常必须搭配非阻塞?](#2.6 为什么 ET 通常必须搭配非阻塞?)
      • [2.7 一个容易忽略的写事件问题](#2.7 一个容易忽略的写事件问题)
    • [2.8 epoll 示例:单线程 TCP 服务器(只接收并打印)](#2.8 epoll 示例:单线程 TCP 服务器(只接收并打印))
  • [3. select / poll / epoll 怎么选?](#3. select / poll / epoll 怎么选?)
  • [4. Reactor:把 I/O 就绪通知组织成服务器架构](#4. Reactor:把 I/O 就绪通知组织成服务器架构)
    • [4.1 什么是 Reactor?](#4.1 什么是 Reactor?)
      • [4.2 Reactor 的常见组件](#4.2 Reactor 的常见组件)
      • [4.3 单 Reactor 的基本流程](#4.3 单 Reactor 的基本流程)
  • [5. One Thread One Loop(OTOL)及多核扩展](#5. One Thread One Loop(OTOL)及多核扩展)
    • [5.1 什么是 One Thread One Loop?](#5.1 什么是 One Thread One Loop?)
    • [5.2 多进程 Reactor:每个 Worker 一个 EventLoop](#5.2 多进程 Reactor:每个 Worker 一个 EventLoop)
      • [5.3 多线程 Reactor:主线程接入,工作线程处理](#5.3 多线程 Reactor:主线程接入,工作线程处理)
      • [5.4 单线程、多线程、多进程怎么选?](#5.4 单线程、多线程、多进程怎么选?)
  • [6. eventfd:在线程间唤醒事件循环](#6. eventfd:在线程间唤醒事件循环)
    • [6.1 什么是 eventfd?](#6.1 什么是 eventfd?)
    • [6.2 函数与参数](#6.2 函数与参数)
      • [6.3 `eventfd` 的两种模式](#6.3 eventfd 的两种模式)
      • [6.4 最小示例:写通知并读取计数](#6.4 最小示例:写通知并读取计数)
      • [6.5 eventfd、管道和 Unix 域套接字的区别](#6.5 eventfd、管道和 Unix 域套接字的区别)
  • [7. Reactor 网络计算器案例:保留关键设计思路](#7. Reactor 网络计算器案例:保留关键设计思路)
    • [7.1 核心对象职责](#7.1 核心对象职责)
    • [7.2 程序启动:`main()`](#7.2 程序启动:main())
    • [7.3 添加连接:`AddConnection()`](#7.3 添加连接:AddConnection())
    • [7.4 事件循环:`Loop()` / `LoopOnce()` / `Dispatcher()`](#7.4 事件循环:Loop() / LoopOnce() / Dispatcher())
    • [7.5 生命周期与常见陷阱](#7.5 生命周期与常见陷阱)
  • [8. 面试常见问题与参考答案](#8. 面试常见问题与参考答案)
      • [Q1. `poll` 与 `select` 是同一种 I/O 模型吗?](#Q1. poll 与 select 是同一种 I/O 模型吗?)
      • [Q2. `pollfd.events` 和 `pollfd.revents` 有什么区别?](#Q2. pollfd.events 和 pollfd.revents 有什么区别?)
      • [Q3. 为什么判断事件用 `revents & POLLIN` 而不是 `revents == POLLIN`?](#Q3. 为什么判断事件用 revents & POLLIN 而不是 revents == POLLIN?)
      • [Q4. `poll` 是否像 `select` 一样最多只能监听 1024 个 fd?](#Q4. poll 是否像 select 一样最多只能监听 1024 个 fd?)
      • [Q5. `poll` 为什么不适合海量低活跃连接?](#Q5. poll 为什么不适合海量低活跃连接?)
      • [Q6. `epoll_create1()` 返回的是什么?](#Q6. epoll_create1() 返回的是什么?)
      • [Q7. epoll 三个核心接口分别干什么?](#Q7. epoll 三个核心接口分别干什么?)
      • [Q8. `epoll_wait()` 的 `maxevents` 是监听连接数的上限吗?](#Q8. epoll_wait() 的 maxevents 是监听连接数的上限吗?)
      • [Q9. epoll 为什么通常比 poll 更适合海量低活跃连接?](#Q9. epoll 为什么通常比 poll 更适合海量低活跃连接?)
      • [Q10. epoll 有没有使用 mmap 来共享就绪链表?](#Q10. epoll 有没有使用 mmap 来共享就绪链表?)
      • [Q11. LT 和 ET 的区别是什么?](#Q11. LT 和 ET 的区别是什么?)
      • [Q12. 为什么 ET 配合非阻塞?](#Q12. 为什么 ET 配合非阻塞?)
      • [Q13. `EPOLLIN` 表示一定收到正常数据吗?](#Q13. EPOLLIN 表示一定收到正常数据吗?)
      • [Q14. 为什么不是一直监听 `EPOLLOUT`?](#Q14. 为什么不是一直监听 EPOLLOUT?)
      • [Q15. `epoll` 和 Reactor 有什么区别?](#Q15. epoll 和 Reactor 有什么区别?)
      • [Q16. Reactor 模式属于同步还是异步 I/O?](#Q16. Reactor 模式属于同步还是异步 I/O?)
      • [Q17. 多进程能通过管道传一个整数 `fd` 就共享 socket 吗?](#Q17. 多进程能通过管道传一个整数 fd 就共享 socket 吗?)
      • [Q18. 主线程怎样唤醒正在 epoll_wait 的工作线程?](#Q18. 主线程怎样唤醒正在 epoll_wait 的工作线程?)
      • [Q19. eventfd 和管道有什么区别?](#Q19. eventfd 和管道有什么区别?)
      • [Q20. TCP 粘包/拆包和 epoll 有关系吗?](#Q20. TCP 粘包/拆包和 epoll 有关系吗?)
  • [9. 考前速记](#9. 考前速记)

0. 先建立整体认识

0.1 为什么需要 I/O 多路复用?

一个 TCP 服务器可能同时连接很多客户端,但某一时刻通常只有部分连接有数据可读或可写。若一个线程阻塞等待一个连接,其他连接可能无法及时处理。

I/O 多路复用(I/O Multiplexing,也叫 I/O 多路转接) :允许一个线程等待多个文件描述符的就绪事件 ,哪个就绪就先处理哪个。Linux 常用 select、poll、epoll。

📌 大白话: 一个服务员管理很多桌客人,不必守着一桌等点菜;谁举手(I/O 就绪),就去服务谁(实际读写)。

0.2 三个容易混淆的概念

概念 准确含义 典型接口
I/O 多路复用 同时等待多个 fd 的读/写等就绪事件 select、poll、epoll
非阻塞 I/O 暂时无法完成操作时,调用及时返回,而不是一直等待 fcntl(..., O_NONBLOCK)
异步 I/O 提交操作后,后续通过完成通知等方式得知 I/O 已完成 Linux AIO、io_uring(支持多种使用方式)

poll/epoll 属于就绪通知 ,应用程序拿到事件后通常仍然需要自己调用 recv() 等;它们不是"由内核自动替应用程序把消息读取到指定用户缓冲区"的完成式异步 I/O。


1. poll:I/O 多路复用接口

1.1 poll() 的概念与函数原型

poll() 是 Linux/POSIX 提供的 I/O 多路复用接口,通过一个 pollfd 数组同时等待多个 fd 的可读、可写和异常状态。

cpp 复制代码
#include <poll.h>

// 函数原型:由 <poll.h> 提供,不需要自己实现
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

// pollfd 的成员示意:类型也已在头文件中定义,不要重复声明
/*
struct pollfd {
    int   fd;       // 待监视的文件描述符
    short events;   // 输入:希望监视的事件
    short revents;  // 输出:实际发生的事件
};
*/
参数 含义 大白话
fds pollfd 数组的首地址 "给内核一张需要关注的 fd 清单"
nfds fds 数组的元素个数,类型为 nfds_t "清单上有多少项"
timeout 等待时间,单位毫秒 "最多等多久"

timeout 有三种常用取值:-1 为一直等待(也可能被信号打断);0 为立即检查并返回;正整数为最多等待指定毫秒数。在 Linux 中,负数一般表示无限等待 ,常用 -1。

pollfd 三个成员重点区分:

  • fd:需要观察哪个文件描述符;fd < 0 时该数组项被忽略 ,其 revents 清零。
  • events:调用者填入希望关注的事件,可以按位或组合,例如 POLLIN | POLLOUT。
  • revents:poll() 返回时内核填写的实际事件,每轮调用都会更新。调用者不要自行当作输入事件设置。

📌 大白话: events 是你交给内核的"待办事项",revents 是内核返回的"检查结果"。

1.2 poll() 返回值与错误处理

返回值 含义
> 0 有 revents 非 0 的数组项,返回这些数组项的个数(不是事件标志位的个数)
0 等待超时,且没有报告就绪事件
-1 调用失败,检查 errno

常见错误:

  • EINTR:等待时被信号中断,通常根据需要重新调用。
  • EINVAL:参数不合法,例如 Linux 下 nfds 超过适用限制。
  • ENOMEM:内核无法分配必要的资源。

注意: poll() 把某个 fd 标记为就绪,不表示读写必然成功,也不保证一次能读完/写完所有数据。

1.3 常用事件标志

标志 主要用途 备注
POLLIN 可读 普通数据可读;监听 socket 有连接可接收等
POLLOUT 可写 通常表示发送缓冲区有可用空间,不代表能一次写完
POLLPRI 高优先级数据 例如 TCP 带外/紧急数据相关情况,了解即可
POLLERR 出错 无需在 events 中指定,也可能在 revents 中出现
POLLHUP 挂断 可能与 POLLIN 同时出现,注意处理残留数据
POLLNVAL fd 无效 例如已经关闭的 fd;仅 poll 提供该标志
POLLRDHUP 对端关闭写方向(Linux 扩展) TCP 半关闭检测;使用时注意头文件特性宏

查看原文事件标志图:

事件判断应使用按位与,因为多个标志可能同时出现:

cpp 复制代码
if (pfd.revents & POLLIN)  { /* 可读 */ }
if (pfd.revents & POLLERR) { /* 出错 */ }

不要只写 pfd.revents == POLLIN:当 POLLIN | POLLHUP 同时出现时,这种写法会漏掉可读事件。

1.4 socket 什么时候算"就绪"?

(1)读就绪(POLLIN)

常见情况:

  1. 接收缓冲区有足够可读数据(通常至少 1 字节;具体还受接收低水位等影响)。
  2. 监听 socket 的已完成连接队列中有可获取的连接,适合调用 accept()。
  3. TCP 对端正常关闭发送方向 且已无剩余数据,recv() 可以返回 0(EOF)。
  4. 某些错误或关闭状态也可能使读操作立即返回结果,需要检查返回值及错误标志。

📌 大白话: "可读"不是"必然读到正常业务数据",也可能是"对方关闭了,我一读就知道"。

(2)写就绪(POLLOUT)

通常表示发送缓冲区具有一定可用空间,或非阻塞 connect() 已完成(成功或失败都可能)。

  • 非阻塞连接完成后,需通过 getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) 判断连接结果。
  • 可写不代表一次 send() 能发送全部内容;需要考虑部分发送 和 EAGAIN。
  • 对端异常断开时,写操作可能出错甚至产生 SIGPIPE;不能把可写等同于连接健康。

(3)错误/挂断就绪

POLLERR、POLLHUP、POLLNVAL 需要结合实际读写返回值处理:特别是 POLLHUP 并不必然表示缓冲区已空。先处理可能仍可读的残留数据,再决定关闭连接。

1.5 poll() 的执行过程

text 复制代码
① 创建 pollfd 数组,填入 fd 和 events
          ↓
② poll(fds, nfds, timeout)
          ↓
③ 内核等待/检查这些 fd 的就绪状态
          ↓
④ 返回就绪数组项数量,同时填写各项 revents
          ↓
⑤ 程序遍历数组,根据 revents 调用 accept/recv/send 等
          ↓
⑥ 更新需要监视的 fd,进入下一轮

📌 大白话: 每一轮把整张清单交给内核,内核把每项的检查结果写在 revents,你再按结果办事。

1.6 poll 相比 select 的优势与局限

对比项 select poll
监听数据结构 fd_set 位图 pollfd 数组
监听事件与返回事件 集合被修改,每轮需重建 events 与 revents 分离
fd 编号范围 常受 FD_SETSIZE 限制 不受 FD_SETSIZE 限制
每轮提交监听信息 是 是
程序获取具体就绪 fd 检查集合 遍历 pollfd 数组
适用情况 fd 数量较少、兼容性要求 fd 数量较少到中等、接口易用性要求

poll 没有 select 常见的位图上限 ,但受系统资源限制(例如进程打开 fd 数量限制、内存和 Linux 对 nfds 的检查)。并不意味着可以无限监听。

性能瓶颈: poll() 每次调用都传递整组监听信息,内核和应用侧都可能进行与监听项数量有关的检查/遍历。若有上万个连接、只有少量活跃,通常考虑 epoll。

1.7 代码示例:使用 poll 监控标准输入

cpp 复制代码
#include <poll.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>

int main() {
    pollfd pfd{};
    pfd.fd = STDIN_FILENO;
    pfd.events = POLLIN;

    char buf[1024];
    while (true) {
        int n = poll(&pfd, 1, 1000); // 最多等 1000 毫秒
        if (n == -1) {
            if (errno == EINTR) continue;
            perror("poll");
            break;
        }
        if (n == 0) {
            puts("poll timeout");
            continue;
        }
        if (pfd.revents & (POLLERR | POLLNVAL)) {
            puts("stdin error");
            break;
        }
        if (pfd.revents & (POLLIN | POLLHUP)) {
            ssize_t r = read(STDIN_FILENO, buf, sizeof(buf));
            if (r > 0) {
                // 按长度输出,避免把原始字节当作 NUL 结尾字符串
                fwrite(buf, 1, static_cast<size_t>(r), stdout);
                fflush(stdout);
            } else if (r == 0) {
                puts("stdin EOF");
                break; // 文件/管道关闭后不要陷入反复就绪循环
            } else if (errno != EINTR && errno != EAGAIN) {
                perror("read");
                break;
            }
        }
    }
    return 0;
}

编译:g++ -std=c++17 -Wall -Wextra poll_stdin.cpp -o poll_stdin

面试记忆: poll 通过 pollfd 数组同时等待多个 fd,就绪结果写入 revents;相比 select,接口更直观且无 FD_SETSIZE 位图限制,但大规模连接下仍有全量提交/遍历成本。


2. epoll:Linux 高并发 I/O 多路复用

2.1 epoll 的概念

epoll 是 Linux 提供的、面向大量文件描述符的I/O 就绪事件通知机制。

相比 poll 每次都传入完整数组,epoll 把注册监听事件 和等待就绪事件分开:提前向内核注册,随后每轮只取回就绪事件。

📌 大白话: poll 像每次查岗都把"所有员工名单"重新交一遍;epoll 像先登记员工,之后管理员只通知"今天哪些人有事"。

特别注意:epoll 依旧是I/O 就绪通知,不是 Linux 完成式异步 I/O 的同义词。

2.2 epoll 的三个核心系统调用

(1)创建 epoll 实例:epoll_create1()

cpp 复制代码
#include <sys/epoll.h>

int epoll_create(int size);       // 历史接口
int epoll_create1(int flags);     // 推荐
函数/参数 含义
epoll_create(size) 创建实例;现代 Linux 中 size 不再表示容量,但必须大于 0
epoll_create1(flags) 创建实例;常用 EPOLL_CLOEXEC
EPOLL_CLOEXEC 进程执行 exec 时关闭该 epoll fd,避免不必要的继承
返回值 成功为非负 epoll fd,失败为 -1 并设置 errno

用完应 close(epfd)。监听容量不由旧接口 size 决定,而受打开 fd 数、内存、内核监视资源限制等影响。

(2)注册/修改/删除:epoll_ctl()

cpp 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
参数 含义
epfd epoll 实例的 fd
op 操作类型:EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL
fd 被监视的文件描述符
event 关心的事件和附加数据;DEL 在 Linux 上可以传 nullptr

返回:成功 0,失败 -1 并设置 errno。

epoll_event 核心成员可理解为:

cpp 复制代码
// 仅用于说明字段含义,结构体已在 <sys/epoll.h> 中定义
/*
struct epoll_event {
    uint32_t     events; // EPOLLIN / EPOLLOUT / EPOLLET 等位掩码
    epoll_data_t data;   // 用户自定义数据,例如 data.fd 或 data.ptr
};
*/

data.fd 常用于取回 fd;data.ptr 可以关联自己的 Connection 对象,但要特别管理对象生命周期,避免出现悬空指针。

(3)等待就绪:epoll_wait()

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event* events,
               int maxevents, int timeout);
参数 含义
epfd epoll 实例 fd
events 调用者提前分配的输出数组,接收就绪事件
maxevents 输出数组最多能放多少个事件,必须 > 0
timeout -1 一直等;0 立即返回;正数按毫秒等待

返回:正数为本次返回的就绪事件条目数 ,0 为超时,-1 为错误(例如 EINTR)。

关键点: maxevents 是这一次最多取回多少条就绪事件 ,与旧版 epoll_create(size) 的 size 无关,也不等于 epoll 可管理连接的总数。

2.3 常见 epoll 事件

事件 作用
EPOLLIN 可读;对监听 socket 表示可能有新连接
EPOLLOUT 可写
EPOLLPRI 高优先级事件,了解即可
EPOLLERR 出错,无须主动订阅也会返回
EPOLLHUP 挂断,无须主动订阅也会返回
EPOLLRDHUP TCP 对端关闭写方向,有助于识别半关闭
EPOLLET 启用边缘触发 ET;不设置则默认水平触发 LT
EPOLLONESHOT 一次通知后暂时禁用,需要 MOD 重新激活
EPOLLEXCLUSIVE 部分多等待者场景中减少惊群;有使用限制,属于进阶内容

常见实践: 客户端 fd 刚加入 epoll 时通常先关注 EPOLLIN。只有输出缓冲区存在尚未发送的数据时再关注 EPOLLOUT,否则 socket 经常处于可写状态,可能使事件循环频繁被唤醒。

2.4 epoll 的内部工作原理

典型 Linux 内核实现可以概括为:

  1. 监听集合:保存已注册的 fd 及事件(常用"红黑树"理解其组织形式)。
  2. 就绪队列/链表:维护等待用户处理的就绪事件。
  3. 事件回调/唤醒机制:底层对象就绪时,把相应事件记录为待处理,并唤醒等待者。
  4. epoll_wait():从就绪队列取得事件,复制到用户提供的数组。
text 复制代码
应用程序                                 Linux 内核
 epoll_ctl(ADD/MOD/DEL) ──────────────→ 监听集合
                                           │
                                fd 状态变化/就绪通知
                                           ↓
                                      就绪事件队列
                                           │
 epoll_wait() ←──────── 返回就绪事件 ───────┘
       ↓
 recv()/send()/accept() 等实际 I/O

📌 大白话: 内核平时记着"谁需要关注";哪个连接有事,就把它放进"待处理列表";程序醒来只处理这份列表。

准确理解性能: epoll_wait() 不需要每轮线性扫描全部被监听 fd,但依然需要处理和复制本次返回的就绪事件 。若返回 k 个事件,处理这些事件的成本不可能是恒定的 O(1)。不要机械背诵"epoll 永远 O(1)、一定比 poll 快"。

也不要把 epoll 与 mmap 混为一谈: 通常 epoll_wait() 返回的就绪事件仍会从内核复制到用户提供的 epoll_event 数组,并不是靠 mmap() 直接把就绪队列映射给用户。

2.5 LT(水平触发)与 ET(边缘触发)

(1)LT:Level Triggered,水平触发(默认)

概念: 只要 fd 仍满足就绪条件,下次 epoll_wait() 仍可能继续报告该事件。

例如缓冲区有 2 KB,程序只读取 1 KB,剩余 1 KB 仍可读,那么 LT 会继续提示可读。

📌 大白话: 门口有快递,快递员会持续提醒:"你还有快递没拿!"

(2)ET:Edge Triggered,边缘触发

概念: 主要在 fd 出现新的就绪通知/事件变化时报告,而不会仅因为旧数据一直没读完,就像 LT 那样持续重复提醒。

例如缓冲区有 2 KB,程序只读取 1 KB,剩余 1 KB 不一定再触发提醒。后续新数据到达等情况仍可能触发新通知,所以"ET 永远只在未就绪变为就绪那一瞬间触发一次"只是过度简化的记忆法。

📌 大白话: 快递到了通知你一次;你没把东西拿完,快递员不会因为旧快递仍在就一直提醒,但新快递到来可能会再通知。

(3)LT 与 ET 对比

对比 LT ET
就绪事件通知 只要仍就绪,可以重复报告 不会因旧的就绪条件持续重复报告
编程复杂度 较低、容错更好 较高,需要更细致处理数据
非阻塞配合 推荐;某些场景也可阻塞 强烈建议非阻塞 fd
典型读处理 可以多轮读取 通常循环读到 EAGAIN
性能 足以适应大量场景 可能减少重复通知,不保证始终更快

原文相关图(帮助理解为什么 ET 要尽量读尽缓冲区):

2.6 为什么 ET 通常必须搭配非阻塞?

原因 1:只读一部分就返回事件循环,剩余数据可能没有新的提醒。

原因 2:若循环读取到没有数据为止,阻塞 fd 可能在缓冲区读空后一直卡住,无法处理其他连接。

正确的常用写法:

cpp 复制代码
#include <unistd.h>
#include <cerrno>

void drain_readable_fd(int fd) {
    char buf[4096];
    for (;;) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            // 处理 n 个字节,必要时放入应用层接收缓冲区
            continue;
        }
        if (n == 0) {
            // TCP 对端关闭发送方向,或文件到达 EOF
            break;
        }
        if (errno == EINTR) continue;
        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
        // 其他错误,按连接/设备类型处理
        break;
    }
}

注意: 代码仅演示"读到 EAGAIN"的思想,业务程序还需要处理 EOF、异常、连接关闭和接收数据的保存;read() 返回正数并不代表已经读到一个完整应用层消息。

推荐使用 fcntl(fd, F_GETFL) 与 fcntl(fd, F_SETFL, old | O_NONBLOCK) 设置非阻塞,并检查返回值。LT 模式也建议在高并发服务器中配合非阻塞 fd :例如就绪后被其他线程抢先读空,阻塞式 recv() 仍可能卡住。

2.7 一个容易忽略的写事件问题

非阻塞 send() 可能只发送部分内容,剩余部分需要保存在应用层发送缓冲区 里,并关注 EPOLLOUT,等下次就绪继续发送。

如果暂时没有需要发送的数据,应取消 EPOLLOUT 关注,以免 socket 持续可写导致无意义唤醒。

📌 大白话: 快递车一次装不下全部货,没装完的要先放仓库;车有空位时再继续运。不能认为"车能装货"就代表"所有货已经送出"。

2.8 epoll 示例:单线程 TCP 服务器(只接收并打印)

以下示例为完整可编译的教学程序 :监听 8080,管理多个客户端,使用非阻塞 socket + ET,接收数据并打印。为集中演示 epoll,它不实现业务回包、协议解码或线程池。

cpp 复制代码
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <cstring>

int main() {
    int listenfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
    if (listenfd < 0) { perror("socket"); return 1; }

    int one = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    if (bind(listenfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0 ||
        listen(listenfd, 128) < 0) {
        perror("bind/listen"); close(listenfd); return 1;
    }

    int epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd < 0) { perror("epoll_create1"); close(listenfd); return 1; }
    epoll_event ev{};
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = listenfd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) < 0) {
        perror("epoll_ctl listen"); close(epfd); close(listenfd); return 1;
    }

    epoll_event ready[64];
    puts("listening on 0.0.0.0:8080");
    for (;;) {
        int n = epoll_wait(epfd, ready, 64, -1);
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("epoll_wait"); break;
        }
        for (int i = 0; i < n; ++i) {
            int fd = ready[i].data.fd;
            if (fd == listenfd) {
                // ET:把当前已完成队列中的连接尽量 accept 完
                for (;;) {
                    int cfd = accept4(listenfd, nullptr, nullptr,
                                      SOCK_NONBLOCK | SOCK_CLOEXEC);
                    if (cfd < 0) {
                        if (errno == EINTR) continue;
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        perror("accept4"); break;
                    }
                    epoll_event cev{};
                    cev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
                    cev.data.fd = cfd;
                    if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev) < 0) {
                        perror("epoll_ctl client"); close(cfd);
                    }
                }
            } else {
                bool closed = false;
                char buf[4096];
                for (;;) {
                    ssize_t r = recv(fd, buf, sizeof(buf), 0);
                    if (r > 0) {
                        fwrite(buf, 1, static_cast<size_t>(r), stdout);
                        fflush(stdout);
                    } else if (r == 0) {
                        closed = true; break;
                    } else if (errno == EINTR) {
                        continue;
                    } else if (errno == EAGAIN || errno == EWOULDBLOCK) {
                        break;
                    } else {
                        perror("recv"); closed = true; break;
                    }
                }
                if (ready[i].events & (EPOLLERR | EPOLLHUP)) closed = true;
                if (closed) {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                    close(fd);
                }
            }
        }
    }
    close(epfd);
    close(listenfd);
    return 0;
}

编译:g++ -std=c++17 -Wall -Wextra epoll_server.cpp -o epoll_server

测试:终端 A 运行 ./epoll_server,终端 B 运行 nc 127.0.0.1 8080 后输入内容(需要安装 netcat)。

理解重点: listenfd 上的 EPOLLIN 对应 accept4();客户端 fd 上的 EPOLLIN 对应 recv();ET 下两处都循环直到 EAGAIN。


3. select / poll / epoll 怎么选?

维度 select poll epoll
核心形式 位图集合 pollfd 数组 内核注册 + 就绪事件输出
每次等待是否传入整组监听 fd 是 是 否;先通过 epoll_ctl 注册
获取就绪信息 fd_set revents epoll_wait 输出数组
FD_SETSIZE 限制 常见实现有 无 无
支持 ET 否 否 是
平台兼容性 广泛 较广泛 Linux 专属
常见使用场景 少量 fd、兼容性需求 中小规模、易维护 大量连接、就绪连接占比小

如何选: 少量 fd 时选择 poll 很合理;大量连接且只有部分活跃时 epoll 通常更有优势。实际性能还取决于 fd 数量、活跃比例、负载分布、事件处理逻辑和系统资源。性能不能只背"大 O"。


4. Reactor:把 I/O 就绪通知组织成服务器架构

4.1 什么是 Reactor?

Reactor(反应堆模式) 是一种事件驱动的服务器设计模式 :统一等待事件,识别事件类型,然后调用对应的处理函数。epoll 是实现 Reactor 的常用底层机制,而 Reactor 是上层软件架构,两者不是同一回事。

text 复制代码
        epoll_wait() 等待事件
                ↓
        Dispatcher(事件分发)
           /             \
    监听 fd 可读        客户端 fd 就绪
        ↓                /      \
     Accepter         Recver    Sender
        ↓                ↓         ↓
   添加新 Connection   读取/解码   发送输出缓冲数据
                \       |        /
                 继续下一轮事件循环

📌 大白话: epoll 是"值班室报警器",Reactor 是"报警以后该由谁处理的值班制度"。

原文 Reactor 示意图:

4.2 Reactor 的常见组件

组件 作用
EventLoop 事件循环,反复等待并处理就绪事件
Poller 封装 epoll/poll/select 的事件监听功能
Connection / Channel 管理 fd、感兴趣事件、输入/输出缓冲区、回调等
Dispatcher 根据事件类型选择相应处理器
Accepter 从监听 socket 获取新连接,交给 Reactor 管理
Recver / Sender 处理读写及业务数据缓冲
业务回调 解码请求、执行具体业务、生成响应

关键点: 通过 Reactor 的回调执行 recv()/send(),本质上仍通常是同步非阻塞 I/O + 就绪通知,不能因为"有回调"就认定是内核完成式异步 I/O。

4.3 单 Reactor 的基本流程

  1. 创建监听 socket,绑定端口并 listen()。
  2. 创建 EventLoop/Poller,注册监听 fd 的 EPOLLIN。
  3. epoll_wait() 等待事件。
  4. 监听 fd 就绪:调用 accept()/accept4(),创建并注册 Connection。
  5. 客户端 fd 读就绪:读取数据,放入输入缓冲区,按应用层协议解析完整消息。
  6. 执行业务回调,生成响应,放入输出缓冲区。
  7. 尝试发送;若没发完,暂时订阅 EPOLLOUT,以后继续发送。
  8. 对端退出、出错或业务完成时移除 fd、释放 Connection。

为什么要输入/输出缓冲区? TCP 是字节流,一次 recv() 可能只读到半个请求,或者读到多个请求;一次 send() 也可能只发送部分响应。

📌 大白话: 收到半张订单先存起来,拼完整再处理;送货车一次运不完,就把剩余货物留下等下次。


5. One Thread One Loop(OTOL)及多核扩展

5.1 什么是 One Thread One Loop?

One Thread One Loop :通常指每个 I/O 线程拥有一个独立的事件循环,由该线程负责其所管理的连接的读写事件。

  • 线程内部事件处理通常串行执行;线程之间可以并行。
  • 通过"一个连接主要归一个 EventLoop/线程管理"简化每条连接上的并发控制。
  • 并不意味着不同线程完全没有共享数据,也不意味着整个系统不需要任何同步。

📌 大白话: 每个服务员负责自己的几桌客人,一桌尽量只让一个服务员主办,减少相互抢活;多个服务员可以同时工作。

5.2 多进程 Reactor:每个 Worker 一个 EventLoop

常见做法:主进程启动多个 Worker 子进程,每个 Worker 用自己的 epoll 实例处理连接。这样可以利用多核,也具有进程级隔离能力。

两种连接接收方式:

  1. 共享监听 socket :父进程在 fork() 前创建监听 fd,子进程继承这个 fd;多个 Worker 可以分别 accept()。需要合理处理多进程竞争和惊群;某些模式可配合 EPOLLEXCLUSIVE 或 SO_REUSEPORT 等机制。
  2. 主进程接收连接并分发 :主进程 accept() 后通过 Unix 域套接字 + sendmsg/recvmsg + SCM_RIGHTS 把已连接 socket fd 传给 Worker。

重要区别: 进程有各自的文件描述符表 ,只通过普通管道传一个 int fd 数字,不能保证接收进程里的同号 fd 指向同一个 socket。进程间传递打开的 fd 通常要用 SCM_RIGHTS;fork() 继承 fd 是另一回事。

更准确地说: 通过 fork() 继承的监听 fd 在不同进程各自的描述符表中存在,但可能引用相同的底层打开文件对象,因此不能说这些进程"完全不共享任何 fd"。

原文多进程结构图:

5.3 多线程 Reactor:主线程接入,工作线程处理

典型方式:

text 复制代码
                  Main Reactor(主线程)
                        │
                  accept() 新连接
                        │
                 选择一个 Worker
                        │
           ┌────────────┼────────────┐
           ↓            ↓            ↓
       EventLoop A  EventLoop B  EventLoop C
        管理一组连接  管理一组连接  管理一组连接

由于同一进程的线程共享文件描述符表 ,主线程 accept() 得到的客户端 fd,可以通过线程安全任务队列交给 Worker;通常再使用 eventfd 或管道唤醒 Worker 的事件循环 ,由 Worker 自己完成 epoll_ctl(ADD, ...)。

注意: 跨线程传递 fd 数字是可行的,但需要约定连接所有权、何时关闭 fd、队列同步;不能让另一个线程在使用 fd 时被提前 close()。

原文多线程参考图:

原文另一种多线程结构:

5.4 单线程、多线程、多进程怎么选?

模式 优点 主要注意事项
单线程单 Reactor 简单、连接状态管理清晰 长耗时业务回调会阻塞所有连接
多线程多 EventLoop 利用多核,线程间共享数据方便 需要线程安全队列、唤醒、连接归属管理
多进程多 EventLoop 故障隔离更强,适合多 Worker IPC 成本、共享监听竞争、进程间状态管理

如果业务计算较耗时,还可让 I/O 线程只做网络读写,把 CPU 密集型任务交给工作线程池,避免卡住事件循环。


6. eventfd:在线程间唤醒事件循环

6.1 什么是 eventfd?

eventfd 是 Linux 提供的一种基于 64 位计数器的事件通知机制 ,对外表现为一个 fd,可以加入 epoll/poll/select 进行监听。

典型用途:Worker 线程正在 epoll_wait(),主线程给它的任务队列放入新任务后,向 eventfd 写入一个数字,Worker 被唤醒,再从任务队列取任务处理。

📌 大白话: 任务队列是"放订单的盒子",eventfd 是"按门铃"。门铃只提醒"有事了",订单内容还在盒子里。

6.2 函数与参数

cpp 复制代码
#include <sys/eventfd.h>
#include <stdint.h>
#include <unistd.h>

int eventfd(unsigned int initval, int flags);
  • initval:计数器初始值。
  • flags:通常使用 EFD_NONBLOCK | EFD_CLOEXEC;需要信号量语义时加 EFD_SEMAPHORE。
  • 返回值:成功返回 fd,失败返回 -1;使用完毕需 close()。

6.3 eventfd 的两种模式

模式 写入行为 读取行为
默认模式 将写入数值累加到计数器 一次 read() 取出当前计数值,并把计数器清零
EFD_SEMAPHORE 同样累加 计数器非零时,一次读取返回数值 1,计数器减 1

重要细节:

  • 使用 read(fd, &value, sizeof(value)) 和 write(fd, &value, sizeof(value)),每次读写的数据长度是 8 字节 。成功时系统调用返回的字节数是 8,真正的计数值存在 value 里。
  • 计数器为 0 时不可读;非阻塞读取返回 -1,errno = EAGAIN。
  • 写入的数值不能是 UINT64_MAX;超出允许的计数上限时,阻塞写会等待,非阻塞写可能返回 EAGAIN。
  • eventfd 保证计数器更新的原子性,但不替代任务队列本身的同步机制。
  • eventfd 无法直接传递字符串、连接对象等完整业务消息。

6.4 最小示例:写通知并读取计数

cpp 复制代码
#include <sys/eventfd.h>
#include <unistd.h>
#include <cstdint>
#include <cstdio>

int main() {
    int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
    if (efd == -1) { perror("eventfd"); return 1; }

    std::uint64_t inc = 3;
    if (write(efd, &inc, sizeof(inc)) != sizeof(inc)) {
        perror("eventfd write"); close(efd); return 1;
    }

    std::uint64_t value = 0;
    if (read(efd, &value, sizeof(value)) == sizeof(value)) {
        printf("counter = %llu\n", static_cast<unsigned long long>(value));
    }
    close(efd);
}

输出:counter = 3。这表示读取到的计数值为 3,不是"本次 read 返回了 3 字节"。

6.5 eventfd、管道和 Unix 域套接字的区别

机制 适合做什么 是否能传复杂数据/真正的 fd
eventfd 轻量级计数/通知、唤醒事件循环 不直接传复杂数据;不能跨进程传递已打开 fd
pipe 字节流通知/少量消息 能传字节,但跨进程传 int 不等于 fd 传递
Unix 域套接字 进程间传消息/连接分发 可以通过 SCM_RIGHTS 传递打开的 fd

不要机械认为 eventfd 总比管道快;性能还取决于具体负载及实现。若任务内容需要传输,而不是只做唤醒,管道或 Unix 域套接字更合适。


7. Reactor 网络计算器案例:保留关键设计思路

原文的 Reactor 网络计算器项目按应用层协议 → 连接对象 → 事件循环 组织,适合作为综合练习。其完整工程依赖 Cal、Protocol、Connection、Listener、Channel、Reactor 等类;原文仅给出了部分流程说明,没有附齐全部源码,因此这里保留设计脉络,不把伪代码冒充可直接运行的完整项目。

项目原参考链接:Linux 网络 Reactor 反应堆示例资源

7.1 核心对象职责

对象 核心职责
Cal 执行加、减、乘、除、取模等业务计算,并处理非法运算
Request / Response 请求与响应的数据结构
Protocol 解决应用层封包、拆包、解码、编码;调度业务处理
Listener 监听端口、获取新连接
Channel / Connection 保存 fd、接收/发送缓冲区、关注的事件、回调等
Reactor 统一管理连接并通过 epoll 等待与分发事件

7.2 程序启动:main()

  1. 初始化日志和监听端口。
  2. 创建 Cal(计算逻辑)与 Protocol(编解码及业务入口)。
  3. 创建 Listener,绑定收到请求后的处理回调。
  4. 创建 Reactor,调用 AddConnection(listener) 注册监听 fd。
  5. 运行 Loop(),进入事件循环。

7.3 添加连接:AddConnection()

  1. 检查 fd 是否已经被当前 Reactor 管理,避免重复注册。
  2. 设置需要关注的事件,比如监听 fd 上的 EPOLLIN。
  3. 通过 epoll_ctl(ADD, ...) 注册到 epoll。
  4. 把连接对象保存在 Reactor 的连接表里,明确谁负责其生命周期。

7.4 事件循环:Loop() / LoopOnce() / Dispatcher()

text 复制代码
Loop()
  └─ LoopOnce(): epoll_wait() 返回就绪事件
       └─ Dispatcher(): 根据事件类型分发
            ├─ 监听 fd 可读 → Listener/Accepter → 新增 Connection
            ├─ 普通 fd 可读 → Recver → inbuffer → Decode → Execute
            └─ 普通 fd 可写 → Sender → 发送 outbuffer 中剩余数据

处理完整请求时不能依赖单次 recv(): 应把字节持续累积到 inbuffer,只有凑齐一个完整应用层数据包才进行 Decode → Execute;每次读可能收到半包 或多个完整包。

回包也不能依赖单次 send(): 先将结果编码到 outbuffer,发送不完时维护发送进度并订阅写事件,直到全部发送完后取消 EPOLLOUT。

7.5 生命周期与常见陷阱

  • 接收到 EOF:根据协议决定是否处理已读完的数据、处理未发送响应,并最终关闭连接。
  • EPOLLERR/EPOLLHUP:不能简单等价为普通读/写事件;应获取错误并考虑缓冲区尚有数据的情况。
  • 使用 data.ptr 指向连接对象时:删除连接后不能再访问旧事件中的悬空指针;需要设计生命周期/延迟销毁策略。
  • Reactor 回调执行时间过长时,其他 fd 就绪也不能及时处理;耗时业务交给工作线程池。
  • 同一连接尽量明确归属哪个 I/O 线程,避免多线程重复读写和重复关闭。

📌 大白话: Reactor 负责"接电话、分配业务";Protocol 负责"听懂对方说什么";Cal 负责"算出答案";Connection 负责"保存尚未听完的话和还没说完的回复"。


8. 面试常见问题与参考答案

Q1. poll 与 select 是同一种 I/O 模型吗?

是,都是I/O 多路复用 。select 使用 fd_set,poll 使用 pollfd 数组。

Q2. pollfd.events 和 pollfd.revents 有什么区别?

events 是用户指定的待观察事件;revents 是内核填入的实际发生事件,判断时要用位运算。

Q3. 为什么判断事件用 revents & POLLIN 而不是 revents == POLLIN?

因为同一 fd 可以同时出现多个事件标志,例如可读与挂断。

Q4. poll 是否像 select 一样最多只能监听 1024 个 fd?

不受 FD_SETSIZE 限制,但仍受到文件描述符数和其他系统资源限制。

Q5. poll 为什么不适合海量低活跃连接?

每轮需要处理完整 pollfd 数组,内核和用户侧可能产生随监听数量增长的开销。

Q6. epoll_create1() 返回的是什么?

一个表示 epoll 实例的 fd,要用 close() 释放。

Q7. epoll 三个核心接口分别干什么?

epoll_create1 创建实例,epoll_ctl 维护监听事件,epoll_wait 获取就绪事件。

Q8. epoll_wait() 的 maxevents 是监听连接数的上限吗?

不是,它限制的是本次输出数组最多返回多少条就绪事件。

Q9. epoll 为什么通常比 poll 更适合海量低活跃连接?

因为事件事先注册,等待时不必重复提交完整监听数组,取回的是就绪事件,而不是由用户每轮遍历所有被监视 fd。

Q10. epoll 有没有使用 mmap 来共享就绪链表?

标准 epoll 接口不是依赖 mmap 来让用户直接读取就绪链表,epoll_wait 仍会复制就绪事件到用户数组。

Q11. LT 和 ET 的区别是什么?

LT 只要仍就绪就可继续报告;ET 主要依靠新的就绪通知,不会单凭旧数据未读完就持续提醒。

Q12. 为什么 ET 配合非阻塞?

要循环读写到 EAGAIN;若 fd 为阻塞模式,缓冲区读空后继续调用可能把整个事件循环卡住。

Q13. EPOLLIN 表示一定收到正常数据吗?

不一定,还可能表示对端关闭写方向、监听 socket 有新连接等,要根据 fd 类型和系统调用返回值判断。

Q14. 为什么不是一直监听 EPOLLOUT?

socket 往往长时间可写,没有待发送数据时继续监听会产生大量无意义的就绪通知。

Q15. epoll 和 Reactor 有什么区别?

epoll 是 Linux 提供的 I/O 就绪通知机制;Reactor 是基于事件循环和事件分发组织服务器的软件设计模式。

Q16. Reactor 模式属于同步还是异步 I/O?

典型 Reactor 使用同步非阻塞读写加 I/O 多路复用;"回调式编程"不意味着数据复制本身就是完成式异步 I/O。

Q17. 多进程能通过管道传一个整数 fd 就共享 socket 吗?

不能。不同进程有各自的 fd 表;应通过 fork() 继承打开的 fd,或用 Unix 域套接字的 SCM_RIGHTS 传递 fd。

Q18. 主线程怎样唤醒正在 epoll_wait 的工作线程?

可以向工作线程注册的 eventfd 写入计数,触发读就绪;实际任务通过线程安全队列传递。

Q19. eventfd 和管道有什么区别?

eventfd 传递计数/通知,管道传递字节流;若需进程间传送真正的 fd,应使用支持 SCM_RIGHTS 的 Unix 域套接字。

Q20. TCP 粘包/拆包和 epoll 有关系吗?

TCP 是字节流,消息边界必须由应用层协议约定(长度字段、分隔符等);无论是 poll 还是 epoll,都不能保证一次读到一个完整业务消息。


9. 考前速记

  • poll :pollfd {fd, events, revents};等多个 fd;返回后遍历数组,检测 revents。
  • epoll :epoll_create1 → epoll_ctl → epoll_wait;事件事先注册,按需取得就绪事件。
  • LT :仍就绪就可能继续提醒。ET :新的就绪通知;通常配非阻塞并读写到 EAGAIN。
  • EPOLLIN :能尝试读/accept;EPOLLOUT:能尝试写,并不保证完全写入。
  • Reactor :EventLoop + Poller + Dispatcher + Connection + 业务回调。
  • OTOL:一个 I/O 线程管理一个事件循环,多线程各管一组连接。
  • eventfd:64 位计数器,适合唤醒事件循环;复杂任务内容在队列里。
  • 最重要的工程习惯 :检查系统调用返回值、处理 EINTR/EAGAIN、维护收发缓冲区、避免重复关闭 fd。

一句话面试总结: poll 和 epoll 都是 I/O 多路复用的就绪通知机制;epoll 通过事件注册和就绪队列降低大量连接下的重复扫描开销。Reactor 在此基础上组织连接管理和事件分发,One Thread One Loop 负责多核扩展,eventfd 常用于线程之间唤醒事件循环。

相关推荐
Raas1002 小时前
MAI Gateway(魔芋企业级AI网关)功能全解:AI网关能做Prompt注入防护吗?一文看懂AI网关能力矩阵(v1.2.0)
网络·人工智能·gateway·prompt·mai gateway·企业级ai网关·企业级产品
Lzg_na2 小时前
DHCP 作用、原理、机制和报文格式详解
网络
发量惊人的中年网工2 小时前
SD-WAN 组网方案怎么选?跨地域与多分支企业的选型指南(含成本对比)
服务器·网络·数据库
星恒讯工业路由器3 小时前
从5G-A到6G:工业通信网络的技术演进与产业落地
网络·物联网·5g·智能路由器·工业路由器·工业物联网·5g-a
砚凝霜3 小时前
【软考信息安全】第二十一章 网络设备安全概述与安全机制
网络·安全
Escalating_xu4 小时前
【Linux 网络编程】网络基础概念(下):IP、端口、Socket 与网络字节序,写代码前先打好基础
linux·网络·tcp/ip
zbtlink5 小时前
网络卡在哪儿:空口时间、会话表容量与接入速率的可复现定位方法
网络·智能路由器
CCblandy5 小时前
Airllm 大模型本地化部署与高效推理应用指南
大数据·网络·人工智能
睡一觉就好了。5 小时前
序列化与非序列化
linux·网络