基于UDP可靠传输实现
- 引言
- 1 概述
- 2 架构设计
- 2.1 架构概述
- 2.2 类功能简介
- 2.2.1 单例的发送策略PolicyContext
- 2.2.2 发送数据缓存SendCache
- 2.2.3 数据包SendDataPack
- 2.2.4 待发送队列SendQueue
- 2.2.5 发送工作者SendWorker
- 2.2.6 UDP网络接口UDPNetChannel
- 2.2.7 UDP数据接收接口IUDPNetListener
- 2.2.8 UDP数据接收实现UDPNetListenerImpl
- 2.2.9 确认信息AckInfo
- 2.2.10 接收数据缓存RecvIndex
- 2.2.11 接收数据包RecvDataPack
- 2.2.12 错误事件处理ErrorWorker
- 2.2.13 接收事件处理RecvWorker
- 2.2.14 可靠UDP事件接口IUDPSeqListener
- 2.2.15 可靠UDP通道UDPSeqChannel
- 3 总结升华
引言
UDP与TCP是七层模型中传输层最常用的协议。这两大协议各有特点:
- TCP协议是流式协议,具备可靠传输,不用考虑分包、乱序、丢包问题;但是,其庞大的机制也严重影响了其性能。
- UDP协议是数据包式协议,具备简单、实时、高效;但是需要考虑丢包、乱序等问题。
在对网络实时要求很高的环境中,很多采用在UDP上封装轻量级可靠的SDK来解决丢包、乱序等问题。例如UE4引擎就是基于UDP封装的可靠传输。下面是我几年前设计的一个可靠UDP传输实现。该设计总体具备如下特色:
- 具备TCP一样的流式可靠传输;
- 简单、轻量,性能高效;
- 能根据网络情况的好坏,自动调整发送频率等等
1 概述
1.1 协议格式
整个协议格式如上图,头部分为两部分,包序号(packSeq)是当前包的序号,采用大端模式;最小期望接收序号(minUnAckSeq)是还未接收到的对端最小包序号,采用大端模式。当数据体为空的时候,包序号没有意义,该包只是接收到对端数据的确认回复。
1.2 领域分析
整个方案的线程模型分为四个,即IO线程、发送线程、事件处理线程、业务线程。
IO线程负责对数据进行发送,调用注册的事件回调;发送线程负责从缓存取数据,然后拼接成协议包投递到IO线程,同时也根据网络丢包的比率控制加快或降低发送频率;当IO线程有事件发生时,将事件的实际处理任务投递到事件处理线程执行;业务线程是上层使用SDK的调用者线程,负责发送数据到缓存,注册流式事件监听。
1.2.1 包序号与确认序号
UDP是不可靠传输,有丢包、乱序等问题。
- 为了解决乱序问题,我们在协议中定义了包序号(packSeq),当对端接收到数据后将缓存,直到该序号之前的所有包都已接收才会回调给调用者;
- 为了解决丢包问题,我们定义了最小期望接收序号(minUnAckSeq),当接收到对端发来的数据包时,该序号表示这个序号之前的所有包都已经成功被对端接收。
- 当接收到对端数据包的时候,如果本端正在发送数据,那么本端的最小期望接收序号(minUnAckSeq)将随本端的数据一起组包发出,否则就会发送一个数据体为空的包到对端。
1.2.2 发送队列
在发送线程发送数据过程中,为了提高效率不可能等对端返回确认包后才发下一个包,于是我们设置一个最大长度为maxSize的待发送队列。
- 当上层有数据缓存的时候,发送线程会从缓存中取数据拆分成数据包,直到把缓存取完数据或者待发送队列达到上限maxSize;
- 当接收到对端数据包中的最小期望接收序号(minUnAckSeq),我们会记录该序号。特别地,由于UDP的乱序到达,对于接收到最小期望接收序号小于已经记录的最小期望接收序号,将不会刷新最小期望接收序号;
- 当发送线程间隔循环发送待发送队列里面的数据包时,如果该序号小于前面的最小期望接收序号(minUnAckSeq),那么我们直接丢弃该包,然后从上层数据缓存中再抽取一个包补充到丢列里面。
1.2.3 发送频率
当网络条件好的时候,丢包是很少的;但是当网络条件差的时候,丢包就会很多,如果频繁发送丢包更多,这既浪费CPU、也浪费流量。我们希望做到自动伸缩发送频率。这和漏桶算法一样,只不过更近一步:做到根据网络质量动态调整速率。
- 统计发送次数,每发送totalSendNum(例如100次),当重发次数达到up_dupSendNum(例如40),我们增大发送间隔。当小于down_dupSendNum(例如20),我们减小发送间隔;
- 设置发送间隔的最大值和最小值,前面的发送间隔调整不能超过该区间;
- 假设我们统计了100次,重发次数达到了80,如果我们不从0开始新的一轮统计,那么后续发100次,即便这100次都未重发,也会造成100次的发送都满足发送间隔的递增从而导致间隔增长太快,所以我们以每发送totalSendNum(例如100次)触发一次调整发送间隔;
- 每当触发一次发送间隔调整,我们必须将各个数据包的重发标记变为false;
- 当待发送队列长度小于maxSize,那么我们每发完一轮,必须停顿 (maxSize - 待发送队列长度) * 发送间隔 的时长再开启新的一轮重发,其值随着发送队列长度变化。否则,会造成这种问题:假设待发送队列的maxSize为50,当前发送间隔为0.1s,每个数据包确认回复间隔为4s。如果我们源源不断地调用发送数据,那么是不会出现重复发送消息的;但是如果我们每间隔10s上层才发送一个小包数据。那么待发送队列始终都满不了,始终最多维持在1的长度,这样每个数据包都会重发40次从而造成流量浪费;此外即便有增大发送间隔机制,会发现后续突然上层加速投递后,数据又不能快速送达,导致很快缓存满而无法写入。
2 架构设计
2.1 架构概述
如上图,整个逻辑架构划分为五块。红色主要为发送线程操作的类;浅灰色主要为SDK调用者操作的类;黑色主要为底层IO线程操作的类;蓝色主要为事件处理线程操作的类;黄白色为多线程共享的类。除PolicyContext为单例类外;其余类为每个UDPSeqChannel各自分开持有,即为每个UDP通道的私有对象。
注1:图中所有的聚合关系都不是直接持有对象指针方式,而是通过封装的无锁化原子引用计数包装器来保证多线程下最后一个引用被释放后安全删除。
注2:图中所有的类的属性在多线程访问下通过原子函数实现无锁化高性能并发。
注3:图中以Worker结尾的类由线程池执行,线程池设计方法很多,就不单独画出,后面的类功能简介中会文字阐述。
2.2 类功能简介
2.2.1 单例的发送策略PolicyContext
功能简介
记录当前发送策略的参数。
属性介绍
- sendTimesPerTrig:每发送sendTimesPerTrig次就触发一次判断是否调整发送间隔。
- minDupRate:最小重发个数。当小于该值,就将SendWorker的perTick减小addPerTick,但不能小于minPerTick。
- minDupRate:最大重发个数。当大于该值,就将SendWorker的perTick增加addPerTick,但不能大于maxPerTick。
- minPerTick:SendWorker的perTick最小值。
- maxPerTick:SendWorker的perTick最大值。
- addPerTick:SendWorker的perTick每次触发时的增减量。
线程要求
该类会被所有UDPSeqChannel的SendWorker调度,需要通过原子操作设置和读取其参数。
2.2.2 发送数据缓存SendCache
功能简介
属性介绍
- nextSeq : 下次发送包序号。每构建一个SendDataPack,该值原子加1;
- buffer : 上层投递数据的缓存。
线程要求
该类会被下层的发送工作线程与上层的业务调用,存在多线程调用关系。对SendDataPack的构建、上层数据像缓存中投递数据都提供无锁化的线程安全操作。ByteArr对象可以使用开源库或自己封装,相对比较简单。
2.2.3 数据包SendDataPack
功能简介
属性介绍
- sendTag : 发送标记,初始值为0。结合领域分析中的发送频率,对该数据包每发送一次,判断sendTag 值与SendWorker的sendTag是否相等。若不相等,只对SendWorker的totalSendNum加一,再将值重新赋值为SendWorker的sendTag值;反之SendWorker的totalSendNum和dupSendNum加一。
- sendSeq :协议中的包序号(即packSeq)。
- data :业务数据。为上层业务投递的真正数据,来源于SendCache中的buffer。
线程要求
该类只会被SendWorker调用,而SendWorker在同一时刻只会被一个线程单独访问。无须考虑线程安全。
2.2.4 待发送队列SendQueue
功能简介
属性介绍
- maxSize:队列最多可以容纳SendDataPack的个数。
- currSize:队列当前的SendDataPack个数。
线程要求
该类只会被SendWorker调用,而SendWorker在同一时刻只会被一个线程单独访问。但是尽管无须考虑线程安全,使用无锁链表是可以轻松做到线程安全的。
2.2.5 发送工作者SendWorker
功能简介
负责将SendQueue中的SendDataPack取出,拼装协议报投递到UDPNetChannel。
当上层投递数据时,通过执行原子函数atomic_cmpxchg(&running,false, true)返回值为false就将SendWorker丢到发送睡眠红黑树中去,待睡眠定时到期后就会被移到发送执行线程池。
当在发送执行线程中运行时,先从队列首部移除sendSeq小于AckInfo的minUnSendSeq的SendDataPack(特别地,需要留意无符号整数越界后的特别比较), 再从SendCache取数据,直到取完或SendQueue满;然后执行发送操作(发送逻辑后面再说);最后若SendQueue和SendCache都为空,就将running原子设置为false。为了与上层投递数据形成多线程逻辑闭环,还需要再判断一次SendCache是否为空,若不为空,执行原子函数atomic_cmpxchg(&running,false, true)返回值为false就将自己丢到发送睡眠红黑树中去。
发送SendDataPack的过程中启动循环,从上一次发送的SendDataPack的后一个开始。首先从SendQueue中取出一个SendDataPack,若SendDataPack的sendSeq小于AckInfo的minUnSendSeq(特别地,需要留意无符号整数越界后的特别比较),说明该包对方已经收到,直接丢弃继续循环;反之,则构建数据包packSeq=SendDataPack的sendSeq、minUnAckSeq=AckInfo的minUnRecvSeq然后发送(次过程中涉及发送总数和丢包的统计和是否触发调整下次执行的时间),然后退出循环,下一次执行又将从发送的SendDataPack下一个开始。特别地,当SendQueue为空也会退出循环。
注:SendQueue每执行到队列末尾,若队列长度小于maxSize,会间隔停顿maxSize - currSize个执行时间间隔。
属性介绍
- running:是否在发送线程执行或睡眠。
- sendTag:发送标记,初始值为构建时的时间戳。结合领域分析中的发送频率最后一点,当每触发一次发送间隔是否调整判断,都会将其值设置为新的时间戳。
- totalSendNum:总的发送次数,初始值为0。当sendTag调整时,也会重置为0。
- dupSendNum:重发次数,初始值为0。
- expireTimes:过期执行时间戳,即上次执行时间戳。名字有点歧义,sorry!
- nextick:下次执行时间戳与expireTimes的差值。
- perTick:当前执行间隔。会根据每次的重发统计自动调整。
线程要求
该类只会被SendWorker调用,而SendWorker在同一时刻只会被一个线程单独访问。无须考虑线程安全。
2.2.6 UDP网络接口UDPNetChannel
功能简介
负责异步收发指定本端端口和远端端口的UDP数据。所以当相同本端端口和多个远端收发,需要用观察者模式加智能引用计数器去包装底层原始的UDP socket功能。
线程要求
该类只会被SendWorker调用,而SendWorker在同一时刻只会被一个线程单独访问。理论上是不需要做到线程安全,但是本方案要求线程安全。
2.2.7 UDP数据接收接口IUDPNetListener
功能简介
异步接收指定本端和远端端口的UDP数据后回调该接口
2.2.8 UDP数据接收实现UDPNetListenerImpl
功能简介
IUDPNetListener的实现类。
当接收到数据的时候且数据长度大于0,会构建一个RecvWorker投递到接收事件处理线程池执行,不过在投递之前,若minUnAckSeq大于AckInfo的minUnSendSeq,则设置minUnSendSeq=minUnAckSeq。
当接收到错误的时候,会构建一个ErrorWorker也投递到错误事件处理线程池执行。
线程要求
由于RecvWorker做到了线程安全,本类不需要考虑线程安全。
2.2.9 确认信息AckInfo
功能简介
记录当前最小的对端未确认的发送包和当前最小的还未接收到的对端包序号。
属性介绍
- minUnSendSeq:当前对端未确认的发送包序号的最小值。
- minUnRecvSeq:当前还未接收到的对端包序号的最小值。
线程要求
会被发送线程和事件处理线程多线程访问,通过原子操作可以实现线程安全操作。
2.2.10 接收数据缓存RecvIndex
功能简介
缓存接收到的数据包,packs和begin组成了一个环形缓存。minPackSeq决定了缓存只能存储AckInfo的minUnRecvSeq到minUnRecvSeq+packs长度-1的包序号,其他数据包将丢弃,事实上,当packs的数组长度大于或等于SendQueue的maxSize时,是不会发生这种情况的。
属性介绍
- packs:接收数据包RecvDataPack的指针缓存数组。与begin字段构成环形缓存;
- begin:环形缓存的起始packs索引。与packs字段构成环形缓存;
线程要求
该类只会被RecvWorker调用,而RecvWorker在同一时刻只会被一个线程单独执行。
2.2.11 接收数据包RecvDataPack
功能简介
接收数据的包装器
属性介绍
- packSeq:对端发送包序号。
- buffer:存储接收数据的缓存。不包括前面的协议头部分。
线程要求
该类同一时刻只会被一个线程单独执行,无须加锁。
2.2.12 错误事件处理ErrorWorker
功能简介
处理所有UDPNetChannel的错误事件, RecvWorker不同的是确保每个UDPNetChannel的接收事件被串行处理。
线程要求
该类只是简单调用IUDPSeqListener接口的onError方法,其线程安全由IUDPSeqListener接口实现保障。
2.2.13 接收事件处理RecvWorker
功能简介
处理所有UDPNetChannel的数据接收, 每个UDPNetChannel都会有一个RecvWorkerQueue对象,RecvWorker以无锁链表插入到RecvWorkerQueue中;当RecvWorkerQueue为空时,将会把RecvWorkerQueue插入到接收事件处理线程池中执行;然后就会被某个线程取出再执行其中的RecvWorker。
RecvWorker的执行是:首先构建RecvDataPack,再插入到RecvIndex的环形缓存中,特别地若PackSeq不在环形缓存可缓存序号范围内将会被丢弃(环形缓存长度只要不小于SendQueue的maxSize,这种事情理论上是不会发生。同样需要留意无符号整数越界后的特别比较);然后从RecvIndex的环形缓存开始遍历RecvDataPack,直到遇到NULL结束(即该位置的数据还没接收)就退出循环,将取出的数据放到局部vector中,并把相应取出的数组项设置为NULL。此过程中会对begin递增;最后将AckInfo的minUnRecvSeq递加begin增量那么多,再用取出的RecvDataPack依次调用上层的IUDPSeqListener接口以通知上层数据接收事件。
线程要求
该类只是简单调用IUDPSeqListener接口的onError方法,其线程安全由IUDPSeqListener接口实现保障。
2.2.14 可靠UDP事件接口IUDPSeqListener
功能简介
对于底层数据接收或出现错误的事件通知机制,由上层应用实现。特别地,这个接口的onRecv方法是相当于TCP的流式数据传输。
2.2.15 可靠UDP通道UDPSeqChannel
功能简介
对上层应用使用提供操作入口类。
3 总结升华
该方案做到了UDP的可靠、轻量、高效的流式传输。但是还没有解决以下问题:
- 建立连接
- 断开连接
为了解决这个问题,我们可以在协议头里面添加一个ConnectionID的字段,类型为有符号整数。
3.1 建立建立
当需要连接对端的时候,构建一个ConnectionID=当前时间戳,packSeq=0,minUnAckSeq=0的空数据体的连接包发送到对端,然后构建一个本地UDPSeqChannel对象;当对端收到数据后,也构建一个本地UDPSeqChannel对象;然后双方在没有数据发送的时候,定时发送空数据体的确认包到对端。
注1:连接建立后,后续的通信双方的ConnectionID保持一致。所以连接的过程,本质上是协商ConnectionID值的过程。
注2:这里的连接相比tcp连接的三次握手,只有一次。双方没有严格的服务端和客户端关系。当然,若想在知道对端连接已经建立的情况下才可以发数据。那么需要等待对端回第一个确认包;同时对端接收到连接包后,立即回一个确认包。
注3:在极端情况下,两边如果都同时发起连接。当接收到对端连接包后,若本端已经发起连接包、已构建UDPSeqChannel对象,将以最小值优先原则协商ConnectionID。
3.2 断开连接
对于连接断开,主要分为以下方式断开:
超时断开:双方在没有数据发送的时候,定时发送空数据体的确认包到对端。当超过一定时间没有收到对端数据,则事件通知连接丢失。
暴力断开:本端直接释放UDPSeqChannel对象,此时缓存中还没发送的数据将丢失,对端还没发送的数据也将丢失。
正常断开:构建一个ConnectionID=建立连接时的ConnectionID的相反数,packSeq=0,minUnAckSeq=0的空数据体的结束包发送到对端;对端收到后,也会构建一个ConnectionID=建立连接时的ConnectionID的相反数,packSeq=0,minUnAckSeq=0的空数据体的结束包发送到本端。双方都要等本端缓存数据发送完毕再发前面所述的结束包数据。从而保证了缓存中的数据不会丢失。