高性能網絡編程3----TCP消息的接收

這篇文章將試圖說明應用程序如何接收網絡上發送過來的TCP消息流,由於篇幅所限,暫時忽略ACK報文的回覆和接收窗口的滑動。
爲了快速掌握本文所要表達的思想,我們可以帶着以下問題閱讀:
1、應用程序調用read、recv等方法時,socket套接字可以設置爲阻塞或者非阻塞,這兩種方式是如何工作的?
2、若socket爲默認的阻塞套接字,此時recv方法傳入的len參數,是表示必須超時(SO_RCVTIMEO)或者接收到len長度的消息,recv方法纔會返回嗎?而且,socket上可以設置一個屬性叫做SO_RCVLOWAT,它會與len產生什麼樣的交集,又是決定recv等接收方法什麼時候返回?
3、應用程序開始收取TCP消息,與程序所在的機器網卡上接收到網絡裏發來的TCP消息,這是兩個獨立的流程。它們之間是如何互相影響的?例如,應用程序正在收取消息時,內核通過網卡又在這條TCP連接上收到消息時,究竟是如何處理的?若應用程序沒有調用read或者recv時,內核收到TCP連接上的消息後又是怎樣處理的?
4、recv這樣的接收方法還可以傳入各種flags,例如MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等。它們是如何工作的?
5、1個socket套接字可能被多個進程在使用,出現併發訪問時,內核是怎麼處理這種狀況的?
6、linux的sysctl系統參數中,有類似tcp_low_latency這樣的開關,默認爲0或者配置爲1時是如何影響TCP消息處理流程的?


書接上文。本文將通過三幅圖講述三種典型的接收TCP消息場景,理清內核爲實現TCP消息的接收所實現的4個隊列容器。當然,瞭解內核的實現並不是目的,而是如何使用socket接口、如何配置操作系統內核參數,才能使TCP傳輸消息更高效,這纔是最終目的。

很多同學不希望被內核代碼擾亂了思維,如何閱讀本文呢?
我會在圖1的步驟都介紹完了纔來從代碼上說明tcp_v4_rcv等主要方法。像flags參數、非阻塞套接字會產生怎樣的效果我是在代碼介紹中說的。然後我會介紹圖2、圖3,介紹它們的步驟時我會穿插一些上文沒有涉及的少量代碼。不喜歡瞭解內核代碼的同學請直接看完圖1的步驟後,請跳到圖2、圖3中,我認爲這3幅圖覆蓋了主要的TCP接收場景,能夠幫助你理清其流程。

接收消息時調用的系統方法要比上一篇發送TCP消息複雜許多。接收TCP消息的過程可以一分爲二:首先是PC上的網卡接收到網線傳來的報文,通過軟中斷內核拿到並且解析其爲TCP報文,然後TCP模塊決定如何處理這個TCP報文。其次,用戶進程調用read、recv等方法獲取TCP消息,則是將內核已經從網卡上收到的消息流拷貝到用戶進程裏的內存中。

第一幅圖描述的場景是,TCP連接上將要收到的消息序號是S1(TCP上的每個報文都有序號,詳見《TCP/IP協議詳解》),此時操作系統內核依次收到了序號S1-S2的報文、S3-S4、S2-S3的報文,注意後兩個包亂序了。之後,用戶進程分配了一段len大小的內存用於接收TCP消息,此時,len是大於S4-S1的。另外,用戶進程始終沒有對這個socket設置過SO_RCVLOWAT參數,因此,接收閥值SO_RCVLOWAT使用默認值1。另外,系統參數tcp_low_latency設置爲0,即從操作系統的總體效率出發,使用prequeue隊列提升吞吐量。當然,由於用戶進程收消息時,並沒有新包來臨,所以此圖中prequeue隊列始終爲空。先不細表。
圖1如下:

上圖中有13個步驟,應用進程使用了阻塞套接字,調用recv等方法時flag標誌位爲0,用戶進程讀取套接字時沒有發生進程睡眠。內核在處理接收到的TCP報文時使用了4個隊列容器(當鏈表理解也可),分別爲receive、out_of_order、prequeue、backlog隊列,本文會說明它們存在的意義。下面詳細說明這13個步驟。
1、當網卡接收到報文並判斷爲TCP協議後,將會調用到內核的tcp_v4_rcv方法。此時,這個TCP連接上需要接收的下一個報文序號恰好就是S1,而這一步裏,網卡上收到了S1-S2的報文,所以,tcp_v4_rcv方法會把這個報文直接插入到receive隊列中。
注意:receive隊列是允許用戶進程直接讀取的,它是將已經接收到的TCP報文,去除了TCP頭部、排好序放入的、用戶進程可以直接按序讀取的隊列。由於socket不在進程上下文中(也就是沒有進程在讀socket),由於我們需要S1序號的報文,而恰好收到了S1-S2報文,因此,它進入了receive隊列。

2、接着,我們收到了S3-S4報文。在第1步結束後,這時我們需要收到的是S2序號,但到來的報文卻是S3打頭的,怎麼辦呢?進入out_of_order隊列!從這個隊列名稱就可以看出來,所有亂序的報文都會暫時放在這。

3、仍然沒有進入來讀取socket,但又過來了我們期望的S2-S3報文,它會像第1步一樣,直接進入receive隊列。不同的時,由於此時out_of_order隊列不像第1步是空的,所以,引發了接來的第4步。

4、每次向receive隊列插入報文時都會檢查out_of_order隊列。由於收到S2-S3報文後,期待的序號成爲了S3,這樣,out_of_order隊列裏的唯一報文S3-S4報文將會移出本隊列而插入到receive隊列中(這件事由tcp_ofo_queue方法完成)。

5、終於有用戶進程開始讀取socket了。做過應用端編程的同學都知道,先要在進程裏分配一塊內存,接着調用read或者recv等方法,把內存的首地址和內存長度傳入,再把建立好連接的socket也傳入。當然,對這個socket還可以配置其屬性。這裏,假定沒有設置任何屬性,都使用默認值,因此,此時socket是阻塞式,它的SO_RCVLOWAT是默認的1。當然,recv這樣的方法還會接收一個flag參數,它可以設置爲MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等,這裏我們假定爲最常用的0。進程調用了recv方法。

6、無論是何種接口,C庫和內核經過層層封裝,接收TCP消息最終一定會走到tcp_recvmsg方法。下面介紹代碼細節時,它會是重點。

7、在tcp_recvmsg方法裏,會首先鎖住socket。爲什麼呢?因此socket是可以被多進程同時使用的,同時,內核中斷也會操作它,而下面的代碼都是核心的、操作數據的、有狀態的代碼,不可以被重入的,鎖住後,再有用戶進程進來時拿不到鎖就要休眠在這了。內核中斷看到被鎖住後也會做不同的處理,參見圖2、圖3。

8、此時,第1-4步已經爲receive隊列裏準備好了3個報文。最上面的報文是S1-S2,將它拷貝到用戶態內存中。由於第5步flag參數並沒有攜帶MSG_PEEK這樣的標誌位,因此,再將S1-S2報文從receive隊列的頭部移除,從內核態釋放掉。反之,MSG_PEEK標誌位會導致receive隊列不會刪除報文。所以,MSG_PEEK主要用於多進程讀取同一套接字的情形。

9、如第8步,拷貝S2-S3報文到用戶態內存中。當然,執行拷貝前都會檢查用戶態內存的剩餘空間是否足以放下當前這個報文,不足以時會直接返回已經拷貝的字節數。
10、同上。

11、receive隊列爲空了,此時會先來檢查SO_RCVLOWAT這個閥值。如果已經拷貝的字節數到現在還小於它,那麼可能導致進程會休眠,等待拷貝更多的數據。第5步已經說明過了,socket套接字使用的默認的SO_RCVLOWAT,也就是1,這表明,只要讀取到報文了,就認爲可以返回了。
做完這個檢查了,再檢查backlog隊列。backlog隊列是進程正在拷貝數據時,網卡收到的報文會進這個隊列。此時若backlog隊列有數據,就順帶處理下。圖3會覆蓋這種場景。

12、在本圖對應的場景中,backlog隊列是沒有數據的,已經拷貝的字節數爲S4-S1,它是大於1的,因此,釋放第7步里加的鎖,準備返回用戶態了。

13、用戶進程代碼開始執行,此時recv等方法返回的就是S4-S1,即從內核拷貝的字節數。


圖1描述的場景是最簡單的1種場景,下面我們來看看上述步驟是怎樣通過內核代碼實現的(以下代碼爲2.6.18內核代碼)。


我們知道,linux對中斷的處理是分爲上半部和下半部的,這是處於系統整體效率的考慮。我們將要介紹的都是在網絡軟中斷的下半部裏,例如這個tcp_v4_rcv方法。圖1中的第1-4步都是在這個方法裏完成的。
int tcp_v4_rcv(struct sk_buff *skb)
{
        ... ...
	//是否有進程正在使用這個套接字,將會對處理流程產生影響
        //或者從代碼層面上,只要在tcp_recvmsg裏,執行lock_sock後只能進入else,而release_sock後會進入if
	if (!sock_owned_by_user(sk)) {
		{
			//當 tcp_prequeue 返回0時,表示這個函數沒有處理該報文
			if (!tcp_prequeue(sk, skb))//如果報文放在prequeue隊列,即表示延後處理,不佔用軟中斷過長時間
			    ret = tcp_v4_do_rcv(sk, skb);//不使用prequeue或者沒有用戶進程讀socket時(圖3進入此分支),立刻開始處理這個報文
		}
	} else
		sk_add_backlog(sk, skb);//如果進程正在操作套接字,就把skb指向的TCP報文插入到backlog隊列(圖3涉及此分支)
        ... ...
}

圖1第1步裏,我們從網絡上收到了序號爲S1-S2的包。此時,沒有用戶進程在讀取套接字,因此,sock_owned_by_user(sk)會返回0。所以,tcp_prequeue方法將得到執行。簡單看看它:
static inline int tcp_prequeue(struct sock *sk, struct sk_buff *skb)
{
	struct tcp_sock *tp = tcp_sk(sk);

	//檢查tcp_low_latency,默認其爲0,表示使用prequeue隊列。tp->ucopy.task不爲0,表示有進程啓動了拷貝TCP消息的流程
	if (!sysctl_tcp_low_latency && tp->ucopy.task) {
		//到這裏,通常是用戶進程讀數據時沒讀到指定大小的數據,休眠了。直接將報文插入prequeue隊列的末尾,延後處理
		__skb_queue_tail(&tp->ucopy.prequeue, skb);
		tp->ucopy.memory += skb->truesize;
		//當然,雖然通常是延後處理,但如果TCP的接收緩衝區不夠用了,就會立刻處理prequeue隊列裏的所有報文
		if (tp->ucopy.memory > sk->sk_rcvbuf) {
			while ((skb1 = __skb_dequeue(&tp->ucopy.prequeue)) != NULL) {
                                //sk_backlog_rcv就是下文將要介紹的tcp_v4_do_rcv方法
				sk->sk_backlog_rcv(sk, skb1);
			}
		} else if (skb_queue_len(&tp->ucopy.prequeue) == 1) {
                        //prequeue裏有報文了,喚醒正在休眠等待數據的進程,讓進程在它的上下文中處理這個prequeue隊列的報文
			wake_up_interruptible(sk->sk_sleep);
		}

		return 1;
	}
	//prequeue沒有處理
	return 0;
}

由於tp->ucopy.task此時是NULL,所以我們收到的第1個報文在tcp_prequeue函數裏直接返回了0,因此,將由 tcp_v4_do_rcv方法處理。
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
	if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */
		//當TCP連接已經建立好時,是由tcp_rcv_established方法處理接收報文的
		if (tcp_rcv_established(sk, skb, skb->h.th, skb->len))
			goto reset;

		return 0;
	}
        ... ...
}

tcp_rcv_established方法在圖1裏,主要調用tcp_data_queue方法將報文放入隊列中,繼續看看它又幹了些什麼事:
static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)
{
	struct tcp_sock *tp = tcp_sk(sk);

	//如果這個報文是待接收的報文(看seq),它有兩個出路:進入receive隊列,正如圖1;直接拷貝到用戶內存中,如圖3
	if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
                //滑動窗口外的包暫不考慮,篇幅有限,下次再細談
		if (tcp_receive_window(tp) == 0)
			goto out_of_window;

		//如果有一個進程正在讀取socket,且正準備要拷貝的序號就是當前報文的seq序號
		if (tp->ucopy.task == current &&
		    tp->copied_seq == tp->rcv_nxt && tp->ucopy.len &&
		    sock_owned_by_user(sk) && !tp->urg_data) {
			//直接將報文內容拷貝到用戶態內存中,參見圖3
			if (!skb_copy_datagram_iovec(skb, 0, tp->ucopy.iov, chunk)) {
				tp->ucopy.len -= chunk;
				tp->copied_seq += chunk;
			}
		}

		if (eaten <= 0) {
queue_and_out:
                        //如果沒有能夠直接拷貝到用戶內存中,那麼,插入receive隊列吧,正如圖1中的第1、3步
			__skb_queue_tail(&sk->sk_receive_queue, skb);
		}
                //更新待接收的序號,例如圖1第1步中,更新爲S2
		tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;

                //正如圖1第4步,這時會檢查out_of_order隊列,若它不爲空,需要處理它
		if (!skb_queue_empty(&tp->out_of_order_queue)) {
                        //tcp_ofo_queue方法會檢查out_of_order隊列中的所有報文
			tcp_ofo_queue(sk);
		}
	}
        ... ...

	//這個包是無序的,又在接收滑動窗口內,那麼就如圖1第2步,把報文插入到out_of_order隊列吧
	if (!skb_peek(&tp->out_of_order_queue)) {
		__skb_queue_head(&tp->out_of_order_queue,skb);
	} else {
                    ... ...
			__skb_append(skb1, skb, &tp->out_of_order_queue);
	}
}

圖1第4步時,正是通過tcp_ofo_queue方法把之前亂序的S3-S4報文插入receive隊列的。
static void tcp_ofo_queue(struct sock *sk)
{
	struct tcp_sock *tp = tcp_sk(sk);
	__u32 dsack_high = tp->rcv_nxt;
	struct sk_buff *skb;
        //遍歷out_of_order隊列
	while ((skb = skb_peek(&tp->out_of_order_queue)) != NULL) {
		... ...
                //若這個報文可以按seq插入有序的receive隊列中,則將其移出out_of_order隊列
		__skb_unlink(skb, &tp->out_of_order_queue);
                //插入receive隊列
		__skb_queue_tail(&sk->sk_receive_queue, skb);
                //更新socket上待接收的下一個有序seq
		tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;
	}
}


下面再介紹圖1第6步提到的tcp_recvmsg方法。
//參數裏的len就是read、recv方法裏的內存長度,flags正是方法的flags參數,nonblock則是阻塞、非阻塞標誌位
int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
		size_t len, int nonblock, int flags, int *addr_len)
{
	//鎖住socket,防止多進程併發訪問TCP連接,告知軟中斷目前socket在進程上下文中
	lock_sock(sk);

        //初始化errno這個錯誤碼
	err = -ENOTCONN;

	//如果socket是阻塞套接字,則取出SO_RCVTIMEO作爲讀超時時間;若爲非阻塞,則timeo爲0。下面會看到timeo是如何生效的
	timeo = sock_rcvtimeo(sk, nonblock);

	//獲取下一個要拷貝的字節序號
	//注意:seq的定義爲u32 *seq;,它是32位指針。爲何?因爲下面每向用戶態內存拷貝後,會更新seq的值,這時就會直接更改套接字上的copied_seq
	seq = &tp->copied_seq;
	//當flags參數有MSG_PEEK標誌位時,意味着這次拷貝的內容,當再次讀取socket時(比如另一個進程)還能再次讀到
	if (flags & MSG_PEEK) {
		//所以不會更新copied_seq,當然,下面會看到也不會刪除報文,不會從receive隊列中移除報文
		peek_seq = tp->copied_seq;
		seq = &peek_seq;
	}

	//獲取SO_RCVLOWAT最低接收閥值,當然,target實際上是用戶態內存大小len和SO_RCVLOWAT的最小值
	//注意:flags參數中若攜帶MSG_WAITALL標誌位,則意味着必須等到讀取到len長度的消息才能返回,此時target只能是len
	target = sock_rcvlowat(sk, flags & MSG_WAITALL, len);

        //以下開始讀取消息
	do {
		//從receive隊列取出1個報文
		skb = skb_peek(&sk->sk_receive_queue);
		do {
			//沒取到退出當前循環
			if (!skb)
				break;


			//offset是待拷貝序號在當前這個報文中的偏移量,在圖1、2、3中它都是0,只有因爲用戶內存不足以接收完1個報文時才爲非0
			offset = *seq - TCP_SKB_CB(skb)->seq;
			//有些時候,三次握手的SYN包也會攜帶消息內容的,此時seq是多出1的(SYN佔1個序號),所以offset減1
			if (skb->h.th->syn)
				offset--;
			//若偏移量還有這個報文之內,則認爲它需要處理
			if (offset < skb->len)
				goto found_ok_skb;

			skb = skb->next;
		} while (skb != (struct sk_buff *)&sk->sk_receive_queue);

		//如果receive隊列爲空,則檢查已經拷貝的字節數,是否達到了SO_RCVLOWAT或者長度len。滿足了,且backlog隊列也爲空,則可以返回用戶態了,正如圖1的第11步
		if (copied >= target && !sk->sk_backlog.tail)
			break;

                //在tcp_recvmsg裏,copied就是已經拷貝的字節數
		if (copied) {
			... ...
		} else {
                        //一個字節都沒拷貝到,但如果shutdown關閉了socket,一樣直接返回。當然,本文不涉及關閉連接
			if (sk->sk_shutdown & RCV_SHUTDOWN)
				break;

			//如果使用了非阻塞套接字,此時timeo爲0
			if (!timeo) {
                                //非阻塞套接字讀取不到數據時也會返回,錯誤碼正是EAGAIN
				copied = -EAGAIN;
				break;
			}
                        ... ...
		}

		//tcp_low_latency默認是關閉的,圖1、圖2都是如此,圖3則例外,即圖3不會走進這個if
		if (!sysctl_tcp_low_latency && tp->ucopy.task == user_recv) {
			//prequeue隊列就是爲了提高系統整體效率的,即prequeue隊列有可能不爲空,這是因爲進程休眠等待時可能有新報文到達prequeue隊列
			if (!skb_queue_empty(&tp->ucopy.prequeue))
				goto do_prequeue;
		}

		//如果已經拷貝了的字節數超過了最低閥值
		if (copied >= target) {
			//release_sock這個方法會遍歷、處理backlog隊列中的報文
			release_sock(sk);
			lock_sock(sk);
		} else
			sk_wait_data(sk, &timeo);//沒有讀取到足夠長度的消息,因此會進程休眠,如果沒有被喚醒,最長睡眠timeo時間

		if (user_recv) {
			if (tp->rcv_nxt == tp->copied_seq &&
			    !skb_queue_empty(&tp->ucopy.prequeue)) {
do_prequeue:
                                //接上面代碼段,開始處理prequeue隊列裏的報文
				tcp_prequeue_process(sk);
			}
		}

		//繼續處理receive隊列的下一個報文
		continue;

	found_ok_skb:
		/* Ok so how much can we use? */
		//receive隊列的這個報文從其可以使用的偏移量offset,到總長度len之間,可以拷貝的長度爲used
		used = skb->len - offset;
		//len是用戶態空閒內存,len更小時,當然只能拷貝len長度消息,總不能導致內存溢出吧
		if (len < used)
			used = len;

		//MSG_TRUNC標誌位表示不要管len這個用戶態內存有多大,只管拷貝數據吧
		if (!(flags & MSG_TRUNC)) {
			{
				//向用戶態拷貝數據
				err = skb_copy_datagram_iovec(skb, offset,
						msg->msg_iov, used);
			}
		}

		//因爲是指針,所以同時更新copied_seq--下一個待接收的序號
		*seq += used;
		//更新已經拷貝的長度
		copied += used;
		//更新用戶態內存的剩餘空閒空間長度
		len -= used;

                ... ...
	} while (len > 0);

	//已經裝載了接收器
	if (user_recv) {
		//prequeue隊列不爲空則處理之
		if (!skb_queue_empty(&tp->ucopy.prequeue)) {
			tcp_prequeue_process(sk);
		}

		//準備返回用戶態,socket上不再裝載接收任務
		tp->ucopy.task = NULL;
		tp->ucopy.len = 0;
	}

	//釋放socket時,還會檢查、處理backlog隊列中的報文
	release_sock(sk);
	//向用戶返回已經拷貝的字節數
	return copied;
}


圖2給出了第2種場景,這裏涉及到prequeue隊列。用戶進程調用recv方法時,連接上沒有任何接收並緩存到內核的報文,而socket是阻塞的,所以進程睡眠了。然後網卡中收到了TCP連接上的報文,此時prequeue隊列開始產生作用。圖2中tcp_low_latency爲默認的0,套接字socket的SO_RCVLOWAT是默認的1,仍然是阻塞socket,如下圖:

簡單描述上述11個步驟:
1、用戶進程分配了一塊len大小的內存,將其傳入recv這樣的函數,同時socket參數皆爲默認,即阻塞的、SO_RCVLOWAT爲1。調用接收方法,其中flags參數爲0。

2、C庫和內核最終調用到tcp_recvmsg方法來處理。

3、鎖住socket。

4、由於此時receive、prequeue、backlog隊列都是空的,即沒有拷貝1個字節的消息到用戶內存中,而我們的最低要求是拷貝至少SO_RCVLOWAT爲1長度的消息。此時,開始進入阻塞式套接字的等待流程。最長等待時間爲SO_RCVTIMEO指定的時間。
這個等待函數叫做sk_wait_data,有必要看下其實現:
int sk_wait_data(struct sock *sk, long *timeo)
{
        //注意,它的自動喚醒條件有兩個,要麼timeo時間到達,要麼receive隊列不爲空
	rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
}

sk_wait_event也值得我們簡單看下:
#define sk_wait_event(__sk, __timeo, __condition)		\
({	int rc;							\
	release_sock(__sk);					\
	rc = __condition;					\
	if (!rc) {						\
		*(__timeo) = schedule_timeout(*(__timeo));	\
	}							\
	lock_sock(__sk);					\
	rc = __condition;					\
	rc;							\
})

注意,它在睡眠前會調用release_sock,這個方法會釋放socket鎖,使得下面的第5步中,新到的報文不再只能進入backlog隊列。

5、這個套接字上期望接收的序號也是S1,此時網卡恰好收到了S1-S2的報文,在tcp_v4_rcv方法中,通過調用tcp_prequeue方法把報文插入到prequeue隊列中。

6、插入prequeue隊列後,此時會接着調用wake_up_interruptible方法,喚醒在socket上睡眠的進程。參見tcp_prequque方法。

7、用戶進程被喚醒後,重新調用lock_sock接管了這個socket,此後再進來的報文都只能進入backlog隊列了。

8、進程醒來後,先去檢查receive隊列,當然仍然是空的;再去檢查prequeue隊列,發現有一個報文S1-S2,正好是socket連接待拷貝的起始序號S1,於是,從prequeue隊列中取出這個報文並把內容複製到用戶內存中,再釋放內核中的這個報文。

9、目前已經拷貝了S2-S1個字節到用戶態,檢查這個長度是否超過了最低閥值(即len和SO_RCVLOWAT的最小值)。

10、由於SO_RCVLOWAT使用了默認的1,所以準備返回用戶。此時會順帶再看看backlog隊列中有沒有數據,若有,則檢查這個無序的隊列中是否有可以直接拷貝給用戶的報文。當然,此時是沒有的。所以準備返回,釋放socket鎖。

11、返回用戶已經拷貝的字節數。

圖3給出了第3種場景。這個場景中,我們把系統參數tcp_low_latency設爲1,socket上設置了SO_RCVLOWAT屬性的值。服務器先是收到了S1-S2這個報文,但S2-S1的長度是小於SO_RCVLOWAT的,用戶進程調用recv方法讀套接字時,雖然讀到了一些,但沒有達到最小閥值,所以進程睡眠了,與此同時,在睡眠前收到的亂序的S3-S4包直接進入backlog隊列。此時先到達了S2-S3包,由於沒有使用prequeue隊列,而它起始序號正是下一個待拷貝的值,所以直接拷貝到用戶內存中,總共拷貝字節數已滿足SO_RCVLOWAT的要求!最後在返回用戶前把backlog隊列中S3-S4報文也拷貝給用戶了。如下圖:

簡明描述上述15個步驟:
1、內核收到報文S1-S2,S1正是這個socket連接上待接收的序號,因此,直接將它插入有序的receive隊列中。

2、用戶進程所處的linux操作系統上,將sysctl中的tcp_low_latency設置爲1。這意味着,這臺服務器希望TCP進程能夠更及時的接收到TCP消息。用戶調用了recv方法接收socket上的消息,這個socket上設置了SO_RCVLOWAT屬性爲某個值n,這個n是大於S2-S1,也就是第1步收到的報文大小。這裏,仍然是阻塞socket,用戶依然是分配了足夠大的len長度內存以接收TCP消息。

3、通過tcp_recvmsg方法來完成接收工作。先鎖住socket,避免併發進程讀取同一socket的同時,也在告訴內核網絡軟中斷處理到這一socket時要有不同行爲,如第6步。

4、準備處理內核各個接收隊列中的報文。

5、receive隊列中的有序報文可直接拷貝,在檢查到S2-S1是小於len之後,將報文內容拷貝到用戶態內存中。

6、在第5步進行的同時,socket是被鎖住的,這時內核又收到了一個S3-S4報文,因此報文直接進入backlog隊列。注意,這個報文不是有序的,因爲此時連接上期待接收序號爲S2。

7、在第5步,拷貝了S2-S1個字節到用戶內存,它是小於SO_RCVLOWAT的,因此,由於socket是阻塞型套接字(超時時間在本文中忽略),進程將不得不轉入睡眠。轉入睡眠之前,還會幹一件事,就是處理backlog隊列裏的報文,圖2的第4步介紹過休眠方法sk_wait_data,它在睡眠前會執行release_sock方法,看看是如何實現的:
void fastcall release_sock(struct sock *sk)
{
	mutex_release(&sk->sk_lock.dep_map, 1, _RET_IP_);

	spin_lock_bh(&sk->sk_lock.slock);
        //這裏會遍歷backlog隊列中的每一個報文
	if (sk->sk_backlog.tail)
		__release_sock(sk);
        //這裏是網絡中斷執行時,告訴內核,現在socket並不在進程上下文中
	sk->sk_lock.owner = NULL;
	if (waitqueue_active(&sk->sk_lock.wq))
		wake_up(&sk->sk_lock.wq);
	spin_unlock_bh(&sk->sk_lock.slock);
}

再看看__release_sock方法是如何遍歷backlog隊列的:
static void __release_sock(struct sock *sk)
{
	struct sk_buff *skb = sk->sk_backlog.head;

        //遍歷backlog隊列
	do {
		sk->sk_backlog.head = sk->sk_backlog.tail = NULL;
		bh_unlock_sock(sk);

		do {
			struct sk_buff *next = skb->next;

			skb->next = NULL;
                        //處理報文,其實就是tcp_v4_do_rcv方法,上文介紹過,不再贅述
			sk->sk_backlog_rcv(sk, skb);

			cond_resched_softirq();

			skb = next;
		} while (skb != NULL);

		bh_lock_sock(sk);
	} while((skb = sk->sk_backlog.head) != NULL);
}

此時遍歷到S3-S4報文,但因爲它是失序的,所以從backlog隊列中移入out_of_order隊列中(參見上文說過的tcp_ofo_queue方法)。

8、進程休眠,直到超時或者receive隊列不爲空。

9、內核接收到了S2-S3報文。注意,這裏由於打開了tcp_low_latency標誌位,這個報文是不會進入prequeue隊列以待進程上下文處理的。

10、此時,由於S2是連接上正要接收的序號,同時,有一個用戶進程正在休眠等待接收數據中,且它要等待的數據起始序號正是S2,於是,這種種條件下,使得這一步同時也是網絡軟中斷執行上下文中,把S2-S3報文直接拷貝進用戶內存。

11、上文介紹tcp_data_queue方法時大家可以看到,每處理完1個有序報文(無論是拷貝到receive隊列還是直接複製到用戶內存)後都會檢查out_of_order隊列,看看是否有報文可以處理。那麼,S3-S4報文恰好是待處理的,於是拷貝進用戶內存。然後喚醒用戶進程。

12、用戶進程被喚醒了,當然喚醒後會先來拿到socket鎖。以下執行又在進程上下文中了。

13、此時會檢查已拷貝的字節數是否大於SO_RCVLOWAT,以及backlog隊列是否爲空。兩者皆滿足,準備返回。

14、釋放socket鎖,退出tcp_recvmsg方法。

15、返回用戶已經複製的字節數S4-S1。


好了,這3個場景讀完,想必大家對於TCP的接收流程是怎樣的已經非常清楚了,本文起始的6個問題也在這一大篇中都涉及到了。下一篇我們來討論TCP連接的關閉。




發佈了86 篇原創文章 · 獲贊 854 · 訪問量 116萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章