UDT实现原理探析

UDT实现原理探析和 TCP 一样 UDT 也有慢启动阶段

大家好,欢迎来到IT知识分享网。

UDT(User Datagram Transport Protocol,用户数据报传输协议)是一种高性能的传输层协议,设计目的是在不可靠的网络环境中提供类似于 TCP 的可靠传输服务,同时保持 UDP 的高效性和低延迟特性。UDT 主要用于高性能计算领域,特别是在大规模并行计算和分布式计算中,以及对带宽和延迟要求较高的应用中。

最近比较火的直播传输协议 SRT ,就是基于 UDT 开发的,其借鉴了 UDT 的传输机制,同时针对实际应用场景进行了增强和优化。

1. 架构

UDT 完全基于 UDP 构建,通过模拟 Berkeley Socket API,提供 listen、send、receive 等常见 socket 编程接口,并使用 Channel 对 socket 接口进行封装。在 API 和 Channel 之间就是 UDT 的核心逻辑,如下图所示:

UDT实现原理探析

上图主要模块的职责定义如下:

1)Listener

类似 TCP 的 listen 机制,服务端可以调用 listen 接口进行监听,当有连接请求过来时,UDT 内部会完成连接协商,然后通知有新的连接。

2)Sender’s Buffer

应用层发送数据时,会先将数据缓存到 Sender’s Buffer,不是直接发送到网络。

3)Sender

Sender 内部有一个线程,在 CC 的控制下循环发送数据报文。

4)Sender’s Loss List

收到接收端 loss report(NAK) 或者本端 EXP 定时器超时,则会将丢失报文保存到 Sender’s Loss List,Sender会优先重传丢失报文。

5)Receiver

Receiver内部有一个线程,不断的尝试从网络收取数据报文。

6)Receiver’s Loss List

收到报文如果发现有丢包,则保存丢失报文插入到 Receiver’s Loss List。

7)Receiver’s Buffer

Receiver从网络收到报文会保存到 Receiver’s Buffer,应用层调用 API 从 Receiver’s Buffer 中接收数据。

8)CC(Congestion Control)

拥塞控制模块基于接收端反馈的接收速率、带宽评估和RTT等链路参数,控制 Sender 的发包速率。

2. 实现

2.1 文件结构

UDT 使用 C++ 实现,模拟操作系统网络 API,对外封装了一层 C API,开发者使用起来更方便。Implementation 层分为两部分,Core 子层包含了主体逻辑,Util 子层包含了辅助据结构,Core 子层根据文件名可以看出,ccc.h 负责拥塞控制实现,epoll.h 负责模拟 epoll 实现,channel.h 负责收发网络报文,core.h 则负责协议实现。Common 层包含两个业务无关的工具文件。

UDT实现原理探析

2.2 静态结构

UDT 使用 C++ 实现,实现比较精巧和高效,定义的类不多,类的静态结构用一张图就可以表示,如下图:

UDT实现原理探析

2.3 调用链

为了更好理解各个类的主要职责,以及它们之间如何配合完成处理逻辑,特选取几个典型调用流程进行分析。

2.2.1 listen 调用链

在 listen 之前需要先创建 UDTSOCKET,调用 listen 接口后,内部会调用 CRcvQueue::setListener 将监听 Socket 所对应的 CUDT 设置到 CRcvQueue 中,当 CRcvQueue 收到连接请求时,能够判断当前是一个 regular 连接请求还是 rendezvous 连接请求。

连接的建立需要经过多次握手协商,具体请参考3.3。连接建立成功后,CUDT 会调用CUDTUnited::newConnection,创建相关数据结构,此时,应用层可以调用 accept 来接收新的连接。

UDT实现原理探析

2.2.2 connect 调用链

客户端主动连接服务器时调用 connect 接口,connect 调用是阻塞的。UDT 内部经历 4 次握手才最终建立连接(client/server)。连接建立(握手成功)后,也完成了连接参数协商。

UDT实现原理探析

2.2.3 send 调用链

应用层要发送数据可以调用 send 接口,UDT 会将待发送数据先保存到 CSndBuffer 中,CSndQueue 的线程会不断读取 CSndBuffer 中的报文,通过 CChannel 发送到网络。

UDT实现原理探析

2.2.4 recv 调用链

CRcvQueue 不断从网络上读取报文,调用 CRcvBuffer::addData 将报文保存到 CRcvBuffer 中。应用层通过调用 recv 接口从 CRcvBuffer 中读取数据。recv 是阻塞接口,如果 CRcvBuffer 中没有数据的话,会阻塞等待。

UDT实现原理探析

2.4 线程模型

UDT 支持多线程,每个 UDT 实例(对应本地 UDTSOCKET)包含两个线程,一个发送线程和一个接收线程,如下图所示。

UDT实现原理探析

具体来说,每个 UDT 实例都包含 RcvQueue 和 SndQueue,RcvQueue 和 SndQueue 各自拥有一个独立线程用来驱动收发报文。其中,一个端口的多个 UDT 实例共享 RcvQueue 和SndQueue,也就是说,服务端通过 UDT 在同一个端口监听建立的连接,共享一个发送线程和接收线程。考虑到应用层的外部线程调用,UDT 内部大部分数据结构都需要用锁进行保护。

2.5 定时器

为了更好的做流量平滑,UDT 在发送报文时,需要使用精度为微秒的定时器。UDT 的高精度定时器的实现是通过提供高精度时间戳来实现的,使用者需要在自己的线程中不停检测是否超时,定时器本身没有线程。

UDT 支持两种形式的高精度时间戳,一种是系统时间戳,一种是 CPU 时间戳,这两种时间戳的时间精度都能达到微妙,但 CPU 时间戳的精度更高,UDT 默认使用 CPU 时间戳。

UDT 提供获取时间戳的接口是 rdtsc,“rdtsc”是 X86/X64 指令,该指令返回 CPU 自启动以来的时钟周期数。UDT 程序内部是统一按照 CPU 时间戳处理。如果使用“虚拟 CPU 时间戳”(系统时间戳),rdtsc 接口获取的是系统运行开始所经历的微秒数,“CPU主频”为1,单位为 HZ/us;如果使用“真实 CPU 时间戳”,rdtsc 获取的是系统运行开始所经历的CPU时钟周期,“CPU 主频”为实际主频除以 1,000,000,单位也是 HZ/us。这样两种时间戳就统一了,二者可以基于“CPU 主频”进行转换。

// 读取CPU时间戳 uint64_t currtime; CTimer::rdtsc(currtime); // 计算两个CPU时间戳之差 uint64_t diff = currtime - oldtime; // CPU时间戳之差转换为微秒,其中 cpu_frequency 为:CPU时间周期/微秒 uint64_t diff_us = diff / cpu_frequency; // 当前CPU时间戳上增加t微秒,变成新的CPU时间戳 uint64_t new_ts = currtime + t * cpu_frequency;

不同系统获取 CPU 时钟周期的方式不一样:Linux 系统使用汇编指令;Windows 系统调用QueryPerformanceCounter 接口;MACOS 系统调用 mach_absolute_time 接口,其他系统通过系统时间戳来模拟。如以下代码所示:

void CTimer::rdtsc(uint64_t &x) { if (m_bUseMicroSecond) { x = getTime(); return; } #ifdef IA32 uint32_t lval, hval; //asm volatile ("push %eax; push %ebx; push %ecx; push %edx"); //asm volatile ("xor %eax, %eax; cpuid"); asm volatile ("rdtsc" : "=a" (lval), "=d" (hval)); //asm volatile ("pop %edx; pop %ecx; pop %ebx; pop %eax"); x = hval; x = (x << 32) | lval; #elif defined(IA64) asm ("mov %0=ar.itc" : "=r"(x) :: "memory"); #elif defined(AMD64) uint32_t lval, hval; asm ("rdtsc" : "=a" (lval), "=d" (hval)); x = hval; x = (x << 32) | lval; #elif defined(WIN32) //HANDLE hCurThread = ::GetCurrentThread(); //DWORD_PTR dwOldMask = ::SetThreadAffinityMask(hCurThread, 1); BOOL ret = QueryPerformanceCounter((LARGE_INTEGER *)&x); //SetThreadAffinityMask(hCurThread, dwOldMask); if (!ret) x = getTime() * s_ullCPUFrequency; #elif defined(OSX) x = mach_absolute_time(); #else // use system call to read time clock for other archs x = getTime(); #endif }

2.6 报文结构

UDT 使用 iovec[2] 数组来存储报文结构,分为报文头和有效载荷两部分,每一部分都用一个 iovec结构表示。其中Header部分固定为 16 字节长度,Payload 部分根据报文类型而异。

UDT实现原理探析

把 iovec 发送出去,Windows 平台调用 WSASendTo 接口,其他平台调用 sendmsg 接口。

#ifndef WINDOWS msghdr mh; mh.msg_name = (sockaddr*)addr; mh.msg_namelen = m_iSockAddrSize; mh.msg_iov = (iovec*)packet.m_PacketVector; mh.msg_iovlen = 2; mh.msg_control = NULL; mh.msg_controllen = 0; mh.msg_flags = 0; int res = ::sendmsg(m_iSocket, &mh, 0); #else DWORD size = CPacket::m_iPktHdrSize + packet.getLength(); int addrsize = m_iSockAddrSize; int res = ::WSASendTo(m_iSocket, (LPWSABUF)packet.m_PacketVector, 2, &size, 0, addr, addrsize, NULL, NULL); res = (0 == res) ? size : -1; #endif

2.7 多路复用

UDT multiplexer 用来处理共享同一个 UDP 端口上的并发 UDT 连接,如下图所示。UDT multiplexer的Channel负责网络通信,绑定到一个端口。multiplexer 包含两个队列:一个发送队列和一个接收队列。这两个队列都有独立的线程驱动。

UDT实现原理探析

Multiplexer从网络收到报文后,根据 Destination Socket ID 将报文分发到不同的 UDT instance(UDT连接),如果报文的 Destination Socket ID 为 0,则会发送到 Listen instance。

应用层发送的数据会先缓存到 UDT instance 的 SndBuffer,同时,UDT会将要发送数据的instance插入SndUList中,并按照发送时间升序排列。Multiplexer 会按照 SndUList 中 UDT instance 的排列顺序,从 SndBuffer 中取出待发送数据发送到网络。

3. 协议

3.1 报文结构

3.1.1 数据报文

数据报文的第一个 bit 为 0。

0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |0| Packet Sequence Number | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |FF |O| Message Number | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Time Stamp | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Destination Socket ID | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ The first two bits "FF" flags the position of the packet is a message. 1) "10" is the first packet, 2) "01" is the last one, 3) "11" is the only packet, 4) "00" is any packets in the middle. The third bit "O" means if the message should be delivered in order (1) or not (0).
3.1.2 协议报文

协议报文的第一个 bit 为 1。(怀疑 Additional Info 使用 32bits,下面图示 Reservred 字段使用了18bits,Additional Info 使用2 9bits,是否是笔误待验证)

0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |1| Type | Reserved | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | Additional Info | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Time Stamp | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Destination Socket ID | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | ~ Control Information Field ~ | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ TYPE 0x0: Protocol Connection Handshake Additional Info: Undefined Control Info: 1) 32 bits: UDT version 2) 32 bits: Socket Type (STREAM or DGRAM) 3) 32 bits: initial packet sequence number 4) 32 bits: maximum packet size (including UDP/IP headers) 5) 32 bits: maximum flow window size 6) 32 bits: connection type (regular or rendezvous) 7) 32 bits: socket ID 8) 32 bits: SYN cookie 9) 128 bits: the IP address of the peer's UDP socket TYPE 0x1: Keep-alive Additional Info: Undefined Control Info: None TYPE 0x2: Acknowledgement (ACK) Additional Info: ACK sequence number Control Info: 1) 32 bits: The packet sequence number to which all the previous packets have been received (excluding) [The following fields are optional] 2) 32 bits: RTT (in microseconds) 3) 32 bits: RTT variance 4) 32 bits: Available buffer size (in bytes) 5) 32 bits: Packets receiving rate (in number of packets per second) 6) 32 bits: Estimated link capacity (in number of packets per second) TYPE 0x3: Negative Acknowledgement (NAK) Additional Info: Undefined Control Info: 1) 32 bits integer array of compressed loss information (see section 3.9). TYPE 0x4: Unused TYPE 0x5: Shutdown Additional Info: Undefined Control Info: None TYPE 0x6: Acknowledgement of Acknowledgement (ACK2) Additional Info: ACK sequence number Control Info: None TYPE 0x7: Message Drop Request Additional Info: Message ID Control Info: 1) 32 bits: First sequence number in the message 2) 32 bits: Last sequence number in the message TYPE 0x7FFF: Explained by bits 16 - 31, reserved for user defined Control Packet 

3.2 序列号

UDT 协议中包含三种序列号:消息序列号、报文序列号和 ACK 序列号。

消息序列号:

应用层调用 API 发送数据/消息的时候,UDT 会对应用层发送的数据/消息进行编号,这个编号称为消息序列号。

报文序列号:

UDT 会尽量将应用层消息切分为相同大小(MSS)的报文进行发送,一个数据/消息可能会被切分为多个报文,切分后的每个报文会进行独立编号,这个编号称为报文序列号。

ACK序列号:

UDT 不是实时 ACK,可以认为是基于定时器的批量 ACK。同时,UDT 还会对 ACK 再进行 ACK(ACK2),ACK 报文里面会携带报文序列号,ACK本身也会有自己独立的编号,这个编号称为ACK序列号。

3.3 连接建立

3.3.1 regular连接

UDT 建立 regular(client-server) 模式的连接,需要4次握手。

1)第一次握手

以建立一个 Stream 连接为例,ReqType 设置为 1。此时还不知道对端 Socket ID,Destination Socket ID 设置为 0。SYN Cookie 设置为 0,待服务器返回。Peer IP Address 设置为客户端看到的服务器 IP 地址。

UDT实现原理探析

2)第二次握手

服务器收到报文,通过 Destination Socket ID 判断这是第一个握手请求。基于安全考虑,会基于客户端的IP地址、端口和时间戳,生成一个cookie,填充到 SYN Cookie。Destination Socket ID 字段填充握手请求中的 Socket ID字段。Requested Type 设置为 1,表明这是一个安全验证请求。

UDT实现原理探析

3)第三次握手

客户端收到服务握手响应(安全验证请求),将服务器发送的 cookie 原样返回。由于这是一个对验证请求的响应,因此,Requested Type 设置为 -1。

UDT实现原理探析

4)第四次握手

服务器收到客户端的验证响应,验证 cookie 是否正确,开始协商连接参数,并将协商好的参数发送到客户端,客户端保存协商好的参数,至此,连接建立完成。由于这是一个握手响应,Requested Type 设置为 -1。

UDT实现原理探析

3.3.2 rendezvous连接

点对点连接的建立,任意一方都可以发起,要求 handshake 报文中的 connection type 字段为 0。

3.4 连接断开

主动关闭连接的一方需要发送 shutdown 报文,对端收到 shutdown 消息后,也会将连接关闭。shutdown 消息使用UDP传输,仅发送一次,并不保证一定会被收到。EXP定时器用来处理这种连接断开的情况。

3.5 ACK

ACK 机制是实现可靠性传输和拥塞控制的必要技术,而 UDT 在 ACK 机制的实现上有其特点。

3.5.1 ACK type

ACK 报文中的 Control Info,第一个 32bits 是必须的,剩下的字段是可选的,如果只包含必须字段,则是一个 Light ACK,否则就是一个普通 ACK。

TYPE 0x2: Acknowledgement (ACK) Additional Info: ACK sequence number Control Info: 1) 32 bits: The packet sequence number to which all the previous packets have been received (excluding) [The following fields are optional] 2) 32 bits: RTT (in microseconds) 3) 32 bits: RTT variance 4) 32 bits: Available buffer size (in bytes) 5) 32 bits: Packets receiving rate (in number of packets per second) 6) 32 bits: Estimated link capacity (in number of packets per second) 
3.5.2 Selective Acknowledgement

UDT 要求对 ACK 再回复 ACK,且必须立即回复。之所以这么做,一是保证 ACK 的可靠性,二是可以用来计算 RTT。之所以选择这么做,是因为 UDT 选择基于定时器的 ACK,不会收到一个报文就立马发送 ACK,这样在高带宽下能够节省很多控制流量和 CPU 时间。

3.6 NAK

NAK(Negative Acknowledgement)即常说的 NACK。NAK 由发端、收端和定时器三者配合实现。

接收端收到报文后根据报文序列号检测是否有丢包,如果发现有丢包(不等乱序重组),立即发送NAK(loss report),同时将丢失报文保存到丢包队列。

丢包情况比较复杂,有可能是丢一个报文,有可能是连续丢一段报文,也可能是两者的混合。为了实现高效传输,UDT 对丢包信息进行了压缩,具体压缩方案见规范文档:Loss Information Compression。

发送端会将接收端上报的丢失报文序号保存起来,每次报文发送时,先检查丢失报文队列是否有需要重发报文,如果有则优先发送丢失报文。

对于 message 模式,如果发送缓存已经找不到丢失报文,则向接收端发送一个 drop request,告诉接收端不要再等这个报文了。也就是说 UDT 的 message 模式是不可靠的,或者说是部分可靠的。

4. 算法

4.1 定时器

UDT 使用4个定时器:EXP、SND、ACK 和 NAK,其中,SND 定时器仅被用在发端用来控制报文发送速率,其他三个定时器仅被用在收端。这里的收端和发端不是应用层上的客户端和服务器,而是对应 UDT 实体双工状态下的 receiver 角色和 sender 角色。

4.1.1 EXP定时器

EXP 定时器用来触发报文重传和维护连接状态。

触发报文重传是指,在超时之前已经发送端发送了一堆报文,但是没有收到接收端任何响应,且发端丢包列表为空,则把这一堆报文重传一遍。如果发端丢包列表不为空,要么收端出问题了,要么网络出问题了,这时候没必要进行重传。

// sender: Insert all the packets sent after last received acknowledgement into the sender loss list. // recver: Send out a keep-alive packet if (m_pSndBuffer->getCurrBufSize() > 0) { if ((CSeqNo::incseq(m_iSndCurrSeqNo) != m_iSndLastAck) && (m_pSndLossList->getLossLength() == 0)) { // resend all unacknowledged packets on timeout, // but only if there is no packet in the loss list int32_t csn = m_iSndCurrSeqNo; int num = m_pSndLossList->insert(m_iSndLastAck, csn); m_iTraceSndLoss += num; m_iSndLossTotal += num; } m_pCC->onTimeout(); CCUpdate(); // immediately restart transmission m_pSndQueue->m_pSndUList->update(this); } else { sendCtrl(1); }

连接状态维护包含两部分,一部分是如果多次超时,超时累积到一定阈值认为对端出问题了,这时候应该关闭连接。UDT 连接异常的超时阈值为:连续 15 次超时,且超过 5 秒钟没有收到对端响应,如下图所示。另一部分是保活,如上图所示,如果发送缓冲区没有报文,此时应该发送一个keep-alive 消息,让对方确认本端还活着。

// Haven't receive any information from the peer, is it dead?! // timeout: at least 16 expirations and must be greater than 10 seconds if ((m_iEXPCount > 16) && (currtime - m_ullLastRspTime >  * m_ullCPUFrequency)) { // // Connection is broken. // UDT does not signal any information about this instead of to stop quietly. // Application will detect this when it calls any UDT methods next time. // m_bClosing = true; m_bBroken = true; m_iBrokenCounter = 30; // update snd U list to remove this socket m_pSndQueue->m_pSndUList->update(this); releaseSynch(); // app can call any UDT API to learn the connection_broken error s_UDTUnited.m_EPoll.update_events(m_SocketID, m_sPollID, UDT_EPOLL_IN | UDT_EPOLL_OUT | UDT_EPOLL_ERR, true); CTimer::triggerEvent(); return; }

EXP 定时器的超时时间会基于 RTT 动态设置:

uint64_t next_exp_time; // 如果外部设置了RTO,则基于外部社会的RTO进行超时 if (m_pCC->m_bUserDefinedRTO) next_exp_time = m_ullLastRspTime + m_pCC->m_iRTO * m_ullCPUFrequency; // 否则基于超时次数和RTT进行超时,当然还要考虑最小超时时间 else { uint64_t exp_int = (m_iEXPCount * (m_iRTT + 4 * m_iRTTVar) + m_iSYNInterval) * m_ullCPUFrequency; if (exp_int < m_iEXPCount * m_ullMinExpInt) exp_int = m_iEXPCount * m_ullMinExpInt; next_exp_time = m_ullLastRspTime + exp_int; }
4.1.2 SND定时器

SND 定时器用来控制报文发送速率。拥塞控制模块设置 UDT 实体的报文发送间隔,SndQueue 会按照设置的时间间隔将 SndBuffer 中的数据平滑发送到网络上。

4.1.3 ACK定时器

UDT 的 ACK 是“及时ACK”,不是“实时ACK”,UDT 触发 ACK 有三种情况:

1)基于定时器触发:有拥塞控制模块控制ACK超时时间。

2)基于报文数量触发:如果应用层设置了 m_iACKInterval,则会检查当前周期累积收到的报文数量。

3)如果以上条件都还没到触发条件,但是已经收到了较多报文,此时会触发一个 Light ACK。

if ((currtime > m_ullNextACKTime) || ((m_pCC->m_iACKInterval > 0) && (m_pCC->m_iACKInterval <= m_iPktCount))) { // ACK timer expired or ACK interval is reached sendCtrl(2); CTimer::rdtsc(currtime); if (m_pCC->m_iACKPeriod > 0) m_ullNextACKTime = currtime + m_pCC->m_iACKPeriod * m_ullCPUFrequency; else m_ullNextACKTime = currtime + m_ullACKInt; m_iPktCount = 0; m_iLightACKCount = 1; } else if (m_iSelfClockInterval * m_iLightACKCount <= m_iPktCount) { //send a "light" ACK sendCtrl(2, NULL, NULL, 4); ++ m_iLightACKCount; }

UDT 要求 ACK 时间间隔不能超过 0.01 秒(SYN):

void CCC::setACKTimer(int msINT) { m_iACKPeriod = msINT > m_iSYNInterval ? m_iSYNInterval : msINT; }
4.1.4 NAK定时器

UDT 在收到报文后,如果发现有丢包会立即触发一个 NAK,如果等了很长时间发送端还没有重传,则应该重新发送 NAK,NAK 定时器就用来干这事。不过,在最新的实现中已经移除 NAK 定时器,而是借助 EXP 定时器来触发重传。

//we are not sending back repeated NAK anymore and rely on the sender's EXP for retransmission //if ((m_pRcvLossList->getLossLength() > 0) && (currtime > m_ullNextNAKTime)) //{ // // NAK timer expired, and there is loss to be reported. // sendCtrl(3); // // CTimer::rdtsc(currtime); // m_ullNextNAKTime = currtime + m_ullNAKInt; //}

不过,在发送 NAK 后,更新 NAK 相关参数的代码还没有注释掉。

// update next NAK time, which should wait enough time for the retansmission, but not too long m_ullNAKInt = (m_iRTT + 4 * m_iRTTVar) * m_ullCPUFrequency; int rcv_speed = m_pRcvTimeWindow->getPktRcvSpeed(); if (rcv_speed > 0) m_ullNAKInt += (m_pRcvLossList->getLossLength() * ULL / rcv_speed) * m_ullCPUFrequency; if (m_ullNAKInt < m_ullMinNakInt) m_ullNAKInt = m_ullMinNakInt;

4.2 带宽评估

UDT 通过 Packet Pair 机制实现带宽评估。UDT 将数据报文每 16 个编为一组,每组第 0个 报文作为检测对的第一个报文,紧接着的报文作为检测对的第二个报文,其中第二个报文在第一个报文发送完成后立即发送(不主动添加报文发送间隔)。接收端记录这收到这两个报文的时间间隔,可以认为是当前网络传输一个报文需要的时长。对所有记录的时间间隔进行平滑计算,就可以得到每秒钟可以发送的报文数量,即为带宽评估值。此值会通过 ACK 报文反馈给发送方,以便于发送方调整报文发送速率。

UDT实现原理探析

可以认为这种带宽评估方式属于“带内带宽评估”,通过发送 Padding 报文的带宽评估方式属于“带外带宽评估”。实现上要考虑特殊情况,发送端发送完 16N 报文后,应该立即发送 16N+1 报文,但此时发送端缓冲区已经没有待发送的报文,这种情况可能会对计算结果产生较大影响。UDT 使用一个简单粗暴的过滤机制来消除这种影响:在计算平均值之前会获取中值,对于大于 8 倍中值和小于 1/8 中值的异常数值进行剔除。

4.3 RTT计算

UDT 要求收到 ACK 后需要立即回复 ACK2(Acknowledgement of Acknowledgement)。

UDT实现原理探析

收到 ACK2 后,立即计算实时 RTT,基于实时 RTT 更新平滑后 RTT 和 RTT 方差,同时,将平滑后 RTT 值设置到拥塞控制模块。

case 6: //110 - Acknowledgement of Acknowledgement { int32_t ack; int rtt = -1; // update RTT rtt = m_pACKWindow->acknowledge(ctrlpkt.getAckSeqNo(), ack); if (rtt <= 0) break; //if increasing delay detected... // sendCtrl(4); // RTT EWMA m_iRTTVar = (m_iRTTVar * 3 + abs(rtt - m_iRTT)) >> 2; m_iRTT = (m_iRTT * 7 + rtt) >> 3; m_pCC->setRTT(m_iRTT); // update last ACK that has been received by the sender if (CSeqNo::seqcmp(ack, m_iRcvLastAckAck) > 0) m_iRcvLastAckAck = ack; break; }

4.4 拥塞控制

拥塞控制目的是最高效的利用网络带宽。UDT 拥塞控制主要控制两个变量:发包间隔和拥塞窗口。

4.4.1 原生实现

UDT 原生拥塞控制实现 CUDTCC,只实现了以下几个接口:

virtual void onACK(int32_t); virtual void onLoss(const int32_t*, int); virtual void onTimeout();

即只处理了 ACK、loss report 和 EXP 定时器超时三个事件。

1)onACK:收到 ACK 要重新计算拥塞窗口大小和更新报文发送间隔。

void CUDTCC::onACK(int32_t ack) { int64_t B = 0; double inc = 0; // Note: 1/24/2012 // The minimum increase parameter is increased from "1.0 / m_iMSS" to 0.01 // because the original was too small and caused sending rate to stay at low level // for long time. const double min_inc = 0.01; uint64_t currtime = CTimer::getTime(); // 每0.01秒调整一次速率(在高速网络下,ack可能会很频繁) if (currtime - m_LastRCTime < (uint64_t)m_iRCInterval) return; m_LastRCTime = currtime; if (m_bSlowStart) { // 慢启动阶段不断增加cwnd m_dCWndSize += CSeqNo::seqlen(m_iLastAck, ack); m_iLastAck = ack; // cwnd达到上限后,慢启动结束 if (m_dCWndSize > m_dMaxCWndSize) { m_bSlowStart = false; // 如果有收到接收端反馈的接收速率,则基于接收速率设置报文发送间隔 if (m_iRcvRate > 0) m_dPktSndPeriod = .0 / m_iRcvRate; // 否则基于cwnd、RTT来设置报文发送间隔 else m_dPktSndPeriod = (m_iRTT + m_iRCInterval) / m_dCWndSize; } } else { // 基于接收端反馈的接收速率和RTT动态设置cwnd,cwnd与BDP成正比 m_dCWndSize = m_iRcvRate / .0 * (m_iRTT + m_iRCInterval) + 16; } // During Slow Start, no rate increase if (m_bSlowStart) return; if (m_bLoss) { m_bLoss = false; return; } // B可以认为是可用带宽:探测到的带宽 - 当前发送速率 B = (int64_t)(m_iBandwidth - .0 / m_dPktSndPeriod); // 上一次降速后,报文发送间隔又变大了,此时,调整要谨慎一些 if ((m_dPktSndPeriod > m_dLastDecPeriod) && ((m_iBandwidth / 9) < B)) B = m_iBandwidth / 9; // 带宽溢出也提速???实际情况是持续缓慢提速,直到发生丢包降速 if (B <= 0) inc = min_inc; else { // inc = max(10 ^ ceil(log10( B * MSS * 8 )) * Beta / MSS, 1/MSS) // Beta = 1.5 * 10^(-6) inc = pow(10.0, ceil(log10(B * m_iMSS * 8.0))) * 0.0000015 / m_iMSS; if (inc < min_inc) inc = min_inc; } // 更新报文发送间隔 m_dPktSndPeriod = (m_dPktSndPeriod * m_iRCInterval) / (m_dPktSndPeriod * inc + m_iRCInterval); }

2)onLoss:收到丢包报告,要做降速处理。UDT 的降速相对于 TCP 来说要温和很多。

void CUDTCC::onLoss(const int32_t* losslist, int) { //Slow Start stopped, if it hasn't yet if (m_bSlowStart) { // 慢启动阶段,发现丢包立即结束 m_bSlowStart = false; if (m_iRcvRate > 0) { // Set the sending rate to the receiving rate. m_dPktSndPeriod = .0 / m_iRcvRate; return; } // If no receiving rate is observed, we have to compute the sending // rate according to the current window size, and decrease it // using the method below. m_dPktSndPeriod = m_dCWndSize / (m_iRTT + m_iRCInterval); } m_bLoss = true; // 自上次降速以来,新发送的报文又有报文丢失,则应该继续降速 if (CSeqNo::seqcmp(losslist[0] & 0x7FFFFFFF, m_iLastDecSeq) > 0) { m_dLastDecPeriod = m_dPktSndPeriod; // 降低发送速率 m_dPktSndPeriod = ceil(m_dPktSndPeriod * 1.125); m_iAvgNAKNum = (int)ceil(m_iAvgNAKNum * 0.875 + m_iNAKCount * 0.125); m_iNAKCount = 1; m_iDecCount = 1; m_iLastDecSeq = m_iSndCurrSeqNo; // remove global synchronization using randomization srand(m_iLastDecSeq); m_iDecRandom = (int)ceil(m_iAvgNAKNum * (double(rand()) / RAND_MAX)); if (m_iDecRandom < 1) m_iDecRandom = 1; } // 已经降速了,但上个拥塞周期发送出去的报文持续上报丢失,还是应该降速, // 但降速频度要低,一个周期最多不超过5次,而且还引入了一个随机值。 else if ((m_iDecCount ++ < 5) && (0 == (++ m_iNAKCount % m_iDecRandom))) { // 0.875^5 = 0.51, rate should not be decreased by more than half within a congestion period m_dPktSndPeriod = ceil(m_dPktSndPeriod * 1.125); m_iLastDecSeq = m_iSndCurrSeqNo; } }

3)onTimeout:当前,EXP 定时器超时只在慢启动阶段生效。

void CUDTCC::onTimeout() { // 慢启动阶段,EXP定时器超时,慢启动阶段结束 if (m_bSlowStart) { m_bSlowStart = false; if (m_iRcvRate > 0) m_dPktSndPeriod = .0 / m_iRcvRate; else m_dPktSndPeriod = m_dCWndSize / (m_iRTT + m_iRCInterval); } else { /* m_dLastDecPeriod = m_dPktSndPeriod; m_dPktSndPeriod = ceil(m_dPktSndPeriod * 2); m_iLastDecSeq = m_iLastAck; */ } }
4.4.2 原理概述
4.4.2.1 慢启动

和 TCP 一样,UDT 也有慢启动阶段。发送端基于设置的发包间隔不停的发包,接收端会不停的反馈 ACK,当出现以下条件之一,慢启动阶段结束:

1)拥塞窗口大小增长到上限值。(这个上限值为接收端的流控窗口大小)

2)发生丢包。

3)EXP 定时超时。

4.4.2.2 发包间隔

UDT 通过发包间隔来控制发包速率,实现了平滑发包。发包间隔的调整和更新机制如下:

1)初始化为 1 微秒。

2)设置调整周期(SYN),有节奏的调整。

3)慢启动阶段不调整发包间隔,慢启动结束时,根据接收端反馈的接收速率更新发包间隔。

4)慢启动结束后,收到 ACK 时尝试将发包间隔减小(提速),收到丢包报告时尝试增加发包间隔(降速)。

下图是参考文档4)中列举的一个例子,B 为链路估算带宽,x 为发送速率,B – x 为链路剩余带宽容量,SYN 的典型值为 0.01 秒,MSS 为 1500bytes。每秒发包速率增加值最大可能为:10 * 1500 * 100 * 8 = 12,000,000 bit/s = 12Mbps。

UDT实现原理探析

4.4.2.3 拥塞窗口

拥塞窗口是为了避免网络拥塞,最大化利用网络资源。UDT 拥塞窗口调整和更新机制如下:

1)初始化最大拥塞窗口大小为接收端的流控窗口大小。

2)在慢启动阶段,拥塞窗口会尽可能增大,直到窗口上限或者出现丢包、超时。

3)慢启动结束后,拥塞窗口根据接收速率和RTT来调整。

4.5 流量控制

流量控制的目的是防止接收端被流量冲垮,限制发送方的发送速率不要太快。UDT 在限制 inflight 报文数量时,取拥塞控制窗口和流量控制窗口二者中较小者。

UDT 也有流量控制机制,以下三种情况下会更新流量窗口大小:

1)在连接建立过程中,双方会通告流控窗口大小。

2)接收端会通过 ACK 报文不断更新自己的接收窗口大小。

3)lite ACK 报文没有携带 Flow Window 字段,UDT 的处理方式是减小接收窗口大小。

// process a lite ACK if (4 == ctrlpkt.getLength()) { ack = *(int32_t *)ctrlpkt.m_pcData; if (CSeqNo::seqcmp(ack, m_iSndLastAck) >= 0) { m_iFlowWindowSize -= CSeqNo::seqoff(m_iSndLastAck, ack); m_iSndLastAck = ack; } break; }

5. 其他

5.1 KCP对比

KCP 也是一个优秀网络库,但严格来说 KCP 只是一个纯算法实现的网络协议库,并不负责底层协议的收发,底层网络可以是 UDP,也可以是其他。UDT 是一个提供完整网络编程能力的库,内部完成传输的所有方面,包括网络收发、定时器、协议和算法等。下表对比了两者实现机制上的差异:

比较项

KCP

UDT

备注

平滑发送

没有

KCP没有带宽评估,不好做平滑

带宽评估

没有

UDT的带宽评估效果待验证

流量控制

机制基本相同

拥塞控制

KCP是基于窗口的控制,UDT是基于窗口+速率的混合控制

快速重传

KCP是经典快速重传机制,UDT是基于NACK的快速重传机制

定时器

KCP需要外部定时器驱动,UDT内部实现了高精度定时器

IPv6

支持

支持

KCP与底层网络无关,UDT代码实现上有支持

可靠性

可靠

可靠/不可靠

KCP是可靠协议,UDT的stream模式是可靠的,message模式是不可靠的(或者说部分可靠)

下面重点分析 KCP 和 UDT 拥塞控制算法的差异。

KCP 的拥塞控制框架和 TCP 类似,分慢启动阶段和拥塞避免阶段。慢启动阶段 cwnd 线性增长(从1开始,每个 ACK 加1),直到发生重传(超时重传或快速重传),慢启动结束,进入拥塞避免阶段。拥塞避免阶段,cwnd 的增长比较保守,每个ACK的增量为:1/16 + 1/cwnd。拥塞避免阶段,如果发生快速重传,ssthresh 设置为 inflight/2,cwnd 等于 ssthresh,也就是快速恢复;如果发生超时重传,ssthresh设置为 cwnd/2,cwnd 等于 1,重新进入慢启动阶段。

UDT 与 KCP 拥塞控制的最大区别是,KCP 是基于窗口的控制,UDT是基于窗口和速率的混合控制。也就是说,KCP 的拥塞控制算法主要控制 cwnd 大小,UDT 的拥塞控制算法,除了控制 cwnd 外,还要控制发包速率。

UDT 也有慢启动阶段和拥塞避免阶段。但 UDT 的慢启动阶段只有开始的一次,慢启动结束后,一直处于拥塞避免阶段。在慢启动阶段,UDT 的 cwnd 增长方式和 KCP 类似,但是 UDT 的慢启动结束条件,除了丢包外(NAK反馈的丢包),还设置了 cwnd 增长上限,这个上限就是远端流控窗口大小(握手协商参数)。拥塞避免阶段,cwnd 的更新主要与对端反馈的接收速率和 RTT 相关。

UDT 的发包速率控制,是通过控制发包间隔来间接控制的,具体控制算法前面已经讲过,在此不在赘述。

5.2 值得借鉴的地方

5.2.1 连接协商

携带外部可见 IP 地址/Cookie 校验安全机制。

5.2.2 锁的粒度

锁的作用域尽可能小,不同的资源使用不同的锁,能够有效降低锁对处理性能的影响。

5.3 疑惑点

5.3.1 异常

源码中使用了C++异常,暂时没有想明白为什么要这么做。

5.3.2 带宽评估

RBPP(Receiver-Based Packet Pair) 这种带内带宽评估或拥塞控制方式看起来比较高级,也是比较理想的方式,但是评估的准确性有待确认,简单测试了下感觉不是太准确,需要做进一步研究。

6. 参考文献

1)UDT协议详解

UDT协议详解_udt 原理-CSDN博客

2)The UDT Congestion Control Algorithm

Detailed Implementation

3)UDT官网

UDT: Breaking the Data Transfer Bottleneck

4)UDT: Break The Data Transfer Bottleneck

https://udt.sourceforge.io/doc/udt-2009.ppt

5)Experiences in Design and Implementation of a High Performance Transport Protocol

https://www.researchgate.net/publication/_Experiences_in_Design_and_Implementation_of_a_High_Performance_Transport_Protocol

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/136867.html

(0)
上一篇 2025-06-24 17:20
下一篇 2025-06-24 17:33

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信