Linux端口复用EUSEPORT新特性,提高服务器端性能的利器!它是为了解决什么问题而创建的?
来源|练内功(ID:kfngxl)
先考,我有一个小问题,如果你的服务器上已经有一个进程在监听6000端口。那么服务器上的其他进程还可以绑定并监听这个端口吗?
我想有的同学会回答不可能。因为很多人都遇到过“地址已被使用”的错误。出现此错误的原因是端口已经繁忙。
但实际上,在Linux 3.9及以后的内核版本中,允许多个进程绑定到同一个端口号。这就是我们今天要讲的REUSEPORT的新特点。
在这篇文章中我们将解释REUSEPORT是为了解决什么问题而创建的。如果多个进程重用同一个端口,当用户请求到达时,内核如何选择一个进程来响应。学习完本文后,您将对这个提高服务器端性能的强大工具有深入的了解!
1。REUSE要解决的问题PORT
我认为理解一个技术点非常重要的前提是了解问题的背景。一旦了解了背景,理解技术点就会容易得多。
REUSE PORT函数的背景其实在Linux commit中已经给出了足够详细的信息(参见:https://github.com/torvalds/linux/commit/da5e36308d9f7151845018369148201a5d28b46d)。
我会根据此承诺中的信息给您详细的解释。
任何有服务器端开发经验的人都知道,服务通常会监听特定的端口。例如Nginx服务通常监听80或8080,Mysql服务通常监听3306,等等。
在互联网用户数量还不够多、终端设备还没有爆发的年代,无法监听端口的模式总是被反复使用。但2010年之后,互联网达到了高潮,移动设备也开始迎来大发展。此时,端口无法复用的性能瓶颈就暴露出来了。
处理海量流量的主要措施是采用多进程模型。在端口无法重复绑定和监听的时代,提供海量服务的多进程服务器通常采用以下两种进程模型来运行。
第一种是专用一个或多个进程服务来接受新连接、接收请求,然后将请求传递给其他工作进程进行处理。
![]()
这种多进程模型有两个问题。首先,Dispatcher进程不处理任务,必须转交给Worker进程处理和响应。这会导致额外的进程上下文切换的开销。另一个问题是,如果流量特别大,调度程序进程很容易成为瓶颈,限制整个服务的QPS提升。
这是另一种多进程模型,多个进程重用处于监听状态的套接字,多个进程同时接受来自套接字的请求进行处理。 Nginx 使用这种模型。
![]()
此流程模型解决了第一个模型的问题。但这导致了新的问题。当套接字接收到连接时,并不是所有的工作进程都能被检索到。需要锁来保证唯一性,因此会存在锁争用问题。
2。REUSE的诞生 PORT
为了更有效地允许多个用户模式进程接收和响应客户端请求。 Linux在2013年的3.9版本中为REUSE PORT添加了新功能。
![]()
内核详细的commit代码请参见https://github.com/torvalds/linux/commit/da5e36308d9f715184501836914820d。 com/torvalds/linux/commit/055dc21a1d1d219608cd4baac7d06 83fb2cbbe8a
此函数允许多个。每个进程创建❙不同的套接字并同时监听端口。然后在内核级别实现多个用户进程的负载均衡。
我们来看看内核是如何支持复用特性的。
2.1 SO_REUSE PORT 设置
我想为我自己的服务启用REUSE PORT。非常简单,只需将此语句添加到用于侦听服务器的套接字中即可。 (这里用C作为demo,其他语言可能有差异,但基本是一样的)
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, ...);
这行代码在内核中对应的处理步骤就是将内核socket的sk_reuseport字段设置为对应的值,打开则为1。
//file: net/core/sock.c
int sock_setsockopt(struct socket *sock, int level, int optname,
char __user *optval, unsigned int optlen)
{
...
switch (optname) {
...
case SO_REUSEPORT:
sk->sk_reuseport = valbool;
...
}
}
2.2 绑定过程中的处理
在inet_bind过程中,内核会调用函数inet_csk_get_port。我们来看看绑定时的重用报表处理。我们看一下源代码:
//file: net/ipv4/inet_connection_sock.c
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
...
//在绑定表(bhash)中查找,
head = &hashinfo->bhash[inet_bhashfn(net, snum,
hashinfo->bhash_size)];
inet_bind_bucket_for_each(tb, &head->chain)
//找到了,在一个命名空间下而且端口号一致,表示该端口已经绑定
if (net_eq(ib_net(tb), net) && tb->port == snum)
goto tb_found;
...
}
内核通过拉链哈希表来管理所有绑定套接字。其中inet_bhashfn是计算哈希值的函数。
计算找到Hash Trace后,通过inet_bind_bucket_for_each遍历所有处于bind状态的socket,判断是否存在冲突。
net_eq(ib_net(tb), net) 该条件表示网络命名空间匹配,tb->port == snum 表示端口号匹配。这两个条件的总和意味着该端口已绑定在同一命名空间下。让我们看看 tb_found 中发生了什么。
//file: net/ipv4/inet_connection_sock.c
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
...
if (((tb->fastreuse > 0 &&
sk->sk_reuse && sk->sk_state != TCP_LISTEN) ||
(tb->fastreuseport > 0 &&
sk->sk_reuseport && uid_eq(tb->fastuid, uid))) &&
smallest_size == -1) {
goto success;
} else {
//绑定冲突
......
}
我们看一下tb->fastreuseport > 0和sk->sk_reuseport这两个条件。
这两个条件意味着绑定的socket和正在绑定的socket都开启了SO_REUSE PORT功能。如果满足条件,则会跳转到success,表示绑定处理成功。 也就是说,这个端口是可以捆绑重复使用的!
uid_eq(tb->fastuid, uid) 这个条件的目的是安全。同一用户进程下的联系人必须声明重用该端口。 避免跨用户打开同一端口来窃取其他用户服务的流量。
2.3 接受响应新连接
当多个进程绑定并侦听同一端口时。当客户端连接请求到达时,需要选择要处理的套接字(进程)。我们简单看一下应答连接时的处理流程。
内核仍然通过 hash + zip 的方式存储所有监听状态联系人。
寻找处于监听状态的socket时,需要查找哈希表。让我们看一下 __inet_lookup_listener,这是响应握手请求时注入的关键函数。
//file: net/ipv4/inet_hashtables.c
struct sock *__inet_lookup_listener(struct net *net,
struct inet_hashinfo *hashinfo,
const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum,
const int dif)
{
//所有 listen socket 都在这个 listening_hash 中
struct inet_listen_hashbucket *ilb = &hashinfo->listening_hash[hash];
begin:
result = NULL;
hiscore = 0;
sk_nulls_for_each_rcu(sk, node, &ilb->head) {
score = compute_score(sk, net, hnum, daddr, dif);
if (score > hiscore) {
result = sk;
hiscore = score;
reuseport = sk->sk_reuseport;
if (reuseport) {
phash = inet_ehashfn(net, daddr, hnum,
saddr, sport);
matches = 1;
}
} else if (score == hiscore && reuseport) {
matches++;
if (((u64)phash * matches) >> 32 == 0)
result = sk;
phash = next_pseudo_random32(phash);
}
}
...
return result;
}
其中,sk_nulls_for_each_rcu 遍历所有具有相同哈希值的监听状态联系人。请注意compute_score 函数,它计算匹配分数。当多个套接字被命中时,匹配分数最高的套接字将首先被命中。让我们来看看这个功能的细节。
//file: net/ipv4/inet_hashtables.c
static inline int compute_score(struct sock *sk, ...)
{
int score = -1;
struct inet_sock *inet = inet_sk(sk);
if (net_eq(sock_net(sk), net) && inet->inet_num == hnum &&
!ipv6_only_sock(sk)) {
//如果服务绑定的是 0.0.0.0,那么 rcv_saddr 为假
__be32 rcv_saddr = inet->inet_rcv_saddr;
score = sk->sk_family == PF_INET ? 2 : 1;
if (rcv_saddr) {
if (rcv_saddr != daddr)
return -1;
score += 4;
}
...
}
return score;
}
那么匹配点解决什么问题呢?为了使描述更清楚,我们假设某个特定服务器有两个 IP 地址:10.0.0.2 和 10.0.0.3。我们启动了以下三个服务器进程。
A 进程:./test-server 10.0.0.2 6000
B 进程:./test-server 0.0.0.0 6000
C 进程:./test-server 127.0.0.1 6000
所以如果你的客户端指定连接到10.0.0.2:6000,进程A将首先执行。因为当你将socket匹配到进程A时,你需要检查握手包中的目的IP是否与这个地址匹配。如果正确,则得分为 4 分,即最高分。
如果设置连接为10.0.0.3,则无法匹配A进程。此时,进程B在监听时指定0.0.0.0(rcv_saddr为false),因此不需要比较目的地址,得分为2。由于没有更高的得分,所以这次命中的是进程B。
C - 进程只有在本地访问并指定ip使用127.0.0.1才能命中,得分为4分。远程服务器或使用本机上的其他 IP 无法访问它。
如果多个socket的匹配分数一致,则调用next_pseudo_random32进行随机选择。 我们在内核模式下进行负载均衡,并选择特定的套接字,以避免同一套接字上多个进程之间的锁竞争。
3。实际操作
尝试一下可以获得更深刻的体验。为此,我编写了一些简单的服务器代码来启用SO_REUSE PORT功能。核心是服务端的连接器
详细源码参见:https://github.com/yanfeizhang/coder-kung-fu/blob/main/tests/network/test08/server.c。
3.1 同端口多服务启动
编译完成后,尝试在多个控制台上运行,看看是否可以启动。
$./test-server 0.0.0.0 6000
Start server on 0.0.0.0:6000 successed, pid is 23179
$./test-server 0.0.0.0 6000
Start server on 0.0.0.0:6000 successed, pid is 23177
$./test-server 0.0.0.0 6000
Start server on 0.0.0.0:6000 successed, pid is 23185
......
是的,大家都起来了!此端口 6000 被多个服务器进程重复使用。
3.2 验证核心负载均衡
由于上述监听同一端口的进程均使用0.0.0.0,因此在计算分数时其分数均为2分。然后内核以随机的方式进行负载均衡。
我们启动另一个客户端,随意启动多个连接请求,并统计每个服务器进程收到的连接数。如下图所示,服务器上收到的连接实际上在进程之间均匀地进行了哈希处理。
Server 0.0.0.0 6000 (23179) accept success:15
Server 0.0.0.0 6000 (23177) accept success:25
Server 0.0.0.0 6000 (23185) accept success:20
Server 0.0.0.0 6000 (23181) accept success:19
Server 0.0.0.0 6000 (23183) accept success:21
3.3 合规优先级验证
使用具有两个IP地址的服务器,假设您的IP分别为10.0.0.2和10.0.0.3。启动以下三个服务器进程。
A 进程:./test-server 10.0.0.2 6000
B 进程:./test-server 0.0.0.0 6000
可以使用telnet命令测试另一个客户端。
$ telnet 10.0.0.2 6000 发现是命中 A 进程。
$ telnet 10.0.0.3 6000 发现是命中 B 进程。
3.4 跨用户安全验证
首先以用户 A 启动服务
$./test-server 0.0.0.0 6000
Start server on 0.0.0.0:6000 successed, pid is 30914
然后切换到其他用户,例如 root。
# ./test-server 0.0.0.0 6000
Server 30481 Error : Bind Failed!
此时我发现绑定不通过,服务也启动不了!
4。总结
在Linux 3.9之前的版本中,一个端口只能绑定一个socket。在多进程场景下,无论是使用一个进程接受socket,还是使用多个worker接受同一个socket,在高并发场景下性能似乎都有些低。
Reuseport 功能在 2013 年发布的 3.9 中添加。该功能允许多个进程使用不同的套接字绑定到同一端口。当流量到达时,在核心态以随机方式进行负载均衡。避免了锁定的开销。
这个功能在Linux中非常有用,但遗憾的是大量工程师不了解原理而不使用它!
如果您的公司在Linux上使用多进程服务器,请快速检查重用端口是否打开。如果没有启用,能否想办法添加一下,对比一下上半年的性能数据?
如果您使用的是 nginx 1.9.1 或更高版本,则只需要简单的配置行。体验一下这个功能。
server {
listen 80 reuseport;
...
} 版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网