從Linux原始碼看Socket(TCP)的accept

Al發表於2020-12-07

從Linux原始碼看Socket(TCP)的accept

前言

筆者一直覺得如果能知道從應用到框架再到作業系統的每一處程式碼,是一件Exciting的事情。 今天筆者就從Linux原始碼的角度看下Server端的Socket在進行Accept的時候到底做了哪些事情(基於Linux 3.10核心)。

一個最簡單的Server端例子

眾所周知,一個Server端Socket的建立,需要socket、bind、listen、accept四個步驟。
今天,筆者就聚焦於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也就是操作函式。
codegen

accept系統呼叫

好了,我們直接進入accept系統呼叫吧。

#include <sys/socket.h>
// 成功,返回代表新連線的描述符,錯誤返回-1,同時錯誤碼設定在errno
int 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的原理可以看下筆者之前的部落格《從linux原始碼看epoll》:

https://www.cnblogs.com/alchemystar/p/13161781.html

在這裡描述一下原因,核心就是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了,如下圖所示:
codegen

accept_queue全連線佇列

在前面的討論中,accept_queue是accept系統呼叫中的核心成員,那麼這個accept_queue是怎麼被填充(add)的呢?如下圖所示:
codegen
圖中展示了client和server在三次互動中,accept_queue(全連線佇列)和syn_table半連線hash表的變遷情況。在accept_queue被填充後,由使用者執行緒通過accept系統呼叫從佇列中獲取對應的fd
codegen
值得注意的是,當使用者執行緒來不及處理的時候,核心會drop掉三次握手成功的連線,導致一些詭異的現象,具體可以看筆者的另一篇部落格《解Bug之路-dubbo流量上線時的非平滑問題》:

https://www.cnblogs.com/alchemystar/p/13473999.html

另外,對於accept_queue具體的填充機制以及原始碼,可以見筆者另一篇部落格的詳細分析
《從Linux原始碼看Socket(TCP)的listen及連線佇列》:

https://www.cnblogs.com/alchemystar/p/13845081.html

總結

Linux核心原始碼博大精深,每次扎進去探索時候都會廢寢忘食,其間可以看到各種優雅的設計,在此分享出來,希望對讀者有所幫助。歡迎大家關注我公眾號,裡面有各種乾貨,還有大禮包相送哦!
codegen

相關文章