Socket緩衝區過小觸發TCP Nagle's algorithm算法導致網絡延遲大

在遊戲服務器當中,通常都會爲每個客戶端鏈接設置一個緩衝區。這樣做的理由是遊戲中通常會有持續不斷,零碎的數據包發送到客戶端,使用一個緩衝區可以把這些數據包攢到一起發送,避免頻繁的io操作;另一個原因是,處理遊戲邏輯的線程通常和io操作的線程是分開的,因此遊戲邏輯線程把數據放到緩衝區後可以繼續處理後續的邏輯,數據的收發交給io線程。

我自己設計的服務器,在早期的版本中,Socket的緩衝區是採用了一個大小可變緩衝區。即每個Socket創建時,需要上層邏輯根據Socket的類型指定緩衝區的上限,例如客戶端Socket上限爲64kb,服務器與服務器之間的上限爲64M。然後給socket初始化一個8kb的緩衝區,當緩衝區滿並且未達到上限時,通過memcpy切換到一個更大的緩衝區,如16kb,接着是32kb,64kb...,只增不減,直到達到上限。這樣設計的優點是收(recv)、發(send),還是(通過protobuf)解析數據包,,由於永遠只有一個緩衝區,數據在內存上是連續的,那這些操作都可以一次完成。缺點也十分明顯,這緩衝區是可變的,意味着我需要設計一個大小可變的內存池,類似於boost的ordered_malloc和ordered_free,並且由於只增不減,浪費的內存也相對嚴重。

在後續版本的優化當中,我認爲針對遊戲服務器而言,一個健康的服務器socket的緩衝區中不應該有太多的數據。即可以在Socket中緩存64M的數據,但對於遊戲服務器而言,單個請求數據量往往很小,64M數據意味着上百萬個請求被阻塞,這服務器已經卡到可以關服了,沒什麼意義了。因此我決定改掉這個複雜又浪費內存單個緩衝區設計,轉而採用典型的鏈表結構,即緩衝區1-->緩衝區2-->緩衝區3-->null。第一個緩衝區滿,再申請一個同樣大小新的緩衝區鏈到鏈表尾部,當緩衝區用完時,依次釋放到內存池。這種設計緩衝區的大小是固定的,設計簡單可靠,通過調整單個緩衝區的大小,可以大大地提高緩衝區利用率。而隨之而來的缺點是由於數據在內存上不是連續的,收(recv)、發(send)只能分多次進行,(通過protobuf)解析數據包時,由於數據不連續,需要把數據拷貝到一個足夠大,連續的緩衝區才能進行解析。不過這些缺點只要單個緩衝區的大小配置得當,都是極少出現的,因此是可以接受的。

優化完成後,重新進行了測試,測試的方式也很簡單

客戶端每秒發送一個ping數據包(裏面包含一個巨大的隨機字符串) --->>> 服務器網關進程收到數據包 --->>> 網關進程向其他進程(AREA1、AREA2、WORLD)發起ping
                                                                                                         |
                                                                                                         |
                                                                                                         ▼
客戶端收到ping返回,校驗字符串是否完整,並校驗延遲是否在預期範圍 <<<---  服務器網關進程返回數據包  <<<---  網關進程收到其他進程(AREA1、AREA2、WORLD)返回的ping數據包,並記錄延遲

測試結果表示字符串是完整的,說明緩衝區的設計基本沒有問題。但發現了一個令我十分不解的問題,那就是延遲實在是太大了。

[A1LP05-06 22:03:28]ping	android_65537	29	1	42225
[A1LP05-06 22:03:30]ping	android_65537	30	85	19149
[A1LP05-06 22:03:30]     latency too large	AREA(I2.S1)	85
[A1LP05-06 22:03:30]     latency too large	AREA(I1.S1)	85
[A1LP05-06 22:03:30]     latency too large	WORLD(I1.S1)	85
[A1LP05-06 22:03:30]ping	android_65537	31	0	47831
[A1LP05-06 22:03:31]ping	android_65537	32	42	8181
[A1LP05-06 22:03:31]     latency too large	AREA(I2.S1)	42
[A1LP05-06 22:03:31]     latency too large	AREA(I1.S1)	42
[A1LP05-06 22:03:31]     latency too large	WORLD(I1.S1)	42

從日誌上看ping android_65537 29 1 42225表示機器人android_65537,ping的序列爲29,延遲爲1ms,用於校驗的字符串長度爲42225byte。可以看到這個延遲十分的不穩定,有時候很小(<3ms),有時候很大(>80ms),有時候中等(~=40ms)。我一度以爲程序哪裏出了問題,比如是不是出現了鎖競爭,epoll_wait是不是傳錯參數了,線程的喚醒是不是不及時。經過一番排查,沒有發現明顯的錯誤,進行一些嘗試後發現幾個特點:

  1. 使用大緩衝區後,延遲消失
    我把單個緩衝區改得很大(80kb,遠超過ping包的大小),延遲變得正常,大約只有1~2ms的延遲。嘗試客戶端使用大緩衝區,服務器使用小緩衝區,或者反過來,服務器用大緩衝區,服務器使用小緩衝區,延遲小的概率稍微變大,但出現不正常延遲的情況還是居多

  2. 使用std::clock函數統計不到延遲(<1ms),用std::chrono::steady_clock則可以

  3. windows 10下執行,沒有延遲。linux下(debian 10,跑在VirtualBox虛擬機)有延遲
    排除WSAPollepoll差異的原因,懷疑是VirtualBox的網絡有問題,但查不到相關資料

繼續排查,分別把客戶端和服務器epoll_wait和send、recv時的std::chrono::system_clock打印出來。由於客戶端和服務器運行在同一個系統,因此它們打印的system_clock時間是可以對比的。對比時間後發現,客戶端send成功後,有時候服務器的epoll_wait需要40ms左右纔會被喚醒;同樣的服務器send成功後,客戶端也有概率延遲喚醒。查了一堆資料,終於查到這是TCP觸發了Nagle's algorithm算法,這個延遲是一個正常的延遲。

簡單來講,Nagle's algorithm算法是一個用於提高tcp發送小包效率的算法。即多次往tcp發送少量數據時(多次調用::send)函數時,tcp會把要發送的數據緩存起來,攢成一個大的數據包,等超時(一般是40ms)或者收到對端的tcp ack再真正發送出去。具體的算法較爲複雜,這裏不細說,參考其他資料即可。

而我設計的這兩個版本的收發操作分別爲:

舊版本,單一的大緩衝區

// 發送
int bytes = ::send(fd, buffer.data, buffer.len);

// 接收
int bytes = ::recv(fd, buffer.data, buffer.len);

新版本,鏈表結構的多個小緩衝區

// 發送
while (true)
{
    size_t len = 0;
    const char *data = buffer.next(len);
    if (!data) break; // 整個鏈表發送完成

    int bytes = ::send(fd, data, len);

    // ... 異常及返回值處理
}

// 接收
while (true)
{
    size_t len = 0;
    const char *data = buffer.reserve(len);
    if (!data) break;

    int bytes = ::recv(fd, data, len);

    // ... 異常及返回值處理
}

在舊版本中,只有一個send操作,因此不會觸發這個算法。而在新版本中,需要多次調用send函數,那tcp的Nagle's algorithm算法會認爲還有後續數據要發送,因此一直在攢數據。但這個算法它實際上是無法知道具體的業務邏輯到底還有沒有數據要發送的,所以它只能等到超時發送,這樣延遲就來了。要解決這個問題,關閉Nagle's algorithm算法即可,關閉的方式就是設置TCP_NODELAY這個標識。

int32_t Socket::set_nodelay(int32_t fd)
{
    /**
     * 默認是不開啓NODELAY選項的,在Nagle's algorithm算法下,tcp可能會緩存數據包大約
     * 40ms,如果雙方都未啓用NODELAY,那麼數據一來一回可能會有80ms的延遲
     */
#ifdef CONF_TCP_NODELAY
    #ifdef __windows__
    DWORD optval = 1;
    return setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (char *)&optval,
                      sizeof(optval));
    #else
    int optval = 1;
    return setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (void *)&optval,
                      sizeof(optval));
    #endif
#else
    return 0;
#endif
}

啓用TCP_NODELAY後,延遲基本都在1ms以下,屬於正常範圍了

[A1LP05-06 23:07:35]ping	android_65537	1	1	27556
[A1LP05-06 23:07:36]ping	android_65537	2	1	57353
[A1LP05-06 23:07:37]ping	android_65537	3	0	10809
[A1LP05-06 23:07:38]ping	android_65537	4	0	10786
[A1LP05-06 23:07:39]ping	android_65537	5	1	38473
[A1LP05-06 23:07:40]ping	android_65537	6	1	47318
[A1LP05-06 23:07:41]ping	android_65537	7	0	5877
[A1LP05-06 23:07:42]ping	android_65537	8	1	38044
[A1LP05-06 23:07:43]ping	android_65537	9	1	50385
[A1LP05-06 23:07:44]ping	android_65537	10	1	60623

現在回到我的程序中,幾個疑點都有了答案:

  1. 延遲十分的不穩定,有時候很小(<3ms),有時候很大(>80ms),有時候中等(~=40ms)
    由於ping數據的大小是隨機的,因此有時候沒有觸發Nagle's algorithm算法,所以會出現沒有延遲的地方。假如客戶端發送數據、服務器返回數據都觸發了這個算法,那麼延遲大約就是40 + 40 = 80ms左右。如果只有一端觸發,那就是40ms左右

  2. 使用大緩衝區後,延遲消失
    大緩衝區不會觸發這個算法,所以沒有延遲

  3. 使用std::clock函數統計不到延遲(<1ms),用std::chrono::steady_clock則可以
    std::clock函數統計的是當前進程CPU運行的時間,它不包括epoll_wait、sleep等時間,而std::chrono::steady_clock是統計當前過了多少時間

  4. windows 10下執行,沒有延遲
    這個我並沒有解決,可能是win下默認開啓了TCP_NODEALY,也可能是默認啓用TCP_QUICKACK,但我並沒有查到資料,這個後面再繼續查了。

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