返回 登录
0

从UDP的连接性说起——告知你不为人知的UDP

阅读7702

声明:本文来自腾讯增值产品部官方公众号小时光茶社,为CSDN原创投稿,未经许可,禁止任何形式的转载。
作者:黄日成,手Q游戏中心后台开发,腾讯高级工程师。从事C++服务后台开发4年多,主要负责手Q游戏中心后台基础系统、复杂业务系统开发,主导过手Q游戏公会、企鹅电竞App-对战系统等项目的后台系统设计,有丰富的后台架构经验。
责编:钱曙光,关注架构和算法领域,寻求报道或者投稿请发邮件qianshg@csdn.net,另有「CSDN 高级架构师群」,内有诸多知名互联网公司的大牛架构师,欢迎架构师加微信qianshuguangarch申请入群,备注姓名+公司+职位。

引言

作为文章《从TCP三次握手说起–浅析TCP协议中的疑难杂症》的姊妹篇,很早就计划写篇关于UDP的文章,尽管UDP协议远没TCP协议那么庞大、复杂,但是要想将UDP描述清楚,用好UDP却要比TCP难不少,于是文章从下笔写,到最终写成,断断续续拖了好几个月。

说起网络socket,大家自然会想到TCP,用得最多的也是TCP,UDP在大家的印象中是作为TCP的补充而存在,是无连接、不可靠、无序、无流量控制的传输层协议。UDP的无连接性已经深入人心,协议上的无连接性指的是一个UDP的Endpoint1(IP,PORT),可以向多个UDP的Endpointi(IP,PORT)发送数据包,也可以接收来自多个UDP的Endpointi(IP,PORT)的数据包。实现上,考虑这样一个特殊情况:UDP Client在Endpoint_C1只往UDP Server的Endpoint_S1发送数据包,并且只接收来自Endpoint_S1的数据包,把UDP通信双方都固定下来,这样不就形成一条单向的虚“连接”了么?

1. UDP的“连接性”

估计很多同学认为UDP的连接性只是将UDP通信双方都固定下来了,一对一只是多对多的一个特例而已,这样UDP连接不连接倒无所谓了。果真如此吗?其实不然,UDP的连接性可以带来以下两个好处:

1.1 高效率、低消耗

我们知道Linux系统有用户空间(用户态)和内核空间(内核态)之分,对于X86处理器以及大多数其它处理器,用户空间和内核空间之间的切换比较耗时(涉及到上下文的保存和恢复,一般3种情况下会发生用户态到内核态的切换:发生系统调用时、产生异常时、中断时)。那么对于一个高性能的服务应该减少频繁不必要的上下文切换,如果切换无法避免,那么尽量减少用户空间和内核空间的数据交换,减少数据拷贝。熟悉socket编程的同学对下面几个系统调用应该比较熟悉了,由于UDP是基于用户数据报的,只要数据包准备好就应该调用一次send或sendto进行发包,当然包的大小完全由应用层逻辑决定。细看两个系统调用的参数便知道,sendto比send的参数多2个,这就意味着每次系统调用都要多拷贝一些数据到内核空间,同时,参数到内核空间后,内核还需要初始化一些临时的数据结构来存储这些参数值(主要是对端Endpoint_S的地址信息),在数据包发出去后,内核还需要在合适的时候释放这些临时的数据结构。进行UDP通信的时候,如果首先调用connect绑定对端Endpoint_S后,就可以直接调用send来给对端Endpoint_S发送UDP数据包。用户在connect之后,内核会永久维护一个存储对端Endpoint_S的地址信息的数据结构,内核不再需要分配/删除这些数据结构,只需要查找就可以了,从而减少了数据的拷贝。这样对于connect方而言,该UDP通信在内核已经维护这一个“连接”了,那么在通信的整个过程中,内核都能随时追踪到这个“连接”。

int connect(int socket, const struct sockaddr *address,
              socklen_t address_len);             
ssize_t send(int socket, const void *buffer, size_t length, int flags);
ssize_t sendto(int socket, const void *message, size_t length,
              int flags, const struct sockaddr *dest_addr,
              socklen_t dest_len);
ssize_t recv(int socket, void *buffer, size_t length, int flags);
ssize_t recvfrom(int socket, void *restrict buffer, size_t length,
              int flags, struct sockaddr *restrict address,
              socklen_t *restrict address_len);

1.2 错误提示

相信大家写UDP Socket程序的时候,有时在第一次调用sendto给一个unconnected UDP socket发送UDP数据包时,接下来调用recvfrom()或继续调sendto的时候会返回一个ECONNREFUSED错误。对于一个无连接的UDP是不会返回这个错误的,之所以会返回这个错误,是因为你明确调用了connect去连接远端的Endpoint_S了。那么这个错误是怎么产生的呢?没有调用connect的UDP Socket为什么无法返回这个错误呢?

当一个UDP Socket去connect一个远端Endpoint_S时,并没有发送任何数据包,其效果仅仅是在本地建立了一个五元组映射,对应到一个对端,该映射的作用正是为了和UDP带外的ICMP控制通道捆绑在一起,使得UDP Socket的接口含义更加丰满。这样内核协议栈就维护了一个从源到目的地的单向连接,当下层有ICMP(对于非IP协议,可以是其它机制)错误信息返回时,内核协议栈就能够准确知道该错误是由哪个用户Socket产生的,这样就能准确将错误转发给上层应用了。对于下层是IP协议的时候,ICMP错误信息返回时,ICMP的包内容就是出错的那个原始数据包,根据这个原始数据包可以找出一个五元组,根据该五元组就可以对应到一个本地connect过的UDP Socket,进而把错误消息传输给该socket,应用程序在调用socket接口函数的时候,就可以得到该错误消息了。

对于一个无“连接”的UDP,sendto系统调用后,内核在将数据包发送出去后,就释放了存储对端Endpoint_S的地址等信息的数据结构了,这样在下层的协议有错误返回时,内核已经无法追踪到源socket了。

这里有个注意点要说明一下,由于UDP和下层协议都是不可靠协议,所以,不能总是指望能够收到远端回复的ICMP包,例如:中间的一个节点或本机禁掉了ICMP,Socket API调用就无法捕获这些错误了。

2. UDP的负载均衡

在多核(多CPU)的服务器中,为了充分利用机器CPU资源,TCP服务器大多采用accept/fork模式,TCP服务的MPM机制(multi processing module),不管是预先建立进程池,还是每到一个连接创建新线程/进程,总体都是源于accept/fork的变体。然而对于UDP却无法很好地采用PMP机制,由于UDP的无连接性、无序性,它没有通信对端的信息,不知道一个数据包的前置和后续,它没有很好的办法知道,还有没后续的数据包以及如果有的话,过多久才会来,会来多久,因此UDP无法为其预先分配资源。

2.1 端口重用SO_REUSEADDRSO_REUSEPORT

要进行多处理,就免不了要在相同的地址端口上处理数据,SO_REUSEADDR允许端口的重用,只要确保四元组的唯一性即可。对于TCP,在bind的时候所有可能产生四元组不唯一的bind都会被禁止(于是,IP相同的情况下,TCP套接字处于TIME_WAIT状态下的socket,才可以重复绑定使用);对于connect,由于通信两端中的本端已经明确了,那么只允许connect从来没connect过的对端(在明确不会破坏四元组唯一性的connect才允许发送SYN包);对于监听listen端,四元组的唯一性由connect端保证就OK了。

TCP通过连接来保证四元组的唯一性,一个connect请求过来,accept进程accept完这个请求后(当然不一定要单独accept进程),就可以分配socket资源来标识这个连接,接着就可以分发给相应的worker进程去处理该连接后续的事情了。这样就可以在多核服务器中,同时有多个worker进程来处理多个并发请求,从而达到负载均衡,CPU资源能够被充分利用。

UDP的无连接状态(没有已有对端的信息),使得UDP没有一个有效的办法来判断四元组是否冲突,于是对于新来的请求,UDP无法进行资源的预分配,于是多处理模式难以进行,最终只能“守株待兔“,UDP按照固定的算法查找目标UDP Socket,这样每次查到的都是UDP Socket列表固定位置的socket。UDP只是简单基于目的IP和目的端口来进行查找,这样在一个服务器上多个进程内创建多个绑定相同IP地址(SO_REUSEADDR),相同端口的UDP Socket,那么你会发现,只有最后一个创建的socket会接收到数据,其它的都是默默地等待,孤独地等待永远也收不到UDP数据。UDP这种只能单进程、单处理的方式将要破灭UDP高效的神话,你在一个多核的服务器上运行这样的UDP程序,会发现只有一个核在忙,其他CPU核心处于空闲状态。创建多个绑定相同IP地址,相同端口的UDP程序,只会起到容灾备份的作用,不会起到负载均衡的作用。

要实现多处理,那么就要改变UDP Socket查找的考虑因素,对于调用了connect的UDP Client而言,由于其具有了“连接”性,通信双方都固定下来了,那么内核就可以根据4元组完全匹配的原则来匹配。于是对于不同的通信对端,可以查找到不同的UDP Socket从而实现多处理。而对于server端,在使用SO_REUSEPORT选项(Linux 3.9以上内核),这样在进行UDP socket查找的时候,源IP地址和源端口也参与进来了,内核查找算法可以保证:

[1] 固定的四元组的UDP数据包总是查找到同一个UDP Socket
[2] 不同的四元组的UDP数据包可能会查找到不同的UDP Socket

这样对于不同client发来的数据包就能查找到不同的UDP Socket从而实现多处理。这样看来,似乎采用SO_REUSEADDRSO_REUSEPORT这两个socket选项并利用内核的socket查找算法,我们在多核CPU服务器上多个进程内创建多个绑定相同端口,相同IP地址的UDP Socket就能做到负载均衡充分利用多核CPU资源了。然而事情远没这么顺利、简单。

2.2 UDP Socket列表变化问题

通过上面我们知道,在采用SO_REUSEADDRSO_REUSEPORT这两个socket选项后,内核会根据UDP数据包的4元组来查找本机上的所有相同目的IP地址,相同目的端口的socket中的一个socket的位置,然后以这个位置上的socket作为接收数据的socket。那么要确保来至同一个Client Endpoint的UDP数据包总是被同一个socket来处理,就需要保证整个socket链表的socket所处的位置不能改变,然而,如果socket链表中间的某个socket挂了的话,就会造成socket链表重新排序,这样会引发问题。于是基本的解决方案是在整个服务过程中不能关闭UDP Socket(当然也可以全部UDP Socket都close掉,从新创建一批新的)。要保证这一点,我们需要所有的UDP Socket的创建和关闭都由一个master进行来管理,worker进程只是负责处理对于的网络IO任务,为此我们需要socket在创建的时候要带有CLOEXEC标志(SOCK_CLOEXEC)。

2.3 UDP和Epoll结合 - UDP的Accept模型

到此,为了充分利用多核CPU资源,进行UDP的多处理,我们会预先创建多个进程,每个进程都创建一个或多个绑定相同端口,相同IP地址(SO_REUSEADDRSO_REUSEPORT)的UDP Socket,这样利用内核的UDP Socket查找算法来达到UDP的多进程负载均衡。然而,这完全依赖于Linux内核处理UDP socket查找时的一个算法,我们不能保证其它的系统或者未来的Linux内核不会改变算法的行为;同时,算法的查找能否做到比较好的均匀分布到不同的UDP socket,(每个处理进程只处理自己初始化时候创建的那些UDP socket)负载是否均衡是个问题。于是,我们多么想给UPD建立一个accept模型,按需分配UDP Socket来处理。

在高性能Server编程中,对于TCP Server而已有比较成熟的解决方案,TCP天然的连接性可以充分利用epoll等高性能event机制,采用多路复用、异步处理的方式,哪个worker进程空闲就去accept连接请求来处理,这样就可以达到比较高的并发,可以极限利用CPU资源。然而对于UDP Server而言,由于整个Svr就一个UDP Socket,接收并响应所有的client请求,于是也就不存在什么多路复用的问题了。UDP Svr无法充分利用epoll的高性能event机制的主要原因是,UDP Svr只有一个UDP Socket来接收和响应所有client的请求。然而如果能够为每个client都创建一个socket并虚拟一个“连接”与之对应,这样不就可以充分利用内核UDP层的socket查找结果和epoll的通知机制了么。Server端具体过程如下:

1. UDP svr创建UDP socket fd,设置socket为REUSEADDR和REUSEPORT、同时bind本地地址local_addr
listen_fd = socket(PF_INET, SOCK_DGRAM, 0)
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt,sizeof(opt))
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt))
bind(listen_fd, (struct sockaddr *) &local_addr, sizeof(struct sockaddr))

2. 创建epoll fd,并将listen_fd放到epoll中 并监听其可读事件
epoll_fd = epoll_create(1000);
ep_event.events = EPOLLIN|EPOLLET;
ep_event.data.fd = listen_fd;
epoll_ctl(epoll_fd , EPOLL_CTL_ADD, listen_fd, &ep_event)
in_fds = epoll_wait(epoll_fd, in_events, 1000, -1);

3. epoll_wait返回时,如果epoll_wait返回的事件fd是listen_fd,调用recvfrom接收client第一个UDP包并根据recvfrom返回的client地址, 创建一个新的socket(new_fd)与之对应,设置new_fd为REUSEADDR和REUSEPORT、同时bind本地地址local_addr,然后connect上recvfrom返回的client地址
recvfrom(listen_fd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &client_len)
new_fd = socket(PF_INET, SOCK_DGRAM, 0)
setsockopt(new_fd , SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse))
setsockopt(new_fd , SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse))
bind(new_fd , (struct sockaddr *) &local_addr, sizeof(struct sockaddr));
connect(new_fd , (struct sockaddr *) &client_addr, sizeof(struct sockaddr)

4. 将新创建的new_fd加入到epoll中并监听其可读等事件
client_ev.events = EPOLLIN;
client_ev.data.fd = new_fd ;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_fd , &client_ev)

5. 当epoll_wait返回时,如果epoll_wait返回的事件fd是new_fd 那么就可以调用recvfrom来接收特定client的UDP包了
recvfrom(new_fd , recvbuf, sizeof(recvbuf), 0, (struct sockaddr *)&client_addr, &client_len)

通过上面的步骤,这样UDP Svr就能充分利用epoll的事件通知机制了。第一次收到一个新的client的UDP数据包,就创建一个新的UDP Socket和这个client对应,这样接下来的数据交互和事件通知都能准确投递到这个新的UDP socket fd了。

这里的UPD和epoll结合方案,有以下几个注意点:

[1] client要使用固定的IP和端口和server端通信,也就是client需要bind本地local address。

如果client没有bind本地local address,那么在发送UDP数据包的时候,可能是不同的Port了,这样如果server端的new_fd connect的是client的Port_CA端口,那么当Client的Port_CB端口的UDP数据包来到server时,内核不会投递到new_fd,相反是投递到listen_fd。由于需要bind和listen fd一样的IP地址和端口,因此SO_REUSEADDRSO_REUSEPORT是必须的。

[2] 要小心处理上面步骤3中connect返回前,Client已经有多个UDP包到达Server端的情况。

如果server没处理好这个情况,在connect返回前,有2个UDP包到达server端了,这样server会new出两个new_fd1new_fd2分别connect到client,那么后续的client的UDP到达server的时候,内核会投递UDP包给new_fd1new_fd2中的一个。

上面的UDP和epoll结合的accept模型有个不好处理的小尾巴(也就是上面的注意点[2]),这个小尾巴的存在其本质是UDP和4元组没有必然的对应关系,也就是UDP的无连接性。

2.3 UDP Fork模型——UDP accept模型之按需建立UDP处理进程

为了充分利用多核CPU(为简化讨论,不妨假设为8核),理想情况下,同时有8个工作进程在同时工作处理请求。于是我们会初始化8个绑定相同端口,相同IP地址(SO_REUSEADDRSO_REUSEPORT)的UDP Socket,接下来就靠内核的查找算法来达到client请求的负载均衡了。由于内核查找算法是固定的,于是,无形中所有的client被划分为8类,类型1的所有client请求全部被路由到工作进程1的UDP Socket由工作进程1来处理,同样类型2的client的请求也全部被工作进程2来处理。这样的缺陷是明显的,比较容易造成短时间的负载极端不均衡。

一般情况下,如果一个UDP包能够标识一个请求,那么简单的解决方案是每个UDP Socket n的工作进程n,自行fork出多个子进程来处理类型n的client的请求。这样每个子进程都直接recvfrom就OK了,拿到UDP请求包就处理,拿不到就阻塞。

然而,如果一个请求需要多个UDP包来标识的情况下,事情就没那么简单了,我们需要将同一个client的所有UDP包都路由到同一个工作子进程。为了简化讨论,我们将注意力集中在都是类型n的多个client请求UDP数据包到来的时候,我们怎么处理的问题,不同类型client的数据包路由问题交给内核了。这样,我们需要一个master进程来监听UDP Socket的可读事件,master进程监听到可读事件,就采用MSG_PEEK选项来recvfrom数据包,如果发现是新的Endpoit(ip、port)Client的UDP包,那么就fork一个新的进行来处理该Endpoit的请求。具体如下:

[1] master进程监听udp_socket_fd的可读事件:pfd.fd = udp_socket_fd;pfd.events = POLLIN; poll(pfd, 1, -1);
当可读事件到来,pfd.revents & POLLIN为true。探测一下到来的UDP包是否是新的client的UDP包:recvfrom(pfd.fd, buf, MAXSIZE, MSG_PEEK, (struct sockaddr *)pclientaddr, &addrlen);查找一下worker_list是否为该client创建过worker进程了。

[2] 如果没有查找到,就fork()处理进程来处理该请求,并将该client信息记录到worker_list中。查找到,那么continue,回到步骤[1]

[3] 每个worker子进程,保存自己需要处理的client信息pclientaddr。worker进程同样也监听udp_socket_fd的可读事件。poll(pfd, 1, -1);当可读事件到来,pfd.revents & POLLIN为true。探测一下到来的UDP包是否是本进程需要处理的client的UDP包:recvfrom(pfd.fd, buf, MAXSIZE, MSG_PEEK, (struct sockaddr *)pclientaddr_2, &addrlen);比较一下pclientaddrpclientaddr_2是否一致。

[4] 一致则,recvfrom(pfd.fd, buf, MAXSIZE, 0, &pclientaddr, &addrlen);,取出请求来出。否则回到步骤[3]

该fork模型很别扭,过多的探测行为,一个数据包来了,会“惊群”唤醒所有worker子进程,大家都去PEEK一把,最后只有一个worker进程能够取出UDP包来处理。同时到来的数据包只能排队被取出。更为严重的是,由于recvfrom的排他唤醒,可能会造成死锁。考虑下面一个场景:

假设有worker1、worker2、worker3、和master共四个进程都阻塞在poll调用上,client1的一个新的UDP包过来,这个时候,四个进程会被同时唤醒,worker1比较神速,赶在其他进程前将UPD包取走了(worker1可以处理client1的UDP包),于是其他三个进程的recvfrom扑空,它们worker2、worker3、和master按序全部阻塞在recvfrom上睡眠(worker2、worker3排在master前面先睡眠的)。这个时候,一个新client4的UDP包packet4到来,(由于recvfrom的排他唤醒)这个时候只有worker2会从recvfrom的睡眠中醒来,然而worker却不能处理该请求UDP包。如果没有新UDP包到来,那么packet4一直留在内核中,死锁了。之所以recv是排他的,是为了避免“承诺给一个进程”的数据被其他进程取走了。

通过上面的讨论,不管采用什么手段,UDP的accept模型总是那么别扭,总有一些无法自然处理的小尾巴。UDP的多路负载均衡方案不通用,不自然,其本因在于UPD的无连接性、无序性(无法标识数据的前续后继)。我们不知道client还在不在,于是难于决策虚拟的“连接”何时终止,以及何时结束掉fork出来的worker子进程(不能无限fork)。于是,在没有好的决策因素时,超时似乎是一个比较好的选择,毕竟当所有的裁决手段都失效的时候,一切都要靠时间来冲淡。

3. UDP疑难杂症

3.1 UDP的传输方式:面向报文

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

  • (1)UDP报文大小的影响因素,主要有以下3个

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

  • (2) UDP数据包最大长度

根据UDP协议,从UDP数据包的包头可以看出,UDP的最大包长度是2^16-1的个字节。由于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)。

  • (3) UDP数据包理想长度

理论上UDP报文最大长度是65507字节,实际上发送这么大的数据包效果最好吗?我们知道UDP是不可靠的传输协议,为了减少UDP包丢失的风险,我们最好能控制UDP包在下层协议的传输过程中不要被切割。相信大家都知道MTU这个概念。MTU最大传输单元,这个最大传输单元实际上和链路层协议有着密切的关系,EthernetII帧的结构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字节以内。

3.2 UDP数据包的发送和接收问题

  • (1) UDP的通信有界性

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

  • (2) UDP数据包的无序性和非可靠性

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

  • (3) UDP数据包的接收

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)将会阻塞。

  • (4) UDP包分片问题

如果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)将阻塞。

3.3 UDP丢包问题

在不考虑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丢包问题。但是,如果服务已经过载了,简单地增大缓冲区并不能解决问题,反而会造成滚雪球效应,造成请求全部超时,服务不可用。

  • [2] UDP Socket缓冲区过小造成的UDP丢包

如果Client发送的UDP报文很大,而socket缓冲区过小无法容下该UDP报文,那么该报文就会丢失。

  • [3] 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)来查到具体的进程。

3.4 UDP冗余传输

在外网通信链路不稳定的情况下,有什么办法可以降低UDP的丢包率呢?一个简单的办法来采用冗余传输的方式。如下图,一般采用较多的是延时双发,双发指的是将原本单发的前后连续的两个包合并成一个大包发送,这样发送的数据量是原来的两倍。这种方式提高丢包率的原理比较简单,例如本例的冗余发包方式,在偶数包全丢的情况下,依然能够还原出完整的数据,也就是在这种情况下,50%的丢包率,依然能够达到100%的数据接收。

图片描述

4. UDP真的比TCP要高效吗

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

4.1 影响UDP高效因素

  • (1) 无法智能利用空闲带宽导致资源利用率低

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

  • (2) 无法动态调整发包

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

  • (3) 改进UDP的成本较高

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

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

5. UDP的使用场合

5.1 通信实时性和持续性

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

[1] 封装带来的空间复杂度
[2] 缓存带来的时间复杂度

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

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

5.2 多点通信

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

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

5.3 UDP使用原则

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

[1] 实时性要求很高,并且几乎不能容忍重传

例子:NTP协议,实时音视频通信,多人动作类游戏中人物动作、位置

[2] TCP实在不方便实现多点传输的情况
[3] 需要进行NAT穿越
[4] 对网络状态很熟悉,确保UDP网络中没有氓流行为,疯狂抢带宽
[5] 熟悉UDP编程

【参考资料】

http://blog.csdn.net/dog250/article/details/6896949


编辑推荐:架构技术实践系列文章(部分):

评论