【Linux高级全栈开发】2.2.3 UDP的可靠传输协议QUIC
【Linux高级全栈开发】2.2.3 UDP的可靠传输协议QUIC
https://github.com/0voice
2.2.3 UDP的可靠传输协议QUIC
1. 如何做到可靠性传输
1.1 ACK机制
- 作用:接收端向发送端反馈数据接收状态,确保发送端知晓数据是否成功到达。
- 原理:
- 正向确认:接收端收到数据后,向发送端返回ACK 报文,其中包含 “期望接收的下一个数据段序号”(即确认号)。
- 例如:接收端成功收到序号 100~199 的数据段,会返回确认号 200,表示 “已收到前 200 字节数据,期待下一个数据段从 200 开始”。
- 超时重传触发:若发送端在 超时时间(Timeout) 内未收到 ACK,会认为数据丢失,触发重传机制。
- 累积确认:接收端可对多个连续数据段合并确认(如 TCP 默认采用累积确认),减少 ACK 报文数量。
- 正向确认:接收端收到数据后,向发送端返回ACK 报文,其中包含 “期望接收的下一个数据段序号”(即确认号)。
1.2 重传机制 重传策略
-
作用:恢复丢失或出错的数据段,确保数据最终到达。
1. 重传策略
- 超时重传(Timeout Retransmission):
- 发送端发送数据后启动定时器,若超时未收到 ACK,则重传对应数据段。
- 超时时间的动态调整:通过测量 ** 往返时间(RTT)** 动态计算超时时间(如 TCP 的自适应重传算法),避免固定超时时间导致的效率问题。
- 快速重传(Fast Retransmission):
- 接收端若连续收到多个重复序号的 ACK(如三次确认号为 200 的 ACK),表明后续数据段丢失,发送端无需等待超时,直接重传丢失的数据段。
- 示例:发送端发送序号 200、300、400 的数据段,若 300 丢失,接收端收到 400 时会返回确认号 200(期望接收 200),连续三次确认号 200 的 ACK 触发快速重传。
2. 重传范围
- 单个数据段重传:仅重传确认丢失的单个数据段(如快速重传场景)。
- 回退 N 步重传(Go-Back-N ARQ):若某个数据段丢失,重传该段及之后所有已发送未确认的数据段(适用于出错率低的信道)。
- 选择重传(Selective Repeat ARQ):仅重传真正丢失的数据段(需接收端支持选择性确认 SACK),效率更高但实现复杂。
- 超时重传(Timeout Retransmission):
1.3 序号机制
- 作用:标识数据段的顺序,解决乱序问题,并协助检测重复数据。
- 原理:发送端为每个数据段分配唯一的序号(如 TCP 中以字节为单位编号),接收端通过序号判断数据是否按顺序到达。若接收端收到序号不连续的数据(如跳过了某个序号),会标记该位置为 “缺失”,并等待后续数据填补或触发重传。重复数据可通过序号直接识别(同一序号数据段多次到达),接收端会丢弃重复段并返回确认。
- 示例:发送端发送序号为 100、200、300 的数据段,若接收端先收到 300,会暂存并等待 100 和 200,直到序号连续后再提交给上层应用。
1.4 重排机制
- 作用:将乱序到达的数据段重新排序,按顺序提交给上层应用。
- 原理:接收端维护一个接收窗口(缓冲区),暂存乱序到达的数据段。通过序号判断数据段的顺序,将已到达的连续数据段先提交给应用层,剩余乱序段等待后续数据填补。若等待时间超过阈值(如 TCP 的重排序超时),未填补的缺失段将触发重传。
1.5 窗口机制 流量控制 带宽有限
-
作用:实现流量控制(Flow Control),协调发送端和接收端的速率,避免发送过快导致接收端缓冲区溢出;同时通过批量发送数据提升传输效率。
1. 流量控制与窗口大小
- 接收端告知窗口大小:接收端在 ACK 报文中携带 接收窗口(Advertised Window) 字段,表示当前接收缓冲区剩余空间,限制发送端最多可发送的数据量。
- 发送端滑动窗口:发送端维护一个 “发送窗口”,包含已发送未确认和未发送的数据段。窗口右边界随 ACK 确认而右移(滑动),左边界随数据确认被移除。
- 示例:接收窗口为 300 字节,发送端已发送序号 100~199(100 字节)未确认,则剩余可发送窗口为 200 字节(300-100),可继续发送序号 200~399 的数据。
2. 与带宽的关系
- 拥塞控制(Congestion Control):窗口机制还需结合拥塞控制(如 TCP 的慢启动、拥塞避免算法),根据网络带宽动态调整发送窗口大小,避免网络拥塞。
- 吞吐量优化:合理设置窗口大小可充分利用带宽(如窗口大小 ≥ 带宽 ×RTT,即 “带宽延迟积”),减少因等待 ACK 导致的空闲时间。
2 如何选择UDP与TCP
2.1 UDP与TCP区别
选项 | UDP | TCP |
---|---|---|
是否连接 | 无连接 | 面向连接 |
是否可靠 | 不可靠传输,不使用流量控制和拥塞控制 | 可靠传输,使用流量控制和拥塞控制 |
连接对象个数 | 支持一对一,一对多,多对一和多对多交互通信 | 只能是一对一通信 |
传输方式 | 面向报文 | 面向字节流 |
首部开销 | 首部开销小,仅8字节 | 首部最小20字节,最大60字节 |
适用场景 | 适用于实时应用(IP电话、视频会议、直播等)游戏行业、物联网行业 Sendto 应用层可控性 | 适用于要求可靠传输的应用,例如文件传输 协议栈send |
2.2 TCP和UDP格式对比
- UDP优点:
- 无连接:不需要像 TCP 那样三次握手建立连接,减少了开销和延迟,能快速发送数据 。
- 开销小:头部开销只有 8 字节,相比 TCP 的 20 字节更小,数据传输效率高。
- 高并发性能好:适合大量短连接、对实时性要求高的场景,如视频直播、在线游戏等,能同时处理多个请求。
- UDP缺点:
- 不可靠:不保证数据按序到达、不保证数据不丢失、不保证数据不重复,需要应用层自己处理这些问题。
- 无拥塞控制:可能会因为网络拥塞导致丢包等问题,且自身无法像 TCP 那样自适应调整发送速率。
- 在网络中,我们认为传输是不可靠的,而在很多场景下我们需要的是可靠的数据,所谓的可靠,指的是数据能够正常收到,且能够顺序收到,于是就有了ARQ协议,TCP之所以可靠就是基于此。
2.3 ARQ协议(Automatic Repeat-reQuest)
ARQ协议(Automatic Repeat-reQuest),即自动重传请求,是传输层的错误纠正协议之一,它通过使用确认和超时两个机制,在不可靠的网络上实现可靠的信息传输。
ARQ协议主要有3种模式:
- **即停等式(**stop-and-wait)ARQ 一般不用
- 回退n帧(go-back-n)ARQ,
- 选择性重传(selective repeat)ARQ
2.3.1 ARQ协议-停等式(stop-and-wait)
- 停等协议的工作原理如下:
-
发送方对接收方发送数据包,然后等待接收方回复ACK并且开始计时。
-
在等待过程中,发送方停止发送新的数据包。
-
当数据包没有成功被接收方接收,接收方不会发送ACK.这样发送方在等待一定时间后,重新发送数据包。
-
反复以上步骤直到收到从接收方发送的ACK.
- 缺点:较长的等待时间导致低的数据传输速度。
2.3.2 ARQ协议-回退n帧(go-back-n)ARQ 1
为了克服停等协议长时间等待ACK的缺陷,连续ARQ协议会连续发送一组数据包,然后再等待这些数据包的ACK。
-
什么是滑动窗口:发送方和接收方都会维护一个数据帧的序列,这个序列被称作窗口。发送方的窗口大小由接收方确定,目的在于控制发送速度,以免接收方的缓存不够大,而导致溢出,同时控制流量也可以避免网络拥塞。协议中规定,对于窗口内未经确认的分组需要重传。
-
回退N步(Go-Back-N,GBN):回退N步协议允许发送方在等待超时的间歇,可以继续发送分组。所有发送的分组,都带有序号。在GBN协议中,发送方需响应以下三种事件:
- 上层的调用。上层调用相应send()时,发送方首先要检查发送窗口是否已满。
- 接收ACK。在该协议中,对序号为n的分组的确认采取累积确认的方式,表明接收方已正确接收到序号n以前(包括n)的所有分组。
- 超时。若出现超时,发送方将重传所有已发出但还未被确认的分组
- 举例:如上图所示,序号为2的分组丢失,因此分组2及之后的分组都将被重传。
2.3.3 ARQ协议-选择重传(Selective-repeat) 1
在SR协议下,发送方需响应以下三种事件:
- 从上层收到数据。当从上层收到数据后,发送方需检查下一个可用于该分组的序号。若序号在窗口中则将数据发送。
- 接收ACK。若收到ACK,且该分组在窗口内,则发送方将那个被确认的分组标记为已接收。若该分组序号等于基序号,则窗口序号向前移动到具有最小序号的未确认分组处。若窗口移动后并且有序号落在窗口内的未发送分组,则发送这些分组。
- 超时。若出现超时,发送方将重传已发出但还未确认的分组。与GBN不同的是,SR协议中的每个分组都有独立的计时器。
2.4 RTT和RTO
◼ RTO(Retransmission TimeOut)即重传超时时间。
◼ RTT(Round-Trip Time): 往返时延。表示从发送端发送数据开始,到发送端收到来自接收端的确认(接收端收到数据后便立即发送确认),总共经历的时延。
由三部分组成:
◼ 链路的传播时间(propagation delay)
◼ 末端系统的处理时间、
◼ 路由器缓存中的排队和处理时间(queuing delay)
其中,前两个部分的值对于一个TCP连接相对固定,路由器缓存中的排队和处理时间会随着整个网络拥塞程度的变化而变化。 所以RTT****的变化在一定程度上反应网络的拥塞程度。
2.4.1 流量控制
通俗易懂讲解TCP流量控制机制,了解一下
◼ 双方在通信的时候,发送方的速率与接收方的速率是不一定相等,如果发送方的发送速率太快,会导致接收方处理不过来,这时候接收方只能把处理不过来的数据存在缓存区里(失序的数据包也会被存放在缓存区里) 接收缓存。
◼ 如果缓存区满了发送方还在疯狂着发送数据,接收方只能把收到的数据包丢掉,大量的丢包会极大着浪费网络资源,因此,我们需要控制发送方的发送速率,让接收方与发送方处于一种动态平衡才好。
接收方每次收到数据包,可以在发送确定报文的时候,同时告诉发送方自己的缓存区还剩余多少是空闲的,我们也把缓存区的剩余大小称之为接收窗口大小,用变量win来表示接收窗口的大小。
◼ 发送方收到之后,便会调整自己的发送速率,也就是调整自己发送窗口的大小,当发送方收到接收窗口的大小为0时,发送方就会停止发送数据,防止出现大量丢包情况的发生。
当发送方停止发送数据后,该怎样才能知道自己可以继续发送数据?
-
当接收方处理好数据,接受窗口 win > 0 时,接收方发个通知报文去通知发送方,告诉他可以继续发送数据了。当发送方收到窗口大于0的报文时,就继续发送数据。
-
当发送方收到接受窗口 win = 0 时,这时发送方停止发送报文,并且同时开启一个定时器,每隔一段时间就发个测试报文去询问接收方,打听是否可以继续发送数据了,如果可以,接收方就告诉他此时接受窗口的大小;如果接受窗口大小还是为0,则发送方再次刷新启动定时器。
2.5拥塞控制
2.6 UDP编程模型
3 UDP如何可靠,KCP协议在哪些方面有优势
以10%-20%带宽浪费的代价换取了比 TCP快30%-40%的传输速度。
RTO翻倍vs不翻倍:
TCP超时计算是RTOx2,这样连续丢三次包就变成RTOx8了,十分恐怖,而KCP启动快速模式后不x2,只是x1.5(实验证明1.5这个值相对比较好),提高了传输速度。以RTO=100ms为例:
选择性重传 vs 全部重传:
TCP丢包时会全部重传从丢的那个包开始以后的数据,KCP是选择性重传,只重传真正丢失的数据包。
快速重传(跳过多少个包马上重传)(如果使用了快速重传,可以不考虑RTO):
发送端发送了1,2,3,4,5几个包,然后收到远端的ACK: 1, 3, 4, 5,当收到ACK3时,KCP知道2被跳过1次,收到ACK4时,知道2被跳过了2次,此时可以认为2号丢失,不用等超时,直接重传2号包,大大改善了丢包时的传输速度。
延迟ACK vs 非延迟ACK:
TCP为了充分利用带宽,延迟发送ACK(NODELAY-针对发送的都没用),这样超时计算会算出较大 RTT时间,延长了丢包时的判断过程。KCP的ACK是否延迟发送可以调节。
UNA vs ACK+UNA:
ARQ模型响应有两种,UNA(此编号前所有包已收到,如TCP)和ACK(该编号包已收到),光用UNA将导致全部重传,光用ACK则丢失成本太高,以往协议都是二选其一,而 KCP协议中,除去单独的 ACK包外,所有包都有UNA信息。
非退让流控:
KCP正常模式同TCP一样使用公平退让法则,即发送窗口大小由:发送缓存大小、接收端剩余接收缓存大小、丢包退让及慢启动这四要素决定。但传送及时性要求很高的小数据时,可选择通过配置跳过后两步,仅用前两项来控制发送频率。以牺牲部分公平性及带宽利用率之代价,换取了开着BT都能流畅传输的效果。
核心差异对比
维度 | TCP | KCP |
---|---|---|
底层协议 | 基于 IP,属于传输层原生协议 | 基于 UDP,属于应用层协议 |
连接性 | 面向连接(三次握手 / 四次挥手) | 无连接(逻辑上的 “连接” 由应用层维护) |
可靠性实现 | 全量重传(默认重传所有未确认数据) | 选择性重传(仅重传丢失的数据包) |
重传机制 | 超时重传、快速重传(3 次冗余 ACK 触发) | 更快的重传策略(如默认 2 次冗余 ACK 触发) |
拥塞控制 | 慢启动、拥塞避免、快速恢复(AIMD 算法) | PRC(比例速率控制)+ 延迟确认机制 |
延迟优化 | 注重稳定性,延迟相对较高 | 减少不必要的等待(如 ACK 延迟),降低延迟 |
带宽利用率 | 在稳定网络中利用率高,但弱网下可能保守 | 弱网下通过激进策略提升带宽利用率 |
适用网络环境 | 适合高稳定性网络(如局域网、有线网络) | 适合高延迟、高丢包网络(如移动网络、广域网) |
系统依赖 | 依赖操作系统 TCP 协议栈实现 | 可在用户空间实现,跨平台兼容性强 |
4 KCP的设计方案与算法原理
- 设计方案:KCP(Keep - Cool - Package)是一个快速可靠协议,针对网络状况不好的场景设计。它在应用层实现,基于 UDP,通过优化算法来提高传输效率和可靠性。
- 算法原理:
- 快速重传:KCP 采用了比 TCP 更激进的快速重传机制。当检测到丢包时,不等超时就快速重传,减少重传等待时间。
- 选择性重传:KCP 只重传真正丢失的数据包,而不是像 TCP 那样可能会重传已经正确接收但序号不连续的数据包,提高了重传效率。
- 流量控制与拥塞控制:KCP 实现了自己的流量控制和拥塞控制算法,根据网络状况动态调整发送窗口大小,适应不同的网络环境,避免网络拥塞。
4.1 名词说明
◼ 名词说明
- 用户数据:应用层发送的数据,如一张图片2Kb的数据
- MTU:最大传输单元。即每次发送的最大数据,1500 实际使用1400
- RTO:Retransmission TimeOut,重传超时时间。
- cwnd: congestion window,拥塞窗口,表示发送方可发送多少个KCP数据包。与接收方窗口有关,与网络状况(拥塞控制)有关,与发送窗口大小有关。
- rwnd: receiver window,接收方窗口大小,表示接收方还可接收多少个KCP数据包
- snd_queue: 待发送KCP数据包队列
- snd_buf:
- snd_nxt:下一个即将发送的kcp数据包序列号
- snd_una:下一个待确认的序列号,即是之前的包接收端都已经收到。
4.2 kcp使用方式
-
创建 KCP对象:
ikcpcb *kcp = ikcp_create(conv, user);
-
设置发送回调函数(如UDP的send函数):
kcp->output = udp_output;
- 真正发送数据需要调用
sendto
- 真正发送数据需要调用
-
循环调用
update:ikcp_update(kcp, millisec);
//在一个线程、定时器 5ms/10m做调度 -
输入一个应用层数据包(如UDP收到的数据包):
ikcp_input(kcp,received_udp_packet,received_udp_size);
- 我们要使用
recvfrom
接收,然后扔到kcp里面做解析
- 我们要使用
-
发送数据:
ikcp_send(kcp1, buffer, 8);
用户层接口 -
接收数据:
hr = ikcp_recv(kcp2, buffer, 10);
用户层读取数据
4.3 kcp源码流程图
-
发送数据,调用ikcp send接口由ikcp update调度的时候发送出去
-
recvfrom读取udp数据
-
问题
◼ sendto每次发送多长的数据?
UDP 数据报的最大长度是 65,507 字节;
网络 MTU(通常为 1,500 字节)可能导致 IP 分片,一般用1400字节
◼ ikcp_send可以发送多大长度的数据?
单次调用:避免超过 MTU(通常 1,400-1,472 字节)
◼ 如何进行ack?
- 带内确认:ACK 信息嵌入在数据包中,不单独发送
- 累积确认:确认号表示 “直到该序列号的所有包已收到”
- 快速重传:当收到 3 次相同 ACK 时,立即重传对应数据包
- 超时重传:设置 RTO(Retransmission Timeout)计时器
◼ 窗口机制如何实现?
- 发送窗口:控制已发送但未确认的数据
- 接收窗口:控制可接收的数据包范围
- 窗口大小:默认 32 个包,可通过
ikcp_wndsize()
调整 - KCP 窗口滑动条件:
- 发送窗口:当收到新 ACK 时向前滑动
- 接收窗口:当按序接收数据包并交付应用层后滑动
4.4 kcp配置模式
-
工作模式:
int ikcp_nodelay(ikcpcb *kcp, int nodelay, int interval, int resend, int nc)
-
nodelay :是否启用 nodelay模式,0不启用;1启用。
-
interval :协议内部工作的 interval,单位毫秒,比如 10ms或者 20ms
-
resend :快速重传模式,默认0关闭,可以设置2(2次ACK跨越将会直接重传)
-
nc :是否关闭流控,默认是0代表不关闭,1代表关闭。(越极速,越浪费带宽)
-
默认模式:
ikcp_nodelay(kcp, 0, 10, 0, 0);
-
普通模式:
ikcp_nodelay(kcp, 0, 10, 0, 1);
关闭流控等 -
极速模式:
ikcp_nodelay(kcp, 2, 10, 2, 1)
,并且修改kcp1->rx_minrto = 10;kcp1->fastresend = 1;
-
-
-
最大窗口:
int ikcp_wndsize(ikcpcb *kcp, int sndwnd, int rcvwnd);
该调用将会设置协议的最大发送窗口和最大接收窗口大小,默认为32,单位为包。 -
最大传输单元:
int ikcp_setmtu(ikcpcb *kcp, int mtu);
kcp协议并不负责探测 MTU,默认 mtu是1400字节 -
最小RTO:不管是 TCP还是 KCP计算 RTO时都有最小 RTO的限制,即便计算出来RTO为40ms,由于默认的 RTO是100ms,协议只有在100ms后才能检测到丢包,快速模式下为30ms,可以手动更改该值: kcp->rx_minrto = 10;
4.5 kcp协议头
-
[0,3]conv:连接号。UDP是无连接的,conv用于表示来自于哪个客户端。对连接的一种替代
-
[4]cmd:命令字。如,IKCP_CMD_ACK确认命令,IKCP_CMD_WASK接收窗口大小询问命令,IKCP_CMD_WINS接收窗口大小告知命令,
-
-
[5]frg:分片,用户数据可能会被分成多个KCP包,发送出去
-
[6,7]wnd:接收窗口大小,发送方的发送窗口不能超过接收方给出的数值
-
[8,11]ts:时间序列
-
[12,15]sn:序列号
-
[16,19]una:下一个可接收的序列号。其实就是确认号,收到sn=10的包,una为11
-
[20,23]len:数据长度
-
data:用户数据,这一次发送的数据长度
4.6 kcp发送数据过程
- ikcp_send 阶段:应用层调用
ikcp_send
函数发送数据,这里要发送的是 1900 字节的图片数据 。KCP 协议会将数据进行分包处理,每个包都有一个 24 字节的 Header(头部),用于携带控制信息,如包序号、校验和等。剩余部分为 Data(数据),这里分成了 1376 字节和 524 字节两部分数据块 。 - sdn_queue 阶段:分包后的数据包会被放入发送队列
sdn_queue
中,等待进一步处理。此时数据包按顺序在队列中排列,准备进入发送窗口。 - snd_buf 阶段(发送窗口)
- 窗口相关参数:
snd_nxt
表示下一个要发送的包序号,这里值为 11 。snd_una
表示已经被确认(应答)的最大包序号,这里值为 9 。cwnd
(拥塞窗口)大小为 5,表示在未收到确认前最多能发送 5 个包 。
- 数据包状态:序号 8 的包已经被应答;序号 9 的包由于超时未收到应答,需要进行超时重传;序号 11、12、13 的包处于第一次传送状态,等待接收方确认 。
- 窗口相关参数:
- sendto 阶段:KCP 协议会从发送窗口(
snd_buf
)中取出数据包,调用sendto
函数将其发送到网络上。这里会发送需要重传的序号 9 的包,以及第一次传送的序号 11、12、13 的包 。
发送数据时 ikcp_send
函数相关的处理流程:
ikcp_send
功能:ret = ikcp_send(kcp, buf, 8)
用于发送应用数据,这里只是将应用数据加入到send_queue
,还未真正发送到网络 。- 计算分包数量
- 首先计算数据最多能分成多少个
frag
(片段)。如果数据长度len
小于等于最大段大小mss
,则count
(分包数量)为 1 ;否则count = (len + mss - 1) / mss
。这是一种常见的整数除法取整来计算分包数的方法,确保数据能合理拆分。 - 接着会检查
count
是否超过对方接收窗口大小,如果超过则返回报错。因为若发送的分包数超出接收方接收能力,会导致接收混乱 。
- 首先计算数据最多能分成多少个
- 插入发送队列:将数据全部新建
segment
(段)插入发送队列尾部,每插入一个,队列计数递增,frag
数量递减 。这样数据就按顺序在发送队列中准备后续的发送处理。
ikcp_update
与 ikcp_flush
调度
-
ikcp_update
用于定时调度,检测网络状态等相关信息。ikcp_flush
则负责调度发送数据 。 -
发送准备相关操作
- 读取应答信息:读取
acklist
获取需要应答的包序号,若超过发送窗口大小则直接发送 。 - 接收窗口检测:检测对端接收窗口,如果为 0,计算是否要发送探测包 。
- 发送探测包与通知窗口
- 若
kcp->probe & IKCP_ASK_SEND
条件满足,发送对端接收窗口探测包。 - 若
kcp->probe & IKCP_ASK_TELL
条件满足,通知对端自己的接收窗口 。
- 若
- 窗口选择
- 多次检测发送窗口和对方接收窗口,选择其中较小的窗口值。若使用拥塞控制,还需对比拥塞窗口
cwnd
,取最小值,且拥塞窗口是动态变化的 。
- 多次检测发送窗口和对方接收窗口,选择其中较小的窗口值。若使用拥塞控制,还需对比拥塞窗口
- 数据包发送处理
- 数据包入窗:将发送队列
snd_queue
中合适的包拷贝到发送窗口snd_buf
。 - 发送判断
- 首次发送:对于
xmit == 0
的包,即第一次发送的包进行发送处理 。 - 重传判断:检查是否到达重传时间,若到达则进行重传 。
- 快速重传:通过
fastack
机制跳过一些序号的包。比如收到对ack 4
的应答,但2
未应答,就跳过2
。
- 首次发送:对于
- 发送操作:当有满足发送条件的包(
needsend
条件满足)时,将包发送到 UDP 协议层。发送时会更新包的相关时间戳(segment->ts = current
)、接收窗口信息(segment->wnd = seg.wnd
)、已确认序号(segment->una = kcp->rcv_nxt
)等 。
- 拥塞窗口调整
若 lost == 1
且 change
非 0,说明有重传包,此时会动态调整拥塞窗口 。
4.6 kcp发送窗口
- snd_wnd:固定大小,默认32
- rmt_wnd:远端接收窗口大小,默认32
- cwnd:滑动窗口,可变,越小一次能发送的数据越小
- 发送速率的控制是:本质是根据滑动窗口控制把数据从snd_ queue 加入到send_buf 。
4.7 kcp接收数据过程
接收窗口
- snd_wnd:固定大小,默认32
- rmt_wnd:远端接收窗口大小,默认32
- cwnd:滑动窗口,可变,越小一次能发送的数据越小
- 接收窗口的控制是:recv_queue的接收能力,比如默认接收端口为32,如果recv_queue接收了32个包后则接收窗口为0,然后用户读走了32个包,则接收窗口变为32。
4.8 kcp确认包处理流程
4.9 kcp快速确认
4.10 ikcp_input逻辑
4.11 应答列表acklist
-
收到包后将序号,时间戳存储到应答列表。
在ikcp_input函数调用ikcp_ack_push存储应答包
ikcp_ack_push(kcp, sn, ts); // 对该报文的确认 ACK 报文放入 ACK 列表中
-
发送应答包
在ikcp_flush函数发送应答包
-
应答包解析
在ikcp_input函数进行解析,判断IKCP_CMD_ACK
4.12 流量控制和拥塞控制
- RTO计算(与TCP完全一样)
- RTT:一个报文段发送出去,到收到对应确认包的时间差。
- SRTT(kcp->rx_srtt):RTT的一个加权RTT平均值,平滑值。
- RTTVAR(kcp->rx_rttval):RTT的平均偏差,用来衡量RTT的抖动。
- 多个rtt取平均
4.13 探测对方接收窗口
4.14 如何在项目中集成kcp
- 如何集成到项目中,参考代码的chat_server.cc和chat_client.cc这两个代码实现聊天室功能
-
客户端(client):
KcpClient
进行封装,其中UdpSocket
负责读取 UDP 数据 。KcpSession
用于处理 KCP 状态以及发送 UDP 数据 。sockaddr_in
用于存储服务器的 IP 地址和端口 。
-
服务端(server):
KcpServer
进行封装,UdpSocket
负责读取 UDP 数据 。KcpSession
用于处理 KCP 状态和发送 UDP 数据 。sockaddr_in
用来存储服务器自身的 IP 地址和端口 。
-
中间层封装:
KcpSession
进一步封装了UdpSocket
、sockaddr_in
以及kcp
。UdpSocket
则是对 UDP 通道和 socket 进行了封装 。- 还介绍了
sockaddr_in
结构体,包含地址族(sin_family
,常为AF_INET
表示 IPv4 )、端口号(sin_port
,网络字节序 )、IP 地址(sin_addr
,网络字节序 )、填充字段(sin_zero
,常为 0 ) 。
-
UdpSocket 核心功能:
Bind
:服务端用于绑定端口。RecvFrom
:接收 UDP 数据。SendTo
:发送 UDP 数据。
-
KcpSession 核心功能:
Update
:客户端用于调度 KCP 和发送心跳包;服务端用于 KCP 调度相关操作。Input
:接收RecvFrom
获取到的数据。Send
:发送用户数据(内部未直接调SendTo
)。Recv
:用户读取拼接好的数据。conv
:获取 KCP 的会话 id。SetKeepAlive
:服务端设置当前 session 最新的心跳时间。
-
KcpClient 核心功能:
- 包含服务端 IP、端口、
kcp_opt
参数 。 Run
:需在线程里运行,处理 KCP 的调度和收发数据。Send
:发送用户数据。HandleMessage
:处理服务端发来的用户数据。CheckRet
:判断RecvFrom
的返回值。
- 包含服务端 IP、端口、
-
KcpServer 核心功能:
- 包含监听的 IP、端口、
kcp_opt
参数 。 Run
:内部自行创建线程,处理 KCP 的调度和收发数据。Update
:KCP 调度相关。HandleSession
:处理客户端数据。Notify
:处理客户端发送过来的用户数据 。
- 包含监听的 IP、端口、
5. udp 高并发的设计方案
- 多线程 / 多进程:利用多线程或多进程模型,每个线程 / 进程处理一个或多个 UDP 连接请求,充分利用多核 CPU 资源,提升并发处理能力。例如在服务器端,使用线程池来管理线程,避免频繁创建和销毁线程带来的开销。
- 事件驱动模型:基于事件驱动编程,如使用 epoll(Linux)、kqueue(FreeBSD)等 I/O 多路复用机制。当有 UDP 数据到达或其他相关事件发生时,系统才通知程序进行处理,减少 CPU 资源浪费,高效处理大量并发连接。
- 负载均衡:通过负载均衡器将大量 UDP 请求分发到多个后端服务器上,防止单个服务器负载过重。常见的负载均衡算法有轮询、加权轮询、最少连接数等。
5.1 qq 早期为什么选择 udp 作为通信协议
- 实时性要求:QQ 早期主要用于即时通讯,聊天消息、状态同步等对实时性要求高,UDP 无连接、传输速度快的特点能满足快速传递消息的需求。
- 网络环境适应性:早期网络环境复杂,在一些网络质量较差的场景下,TCP 的重传机制可能导致消息延迟较高,而 UDP 可以快速发送消息,即使有少量丢包,在即时通讯场景下部分消息丢失对用户体验影响相对较小,且应用层可以采取一些简单策略(如定时重发)来弥补。
- 资源占用:UDP 头部开销小,在早期计算机和网络资源相对有限的情况下,使用 UDP 可以减少网络带宽和设备资源的占用。
5.2 udp 可靠传输原理
一般通过在应用层实现一些机制来保证 UDP 可靠传输:
- 序列号:给每个 UDP 数据包添加序列号,接收方根据序列号判断数据包是否按序到达,对乱序数据包进行缓存或重排。
- 确认机制:接收方收到数据包后,向发送方发送确认信息(ACK),发送方如果在一定时间内未收到 ACK,则重发数据包。
- 超时重传:发送方设置超时时间,若超时未收到 ACK,就重新发送数据包,以确保数据不丢失。
- 流量控制与拥塞控制:在应用层实现类似 TCP 的流量控制和拥塞控制算法,防止发送方发送数据过快导致接收方缓冲区溢出或网络拥塞。
6. quic 协议的设计原理
1.1 QUIC前世今生
- QUIC ,即 快速UDP网络连接 ( Quick UDP Internet Connections ), 是由Google 提出的实验性网络传输协议 ,位于 OSI 模型传输层。 QUIC 旨在解决TCP 协议的缺陷,并最终替代 TCP 协议, 以减少数据传输,降低连接建立延迟时间,加快网页传输速度。
1.2 QUIC协议术语
- ◼ QUIC****连接:Client和Server之间的通信关心,Client发起连接,Server接受连接
- ◼ 流(Stream):一个QUIC连接内,单向或者双向的有序字节流。一个QUIC连接可以同时包含多个Stream
- ◼ 帧(Frame):QUIC连接内的最小通信单元。一个QUIC数据包(packet)中的数据部分包含一个或多个帧
1.5 QUIC和TCP对比
对比项 | TCP | QUIC |
---|---|---|
握手延迟 | 需先进行三次握手建立连接,再进行 TLS 握手来实现安全传输,增加了建立连接的往返时间(RTT),延迟较高 | 将传输层握手和 TLS 握手合并,首次连接就能实现 0RTT(零往返时间),极大降低连接延迟 ,后续连接也可快速恢复 |
头阻塞问题 | 采用连接级别的序列号(seq)进行数据保序,一旦某个报文丢失,即使其他报文已到达,在丢失报文重传并确认前,后续所有应用数据包都无法被接收方按序处理,导致整个连接阻塞 | 基于 UDP 封装传输层 Stream(流),每个 Stream 内部单独保序,不同 Stream 之间相互独立,某个 Stream 丢包不影响其他 Stream 的数据传输,不存在连接级别的头阻塞问题 |
连接迁移 | 通过五元组(源 IP、源端口、目的 IP、目的端口、协议)在内核中维持会话(session),当网络环境变化(如 IP 地址或端口改变,像移动设备切换网络时 ),五元组变动,需重新建立连接 | UDP 本身不面向连接,QUIC 通过连接 ID(CID)标识连接,当网络变化导致五元组改变时,只要 CID 不变,就能维持 Session 状态,实现连接在不同网络间的无缝迁移,无需重新建立连接 |
特性迭代速度 | 在内核中实现,其协议改进和新特性添加需经过复杂的内核更新流程,涉及大量测试和验证,迭代缓慢 | 在用户态实现,不依赖内核修改,开发者可更灵活、快速地对协议进行更新和添加新特性,能更快适应新的网络需求和安全挑战 |
安全性 | TCP 头部完全明文传输,攻击者易获取诸如源 IP、目的 IP、端口号、序列号等信息,用于网络分析、攻击探测等 | 除必要字段外,QUIC 头部也进行加密,减少了敏感信息在传输过程中的暴露风险,增强了协议的安全 |
- 基于 UDP:QUIC(Quick UDP Internet Connections)基于 UDP 构建,继承 UDP 的优点,同时解决 UDP 不可靠等问题。
- 连接管理:使用加密的连接 ID 来标识连接,即使网络切换(如从 Wi - Fi 切换到移动数据)也能快速恢复连接,减少连接重建时间。
- 多路复用:类似于 HTTP/2 的多路复用,在一条连接上可以同时发送多个数据流,且不同数据流之间相互独立,避免了队头阻塞问题。
- 加密传输:在传输层就进行加密,使用 TLS 1.3 的加密套件,保证数据的安全性和隐私性。
- 快速握手:首次连接时,QUIC 通过减少握手次数,实现更快的连接建立,后续连接可以实现 0 - RTT(零往返时间)恢复,加快数据传输速度。
3 QUIC的特点
-
连接建立低时延
-
多路复用
-
无队头阻塞
-
灵活的拥塞控制机制
-
连接迁移
-
数据包头和包内数据的身份认证和加密
-
FEC前向纠错
-
可靠性传输
-
其他
4 quic 的开源方案 quiche
- 简介:quiche 是一个实现 QUIC 协议和 HTTP/3 的开源库,由 Mozilla 开发。它用 Rust 语言编写,具有内存安全、高性能等特点。
- 应用场景:可用于构建支持 QUIC 协议和 HTTP/3 的服务器和客户端软件,例如在 Web 浏览器(如 Firefox 部分版本已集成)、网络代理服务器等场景中应用,以提升网络传输性能和安全性。
- 优势:Rust 语言的特性使得 quiche 在安全性和性能上表现出色,并且它遵循开源协议,方便开发者进行二次开发和集成到自己的项目中。
7 总结
- TCP 可靠性相关
- 怎么可靠:通过序列号对发送数据编号,接收方按序接收,乱序可重排;确认应答机制,接收方收到数据发 ACK 确认,发送方未收到就重传;超时重传,设置超时时间,超了没收到确认就重发;滑动窗口机制,接收方告知窗口大小控制发送量,避免拥塞。
- 为什么可靠:基于上述机制,保证数据按序、完整、无差错到达,有流量控制和拥塞控制避免网络拥塞。
- UDP 用途和场景:无连接,开销小、延迟低,适用于实时性要求高、允许少量丢包场景,像视频直播、语音通话、在线游戏。
- UDP 做到可靠的方式:在应用层实现确认应答、超时重传等机制;定制重传策略是因不同应用对数据可靠性和实时性要求不同,合适策略平衡两者,如游戏对实时性敏感,重传不能太频繁。
- KCP 相关
- KCP 是基于 UDP 的可靠传输协议。
- 牺牲带宽换速度,采用快速重传和快速恢复算法,减少重传等待时间;
- 适当增加发送频率和数据量,以更多带宽消耗提升传输速度,适用于对延迟敏感场景,像竞技游戏。
- 抢流量是指在复杂网络环境中,能更高效利用带宽资源传输数据。
- UDP 编程相关
- 实现客户端和服务端编程:客户端创建 UDP 套接字,指定服务端 IP 和端口发送数据;服务端创建套接字,绑定端口监听,接收数据并处理。
- 服务端维护和客户端的连接:UDP 无连接,但服务端可记录客户端 IP 和端口标识连接;通过心跳包机制探测客户端状态;在应用层实现会话管理机制,维护交互逻辑。
2 「代码实现」
chat_client.cc
#include "kcp_client.h"
#include "trace.h"
#include <thread>// 基于KcpClient封装业务
class ChatClient : KcpClient {public:using KcpClient::KcpClient;void Start() {std::thread t([this]() {while (true) {usleep(10); // 客户端if (!Run()) {TRACE("error ouccur");break;}}});while (true) {std::string msg;std::getline(std::cin, msg); // 读取用户输入数据Send(msg.data(), msg.length()); // 发送消息}t.join(); // 等待线程退出}// 服务端发送过来的消息virtual void HandleMessage(const std::string &msg) override {std::cout << msg << std::endl;}virtual void HandleClose() override {std::cout << "close kcp connection!" << std::endl;exit(-1);}
};int main(int argc, char *argv[]) {// arg : ip + portif (argc != 3) {TRACE("args error please input [ip] [port]");exit(-1);}std::string ip = argv[1];uint16_t port = atoi(argv[2]);srand(time(NULL));KcpOpt opt;opt.conv = rand() * rand(); // uuid的函数opt.is_server = false;opt.keep_alive_timeout = 1000; // 保活心跳包间隔,单位msTRACE("conv = ", opt.conv); ChatClient client(ip, port, opt);client.Start();return 0;
}
chat_server.cc
#include "kcp_server.h"
#include <memory>
#include <mutex>
#include <sstream>
#include <unordered_set>
// 基于KcpServer封装业务
class ChatServer : public KcpServer {public:using KcpServer::KcpServer;// 接收客户端发送过来的消息virtual void HandleMessage(const KcpSession::ptr &session,const std::string &msg) override {TRACE(msg);std::stringstream ss;ss << "user from [" << session->GetAddrToString() << "] : " << msg;TRACE(ss.str());Notify(ss.str());}void HandleConnection(const KcpSession::ptr &session) {{ // 注意锁的粒度Lock lock(mtx_);users_.insert(session);}std::stringstream ss;ss << "user from [" << session->GetAddrToString()<< "] join the ChatRoom!";Notify(ss.str()); // 通知房间的所有人}void HandleClose(const KcpSession::ptr &session) {TRACE("close ", session->GetAddrToString());{Lock lock(mtx_);users_.erase(session);}std::stringstream ss;ss << "user from [" << session->GetAddrToString()<< "] left the ChatRoom!";Notify(ss.str());}void Notify(const std::string &str) {Lock lock(mtx_);for (auto &user : users_)user->Send(str.data(), str.size());}public:using Lock = std::unique_lock<std::mutex>;private:std::unordered_set<KcpSession::ptr> users_; // 业务保存std::mutex mtx_;
};int main(int argc, char *argv[]) {// arg : ip + portif (argc != 3) {TRACE("args error please input [ip] [port]");exit(-1);}std::string ip = argv[1];uint16_t port = atoi(argv[2]);KcpOpt opt;opt.conv = 0;opt.is_server = true;opt.keep_alive_timeout = 5000; // 超过5秒没有收到客户端的信息则认为其断开ChatServer server(opt, ip, port);server.Run();return 0;
}
client.c
#include <sys/types.h>
#include <sys/socket.h>
#include <pthread.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include "ikcp.h"#include <sys/time.h>
#include <sys/wait.h>
#include <arpa/inet.h>#include "delay.h"
#define DELAY_TEST2_N 1
#define UDP_RECV_BUF_SIZE 1500// 编译: gcc -o client client.c ikcp.c delay.c -lpthreadtypedef struct {unsigned char *ipstr;int port;ikcpcb *pkcp;int sockfd;struct sockaddr_in addr;//存放服务器的结构体char buff[UDP_RECV_BUF_SIZE];//存放收发的消息
}kcpObj;/* sleep in millisecond */
void isleep(unsigned long millisecond)
{#ifdef __unix /* usleep( time * 1000 ); */struct timespec ts;ts.tv_sec = (time_t)(millisecond / 1000);ts.tv_nsec = (long)((millisecond % 1000) * 1000000);/*nanosleep(&ts, NULL);*/usleep((millisecond << 10) - (millisecond << 4) - (millisecond << 3));#elif defined(_WIN32)Sleep(millisecond);#endif
}int udp_output(const char *buf, int len, ikcpcb *kcp, void *user){// printf("使用udp_output发送数据\n");kcpObj *send = (kcpObj *)user;//发送信息int n = sendto(send->sockfd, buf, len, 0,(struct sockaddr *) &send->addr,sizeof(struct sockaddr_in));//【】if (n >= 0) { //会重复发送,因此牺牲带宽printf("send:%d bytes\n", n);//24字节的KCP头部return n;} else {printf("udp_output: %d bytes send, error\n", n);return -1;}
}int init(kcpObj *send)
{ send->sockfd = socket(AF_INET,SOCK_DGRAM,0);if(send->sockfd < 0){perror("socket error!");exit(1);}bzero(&send->addr, sizeof(send->addr));//设置服务器ip、portsend->addr.sin_family=AF_INET;send->addr.sin_addr.s_addr = inet_addr((char*)send->ipstr);send->addr.sin_port = htons(send->port);printf("sockfd = %d ip = %s port = %d\n",send->sockfd,send->ipstr,send->port);}// 特别说明,当我们使用kcp测试rtt的时候,如果发现rtt过大,很大一种可能是分片数据没有及时发送出去,需要调用ikcp_flush更快速将分片发送出去。
void delay_test2(kcpObj *send) {// 初始化 100个 delay objchar buf[UDP_RECV_BUF_SIZE];unsigned int len = sizeof(struct sockaddr_in);size_t obj_size = sizeof(t_delay_obj);t_delay_obj *objs = malloc(DELAY_TEST2_N * sizeof(t_delay_obj));int ret = 0;int recv_objs = 0;//ikcp_update包含ikcp_flush,ikcp_flush将发送队列中的数据通过下层协议UDP进行发送ikcp_update(send->pkcp,iclock());//不是调用一次两次就起作用,要loop调用for(int i = 0; i < DELAY_TEST2_N; i++) {// isleep(1);delay_set_seqno_send_time(&objs[i], i); ret = ikcp_send(send->pkcp, (char *) &objs[i], obj_size); if(ret < 0) {printf("send %d seqno:%u failed, ret:%d, obj_size:%ld\n", i, objs[i].seqno, ret, obj_size);return;} // ikcp_flush(send->pkcp); // 调用flush能更快速把分片发送出去//ikcp_update包含ikcp_flush,ikcp_flush将发送队列中的数据通过下层协议UDP进行发送ikcp_update(send->pkcp,iclock());//不是调用一次两次就起作用,要loop调用int n = recvfrom(send->sockfd, buf, UDP_RECV_BUF_SIZE, MSG_DONTWAIT,(struct sockaddr *) &send->addr,&len);// printf("print recv1:%d\n", n);if(n < 0) {//检测是否有UDP数据包 // isleep(1);continue;}ret = ikcp_input(send->pkcp, buf, n); // 从 linux api recvfrom先扔到kcp引擎if(ret < 0)//检测ikcp_input是否提取到真正的数据{//printf("ikcp_input ret = %d\n",ret);continue; // 没有读取到数据} ret = ikcp_recv(send->pkcp, (char *)&objs[i], obj_size); if(ret < 0)//检测ikcp_recv提取到的数据 {printf("ikcp_recv1 ret = %d\n",ret);continue;}delay_set_recv_time(&objs[recv_objs]);recv_objs++;printf("recv1 %d seqno:%d, ret:%d\n", recv_objs, objs[i].seqno, ret);if(ret != obj_size) {printf("recv1 %d seqno:%d failed, size:%d\n", i, objs[i].seqno, ret);delay_print_rtt_time(objs, i);return;}}// 还有没有发送完毕的数据for(int i = recv_objs; i < DELAY_TEST2_N; ) {// isleep(1);//ikcp_update包含ikcp_flush,ikcp_flush将发送队列中的数据通过下层协议UDP进行发送ikcp_update(send->pkcp,iclock());//不是调用一次两次就起作用,要loop调用//ikcp_flush(send->pkcp); // 调用flush能更快速把分片发送出去 int n = recvfrom(send->sockfd, buf, UDP_RECV_BUF_SIZE, MSG_DONTWAIT,(struct sockaddr *) &send->addr,&len);// printf("recv2:%d\n", n);if(n < 0) {//检测是否有UDP数据包// printf("recv2:%d\n", n);isleep(1);continue;}ret = ikcp_input(send->pkcp, buf, n); if(ret < 0)//检测ikcp_input是否提取到真正的数据{printf("ikcp_input2 ret = %d\n",ret);continue; // 没有读取到数据} ret = ikcp_recv(send->pkcp, (char *)&objs[i], obj_size); if(ret < 0)//检测ikcp_recv提取到的数据 {// printf("ikcp_recv2 ret = %d\n",ret);continue;}printf("recv2 %d seqno:%d, ret:%d\n", recv_objs, objs[i].seqno, ret);delay_set_recv_time(&objs[recv_objs]);recv_objs++;i++;if(ret != obj_size) {printf("recv2 %d seqno:%d failed, size:%d\n", i, objs[i].seqno, ret);delay_print_rtt_time(objs, i);return;}}ikcp_flush(send->pkcp);delay_print_rtt_time(objs, DELAY_TEST2_N);
}void loop(kcpObj *send)
{unsigned int len = sizeof(struct sockaddr_in);int n,ret;// while(1){isleep(1);delay_test2(send);}printf("loop finish\n");close(send->sockfd);}int main(int argc,char *argv[])
{//printf("this is kcpClient,请输入服务器 ip地址和端口号:\n");if(argc != 3){printf("请输入服务器ip地址和端口号\n");return -1;}printf("this is kcpClient\n");int64_t cur = iclock64();printf("main started t:%ld\n", cur); // prints Hello World!!!unsigned char *ipstr = (unsigned char *)argv[1];unsigned char *port = (unsigned char *)argv[2];kcpObj send;send.ipstr = ipstr;send.port = atoi(argv[2]);init(&send);//初始化send,主要是设置与服务器通信的套接字对象bzero(send.buff,sizeof(send.buff));// 每个连接都是需要对应一个ikcpcbikcpcb *kcp = ikcp_create(0x1, (void *)&send);//创建kcp对象把send传给kcp的user变量kcp->output = udp_output;//设置kcp对象的回调函数ikcp_nodelay(kcp,0, 10, 0, 0);//(kcp1, 0, 10, 0, 0); 1, 10, 2, 1ikcp_wndsize(kcp, 128, 128);ikcp_setmtu(kcp, 1400);send.pkcp = kcp; loop(&send);//循环处理ikcp_release(send.pkcp);printf("main finish t:%ldms\n", iclock64() - cur);return 0;
}