前言作者一直觉得,如果能知道从应用程序到框架再到操作系统的每一段代码,那将是一件激动人心的事情。今天笔者就从Linux源码(基于Linux3.10内核)的角度来看下server端的Socket在accept的时候是干什么的。一个最简单的服务器端例子众所周知,建立一个服务器端Socket需要四个步骤:socket、bind、listen、accept。今天,笔者重点介绍accept。代码如下:....call_err=listen(sockfd_server,MAX_BACK_LOG);......while(1){structsockaddr_in*s_addr_client=mem_alloc(sizeof(structsockaddr_in));intclient_length=sizeof(*s_addr_client);//这里是我们的重点今天点acceptsockfd=accept(sockfd_server,(structsockaddr_*)(s_addr_client),(socklen_t*)&(client_length));if(sockfd==-1){printf("Accepterror!\n");continue;}process_connection(sockfd,(structsockaddr_in*)(&s_addr_client));}}首先我们通过socket系统调用创建一个Socket,它指定SOCK_STREAM,最后一个参数为0,即建立一个普通的TCPSocket。这里我们直接给出了TCPSocket对应的ops,也就是操作函数。accept系统调用已经准备好了,我们直接进入accept系统调用。#include//成功,返回代表新连接的描述符,错误返回-1,错误码设置在errnointaccept(intsockfd,structsockaddr*addr,socklen_t*addrlen);//注意,其实linux还有一个accept扩展accept4://另外添加flags参数可以为新的连接描述符设置O_NONBLOCK|O_CLOEXEC(执行exec后关闭)。注意这里的accept调用被glibc用SYSCALL_CANCEL包裹,将返回值修正为只有0和-1,并在errno中设置了错误码的绝对值。由于glibc对系统调用的封装过于复杂,这里不再赘述。如果要查找具体逻辑,使用//注意accept和(之间必须有一个空格,否则将无法搜索到accept(int,只能在整个glibc代码中搜索。理解accept的关键点在于它会创建一个新的Socket与对端运行connect()的对端Socket进行连接,如下图所示:接下来我们进入Linux内核源码栈。accept|->SYSCALL_CANCEL(accept...)...|->SYSCALL_DEFINE3(accept//最后调用了sys_accept4|->sys_accept4/*检查监控描述符fd是否存在,不存在,返回-BADF|->sockfd_lookup_light|->sock_alloc/*NewSocket*/|->get_unused_fd_flags/*获取一个未使用的fd*/|->sock->ops->accept(sock...)/*调用核心*/以上过程如如下:由此我们知道核心函数是在sock->ops->accept上,既然我们关注的是TCP,那么它的实现就是inet_stream_ops->accept,也就是inet_accept,再次追查调用栈:sock->ops->accept|->inet_steam_ops->accept(inet_accept)/*从一开始的sock图可以看出sk_prot=tcp_prot|->sk1->sk_prot->accept|->inet_csk_acceptOK,经过层层封装,终于到了具体的逻辑部分。上面代码:structsock*inet_csk_accept(structsock*sk,intflags,int*err){structinet_connection_sock*icsk=inet_csk(sk);/*获取当前监听sock的accept队列*/structrequest_sock_queue*queue=&icsk->icsk_accept_queue;....../*如果监听的Socket状态不是TCP_LISEN,则返回错误*/if(sk->sk_state!=TCP_LISTEN)gotoout_err/*如果当前accept队列为空*/if(reqsk_queue_empty(queue)){longtimeo=sock_rcvtimeo(sk,flags&O_NONBLOCK);/*如果是非阻塞模式,直接返回-EAGAIN*/error=-EAGAIN;if(!timeo)gotoout_err;/*如果是阻塞模式,切超时时间不是0,然后等待新连接进入队列*/error=inet_csk_wait_for_connect(sk,timeo);if(error)gotoout_err;}/*这里acceptqueue不为空,从队列中获取一个连接*/req=reqsk_queue_remove(queue);newsk=req->sk;/*fastopen判断逻辑*/.../*返回一个新的sock,即相当于client端的accept派生的sock*/returnnewsk}以上过程如图在里面下图:我们关注inet_csk_wait_for_connect,即accept超时逻辑:staticintinet_csk_wait_for_connect(structsock*sk,longtimeo){for(;;){/*通过增加EXCLUSIVE标志,在调用accept时不会出现雷群效应BIO*/prepare_to_wait_exclusive(sk_sleep(sk),&wait,TASK_INTERRUPTIBLE);if(reqsk_queue_empty(&icsk->icsk_accept_queue))timeo=schedule_timeout(timeo);......err=-EAGAIN;/*这里的accept超时返回-EAGAIN*/if(!timeo)break;}finish_wait(sk_sleep(sk),&wait);returnerr;}通过exclusionflag,当我们在BIO中调用accept时(没有epoll/select等),代码知道是EAGAIN而不是ETIMEOUT在accept超时时返回(errno),我们不会感到惊讶.EPOLL(accept时)“惊群”因为在EPOLLLT(水平触发模式下),一个accept事件可能会唤醒多个(epoll_wait)线程等待这个listenfd,最后可能只有一个可以成功得到一个新的连接(newfd),其他都是-EGAIN,也就是唤醒一些不需要的线程,做无用的工作。关于epoll的原理,可以看作者之前的博客《从linux源码看epoll》:https://my.oschina.net/alchemystar/blog/3008840这里说明一下原因。核心是在水平触发下,epoll_wait在这个fd中还是会有一个未完成的状态。处理事件时,重新插入ready_list,唤醒另一个等待epoll的进程!所以我们可以看到epoll_wait虽然给自己加了exclusive,但是在触发中断事件的时候并不会惊群,但是横向触发这种机制确实造成了类似“惊群”的现象!从上面的讨论可以看出,fd1中仍然存在引起额外唤醒的事件。这也很好理解。毕竟这个事件是另外一个线程处理的,那个线程估计还没来得及运行,自然来不及处理!下面看看如何判断这个fd(listensock的fd)在accept事件中还有未处理的事件。//通过f_op->pollEmpty判断epi->ffd.file->f_op->poll|->tcp_poll判断监听sock中是否有未处理的事件*/staticinlineunsignedintinet_csk_listen_poll(conststructsock*sk){return!reqsk_queue_empty(&inet_csk(sk)->icsk_accept_queue)?(POLLIN|POLLRDNORM):0;}那么我们就可以按照上面的逻辑画一个时序图了。其实不仅仅是accept,如果同一个fd的多线程epoll_waitread/write也是同一个惊群,但应该没人做吧。正是因为这种“惊群”效应的存在,我们才会经常使用单线程独占接受,比如reactor模式。但是如果一下子有大量的连接涌入,单线程处理还是有瓶颈的,无法充分发挥多核的优势,在海量短连接的场景下就显得有些无力了。这也是一种解决方法!使用so_reuseport解决惊群。前面说了,出现这个问题是因为我们在同一个fd上运行多线程的epoll_wait。其实我们多开几个fd就可以解决。第一个想到的方案就是多开几个端口号,人为的单独监控fd,但这显然带来了额外的复杂度。为了解决这个问题,Linux提供了so_reuseport参数。原理如下图所示:多个fd监听同一个端口号,在内核中做负载均衡(Sharding),将accept任务分发到不同线程。在Socket(Sharding)上,毫无疑问可以利用多核能力,大大提升连接成功后的Socket分发能力。那么我们的线程模型也可以改成多线程accept,如下图:accept_queue全连接队列在前面的讨论中,accept_queue是accept系统调用的核心成员,那么这个accept_queue是怎么填充(add)的怎么样?如下图所示:图中显示了client和server的三个交互中accept_queue(全连接队列)和syn_table半连接哈希表的变化。accept_queue填满后,用户线程通过accept系统调用从queue中获取对应的fd。值得注意的是,当用户线程来不及处理时,内核会丢弃三次握手的成功连接,从而产生一些奇怪的现象。可以看我的另一篇博文《解Bug之路-dubbo流量上线时的非平滑问题》:https://my.oschina.net/alchemystar/blog/3098219另外accept_queue的具体填充机制和源码可以看我另一篇博文的详细解析《从Linux源码看Socket(TCP)的listen及连接队列》:https://my.oschina.net/alchemystar/blog/4672630总结Linux内核源码博大精深,每次深挖都会废寝忘食。期间可以看到各种精美的设计,分享到这里,希望对读者有所帮助。本文转载自微信公众号《Bug解决之道》,可通过以下二维码关注。转载本文请联系BUG解决公众号。