【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议

【1】UDP 的部首

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第1张图片

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第2张图片

1. 源端口 :     源端口号, 需要对方回信时选用, 不需要时全部置 0
2. 目的端口 :     目的端口号,在终点交付报文的时候需要用到
3. 长度 : UDP 的数据报的长度 (包括首部和数据) 其最小值为 8 (只有首部)
4. 校验和 : 检测 UDP 数据报在传输中是否有错,有错则丢弃
该字段是可选的,当源主机不想计算校验和,则直接令该字段全为 0
当传输层从 IP 层收到 UDP 数据报时,就根据首部中的目的端口,把 UDP 数据报通过相应的端口,上交给应用进程;
如果接收方发现收到的 UDP 报文中的目的端口号不正确,就丢弃该报文,并由 ICMP 发送“端口不可达”差错报文给对方;

【2】UDP 的传输方式 -- 面向报文

面向报文的传输方式决定了 UDP 的数据发送方式是一份一份的,也就是应用层交给 UDP 多长的报文,UDP 就照样发送,即一次发送一个报文;

【2.1】UDP 报文大小的影响因素

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

【2.2】UDP数据包最大长度
根据 UDP 协议,从 UDP 数据包的包头可以看出,UDP 的最大包长度是2^16-1的个字节,由于UDP包头占8个字节,而在IP层进行封装后的IP包头占去20字节,所以这个是UDP数据包的最大理论长度是2^16 - 1 - 8 - 20 = 65507字节,如果发送的数据包超过65507字节,send或sendto函数会错误码(Operation not permitted, Message too long),实际上,一个数据包能否发送65507字节,还和UDP发送缓冲区大小(linux下UDP发送缓冲区大小为:cat /proc/sys/net/core/wmem_default)相关,如果发送缓冲区小于65507字节,在发送一个数据包为65507字节的时候,send或sendto函数会错误码(Operation not permitted, No buffer space available);

【2.3】UDP 数据包实际应用中的长度

1. 局域网环境下,建议将UDP数据控制在1472字节以下;
2. Internet编程时,建议将UDP数据控制在548字节以下;

【2.4】UDP 的"连接性"

1. 高效率、低消耗

Linux系统有用户空间(用户态)和内核空间(内核态),对于x86处理器以及大多数其它处理器,用户空间和内核空间之前的切换是比较耗时(涉及到上下文的保存和恢复,一般3种情况下会发生用户态到内核态的切换,发生系统调用时、产生异常时、中断时);那么对于一个高性能的服务应该减少频繁不必要的上下文切换,如果切换无法避免,那么尽量减少用户空间和内核空间的数据交换,减少数据拷贝,由于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);

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错误信息返回时,内核协议栈就能够准确知道该错误是由哪个用户socket产生的,这样就能准确将错误转发给上层应用了,对于下层是IP协议的时候,ICMP 错误信息返回时,ICMP 的包内容就是出错的那个原始数据包,根据这个原始数据包可以找出一个五元组,根据该五元组就可以对应到一个本地的connect过的UDP socket,进而把错误消息传输给该 socket,应用程序在调用socket接口函数的时候,就可以得到该错误消息了;
对于一个无“连接”的UDP,sendto系统调用后,内核在将数据包发送出去后,就释放了存储对端Endpoint_S的地址等信息的数据结构了,这样在下层的协议有错误返回的时候,内核已经无法追踪到源socket;
这里有个注意点要说明一下,由于UDP和下层协议都是不可靠的协议,所以,不能总是指望能够收到远端回复的ICMP包;

【3】UDP数据包的发送和接收问题

【3.1】UDP的通信有界性

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

【3.2】UDP数据包的无序性和非可靠性

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

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

【3.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)将阻塞;

【4】UDP丢包问题

造成UDP丢包的因素

【4.1】UDP socket缓冲区满造成的UDP丢包
通过 cat /proc/sys/net/core/rmem_default 和 cat /proc/sys/net/core/rmem_max可以查看socket缓冲区的缺省值和最大值,如果socket缓冲区满了,应用程序没来得及处理在缓冲区中的UDP包,那么后续来的UDP包会被内核丢弃,造成丢包,在socket缓冲区满造成丢包的情况下,可以通过增大缓冲区的方法来缓解UDP丢包问题,但是,如果服务已经过载了,简单的增大缓冲区并不能解决问题,反而会造成滚雪球效应,造成请求全部超时,服务不可用;
【4.2】UDP socket缓冲区过小造成的UDP丢包
如果Client发送的UDP报文很大,而socket缓冲区过小无法容下该UDP报文,那么该报文就会丢失;
【4.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)来查到具体的进程;

【5】影响 UDP 高效性的因素

(1) 无法智能利用空闲带宽导致资源利用率低
一个简单的事实是UDP并不会受到MTU的影响,MTU只会影响下层的IP分片,对此UDP一无所知,在极端情况下,UDP每次都是发小包,包是MTU的几百分之一,这样就造成UDP包的有效数据占比较小(UDP头的封装成本),或者,UDP每次都是发巨大的UDP包,包大小MTU的几百倍,这样会造成下层IP层的大量分片,大量分片的情况下,其中某个分片丢失了,就会导致整个UDP包的无效,由于网络情况是动态变化的,UDP无法根据变化进行调整,发包过大或过小,从而导致带宽利用率低下,有效吞吐量较低;
(2) 无法动态调整发包
由于UDP没有确认机制,没有流量控制和拥塞控制,这样在网络出现拥塞或通信两端处理能力不匹配的时候,UDP并不会进行调整发送速率,从而导致大量丢包,在丢包的时候,不合理的简单重传策略会导致重传风暴,进一步加剧网络的拥塞,从而导致丢包率雪上加霜,更加严重的是,UDP的无秩序性和自私性,一个疯狂的UDP程序可能会导致这个网络的拥塞,挤压其他程序的流量带宽,导致所有业务质量都下降;
(3) 改进UDP的成本较高

【6】UDP 适用的场景

【6.1】高通信实时性要求和低持续性要求的场景

在分组交换通信当中,协议栈的成本主要表现在以下两方面:
[1] 封装带来的空间复杂度;
[2] 缓存带来的时间复杂度;
以上两者是对立影响的,如果想减少封装消耗,那么就必须缓存用户数据到一定量在一次性封装发送出去,这样每个协议包的有效载荷将达到最大化,这无疑是节省了带宽空间,带宽利用率较高,但是延时增大了;如果想降低延时,那么就需要将用户数据立马封装发出去,这样显然会造成消耗更多的协议头等消耗,浪费带宽空间;
因此,我们进行协议选择的时候,需要重点考虑一下空间复杂度和时间复杂度间的平衡;

通信的持续性对两者的影响比较大,根据通信的持续性有两种通信类型
[1] 短连接通信;
[2] 长连接通信;
对于短连接通信,一方面如果业务只需要发一两个包并且对丢包有一定的容忍度,同时业务自己有简单的轮询或重复机制,那么采用UDP会较为好些;另一方面,如果业务实时性要求非常高,并且不能忍受重传,那么首先就是UDP了或者只能用UDP了;

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

【7】UDP 的负载均衡

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

【7.1】端口重用 : SO_REUSEADDR、SO_REUSEPORT

要进行多处理,就免不了要在相同的地址端口上处理数据,SO_REUSEADDR允许端口的重用,只要确保四元组的唯一性即可;对于TCP,在bind的时候所有可能产生四元组不唯一的bind都会被禁止(ip相同的情况下,TCP套接字处于TIME_WAIT状态下的socket,才可以重复绑定使用);对于connect,由于通信两端中的本端已经明确了,那么只允许connect从来没connect过的对端(在明确不会破坏四元组唯一性的connect才允许发送SYN包);对于监听listen端,四元组的唯一性由connect端保证;
TCP通过连接来保证四元组的唯一性,一个connect请求过来,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选项在进行UDP socket查找的时候,源IP地址和源端口也参与匹配,从而内核查找算法可以保证:

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

这样对于不同client发来的数据包就能查找到不同的UDP socket从而实现多处理,这样看来,似乎采用SO_REUSEADDR、SO_REUSEPORT这两个socket选项并利用内核的socket查找算法,在多核CPU服务器上多个进程内创建多个绑定相同端口,相同IP地址的UDP socket就能做到负载均衡,然而并非如此;

【7.2】UDP Socket 列表变化问题

采用SO_REUSEADDR、SO_REUSEPORT这两个socket选项后,内核会根据UDP数据包的4元组来查找本机上的所有相同目的IP地址,相同目的端口的socket中的一个socket的位置,然后以这个位置上的socket作为接收数据的socket,那么要确保来自同一个Client Endpoint的UDP数据包总是被同一个socket来处理,就需要保证整个socket链表的socket所处的位置不能改变,然而,如果socket链表中间的某个socket崩溃,就会造成socket链表重新排序,基本的解决方案是在整个服务过程中不能关闭UDP socket,要保证这一点,需要所有的UDP socket的创建和关闭都由一个master进行来管理,worker进程只是负责处理对于的网络IO任务,为此我们需要socket在创建的时候要带有CLOEXEC标志(SOCK_CLOEXEC);

【7.3】UDP 和 Epoll 结合,UDP 的 Accept 模型

为了充分利用多核CPU资源,进行UDP的多处理,会预先创建多个进程,每个进程都创建一个或多个绑定相同端口,相同IP地址(SO_REUSEADDR、SO_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的通知机制;

// 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);

注意点

[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_REUSEADDR和SO_REUSEPORT是必须的;
[2] 要小心处理上面步骤3中connect返回前,Client已经有多个UDP包到达Server端的情况
如果server没处理好这个情况,在connect返回前,有2个UDP包到达server端了,这样server会new出两个new_fd1和new_fd2分别connect到client,那么后续的client的UDP到达server的时候,内核会投递UDP包给new_fd1和new_fd2中的一个;

【7.4】UDP Fork 模型,UDP accept 模型之按需建立 UDP 处理进程

为了充分利用多核 CPU (为简化讨论,不妨假设为8核),理想情况下,同时有8个工作进程在同时工作处理请求,于是初始化8个绑定相同端口,相同IP地址(SO_REUSEADDR、SO_REUSEPORT)的 UDP socket ,接下来就靠内核的查找算法来达到client请求的负载均衡了,由于内核查找算法是固定的,于是,无形中所有的client被划分为8类,类型1的所有client请求全部被路由到工作进程1的UDP socket由工作进程1来处理,同样类型2的client的请求也全部被工作进程2来处理,这样的缺陷是明显的,比较容易造成短时间的负载极端不均衡;
一般情况下,如果一个 UDP 包能够标识一个请求,那么简单的解决方案是每个 UDP socket n 的工作进程 n,自行 fork 出多个子进程来处理类型n的 client 的请求,这样每个子进程都直接 recvfrom,拿到 UDP 请求包就处理,拿不到就阻塞;
然而,对于一个请求需要多个 UDP 包来标识的情况下,需要将同一个 client 的所有 UDP 包都路由到同一个工作子进程,这样,需要一个master进程来监听UDP socket的可读事件,master进程监听到可读事件,就采用MSG_PEEK选项来recvfrom数据包,如果发现是新的Endpoit(ip、port)Client的UDP包,那么就fork一个新的进行来处理该Endpoit的请求;
具体处理流程
[1]
[1.1] master进程监听udp_socket_fd的可读事件,pfd.fd = udp_socket_fd; pfd.events = POLLIN; poll(pfd, 1, -1);
[1.2] 当可读事件到来,pfd.revents & POLLIN 为true,
[1.3] 探测一下到来的UDP包是否是新的client的UDP包,
recvfrom(pfd.fd, buf, MAXSIZE, MSG_PEEK, (struct sockaddr *)pclientaddr,&addrlen);
[1.4] 查找一下worker_list是否为该client创建过worker进程了;
[2] 如果没有查找到,就fork()处理进程来处理该请求,并将该client信息记录到worker_list中,查找到,那么continue,回到步骤[1];
[3] 
[3.1] 每个worker子进程,保存自己需要处理的client信息pclientaddr;
[3.2] worker进程同样也监听udp_socket_fd的可读事件,poll(pfd, 1, -1);
[3.3] 当可读事件到来,pfd.revents & POLLIN 为true,
[3.4] 探测一下到来的UDP包是否是本进程需要处理的client的UDP包,
recvfrom(pfd.fd, buf, MAXSIZE, MSG_PEEK, (struct sockaddr * )pclientaddr_2, &addrlen);
[3.5] 比较一下pclientaddr和pclientaddr_2是否一致;
注意问题
该fork模型很别扭,过多的探测行为,一个数据包来了,会”惊群”唤醒所有worker子进程,大家都去PEEK一把,最后只有一个worker进程能够取出UDP包来处理,同时到来的数据包只能排队被取出,更为严重的是,由于recvfrom的排他唤醒,可能会造成死锁;

【8】RUDP(Reliable UDP)

保证 UDP 可靠性的必要性,在保证通信的时延和质量的条件下尽量降低成本;

【8.1】实时通信中的"可靠性"

三类可靠性定义

  • 尽力可靠,通信的接收方要求发送方的数据尽量完整到达,但业务本身的数据是可以允许缺失的;
  • 无序可靠,通信的接收方要求发送方的数据必须完整到达,但可以不管到达先后顺序;
  • 有序可靠,通信接收方要求发送方的数据必须按顺序完整到达;

【8.2】RUDP 待解决的问题

1. 端对端连通性问题
一般终端直接和终端通信都会涉及到 NAT 穿越,TCP 在 NAT 穿越实现非常困难,相对来说 UDP 穿越 NAT 却简单很多,如果是端到端的可靠通信一般用 RUDP 方式来解决;
2. 弱网环境传输问题
在一些 Wi-Fi 或者 3G/4G 移动网下,需要做低延迟可靠通信,如果用 TCP 通信延迟可能会非常大,这会影响用户体验;
3. 带宽竞争问题
有时候客户端数据上传需要突破本身 TCP 公平性的限制来达到高速低延时和稳定,即要用特殊的流控算法来压榨客户端上传带宽;
4. 传输路径优化问题
在一些对延时要求很高的场景下,会用应用层 relay 的方式来做传输路由优化,也就是动态智能选路,这时双方采用 RUDP 方式来传输,中间的延迟进行 relay 选路优化延时;还有一类基于传输吞吐量的场景,这类场景一般会采用多点并联 relay 来提高传输的速度,也是要建立在 RUDP 上的;
5. 资源优化问题
某些场景为了避免 TCP 的三次握手和四次挥手的过程,会采用 RUDP 来优化资源的占用率和响应时间,提高系统的并发能力;

【8.3】RUDP 可靠性的实现

【8.3.1】RUDP 可靠性的实现基本依赖于重传机制

RUDP 基本框架图示

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第3张图片

定时重传

定时重传就是发送端如果在发出数据包(T1)时刻一个 RTO 之后还未收到这个数据包的 ACK 消息,那么发送端就重传这个数据包,这种方式依赖于接收端的 ACK 和 RTO,容易产生误判,主要有两种情况;

  • 1)对方收到了数据包,但是 ACK 发送途中丢失;
  • 2)ACK 在途中,但是发送端的时间已经超过了一个 RTO;

因此超时重传的方式主要集中在 RTO 的计算上,如果你的场景是一个对延迟敏感但对流量成本要求不高的场景,就可以将 RTO 的计算设计得比较小,这样能尽最大可能保证你的延时足够小;

请求重传

请求重传就是接收端在发送 ACK 的时候携带自己丢失报文的信息反馈,发送端接收到 ACK 信息时根据丢包反馈进行报文重传;

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第4张图片

这个反馈过程最关键的步骤就是回送 ACK 的时候应该携带哪些丢失报文的信息,因为 UDP 在网络传输过程中会乱序会抖动,接收端在通信的过程中要评估网络的抖动时间(jitter time),也就是 rtt_var(RTT 方差值),当发现丢包的时候记录一个时刻 t1,当 t1 + rtt_var < curr_t(当前时刻),便认为丢包;
这个时候后续的 ACK 就需要携带这个丢包信息并更新丢包时刻 t2,后续持续扫描丢包队列,如果 t2 + RTO 这种方式是由丢包请求引起的重发,如果网络很不好,接收端会不断发起重传请求,造成发送端不停的重传,引起网络风暴,通信质量会下降,因此需要在发送端设计一个拥塞控制模块来限流;
整个请求重传机制依赖于 jitter time 和 RTO 这个两个时间参数,评估和调整这两个参数和对应的传输场景也息息相关,请求重传这种方式比定时重传方式的延迟会大,一般适合于带宽较大的传输场景;

FEC 选择重传

FEC 分组方式选择重传,FEC(Forward Error Correction)是一种前向纠错技术

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第5张图片

在发送方发送报文的时候,会根据 FEC 方式把几个报文进行 FEC 分组,通过 XOR 的方式得到若干个冗余包,然后一起发往接收端,如果接收端发现丢包但能通过 FEC 分组算法还原,就不向发送端请求重传,如果分组内包是不能进行 FEC 恢复的,就向发送端请求原始的数据包;
FEC 分组方式适合解决要求延时敏感且随机丢包的传输场景,在一个带宽不是很充裕的传输条件下,FEC 会增加多余的包,可能会使得网络更加不好,FEC 方式不仅可以配合请求重传模式,也可以配合定时重传模式;

【8.3.2】RTT 与 RTO 计算

RTT(Round Trip Time)即网络环路延时,RTO 就是一个报文的重传周期;

RTT = T2 - T1
SRTT = (α * SRTT) + (1-α)RTT,一般α=0.8
RTT_VAR = |SRTT – RTT|,SRTT_VAR =(α * SRTT_VAR) + (1-α) RTT_VAR
RTO = β*(SRTT + RTT_VAR),1.2 <β<2.0

【8.4】窗口与拥塞控制

【8.4.1】窗口

RUDP 需要一个收发的滑动窗口系统来配合对应的拥塞算法做流量控制,有些 RUDP 需要发送端和接收端的窗口严格地对应,有些 RUDP 不要求收发窗口严格对应,如果涉及到可靠有序的 RUDP,接收端就要做窗口排序和缓冲,如果是无序可靠或者尽力可靠的场景,接收端一般就不做窗口缓冲,只做位置滑动;

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第6张图片

上图描述的是发送端从发送窗口中发了 6 个数据报文给接收端,接收端收到 101,102,103,106 时会先判断报文的连续性并滑动窗口开始位置到 103,接着每个包都回应 ACK,发送端在接收到 ACK 的时候,会确认报文的连续性,并滑动窗口到 103,发送端会再判断窗口的空余,然后填补新的发送数据,这就是整个窗口滑动的流程;
这里值的一提的是在接收端收到 106 时的处理,如果是有序可靠,那么 106 不会通知上层业务进行处理,而是等待 104、105;如果是尽力可靠和无序可靠场景,会将 106 通知给上层业务先进行处理;在收到 ACK 后,发送端的窗口要滑动多少是由自己的拥塞机决定的,也就是说窗口的滑动速度受拥塞机制控制,拥塞控制实现要么基于丢包率来实现,要么基于双方的通信时延来实现;

【8.4.2】经典拥塞算法

【网络通信 -- 直播】网络通信协议简介 -- TCP 传输控制协议

不为人知的网络编程(七):如何让不可靠的UDP变的可靠?

【8.4.3】BBR 拥塞算法 [详解]

致力于解决两个问题

  • 1)在一定丢包率网络传输链路上充分利用带宽;
  • 2)降低网络传输中的 buffer 延迟;

BBR 的主要策略
周期性通过 ACK 和 NACK 返回来评估链路的 min_rtt 和 max_bandwidth,最大吞吐量(cwnd)的大小就是:cwnd = max_bandwidth / min_rtt;

BBR 传输模型图示

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第7张图片

BBR 拥塞控制常见状态与切换步骤

BBR 整个拥塞控制是一个探测带宽和 Pacing rate 的状态,有 4 个状态

  • 1)Startup,启动状态(相当于慢启动),增益参数为 max_gain  = 2.85;
  • 2)DRAIN,满负荷传输状态;
  • 3)PROBE_BW,带宽评估状态,通过一个较小的 BBR 增益参数来递增(1.25)或者递减 (0.75);
  • 4)PROBE_RTT,延迟评估状态,通过维持一个最小发送窗口(4 个 MSS)进行的 RTT 采样;

状态切换大致步骤

  • 1)初始化连接时会设置一个初始的 cwnd = 8 并将状态设置 Startup;
  • 2)在 Startup 下发送数据,根据 ACK 数据的采样周期性判断是否可以增加带宽,如果可以,将 cwnd = cwnd *max_gain,如果时间周期数超过了预设的启动周期时间或者发生了丢包,进行 DRAIN 状态;
  • 3)在 DRAIN 状态下,如果 flight_size(发送出去但还未确认的数据大小) >cwnd, 继续保持 DRAIN 状态,如果 flight_size
  • 4)在PROBE_BW状态下,如果未发生丢包且flight_size cwnd,将cwnd = cwnd * 1.25;如果发生丢包,cwnd = cwnd * 0.75;
  • 5)在 Startup/DRAIN/PROBE_BW 三个状态中,如果持续 10 秒钟的通信中没有出现 RTT <= min_rtt,就会进入到 PROBE_RTT 状态,并将 cwnd = 4 *MSS;
  • 6)在 PROBE_RTT 状态,会在收到 ACK 返回的时候持续判断 flight_size >= cwnd 并且无丢包,将本次统计的最小 RTT 作为 min_rtt,进入 Startup 状态;

WebRTC GCC [详解]

在 WebRTC 中对于视频传输实现了一个拥塞控制算法(GCC),WebRTC 的 GCC 是一个基于发送端丢包率和接收端延迟带宽统计的拥塞控制,而且是一个尽力可靠的传输算法,在传输的过程中如果一个报文重发太多次后会直接丢弃;

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第8张图片

GCC 的发送端会根据丢包率和一个对照表来 pacing rate,当 loss < 2% 时,会加大传输带宽,当 loss >=2% &&loss <10%,会保持当前码率,当 loss>=10%,会认为传输过载,进行调小传输带宽;
GCC 的接收端是根据数据到达的延迟方差和大小进行 KalmanFilter 进行带宽逼近收敛;
这里值得一说的是 GCC 引入接收端对带宽进行 KalmanFilter 评估是一个非常新颖的拥塞控制思路,如果实现一个尽力可靠的 RUDP 传输系统不失为是一个很好的参考;
但这种算法也有个缺陷,就是在网络间歇性丢包情况下,GCC 可能收敛的速度比较慢,在一定程度上有可能会造成 REMB 很难反馈给发送端,容易出现发送端流控失效;

【8.5】传输路径

【8.5.1】多点串联 relay

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第9张图片

解决延迟敏感性问题上 SKYPE 率先提出全球 RTN(实时多点传输网络),其实是在通信双方之间通过几个 relay 节点来动态智能选路,这种传输方式很适合 RUDP,只要在通信双方构建一个 RUDP 通道,中间链路只是一个无状态的 relay cache 集合,relay 与 relay 之间进行路由探测和选路,以此来做到链路的高可用和实时性;

【8.5.2】多点并联 relay

【网络通信 -- 直播】网络通信协议简介 -- UDP 用户数据报协议_第10张图片

在服务与服务进行媒体数据传输或者分发过程中,需要保证传输路径高可用和带宽并发,这类使用场景也会使用传输双方构建一个 RUDP 通道,中间通过多 relay 节点的并联来解决;
这种模型需要在发送端设计一个多点路由表探测机制,以此来判断各个路径同时发送数据的比例和可用性,这个模型除了链路备份和增大传输并发带宽外,还有个辅助的功能,如果是流媒体分发系统,一般会用 BGP(路由协议) 来做中转,如果节点与节点之间可以直连,这样还可以减少对 BGP 带宽的占用,以此来减少成本;

参考致谢
本博客为博主的学习实践总结,并参考了众多博主的博文,在此表示感谢,博主若有不足之处,请批评指正。

【1】TCP/IP详解 卷1:协议

【2】不为人知的网络编程(六):深入地理解UDP协议并用好它

【3】UDP中一个包的大小最大能多大

【4】基于UDP服务的负载均衡方法

【5】不为人知的网络编程(五):UDP的连接性和负载均衡

【6】不为人知的网络编程(七):如何让不可靠的UDP变的可靠?

附录

WEBRTC 中的拥塞控制相关论文

你可能感兴趣的:(流媒体系列,--,网络协议)