Skip to content

Flow Control for Users

来风 edited this page Aug 18, 2023 · 13 revisions

如何避免缓存积累延迟

不管使用 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下层增加前向纠错协议。详细见:协议分层最佳实践

更多见讨论记录:

https://github.com/skywind3000/kcp/issues/4

https://github.com/skywind3000/kcp/issues/93