天天看點

高性能網絡程式設計2–TCP消息的發送

作者:陶輝

在上一篇中,我們已經建立好的tcp連接配接,對應着作業系統配置設定的1個套接字。操作tcp協定發送資料時,面對的是資料流。通常調用諸如send或者write方法來發送資料到另一台主機,那麼,調用這樣的方法時,在作業系統核心中發生了什麼事情呢?我們帶着以下3個問題來細細分析:發送方法成功傳回時,能保證tcp另一端的主機接收到嗎?能保證資料已經發送到網絡上了嗎?套接字為阻塞或者非阻塞時,發送方法做的事情有何不同?

要回答上面3個問題涉及了不少知識點,我們先在tcp層面上看看,發送方法調用時核心做了哪些事。我不想去羅列核心中的資料結構、方法等,畢竟大部分應用程式開發者不需要了解這些,僅以一幅示意圖粗略表示,如下:

高性能網絡程式設計2–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–TCP消息的發送

圖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方法做了些什麼呢?先來看看主要的流程:

高性能網絡程式設計2–TCP消息的發送

圖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)

    //如果這個分組包含了四次握手關閉連接配接的fin包,也可以發送出去

    if (tp->urg_mode ||

        (tcp_skb_cb(skb)->flags & tcpcb_flag_fin))

        //檢查nagle算法

    if (!tcp_nagle_check(tp, skb, cur_mss, nonagle))

再來看看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層的方法傳回後,也未必就保證此時資料一定發送到網絡中去了。

繼續閱讀