网络 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. 唤醒等待进程

详细步骤说明

  1. 数据到达网卡:当数据帧从网线到达网卡时,首先进入网卡的接收队列
  2. DMA 传输:网卡在分配给自己的 RingBuffer 中寻找可用的内存位置,找到后 DMA 引擎会把数据直接拷贝到网卡关联的内存中。这个过程 CPU 完全无感知
  3. 硬中断通知:DMA 操作完成后,网卡向 CPU 发起一个硬中断,通知 CPU 有数据到达
  4. 硬中断处理:Linux 在硬中断中只完成简单必要的工作(记录寄存器、修改 CPU 的 poll_list),然后发出软中断。硬中断处理过程非常短暂
  5. 软中断处理:软中断和硬中断都调用 local_softirq_pending 函数,区别在于硬中断用于写入标记,软中断用于读取标记
  6. 协议栈处理:从 RingBuffer 取出数据帧,送入协议栈处理(IP → TCP/UDP)。**tcpdump 就是在这一层捕获数据包**
  7. 通知应用进程:协议栈将数据送往对应的 socket,socket 通知对应的进程

2、Socket 概念与进程唤醒机制

2.1、Socket 与进程的关系

用户进程通过 socket 接口与内核进行数据交互。当一个进程想要监听(listen)某个端口时,首先需要创建一个 socket 并绑定该端口。当该端口收到数据时,内核会将数据送往对应的协议栈,协议栈主要做两件事:

  1. 保存数据:将数据保存到 socket 的接收缓冲队列
  2. 唤醒进程:唤醒等待队列上的进程

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 时的唤醒流程

  1. 根据 socket 等待队列中的元素,找到对应的 epoll 和 epitem
  2. 回调函数会把 epitem 放到 epoll 对象的就绪链表
  3. 后续 epoll_wait 从就绪链表读取事件进行处理

两种共享模式

  • 多进程共享一个 socket:每个进程有自己的 epoll(常见模式)
  • 多进程共享一个 epoll:较少使用,设计上不推荐

3、惊群效应(Thundering Herd)

讨论惊群效应需要分层次讨论,因为 socket 层和 epoll 层都可能产生惊群效应:

场景 惊群发生位置
fork()epoll_create() socket 的等待队列
epoll_create()fork() epoll 的等待队列

3.1、Socket 层的惊群效应

场景描述:多个进程共享一个 socket,即主进程 createbindlisten,然后 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(),区别在于传入的参数不同,决定子进程是否与父进程共享地址空间等资源。

问题分析

  1. 各个进程调用 epoll_wait 时,会把自己注册到 epoll 的等待队列
  2. 导致 epoll 的等待队列存在多个进程
  3. 当 socket 执行 epoll 的回调函数时,epoll 会把 epitem 放到就绪链表,然后唤醒等待队列的进程
  4. 如果唤醒所有进程,就会引发惊群效应

解决方案

查看 Linux 内核代码,epoll_wait默认设置独占模式

1
2
3
4
5
6
static inline void
__add_wait_queue_exclusive(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry)
{
wq_entry->flags |= WQ_FLAG_EXCLUSIVE; // 设置独占标志
__add_wait_queue(wq_head, wq_entry);
}

用户调用 epoll_wait 进入阻塞状态时,如果没有事件,会把当前进程写入 epoll 的等待队列,并设置 WQ_FLAG_EXCLUSIVE。这意味着这种场景下 Linux 内核已经解决了惊群效应

推荐架构设计

一般多线程架构不会采用共享 epoll 的设计,而是:

  1. 主线程负责 accept
  2. 创建新的 socket 连接后,交由 worker 线程
  3. 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
2
3
4
5
6
7
8
void __wake_up_sync_key(struct wait_queue_head *wq_head, unsigned int mode,
void *key)
{
if (unlikely(!wq_head))
return;

__wake_up_common_lock(wq_head, mode, 1, WF_SYNC, key); // nr_exclusive = 1
}

调用链:__wake_up_common_lock__wake_up_common

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_entry_t *curr, *next;
lockdep_assert_held(&wq_head->lock);
curr = list_first_entry(&wq_head->head, wait_queue_entry_t, entry);

// 如果列表为空,返回未使用的独占型唤醒名额
if (&curr->entry == &wq_head->head)
return nr_exclusive;

// 遍历等待队列
list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
unsigned flags = curr->flags;
int ret;

// 调用当前队列项的唤醒函数
ret = curr->func(curr, mode, wake_flags, key);
if (ret < 0)
break;

// 【关键】如果唤醒成功且是独占型,减少名额
if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
return nr_exclusive;
}

关键代码解析

1
2
if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
条件 含义
ret 唤醒操作是否成功(true 表示成功)
flags & WQ_FLAG_EXCLUSIVE 当前队列项是否为独占型
!--nr_exclusive 独占名额减 1 后是否为 0(用完则跳出循环)

等待队列的排列顺序

问题:如果非独占型进程在前,独占型进程在后,会发生什么?

1
2
3
4
5
假设队列:A(非独占) → B(独占) → C(独占) → D(非独占)

1. A 被唤醒(ret=1),继续遍历
2. B 被唤醒(ret=1),nr_exclusive--,此时 nr_exclusive=0,触发 break
3. C 和 D 不会被处理

Linux 的解决方案:添加进程时,独占型进程会被优先添加到队列头部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static inline void __add_wait_queue(struct wait_queue_head *wq_head, 
struct wait_queue_entry *wq_entry)
{
struct list_head *head = &wq_head->head;
struct wait_queue_entry *wq;

// 遍历等待队列,找到优先级标志的边界
list_for_each_entry(wq, &wq_head->head, entry) {
if (!(wq->flags & WQ_FLAG_PRIORITY))
break;
head = &wq->entry;
}
// 将新项添加到适当位置
list_add(&wq_entry->entry, head);
}

添加顺序示例

1
2
添加顺序:A(非独占) → B(独占) → C(独占) → D(非独占)
实际队列:B → C → A → D

唤醒场景总结

场景 队列中有独占任务 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
2
#define wake_up_locked(x)        __wake_up_locked((x), TASK_NORMAL, 1)
#define wake_up_all_locked(x) __wake_up_locked((x), TASK_NORMAL, 0)

关键区别

  • 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
2
3
4
5
6
struct eventpoll {
wait_queue_head_t wq; // 等待队列
struct list_head rdllist; // 就绪链表
struct rb_root rbr; // 红黑树
......
}

核心成员说明

成员 类型 作用
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 时,内核执行以下三个步骤:

  1. 分配 epitem:为该 socket 分配一个红黑树节点对象 epitem
  2. 注册回调:添加等待事件到 socket 的等待队列,回调函数为 ep_poll_callback
  3. 插入红黑树:将 epitem 插入到 epoll 对象的红黑树中

epitem 数据结构

1
2
3
4
5
6
7
// file: fs/eventpoll.c
struct epitem {
struct rb_node rbn; // 红黑树节点
struct epoll_filefd ffd; // socket 文件描述符信息
struct eventpoll *ep; // 所归属的 eventpoll 对象
struct list_head pwqlist; // 等待队列
}

回调函数注册

创建并初始化 epitem 后,ep_insert 会设置 socket 对象上的等待任务队列,并将 ep_poll_callback 设置为数据就绪时的回调函数:

1
2
3
4
5
6
7
8
// file: include/linux/wait.h
static inline void init_waitqueue_func_entry(
wait_queue_t *q, wait_queue_func_t func)
{
q->flags = 0;
q->private = NULL;
q->func = func; // 注册 ep_poll_callback 为回调函数
}

注意:这里是向 socket 的等待队列 注册回调,不是 epoll 对象的等待队列。

最后,将 epitem 对象插入到红黑树中完成注册。

4.3、epoll_wait - 等待事件

epoll_wait 的工作流程:

  1. 检查 eventpoll->rdllist 就绪链表是否有数据
  2. 有数据:立即返回就绪事件
  3. 无数据:创建等待队列项,添加到 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. 返回就绪事件并处理

详细流程

  1. 网卡数据交给 TCP 协议栈处理
  2. 协议栈找到对应的 socket
  3. 调用 socket 等待队列中注册的 ep_poll_callback 回调函数
  4. ep_poll_callback 将对应的 epitem 添加到 epoll 的就绪队列
  5. 检查 eventpoll 的等待队列是否有等待项
  6. 调用 __wake_up_common,执行 default_wake_function 唤醒进程
  7. epoll_wait 进程推入可运行队列,等待内核调度
  8. 进程醒来后,从 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 在初始化事件模块时,根据系统支持的事件模型(epolldevpollkqueueselect)选择合适的事件模块。支持哪些模型由编译期决定:

操作系统 支持的事件模型
Linux epoll
BSD/macOS kqueue
Solaris devpoll
通用 select/poll
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#if (NGX_HAVE_EPOLL) && !(NGX_TEST_BUILD_EPOLL)

fd = epoll_create(100);

if (fd != -1) {
(void) close(fd);
module = &ngx_epoll_module;

} else if (ngx_errno != NGX_ENOSYS) {
module = &ngx_epoll_module;
}

#endif

...........................

event_module = module->ctx;

定义ngx_epoll_module_ctx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
typedef struct {
ngx_str_t *name;

void *(*create_conf)(ngx_cycle_t *cycle);
char *(*init_conf)(ngx_cycle_t *cycle, void *conf);

ngx_event_actions_t actions; //定义的方法集合
} ngx_event_module_t;

static ngx_event_module_t ngx_epoll_module_ctx = {
&epoll_name,
ngx_epoll_create_conf, /* create configuration */
ngx_epoll_init_conf, /* init configuration */

{
ngx_epoll_add_event, /* add an event */
ngx_epoll_del_event, /* delete an event */
ngx_epoll_add_event, /* enable an event */
ngx_epoll_del_event, /* disable an event */
ngx_epoll_add_connection, /* add an connection */
ngx_epoll_del_connection, /* delete an connection */
#if (NGX_HAVE_EVENTFD)
ngx_epoll_notify, /* trigger a notify */
#else
NULL, /* trigger a notify */
#endif
ngx_epoll_process_events, /* process the events */
ngx_epoll_init, /* init the events */
ngx_epoll_done, /* done the events */
}
};

epoll初始化时

1
ngx_event_actions = ngx_epoll_module_ctx.actions;

设置事件模块的回调为 epoll 的函数。如果使用的是 poll,则会是 poll 的方法。

统一的事件接口宏

1
2
3
4
5
6
7
8
9
#define ngx_process_events   ngx_event_actions.process_events  // 处理事件
#define ngx_done_events ngx_event_actions.done // 清理

#define ngx_add_event ngx_event_actions.add // 添加事件
#define ngx_del_event ngx_event_actions.del // 删除事件
#define ngx_add_conn ngx_event_actions.add_conn // 添加连接
#define ngx_del_conn ngx_event_actions.del_conn // 删除连接

#define ngx_notify ngx_event_actions.notify // 通知

这种设计实现了运行时多态,使 Nginx 能够在不同平台使用最优的事件机制。


5.2、EPOLLEXCLUSIVE 的负载均衡问题

Nginx 默认开启 EPOLLEXCLUSIVE,但存在一个负载不均衡的问题:

1
2
3
4
5
/*
* Linux with EPOLLEXCLUSIVE 通常只通知第一个将监听套接字添加到 epoll 实例的进程。
* 因此,大多数连接都由第一个工作进程处理。为了解决这个问题,我们会定期重新添加套接字,
* 以便其他工作进程也有机会接受连接。
*/

问题分析

根据 3.4 节的分析,添加独占型进程时,独占型进程总是在链表前面,但后加的独占型进程会排在前面独占型的后面

1
2
添加顺序:A(非独占) → B(独占) → C(独占) → D(非独占)
实际队列:B → C → A → D

问题:C 是后注册的,但排在 B 后面。由于只唤醒第一个独占进程,所有连接事件都会被 B 处理,导致负载不均衡。

Nginx 的解决方案

当一个 socket 的连接数大于 16 时,重新将该 socket 添加到 epoll 中

1
2
优化前队列:B → C → A → D(B 总是被唤醒)
重新注册 B 后:C → BA → D(C 获得机会)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#if (NGX_HAVE_EPOLLEXCLUSIVE)

static void
ngx_reorder_accept_events(ngx_listening_t *ls)
{
ngx_connection_t *c;

// 如果没有启用独占接受,则直接返回
if (!ngx_use_exclusive_accept) {
return;
}

#if (NGX_HAVE_REUSEPORT)
// 如果启用了端口复用,则直接返回
if (ls->reuseport) {
return;
}
#endif

// 获取连接对象
c = ls->connection;

// 如果请求计数不是 16 的倍数,并且接受禁用计数器小于等于 0,则直接返回
if (c->requests++ % 16!= 0
&& ngx_accept_disabled <= 0)
{
return;
}

// 从 epoll 中删除读事件
if (ngx_del_event(c->read, NGX_READ_EVENT, NGX_DISABLE_EVENT)
== NGX_ERROR)
{
return;
}

// 将读事件重新添加到 epoll 中,并设置为独占模式
if (ngx_add_event(c->read, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
== NGX_ERROR)
{
return;
}
}


#endif

性能测试验证

有人报告这个补丁使性能下降 10%,于是使用 Nginx 1.27 版本做性能测试对比:

测试命令

1
wrk -t10 -c500 -d10s http://127.0.0.1:80

参数说明

  • -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
2
3
4
5
#if (NGX_HAVE_REUSEPORT)
if (nls[n].reuseport && !ls[i].reuseport) {
nls[n].add_reuseport = 1; // 标记需要添加 reuseport
}
#endif

事件模块初始化阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#if (NGX_HAVE_REUSEPORT)

// 获取核心配置结构体
ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx, ngx_core_module);

// 如果不是测试配置且master进程存在,则进行端口复用处理
if (!ngx_test_config && ccf->master) {

// 获取监听套接字数组
ls = cycle->listening.elts;
// 遍历所有监听套接字
for (i = 0; i < cycle->listening.nelts; i++) {

// 跳过未启用reuseport或worker不为0的监听套接字
if (!ls[i].reuseport || ls[i].worker != 0) {
continue;
}

// 克隆监听套接字,如果克隆失败,则返回配置错误
if (ngx_clone_listening(cycle, &ls[i]) != NGX_OK) {
return NGX_CONF_ERROR;
}

// 克隆操作可能更改cycle->listening.elts指针,因此重新获取指针
ls = cycle->listening.elts;
}
}

#endif

**关键函数 ngx_clone_listening**:克隆当前监听套接字,为每个 worker 进程创建独立的监听实例。

作用

  • 每个克隆的监听套接字分配给一个 worker,拥有独立的监听队列
  • 每个进程能独立处理自己的客户端连接,避免锁争用,提高性能

Worker 进程初始化

每个 worker 进程初始化时,只保留属于自己的监听套接字,关闭其他套接字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#if (NGX_HAVE_REUSEPORT)
// 检查当前监听套接字是否启用了reuseport选项并且不是当前工作进程的
if (ls[i].reuseport && ls[i].worker != ngx_worker) {
// 如果是,记录调试信息并关闭该套接字
ngx_log_debug2(NGX_LOG_DEBUG_CORE, cycle->log, 0,
"closing unused fd:%d listening on %V",
ls[i].fd, &ls[i].addr_text);

// 关闭套接字并检查是否成功
if (ngx_close_socket(ls[i].fd) == -1) {
// 如果关闭失败,记录错误信息
ngx_log_error(NGX_LOG_EMERG, cycle->log, ngx_socket_errno,
ngx_close_socket_n " %V failed",
&ls[i].addr_text);
}

// 将套接字描述符设置为无效值
ls[i].fd = (ngx_socket_t) -1;

// 继续处理下一个监听套接字
continue;
}
#endif

注册 Epoll 到 Socket

最后看一下将 epoll 注册到 socket 内核中的流程:ngx_add_eventepoll_ctl

三种注册模式

条件 注册方式
开启 reuseport 将本进程的 epoll 注册到自己的 socket
开启 EPOLLEXCLUSIVE 将本进程的 epoll 独占式注册到共享 socket
都未开启 普通方式注册到共享 socket

说明c = rev->datac->fd 就是本进程监听的 socket(在上面的步骤中已设置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 如果定义了NGX_HAVE_REUSEPORT宏
#if (NGX_HAVE_REUSEPORT)

// 如果当前监听套接字启用了reuseport选项
if (ls[i].reuseport) {
// 尝试为读事件添加epoll事件,如果不成功则返回错误
if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
return NGX_ERROR;
}

// 继续处理下一个监听套接字
continue;
}

#endif

// 如果使用了accept mutex机制,则跳过添加epoll事件
if (ngx_use_accept_mutex) {
continue;
}

// 如果定义了NGX_HAVE_EPOLLEXCLUSIVE宏
#if (NGX_HAVE_EPOLLEXCLUSIVE)

// 如果使用了epoll事件模型且配置了多个工作进程
if ((ngx_event_flags & NGX_USE_EPOLL_EVENT)
&& ccf->worker_processes > 1)
{
// 启用exclusive accept模式
ngx_use_exclusive_accept = 1;

// 尝试以独占方式为读事件添加epoll事件,如果不成功则返回错误
if (ngx_add_event(rev, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
== NGX_ERROR)
{
return NGX_ERROR;
}

// 继续处理下一个监听套接字
continue;
}

#endif

// 如果上述条件都不满足,尝试为读事件添加epoll事件,如果不成功则返回错误
if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
return NGX_ERROR;
}

使用 accept_mutex 锁的传统方式

当不使用 reuseport 时,Nginx 通过 accept_mutex 锁来避免惊群效应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 检查是否需要使用接受互斥锁
if (ngx_use_accept_mutex) {
// 如果接受互斥锁被禁用,则递减禁用计数器
if (ngx_accept_disabled > 0) {
ngx_accept_disabled--;

} else {
// 尝试获取接受互斥锁
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}

// 根据互斥锁是否已被持有来决定事件的处理方式
if (ngx_accept_mutex_held) {
flags |= NGX_POST_EVENTS;

} else {
// 调整定时器以适应接受互斥锁的延迟
if (timer == NGX_TIMER_INFINITE
|| timer > ngx_accept_mutex_delay)
{
timer = ngx_accept_mutex_delay;
}
}
}
}

5.4、实际效果对比

开启 reuseport

配置:1 个 Master + 2 个 Worker,开启 reuseport

开启reuseport

未开启 reuseport

多进程监听一个socket

对比结论

  • 开启 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

网络 IO 与 epoll 深入分析
https://zjfans.github.io/2024/11/29/网络IO/
作者
张三疯
发布于
2024年11月29日
许可协议