高性能網絡編程2–TCP消息的發送
作者:陶輝
在上一篇中,我們已經建立好的TCP連接,對應著操作係統分配的1個套接字。操作TCP協議發送數據時,麵對的是數據流。通常調用諸如send或者write方法來發送數據到另一台主機,那麼,調用這樣的方法時,在操作係統內核中發生了什麼事情呢?我們帶著以下3個問題來細細分析:發送方法成功返回時,能保證TCP另一端的主機接收到嗎?能保證數據已經發送到網絡上了嗎?套接字為阻塞或者非阻塞時,發送方法做的事情有何不同?
要回答上麵3個問題涉及了不少知識點,我們先在TCP層麵上看看,發送方法調用時內核做了哪些事。我不想去羅列內核中的數據結構、方法等,畢竟大部分應用程序開發者不需要了解這些,僅以一幅示意圖粗略表示,如下:
圖1 一種典型場景下發送TCP消息的流程
再詳述上圖10個步驟前,先要澄清幾個概念:MTU、MSS、tcp_write_queue發送隊列、阻塞與非阻塞套接字、擁塞窗口、滑動窗口、Nagle算法。
當我們調用發送方法時,會把我們代碼中構造好的消息流作為參數傳遞。這個消息流可大可小,例如幾個字節,或者幾兆字節。當消息流較大時,將有可能出現分片。我們先來討論分片問題。
1、MSS與TCP的分片
由上一篇文中可知,TCP層是第4層傳輸層,第3層IP網絡層、第2層數據鏈路層具備的約束條件同樣對TCP層生效。下麵來看看數據鏈路層中的一個概念:最大傳輸單元MTU。
無論何種類型的數據鏈路層,都會對網絡分組的長度有一個限製。例如以太網限製為1500字節,802.3限製為1492字節。當內核的IP網絡層試圖發送報文時,若一個報文的長度大於MTU限製,就會被分成若幹個小於MTU的報文,每個報文都會有獨立的IP頭部。
看看IP頭部的格式:
圖2 IP頭部格式
可以看到,其指定IP包總長度的是一個16位(2字節)的字段,這意味一個IP包最大可以是65535字節。
若TCP層在以太網中試圖發送一個大於1500字節的消息,調用IP網絡層方法發送消息時,IP層會自動的獲取所在局域網的MTU值,並按照所在網絡的MTU大小來分片。IP層同時希望這個分片對於傳輸層來說是透明的,接收方的IP層會根據收到的多個IP包頭部,將發送方IP層分片出的IP包重組為一個消息。
這種IP層的分片效率是很差的,因為必須所有分片都到達才能重組成一個包,其中任何一個分片丟失了,都必須重發所有分片。所以,TCP層會試圖避免IP層執行數據報分片。
為了避免IP層的分片,TCP協議定義了一個新的概念:最大報文段長度MSS。它定義了一個TCP連接上,一個主機期望對端主機發送單個報文的最大長度。TCP3次握手建立連接時,連接雙方都要互相告知自己期望接收到的MSS大小。例如(使用tcpdump抓包):
15:05:08.230782 IP 10.7.80.57.64569 > houyi-vm02.dev.sd.aliyun.com.tproxy: S 3027092051:3027092051(0) win 8192 <mss 1460,nop,wscale 8,nop,nop,sackOK>
15:05:08.234267 IP houyi-vm02.dev.sd.aliyun.com.tproxy > 10.7.80.57.64569: S 26006838:26006838(0) ack 3027092052 win 5840 <mss 1460,nop,nop,sackOK,nop,wscale 9>
15:05:08.233320 IP 10.7.80.57.64543 > houyi-vm02.dev.sd.aliyun.com.tproxy: P 78972532:78972923(391) ack 12915963 win 255
由於例子中兩台主機都在以太網內,以太網的MTU為1500,減去IP和TCP頭部的長度,MSS就是1460,三次握手中,SYN包都會攜帶期望的MSS大小。
當應用層調用TCP層提供的發送方法時,內核的TCP模塊在tcp_sendmsg方法裏,會按照對方告知的MSS來分片,把消息流分為多個網絡分組(如圖1中的3個網絡分組),再調用IP層的方法發送數據。
這個MSS就不會改變了嗎?
會的。上文說過,MSS就是為了避免IP層分片,在建立握手時告知對方期望接收的MSS值並不一定靠得住。因為這個值是預估的,TCP連接上的兩台主機若處於不同的網絡中,那麼,連接上可能有許多中間網絡,這些網絡分別具有不同的數據鏈路層,這樣,TCP連接上有許多個MTU。特別是,若中間途徑的MTU小於兩台主機所在的網絡MTU時,選定的MSS仍然太大了,會導致中間路由器出現IP層的分片。
怎樣避免中間網絡可能出現的分片呢?
通過IP頭部的DF標誌位,這個標誌位是告訴IP報文所途經的所有IP層代碼:不要對這個報文分片。如果一個IP報文太大必須要分片,則直接返回一個ICMP錯誤,說明必須要分片了,且待分片路由器網絡接受的MTU值。這樣,連接上的發送方主機就可以重新確定MSS。
2、發送方法返回成功後,數據一定發送到了TCP的另一端嗎?
答案當然是否定的。解釋這個問題前,先來看看TCP是如何保證可靠傳輸的。
TCP把自己要發送的數據流裏的每一個字節都看成一個序號,可靠性是要求連接對端在接收到數據後,要發送ACK確認,告訴它已經接收到了多少字節的數據。也就是說,怎樣確保數據一定發送成功了呢?必須等待發送數據對應序號的ACK到達,才能確保數據一定發送成功。TCP層提供的send或者write這樣的方法是不會做這件事的,看看圖1,它究竟做了哪些事。
圖1中分為10步。
(1)應用程序試圖調用send方法來發送一段較長的數據。
(2)內核主要通過tcp_sendmsg方法來完成。
(3)(4)內核真正執行報文的發送,與send方法的調用並不是同步的。即,send方法返回成功了,也不一定把IP報文都發送到網絡中了。因此,需要把用戶需要發送的用戶態內存中的數據,拷貝到內核態內存中,不依賴於用戶態內存,也使得進程可以快速釋放發送數據占用的用戶態內存。但這個拷貝操作並不是簡單的複製,而是把待發送數據,按照MSS來劃分成多個盡量達到MSS大小的分片報文段,複製到內核中的sk_buff結構來存放,同時把這些分片組成隊列,放到這個TCP連接對應的tcp_write_queue發送隊列中。
(5)內核中為這個TCP連接分配的內核緩存是有限的(/proc/sys/net/core/wmem_default)。當沒有多餘的內核態緩存來複製用戶態的待發送數據時,就需要調用一個方法sk_stream_wait_memory來等待滑動窗口移動,釋放出一些緩存出來(收到ACK後,不需要再緩存原來已經發送出的報文,因為既然已經確認對方收到,就不需要定時重發,自然就釋放緩存了)。例如:
- wait_for_memory:
- if (copied)
- tcp_push(sk, tp, flags & ~MSG_MORE, mss_now, TCP_NAGLE_PUSH);
- if ((err = sk_stream_wait_memory(sk, &timeo)) != 0)
- goto do_error;
這裏的sk_stream_wait_memory方法接受一個參數timeo,就是等待超時的時間。這個時間是tcp_sendmsg方法剛開始就拿到的,如下:
- timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);
看看其實現:
- static inline long sock_sndtimeo(const struct sock *sk, int noblock)
- {
- return noblock ? 0 : sk->sk_sndtimeo;
- }
也就是說,當這個套接字是阻塞套接字時,timeo就是SO_SNDTIMEO選項指定的發送超時時間。如果這個套接字是非阻塞套接字, timeo變量就會是0。
實際上,sk_stream_wait_memory對於非阻塞套接字會直接返回,並將 errno錯誤碼置為EAGAIN。
(6)在圖1的例子中,我們假定使用了阻塞套接字,且等待了足夠久的時間,收到了對方的ACK,滑動窗口釋放出了緩存。
(7)將剩下的用戶態數據都組成MSS報文拷貝到內核態的sk_buff中。
(8)最後,調用tcp_push等方法,它最終會調用IP層的方法來發送tcp_write_queue隊列中的報文。
注意,IP層返回時,並不一定是把報文發送了出去。
(9)(10)發送方法返回。
從圖1的10個步驟中可知,無論是使用阻塞還是非阻塞套接字,發送方法成功返回時(無論全部成功或者部分成功),既不代表TCP連接的另一端主機接收到了消息,也不代表本機把消息發送到了網絡上,隻是說明,內核將會試圖保證把消息送達對方。
3、Nagle算法、滑動窗口、擁塞窗口對發送方法的影響
圖1第8步tcp_push方法做了些什麼呢?先來看看主要的流程:
圖3 發送TCP消息的簡易流程
下麵簡單看看這幾個概念:
(1)滑動窗口
滑動窗口大家都比較熟悉,就不詳細介紹了。TCP連接上的雙方都會通知對方自己的接收窗口大小。而對方的接收窗口大小就是自己的發送窗口大小。tcp_push在發送數據時當然需要與發送窗口打交道。發送窗口是一個時刻變化的值,隨著ACK的到達會變大,隨著發出新的數據包會變小。當然,最大也隻能到三次握手時對方通告的窗口大小。tcp_push在發送數據時,最終會使用tcp_snd_wnd_test方法來判斷當前待發送的數據,其序號是否超出了發送滑動窗口的大小,例如:
- //檢查這一次要發送的報文最大序號是否超出了發送滑動窗口大小
- static inline int tcp_snd_wnd_test(struct tcp_sock *tp, struct sk_buff *skb, unsigned int cur_mss)
- {
- //end_seq待發送的最大序號
- u32 end_seq = TCP_SKB_CB(skb)->end_seq;
- if (skb->len > cur_mss)
- end_seq = TCP_SKB_CB(skb)->seq + cur_mss;
- //snd_una是已經發送過的數據中,最小的沒被確認的序號;而snd_wnd就是發送窗口的大小
- return !after(end_seq, tp->snd_una + tp->snd_wnd);
- }
(2)慢啟動和擁塞窗口
由於兩台主機間的網絡可能很複雜,通過廣域網時,中間的路由器轉發能力可能是瓶頸。也就是說,如果一方簡單的按照另一方主機三次握手時通告的滑動窗口大小來發送數據的話,可能會使得網絡上的轉發路由器性能雪上加霜,最終丟失更多的分組。這時,各個操作係統內核都會對TCP的發送階段加入慢啟動和擁塞避免算法。慢啟動算法說白了,就是對方通告的窗口大小隻表示對方接收TCP分組的能力,不表示中間網絡能夠處理分組的能力。所以,發送方請悠著點發,確保網絡非常通暢了後,再按照對方通告窗口來敞開了發。
擁塞窗口就是下麵的cwnd,它用來幫助慢啟動的實現。連接剛建立時,擁塞窗口的大小遠小於發送窗口,它實際上是一個MSS。每收到一個ACK,擁塞窗口擴大一個MSS大小,當然,擁塞窗口最大隻能到對方通告的接收窗口大小。當然,為了避免指數式增長,擁塞窗口大小的增長會更慢一些,是線性的平滑的增長過程。
所以,在tcp_push發送消息時,還會檢查擁塞窗口,飛行中的報文數要小於擁塞窗口個數,而發送數據的長度也要小於擁塞窗口的長度。
如下所示,首先用unsigned int tcp_cwnd_test方法檢查飛行的報文數是否小於擁塞窗口個數(多少個MSS的個數):
- static inline unsigned int tcp_cwnd_test(struct tcp_sock *tp, struct sk_buff *skb)
- {
- u32 in_flight, cwnd;
- /* Don’t be strict about the congestion window for the final FIN. */
- if (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN)
- return 1;
- //飛行中的數據,也就是沒有ACK的字節總數
- in_flight = tcp_packets_in_flight(tp);
- cwnd = tp->snd_cwnd;
- //如果擁塞窗口允許,需要返回依據擁塞窗口的大小,還能發送多少字節的數據
- if (in_flight < cwnd)
- return (cwnd - in_flight);
- return 0;
- }
再通過tcp_window_allows方法獲取擁塞窗口與滑動窗口的最小長度,檢查待發送的數據是否超出:
- static unsigned int tcp_window_allows(struct tcp_sock *tp, struct sk_buff *skb, unsigned int mss_now, unsigned int cwnd)
- {
- u32 window, cwnd_len;
- window = (tp->snd_una + tp->snd_wnd - TCP_SKB_CB(skb)->seq);
- cwnd_len = mss_now * cwnd;
- return min(window, cwnd_len);
- }
(3)是否符合NAGLE算法?
Nagle算法的初衷是這樣的:應用進程調用發送方法時,可能每次隻發送小塊數據,造成這台機器發送了許多小的TCP報文。對於整個網絡的執行效率來說,小的TCP報文會增加網絡擁塞的可能,因此,如果有可能,應該將相臨的TCP報文合並成一個較大的TCP報文(當然還是小於MSS的)發送。
Nagle算法要求一個TCP連接上最多隻能有一個發送出去還沒被確認的小分組,在該分組的確認到達之前不能發送其他的小分組。
內核中是通過 tcp_nagle_test方法實現該算法的。我們簡單的看下:
- static inline int tcp_nagle_test(struct tcp_sock *tp, struct sk_buff *skb,
- unsigned int cur_mss, int nonagle)
- {
- //nonagle標誌位設置了,返回1表示允許這個分組發送出去
- if (nonagle & TCP_NAGLE_PUSH)
- return 1;
- //如果這個分組包含了四次握手關閉連接的FIN包,也可以發送出去
- if (tp->urg_mode ||
- (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN))
- return 1;
- //檢查Nagle算法
- if (!tcp_nagle_check(tp, skb, cur_mss, nonagle))
- return 1;
- return 0;
- }
再來看看tcp_nagle_check方法,它與上一個方法不同,返回0表示可以發送,返回非0則不可以,正好相反。
- static inline int tcp_nagle_check(const struct tcp_sock *tp,
- const struct sk_buff *skb,
- unsigned mss_now, int nonagle)
- {
- //先檢查是否為小分組,即報文長度是否小於MSS
- return (skb->len < mss_now &&
- ((nonagle&TCP_NAGLE_CORK) ||
- //如果開啟了Nagle算法
- (!nonagle &&
- //若已經有小分組發出(packets_out表示“飛行”中的分組)還沒有確認
- tp->packets_out &&
- tcp_minshall_check(tp))));
- }
最後看看tcp_minshall_check做了些什麼:
- static inline int tcp_minshall_check(const struct tcp_sock *tp)
- {
- //最後一次發送的小分組還沒有被確認
- return after(tp->snd_sml,tp->snd_una) &&
- //將要發送的序號是要大於等於上次發送分組對應的序號
- !after(tp->snd_sml, tp->snd_nxt);
- }
想象一種場景,當對請求的時延非常在意且網絡環境非常好的時候(例如同一個機房內),Nagle算法可以關閉,這實在也沒必要。使用TCP_NODELAY套接字選項就可以關閉Nagle算法。看看setsockopt是怎麼與上述方法配合工作的:
- static int do_tcp_setsockopt(struct sock *sk, int level,
- int optname, char __user *optval, int optlen)
- …
- switch (optname) {
- …
- case TCP_NODELAY:
- if (val) {
- //如果設置了TCP_NODELAY,則更新nonagle標誌
- tp->nonagle |= TCP_NAGLE_OFF|TCP_NAGLE_PUSH;
- tcp_push_pending_frames(sk, tp);
- } else {
- tp->nonagle &= ~TCP_NAGLE_OFF;
- }
- break;
- }
- }
可以看到,nonagle標誌位就是這麼更改的。
當然,調用了IP層的方法返回後,也未必就保證此時數據一定發送到網絡中去了。
最後更新:2017-04-03 08:26:24