告知你不为人知的 UDP —— 疑难杂症和使用

UDP 说来简单,但是一些细节你都清楚吗?

注:本文系全文转载,原文信息如下:

作者:贺嘉

链接:https://zhuanlan.zhihu.com/p/25622691

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

面向报文的传输方式决定了 UDP 的数据发送方式是一份一份的,也就是应用层交给 UDP 多长的报文,UDP 就照样发送,即一次发送一个报文。那么 UDP 的报文大小由哪些影响因素呢?UDP 数据包的理论长度是多少,合适的 UDP 数据包应该是多少呢?

  • UDP 协议本身,UDP 协议中有 16 位的 UDP 报文长度,那么 UDP 报文长度不能超过 $2^{16}=65536$.
  • 以太网(Ethernet)数据帧的长度,数据链路层的 MTU(最大传输单元)。
  • socket 的 UDP 发送缓存区大小

根据 UDP 协议,从 UDP 数据包的包头可以看出,UDP 的最大包长度是 $2^{16}$ 的个字节。由于 UDP 包头占 8 个字节,而在 IP 层进行封装后的 IP 包头占去 20 字节,所以这个是 UDP 数据包的最大理论长度是 $2^{16} - 1 - 8 - 20 = 65507$ 字节。

如果发送的数据包超过 65507 字节,send 或 sendto 函数会错误码 1(Operation not permitted,Message too long),当然啦,一个数据包能否发送 65507 字节,还和 UDP 发送缓冲区大小(Linux 下 UDP 发送缓冲区大小为:cat /proc/sys/net/core/wmem_default)相关,如果发送缓冲区小于 65507 字节,在发送一个数据包为 65507 字节的时候,send 或 sendto 函数会错误码 1(Operation not permitted,No buffer space available)。

理论上 UDP 报文最大长度是 65507 字节,实际上发送这么大的数据包效果最好吗?

  • 我们知道 UDP 是不可靠的传输协议,为了减少 UDP 包丢失的风险,我们最好能控制 UDP 包在下层协议的传输过程中不要被切割

    相信大家都知道 MTU 这个概念。MTU 最大传输单元,这个最大传输单元实际上和链路层协议有着密切的关系,Ethernet II 帧的结构 DMAC+SMAC+Type+Data+CRC 由于以太网传输电气方面的限制,每个以太网帧都有最小的大小 64 字节,最大不能超过 1518 字节,对于小于或者大于这个限制的以太网帧我们都可以视之为错误的数据帧,一般的以太网转发设备会丢弃这些数据帧。由于以太网 EthernetII 最大的数据帧是 1518 字节,除去以太网帧的帧头(DMAC 目的 MAC 地址 48bit=6Bytes+SMAC 源 MAC 地址 48bit=6Bytes+Type 域 2bytes)14Bytes 和帧尾 CRC 校验部分 4Bytes 那么剩下承载上层协议的地方也就是 Data 域最大就只能有 1500 字节这个值我们就把它称之为 MTU。

    在下层数据链路层最大传输单元是 1500 字节的情况下,要想 IP 层不分包,那么 UDP 数据包的最大大小应该是 1500 字节 – IP 头(20 字节) – UDP 头(8 字节) = 1472 字节。不过鉴于 Internet 上的标准 MTU 值为 576 字节,所以建议在进行 Internet 的 UDP 编程时,最好将 UDP 的数据长度控制在 (576-8-20)548 字节以内。

在阻塞模式下,UDP 的通信是以数据包作为界限的,即使 Server 端的缓冲区再大也要按照 Client 发包的次数来多次接收数据包,Server 只能一次一次的接收,Client 发送多少次,Server 就需接收多少次,即客户端分几次发送过来,服务端就必须按几次接收。

Client 依次发送 1、2、3 三个 UDP 数据包,Server 端先后调用 3 次接收函数,可能会依次收到 3、2、1 次序的数据包,收包可能是 1、2、3 的任意排列组合,也可能丢失一个或多个数据包。

Client 发送两次 UDP 数据,第一次 500 字节,第二次 300 字节,Server 端阻塞模式下接包,第一次 recvfrom( 1000 ),收到是 1000,还是 500,还是 300,还是其他?

  • 由于 UDP 通信的有界性,接收到只能是 500 或 300,又由于 UDP 的无序性和非可靠性,接收到可能是 300,也可能是 500,也可能一直阻塞在 recvfrom 调用上,直到超时返回(也就是什么也收不到)。

在假定数据包是不丢失并且是按照发送顺序按序到达的情况下,Server 端阻塞模式下接包,先后三次调用:recvfrom( 200),recvfrom( 1000),recvfrom( 1000),接收情况如何呢?

  • 由于 UDP 通信的有界性,第一次 recvfrom( 200)将接收第一个 500 字节的数据包,但是因为用户空间 buf 只有 200 字节,于是只会返回前面 200 字节,剩下 300 字节将丢弃。第二次 recvfrom( 1000)将返回 300 字节,第三次 recvfrom( 1000)将会阻塞。

如果 MTU 是 1500,Client 发送一个 8000 字节大小的 UDP 包,那么 Server 端阻塞模式下接包,在不丢包的情况下,recvfrom(9000)是收到 1500,还是 8000。如果某个 IP 分片丢失了,recvfrom(9000),又返回什么呢?

  • 根据 UDP 通信的有界性,在 buf 足够大的情况下,接收到的一定是一个完整的数据包,UDP 数据在下层的分片和组片问题由 IP 层来处理,提交到 UDP 传输层一定是一个完整的 UDP 包,那么 recvfrom(9000)将返回 8000。如果某个 IP 分片丢失,UDP 里有个 CRC 检验,如果包不完整就会丢弃,也不会通知是否接收成功,所以 UDP 是不可靠的传输协议,那么 recvfrom(9000)将阻塞。

在不考虑 UDP 下层 IP 层的分片丢失,CRC 检验包不完整的情况下,造成 UDP 丢包的因素有哪些呢?

  1. UDP socket 缓冲区满造成的 UDP 丢包
  • 通过 cat /proc/sys/net/core/rmem_defaultcat /proc/sys/net/core/rmem_max 可以查看 socket 缓冲区的缺省值和最大值。如果 socket 缓冲区满了,应用程序没来得及处理在缓冲区中的 UDP 包,那么后续来的 UDP 包会被内核丢弃,造成丢包。在 socket 缓冲区满造成丢包的情况下,可以通过增大缓冲区的方法来缓解 UDP 丢包问题。但是,如果服务已经过载了,简单的增大缓冲区并不能解决问题,反而会造成滚雪球效应,造成请求全部超时,服务不可用。
  1. UDP socket 缓冲区过小造成的 UDP 丢包
  • 如果 Client 发送的 UDP 报文很大,而 socket 缓冲区过小无法容下该 UDP 报文,那么该报文就会丢失。
  1. ARP 缓存过期导致 UDP 丢包
  • ARP 的缓存时间约 10 分钟,APR 缓存列表没有对方的 MAC 地址或缓存过期的时候,会发送 ARP 请求获取 MAC 地址,在没有获取到 MAC 地址之前,用户发送出去的 UDP 数据包会被内核缓存到 arp_queue 这个队列中,默认最多缓存 3 个包,多余的 UDP 包会被丢弃。

    被丢弃的 UDP 包可以从 /proc/net/stat/arp_cache 的最后一列的 unresolved_discards 看到。当然我们可以通过 echo 30 > /proc/sys/net/ipv4/neigh/eth1/unres_qlen 来增大可以缓存的 UDP 包。UDP 的丢包信息可以从 cat /proc/net/udp 的最后一列 drops 中得到,而倒数第四列 inode 是丢失 UDP 数据包的 socket 的全局唯一的虚拟 i 节点号,可以通过这个 inode 号结合 lsof(lsof -P -n | grep 25445445)来查到具体的进程。

在外网通信链路不稳定的情况下,有什么办法可以降低 UDP 的丢包率呢?

一个简单的办法来采用冗余传输的方式。一般采用较多的是延时双发,双发指的是将原本单发的前后连续的两个包合并成一个大包发送,这样发送的数据量是原来的两倍。这种方式提高丢包率的原理比较简单,例如本例的冗余发包方式,在偶数包全丢的情况下,依然能够还原出完整的数据,也就是在这种情况下,50% 的丢包率,依然能够达到 100% 的数据接收。

相信很多同学都认为 UDP 无连接,无需重传和处理确认,UDP 比较高效。然而 UDP 在大多情况下并不一定比 TCP 高效,TCP 发展至今天,为了适应各种复杂的网络环境,其算法已经非常丰富,协议本身经过了很多优化,如果能够合理配置 TCP 的各种参数选项,那么在多数的网络环境下 TCP 是要比 UDP 更高效的。

一个简单的事实是 UDP 并不会受到 MTU 的影响,MTU 只会影响下层的 IP 分片,对此 UDP 一无所知。在极端情况下,UDP 每次都是发小包,包是 MTU 的几百分之一,这样就造成 UDP 包的有效数据占比较小(UDP 头的封装成本);或者,UDP 每次都是发巨大的 UDP 包,包大小 MTU 的几百倍,这样会造成下层 IP 层的大量分片,大量分片的情况下,其中某个分片丢失了,就会导致整个 UDP 包的无效。由于网络情况是动态变化的,UDP 无法根据变化进行调整,发包过大或过小,从而导致带宽利用率低下,有效吞吐量较低。而 TCP 有一套智能算法,当发现数据必须积攒的时候,就说明此时不积攒也不行,TCP 的复杂算法会在延迟和吞吐量之间达到一个很好的平衡。

由于 UDP 没有确认机制,没有流量控制和拥塞控制,这样在网络出现拥塞或通信两端处理能力不匹配的时候,UDP 并不会进行调整发送速率,从而导致大量丢包。在丢包的时候,不合理的简单重传策略会导致重传风暴,进一步加剧网络的拥塞,从而导致丢包率雪上加霜。更加严重的是,UDP 的无秩序性和自私性,一个疯狂的 UDP 程序可能会导致这个网络的拥塞,挤压其他程序的流量带宽,导致所有业务质量都下降。

可能有同学想到针对 UDP 的一些缺点,在用户态做些调整改进,添加上简单的重传和动态发包大小优化。然而,这样的改进并比简单的,UDP 编程可是比 TCP 要难不少的,考虑到改造成本,为什么不直接用 TCP 呢?当然可以拿开源的一些实现来抄一下(例如:libjingle),或者拥抱一下 Google 的 QUIC 协议,然而,这些都需要不少成本的。

上面说了这么多,难道真的不该用 UDP 了吗?其实也不是的,在某些场景下,我们还是必须 UDP 才行的。那么 UDP 的较为合适的使用场景是哪些呢?

在分组交换通信当中,协议栈的成本主要表现在以下两方面:

  • 封装带来的空间复杂度
  • 缓存带来的时间复杂度

以上两者是对立影响的,如果想减少封装消耗,那么就必须缓存用户数据到一定量在一次性封装发送出去,这样每个协议包的有效载荷将达到最大化,这无疑是节省了带宽空间,带宽利用率较高,但是延时增大了。如果想降低延时,那么就需要将用户数据立马封装发出去,这样显然会造成消耗更多的协议头等消耗,浪费带宽空间。

因此,我们进行协议选择的时候,需要重点考虑一下空间复杂度时间复杂度间的平衡。

通信的持续性对两者的影响比较大,根据通信的持续性有两种通信类型:

  • 短连接通信
  • 长连接通信

对于短连接通信,一方面如果业务只需要发一两个包并且对丢包有一定的容忍度,同时业务自己有简单的轮询或重复机制,那么采用 UDP 会较为好些。在这样的场景下,如果用 TCP,仅仅握手就需要 3 个包,这样显然有点不划算,一个典型的例子是 DNS 查询。另一方面,如果业务实时性要求非常高,并且不能忍受重传,那么首先就是 UDP 了或者只能用 UDP 了,例如 NTP 协议,重传 NTP 消息纯属添乱(为什么呢?重传一个过期的时间包过来,还不如发一个新的 UDP 包同步新的时间过来)。如果 NTP 协议采用 TCP,撇开握手消耗较多数据包交互的问题,由于 TCP 受 Nagel 算法等影响,用户数据会在一定情况下会被内核缓存延后发送出去,这样时间同步就会出现比较大的偏差,协议将不可用。

对于一些多点通信的场景,如果采用有连接的 TCP,那么就需要和多个通信节点建立其双向连接,然后有时在 NAT 环境下,两个通信节点建立其直接的 TCP 连接不是一个容易的事情,在涉及 NAT 穿越的时候,UDP 协议的无连接性使得穿透成功率更高(原因详见:由于 UDP 的无连接性,那么其完全可以向一个组播地址发送数据或者轮转地向多个目的地持续发送相同的数据,从而更为容易实现多点通信。)

一个典型的场景是多人实时音视频通信,这种场景下实时性要求比较高,可以容忍一定的丢包率。比如:对于音频,对端连续发送 p1、p2、p3 三个包,另一端收到了 p1 和 p3,在没收到 p2 的保持 p1 的最后一个音(也是为什么有时候网络丢包就会听到嗞嗞嗞嗞嗞嗞…或者卟卟卟卟卟卟卟卟…重音的原因),等到到 p3 就接着播 p3 了,不需要也不能补帧,一补就越来越大的延时。对于这样的场景就比较合适用 UDP 了,如果采用 TCP,那么在出现丢包的时候,就可能会出现比较大的延时。

通常情况下,UDP 的使用范围是较小的,在以下的场景下,使用 UDP 才是明智的:

  • 实时性要求很高,并且几乎不能容忍重传。例子:NTP 协议,实时音视频通信,多人动作类游戏中人物动作、位置
  • TCP 实在不方便实现多点传输的情况
  • 需要进行 NAT 穿越
  • 对网络状态很熟悉,确保 UDP 网络中没有氓流行为,例如疯狂抢带宽
  • 熟悉 UDP 编程