当前位置: 首页 > 科技观察

深入理解Linux端口复用特性

时间:2023-03-20 20:34:29 科技观察

大家好,我是飞哥!在这篇文章的开头,我会问你一个小问题。如果你的服务器上已经有一个进程在监听6000端口号,那么这个服务器上的其他进程是否还能绑定监听这个端口呢?相信有同学会回答说不可能。因为很多人都遇到过“Addressalreadyinuse”的错误。出现这个错误的原因是端口已经被占用了。但实际上,在3.9以上的Linux内核版本中,允许多个进程绑定同一个端口号。这就是我们今天要说的REUSEPORT的新特性。在本文中,我们将解释创建REUSEPORT是为了解决什么问题。如果有多个进程复用同一个端口,当用户请求到来时,内核如何选择一个进程响应。学习完本文,您将深入掌握这个提升服务器端性能的利器!一、REUSEPORT要解决的问题我觉得理解一个技术点很重要的前提是理解这个问题的背景。理解技术要点会容易很多。REUSEPORT功能的背景实际上在Linux提交中提供了足够详细的信息(参见:https://github.com/torvalds/linux/commit/da5e36308d9f7151845018369148201a5d28b46d)。我会根据这个commit中的信息给大家解释一下。有服务端开发经验的都知道,一般一个服务都会监听某个端口。比如Nginx服务一般监听80或者8080,Mysql服务监听3306等等。在那个网民数量还不够多,终端设备还没有大爆发的时代,一直沿用着端口无法重复监听的模式。但2010年后,Web互联网发展到了高潮,移动设备也开始迎来大发展。这时候端口不能复用的性能瓶颈就暴露出来了。应对海量流量的主要措施是应用多进程模型。在端口无法绑定和重复监听的时代,提供海量服务的多进程服务器一般采用以下两种进程模型进行工作。第一种是指定一个或多个进程来接受新的连接,接收请求,然后将请求传递给其他工作进程进行处理。这种多进程模型有两个问题。首先是dispatcher进程不处理任务,需要交给worker进程处理和响应。这会产生额外的进程上下文切换开销。第二个问题,如果流量特别大,dispatcher进程很容易成为制约整个服务QPS提升的瓶颈。另一种多进程模型是多个进程复用一个处于listen状态的socket,多个进程同时接受来自一个socket的请求进行处理。Nginx使用这种模型。这个过程模型解决了第一个模型的问题。但它带来了新的问题。当socket接收到一个连接时,所有的worker进程都不能被问候。需要锁来保证唯一性,所以会出现锁竞争的问题。2、REUSEPORT的诞生是为了让多个用户态进程更高效地接收和响应客户端请求。Linux在2013年的3.9版本中提供了一个新的REUSEPORT功能。请参阅https://github.com/torvalds/linux/commit/da5e36308d9f7151845018369148201a5d28b46d和https://github.com/torvalds/linux/commit/055dc21a1d1d219608cd4baac7d0cb683f的内核详细代码Commitab2此功能允许在同一台机器套接字上同时创建多个进程,以在同一端口上绑定和侦听。然后在内核级别实现多用户进程的负载均衡。让我们看看内核是如何支持reuseport特性的。2.1SO_REUSEPORT设置为自己的服务开启REUSEPORT非常简单,只需要在你服务器中用于listen的socket中加入这句话即可。(这里用C作为demo,其他语言可能不同,但基本类似)setsockopt(fd,SOL_SOCKET,SO_REUSEPORT,...);这行代码在内核中对应的处理步骤是设置内核socket的sk_reuseport字段为对应的值,如果启用则为1。//文件:net/core/sock.cintsock_setsockopt(structsocket*sock,intlevel,intoptname,char__user*optval,unsignedintoptlen){...switch(optname){...caseSO_REUSEPORT:sk->sk_reuseport=valbool;...}}2.2Bind过程内核在inet_bind过程中会调用inet_csk_get_port函数。下面看一下bind时对reuseport的处理。看源码://file:net/ipv4/inet_connection_sock.cintinet_csk_get_port(structsock*sk,unsignedshortsnum){...//在绑定表(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)gototb_found;...}内核通过拉链哈希表管理所有绑定套接字。其中inet_bhashfn是计算哈希值的函数。计算找到hashslot后,使用inet_bind_bucket_for_each遍历所有处于bind状态的socket,判断是否有冲突。net_eq(ib_net(tb),net)这个条件表示网络命名空间匹配,tb->port==snum表示端口号匹配。这两个条件的组合意味着该端口已绑定在同一名称空间下。让我们看看在tb_found中会做什么。//文件:net/ipv4/inet_connection_sock.cintinet_csk_get_port(structsock*sk,unsignedshortsnum){...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){gotosuccess;}else{//绑定冲突........}我们看两个条件tb->fastreuseport>0和sk->sk_reuseport。这两个条件意味着boundsocket和bindingsocket都开启了SO_REUSEPORT特性。如果满足条件,则跳转到success处理绑定成功。也就是说这个端口是可以绑定重复使用的!uid_eq(tb->fastuid,uid)这个条件的目的是为了安全,必须要求同一个用户进程下的socket才能复用这个端口。避免跨用户启动同一个端口以窃取另一个用户服务的流量。2.3accept多个进程绑定监听同一个端口时响应新的连接。当一个客户端连接请求来的时候,涉及到选择哪个socket(进程)去处理。下面简单看一下响应连接时的处理过程。内核仍然通过hash+zipper保存所有监听状态的sockets。查找处于监听状态的套接字时,需要查找这个哈希表。再来看一个我们在响应握手请求时输入的关键函数__inet_lookup_listener。//file:net/ipv4/inet_hashtables.cstructsock*__inet_lookup_listener(structnet*net,structinet_hashinfo*hashinfo,const__be32saddr,__be16sport,const__be32daddr,constunsignedshorthnum,constintdif){//全部监听所有套接字都在这个listening_hashstructinet_listen_hashbucket*ilb=&hashinfo->listening_hash[hash];begin:result=NULL;他的分数=0;sk_nulls_for_each_rcu(sk,node,&ilb->head){score=compute_score(sk,net,hnum,daddr,dif);如果(分数>hiscore){结果=sk;hiscore=得分;reuseport=sk->sk_reuseport;if(reuseport){phash=inet_ehashfn(net,daddr,hnum,saddr,sport);匹配=1;}}elseif(score==hiscore&&reuseport){matches++;如果(((u64)phash*匹配)>>32==0)结果=sk;phash=next_pseudo_random32(phash);}}...returnresult;}其中sk_nulls_for_each_rcu遍历具有相同哈希值的所有处于侦听状态的套接字。注意compute_score这个函数,这里是计算匹配分数的。当多个插槽被击中时,匹配分数较高的将首先被击中。让我们看一下这个函数中的一个细节。//文件:net/ipv4/inet_hashtables.cstaticinlineintcompute_score(structsock*sk,...){intscore=-1;结构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为false__be32rcv_saddr=inet->inet_rcv_saddr;分数=sk->sk_family==PF_INET?2:1;如果(rcv_saddr){如果(rcv_saddr!=daddr)返回-1;得分+=4;}...}returnscore;}那么匹配分数解决什么问题呢?为了描述更清楚,我们假设一个服务器有两个ip地址,10.0.0.2和10.0.0.3。我们启动了以下三个服务器进程。A进程:./test-server10.0.0.26000B进程:./test-server0.0.0.06000C进程:./test-server127.0.0.16000如果你的客户端指定连接10.0.0.2:6000,那么A进程将首先执行。因为匹配到进程A的socket时,需要检查握手包中的目的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进行随机选择。负载均衡是在内核态完成的,选择特定的socket,避免同一个socket上多个进程之间的锁竞争。三、动手实践动手尝试才能体会更深刻。为此,我编写了一个启用SO_REUSEPORT功能的简单服务器代码。核心是参考socketforserver的详细源码:https://github.com/yanfeizhang/coder-kung-fu/blob/main/tests/network/test08/server.c。3.1同一个端口的多个服务启动并编译后,在多个控制台下尝试运行,看能否启动。$./test-server0.0.0.06000Startserveron0.0.0.0:6000successful,pidis23179$./test-server0.0.0.06000Startserveron0.0.0.0:6000successful,pidis23177$./test-server0.0.0.06000在0.0.0.0:6000上启动服务器成功,pid为23185……是的,都起来了!这个6000端口被多个服务器进程重用。3.2内核负载均衡验证由于上述监听同一个端口的进程都使用0.0.0.0,所以在计算分数时,它们的分数都是2分。然后由内核以随机方式执行负载平衡。我们再启动一个客户端,随意发起几个连接请求,统计每个服务器进程收到的连接数。从下图可以看出,服务器上收到的连接确实在每个进程中被均匀地散列了。Server0.0.0.06000(23179)接受成功:15Server0.0.0.06000(23177)接受成功:25Server0.0.0.06000(23185)接受成功:20Server0.0.0.06000(23181)接受成功:19Server0.0.0.06000(23183)acceptsuccess:213.3匹配优先级验证使用的服务器有两个ip地址,假设你的ips分别是10.0.0.2和10.0.0.3。启动了以下三个服务器进程。A进程:./test-server10.0.0.26000B进程:./test-server0.0.0.06000可以使用telnet命令测试另一个客户端。$telnet10.0.0.26000发现被命中了A进程。$telnet10.0.0.36000发现被命中了B进程。3.4跨用户安全验证先用用户A启动一个服务$./test-server0.0.0.06000在0.0.0.0:6000上启动服务器成功,pid为30914然后切换到另一个用户,比如root。#./test-server0.0.0.06000Server30481错误:绑定失败!这时候发现bind不会通过,服务启动失败!4.总结在Linux3.9之前的版本中,一个端口只能绑定一个socket。在多进程场景下,无论是一个进程在这个socket上接受,还是多个worker在同一个socket上接受,在高并发场景下性能显得有些低。2013年发布的3.9中添加了reuseport功能。该规范允许多个进程使用不同的套接字绑定到同一端口。当流量到达时,在内核态以随机方式进行负载均衡。避免了锁定的开销。Linux的这个特性非常有用,但遗憾的是,仍然有大量的工程师不了解它的原理,并没有使用它。很可惜!如果你的企业在Linux上使用了多进程服务器,那就去看看是否启用了reuseport。如果没有启用,可以想办法添加,然后对比一下性能数据,上半年的性能会有