网络 IO 与 epoll 深入分析
网络 IO 与 epoll 深入分析
本文深入分析 Linux 内核网络数据接收流程、socket 机制、惊群效应及其解决方案,以及 epoll 的实现原理和 Nginx 的 IO 复用实践。
1、内核如何接收网络数据
不同主机通过网卡进行数据交互。网卡将电磁波转换为模拟信号,再转换为数字信号,最后由 OSI 模型逐层传递到应用层,变成应用程序可以处理的数据。本节从接收到数字信号开始分析数据的流转过程。
数据接收流程
sequenceDiagram
participant NIC as 网卡
participant DMA as DMA 引擎
participant CPU as CPU
participant Kernel as 内核协议栈
participant Socket as Socket
participant App as 应用进程
NIC->>DMA: 1. 数据到达网卡
DMA->>DMA: 2. DMA 拷贝到 RingBuffer
DMA->>CPU: 3. 发起硬中断
CPU->>CPU: 4. 硬中断处理(设置标记)
CPU->>Kernel: 5. 触发软中断
Kernel->>Kernel: 6. 从 RingBuffer 取数据
Kernel->>Socket: 7. 协议栈处理(IP → TCP/UDP)
Socket->>App: 8. 唤醒等待进程
详细步骤说明:
- 数据到达网卡:当数据帧从网线到达网卡时,首先进入网卡的接收队列
- DMA 传输:网卡在分配给自己的 RingBuffer 中寻找可用的内存位置,找到后 DMA 引擎会把数据直接拷贝到网卡关联的内存中。这个过程 CPU 完全无感知
- 硬中断通知:DMA 操作完成后,网卡向 CPU 发起一个硬中断,通知 CPU 有数据到达
- 硬中断处理:Linux 在硬中断中只完成简单必要的工作(记录寄存器、修改 CPU 的
poll_list),然后发出软中断。硬中断处理过程非常短暂 - 软中断处理:软中断和硬中断都调用
local_softirq_pending函数,区别在于硬中断用于写入标记,软中断用于读取标记 - 协议栈处理:从 RingBuffer 取出数据帧,送入协议栈处理(IP → TCP/UDP)。**
tcpdump就是在这一层捕获数据包** - 通知应用进程:协议栈将数据送往对应的 socket,socket 通知对应的进程
2、Socket 概念与进程唤醒机制
2.1、Socket 与进程的关系
用户进程通过 socket 接口与内核进行数据交互。当一个进程想要监听(listen)某个端口时,首先需要创建一个 socket 并绑定该端口。当该端口收到数据时,内核会将数据送往对应的协议栈,协议栈主要做两件事:
- 保存数据:将数据保存到 socket 的接收缓冲队列
- 唤醒进程:唤醒等待队列上的进程
2.2、多进程场景下的 Socket 共享
以多进程模型为例:
graph TD
Master[Master 进程] -->|fork| Worker1[Worker 进程 1]
Master -->|fork| Worker2[Worker 进程 2]
Master -->|fork| WorkerN[Worker 进程 N]
Socket[共享 Socket<br>等待队列] --> Worker1
Socket --> Worker2
Socket --> WorkerN
Worker1 -->|各自拥有| Epoll1[epoll 实例 1]
Worker2 -->|各自拥有| Epoll2[epoll 实例 2]
WorkerN -->|各自拥有| EpollN[epoll 实例 N]
- Master 进程监听某端口后,会创建对应的 socket
- 后续
fork()时,子进程共享这个已打开的 socket - 当某个进程调用
accept()时,内核会动态地将该进程注册到 socket 的等待队列中 - 这导致 socket 的等待队列中存在多个进程
关键点:多进程共享一个 socket,但每个进程各自拥有独立的 epoll 实例。
2.3、唤醒机制与惊群效应
当数据到达时,如果唤醒所有进程,就会引发惊群效应(Thundering Herd)。
使用 epoll 时的唤醒流程:
- 根据 socket 等待队列中的元素,找到对应的 epoll 和 epitem
- 回调函数会把 epitem 放到 epoll 对象的就绪链表中
- 后续
epoll_wait从就绪链表读取事件进行处理
两种共享模式:
- 多进程共享一个 socket:每个进程有自己的 epoll(常见模式)
- 多进程共享一个 epoll:较少使用,设计上不推荐
3、惊群效应(Thundering Herd)
讨论惊群效应需要分层次讨论,因为 socket 层和 epoll 层都可能产生惊群效应:
| 场景 | 惊群发生位置 |
|---|---|
先 fork() 再 epoll_create() |
socket 的等待队列 |
先 epoll_create() 再 fork() |
epoll 的等待队列 |
3.1、Socket 层的惊群效应
场景描述:多个进程共享一个 socket,即主进程 create → bind → listen,然后 fork 子进程。多个进程共享同一个 socket 进行 accept。
问题分析:
- 当使用 epoll 时,socket 的等待队列中存在多个 epoll 实例
- 以 Nginx 为例,每个 worker 都有自己的 epoll,会把共享的 socket 注册到各自的 epoll
- 相应地,epoll 的回调函数会注册到 socket 的等待队列
- 当数据到达 socket 时,会”唤醒”等待队列的各个进程(调用 epoll 注册的回调函数)
- 问题:如果唤醒所有进程,就会引发惊群效应,因为只有一个进程能
accept成功
解决方案
| 方案 | 原理 | Linux 版本 |
|---|---|---|
WQ_FLAG_EXCLUSIVE |
唤醒时只唤醒一个进程,但无法解决 epoll 场景 | 2.6 |
SO_REUSEPORT |
每个进程有独立 socket,内核负责负载均衡 | 3.9 |
| 应用层加锁 | 只有拿到锁的进程才能 accept(Nginx 早期方案) | - |
EPOLLEXCLUSIVE |
epoll 层的独占唤醒机制 | 4.5 |
EPOLLEXCLUSIVE 详解:
- 用户层可通过
EPOLLEXCLUSIVE标志启用独占模式 - 底层使用
WQ_FLAG_EXCLUSIVE实现 - 添加事件时,epoll 会将对应的 epitem 节点标记为”独占模式”
- 带有
EPOLLEXCLUSIVE的监听者会被加入独占等待队列 - 唤醒时只唤醒第一个处于
EPOLLEXCLUSIVE模式的监听者
3.2、Epoll 层的惊群效应
场景描述:多个进程/线程共享一个 epoll 对象。主进程先 epoll_create(),然后 fork() 创建多个子进程。
补充说明:这种模式在多线程中更常见。使用
pthread_create()创建线程,本质上和fork()没有区别,都会调用kernel_clone(),区别在于传入的参数不同,决定子进程是否与父进程共享地址空间等资源。
问题分析:
- 各个进程调用
epoll_wait时,会把自己注册到 epoll 的等待队列 - 导致 epoll 的等待队列存在多个进程
- 当 socket 执行 epoll 的回调函数时,epoll 会把 epitem 放到就绪链表,然后唤醒等待队列的进程
- 如果唤醒所有进程,就会引发惊群效应
解决方案
查看 Linux 内核代码,epoll_wait 会默认设置独占模式:
1 | |
用户调用 epoll_wait 进入阻塞状态时,如果没有事件,会把当前进程写入 epoll 的等待队列,并设置 WQ_FLAG_EXCLUSIVE。这意味着这种场景下 Linux 内核已经解决了惊群效应。
推荐架构设计
一般多线程架构不会采用共享 epoll 的设计,而是:
- 主线程负责
accept - 创建新的 socket 连接后,交由 worker 线程
- Worker 线程把新 socket 加到自己的 epoll,处理后续事件
注意:多进程共享一个 epoll 是不推荐的设计模式。
3.3、关键属性详解
3.3.1、WQ_FLAG_EXCLUSIVE
作用:
- 减少惊群效应:当多个进程等待同一个 socket 上的事件时,新连接到来会导致所有阻塞的进程都被唤醒,但最终只有一个能处理这个连接,其余进程会重新进入等待状态
- 内核层面优化:通过内核排他性唤醒机制,确保一次只唤醒一个等待队列中的进程,减少不必要的上下文切换和性能损耗
3.3.2、SO_REUSEPORT
作用:
- 端口复用:允许多个进程绑定到同一端口,每个进程独立处理收到的数据(传统 socket 编程中,一个端口只能被一个进程绑定)
- 内核负载均衡:内核层面实现负载均衡,将新连接均匀分配给不同的进程,提高多核系统的并行处理能力
3.3.3、对比分析
| 对比维度 | WQ_FLAG_EXCLUSIVE |
SO_REUSEPORT |
|---|---|---|
| 作用层面 | 内核层面减少不必要的进程唤醒 | 应用层面允许多进程共享端口 + 内核负载均衡 |
| 应用场景 | 减少进程间的唤醒竞争 | 多进程共享端口,提高并发处理能力 |
| 性能优化方式 | 减少不必要的进程唤醒 | 负载均衡 + 多进程并行处理 |
3.3.4、SO_REUSEPORT vs EPOLLEXCLUSIVE 详细对比
| 特性 | SO_REUSEPORT | EPOLLEXCLUSIVE |
|---|---|---|
| 所属层 | Socket 层(内核 TCP/IP 协议栈) | Epoll 层(事件通知机制) |
| 作用对象 | 监听 socket(listen fd) | epoll 实例中的事件注册项 |
| 控制粒度 | 每个 socket | 每个 epoll 实例 + FD 组合 |
| 设计目的 | 多 socket 绑定同一端口、分散新连接 | 多个进程监听同一 socket 时避免惊群 |
| 主要用途 | 多个进程各自有独立 listen fd | 多个进程共享同一个 listen fd |
| 是否均衡连接分配 | 是,由内核 hash 决定 | 否,依赖队列顺序,需应用层辅助均衡 |
| 相互影响 | 相互独立 | 相互独立 |
总结:
- SO_REUSEPORT:内核将多个 socket 进行负载均衡,只唤醒一个 socket(一个 socket 对应一个进程)
- EPOLLEXCLUSIVE:只存在一个 socket,但等待队列中有多个进程(epoll),只唤醒一个进程
重要区别:SO_REUSEPORT 可以保证多个进程获得的连接近似均匀,而 EPOLLEXCLUSIVE 无法保证,需要应用层辅助实现(详见 5.2 小节)。
3.4、内核源码分析
3.4.1、Socket 层的唤醒机制
数据到达协议栈后,最终会调用 __wake_up_sync_key 唤醒进程:
1 | |
调用链:__wake_up_common_lock → __wake_up_common
1 | |
关键代码解析:
1 | |
| 条件 | 含义 |
|---|---|
ret |
唤醒操作是否成功(true 表示成功) |
flags & WQ_FLAG_EXCLUSIVE |
当前队列项是否为独占型 |
!--nr_exclusive |
独占名额减 1 后是否为 0(用完则跳出循环) |
等待队列的排列顺序
问题:如果非独占型进程在前,独占型进程在后,会发生什么?
1 | |
Linux 的解决方案:添加进程时,独占型进程会被优先添加到队列头部:
1 | |
添加顺序示例:
1 | |
唤醒场景总结
| 场景 | 队列中有独占任务 | nr_exclusive |
结果 | 返回值 |
|---|---|---|---|---|
| 1 | 否 | 1 | 所有非独占任务被唤醒 | 1 |
| 2 | 否 | 0 | 所有非独占任务被唤醒 | 0 |
| 3 | 是 | 1 | 唤醒一个独占任务后退出 | 0 |
| 4 | 是 | 0 | 所有任务都被唤醒 | 负数 |
结论:Socket 处理过程中 nr_exclusive 始终为 1,是否设置 WQ_FLAG_EXCLUSIVE 由用户层决定。
3.4.2、Epoll 层的唤醒机制
Epoll 唤醒等待队列时的调用链:wake_up_locked() → __wake_up_locked → __wake_up_common
1 | |
关键区别:
epoll_wait将进程设置到 epoll 等待链表时,默认设置WQ_FLAG_EXCLUSIVE- Socket 的等待队列不会默认设置
WQ_FLAG_EXCLUSIVE - 且
nr_exclusive传入 1,根据上述组合分析,只会唤醒一个独占进程
4、Epoll 实现原理
Epoll 主要涉及 3 个核心接口:
| 接口 | 功能 |
|---|---|
epoll_create |
创建一个 epoll 对象 |
epoll_ctl |
向 epoll 对象中添加/修改/删除 FD |
epoll_wait |
等待其管理的连接上的 IO 事件 |
4.1、epoll_create - 创建 Epoll 对象
用户进程调用 epoll_create 时,内核会创建一个 struct eventpoll 结构体:
1 | |
核心成员说明:
| 成员 | 类型 | 作用 |
|---|---|---|
wq |
wait_queue_head_t |
等待队列链表。软中断数据就绪时,通过 wq 找到阻塞在 epoll 上的用户进程 |
rbr |
struct rb_root |
红黑树。支持对海量连接的高效查找、插入和删除(O(log n)) |
rdllist |
struct list_head |
就绪描述符链表。连接就绪时,内核把就绪连接放入此链表 |
设计优势:应用进程只需检查 rdllist 链表即可获取就绪事件,无需遍历整棵红黑树。
4.2、epoll_ctl - 管理监听事件
使用 epoll_ctl 注册 socket 时,内核执行以下三个步骤:
- 分配 epitem:为该 socket 分配一个红黑树节点对象
epitem - 注册回调:添加等待事件到 socket 的等待队列,回调函数为
ep_poll_callback - 插入红黑树:将
epitem插入到 epoll 对象的红黑树中
epitem 数据结构
1 | |
回调函数注册
创建并初始化 epitem 后,ep_insert 会设置 socket 对象上的等待任务队列,并将 ep_poll_callback 设置为数据就绪时的回调函数:
1 | |
注意:这里是向 socket 的等待队列 注册回调,不是 epoll 对象的等待队列。
最后,将 epitem 对象插入到红黑树中完成注册。
4.3、epoll_wait - 等待事件
epoll_wait 的工作流程:
- 检查
eventpoll->rdllist就绪链表是否有数据 - 有数据:立即返回就绪事件
- 无数据:创建等待队列项,添加到 eventpoll 的等待队列,然后阻塞自己
关键点:添加到 eventpoll 等待队列时,会附带
WQ_FLAG_EXCLUSIVE属性(独占模式)。
4.4、数据接收流程
sequenceDiagram
participant NIC as 网卡/协议栈
participant Socket as Socket
participant Epoll as Epoll 对象
participant Process as 用户进程
NIC->>Socket: 1. 数据到达协议栈
Socket->>Socket: 2. 找到对应 socket
Socket->>Epoll: 3. 调用 ep_poll_callback
Epoll->>Epoll: 4. 将 epitem 加入就绪链表
Epoll->>Epoll: 5. 检查是否有等待进程
Epoll->>Process: 6. 唤醒 epoll_wait 进程
Process->>Process: 7. 从 schedule 恢复
Process->>Process: 8. 返回就绪事件并处理
详细流程:
- 网卡数据交给 TCP 协议栈处理
- 协议栈找到对应的 socket
- 调用 socket 等待队列中注册的
ep_poll_callback回调函数 ep_poll_callback将对应的epitem添加到 epoll 的就绪队列- 检查 eventpoll 的等待队列是否有等待项
- 调用
__wake_up_common,执行default_wake_function唤醒进程 - 将
epoll_wait进程推入可运行队列,等待内核调度 - 进程醒来后,从
schedule恢复,返回就绪事件给用户进程
4.5、Epoll 的本质与定位
Epoll 的核心优势
传统阻塞 I/O:每次处理一个 socket,没有数据就阻塞睡眠,有新事件才唤醒。
Epoll 的优势:让一个进程同时处理多个 socket,显著减少进程切换开销,提升处理性能。
Epoll 是同步还是异步?
| 特性 | Epoll | 真正的异步 I/O(io_uring、AIO) |
|---|---|---|
| 等待方式 | epoll_wait 阻塞等待事件触发 |
发起 I/O 后立即返回 |
| 数据读写 | 不负责,只告诉哪些 socket 可以读写 | 由内核/驱动完成 I/O 操作 |
| 通知方式 | 返回就绪 FD 列表 | 通过回调或信号通知操作结果 |
| 本质 | 同步 I/O 多路复用 | 异步 I/O |
结论:
- Epoll 本身通过
epoll_wait阻塞等待事件触发 - Socket 可设置为非阻塞模式,
read/write时若无数据则立即返回错误码 - Epoll 只是事件通知机制,不负责真正的数据读写
- 应用程序仍需显式调用
read/write,因此 epoll 本质上是同步的
5、Nginx 的 IO 复用实践
Nginx 的 I/O 模型
Nginx 本质上属于事件驱动的非阻塞 I/O 模型:
- 非阻塞:处理请求时不会因等待 I/O 操作(如读取数据)而阻塞当前进程
- 事件驱动:通过事件循环和回调机制,在 I/O 操作完成时继续处理请求
- 依赖系统调用:依赖操作系统(如
epoll)管理事件触发
5.1、Nginx 如何选择 IO 复用机制
Nginx 在初始化事件模块时,根据系统支持的事件模型(epoll、devpoll、kqueue、select)选择合适的事件模块。支持哪些模型由编译期决定:
| 操作系统 | 支持的事件模型 |
|---|---|
| Linux | epoll |
| BSD/macOS | kqueue |
| Solaris | devpoll |
| 通用 | select/poll |
1 | |
定义ngx_epoll_module_ctx
1 | |
epoll初始化时
1 | |
设置事件模块的回调为 epoll 的函数。如果使用的是 poll,则会是 poll 的方法。
统一的事件接口宏
1 | |
这种设计实现了运行时多态,使 Nginx 能够在不同平台使用最优的事件机制。
5.2、EPOLLEXCLUSIVE 的负载均衡问题
Nginx 默认开启 EPOLLEXCLUSIVE,但存在一个负载不均衡的问题:
1 | |
问题分析
根据 3.4 节的分析,添加独占型进程时,独占型进程总是在链表前面,但后加的独占型进程会排在前面独占型的后面:
1 | |
问题:C 是后注册的,但排在 B 后面。由于只唤醒第一个独占进程,所有连接事件都会被 B 处理,导致负载不均衡。
Nginx 的解决方案
当一个 socket 的连接数大于 16 时,重新将该 socket 添加到 epoll 中:
1 | |
1 | |
性能测试验证
有人报告这个补丁使性能下降 10%,于是使用 Nginx 1.27 版本做性能测试对比:
测试命令:
1 | |
参数说明:
-t10:使用 10 个线程发送 HTTP 请求-c500:每个线程保持 500 个连接(共 5000 个连接)-d10s:测试运行 10 秒
测试环境:2C2G
测试结果对比
1、带补丁版本(默认)
| 次数 | 请求速率 (Requests/sec) | 延迟 (Avg)/ms |
|---|---|---|
| 1 | 17090.42 | 49.61 |
| 2 | 17432.39 | 41.05 |
| 3 | 17204.18 | 38.19 |
| 4 | 18132.32 | 29.42 |
| 5 | 18691.90 | 33.30 |
| 6 | 17703.41 | 32.45 |
| 7 | 17217.81 | 39.34 |
| 8 | 16366.40 | 43.62 |
| 9 | 17850.73 | 40.91 |
| 平均 | 17521.06 | 38.65 |
2、去掉补丁版本
| 次数 | 请求速率 (Requests/sec) | 延迟 (Avg)/ms |
|---|---|---|
| 1 | 16526.34 | 40.29 |
| 2 | 16450.97 | 44.82 |
| 3 | 16267.48 | 42.94 |
| 4 | 15692.15 | 41.11 |
| 5 | 15625.75 | 46.53 |
| 6 | 15756.77 | 41.79 |
| 7 | 15714.21 | 44.25 |
| 8 | 15693.36 | 46.92 |
| 9 | 16354.10 | 57.01 |
| 平均 | 16009.01 | 45.67 |
结论:性能不仅没有下降,反而有提升。该补丁可提升约 9.44% 的 QPS,同时降低平均延迟。
5.3、SO_REUSEPORT 实现详解
配置解析阶段
进程初始化解析配置时,会判断端口是否开启了 reuseport:
1 | |
事件模块初始化阶段
1 | |
**关键函数 ngx_clone_listening**:克隆当前监听套接字,为每个 worker 进程创建独立的监听实例。
作用:
- 每个克隆的监听套接字分配给一个 worker,拥有独立的监听队列
- 每个进程能独立处理自己的客户端连接,避免锁争用,提高性能
Worker 进程初始化
每个 worker 进程初始化时,只保留属于自己的监听套接字,关闭其他套接字:
1 | |
注册 Epoll 到 Socket
最后看一下将 epoll 注册到 socket 内核中的流程:ngx_add_event → epoll_ctl
三种注册模式:
| 条件 | 注册方式 |
|---|---|
开启 reuseport |
将本进程的 epoll 注册到自己的 socket |
开启 EPOLLEXCLUSIVE |
将本进程的 epoll 独占式注册到共享 socket |
| 都未开启 | 普通方式注册到共享 socket |
说明:
c = rev->data,c->fd就是本进程监听的 socket(在上面的步骤中已设置)。
1 | |
使用 accept_mutex 锁的传统方式
当不使用 reuseport 时,Nginx 通过 accept_mutex 锁来避免惊群效应:
1 | |
5.4、实际效果对比
开启 reuseport
配置:1 个 Master + 2 个 Worker,开启 reuseport

未开启 reuseport

对比结论:
- 开启
reuseport后,不同的 worker 监听各自独立的 socket - 内核负责负载均衡,将连接分配给不同的 worker
- 实际性能测试表明,开启
reuseport可以明显提升性能
总结
| 特性 | 作用 | 推荐场景 |
|---|---|---|
SO_REUSEPORT |
内核级负载均衡,每个进程独立 socket | 高性能生产环境首选 |
EPOLLEXCLUSIVE |
独占唤醒,避免惊群 | 不支持 reuseport 时的替代方案 |
accept_mutex |
应用层锁,只有拿到锁的进程才能 accept | Nginx 早期方案,现已不推荐 |
最佳实践:在 Linux 3.9+ 环境下,推荐使用 SO_REUSEPORT 获得最佳性能。
参考资料
- 《深入理解 Linux 网络》第三章
- Linux 内核版本:6.12
- Nginx 版本:1.27