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

为什么服务器端程序需要先监听?

时间:2023-03-13 20:53:22 科技观察

本文转载自微信公众号《内功修炼之养成》,作者张燕飞allen。转载本文请联系内功修炼发展公众号。大家好,我是飞哥。飞哥来北京9年多,最近终于拿到了北京电动车牌。写10,000字所涉及的艰难过程可能不足以完成。不管怎么说,新能源也是车,终于有了能开的车。这几天买车卖车(洋牌)很忙。但是再忙,硬核文章还是停不下来!大家都知道,在创建服务器程序时,需要先监听,然后再接收客户端请求。例如,下面这段代码我们再熟悉不过了。intmain(intargc,charconst*argv[]){intfd=socket(AF_INET,SOCK_STREAM,0);bind(fd,...);listen(fd,128);accept(fd,...);所以今天我们我们想一个问题,为什么需要监听接收连接?或者换句话说,listen在内部执行的时候到底做了什么?如果你也想弄清楚listen里面的这些秘密,就请关注我吧!1.创建套接字服务器要做的第一件事是创建套接字。具体来说,通过调用socket函数。当执行socket函数时,从用户层的角度我们可以看到返回了一个文件描述符fd。但在内核中实际上是一组内核对象的组合,大致结构如下。下面简单理解一下这个结构。后面在源码中看到函数指针调用的时候,需要回过头来看。2、内核执行listen2.1listen系统调用。我在net/socket.c下找到了listen系统调用的源码。//file:net/socket.cSYSCALL_DEFINE2(listen,int,fd,int,backlog){//根据fd查找socket内核对象sock=sockfd_lookup_light(fd,&err,&fput_needed);if(sock){//获取内核parametersnet.core.somaxconnsomaxconn=sock_net(sock->sk)->core.sysctl_somaxconn;if((unsignedint)backlog>somaxconn)backlog=somaxconn;//调用协议栈注册的listen函数err=sock->ops->listen(sock,backlog);......}用户态的socket文件描述符只是一个整数,内核不能直接使用。所以这个函数的第一行代码就是根据用户传入的文件描述符找到对应的socket内核对象。然后获取系统中net.core.somaxconn内核参数的值,与用户传入的backlog进行比较,然后取一个最小值传递给下一步。因此,虽然listen允许我们传入backlog(这个值与半连接队列和全连接队列有关)。但是如果用户传入一个大于net.core.somaxconn的值就不行了。然后通过调用sock->ops->listen进入协议栈的listen函数。2.2协议栈listen这里需要用到第一节中的socket内核对象结构图,通过它可以看出sock->ops->listen实际上是执行了inet_listen。//file:net/ipv4/af_inet.cintinet_listen(structsocket*sock,intbacklog){//还没有进入监听状态(notlistenedyet)if(old_state!=TCP_LISTEN){//开始监听err=inet_csk_listen_start(sk,backlog);}//设置全连接队列长度sk->sk_max_ack_backlog=backlog;}这里我们先看底线,sk->sk_max_ack_backlog是全连接队列的最大长度。所以这里我们知道一个关键的技术点,服务器的全连接队列长度是监听时传入的backlog和net.core.somaxconn中较小的值。如果线上遇到全连接队列溢出问题,想增加队列长度,可能需要同时考虑backlog和listen时传入的net.core.somaxconn。回头看看inet_csk_listen_start函数。//file:net/ipv4/inet_connection_sock.cintinet_csk_listen_start(structsock*sk,constintnr_table_entries){structinet_connection_sock*icsk=inet_csk(sk);//icsk->icsk_accept_queue为接收队列,详见2.3节//接收队列内核对象申请及初始化见2.4节intrc=reqsk_queue_alloc(&icsk->icsk_accept_queue,nr_table_entries);......}函数开头将structsock对象强制转换为inet_connection_sock,命名为icsk。这里简单说明一下为什么可以这样转换,因为inet_connection_sock包含sock。tcp_sock、inet_connection_sock、inet_sock、sock是层层嵌套的关系,类似于面向对象中的继承概念。对于TCP套接字,sock对象实际上是一个tcp_sock。因此,TCP中的sock对象可以转换为tcp_sock、inet_connection_sock、inet_sock,以备随时使用。在下一行中,reqsk_queue_alloc实际上包含了两件重要的事情。一是接收队列数据结构的定义。其次是接收队列的申请和初始化。这两块比较重要,我们将分别在2.3节和2.4节介绍。2.3接收队列的定义icsk->icsk_accept_queue定义在inet_connection_sock下,是一个request_sock_queue类型的对象。它是内核用来接收客户端请求的主要数据结构。我们平时说的全连接队列和半连接队列都是在这个数据结构中实现的。我们来看具体的代码。//file:include/net/inet_connection_sock.hstructinet_connection_sock{/*inet_sockhastobethefirstmember!*/structinet_sockicsk_inet;structrequest_sock_queueicsk_accept_queue;...}我们再找request_sock_queue的定义,如下。//file:include/net/request_sock.hstructrequest_sock_queue{//全连接队列structrequest_sock*rskq_accept_head;structrequest_sock*rskq_accept_tail;//半连接队列structrequest_sock*listen_opt;...};对于全连接队列,在中不需要做复杂的查找工作,接受时只接受先进先出。所以全连接队列是通过rskq_accept_head和rskq_accept_tail以链表的形式进行管理的。半连接队列相关的数据对象是listen_opt,类型为listen_sock。//file:structlisten_sock{u8max_qlen_log;u32nr_table_entries;......structrequest_sock*syn_table[0];};由于服务器端需要在第三次握手时快速找出第一次握手保留的request_sock对象,所以其实,用一个哈希表来管理,就是structrequest_sock*syn_table[0]。max_qlen_log和nr_table_entries都与半连接队列的长度有关。2.4接收队列申请及初始化了解完全/半连接队列数据结构,让我们回到inet_csk_listen_start函数。调用reqsk_queue_alloc申请并初始化重要对象icsk_accept_queue。//file:net/ipv4/inet_connection_sock.cintinet_csk_listen_start(structsock*sk,constintnr_table_entries){...intrc=reqsk_queue_alloc(&icsk->icsk_accept_queue,nr_table_entries);...}接收队列的request_sock_queue内核对象完成在reqsk_queue_alloc函数创建和初始化。其中包括内存申请、半连接队列长度的计算、全连接队列头部的初始化等等。我们进入其源码://file:net/core/request_sock.cintreqsk_queue_alloc(structrequest_sock_queue*queue,unsignedintnr_table_entries){size_tlopt_size=sizeof(structlisten_sock);structlisten_sock*lopt;//计算半连接队列长度nr_table_entries=min_t(u3,nr_table_entries,sysctl_max_syn_backlog);nr_table_entries=...//为listen_sock对象申请内存,其中包括半连接队列lopt_size+=nr_table_entries*sizeof(structrequest_sock*);if(lopt_size>PAGE_SIZE)lopt=vzalloc(lopt_size);elselopt=kzalloc(lopt_size,GFP_KERNEL);//全连接队列头初始化queue->rskq_accept_head=NULL;//半连接队列设置lopt->nr_table_entries=nr_table_entries;queue->listen_opt=lopt;...}开头定义了一个structlisten_sock指针。这个listen_sock就是我们通常所说的半连接队列。接下来计算半连接队列的长度。计算出实际大小后,开始申请内存。最后将全连接队列头queue->rskq_accept_head设置为NULL,将半连接队列挂在接收队列queue上。这里有一个细节需要注意。半连接队列上的每个元素都分配了一个指针大小(sizeof(structrequest_sock*))。这实际上是一个哈希表。真正半连接的request_sock对象是在握手过程中分配的,计算出Hash值后挂在Hash表上。2.5半连接队列长度的计算在上一节中,我们提到在reqsk_queue_alloc函数中计算半连接队列的长度。由于这有点复杂,我们将在单独的部分中进行讨论。//file:net/core/request_sock.cintreqsk_queue_alloc(structrequest_sock_queue*queue,unsignedintnr_table_entries){//计算半连接队列的长度nr_table_entries=min_t(u32,nr_table_entries,sysctl_max_syn_backlog);nr_table_entries=max_t(u32,riers_table,8_table)=roundup_pow_of_two(nr_table_entries+1);//为了效率,不记录nr_table_entries//而是记录2的几个次方等于nr_table_entriesfor(lopt->max_qlen_log=3;(1<max_qlen_log)max_qlen_log++);...}在最初调用reqsk_queue_alloc的地方可以看到传入的nr_table_entries。它是内核参数net.core.somaxconn和用户调用listen时传入的backlog值之间较小的值。在这个reqsk_queue_alloc函数中,会完成三个比较和计算。min_t(u32,nr_table_entries,sysctl_max_syn_backlog)这又是sysctl_max_syn_backlog内核对象的最小值。max_t(u32,nr_table_entries,8)这句话保证nr_table_entries不能小于8,用于防止新手用户传入的值太小导致无法建立连接。roundup_pow_of_two(nr_table_entries+1)用于向上对齐到2的整数次幂。说到这里,你可能已经很头疼了。确实,这样的描述有??点抽象。我们换个方法,通过两个实际案例来计算一下。假设:某台服务器的内核参数net.core.somaxconn为128,net.ipv4.tcp_max_syn_backlog为8192。那么当用户积压5时过去,半连接队列有多长?和代码一样,我们也分四步计算,最终结果为16。min(backlog,somaxconn)=min(5,128)=5min(5,tcp_max_syn_backlog)=min(5,8192)=8max(5,8)=8roundup_pow_of_two(8+1)=16somaxconn和tcp_max_syn_backlog保持不变,监听时的backlog增加到512,然后重新计算,结果为256。min(backlog,somaxconn)=min(512,128)=128min(128,tcp_max_syn_backlog)=min(128,8192)=128max(128,8)=8roundup_pow_of_two(128+1)=256算在这里,我总结了长度的计算半连接队列成一个句子。semi-join队列的长度为min(backlog,somaxconn,tcp_max_syn_backlog)+1然后四舍五入为2的次方,但是最小不能小于16。我用的内核源是3.10,你的内核版本可能与这个略有不同。如果线上遇到半连接队列溢出问题,想增加队列长度,需要同时考虑somaxconn、backlog、tcp_max_syn_backlog这三个内核参数。最后,为了提高比较性能,内核不直接记录半连接队列的长度。相反,使用一种晦涩的方法来仅记录功率。假设队列长度为16,则记录max_qlen_log为4(2的4次方等于16),假设队列长度为256,则记录max_qlen_log为8(2的8次方等于16)。大家只需要知道这东西是为了提高性能就可以了。最后总结一下,计算机系的同学背服务器端socket程序流程就像背八股文章一样:先bind,再listen,再accept。至于为什么要先听后才能接受,我们似乎很少关注。今天通过简单浏览了一下listen的源码,发现listen的主要工作就是申请和初始化接收队列,包括全连接队列和半连接队列。其中,全连接队列是一个链表,半连接队列因为需要快速查找,所以使用哈希表(其实半连接队列更准确的名字应该叫半连接哈希表)。全/半队列是三次握手中两个非常重要的数据结构。有了它们,服务器就可以正常响应客户端的三次握手。所以服务器需要监听它。除此之外,我们还有额外的收获,我们也知道了内核是如何决定全连接/半连接队列的长度的。1.全连接队列的长度对于全连接队列,最大长度为监听时传入的backlog和net.core.somaxconn之间的较小值。如果需要增加全连接队列的长度,那么调整backlog和somaxconn。2、半连接队列的长度在监听的过程中,我们在内核中也看到,对于半连接队列,最大长度为min(backlog,somaxconn,tcp_max_syn_backlog)+1然后四舍五入到2的幂,但最小值不能小于16。如果需要增加半连接队列的长度,需要考虑backlog这三个参数,somaxconn和tcp_max_syn_backlog。网上任何一篇文章告诉你修改某个参数可以增加半连接队列的长度都是错误的。所以,千万不要放过一个细节,说不定你会有意想不到的收获呢!