nginx的reuseport特性分析

1、奇怪的现象

1.1、断崖问题

业务进行性能测试,发现一个奇怪的现象,整个压测过程中总会有断崖的情况。本来TPS在2万8左右,会直接掉到1500左右,然后又马上恢复,但是只能恢复到2万2,损耗了20%。

现场架构:

私有协议压测

现场XXX、nginx、服务都部署在一个机器,配置为:

1
2
3
4
5
6
7
机器:海光麒麟v10  sp4  x86

性能指标:128c 512G

nginx配置:16个work进程,句柄数40960

客户端与nginx建立的长连接:16个

问题在于nginx调大进程为64,或者客户端通道数调大为64,就没有问题。即16-64,64-16没问题,但是16-16有问题,100%复现断崖。

排查步骤:现场没有监控,所以排查过程比较困难,不过还是确认了一些问题

1、整个压测过程中,瓶颈不在nginx,因为最大cpu压力才到45%,主要排查问题是断崖。

2、查看nginx的日志,没有任何报错,但是发现断崖时,客户端给的流量下降了,根据日志绘出曲线后,与压力机的曲线一致

3、所以着重分析为什么此时客户端给的压力会突降低。但是整个链路的节点都有可能有问题,客户端队列阻塞?nginx处理变慢?服务端处理变慢?

重点是,这是私有协议,没有做access.log日志,根本看不到请求的耗时、数量等信息,从压力机看,64个进程和16个进程的平均时延没有差距。2中查看的流量还是打开debug,数的日志条数🙂,苦力活。因此开始tcpdump抓包,一个包抓了21个G,现场内网环境拷贝需要经过2层网络,现场还有其他的测试计划,只能见缝插针压测一把,第一天就这样过去了

第二天包终于拷贝出来了,在看包之前,我想起是信创系统,看了一下nginx的版本,不适配😬,赶快换了对应版本,16-16的模式没有再出现断崖,而且TPS上升了4千,到了3万2,一个数据库的分区直接被打满,断崖问题解决,但是因为操作系统不适配导致的断崖来日待查。紧急的问题是,压测的过程中,nginx的各个进程压力不均匀?进程的压力呈递减状态?

1.2、每个work的连接数不均衡

我们拥有2种私有协议,可以理解为tcp+xxx数据格式、tcp+json。这类协议的客户端和服务端会建立一条长连接,通常成为通道,后续的请求都是依靠这个通道传输。

因此为了更加直观的复现,nginx开启了64个进程,xxx应用与nginx建立64条连接。使用netstat命令统计连接数,得以下结果,nginx共有64条连接(其中39个nginx进程有连接,25个进程处于空闲,没有连接。其中1个进程的连接数为4条;5个进程的连接数分别为3条;14个进程的连接数为2条;19个进程的连接数为1条;25个进程的连接数为0条;

nginx各个进程连接数分布图

压测过程中,发现共有39个nginx进程有压力,且压力大小与该进程数的连接数成正比,即nginx进程的连接数越多,压力越大,一个进程拿到了4个连接,一个进程拿到了1个连接,压力比是4:1。现场反馈的“递减”现象,其实就是这个现象。那么为什么连接数不一致?

2、HTTP协议的不均衡

既然私有协议的连接数不一致,那么来试试HTTP,直接使用HTTP协议连接nginx,配置做了更新,如下:

1
2
3
4
5
6
7
128个work进程

keepalive_request默认值为100

服务每个增加到3个节点

数据库增加到3个分区

架构如下:

HTTP直连

发现nginx的压力依旧不均衡,只有十几个进程有压力,维持在60%~90%,此时tps已经达到8万+,依旧是数据库的瓶颈,到这里就需要研究nginx建立连接的机制。

2、基础知识

2.1、epoll

回顾一下4年前写的epoll的例子,https://github.com/ZJfans/EpollET-Server/blob/master/epollET.c

  • 初始化监听套接字
  • 创建epoll实例
  • 监听套接字设置到epoll_ctl中
  • 使用epoll_wait,循环等待事件
  • 如果触发事件的是监听套接字,那么建立新的连接
  • 如果触发事件的是客户端的套接字,那么处理读写事件

2.2、nginx的工作模式 区别于 muduo

nginx为多进程模式,初始化时,master监听端口,而后master fork多个work进程,此时所有的work监听同一端口

muduo为多线程的工作方式,main主线程负责处理监听套接字的事件,在建立连接后,将连接分配给work thread,后续的读写事件都由work线程处理

nginx各个进程绑定同一个socket

3、reuseport && SO_REUSEPORT

3.1、惊群效应

惊群效应(Thundering Herd)是多进程或多线程系统在等待同一事件时可能遇到的问题。在网络编程中,尤其是在使用epoll进行I/O多路复用时,惊群效应可能导致性能问题。下面是关于Nginx如何处理惊群效应的详细解释。

原因

nginx的多个进程等待新的网络连接请求。当事件发生时(例如,一个新连接到达),所有等待的进程都会被唤醒。但最终只有一个进程能够处理该事件(例如,通过accept系统调用接受连接),其他进程在尝试处理事件失败后会重新进入等待状态。这个过程会导致大量的上下文切换和CPU资源的浪费。

Nginx采用了几种策略来避免或减少惊群效应的影响:

  1. accept_mutex:
    • Nginx可以使用accept_mutex来同步对accept调用的访问。这意味着在任何给定时间,只有一个工作进程可以处理新的连接请求。这通过在工作进程之间引入互斥锁来实现,从而避免了多个进程同时尝试接受同一个连接的情况。
    • 通过在配置文件中设置accept_mutexon,可以启用此功能。这有助于减少因多个进程竞争同一个accept操作而产生的惊群效应。
  2. EPOLLEXCLUSIVE:
    • 从Linux内核版本4.5开始,引入了EPOLLEXCLUSIVE标志。Nginx从1.11.3版本开始支持这个特性。
    • 当使用EPOLLEXCLUSIVE标志添加epoll事件时,内核保证在事件发生时只唤醒一个等待的进程。这减少了因多个进程监听同一个文件描述符而产生的惊群效应。
  3. SO_REUSEPORT:
    • SO_REUSEPORT是Linux内核3.9版本引入的一个选项,允许多个进程绑定到相同的端口上。Nginx从1.9.1版本开始支持这个特性。
    • 使用SO_REUSEPORT时,内核会在多个监听相同端口的进程之间进行负载均衡。这样,当新的连接请求到达时,内核会根据一定的规则选择一个进程来处理该请求,从而避免了多个进程同时被唤醒的问题。

3.2、SO_REUSEPORT

SO_REUSEPORT 是一个 Linux 内核级别的套接字选项,它允许多个套接字(通常是监听套接字)绑定到相同的网络地址和端口上。这个特性在 Linux 3.9 版本中引入,主要用于解决多进程或多线程环境中的惊群效应问题。以下是 SO_REUSEPORT 的实现原理的详细解释:

传统的端口绑定

SO_REUSEPORT 出现之前,根据 POSIX 标准,一个网络端口在同一时间内只能被一个套接字绑定。如果有多个进程想要监听同一个端口,它们必须使用某种同步机制(如互斥锁)来协调对端口的访问,这可能会导致性能问题和复杂的编程模型。

SO_REUSEPORT 的引入

SO_REUSEPORT 选项的引入打破了这个限制,它允许多个套接字监听同一个端口,而不需要特殊的同步机制。当启用 SO_REUSEPORT 时,内核会在内部进行负载均衡,将到达的数据包分发给监听该端口的多个套接字。

实现原理

  1. 端口复用
    • 当多个进程或线程的套接字启用了 SO_REUSEPORT 并绑定到同一个端口时,内核会为每个套接字创建一个独立的接收队列。
    • 所有到达的数据包(例如,新的连接请求)都会根据某种负载均衡算法在这些队列之间进行分配。
  2. 负载均衡
    • 内核使用一种负载均衡算法(通常是轮询或某种形式的哈希算法)来决定哪个套接字应该接收特定的连接请求。
    • 这意味着即使多个进程在监听同一个端口,每个进程也只会接收到一部分的连接请求,而不是全部。
  3. 并发处理
    • 由于每个进程都有自己的接收队列,它们可以并发地处理连接请求,而不会相互干扰。
    • 这种方式显著减少了进程间的上下文切换和竞争,提高了系统的并发处理能力。
  4. 安全性和隔离
    • 尽管多个套接字绑定到了同一个端口,但它们之间的通信是隔离的。每个套接字只能处理分配给它的数据包。
    • 此外,SO_REUSEPORT 选项通常要求所有绑定到同一端口的套接字必须属于同一个用户,以避免潜在的安全问题。

3.3、负载均衡算法

这是内核从监听的哈希表中查找匹配的套接字,关键函数是compute_score,会给每一个socket算一个权重值,有点类似于nginx的轮询,也是按照算法,得出同一个upstream下每个server的权重,最大的分配请求

但是当开启SO_REUSEPORT后,其实会直接调用inet_lookup_reuseport,这里直接选择socket,选择到就return了。具体分析见第4节。

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
static struct sock *inet_lhash2_lookup(struct net *net,
struct inet_listen_hashbucket *ilb2,
struct sk_buff *skb, int doff,
const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum,
const int dif, const int sdif)
{
struct sock *sk, *result = NULL;
struct hlist_nulls_node *node;
int score, hiscore = 0;

sk_nulls_for_each_rcu(sk, node, &ilb2->nulls_head) {
score = compute_score(sk, net, hnum, daddr, dif, sdif);
if (score > hiscore) {
result = inet_lookup_reuseport(net, sk, skb, doff,
saddr, sport, daddr, hnum, inet_ehashfn);
if (result)
return result;

result = sk;
hiscore = score;
}
}

return result;
}

3.4、均衡吗?引发reuseport奇怪的现象

如果nginx有8个进程监听这个端口,为什么我觉得会很不均匀的分配连接呢?于是用了我们自己的nginx,不开启reuseport的情况下,多个进程都监听了一个socket,但是开启了reuseport后,8个进程每个进程都监听了8个socket??为什么不是8个进程各自监听自己的socket呢?
1、难道是内核版本太低了不支持?或者显示有问题?

我这个虚拟机的内核是3.1,确实低了,于是找了一个4.19的操作系统,也是这样?

那就不是linux内核的版本问题

openresty-1.15.8开启reuseport

2、nginx的版本的问题?

我用的是openresty-1.15.8版本,因此nginx的版本也是15.8,因此我下载了最新的openresty-1.25版本,重新编译启动后,结果如下图,work进程完全符合我的预期!!!

openresty-1.25.3开启reuseport

因此我去对比了2个版本的代码,发现新版本确实做了优化,会close多余的socket

而且lsof -i出来的也只是绑定的意思,nginx1.15.8版本的nginx进程虽然绑定了多个socket,但是并没有监听每一个,也就是没有把每一个socket放到epoll里面

nginx-15

1
2
3
4
5
#if (NGX_HAVE_REUSEPORT)
if (ls[i].reuseport && ls[i].worker != ngx_worker) {
continue;
}
#endif

nginx-25

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#if (NGX_HAVE_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

问题是master为什么也绑定了4个socket?acceept事件来时,master也会触发?

事实上不用担心这个问题,因为master根本不会把这些socket放到epoll里面,所以永远不会触发。

那能不能删除绑定呢?

nginx的重启依赖于master fork work,我在想是不是master的socket不能丢掉,要不然reload的时候,重新创建socket,那之前的一些状态是不是就丢掉了?

或者停止时,要关掉socket,那么master需要知道当前打开的句柄数,我觉得这个怀疑是最合理的

有兴趣待查

4、linux内核源码分析

现在来看下linux内核是如何实现SO_REUSEPORT,Linux 内核版本 3.9 中引入了这个特性,所以我下载了2个版本的linux内核代码,目前广泛使用的4.19和最新的6.8

6.8的代码比较清晰直观,直接用ai生成注释😀

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
/*
* 在特定的网络环境中,查找监听哈希桶中与给定条件匹配的套接字。
* 此函数在持有RCU读锁时被调用,不会增加套接字的引用计数。
*
* 参数:
* - net: 网络环境上下文。
* - ilb2: 指向当前监听哈希桶的指针。
* - skb: 数据包缓冲区,可用于查找过程中的某些计算。
* - doff: 数据包中头部的偏移量。
* - saddr: 源IP地址。
* - sport: 源端口号。
* - daddr: 目标IP地址。
* - hnum: 目标端口号。
* - dif: 发送接口索引。
* - sdif: 源发送接口索引。
*
* 返回值:
* - 查找到的套接字指针,如果没有找到匹配的套接字则返回NULL。
*/
static struct sock *inet_lhash2_lookup(struct net *net,
struct inet_listen_hashbucket *ilb2,
struct sk_buff *skb, int doff,
const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum,
const int dif, const int sdif)
{
struct sock *sk, *result = NULL;
struct hlist_nulls_node *node;
int score, hiscore = 0;

// 遍历哈希桶中的所有套接字,计算每个套接字与目标匹配的得分
sk_nulls_for_each_rcu(sk, node, &ilb2->nulls_head) {
score = compute_score(sk, net, hnum, daddr, dif, sdif);
if (score > hiscore) {
// 尝试使用ReusePort特性更新结果套接字,如果成功则直接返回
result = inet_lookup_reuseport(net, sk, skb, doff,
saddr, sport, daddr, hnum, inet_ehashfn);
if (result)
return result;

// 更新最高得分及对应的套接字
result = sk;
hiscore = score;
}
}

return result;
}

那么重点是2个地方

4.1、compute_score

4.1.1、compute_score函数

类似于nginx的轮询算法,算出权重/分数,根据 权重/分数 分发事件

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
/**
* 计算套接字的得分
*
* 本函数用于根据给定的网络套接字、网络、目的网络地址、差异接口和源差异接口信息,计算套接字的得分。
* 得分根据套接字的网络匹配、端口号匹配、IPv4/IPv6类型、绑定的设备接口和接收到的数据包的CPU等条件计算。
*
* @param sk 指向当前套接字的指针。
* @param net 指向当前网络的指针。
* @param hnum 当前套接字的端口号。
* @param daddr 目的网络地址。
* @param dif 当前套接字绑定的差异接口索引。
* @param sdif 源差异接口索引。
* @return 返回套接字的得分,匹配不成功返回-1。
*/
static inline int compute_score(struct sock *sk, struct net *net,
const unsigned short hnum, const __be32 daddr,
const int dif, const int sdif)
{
int score = -1; // 初始化得分为-1分

// 检查套接字所属的网络是否与指定的网络相同,端口号是否匹配,并且套接字不是IPv6 only类型
if (net_eq(sock_net(sk), net) && sk->sk_num == hnum &&
!ipv6_only_sock(sk)) {
// 检查套接字的接收地址是否与目的地址不同
if (sk->sk_rcv_saddr != daddr)
return -1; // 如果不同,直接返回-1

// 检查套接字是否绑定到指定的设备接口,并且设备接口是否匹配差异接口
if (!inet_sk_bound_dev_eq(net, sk->sk_bound_dev_if, dif, sdif))
return -1; // 如果不匹配,返回-1

// 根据套接字是否绑定了设备接口,给予1分或2分的奖励
score = sk->sk_bound_dev_if ? 2 : 1;

// 如果套接字是IPv4类型,额外加1分
if (sk->sk_family == PF_INET)
score++;
// 如果一个socket上次处理它的数据包的CPU与当前CPU相同,额外加1分
if (READ_ONCE(sk->sk_incoming_cpu) == raw_smp_processor_id())
score++;
}
return score; // 返回计算出的得分
}

score是有3个地方会变化,连接会分发给哪个socket,那就是看4个socket哪个点不一样,导致分不一样,也就是4个nginx进程

1、检查套接字是否绑定到指定的设备接口,并且设备接口是否匹配差异接口

这个就是网卡,显然对于nginx的4个进程而言,我都是监听所有的网卡,所以这里4个进程的socket得分都是1,也就没有差异

1
2
3
4
5
6
7
8
9
server {
listen 38088 reuseport;
server_name example.com;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
}

2、 如果套接字是IPv4类型,额外加1分
这里我只考虑ipv4地址的场景,虽然我也监听了ipv6,因此这里4个进程的socket得分也都加1,没有差异

3、如果接收到的数据包的CPU与当前CPU相同,额外加1分

首先先到sock_reuseport.c模块,看下sk_incoming_cpu的定义

  1. **sk_incoming_cpu**: 在Linux内核中,sk_incoming_cpu是套接字结构中的一个字段,它记录了最近处理该套接字传入数据的CPU核心。当新的数据包到达时,操作系统会尝试将数据包分配给记录在sk_incoming_cpu中的CPU核心来处理,以此来优化性能。

4.1.2、CPU和套接字的关系

  1. 数据包到达: 当一个网络数据包到达时,它首先被网络接口卡(NIC)捕获,并通过中断通知CPU。
  2. 中断处理: CPU接收到中断后,操作系统的中断处理程序会捕获这个事件,并开始处理数据包。
  3. 套接字绑定: 操作系统的网络栈会根据数据包的目标地址和端口号,决定将数据包发送到哪个套接字。如果一个套接字已经绑定到了特定的端口,那么所有到达该端口的数据包都会被发送到这个套接字。
  4. CPU亲和性: 在多核CPU系统中,操作系统可能会将特定的套接字或网络流量绑定到特定的CPU核心,这种做法称为CPU亲和性(CPU affinity)。这样做的目的是为了提高效率,因为:
    • 缓存利用:如果套接字总是在同一个CPU核心上处理数据,相关的数据结构和状态信息更有可能保留在该核心的CPU缓存中,从而减少内存访问延迟。
    • 上下文切换:减少不同CPU核心之间的上下文切换,因为数据包的处理总是在同一个核心上进行。
    • 负载均衡:通过将不同的套接字或网络流量分配给不同的CPU核心,可以实现更好的负载均衡。

重要的是第一个网络包达到的时候,那就看看3次握手吧

  1. 客户端发送 SYN 包: 当客户端想要建立与服务端的 TCP 连接时,它会发送一个 SYN(同步)包给服务端,这个包包含客户端的初始序列号。
  2. 服务端接收 SYN 包并创建 socket: 服务端收到客户端的 SYN 包后,会分配资源并创建一个用于与客户端通信的 socket,并为该连接分配一个序列号,同时为其分配缓冲区等资源。
  3. 服务端发送 SYN-ACK 包: 接着,服务端会发送一个 SYN-ACK 包给客户端,该包中包含服务端的序列号以及确认号(即客户端序列号加一),表示服务端已经接收到了客户端的 SYN 包,并愿意建立连接。
  4. 客户端接收 SYN-ACK 包并发送 ACK 包: 客户端收到服务端的 SYN-ACK 包后,会发送一个 ACK(确认)包给服务端,确认服务端的 SYN 包,并携带服务端的序列号加一的确认号。
  5. 连接建立完成: 当服务端收到客户端发送的 ACK 包后,连接就建立完成了,服务端和客户端之间可以开始进行数据传输。

可以看到服务端在接收到客户端的 SYN 包后,会创建一个用于与客户端通信的 socket,这时候就会更新cpu了,也就是sk_incoming_cpu,下次这个cpu在分配连接的时候,会优先给这个cpu处理过的socket,也就是加一分

1
2
3
// 如果一个socket上次处理它的数据包的CPU与当前CPU相同,额外加1分
if (READ_ONCE(sk->sk_incoming_cpu) == raw_smp_processor_id())
score++;

4.1.3、更新sk_incoming_cpu

重要的是reuseport_update_incoming_cpu,如何设置和更新sk_incoming_cpu

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
void reuseport_update_incoming_cpu(struct sock *sk, int val)
{
struct sock_reuseport *reuse;
int old_sk_incoming_cpu;

// 如果reuseport选项未启用,直接更新sk_incoming_cpu值。
if (unlikely(!rcu_access_pointer(sk->sk_reuseport_cb))) {
WRITE_ONCE(sk->sk_incoming_cpu, val);
return;
}

// 加锁以保护对reuseport相关资源的访问。
spin_lock_bh(&reuseport_lock);

// 在加锁保护下更新sk_incoming_cpu值,以避免并发问题。
old_sk_incoming_cpu = sk->sk_incoming_cpu;
WRITE_ONCE(sk->sk_incoming_cpu, val); //这里做更新

// 安全地访问reuseport_cb,考虑了锁的依赖关系。
reuse = rcu_dereference_protected(sk->sk_reuseport_cb,
lockdep_is_held(&reuseport_lock));

// 如果reuseport_cb变为NULL,说明套接字已关闭,直接解锁退出。
if (!reuse)
goto out;

// 根据incoming_cpu值的正负变化,调整计数。
if (old_sk_incoming_cpu < 0 && val >= 0)
__reuseport_get_incoming_cpu(reuse);
else if (old_sk_incoming_cpu >= 0 && val < 0)
__reuseport_put_incoming_cpu(reuse);

out:
// 释放锁。
spin_unlock_bh(&reuseport_lock);
}

理解了sk_incoming_cpu,其实就可以理解得分,但是事实上开启了SO_REUSEPORT后,选择的函数是inet_lookup_reuseport

4.2、inet_lookup_reuseport

1
2
3
4
5
// 尝试使用ReusePort特性更新结果套接字,如果成功则直接返回
result = inet_lookup_reuseport(net, sk, skb, doff,
saddr, sport, daddr, hnum, inet_ehashfn);
if (result)
return result;

得分完,如果找到了socket,那就直接返回了,让我们看下inet_lookup_reuseport做了什么

4.2.1、inet_lookup_reuseport源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct sock *inet_lookup_reuseport(struct net *net, struct sock *sk,
struct sk_buff *skb, int doff,
__be32 saddr, __be16 sport,
__be32 daddr, unsigned short hnum,
inet_ehashfn_t *ehashfn)
{
struct sock *reuse_sk = NULL; /* 默认返回NULL,表示没有找到可重用的套接字 */
u32 phash;

/* 如果当前套接字允许端口复用,则计算哈希值并尝试选择一个可重用的套接字 */
if (sk->sk_reuseport) {
/* 根据提供的函数指针调用相应的哈希函数计算端口哈希值 */
phash = INDIRECT_CALL_2(ehashfn, udp_ehashfn, inet_ehashfn,
net, daddr, hnum, saddr, sport);
/* 使用计算得到的哈希值从哈希表中选择一个合适的套接字 */
reuse_sk = reuseport_select_sock(sk, phash, skb, doff);
}
return reuse_sk;
}

最后是调用了reuseport_select_sock

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
49
50
51
52
53
54
55
56
/**
* reuseport_select_sock - 选择合适的socket进行复用
* @sk: 当前的socket结构体
* @hash: 数据包的哈希值
* @skb: 数据包的缓冲区
* @hdr_len: 数据包头的长度
*
* 此函数根据给定的条件(如BPF程序的结果或哈希值)从复用端口的socket池中选择一个合适的socket。
* 如果有配置的BPF程序,则会先尝试使用BPF程序来决定选择哪个socket。
* 若无BPF程序或BPF程序决策失败,则会基于哈希值来选择socket。
*
* 返回值: 返回选择的socket结构体指针。如果没有合适的socket,则返回NULL。
*/
struct sock *reuseport_select_sock(struct sock *sk,
u32 hash,
struct sk_buff *skb,
int hdr_len)
{
struct sock_reuseport *reuse;
struct bpf_prog *prog;
struct sock *sk2 = NULL;
u16 socks;

rcu_read_lock();
reuse = rcu_dereference(sk->sk_reuseport_cb);

/* 如果内存分配失败或添加调用尚未完成,则直接退出 */
if (!reuse)
goto out;

prog = rcu_dereference(reuse->prog);
socks = READ_ONCE(reuse->num_socks);
if (likely(socks)) {
/* 配合__reuseport_add_sock()中的smp_wmb()使用 */
smp_rmb();

/* 如果没有配置BPF程序或者skb为空,则直接进行哈希选择 */
if (!prog || !skb)
goto select_by_hash;

/* 根据BPF程序类型执行相应的程序逻辑 */
if (prog->type == BPF_PROG_TYPE_SK_REUSEPORT)
sk2 = bpf_run_sk_reuseport(reuse, sk, prog, skb, NULL, hash);
else
sk2 = run_bpf_filter(reuse, socks, prog, skb, hdr_len);

select_by_hash:
/* 如果没有使用BPF程序或BPF程序结果无效,则回退到使用哈希值选择socket */
if (!sk2)
sk2 = reuseport_select_sock_by_hash(reuse, hash, socks);
}

out:
rcu_read_unlock();
return sk2;
}

实际的选择

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
/**
* reuseport_select_sock_by_hash - 根据哈希值选择一个合适的socket
* @reuse: 指向reuseport结构的指针,包含要搜索的socket数组
* @hash: 用于选择socket的哈希值
* @num_socks: socket数组中的socket数量
*
* 描述:
* 此函数用于在给定的socket数组中,根据特定的哈希值选择一个处于TCP_ESTABLISHED状态的socket。
* 如果没有处于该状态的socket,则返回第一个找到的非TCP_ESTABLISHED状态的socket。
*
* 返回值:
* 返回一个指向选择的socket的指针。如果没有找到合适的socket,则返回NULL。
*/
static struct sock *reuseport_select_sock_by_hash(struct sock_reuseport *reuse,
u32 hash, u16 num_socks)
{
struct sock *first_valid_sk = NULL; /* 用于存储第一个找到的有效(非TCP_ESTABLISHED)socket */
int i, j;

i = j = reciprocal_scale(hash, num_socks); /* 使用哈希值和socket数量计算起始索引 */
do {
struct sock *sk = reuse->socks[i]; /* 获取当前索引位置的socket */

/* 如果socket状态不是TCP_ESTABLISHED,则进行进一步判断 */
if (sk->sk_state != TCP_ESTABLISHED) {
/* 如果没有设置incoming_cpu,表示没有活动的连接请求,则返回当前socket */
if (!READ_ONCE(reuse->incoming_cpu))
return sk;

/* 如果当前socket的incoming_cpu与当前CPU一致,表示有活动的连接请求,则返回当前socket */
if (READ_ONCE(sk->sk_incoming_cpu) == raw_smp_processor_id())
return sk;

/* 如果还没有找到第一个有效的socket,则将当前socket设置为第一个有效socket */
if (!first_valid_sk)
first_valid_sk = sk;
}

i++; /* 移动到下一个socket */
if (i >= num_socks)
i = 0; /* 如果超出范围,则从头开始 */
} while (i != j); /* 如果当前索引与起始索引不同,继续循环 */

return first_valid_sk; /* 返回第一个有效的socket,如果没有找到则返回NULL */
}

reuse->socks[i],是一个指针数组,它存储了一系列 struct sock 指针。每个 struct sock 指针代表一个网络套接字,这些套接字都绑定到了同一个端口上,并且启用了 SO_REUSEPORT 特性。

num_socks; 字段表示 socks 数组中当前有效的套接字(struct sock 指针)的数量。这个字段用于跟踪监听同一个端口并启用了 SO_REUSEPORT 特性的套接字数量。

4.2.2、总结

  1. 根据netdaddrhnumsaddrsport 这几个参数计算一个hash值
  2. 使用哈希值和socket数量计算reuse->socks数组的起始索引
  3. 判断当前socket是否有连接请求在处理,如果没有,说明这个监听socket目前空闲,所以选择这个
  4. 如果上面没有返回,再判断sk_incoming_cpu,如果这个socket的上一次数据是当前cpu处理的,那么就选这个socket
  5. 如果这个socket不满足条件,那么作为保底,将这个socket设置为保底选择
  6. 循环3-5步骤
  7. 遍历完reuse->socks数组中的socket后,返回第一个有效的socket,如果没有找到则返回NULL

5、总结

5.1、原理总结

对于内核而言,整个过程处于传输层,它不需要关注应用层,因此对于连接的分配,会最大化的优化处理速度,只考虑传输层的属性。主要点在于优先使用空闲的监听socket,并且使监听socket尽量在一个cpu处理,这样有利于cpu缓存的利用。

因此当开启SO_REUSEPORT 特性后,一个socket是否能拿到连接,取决于3个点

1、根据哈希值和socket数量计算reuse->socks数组的起始值是多少,第一个当然有优先优势

2、取决于当前socket是否处于空闲

3、上一次处理这个socket的数据的cpu,是否是当前cpu

5.2、均匀吗?

5.1.1、不会绝对均匀

当同一个客户端和同一个nginx建立64条长连接时,上面1中的哈希值和socket数量是一样的,所以数组的起始下标是一样的。

那么连接分配给哪个进程就取决于2、3。当64条连接绝对同时来临时,且nginx的socket此时并没有其它连接来时,也就是处于空闲时,那么第2步会保证每个socket拿到1条连接,但是问题是绝对同时?还要保证没有连接到这些socket,这是不可能的。

因为连接总会有先后时间,即数据包会先后到,第一个socket处理完第一个连接后,它就又处于空闲了,所以它还会拿到连接,没办法它有优势,起始值算的。

5.1.2、会发生极限场景吗

nginx开启64个进程,只有几个进程能拿到连接?

可能性几乎为0,因为连接虽然有先后,但是时间差会非常小,所以都会在2中分发。除非客户端隔一段时间发一个请求,事实上客户端如果建立连接会”同时”发的,但是因为有时间差,前面的socket会拿到更多的连接

同时当连接数量级足够大,那么会近似均匀,但是当只有几十个连接时,也会是近似均匀,但是看着差距会比较大,毕竟有的socket拿不到连接,也就是nginx的进程拿不到连接

5.1.3、如果想在应用层保证连接数均匀可以实现吗

这是一个非常意思的想法,我们从2点考虑,可行性与性能。

1、可行性

​ 最直接的想法,应用层怎么判断进程现在拥有多少个长连接,以及长连接就是T2/T3,而不是websocket这种长连接?

2、性能

如果每次建立连接都需要判断是否是长连接,且均匀分发到各个进程,性能会断崖式下降

结论:所以不可能做的到,也没有意义。

5.1.4、目前这种情况,有必要做连接的均匀分发吗

​ 依据上次实际的统计来看,64个进程会有39个进程拿到连接,也就是39个进程会工作。

所以cpu只会利用39个?

根本不是的,因为work进程使用cpu是会切换的,这也是压测到极限,cpu的利用率会超过100%,有些java服务甚至会达到几千。因此cpu的利用率,nginx是最大化的,只不过存在cpu切换的损耗,基本可以忽略不计。

​ 所以,当压力到达nginx极限时,不同的进程的cpu利用率会有不同,但是一定会利用到所有的cpu,也就是可以发挥机器的最大性能。


nginx的reuseport特性分析
https://zjfans.github.io/2024/04/12/nginx的reuseport特性分析/
作者
张三疯
发布于
2024年4月12日
许可协议