写点什么

从 Linux 源码 看 Socket(TCP) 的 accept

作者:赖猫
  • 2021 年 11 月 16 日
  • 本文字数:3569 字

    阅读完需:约 12 分钟

从 Linux 源码的角度看下 Server 端的 Socket 在进行 Accept 的时候到底做了哪些事情(基于 Linux 3.10 内核)。

一个最简单的 Server 端例子


众所周知,一个 Server 端 Socket 的建立,需要 socket、bind、listen、accept 四个步骤。


代码如下:

void start_server(){    // server fd    int sockfd_server;    // accept fd     int sockfd;    int call_err;    struct sockaddr_in sock_addr;	 ......    call_err=bind(sockfd_server,(struct sockaddr*)(&sock_addr),sizeof(sock_addr)); 	 ......    call_err=listen(sockfd_server,MAX_BACK_LOG);	 ......    while(1){        struct sockaddr_in* s_addr_client = mem_alloc(sizeof(struct sockaddr_in));              int client_length = sizeof(*s_addr_client);         // 这边就是我们今天的聚焦点accept        sockfd = accept(sockfd_server,(struct sockaddr_ *)(s_addr_client),(socklen_t *)&(client_length));        if(sockfd == -1){            printf("Accept error!\n");            continue;        }        process_connection(sockfd,(struct sockaddr_in*)(&s_addr_client));    }}
复制代码

首先我们通过 socket 系统调用创建了一个 Socket,其中指定了 SOCK_STREAM ,而且最后一个参数为 0,也就是建立了一个通常所有的 TCP Socket 。在这里,我们直接给出 TCP Socket 所对应的 ops 也就是操作函数。


accept 系统调用


好了,我们直接进入 accept 系统调用吧。

#include <sys/socket.h>// 成功,返回代表新连接的描述符,错误返回-1,同时错误码设置在errnoint accept(int sockfd,struct sockaddr* addr,socklen_t *addrlen);// 注意,实际上Linux还有个accept扩展accept4:// 额外添加的flags参数可以为新连接描述符设置O_NONBLOCK|O_CLOEXEC(执行exec后关闭)这两个标记int accept4(int sockfd, struct sockaddr *addr,socklen_t *addrlen, int flags);
复制代码


注意,这边的 accept 调用是被 glibc 用 SYSCALL_CANCEL 包了一层,其将返回值修正为只有 0 和 -1 这两个选择,同时将错误码的绝对值设置在 errno 内。由于 glibc 对于系统调用的封装过于复杂,就不在这里细讲了。如果要寻找具体的逻辑,用

// 注意accept和(之间要有空格,不然搜索不到accept (int
复制代码

在整个 glibc 代码中搜索即可。


理解 accept 的关键点是,它会创建一个新的 Socket ,这个新的 Socket 来与对端运行 connect() 的对等 Socket 进行连接,如下图所示:



接下来,我们就进入 Linux 内核源码栈吧

accept |->SYSCALL_CANCEL(accept......)   ......    |->SYSCALL_DEFINE3(accept     // 最终调用了sys_accept4     |->sys_accept4	      /* 检测监听描述符fd是否存在,不存在,返回-BADF	  |->sockfd_lookup_light	   |->sock_alloc /*新建Socket*/	     |->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_accept
复制代码

好了,穿过了层层包装,终于到具体逻辑部分了。上代码:

struct sock *inet_csk_accept(struct sock *sk, int flags, int *err){	struct inet_connection_sock *icsk = inet_csk(sk);	/* 获取当前监听sock的accept队列*/	struct request_sock_queue *queue = &icsk->icsk_accept_queue;	......	/* 如果监听Socket状态非TCP_LISEN,返回错误 */	if (sk->sk_state != TCP_LISTEN)		goto out_err	/* 如果当前accept队列为空 */	if (reqsk_queue_empty(queue)) {		long timeo = sock_rcvtimeo(sk, flags & O_NONBLOCK);		/* 如果是非阻塞模式,直接返回-EAGAIN */		error = -EAGAIN;		if (!timeo)			goto out_err;		/* 如果是阻塞模式,切超时时间不为0,则等待新连接进入队列 */		error = inet_csk_wait_for_connect(sk, timeo);		if (error)			goto out_err;	}		/* 到这里accept queue不为空,从queue中获取一个连接 */	req = reqsk_queue_remove(queue);	newsk = req->sk;	/* fastopen 判断逻辑 */	......	/* 返回新的sock,也就是accept派生出的和client端对等的那个sock */	return newsk}
复制代码


上面流程如下图所示:



我们关注下 inet_csk_wait_for_connect ,即 accept 的超时逻辑:

static int inet_csk_wait_for_connect(struct sock *sk, long timeo){	for (;;) {		/* 通过增加EXCLUSIVE标志使得在BIO中调用accept中不会产生惊群效应 */		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);	return err;						}
复制代码


通过 exclusice 标志使得我们在 BIO 中调用 accept(不用 epoll/select 等) 时,不会惊群。

由代码得知在 accept 超时时候返回(errno)的是 EAGAIN 而不是 ETIMEOUT。

EPOLL(在 accept 时候)"惊群"


由于在 EPOLL LT(水平触发模式下),一次 accept 事件,可能会唤醒多个等待在此 listen fd 上的(epoll_wait)线程,而最终可能只有一个能成功的获取到新连接(newfd),其它的都是-EGAIN,也即有一些不必要的线程被唤醒了,做了无用功。


在这里描述一下原因,核心就是 epoll_wait 在水平触发下会在这个 fd 仍有未处理事件的时候重新塞回 ready_list 并在此唤醒另一个等待在 epoll 上的进程!



所以我们看到,虽然 epoll_wait 的时候给自己加了 exclusive 不会在有中断事件触发的时候惊群,但是水平触发这个机制确也造成了类似"惊群"的现象!


由上面的讨论看出,fd1 仍旧有事件是造成额外唤醒的原因,这个也很好理解,毕竟这个事件是另一个线程处理的,那个线程估摸着还没来得及运行,自然也来不及处理!


我们看下在 accept 事件中,怎么判定这个 fd(listen sock 的 fd) 还有未处理事件的。


// 通过f_op->poll判定epi->ffd.file->f_op->poll	|->tcp_poll		/* 如果sock是listen状态,则由下面函数负责 */		|->inet_csk_listen_poll		/* 通过accept_queue队列是否为空判断监听sock是否有未处理事件*/static inline unsigned int inet_csk_listen_poll(const struct sock *sk){	return !reqsk_queue_empty(&inet_csk(sk)->icsk_accept_queue) ?			(POLLIN | POLLRDNORM) : 0;}
复制代码

那么我们就可以根据逻辑画出时序图了。



其实不仅仅是 accept,要是多线程 epoll_wait 同一个 fd 的 read/write 也是同样的惊群,只不过应该不会有人这么做吧。


正是由于这种"惊群"效应的存在,所以我们经常采用单开一个线程去专门 accept 的形式,例如 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 半连接 hash 表的变迁情况。在 accept_queue 被填充后,由用户线程通过 accept 系统调用从队列中获取对应的 fd


值得注意的是,当用户线程来不及处理的时候,内核会 drop 掉三次握手成功的连接,导致一些诡异的现象。

原文链接:https://www.cnblogs.com/alchemystar/p/14096447.html


C/C++Linux服务器开发/高级架构师 系统性学习

Linux 服务器开发/架构师 面试题、学习资料、教学视频和学习路线图(资料包括 C/C++,Linux,golang 技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg 等),免费分享有需要的可以自行添加 学习交流群960994558

用户头像

赖猫

关注

C/C++Linux服务器开发学习群960994558 2020.11.28 加入

纸上得来终觉浅,绝知此事要躬行

评论

发布
暂无评论
从 Linux源码 看 Socket(TCP)的accept