网络部分-epoll分析
1、内核如何接收数据
不同主机通过网卡进行数据的交互,网卡将电磁波转换为模拟信号,再转换为数字信号,再由OSI模型传到应用层,变成人可以识别的数据。信号转换属于通信相关的知识,所以从接收到数字信号开始分析数据的流转。
- 首先当数据帧从网线到达网卡上的时候,第一站是网卡的接收队列。网卡在分配给自己的RingBuffer中寻找可用的内存位置,找到后DMA引擎会把数据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绑定这个端口,当这个端口收到数据时,内核先将数据送往对应的协议栈,协议栈主要做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成功。
解决办法
- 使用WQ_FLAG_EXCLUSIVE,在唤醒进程时,不会唤醒所有的进程,只会唤醒一个进程,但是解决不了epoll的场景。( Linux 2.6 版本中引入)
- 使用SO_REUSEPORT,每个进程都有自己的socket,大家不共享socket,由内核负载socket。 (Linux 3.9 版本中引入)
- 使用锁,在应用层解决竞争关系,只有拿到锁的进程才能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放到就绪链表,然后唤醒等待队列的进程,其实就是执行等待队列元素的回调函数。如果唤醒所有的进程,那就会引发惊群效应。
解决办法
实际查看linux代码,epoll_wait会默认设置独占模式。用户调用 epoll_wait 进入阻塞状态,如果没有事件,就阻塞自己,把当前进程写入到epoll元素的等待队列中,并设置WQ_FLAG_EXCLUSIVE。那其实就意味着这种场景没有惊群效应。
1
2
3
4
5
6static 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
- 减少惊群效应:
WQ_FLAG_EXCLUSIVE
主要用于减少多个进程或进程同时被唤醒的情况,即惊群效应。当多个进程或进程等待同一个socket上的事件时,一个新连接的到来会导致所有阻塞在该socket上的进程或进程都被唤醒,但最终只有一个能处理这个连接,其余的进程或进程会重新进入等待状态。 - 内核层面的优化:
WQ_FLAG_EXCLUSIVE
通过内核排他性唤醒机制,确保一次只唤醒一个等待队列中的进程,从而减少不必要的上下文切换和性能损耗。
2、SO_REUSEPORT
- 端口复用:
SO_REUSEPORT
允许多个进程或进程绑定到同一端口上,每个进程或进程独立处理收到的数据。这在传统的socket编程中是不允许的,因为一个端口只能被一个进程绑定。 - 负载均衡:
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 |
|
调用到wake_up_common_lock -> __wake_up_common,其中nr_exclusive传了1
1 |
|
重点在于
1 |
|
ret
: 唤醒操作是否成功。如果为true
,表示有进程被成功唤醒。flags & WQ_FLAG_EXCLUSIVE
: 检查当前队列项是否为独占型。WQ_FLAG_EXCLUSIVE
是一个标志位,表示该队列项是独占型的。!--nr_exclusive
: 减少剩余的独占型进程唤醒名额,并检查是否已经用完所有名额。--nr_exclusive
先将nr_exclusive
减 1,然后取其值。如果减 1 后nr_exclusive
变为 0,则!
运算符将其转换为true
。
代码逻辑分析
- 唤醒成功:
ret
为true
,表示有进程被成功唤醒。 - 独占型队列项:
flags & WQ_FLAG_EXCLUSIVE
为true
,表示当前队列项是独占型的。 - 减少独占型唤醒名额:
--nr_exclusive
将nr_exclusive
减 1。 - 检查名额是否用完:如果
nr_exclusive
减 1 后变为 0,则!
运算符将其转换为true
,执行break
语句,跳出循环。
通常nr_exclusive为1,也就是唤醒独占型的1个进程。但是会不会发生下面的场景呢?非独占型的进程在前面,独占型的进程在后面
1 |
|
但是实际linux在添加进程时,会优先把独占型的进程添加到头部,新的独占型总会加到独占型的最后一个,如果没有他就会第一个,例如
- A(非独占)B(独占)C(独占)D(非独占)
A
B->A
B->C->A
B->C->A->D
1 |
|
所以如果有独占型的进程,那确实不会唤醒其他非独占型的进程,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 |
|
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 |
|
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 |
|
在创建 epitem 并初始化之后,ep_insert 中第二件事情就是设置 socket 对象上的等待任务队列。并把函数 fs/eventpoll.c 文件下的 ep_poll_callback 设置为数据就绪时候的回调函数。
在这个函数里它获取了 sock 对象下的等待队列列表头 wait_queue_head_t,待会等待队列项就插入这里。这里稍微注意下,是 socket 的等待队列,不是 epoll 对象的
1 |
|
分配完 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
可以设置为非阻塞模式,在调用 read
或 write
时,如果没有数据可读或无法立即写入,系统会返回一个错误码,而不是阻塞进程。
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 |
|
定义ngx_epoll_module_ctx
1 |
|
epoll初始化时
1 |
|
设置事件模块的回调为epoll的函数,如果使用的是poll,那将会是poll的方法
1 |
|
5.2、EPOLLEXCLUSIVE的问题
对于epoll,nginx默认就开启了EPOLLEXCLUSIVE,但是存在一个问题,nginx是这么描述的
1 |
|
由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 |
|
但是有人报告这个补丁使性能下降10%,于是我使用1.27版本做一个性能测试对比
wrk -t10 -c500 -d10s http://127.0.0.1:80
-t10
:wrk
将使用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 |
|
每个进程初始化event模块配置时
1 |
|
关键是ngx_clone_listening:克隆当前监听套接字,为每个 worker
进程创建一个独立的监听实例,并设置其worker属性
- 每个克隆的监听套接字会分配给一个
worker
,并具有独立的监听队列。 - 这样每个进程能独立处理自己的客户端连接,避免锁争用,提高性能。
每个进程初始化时,确保每个工作进程(worker)只保留属于自己的监听套接字,关闭那些不属于自己的套接字
1 |
|
最后看一下将epoll注册到socket内核中,ngx_add_event -> epoll_ctl
- 开启了reuseport时,将本进程的epoll注册到socket
- 开启了EPOLLEXCLUSIVE时,将本进程的epoll独占式的注册到socket
- 如果什么都没开启,则将本进程的epoll注册到socket
其中
c = rev->->data
c->fd就是本进程监听的socket,在上面已经设置过了
1 |
|
最后再看下不使用reuseport,常规使用锁的情况
1 |
|
5.4、实际操作
nginx开启了1个master,2个work,且开启了reuseport
来看下没有启用reuseport的情况
可以明显看到开启reuseport后,不同的work会监听自己的socket。而根据实际的性能测试来看,开启reuseport可以明显提升性能。
参考
参考自:《深入理解linux网络》第三章
linux版本:6.12
nginx代码:1.27