Linux網絡編程 - 理解TCP協議中的動態數據傳輸

這一篇主要從 TCP 角度看數據流的發送和接收,更通俗易懂的剖析發送窗口、接收窗口、擁塞窗口等。

在前面的內容中,我們已經熟悉如何通過套接字發送數據,比如使用 write 或者 send 方法來進行數據流的發送。調用這些接口並不意味着數據被真正發送到網絡上,其實,這些數據只是從應用程序中被拷貝到了系統內核的套接字緩衝區中,或者說是發送緩衝區中,等待協議棧的處理。至於這些數據是什麼時候被髮送出去的,對應用程序來說,是無法預知的。這裏的協議棧就是,運行於操作系統內核的 TCP 協議棧實現模塊。

TCP 的生產者 - 消費者模型

發送窗口和接收窗口是 TCP 連接的雙方,一個作爲生產者,一個作爲消費者,爲了達到一致協同的生產 - 消費速率、而產生的算法模型實現。說白了,作爲 TCP 發送端,也就是生產者,不能忽略 TCP 的接收端,也就是消費者的實際狀況,不管不顧地把數據包都傳送過來。如果都傳送過來,消費者來不及消費,必然會丟棄;而丟棄反過使得生產者又重傳,發送更多的數據包,最後導致網絡崩潰。

理解了“TCP 的生產者 - 消費者”模型,再反過來看發送窗口和接收窗口的設計目的和方式,我們就會恍然大悟了。

擁塞控制和數據傳輸

TCP 的生產者 - 消費者模型,只是在考慮單個連接的數據傳遞,但是, TCP 數據包是需要經過網卡、交換機、核心路由器等一系列的網絡設備的,網絡設備本身的能力也是有限的,當多個連接的數據包同時在網絡上傳送時,勢必會發生帶寬爭搶、數據丟失等,這樣,TCP 就必須考慮多個連接共享在有限的帶寬上,兼顧效率和公平性的控制,這就是擁塞控制的本質。

在 TCP 協議中,擁塞控制是通過擁塞窗口來完成的,擁塞窗口的大小會隨着網絡狀況實時調整。

擁塞控制常用的算法有“慢啓動”,它通過一定的規則,慢慢地將網絡發送數據的速率增加到一個閾值。超過這個閾值之後,慢啓動就結束了,另一個叫做“擁塞避免”的算法登場。在這個階段,TCP 會不斷地探測網絡狀況,並隨之不斷調整擁塞窗口的大小。

現在你可以發現,在任何一個時刻,TCP 發送緩衝區的數據是否能真正發送出去,至少取決於兩個因素,一個是當前的發送窗口大小,另一個是擁塞窗口大小,而 TCP 協議中總是取兩者中最小值作爲判斷依據

這裏千萬要分清楚發送窗口和擁塞窗口的區別。

發送窗口反應了作爲單 TCP 連接、點對點之間的流量控制模型,它是需要和接收端一起共同協調來調整大小的;而擁塞窗口則是反應了作爲多個 TCP 連接共享帶寬的擁塞控制模型,它是發送端獨立地根據網絡狀況來動態調整的

一些有趣的場景

前面的表述中,提到了在任何一個時刻裏,TCP 發送緩衝區的數據是否能真正發送出去,用了“至少兩個因素”這個說法,那麼除了之前引入的發送窗口、擁塞窗口之外,還有什麼其他因素嗎?

我們考慮以下幾個有趣的場景:

第一個場景,接收端處理得急不可待,比如剛剛讀入了 100 個字節,就告訴發送端:“喂,我已經讀走 100 個字節了,你繼續發”,在這種情況下,你覺得發送端應該怎麼做呢?

第二個場景,是所謂的“交互式”場景,比如我們使用 telnet 登錄到一臺服務器上,或者使用 SSH 和遠程的服務器交互,這種情況下,我們在屏幕上敲打了一個命令,等待服務器返回結果,這個過程需要不斷和服務器端進行數據傳輸。這裏最大的問題是,每次傳輸的數據可能都非常小,比如敲打的命令“pwd”,僅僅三個字符。這意味着什麼?這就好比,每次叫了一輛大貨車,只送了一個小水壺。在這種情況下,你又覺得發送端該怎麼做才合理呢?

第三個場景,是從接收端來說的。我們知道,接收端需要對每個接收到的 TCP 分組進行確認,也就是發送 ACK 報文,但是 ACK 報文本身是不帶數據的分段,如果一直這樣發送大量的 ACK 報文,就會消耗大量的帶寬。之所以會這樣,是因爲 TCP 報文、IP 報文固有的消息頭是不可或缺的,比如兩端的地址、端口號、時間戳、序列號等信息, 在這種情形下,你覺得合理的做法是什麼?

TCP 之所以複雜,就是因爲 TCP 需要考慮的因素較多。像以上這幾個場景,都是 TCP 需要考慮的情況,一句話概況就是如何有效地利用網絡帶寬。

第一個場景也被叫做糊塗窗口綜合症,這個場景需要在接收端進行優化。也就是說,接收端不能在接收緩衝區空出一個很小的部分之後,就急吼吼地向發送端發送窗口更新通知,而是需要在自己的緩衝區大到一個合理的值之後,再向發送端發送窗口更新通知。這個合理的值,由對應的 RFC 規範定義。

第二個場景需要在發送端進行優化。這個優化的算法叫做 Nagle 算法,Nagle 算法的本質其實就是限制大批量的小數據包同時發送,爲此,它提出,在任何一個時刻,未被確認的小數據包不能超過一個。這裏的小數據包,指的是長度小於最大報文段長度 MSS 的 TCP 分組。這樣,發送端就可以把接下來連續的幾個小數據包存儲起來,等待接收到前一個小數據包的 ACK 分組之後,再將數據一次性發送出去。

第三個場景,也是需要在接收端進行優化,這個優化的算法叫做延時 ACK。延時 ACK 在收到數據後並不馬上回復,而是累計需要發送的 ACK 報文,等到有數據需要發送給對端時,將累計的 ACK捎帶一併發送出去。當然,延時 ACK 機制,不能無限地延時下去,否則發送端誤認爲數據包沒有發送成功,引起重傳,反而會佔用額外的網絡帶寬。

禁用 Nagle 算法

通過上面的講述,發現一個很奇怪的組合,即 Nagle 算法和延時 ACK 的組合。這個組合爲什麼奇怪呢?我舉一個例子你來體會一下。

比如,客戶端分兩次將一個請求發送出去,由於請求的第一部分的報文未被確認,Nagle 算法開始起作用;同時延時 ACK 在服務器端起作用,假設延時時間爲 200ms,服務器等待 200ms 後,對請求的第一部分進行確認;接下來客戶端收到了確認後,Nagle 算法解除請求第二部分的阻止,讓第二部分得以發送出去,服務器端在收到之後,進行處理應答,同時將第二部分的確認捎帶發送出去。

                                                          

Nagle 算法和延時確認組合在一起,增大了處理時延,實際上,兩個優化彼此在阻止對方。因此,在有些情況下 Nagle 算法並不適用, 比如對時延敏感的應用。不過,我們可以通過對套接字的修改來關閉 Nagle 算法。

int on = 1; 
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&on, sizeof(on)); 

值得注意的是,除非我們對此有十足的把握,否則不要輕易改變默認的 TCP Nagle 算法。因爲在現代操作系統中,針對 Nagle 算法和延時 ACK 的優化已經非常成熟了,有可能在禁用 Nagle 算法之後,性能問題反而更加嚴重。

將寫操作合併

前面的例子裏,如果我們能將一個請求一次性發送過去,而不是分開兩部分獨立發送,結果會好很多。所以,在寫數據之前,將數據合併到緩衝區,批量發送出去,這是一個比較好的做法。不過,有時候數據會存儲在兩個不同的緩存中,對此,我們可以使用如下的方法來進行數據的讀寫操作,從而避免 Nagle 算法引發的副作用。

ssize_t writev(int filedes, const struct iovec *iov, int iovcnt)
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);

這兩個函數的第二個參數都是指向某個 iovec 結構數組的一個指針,其中 iovec 結構定義如下:

struct iovec {
void *iov_base; /* starting address of buffer */
size_t iov_len; /* size of buffer */
};

下面的程序展示了集中寫的方式:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MESSAGE_SIZE 10240000

void send_data(int sockfd) {
        char buf[128];
        struct iovec iov[2];
        char *send_one = "hello,";
        iov[0].iov_base = send_one;
        iov[0].iov_len = strlen(send_one);
        iov[1].iov_base = buf;
        while (fgets(buf, sizeof(buf), stdin) != NULL) {
                iov[1].iov_len = strlen(buf);
                int n = htonl(iov[1].iov_len);
                if (writev(sockfd, iov, 2) < 0){
                        fprintf(stderr, "writev failure\n");
                        exit(0);
                }
        }
        return;
}

int main()
{
        int sockfd;
        int connect_rt;
        struct sockaddr_in serv_addr;

        sockfd = socket(PF_INET, SOCK_STREAM, 0);
        serv_addr.sin_family = AF_INET;
        serv_addr.sin_port = htons(7878);
        inet_pton(AF_INET, "192.168.133.131", &serv_addr.sin_addr);

        connect_rt = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
        if (connect_rt < 0)
        {
                fprintf(stderr, "Connect failed !\n");
                exit(0);
        }
        send_data(sockfd);
        return 0;
}

使用了 iovec 數組,分別寫入了兩個不同的字符串,一個是“hello,”,另一個通過標準輸入讀入。在啓動該程序之前,我們需要啓動服務器端程序,在客戶端依次輸入“world”和“network”:

world
network

接下來我們可以看到服務器端接收到了 iovec 組成的新的字符串。這裏的原理其實就是在調用 writev 操作時,會自動把幾個數組的輸入合併成一個有序的字節流,然後發送給對端。

received 12 bytes: hello,world
received 14 bytes: hello,network

總結:

發送窗口用來控制發送和接收端的流量;

擁塞窗口用來控制多條連接公平使用的有限帶寬。

小數據包加劇了網絡帶寬的浪費,爲了解決這個問題,引入瞭如 Nagle 算法、延時 ACK 等機制。

在程序設計層面,不要多次頻繁地發送小報文,如果有,可以使用 writev 批量發送。

 

溫故而知新 !

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章