可靠 UDP 傳輸

wuqingman 8年前發布 | 15K 次閱讀 UDP TCP

本文分三個部分:一,什么時候有可能采用 UDP 通訊而不是用 TCP 更好;二,一個可靠的 UDP 通訊模塊的 API 接口該如何設計;三,一個簡單的實現。


首先,我一直是非常反對在 UDP 協議上實現一個可靠傳輸協議的,即類似 TCP over UDP 的東西。

TCP 已經夠復雜了,幾乎不太可能重新設計的更好。如果用 UDP 再實現一個可靠傳輸協議,而表現的比 TCP 效果更好,那么多半只是在部分情況下的優勢;或是霸道的占用了過量的資源,而 TCP 在設計時則是很友好的,以整個網絡的通暢為更高準則的。

對于后者,我心里相當排斥。如果大家都想獨占網絡帶寬,那么只會讓每個人都無法獲得高質量通訊。

在網絡游戲,尤其是移動網絡上的網絡游戲制作圈里,不斷的有人期望基于 UDP 協議通訊來獲得更快的響應速度,而又想讓通訊流像 TCP 一般可靠。我也時常思考這個問題,到底該怎么做這件事?

如果基于 UDP 可以做的比 TCP 更好,那么一定是放棄了點 TCP 需要做到的東西。

一條路是寄希望于業務邏輯上允許信息丟失:比如,在同步狀態中,如果狀態是有實效性的,那么過期的狀態信息就是可丟失的。這需要每次或周期性的全量狀態信息同步,每個新的全量狀態信息都可以取代舊的信息。或者在同步玩家在場景中的位置時可以用這樣的策略。不過在實際操作中,我發現一旦允許中間狀態丟失,業務層將會特別難寫。真正可以全量同步狀態的場合也非常少。

那么,不允許信息丟失,但允許包亂序會不會改善? 一旦所有的包都一定能送達,即丟失的包會用某種機制重傳,那么事實上你同樣也可以保證次序。只需要和 TCP 一樣在每個包中加個序號即可。唯一有優勢的地方是,即使中間有包晚到了,業務層有可能先拿到后面的包處理。

什么情況下是包次序無關的呢?最常見的場合就是一問一答的請求回應。采用這種方式的, UDP 在互聯網上最為廣泛的應用,就是 DNS 查詢了。

在網絡狀況不好的時候,我們可以看到有時采用短連接反而能獲得比長連接更好的用戶體驗。不同的短連接互不影響,無所謂哪個回應先到達。如果某個請求超時,可以立刻重新建立一條新的短連接重發請求。這時,丟包重發其實是放在業務層來做了。而一問一答式的小數據量通訊,正是 TCP 的弱項:正常的 TCP 連接建立就需要三次交互,確定通訊完畢還需要四次交互。如果你建立一次通訊只為了傳輸很少量的一整塊數據,那么明顯是一種浪費。這也是為什么 google 的 QUIC 對傳統的 http over TCP 有改善的空間。

我的思考結論就是:在 UDP 協議之上,實現一個帶超時的請求回應機制,讓業務層負責超時重發,有可能取得比 TCP 通訊更好的效果。但其前提是:單個請求或回應的包不應該過大,最好不要超過一個 MTU ,在互聯網上大約是 500 多字節。


如果有需要在 UDP 上建立一個可靠通訊模塊,怎樣的 API 比較好呢?

看了幾個開源實現,我認為一個最糟糕的地方是,通訊模塊本身和 UDP 綁的太死,也就是這個模塊本身負責了 UDP 包的收發。

如果把這樣的開源庫簡單拿過來用倒是容易,但如果想整合入以有的網絡層就會相對困難。其實,建立一個可靠通訊協議,最主要解決的問題還是如果利用不可靠的數據傳輸,實現一個協議來達到可靠傳輸(保證次序不丟包)的問題。而使用怎樣的通訊 API 是次要的。

所以,我認為整個模塊應該只提供輸入和輸出數據包的接口,和網絡通訊 api 無關。

struct rudp_package {
     struct rudp_package *next;
     char *buffer;
     int sz;
};

struct rudp * rudp_new(int send_delay, int expired_time);
void rudp_delete(struct rudp *);

// return the size of new package, 0 where no new package
// -1 corrupt connection
int rudp_recv(struct rudp *U, char buffer[MAX_PACKAGE]);

// send a new package out
void rudp_send(struct rudp *U, const char *buffer, int sz);

// should call every frame with the time tick, or a new package is coming.
// return the package should be send out.
struct rudp_package * rudp_update(struct rudp *U, const void * buffer, int sz, int tick);

一般在網絡游戲或其它需要低延遲的應用中,我們都需要定期保持心跳,以檢查連接質量。所以必然會周期性的調用維持用的 api ,這和一般網絡應該是不同的。

這里提供了一個 rudp_update 的 api 要求業務層按時間周期調用,當然也可以在同一時間片內調用多次,用傳入的參數 tick 做區分。如果 tick 為 0 表示是在同一時間片內,不用急著處理數據,當 tick 大于 0 時,才表示時間流逝,這時可以合并上個時間周期內的數據集中處理。

rudp_update 的每次調用均可以傳入一個實際收到的 UDP 包(可以是一個完整的 UDP 包,也可以是一部分),這個包數據是一個黑盒子,業務層不必了解細節。它的編碼依賴對端采用的相同的 rudp 模塊。

每次調用都有可能輸出一系列需要發送出去的 UDP 包。這些數據包是由過去的 rudp_send 調用壓入的數據產生的,同時也包含了最近接收到的數據包中發現的,對端可能需要重傳的數據,以及在沒有通訊數據時插入的心跳包等。

總的來說,rudp_update 內部做了所有的可靠化通訊需要的數據組織工作。使用的人傳入從 UDP socket 上收到的數據(不包括數據加密或其它數據組織工作),并從中獲取需要發送到 UDP socekt 的數據。

而業務層的數據收發只需要調用 rudp_send 和 rudp_recv 即可。其中,rudp_recv 保證數據包按次序輸出;rudp_send 也并不真正發送這些數據包,而是堆積在 rudp 對象內,等待下一個時間片。

rudp_new 創建 rudp 對象時,有兩個參數可配置。send delay 表示數據累積多少個時間周期 tick 數才打包在一起發送。expired time 表示已發送的包至少保留多少個時間周期。和 TCP 不同,我們既然使用 udp 通訊,就是希望高響應速度,所以即使數據抱遲遲沒有送達,它們也不必保留太長時間,而只需要通知業務層異常即可。


我花了兩天時間設計一個可靠傳輸協議,并做了一個簡單的實現。

這兩天一共設計了三個版本,前兩個版本都因為過多考慮協議的緊湊性而導致了實現太復雜,而在我的實現超過 700 行 C 代碼后推翻重寫了。

最后一個實現出來的版本是這樣的:

通訊是雙向的,每邊都可以是數據生產方 P 或數據消費方 C。

每個邏輯包都有一個 16bit 的序號,從 0 開始編碼,如果超過 64K 則回到 0 。通訊過程中,如果收到一個數據包和之前的數據包 id 相差正負 32K ,則做一下更合理的調整。例如,如果之前收到的序號為 2 ,而下一個包是 FFFF ,則認為是 2 這個序號的前三個,而不是向后一個很遠的序號。

若干邏輯包可以打包在一個物理包內,但一個物理包盡可能的保證在 512 字節內,超過則分成多個包。但每個邏輯包都不會分拆在不同的物理包中。

如果需要生產方 P 重發一個特定序號的包,消費方 C 可以發起一個請求。多個請求可以打包在同一個物理包內,也可以和待發送的邏輯包打包在一起。

這里采用請求機制,而不是 TCP 那樣的確認機制,是因為在特定條件下,請求機制實現更簡單。正常網絡狀況下,無論是缺少包(發現收到的邏輯包序號不連續)再向對端請求,還是讓消費方 C 去確認收到了哪些包,生產方 P 發現未請求的包主動重發;都是極其稀少的事情,其差別可以忽略。

主要區別在于,采用請求重發機制要求 P 方盡可能的保留已發出的數據,正常通訊條件下, 缺少確認機制會導致 P 不敢隨意丟棄過去發出的數據。但在這里,我們可以依據超時來清理過期的數據,也就回避了這個問題。

除此之外,我們還需要在沒有數據時,有可以維持心跳的空包,以及發生異常時通知對方異常的機制。

最終,有四類固定格式的數據:

  • 0 心跳包
  • 1 連接異常
  • 2 請求包 (+2 id)
  • 3 異常包 (+2 id)

后兩種數據需要跟上兩字節的序號(采用大端編碼)

普通的數據包可以直接采取長度 + id + 數據的方式。

這五類數據均可以統一采用 tag + 數據的方式編碼。如果是前四種數據,就在 tag 部分直接編碼 0~3 ,如果是最后一種數據包,則將 tag 編碼為編碼 (數據長度 + 4)

tag 采用 1 或 2 字節編碼。如果 tag < 127 編碼為 1 字節,tag 是 128 到 32K 間時,編碼為兩字節;其中第一字節高位為 1 。tag 不能超過 32K 。

我在 github 上放了一個只經過非常簡單測試的代碼實現。https://github.com/cloudwu/rudp 僅供參考,真想拿去用的同學風險自負。好在實現并不復雜,只有 500+ 行 C 代碼,有 bug 也比較容易查。

注: 我定義了一個宏 GENERAL_PACKAGE ,為了測試方便定義為 128 。實際使用的時候應該調整為 MTU 的大小左右。

來源:http://blog.codingnow.com/2016/03/reliable_udp.html

 本文由用戶 wuqingman 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!