IT技术博客大学习 共学习 共进步
全部 移动开发 后端 数据库 AI 算法 安全 DevOps 前端 设计 开发者

可靠 UDP 传输

云风的 BLOG 2016-03-12 22:55:19 累计浏览 4,160 次
本机暂存

   本文分三个部分:一,什么时候有可能采用 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_sendrudp_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 的大小左右。

同分类推荐文章

  1. 等了十年的 Go 链式管道,终于来了:seq 让你像写 Scala 一样写 Go (2026-06-25 18:38:18)
  2. Go 实验特性详解 (2026-06-21 10:05:27)
  3. amd64 微架构级别对 Go 程序性能提升多少? (2026-06-21 09:38:49)

查看更多 后端 文章 →

建议继续学习

  1. gen_tcp发送进程被挂起起因分析及对策 (累计阅读 37,821)
  2. TCP 的那些事儿(上) (累计阅读 22,696)
  3. 从输入 URL 到页面加载完成的过程中都发生了什么事情? (累计阅读 15,933)
  4. 自建DNS以防止GFW干扰 (累计阅读 13,125)
  5. 浅谈TCP优化 (累计阅读 11,081)
  6. 推荐一些socket工具,TCP、UDP调试、抓包工具 (累计阅读 10,843)
  7. 查看 Apache并发请求数及其TCP连接状态 (累计阅读 10,068)
  8. 推荐一些socket工具,TCP、UDP调试、抓包工具 (累计阅读 8,840)
  9. 关于 SOCKS 代理的远端 DNS 解析 (累计阅读 7,984)
  10. websocket 连接 C Server的尝试 (累计阅读 7,922)