關於TCP Zero Window Update感知的非常棒的優化

分類:IT技術 時間:2016-10-10
本文從“然而有一種丟包...”開始步入正題。此前的胡扯可以直接跳過。
這個周末是搬入新家的第二個周末,感覺整個人比在羅湖時狀態更加好了。也許這個房子的色調跟我上海的家更像吧...不管怎麽說,這是我到深圳以後第一個感到振作的地方,以前曾經好幾次都想離開了,但是這個家讓我決定可以繼續堅持。說實話我並不喜歡深圳,雖然我比較喜歡下雨,但是喜歡的是那種持續不斷的雨,而不是亞熱帶雨林式的十分鐘暴雨十分鐘烈日那種。我喜歡的城市是那種縱深的,30公裏回家路可以看完一本書的那種...本來嘛,今天想好好睡一覺,以往周末都是半夜三點多爬起來總結這一周的收獲寫寫文章,今天五點多才起來,也算是睡足了。沒想到兩小時不到一氣呵成了本文,也是簡直了。唉,環境影響心情,環境影響效率,特別是對性情中人...
        我的作文習慣中,都是在寫完了一篇文章後在開頭添加一段應景的文字,本文也不例外。現在時間2016/07/09 08:25,上述是我添加的。

TCP重傳概述

TCP使用重傳機制來應對丟包,這種丟包一般被認為發生在TCP的端到端之間,其原因大致可以分為線路誤碼,網絡擁塞。
        TCP采用兩種機制來進行重傳,分別是超時重傳和快速重傳,其中最根本的是超時重傳,至於說快速重傳,你可以把它看作是一種優化措施。超時重傳是在ACK時鐘丟失後的最後一道防線,它是一個外部時鐘來彌補ACK時鐘的暫時停擺,並試圖再次啟動ACK時鐘。

ACK而非NAK小史

TCP在設計之初,並沒有采用NAK的機制來顯式通知數據發送端哪些數據已經丟失,這是因為一方面在上世紀80年代線路資源是昂貴的,就目前的普遍情形來講,互聯網中占據30%的包都是ACK包,這種情況在那個年代本來就很誇張,再加上NAK通知,那就更不能接受了,另一方面是TCP的ACK機制完全有能力讓發送端判斷哪些數據包是丟失的。作為一系列的優化措施,Nagle,延遲ACK等算法被發明,這些優化措施中,很多都是相互打架的,造成這種局面的原因在於,吞吐和時延本來就是不相容的,就像時間和空間一樣,你必須做出取舍或者權衡。

SACK的出現

隨著網絡技術的發展,節點的互聯拓撲越來越復雜,分層設計是應對復雜的根本之道。相繼出現了各種支持分層的域,組織與組織之間也劃分為各種自治域或者路由域,這些對於端到端按序協議比如TCP來講,是一種挑戰!
        本來在設計之初,數據的傳輸只是利用基於統計復用的分組交換技術來代替嚴格TDM/FDM等電路交換技術從而進一步提高線路利用率,至於說節點之間在動態路由協議的管理下大規模自適應互聯則是後來的事,因此端到端協議在這個層面上其實工作地並不好,挑戰在於“按序”到達!網絡節點的大規模隨機互聯無法保證其中傳輸數據的到達時序,然而按照分層模型的原則,端到端傳輸層協議對網絡層拓撲變更的漠然以及網絡層本身的無狀態無連接之間,又產生一組矛盾。解決之道依然是修改TCP而保持網絡層的簡潔。增加一個TCP選項,由接收端在ACK中設置,在收到亂序到達的數據時,顯式告訴發送端它都收到了哪些數據,由發送端自己來判斷這些被收到的亂序數據和已經被確認的按序數據之間的空洞是丟失了還是亂序了,從而做出是立即重傳還是繼續等待重傳定時器超時的決策。

超時定時器是一個必不可少的外部時鐘

之所以要采用定時器超時重傳機制,是因為TCP發送端發出數據後,除了被接收端確認可以明確知道發送成功之外,完全不知道數據發出後發生了什麽,因此發送端需要進行判斷,數據肯定是丟失了(其實不一定,也可能被網絡cache住了,或者說繞遠路了),如果此時能有一個機制告訴發送端數據丟失的原因,那將會幫助發送端做出更好的決策。
比如,數據因為擁塞被路由器丟棄,那麽路由器如果能發送一小段數據告訴發送端“線路已經擁塞,請不要再繼續發送”,這就是傳說中的“源抑制報文”,但是由於這是一種帶外控制報文,不一定會被發送端收到或者處理。另一方面,如果數據是由於線路誤碼被丟棄了,那麽要是可以發送一個消息,告訴發送端該情況,發送端就可以立即重傳這個被丟失的數據包。只可惜,TCP無法保證中間網絡會發送這樣的報文,即便它們發了,也不能保證這些報文不會在回來的路上被攔截...困難重重。因此發送端采用了一種自適應的超時機制擇機重傳沒有被確認的數據。這個超時時間基於RTT來計算,這裏就不說了,哪裏都有。特別要指出的是,TCP的超時重傳采用一種退避的方式,也就是說,在第一次重傳仍然沒有被確認時,會退避一段更長的時間再來進行重傳,隨著重傳次數的增多,數據始終沒有被確認的話,這個退避的時間會越來越久,可以多達數分鐘...註意!這是本文接下來討論的根本。


然而有一種丟包...

然而有一種丟包明明是可以將原因通知到發送端的,但好像沒有,這種丟包是由於數據接收端的緩沖區不足引發的。我先說結論吧,然後再詳細分析。

結論

收到數據接收端的通告窗口從0到非0的update通知後,應該判斷是否存在數據被發送卻沒有得到確認,如果存在,應該首先立即重傳這部分數據並重置超時重傳定時器。
這個地方會導致性能瓶頸,本質原因在於:明明有一種丟包是數據接收端通知給發送端原因的,可發送端並不感知,依然我行我素。解決之道也簡單,讓這種通知被發送端感知到即可!

接收緩沖區配額不足引發丟包可以被優化的原因分析

1.為什麽會發生由於接收端緩沖區不足而導致的丟包?

也許你要問了,通告窗口即可以再發多少數據不是接收端發給發送端的嗎?怎麽可能出現發送端發了而接收端收不下的情形呢?
        我們拋開發送端忽略接收窗口而激進發送不說,僅僅從接收端的角度來討論。事實上,接收窗口只是說發送端最多只能再發這麽多數據,它表示的是接收端發送攜帶通告窗口的ACK時的剩余緩沖區大小,然而過了大約一個RTT後,當發送端真的發了這麽多數據過來,接收端此時是否能成功接收,還要看一個全局配額,如果在此間,其它的TCP連接占據了更多的配額,導致配額剩余不足,數據包還是會被丟棄的。
        在linux的TCP實現中,有一個內核參數:
tcp_mem - vector of 3 INTEGERs: min, pressure, max min: below this number of pages TCP is not bothered about its
    memory appetite.
    pressure: when amount of memory allocated by TCP exceeds this number
    of pages, TCP moderates its memory consumption and enters memory pressure mode, which is exited when memory consumption falls
    under "min".
    max: number of pages allowed for queueing by all TCP sockets.
    Defaults are calculated at boot time from amount of available
    memory.

說的就是這回事。其它的實現中有沒有這個參數,我不是十分清楚,如果誰清楚細節,可以告知一下,我們一起討論。在Linux的這部分判斷實現中,邏輯還是比較簡單的,如下的偽代碼所示:

接收端在ACK中通告給發送端的窗口計算公式如下:
window = socket.receive_buffer - socket.alloced;

當接收端收到一個skb的時候,會做如下判斷來決定是接收該skb,還是丟棄它:
# check_local僅僅受限於自己的通告窗口,只要發送端嚴格遵循接收端的通告窗口發送,一般可以通過。
if (check_local(socket, skb_size) != 0)
    DROP;
# check_global受限於所有的TCP連接的內存分配,其它的TCP連接可以搶走更多的配額。
else if (check_global(socket, skb_size) != 0)
    DROP
else
    ACCEPT


我們來看一下check_local的細節:
bool check_local(socket, skb_size)
{
    if (socket.alloced + skb_size > socket.receive_buffer)
        return 1;
    else
        return 0;
}


當check_local通過後,意味這數據並沒有超過其上一次的通告窗口的大小,進一步要檢查一下全局的配額是否允許這個skb被TCP接收:
bool check_global(socket, skb_size)
{
    # 首先查看自己的預分配配額。
    if (socket.forward_alloc >= skb_size)
    {
        # 這個skb用掉了其長度大小的配額
        socket.forward_alloc -= skb_size;
        return 0;
    }
    # 如果預分配配額不夠,則一次性在全局配額中為自己分配一個預分配的配額
    else
    {
        new_forward = ALIGIN(skb_size, PAGE_SIZE);
        # 如果超過了內核配置的TCP最大內存使用配額,則失敗!這意味新收的skb將要被丟棄!
        if (G_mem + new_forward > sysctl_mem)
        {
            return 1;
        }
        else
        {
            # 預分配配額延展成功,累加給socket,此後的skb可以使用這個配額
            socket.forward_alloc += new_forward;
            G_mem += new_forward;
            return 0;
        }
    }
}

註意,這裏並不涉及內存分配的操作,只是檢查一下配額,既然skb已經存在並且已經到達了TCP層,內存分配肯定是成功了,這裏檢查的目的是為了避免TCP連接瘋狂的吃內存,因此檢查一個限額,超過限額則丟棄skb並釋放內存,畢竟在skb被分配的時候,處在底層的邏輯還無法精細識別這個skb的性質,到達TCP層之後,協議棧就可以對TCP配額進行全局檢查了。連同上述的check_local,這是一個分層檢查體系:
skb的分配:全局內存的檢查。
check_local(通告窗口檢查):本TCP連接範圍內的檢查。
check_global(TCP配額檢查):TCP協議範圍內的檢查。

        另外需要註意的是,上述的偽代碼中,我省略更復雜的邏輯。實際上,內核參數tcp_mem有三個值,分別是安全閾值,警戒閾值,超標閾值,全局的配額在不同的區間時,內核會采取不同的應對策略,更平滑地對待skb的接收,在可能的情況下,協議棧會騰出一部分為優化性能而亂序到達的skb占據的配額供按序到達的skb使用。這部分由於不涉及本文所述優化的根本,就不多說了。

2.收到Window Update時若有已重傳未確認的數據,如何確認這些重傳不是由於網絡擁塞引發的?

首先,采用我的這個優化策略,即“收到數據接收端的通告窗口從0到非0的Update通知後,應該判斷是否存在數據被發送卻沒有得到確認,如果存在,應該首先立即重傳這部分數據並重置超時重傳定時器。”
        如果發生誤判會怎樣?也就是說這個重傳並不是由於接收端緩沖區配額不足而引發的,而是由於網絡擁塞而引發。如果是這樣的話,我們依然首先傳輸已經重傳的那個數據而不是發送新數據的話,會怎樣?
        其實結果就是無效重傳一個數據段而已。如果真的發生了網絡擁塞,即便發送新數據也還是有大概率會丟包的。但是接下來我要說的是,收到Window Update後發現依然有數據包發出後未被確認,這件“未被確認”的事情是由於網絡擁塞導致的概率是多麽地低,以至於你可以認為只要在收到Window Update將窗口從0變為非0的時候仍有數據發出未被確認,幾乎就是由於接收端緩沖區配額不足引發的。
        假設接收端通告了N大小接收窗口,發送端發送了N個數據,考慮到目前的網絡和主機配置,網絡基本都是百兆,千兆級,主機的內存以4G為單位,因此窗口N應該是一個不止幾個MSS的相對較大的值,也就是說,in flight的報文數量也不止幾個MSS:

1).若開頭N-3個數據段發生擁塞丟包

由於發生丟包,接收端收到的數據段不會填滿通告窗口,丟包將會觸發3個重復ACK,將會導致窗口後滑的減緩乃至停滯,因此不待通告窗口被填滿即可觸發重傳。

2).若最後3個數據段發生擁塞丟包

若是這樣,雖然不會觸發快速重傳,但會觸發超時重傳,然後繼續著1)或者2)。
        因此,幾乎可以確定,如果在收到接收端的Zero Window消息時仍有數據未被確認,那麽原因就是接收端配額滿了,造成了丟包。此時發送端將會只能對這些數據進行超時重傳。註意,此時由於接收端的Window已經Shrunk to zero,因此即使收到了多個duplicated ACK(其實每一個Zero Window Probe帶來的ACK都是一個重復的ACK)也無法進行快速重傳了,這個時候可以進行超時重傳嗎?註意Linux的TCP協議實現中的tcp_retransmit_skb函數,我們知道,在重傳定時器超時時,會無條件調用tcp_retransmit_skb,傳輸傳輸隊列中的第一個skb,該函數中有一段邏輯:
tcp_retransmit_skb(sk, tcp_write_queue_head(sk)):
    ...
    /* If receiver has shrunk his window, and skb is out of
     * new window, do not retransmit it. The exception is the
     * case, when window is shrunk to zero. In this case
     * our retransmit serves as a zero window probe.
     */
    if (!before(TCP_SKB_CB(skb)->seq, tcp_wnd_end(tp))
        && TCP_SKB_CB(skb)->seq != tp->snd_una)
        return -EAGAIN;
    ...
    err = tcp_transmit_skb(sk, skb, 1, GFP_ATOMIC);

註釋已經很清楚了,只有重傳定時器超時才會在發送窗口不夠的情況下把執行流帶到tcp_retransmit_skb,理由呢?正如註釋所述,將其作為一個zero window probe,這麽做的目的看來實現者也想周到了,不浪費一個字節的帶寬!

3.收到Window Update時會立即重傳仍未確認的數據嗎?

不會!
        我們知道,TCP的數據發送在正常情況下是不依賴外部時鐘,它是自時鐘驅動的,也就是由ACK來驅動,然而一旦接收端緩沖區配額不足,將會產生丟包並不允許發送端再發送數據,一直到接收端重新擁有足夠的緩沖區配額為止!在這段時間內,ACK自時鐘是停擺的,我們認為這個時鐘已丟失!
        TCP依靠一個外部的定時器取代ACK自時鐘丟失期間的時鐘,這就是Zero Window Probe機制,TCP發送端會定時發送一個探測包,誘導接收端回應一個ACK,這個ACK中會包含其當前的接收窗口,這個接收窗口反應了自己的緩沖區配額現狀,一旦發送端收到了一個通告窗口不為0的ACK,它就可以繼續發送數據了,是否立即發送取決於Nagle,CORK等算法。發送端可以發送數據意味著可以重啟ACK自時鐘了。這一切看似都很合理,但是會有一個性能問題。
        如果在收到Window Update的時候,恰恰有數據已經被重傳且仍未得到確認(我們已經知道這十有八九是接收端緩沖區配額不足引發的),如果這個時候能立即發送這個數據的話,將會在一個RTT內獲得確認,從而TCP發送流繼續下去,然而由於ACK時鐘的缺失,發送端完全依靠外部的重傳定時器來決定什麽時候再次重傳這個未被確認的數據,而我們知道,重傳定時器是執行退避策略的,簡單點說就是再次重傳的間隔逐漸延長,在發送端收到帶有Window Update的ACK時,這個重傳定時器離到期的時間可能還很久,這個時候會有以下3種可能,假設發送端禁用了Nagle:

1).接收端騰出的窗口空間太小

以下的TCP時序圖描述了這種情況:




2).接收端騰出的窗口剛剛好

在1)的基礎上,請看以下的時序圖:




3).接收端騰出的窗口足夠大

這種情形跟上面的2)沒有本質的區別,上圖中紅色的“浪費的時間”在本場景中到底能否被彌補或者說到底有多大,取決於“接收端空出的窗口大小”以及“離Seg 13超時還有多久”之間的關系,如果在Seg 13超時之前,空出的窗口內新數據均已經被發送,那麽將會出現紅色的“浪費的時間”,如果到達Seg 13重傳定時器到期,空閑窗口內的數據仍未被發送完畢,這就不會浪費時間。但是這種情況有多大的發生概率呢?
        這種事情發生的概率會非常低!因為主機的發送時延和網絡時延(體現在RTT上)根本不在一個量級上,如果說在超高速網絡上主機延時是一筆很大的開銷並且可能大於網絡延時,但是如果真的是在超高速網絡上,接收端產生Zero Window的概率也是極低的,不是有個原則叫做“在超高速網絡上,優化端主機的收益大於優化網絡本身的收益”嗎?此時端主機是瓶頸,如果連這個都還沒搞定,別的還搞毛啊!

        綜上所述,無論哪種情況,首先立即重傳已經被重傳的數據並重置重傳定時器都是不錯的選擇。

如何優化這個現象帶來的性能損失

如此一來,如何優化就是非常簡單的事情了。我是基於tcp_probe做的測試,修改了tcp_probe裏面的HOOK函數,本例中我HOOK的是tcp_ack函數:
static inline int tcp_may_update_window_ok(const struct tcp_sock *tp,
                                        const u32 ack, const u32 ack_seq,
                                        const u32 nwin)
{
        return (after(ack, tp->snd_una) ||
                after(ack_seq, tp->snd_wl1) ||
                (ack_seq == tp->snd_wl1 && nwin > tp->snd_wnd));
}

static int jtcp_ack(struct sock *sk, struct sk_buff *skb, int flag)
{
        const struct tcp_sock *tp = tcp_sk(sk);
        const struct inet_sock *inet = inet_sk(sk);
        struct tcphdr *th = tcp_hdr(skb);
        struct inet_connection_sock *icsk = inet_csk(sk);

        u32 ack_seq = TCP_SKB_CB(skb)->seq;
        u32 ack = TCP_SKB_CB(skb)->ack_seq;

        u32 rnwin = ntohs(tcp_hdr(skb)->window);
        u32 nwin = rnwin;
        u32 owin = tp->snd_wnd;

        if (likely(!tcp_hdr(skb)->syn))
                nwin <<= tp->rx_opt.snd_wscale;

        if ((port == 0 || ntohs(inet->dport) == port || ntohs(inet->sport) == port) &&
             tcp_may_update_window_ok(tp, ack, ack_seq, nwin) &&
             (owin < nwin && owin <= 2*tp->mss_cache)) {
                printk("hit! owin: %u, nwin: %u\n", owin, nwin);
                icsk->icsk_retransmits++;
                tcp_retransmit_skb(sk, tcp_write_queue_head(sk));
                inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
                                          icsk->icsk_rto, TCP_RTO_MAX);
        }

        jprobe_return();
        return 0;
}

在正式的代碼中,上述邏輯填入tcp_ack即可。測試結果也是非常符合預期的。唯一的難點就是現象不是太好模擬。為了模擬緩沖區配額不足導致的丟包,我需要執行以下的腳本:
id=$(ps -e|grep curl|awk -F ' ' '{print $1}') ;
echo $id;
while true; do
    kill -STOP $id;
    sleep 10;
    kill -SIGCONT $id;
    # sleep 10ms,為了避免一次性騰出太多的空間!
    sleep 0.01;
done

然而TCP會自動降低發送速率的,為了制造突發而丟包,我嘗試在發送端制造了突發隊列,但這並無卵用。最簡單的就是使用tcp_probe機制在發送端忽略接收端的通告窗口,直接修改tp->snd_wnd字段即可。模擬現象是令人痛苦的,最好的方式就是在真實網絡上抓包,比較優化前後的抓包結果,同時比較優化前後的效率統計結果。
        關於模擬測試這種事情本來就不能精確化每一次測試,如果沒有統計意義,測試就是沒有意義的,畢竟,任意一次的TCP測試任何人使用任何技術都無法把控細節,它跟全世界的人們的行為有關,很有可能,處在石家莊的一個人突然感覺心情不好,然後打開電腦開始看電影,就會影響你的一次測試結果!這是個混沌系統,蝴蝶效應時刻會影響測試結果。
        理解現象的本質比知道怎麽優化更加重要和有用,因為只要你對一個現象有足夠深入的理解,你會發現優化方案遠不止一個,本例中,還有一種優化方案就是在重發一個數據作為Zero Window Probe之後,如果收到的是一個Zero Window ACK,那麽就不再執行退避,我不知道Linux目前是不是這麽實現的,帶我認為這是合理的,為什麽說合理呢?因為退避是為網絡擁塞的判定而退避的,然而當你被一個重傳作為Probe並且確實得到了回應時,就可以下結論網絡並沒有擁塞,同時可以下結論這個丟包是由於接收端緩沖區配額不足引發的。爆炸!

擁塞窗口和接收窗口

本文中沒有提到擁塞窗口,因為這是一篇講端到端控制的文章,與網絡擁塞無關,也就忽略了網絡擁塞,當然也就不提擁塞窗口的事了。但是我還是想在最後來簡單說一下接收窗口和擁塞窗口的幾點不同。
        幾乎所有人都知道,TCP的發送端以擁塞窗口和接收窗口之間的最小值來作為發送窗口發送數據。其實這麽理解是不足的,原因在於擁塞窗口的約束更小。直接點說,擁塞窗口不是滑動窗口,它是一個標量,只控制可以發送數據的多少而不控制數據之間的序列,而接收窗口則是一個滑動窗口,它是一個向量,不但控制發送數據的多少(由對端緩沖區配額決定),也控制發送數據的順序(由TCP的按序接收決定)。這麽規定是合理的,因為目前的網絡基本是一個無狀態無連接的IP網絡,網絡不會要求TCP的數序,它只關心TCP發送的數據是否超過了它的處理能力,然而接收端卻除了要關註數據是否超過了自身處理能力之外,還要關心是否按序。所以說,在TCP數據發送或者重發的時候,對兩種窗口的配額檢測是不同的,分別如下:

1).檢查擁塞窗口配額

這裏的檢查非常簡單,只檢查大小即可:
if (segments_in_flight < congestion_window)
    TRUE;
else
    FALSE;

只要是TRUE,就可以繼續發送skb,而根本不管是哪個skb!

2).檢查接收窗口配額

對於接收窗口,不但要檢查大小,還要檢查數序,因此這需要和當前發送的skb相關聯:
if (skb.end_seqence - tcp.una < receive_window)
    TRUE;
else
    FALSE;

只有兩種窗口的配額檢測全部通過,數據才能被發送。

寫在後面

這個問題並不是憑空想出了的,也不是看代碼看出來的,它來自於一個真實的問題,這個問題自出現已經有一年多了,就在前幾天被現同事抓包給重現了,雖然它很難被模擬,但是就那一次被揪住現行就不錯了。我把這個抓包貼圖如下:




非常明確,在收到Window Update的時候,重傳的1461號數據超時時間已經退避到分鐘級別了,雖然從抓包上看,並沒有窗口為0的ACK,這是因為ACK在Qidsc隊列裏丟失導致,此時如果在收到Window Update的時候,能立即重傳它,就可以節省約一分鐘的時間。另外還有一個例子:


因此就有了本文。


Tags: 現在時間 熱帶雨林 深圳 上海 文章

文章來源:


ads
ads

相關文章
ads

相關文章

ad