天天看點

遊戲伺服器架構:如何避免緩存積累延遲

不管使用 TCP 還是 KCP,你都不可能超越信道限制的發送資料。TCP 的發送視窗 SNDBUF 決定了最多可以同時發送多少資料,KCP的也一樣。

目前發送且沒有得到 ACK/UNA确認的資料,都會滞留在發送緩存中,一旦滞留資料超過了發送視窗大小限制,則該連結的 tcp send 調用将會 被阻塞,或者傳回:EAGAIN / EWOULDBLOCK,這時候說明目前 tcp 信道可用帶寬已經趕不上你的發送速度了。

可用帶寬 = min(本地可用發送視窗大小,遠端可用接收視窗大小) * (1 - 丢包率) / RTT      

當你持續調用 ikcp_send,首先會填滿kcp的 snd_buf,如果 snd_buf 的大小超過發送視窗 snd_wnd 限制,則會停止向 snd_buf 裡追加 資料包,隻會放在 snd_queue 裡面滞留着,等待 snd_buf 有新位置了(因為收到遠端 ack/una而将曆史包從 snd_buf中移除),才會從 snd_queue 轉移到 snd_buf,等待發送。

TCP發送視窗滿了不能發送了,會給你阻塞住或者 EAGAIN/EWOULDBLOCK;KCP發送視窗滿了,ikcp_send 并不會給你傳回 -1,而是讓資料滞留 在 snd_queue 裡等待有能力時再發送。

是以,千萬不要以為 ikcp_send 可以無節制的調用,為什麼 KCP在發送視窗滿的時候不傳回錯誤呢?這個問題當年設計時權衡過,如果傳回希望發送時傳回錯誤的 EAGAIN/EWOULDBLOCK 你勢必外層還需要建立一個緩存,等到下次再測試是否可以 send。那麼還不如 kcp直接把這一層緩存做了,讓上層更簡單些,而且具體要如何處理 EAGAIN,可以讓上層通過檢測 ikcp_waitsnd 函數來判斷還有多少包沒有發出去,靈活抉擇是否向 snd_queue 緩存追加資料包還是其他。

重設視窗大小

要解決上面的問題首先對你的使用帶寬有一個預計,并根據上面的公式重新設定發送視窗和接收視窗大小,你寫後端,想追求tcp的性能,也會需要重新設定tcp的 sndbuf, rcvbuf 的大小,KCP 預設發送視窗和接收視窗大小都比較小而已(預設32個包),你可以朝着 64, 128, 256, 512, 1024 等檔次往上調,kcptun預設發送視窗 1024,用來傳高清視訊已經足夠,遊戲的話,32-256 應該滿足。

不設定的話,如果預設 snd_wnd 太小,網絡不是那麼順暢,你越來越多的資料會滞留在 snd_queue裡得不到發送,你的延遲會越來越大。

設定了 snd_wnd,遠端的 rcv_wnd 也需要相應擴大,并且不小于發送端的 snd_wnd 大小,否則設定沒意義。

其次對于成熟的後端業務,不管用 TCP還是 KCP,你都需要實作相關緩存控制政策:

緩存控制:傳送檔案

你用 tcp傳檔案的話,當網絡沒能力了,你的 send調用要不就是阻塞掉,要不就是 EAGAIN,然後需要通過 epoll 檢查 EPOLL_OUT事件來決定下次什麼時候可以繼續發送。

KCP 也一樣,如果 ikcp_waitsnd 超過門檻值,比如2倍 snd_wnd,那麼停止調用 ikcp_send,ikcp_waitsnd的值降下來,當然期間要保持 ikcp_update 調用。

緩存控制:實時視訊直播

視訊點播和傳檔案一樣,而視訊直播,一旦 ikcp_waitsnd 超過門檻值了,除了不再往 kcp 裡發送新的資料包,你的視訊應該進入一個 “丢幀” 狀态,直到 ikcp_waitsnd 降低到門檻值的 1/2,這樣你的視訊才不會有積累延遲。

這和使用 TCP推流時碰到 EAGAIN 期間,要主動丢幀的邏輯時一樣的。

同時,如果你能做的更好點,waitsnd 超過門檻值了,代表一段時間内網絡傳輸能力下降了,此時你應該動态降低視訊品質,減少碼率,等網絡恢複了你再恢複。

緩存控制:遊戲控制資料

大部分邏輯嚴密的 TCP遊戲伺服器,都是使用無阻塞的 tcp連結配套個 epoll之類的東西,當後端業務向使用者發送資料時會追加到使用者空間的一塊發送緩存,比如 ring buffer 之類,當 epoll 到 EPOLL_OUT 事件時(其實也就是tcp發送緩存有空餘了,不會EAGAIN/EWOULDBLOCK的時候),再把 ring buffer 裡面暫存的資料使用 send 傳遞給系統的 SNDBUF,直到再次 EAGAIN。

那麼 TCP SERVER的後端業務持續向用戶端發送資料,而用戶端又遲遲沒能力接收怎麼辦呢?此時 epoll 會長期不傳回 EPOLL_OUT事件,資料會堆積再該使用者的 ring buffer 之中,如果堆積越來越多,ring buffer 會自增長的話就會把 server 的記憶體給耗盡。是以成熟的 tcp 遊戲伺服器的做法是:當用戶端應用層發送緩存(非tcp的sndbuf)中待發送資料超過一定門檻值,就斷開 TCP連結,因為該使用者沒有接收能力了,無法持續接收遊戲資料。

使用 KCP 發送遊戲資料也一樣,當 ikcp_waitsnd 傳回值超過一定限度時,你應該斷開遠端連結,因為他們沒有能力接收了。

但是需要注意的是,KCP的預設視窗都是32,比tcp的預設視窗低很多,實際使用時應提前調大視窗,但是為了公平性也不要無止盡放大(不要超過1024)。

總結

緩存積累這個問題,不管是 TCP還是 KCP你都要處理,因為TCP預設視窗比較大,是以可能很多人并沒有處理的意識。

當你碰到緩存延遲時:

  1. 檢查 snd_wnd, rcv_wnd 的值是否滿足你的要求,根據上面的公式換算,每秒鐘要發多少包,目前 snd_wnd滿足條件麼?
  2. 确認打開了 ikcp_nodelay,讓各項加速特性得以運轉,并确認 nc參數是否設定,以關閉預設的類 tcp保守流控方式。
  3. 确認 ikcp_update 調用頻率是否滿足要求(比如10ms一次)。

如果你還想更激進:

  1. 确認 minrto 是否設定,比如設定成 10ms, nodelay 隻是設定成 30ms,更激進可以設定成 10ms 或者 5ms。
  2. 确認 interval是否設定,可以更激進的設定成 5ms,讓内部始終循環更快。
  3. 每次發送完資料包後,手動調用 ikcp_flush
  4. 降低 mtu 到 470,同樣資料雖然會發更多的包,但是小包在路由層優先級更高。

如果你還想更快,可以在 KCP下層增加前向糾錯協定。

協定單元

一個純算法的 KCP對象,組成了一個幹淨獨立的協定單元:

kcp 的 input, output 方法用來對接下層的 udp 收發子產品。而 ikcp_send, ikcp_recv 提供給上層邏輯調用實作協定的收發。

協定組裝

不同的協定單元子產品可以串聯起來,比如:

假設你設計了一套 fec 協定,那麼可以把 kcp 的 input/output 和fec協定的 send/recv 串聯起來,使 kcp->output 被調用時,把kcp希望 發送的資料調用 fec 的 send 方法傳遞給 fec子產品,而從 fec 子產品 recv 到的資料再反向 input 給 kcp。

而原來直接和 kcp 接觸的 udp 傳輸層,就放到了 fec層下面,與 fec打交道,這樣就完成了協定組裝。

協定棧

你可能需要實作 UDP繪話管理,KCP,加密 等若幹功能,那麼最好的做法就是把他們實作成協定單元,然後串聯起來成為協定棧,這樣每一層可以 單獨開發調試,需要時再進行串聯,這是網絡庫成熟的寫法。