Code前端首页关于Code前端联系我们

Linux端口复用EUSEPORT新特性,提高服务器端性能的利器!它是为了解决什么问题而创建的?

terry 2年前 (2023-09-28) 阅读数 56 #未命名

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年之后,互联网达到了高潮,移动设备也开始迎来大发展。此时,端口无法复用的性能瓶颈就暴露出来了。

处理海量流量的主要措施是采用多进程模型。在端口无法重复绑定和监听的时代,提供海量服务的多进程服务器通常采用以下两种进程模型来运行。

第一种是专用一个或多个进程服务来接受新连接、接收请求,然后将请求传递给其他工作进程进行处理。

Linux端口重用EUSEPORT 新特性,提升服务器端性能的利器!为了解决什么问题而产生?

这种多进程模型有两个问题。首先,Dispatcher进程不处理任务,必须转交给Worker进程处理和响应。这会导致额外的进程上下文切换的开销。另一个问题是,如果流量特别大,调度程序进程很容易成为瓶颈,限制整个服务的QPS提升。

这是另一种多进程模型,多个进程重用处于监听状态的套接字,多个进程同时接受来自套接字的请求进行处理。 Nginx 使用这种模型。

Linux端口重用EUSEPORT 新特性,提升服务器端性能的利器!为了解决什么问题而产生?

此流程模型解决了第一个模型的问题。但这导致了新的问题。当套接字接收到连接时,并不是所有的工作进程都能被检索到。需要锁来保证唯一性,因此会存在锁争用问题。

2。REUSE的诞生 PORT

为了更有效地允许多个用户模式进程接收和响应客户端请求。 Linux在2013年的3.9版本中为REUSE PORT添加了新功能。

Linux端口重用EUSEPORT 新特性,提升服务器端性能的利器!为了解决什么问题而产生?

内核详细的commit代码请参见https://github.com/torvalds/linux/commit/da5e36308d9f715184501836914820d。 com/torvalds/linux/commit/055dc21a1d1d219608cd4baac7d06 83fb2cbb​​e8a

此函数允许多个。每个进程创建❙不同的套接字并同时监听端口。然后在内核级别实现多个用户进程的负载均衡。

我们来看看内核是如何支持复用特性的。

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是计算哈希值的函数。

Linux端口重用EUSEPORT 新特性,提升服务器端性能的利器!为了解决什么问题而产生?计算找到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 的方式存储所有监听状态联系人。

Linux端口重用EUSEPORT 新特性,提升服务器端性能的利器!为了解决什么问题而产生?寻找处于监听状态的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前端网发表,如需转载,请注明页面地址。

热门