游戏服务器架构:如何避免缓存积累延迟
2022-08-17 19:52:25 【

不管使用 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,加密 等若干功能,那么最好的做法就是把他们实现成协议单元,然后串联起来成为协议栈,这样每一层可以 单独开发调试,需要时再进行串联,这是网络库成熟的写法。

为了方便数据再协议栈中高性能的传递,你可以选择实现类似 linux skbuf 的数据结构来管理各个数据包:

这样的数据结构方便在包的头部不断添加或者剥离数据,当数据包由最高层协议产生不断往下传递的过程是一个不断追加包头的过程,而数据接收回来,从最底层进入一直往上的过程是一个不断剥离包头的过程,使用 skbuf 数据结构利于避免追加/剥离包头时的频繁内存拷贝。


】【打印关闭】 【返回顶部
上一篇没有了 下一篇有效防御DDOS的八规则