网络部分-epoll分析

1、内核如何接收数据

不同主机通过网卡进行数据的交互,网卡将电磁波转换为模拟信号,再转换为数字信号,再由OSI模型传到应用层,变成人可以识别的数据。信号转换属于通信相关的知识,所以从接收到数字信号开始分析数据的流转。

  1. 首先当数据帧从网线到达网卡上的时候,第一站是网卡的接收队列。网卡在分配给自己的RingBuffer中寻找可用的内存位置,找到后DMA引擎会把数据DMA到网卡之前关联的内存里,这个时候CPU都是无感的。当DMA操作完成以后,网卡会像CPU发起一个硬中断,通知CPU有数据到达。
  2. Linux在硬中断里只完成简单必要的工作,剩下的大部分的处理都是转交给软中断的,硬中断处理过程真的是非常短。只是记录了一个寄存器,修改了一下下CPU的poll_list,然后发出个软中断。
  3. 软中断和硬中断中调用了同一个函数local_softirq_pending。使用方式不同的是硬中断位置是为了写入标记,这里仅仅只是读取这个标记。
  4. 把数据帧从RingBuffer上取下来,数据包将被送到协议栈中,ip -> tcp/udp,tcpdump就是在这里获取数据包
  5. 对应的协议栈将数据送往对应的socket,socket通知对应的进程

2、socket的概念

2.1、通过socket唤醒各个进程

用户进程可以通过socket接口与内核进行数据的交互,当一个进程想要listen一个端口时,首先需要创建一个socket绑定这个端口,当这个端口收到数据时,内核先将数据送往对应的协议栈,协议栈主要做2个事情

  • 保存数据到socket的接收缓冲队列
  • 唤醒队列上的进程

拿多进程举例子,如果master监听了某端口后,会创建对应的socket,后续fork时,子进程也共享这个打开的socket,也会”监听”这个socket(当某个进程调用 accept() 时,内核会动态地将该进程注册到 socket 的等待队列中),这就导致socket的等待队列会有多个work进程阻塞在这里,也就是说socket的等待队列存在多个进程。

​ 多进程共享一个socket,但是进程各自有epoll

在唤醒进程时,如果唤醒所有的进程,就会引发惊群效应。有了epoll后,每个进程都会有自己的epoll,相当于会唤醒所有的epoll ,唤醒本质就是调用epoll注册的回调函数。

2.2、通过epoll唤醒各个进程

根据socket等待队列中的元素,找到对应的epoll和epitem,这个回调函数第一时间会把epitem放到epoll对象的就绪链表里面,后续epoll_wait就会从就绪链表读取事件进行处理。

同样唤醒时可以选择唤醒一个进程还是多个

​ 多进程共享一个epoll

3、惊群效应

当讨论到惊群效应,其实要分层次讨论,因为socket和epoll都会有惊群效应。简单来说先fork再epoll_create(),socket的等待队列会存在多个””进程”; 先epoll_create()再fork,epoll的等待队列会有多个进程;

3.1、socket的惊群效应

多个进程共享一个socket,即主进程create、bind、listen,然后fork子进程后,多个进程共享一个socket,进行accept的场景,这时候socket的等待队列会存在多个进程。

当使用epoll时,那就意味着socket的等待队列存在多个epoll。比如nginx的每个work都会有自己的epoll,会把这个socket注册到epoll,相应的注册回调到socket的等待队列,相当于把本进程注册到socket。

当数据被送到socket时,socket会”唤醒”等待队列的各个进程(其实是调用epoll注册的回调函数),这时候如果唤醒所有的进程,就会引发惊群效应,因为只有一个进程会accept成功。

解决办法

  1. 使用WQ_FLAG_EXCLUSIVE,在唤醒进程时,不会唤醒所有的进程,只会唤醒一个进程,但是解决不了epoll的场景。( Linux 2.6 版本中引入)
  2. 使用SO_REUSEPORT,每个进程都有自己的socket,大家不共享socket,由内核负载socket。 (Linux 3.9 版本中引入)
  3. 使用锁,在应用层解决竞争关系,只有拿到锁的进程才能accept,nginx早期就是这么做的

对于epoll,用户层可以调用的EPOLLEXCLUSIVE,实际使用的就是WQ_FLAG_EXCLUSIVE,使用EPOLLEXCLUSIVE ,添加事件时,epoll 会将对应的 epitem 节点标记为“独占模式” ,带有 EPOLLEXCLUSIVE 的监听者会被加入独占等待队列中,而非普通等待队列。如果有多个监听者,只会唤醒等待队列中第一个处于 EPOLLEXCLUSIVE 模式的监听者。如果没有 EPOLLEXCLUSIVE 模式监听者,唤醒其他普通监听者。(Linux 4.5 版本中引入)

3.2、epoll的惊群效应

多进程共享一个epoll,即多个进程共享一个epoll的对象。一个主进程先epoll_create(),然后再fork() 创建多个进程。其实这种模式常见于多线程(其实也不常见吧,应该不会有人会这么设计吧?),使用pthread_create()创建线程,本质上和fork没区别,实际上进程和进程也没区别,都会调用到 kernel_clone(),区别在于传入的参数不一样,这个函数会根据参数的不同,执行不同的逻辑,结果就是子进程会不会与父进程共享地址等等。

各个进程调用epoll_wait时,会把自己注册到epoll的等待队列,这会导致epoll的等待队列存在多个进程。

当socket执行到epoll的回调函数时,epoll首先会把自己的epitem放到就绪链表,然后唤醒等待队列的进程,其实就是执行等待队列元素的回调函数。如果唤醒所有的进程,那就会引发惊群效应。

解决办法

  1. 实际查看linux代码,epoll_wait会默认设置独占模式。用户调用 epoll_wait 进入阻塞状态,如果没有事件,就阻塞自己,把当前进程写入到epoll元素的等待队列中,并设置WQ_FLAG_EXCLUSIVE。那其实就意味着这种场景没有惊群效应。

    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; //设置WQ_FLAG_EXCLUSIVE
    __add_wait_queue(wq_head, wq_entry);
    }

不过一般多线程的架构设计不会这么设计,一般会主线程负责accept,在创建新的socket连接后,交由work线程,work会把这个新的socket加到自己的epoll,然后处理后续的事件。

不过多进程共享一个epoll绝对是不好的设计。

3.3、各个属性分析

1、WQ_FLAG_EXCLUSIVE

  1. 减少惊群效应:WQ_FLAG_EXCLUSIVE 主要用于减少多个进程或进程同时被唤醒的情况,即惊群效应。当多个进程或进程等待同一个socket上的事件时,一个新连接的到来会导致所有阻塞在该socket上的进程或进程都被唤醒,但最终只有一个能处理这个连接,其余的进程或进程会重新进入等待状态。
  2. 内核层面的优化:WQ_FLAG_EXCLUSIVE 通过内核排他性唤醒机制,确保一次只唤醒一个等待队列中的进程,从而减少不必要的上下文切换和性能损耗。

2、SO_REUSEPORT

  1. 端口复用:SO_REUSEPORT 允许多个进程或进程绑定到同一端口上,每个进程或进程独立处理收到的数据。这在传统的socket编程中是不允许的,因为一个端口只能被一个进程绑定。
  2. 负载均衡:SO_REUSEPORT 不仅允许多个进程绑定到同一端口,还能在内核层面实现负载均衡,将新连接均匀分配给不同的进程或进程,从而提高多核系统的并行处理能力和整体性能。

区别和联系

  • 作用层面:WQ_FLAG_EXCLUSIVE 主要是在内核层面减少不必要的进程唤醒,而 SO_REUSEPORT 是在应用层面允许多个进程或进程共享同一个端口,并在内核层面实现负载均衡。
  • 应用场景:WQ_FLAG_EXCLUSIVE 更适用于单个进程内部的进程间协作,减少进程间的唤醒竞争;而 SO_REUSEPORT 更适用于多个进程间共享端口资源,提高系统的并发处理能力。
  • 性能优化:WQ_FLAG_EXCLUSIVE 通过减少不必要的进程唤醒来优化性能;SO_REUSEPORT 通过负载均衡和多进程/进程处理来提高性能。

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); //这里的参数传了1
}

调用到wake_up_common_lock -> __wake_up_common,其中nr_exclusive传了1

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: 检查当前队列项是否为独占型。WQ_FLAG_EXCLUSIVE 是一个标志位,表示该队列项是独占型的。
  • !--nr_exclusive: 减少剩余的独占型进程唤醒名额,并检查是否已经用完所有名额。--nr_exclusive 先将 nr_exclusive 减 1,然后取其值。如果减 1 后 nr_exclusive 变为 0,则 ! 运算符将其转换为 true

代码逻辑分析

  1. 唤醒成功:rettrue,表示有进程被成功唤醒。
  2. 独占型队列项:flags & WQ_FLAG_EXCLUSIVEtrue,表示当前队列项是独占型的。
  3. 减少独占型唤醒名额:--nr_exclusivenr_exclusive 减 1。
  4. 检查名额是否用完:如果 nr_exclusive 减 1 后变为 0,则 ! 运算符将其转换为 true,执行 break 语句,跳出循环。

通常nr_exclusive为1,也就是唤醒独占型的1个进程。但是会不会发生下面的场景呢?非独占型的进程在前面,独占型的进程在后面

1
2
3
4
5
6
7
8
9
/*假设队列为:A(非独占) → B(独占) → C(独占) → D(非独占)
任务 A(非独占):

被唤醒(ret = 1),继续遍历。
任务 B(独占):

被唤醒(ret = 1),nr_exclusive--。
此时 nr_exclusive = 0,触发 break。
循环终止,任务 C 和任务 D 不会被处理。*/

但是实际linux在添加进程时,会优先把独占型的进程添加到头部,新的独占型总会加到独占型的最后一个,如果没有他就会第一个,例如

  • A(非独占)B(独占)C(独占)D(非独占)

A

B->A

B->C->A

B->C->A->D

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
}

所以如果有独占型的进程,那确实不会唤醒其他非独占型的进程,WQ_FLAG_PRIORITY和nr_exclusive决定了最终唤醒的结果,让我们分析下4种组合的情况


场景 队列中是否有 WQ_FLAG_EXCLUSIVE nr_exclusive 结果 返回值
1 无独占任务 1 所有非独占任务被唤醒;nr_exclusive 不减少 1
2 无独占任务 0 所有非独占任务被唤醒;nr_exclusive 不减少 0
3 有独占任务 1 唤醒一个独占任务后退出; 0
4 有独占任务 0 所有任务(独占和非独占)都被唤醒;nr_exclusive 递减到负数 负数(最终值)

其实socket的处理过程中,nr_exclusive一直是1,所以到底唤醒一个进程还是多个,由用户层决定,毕竟WQ_FLAG_EXCLUSIVE是可以设置的。

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)

wake_up_locked() 和 wake_up_all_locked() 都是用于唤醒等待队列中的进程的宏,最后都调用了 wake_up_common,原理也如上述所示。

但是因为epoll_wait,将进程设置到epoll的等待链表时,会默认设置WQ_FLAG_EXCLUSIVE(要明白,这里区别于socket的等待队列,因为socket设置时并不会默认设置WQ_FLAG_EXCLUSIVE),且nr_exclusive传了1,按照组合的情况,只会唤醒一个独占进程。

4、epoll实现原理

epoll主要涉及3个接口

  • epoll_create:创建一个 epoll 对象
  • epoll_ctl:向 epoll 对象中添加要管理的连接
  • epoll_wait:等待其管理的连接上的 IO 事件

4.1、epoll_create

在用户进程调用 epoll_create 时,内核会创建一个 struct eventpoll 的内核对象

1
2
3
4
5
6
7
8
9
10
11
12
13
struct eventpoll {

//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;

//接收就绪的描述符都会放到这里
struct list_head rdllist;

//每个epoll对象中都有一颗红黑树
struct rb_root rbr;

......
}

eventpoll 这个结构体中的几个成员的含义如下:

  • wq: 等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
  • rbr: 一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
  • rdllist: 就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。

4.2、epoll_ctl

使用 epoll_ctl 注册每一个 socket 的时候,内核会做如下三件事情

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

对于每一个 socket,调用 epoll_ctl 的时候,都会为之分配一个 epitem。该结构的主要数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//file: fs/eventpoll.c
struct epitem {

//红黑树节点
struct rb_node rbn;

//socket文件描述符信息
struct epoll_filefd ffd;

//所归属的 eventpoll 对象
struct eventpoll *ep;

//等待队列
struct list_head pwqlist;
}

在创建 epitem 并初始化之后,ep_insert 中第二件事情就是设置 socket 对象上的等待任务队列。并把函数 fs/eventpoll.c 文件下的 ep_poll_callback 设置为数据就绪时候的回调函数。

在这个函数里它获取了 sock 对象下的等待队列列表头 wait_queue_head_t,待会等待队列项就插入这里。这里稍微注意下,是 socket 的等待队列,不是 epoll 对象的

1
2
3
4
5
6
7
8
9
10
11
//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;

//ep_poll_callback 注册到 wait_queue_t对象上
//有数据到达的时候调用 q->func
q->func = func;
}

分配完 epitem 对象后,紧接着并把它插入到红黑树中

4.3、epoll_wait

当它被调用时它观察 eventpoll->rdllist 链表里有没有数据即可。有数据就返回,没有数据就创建一个等待队列项,将其添加到 eventpoll 的等待队列上,然后把自己阻塞掉。

再回顾一下,这里添加到 eventpoll 的等待队列上时,会附带WQ_FLAG_EXCLUSIVE属性。

4.4、接收数据

当网卡的数据交到tcp协议栈时,协议栈会找到对应的socket,然后回调socket等待队列中的”epoll”们,找到了 socket 等待队列项里注册的函数 ep_poll_callback,软中断接着就会调用它。首先把自己的 epitem 添加到 epoll 的就绪队列中。接着它又会查看 eventpoll 对象上的等待队列里是否有等待项(epoll_wait 执行的时候会设置)。

在 __wake_up_common里, 调用 curr->func。这里的 func 是在 epoll_wait 是传入的 default_wake_function 函数。在default_wake_function 中找到等待队列项里的进程描述符,然后唤醒之。将epoll_wait进程推入可运行队列,等待内核重新调度进程。然后epoll_wait对应的这个进程重新运行后,就从 schedule 恢复

当进程醒来后,继续从 epoll_wait 时暂停的代码继续执行。把 rdlist 中就绪的事件返回给用户进程,用户进程对其进行处理。


我觉得epoll 的关键在于它能够让一个进程同时处理多个 socket,而不需要像传统的阻塞 I/O那样每次处理一个 socket,然后进入阻塞睡眠,等到有新事件再唤醒。这种方式显著减少了进程切换的开销,从而提升了处理性能。

虽然 epoll 本身是通过 epoll_wait 阻塞来等待事件的触发,但它提供了一种非阻塞的方式来处理每个 socket 的 I/O 操作。具体来说,socket 可以设置为非阻塞模式,在调用 readwrite 时,如果没有数据可读或无法立即写入,系统会返回一个错误码,而不是阻塞进程。

epoll 不负责真正的数据读写,它只是告诉用户程序哪些 socket 可以进行读写操作。在真正的异步 I/O 模型(如 io_uring 或 POSIX AIO)中,应用程序发起 I/O 操作后立即返回,由内核或驱动完成 I/O 操作,并通过回调或信号通知操作结果。所以epoll 的工作方式仍然需要应用程序显式调用处理函数,因此是同步的。


5、nginx如何支持IO复用

Nginx 本质上属于 异步非阻塞 模型。在处理请求时,Nginx 不会因为等待某个操作完成(如读取数据)而阻塞当前进程,而是通过事件驱动机制在 I/O 操作完成时继续处理请求。虽然 Nginx 使用的是异步方式,但它依赖操作系统(如 epoll)来管理事件的触发和 I/O 操作的完成,Nginx 自己并不处理底层的 I/O 操作,而是通过事件循环和回调的方式继续处理其他任务。

5.1、nginx怎么选择IO复用

nginx在初始化事件模块时,根据系统支持的事件模型(如epoll、devpoll、kqueue、select)选择一个合适的事件模块,支持哪些模型由编译期决定,比如linux支持epoll,windows支持kqueue,因此编译的宏也是不一样的。

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

5.2、EPOLLEXCLUSIVE的问题

对于epoll,nginx默认就开启了EPOLLEXCLUSIVE,但是存在一个问题,nginx是这么描述的

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

由3.4可以知道,在添加独占型的进程时,独占型的进程总是处在链表的前面,但是当有多个独占型的进程时,后加的就会排在独占型后面

A(非独占)B(独占)C(独占)D(非独占)

A

B->A

B->C->A

B->C->A->D

这里的C就是后面注册的,这导致连接事件都会被B处理掉,因此nginx做了优化,当一个socket的连接数大于16时,重新将这个socket添加到epoll中。即如果B处理的连接过多,就重新注册B,那么链表的顺序就会变为

C->B->A->D

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%,于是我使用1.27版本做一个性能测试对比

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

  • -t10wrk将使用10个线程来发送HTTP请求。
  • -c500:每个线程将保持500个HTTP连接。因此,总共会有10*500=5000个HTTP连接。
  • -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,如果开启了,套接字 nls[n]reuseport 标志为真

1
2
3
4
5
#if (NGX_HAVE_REUSEPORT)
if (nls[n].reuseport && !ls[i].reuseport) {
nls[n].add_reuseport = 1; //
}
#endif

每个进程初始化event模块配置时

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内核中,ngx_add_event -> epoll_ctl

  • 开启了reuseport时,将本进程的epoll注册到socket
  • 开启了EPOLLEXCLUSIVE时,将本进程的epoll独占式的注册到socket
  • 如果什么都没开启,则将本进程的epoll注册到socket

其中
c = rev->->data

c->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;
}

最后再看下不使用reuseport,常规使用锁的情况

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、实际操作

nginx开启了1个master,2个work,且开启了reuseport

开启reuseport

来看下没有启用reuseport的情况

多进程监听一个socket

可以明显看到开启reuseport后,不同的work会监听自己的socket。而根据实际的性能测试来看,开启reuseport可以明显提升性能。

参考

参考自:《深入理解linux网络》第三章
linux版本:6.12
nginx代码:1.27


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