TCP 协定
KCP 是一个疾速牢靠协定,能以比 TCP 节约 10%-20% 的带宽的代价,换取均匀提早升高 30%-40%,且最大提早升高三倍的传输成果。纯算法实现,并不负责底层协定(如 UDP)的收发,须要使用者本人定义上层数据包的发送形式,以 callback 的形式提供给 KCP。连时钟都须要内部传递进来,外部不会有任何一次零碎调用。
TCP 是为流量设计的(每秒钟多少 KB),KCP 是为流速设计的(RTT 时延多少毫秒)。KCP 参考 TCP 做了一些优化, 就义了带宽, 以换取更低的时延, 设计上大部分是通用的, 所以这里先介绍 TCP 协定的原理.
假如你曾经理解 TCP/IP 的基本概念, UDP 和 TCP 都属于第四层传输层, 实现了过程到另一个过程通信的最初一步, 咱们看看 TCP 的协定报
TCP 的协定报
5 层网络顺次是: 物理层, 链路层, 网络层, 传输层, 应用层
网络上的包都是残缺的, 有了下层必须有上层, 所以上面也贴一下第二层链路层和第三层网络层的协定报
第二层链路层的协定报
MAC 以 帧 为单位, 蕴含协定报(18 字节), 数据(46-1500 字节)
协定报蕴含了源 MAC 地址(6 字节), 指标 MAC 地址(6 字节), 以太网类型(2 字节), 循环冗余校验码(4 字节)
- 以太网类型: 0x0800 示意 IP 协定, 0x0806 示意 ARP 协定, 0x8035 示意 RARP 协定
第三层网络层的协定报
IP 层提供主机到主机之间的通信, 以 包 为单位, 蕴含协定报(20-60 字节), 数据(0-65535 字节)
协定报蕴含了版本(4 位), 首部长度(4 位), 服务类型(8 位), 总长度(16 位), 标识(16 位), 标记(3 位), 片偏移(13 位), 生存工夫(8 位), 协定(8 位), 首部校验和(16 位), 源 IP 地址(32 位), 指标 IP 地址(32 位), 选项(0-40 字节)
- 版本: 4 位, 4 示意 IPv4, 6 示意 IPv6
- 首部长度: 4 位, 示意首部 / 协定报的长度, 以 32 位 (4 字节) 为单位, 也就是说, 首部的长度最小是 5 个 32 位, 最大是 15 个 32 位, 也就是说, 最小是 20 字节, 最大是 60 字节
- 服务类型 / 辨别服务: 8 位, 用来标识服务的类型, 个别不必, 只有在辨别服务时候这个字段才有用
- 总长度: 16 位, 示意整个包的长度, 以字节为单位, 最小是 20 字节(只蕴含协定报), 最大是 65535 字节
- 标识: 16 位, IP 软件在存储器中维持一个计数器,每产生一个数据报,计数器就加 1,并将此值赋给标识字段。但这个“标识”并不是序号,因为 IP 是无连贯服务,数据报不存在按序接管的问题。当数据报因为长度超过网络的 MTU 而必须分片时,这个标识字段的值就被复制到所有的数据报片的标识字段中。雷同的标识字段的值使分片后的各数据报片最初能正确地重装成为原来的数据报
- 标记: 3 位, 最低位示意是否分片, 0 示意不分片, 1 示意分片, 两头位示意是否是最初一个分片, 0 示意不是, 1 示意是, 最高位保留, 个别为 0
- 片偏移: 片偏移 占 13 位。片偏移指出:较长的分组在分片后,某片在原分组中的绝对地位。也就是说,绝对于用户数据字段的终点,该片从何处开始。片偏移以 8 个字节为偏移单位。这就是说,每个分片的长度肯定是 8 字节(64 位)的整数倍。
- 生存工夫: 占 8 位,生存工夫字段罕用的英文缩写是 TTL (Time To Live),表明是数据报在网络中的寿命。由收回数据报的源点设置这个字段。其目标是避免无奈交付的数据报无限度地在因特网中兜圈子(例如从路由器 R1 转发到 R2,再转发到 R3,而后又转发到 R1),因此白白耗费网络资源。最后的设计是以秒作为 TTL 值的单位。每通过一个路由器时,就把 TTL 减去数据报在路由器所消耗掉的一段时间。若数据报在路由器耗费的工夫小于 1 秒,就把 TTL 值减 1。当 TTL 值减为零时,就抛弃这个数据报.
- 协定: 8 位, 示意下一层协定的类型, 例如:
留神这里的 IP 指的是再次将 IP 报封装到 IP 报中
- 校验和: 16 位, 用来测验 IP 报头的正确性, 由发送方计算, 接管方测验
- 源地址: 32 位, 示意发送方的 IP 地址
- 目标地址: 32 位, 示意接管方的 IP 地址
- 选项: 可选, 用来扩大 IP 报文, 个别不应用
第四层 TCP 协定报
TCP 提供端到端的通信, 是过程之间通信的最初一步, 以 段 为单位, 蕴含协定报(20-60 字节), 数据(0-65535 字节)
- 源端口: 16 位, 示意发送方的端口号
- 目标端口: 16 位, 示意接管方的端口号
- 序列号: 32 位, 示意发送方发送的数据的第一个字节的序号
- 确认号: 32 位, 示意接管方冀望收到的下一个字节的序号
- 部首长度: 4 位, 示意 TCP 报头的长度, 个别为 5, 也就是 20 字节
- 保留: 6 位, 保留, 个别为 0
-
标记位: 6 位, 用来标识 TCP 报文的各种状态, 例如: SYN, ACK, FIN 等
- URG(Urgent) 紧急标记, 示意紧急指针 (16 位) 失效 (比方近程 ssh 的 Ctl+ C 中断命令), 发送端不再按程序发送, 优先发送和前面的紧急指针配合的紧急数据, 接收端优先承受, 不期待 buffer 满, 读取 start= 序列号, offset= 紧急指针的数据
- ACK (Acknowledge) 确认标记, 确认序列号 (32 位) 失效
- PUSH 推送标记, 起到督促作用, 发送端不再按程序发送, 优先创立 PUSH 报文, 接收端不期待 buffer 满, 读取整个 buffer + 新报文的数据
- RST (Reset) 重置, 示意须要退出或者从新连贯
- SYN (Synchronization) 同步 (3 次握手的 SYN 包)
- FIN (Finish) 完结 (4 次挥手的 FIN 包)
- 窗口大小: 16 位, 接受方 ACK 发送本人的承受窗口大小, 用来管制发送方的发送速率 (流量管制)
- 校验和: 16 位, 用来测验 TCP 报头的正确性, 由发送方计算, 接管方测验
- 紧急指针: 16 位, 只有紧急指针标记位 URG 无效时候无效, 示意紧急数据的最初一个字节的序号
- 选项: 可选, 用来扩大 TCP 报文, 比方 SACK(Selective ACK), MSS(Maximum Segment Size), TS(Timestamp), WSOPT(Window Scale Option) 等
牢靠传输的工作原理
咱们晓得,TCP 发送的报文段是交给 IP 层传送的。但 IP 层只能提供尽最大致力服务,也就是说,TCP 上面的网络所提供的是不牢靠的传输。因而,TCP 必须采纳适当的措施能力使得两个运输层之间的通信变得牢靠。
现实的传输条件有以下两个特点:
(1) 牢靠传输: 传输信道不产生过错。
(2) 不论发送方以多快的速度发送数据,接管方总是来得及解决收到的数据。
牢靠传输次要由 ACK + 重传机制 保障, 有 进行期待协定 和 间断 ARQ 协定 两种实现形式。
最简略的计划就是进行期待协定
进行期待协定
进行期待 就是每发送完一个分组就进行发送,期待对方的确认。在收到确认后再发送下一个分组。
无差错状况
例如: A 发送分组 M1,发完就暂停发送,期待 B 的确认。B 收到了 M1 就向 A 发送确认。A 在收到了对 M1 的确认后,就再发送下一个分组 M2。同样,在收到 B 对 M2 的确认后,再发送 M3。
有过错状况
例如: A 发送分组 M1,M1 丢包了, A 期待超时还没有收到 M1 的确认, 就重传 M1
进行期待协定的信道利用率问题
对于进行期待协定的信道利用率, 如果 A 发送数据的工夫是 Td, B 承受和解决的工夫忽略不计, B 返回 ACK 的工夫是 Ta, 数据在网络中传输的工夫是 RTT, 那么信道的利用率是 Td/(Td+RTT+Ta)
比方 A, B 间隔 200KM, RTT 是 20ms, 发送速率是 1MB/s, 均匀 TCP 包的大小 1KB, 用时 1ms 发送 Ta 忽略不计
信道的利用率大略就是 1/21
进行期待协定的信道利用率切实是太低了, 这还没有算上超时丢包等异常情况, 算上的话信道利用率会更低
流水线的传输 (间断的 ARQ 协定) 能够进步信道利用率, 如下图所示
间断的 ARQ 协定(滑动窗口协定)
滑动窗口协定,能够将窗口内的多个分组数据都发送进来, 而不须要期待对方的确认。这样,信道利用率就进步了.
TCP 什么时候发送端什么时候发送数据, 接收端什么时候确认数据呢? 这里也是应用缓存的 累积 的思维, 发送端累积发送, 接收端累积确认
累积发送
利用过程把数据传送到 TCP 的发送缓存后,剩下的发送工作就由 TCP 来管制了。能够用不同的机制来管制 TCP 报文段的发送机会。
- 第一种机制是 TCP 维持一个变量,它等于最大报文段长度 MSS。只有缓存中寄存的数据达到 MSS 字节时,就组装成一个 TCP 报文段发送进来。
- 第二种机制是由发送方的利用过程指明要求立刻发送报文段,比方 PUSH 和 URG 标记位。
- 第三种机制是发送方的一个计时器期限到了,这时就把以后已有的缓存数据装入报文段(但长度不能超过 MSS)发送进来。
其实什么时候发送数据是一个简单的问题, 因为 必须思考传输效率, 前面会讲到
累积确认
同理累积发送, 接管方的 TCP 接管到一个报文段后,就把它放入接管缓存中, 剩下的确认工作就由 TCP 来管制了。能够用不同的机制来管制 TCP 报文段的确认机会。
- 第一种机制是 TCP 维持一个变量,它等于最大报文段长度 MSS。只有接管缓存中寄存的数据达到 MSS 字节时,就把累积确认标记置 1,发送确认报文段。
- 第二种机制是由发送方的利用过程指明要求立刻确认的报文段,比方 PUSH 和 URG 标记位。
- 第三种机制是接管方的一个计时器期限到了,这时就把累积确认标记置 1,发送确认报文段。
累积确认只须要回复 残缺间断的数据块的最大序号, 不须要回复每一个数据块的序号, 大大减少了 ack 的数量
比方上图 (a) 发送端将 1 2 3 4 5 分组都发送进来
- 如果接收端回复了收到了数据 1, 发送端就能将窗口向右挪动 1 位, 如上图 (b) 所示
- 如果接收端回复了收到了数据 2, 发送端就能将窗口向右挪动 2 位, 如上图 (c) 所示
留神: 上图将窗口和缓存画成线性数组, 其实缓存应该是循环利用的环形 ringbuffer
窗口只能不动或者右挪动, 收到 ack 之后就右移, 并且窗口右边的数据都不能再应用
刚刚咱们介绍了失常状况下的 ack 机制, 咱们再看看 2 种异常情况下重传机制, 超时重传和快重传
超时重传
同理进行期待协定, 如果超时还没有收到 ack (发送的数据包丢包或者 ack 的数据包丢包) 就重传数据
例如: A 发送分组 M1,M1 丢包了, A 期待超时还没有收到 M1 的确认, 就重传 M1
超时重传工夫是多少呢?
如果把超时重传工夫设置得太短,就会引起很多报文段的不必要的重传,使网络负荷增大。但若把超时重传工夫设置得过长,则又使网络的闲暇工夫增大,升高了传输效率。
TCP 应用自适应算法(一种动静布局), 动静计算超时重传工夫 RTO (RetransmissionTime-Out)
计算超时重传工夫 RTO
(1) 首先须要计算均匀往返工夫, 又叫做平滑往返工夫 RTTS (Round Trip Time Smooth)
新 RTTS = (1-α) * 旧 RTTS + α * 新 RTT
α 是平滑因子, 个别取 0.125
(2) 而后计算均匀往返工夫的偏差, 又叫做平滑往返工夫偏差 RTTVAR (Round Trip Time Variation)
新 RTTVAR = (1-β) * 旧 RTTVAR + β * | 新 RTTS - 旧 RTTS|
β 是平滑因子, 个别取 0.25
(3) 最初计算超时重传工夫 RTO
RTO = RTTS + 4 * RTTVAR
个别 RTO 的最小值是 1 秒, 最大值是 60 秒
为什么说往返工夫 RTT 是不精确的?
往返工夫 RTT 个别计算形式是: 发送端发送数据包, 接收端收到数据包后回复 ack, 发送端收到 ack 后计算时间差, 失去往返工夫 RTT
为什么说往返工夫 RTT 是不精确的? 次要有 2 个起因:
- 接收端承受数据不会立刻回复, 而是期待提早应答定时器完结后再回复
- 如何断定此确认报文段是对先发送的报文段的确认,还是对起初重传的报文段的确认
针对第 2 点, 比方发送端发送了一个数据包 M1, 如果 M1 丢包了, 发送端会重传 M2, 接收端收到 M2 后, 会回复一个 ack, 然而发送端收到的 ack 可能是对 M1 的确认, 也可能是对重传的 M2 的确认, 如果发送端用重传的 M2 的确认来计算发送工夫, 失去的往返工夫 RTT 就会比理论的往返工夫 RTT 大, 从而导致超时重传工夫 RTO 计算不精确
解决办法就是: 重传的往返工夫不参加 RTT 计算
只有一个包产生了重传, 这个包就不参加 RTT 计算, 间接 RTO 翻倍
也就是说每次产生重传 RTO 都会翻倍, 比方间断重传 3 次, RTO 就会变成 8 倍 (比照 KCP 的翻 1.5 倍, 8 倍的翻倍是十分恐怖的)
快重传
超时重传须要重传丢包地位开始前面的所有数据, 比拟浪费资源
比方发送端发送 1 2 3 4 5 6 几组数据, 只有 3 丢包了, 超时重传须要重传 3 4 5 6
快重传: 接管方每收到一个失序的报文段后就 立刻收回反复确认 , 发送端收到 3 个反复的确认后立刻重传, 而不是等到发送端超时重传 (快重传个别和 TCP 拥塞管制的 快复原 搭配应用, 前面介绍)
超时重传的状况下: 接管方收到数据个别不会马上回复, TCP 会聚合收到的数据, 比方每 200ms 回复一次
上面举个例子介绍下快重传的流程:
- 比方发送端发送 1 2 3 4 5 6 几组数据, 3 丢包了
- 接收端收到 1 2 4 后, 因为累积确认只能确认收到残缺的数据, 所以立刻回复收到了 2
- 接收端下次再收到数据 5, 还是没有收到 3, 回复收到了 2
- 接收端下次再收到数据 6, 还是没有收到 3, 回复收到了 2
- 发送端 3 次收到反复的 ack 后, 立刻重传 3
- 接收端凑齐了 1 2 3 4 5 6, 回复收到了 6
ack 示意了接下来发送的数据, 所以回复收到了 x, 其实 ack=x+1, 例如回复收到了 6, 其实 ack=7
因为发送方能尽早重传未被确认的报文段,因而采纳快重传后能够使整个网络的吞吐量进步约 20%
SACK 选择性确认
快重传次要是解决 累积确认 如果两头数据包丢了导致的重传丢包后所有数据的资源节约问题
这次要是因为累积确认只返回收到的残缺的最大序号, 例如: 1 2 3 4 5 6, 3 丢了, 累积确认只能返回 2, 所以发送端只能重传 3 4 5 6
其实如果咱们能晓得 3 丢了, 那么就能够只重传 3, 而不是 3 4 5 6
如何晓得 3 丢了呢? 这就是 SACK 选择性确认
SACK 选择性确认: 接管方收到数据后, 不仅仅返回收到的最大序号, 还会在报文的选项局部返回收到的数据块, 例如: 1 2 3 4 5 6, 3 丢了, 在选项局部返回 1-2 4-6 示意收到的数据块, 这样发送端就晓得 3 丢了, 只须要重传 3
SACK 处于 TCP 头部的选项局部, 须要发送方和接管方都反对
选项局部最多有 40 字节, 标记一个数据块须要 2 个边界, 也就是 2 * 4 = 8
字节, 因为须要 1 个字节示意选项类型, 1 个字节示意选项长度, 所以 SACK 最多标记 4 个数据块 (4 * 8 + 2 = 34
没超过, 5 * 8 + 2 = 42
超过)
SACK 文档并没有指明发送方该当怎么响应 SACK。因而大多数的实现还是重传所有未被确认的数据块。
流量管制
辨别流量管制和拥塞管制
兴许你据说过 TCP 的流量管制, 拥塞管制, 我这里举个例子来辨别下他们
流量管制: 比方带宽是 10Gb, A 向 B 以 1Gb 的速度发送数据, 显然网络带宽是足够的不存在拥塞问题, 然而流量管制是必须的, 因为 B 解决不过去, 须要常常停下来 (这个时候 B 能够通过流量管制通知 A, 你的速度太快了, 我解决不过去, 你慢点)
拥塞管制: 比方带宽是 1Mb, 有 1000 台机器用 100Kb 的速度向服务器发送数据, 网络承受不了这么多的数据, 交换机和路由器解决不过去的数据就会抛弃, 导致大量丢包. (这个时候发送端丢包了就晓得网络可能不太好, 就应用拥塞管制减低发送速度)
流量管制避免数据将服务器的撑爆, 拥塞管制避免把网络设备撑爆
基于滑动窗口实现流量管制
流量管制是基于滑动窗口实现的, 每次发送端发送数据后, 接收端会返回一个窗口大小, 发送端依据窗口大小来决定发送的数据量
举个例子
- A 和 B 建设连贯的时候 B 通知 A, 我的承受窗口是 400 字节, 发送端的发送窗口不要大于接收端的承受窗口
- 接管方 B 对于发送方 A 进行了 3 次流量管制, 第一次将窗口缩小到 300, 第二次缩小到 100, 第三次缩小到 0
咱们又思考一种状况, 如果窗口缩小到 0 之后, B 将数据处理后又有了空间, 于是 B 向 A 发送窗口有 400 的报文, A 收到后将窗口减少到 400
然而如果 B 向 A 发送窗口有 400 的报文丢包了呢? A 始终期待收到 B 发送的非零窗口的告诉,而 B 也始终期待 A 发送的数据。如果没有其余措施,这种相互期待的死锁场面将始终延续下去。
为了解决这个问题,TCP 为每一个连贯设有一个继续计时器(persistence timer)。只有 TCP 连贯的一方收到对方的零窗口告诉,就启动继续计时器。若继续计时器设置的工夫到期,就发送一个零窗口探测报文段(仅携带 1 字节的数据),而对方就在确认这个探测报文段时给出了当初的窗口值。如果窗口依然是零,那么收到这个报文段的一方就从新设置继续计时器。如果窗口不是零,那么死锁的僵局就能够突破了。
必须思考传输效率
其实什么时候发送数据是一个简单的问题, 在 累积发送 的根底上, 必须思考传输效率
咱们来看一种极其状况
一个交互式用户应用一条 ssh 连贯(运输层为 TCP 协定)。假如用户只发 1 个字符。加上 20 字节的首部后,失去 21 字节长的 TCP 报文段。再加上 20 字节的 IP 首部,造成 41 字节长的 IP 数据报。在接管方 TCP 立刻收回确认,形成的数据报是 40 字节长(假设没有数据发送)。若用户要求远地主机回送这一字符,则又要发回 41 字节长的 IP 数据报和 40 字节长的确认 IP 数据报。这样,用户仅发 1 个字符时线路上就需传送总长度为 162 字节共 4 个报文段。当线路带宽并不富裕时,这种传送办法的效率确实不高。因而应适当推延发回确认报文,并尽量应用捎带确认的办法。
在 TCP 的实现中宽泛应用 Nagle 算法。算法如下:若发送利用过程把要发送的数据一一字节地送到 TCP 的发送缓存,则发送方就把第一个数据字节先发送进来,把前面达到的数据字节都缓存起来。当发送方收到对第一个数据字符的确认后,再把发送缓存中的所有数据组装成一个报文段发送进来,同时持续对随后达到的数据进行缓存。只有在收到对前一个报文段的确认后才持续发送下一个报文段。当数据达到较快而网络速率较慢时,用这样的办法可显著地缩小所用的网络带宽。Nagle 算法还规定,当达到的数据已达到发送窗口大小的一半或已达到报文段的最大长度时,就立刻发送一个报文段。这样做,就能够无效地进步网络的吞吐量。
咱们再来看一种极其状况, 叫做 “ 糊涂窗口综合症 ”
TCP 接管方的缓存已满,而交互式的利用过程一次只从接管缓存中读取 1 个字节, 接受方有 1 个承受窗口空余的时候, 向发送端告知还有一个承受窗口空余, 这样发送方的发送窗口为 1, 发送端再发 1 个字节的数据, 承受端收到数据后窗口又满了 … 这样上来, 传输效率就很低了
要解决这个问题, 能够有上面的计划
- 接管方的承受窗口有空余时候, 不要立刻回复发送端, 期待一段时间累积回复
- 接管方的承受窗口有空余时候, 立刻回复发送端, 累积肯定数量再复原 (最大报文长度 MSS)
- 发送端有发送数据的时候不要立刻发送, 参考 累积发送 的机制
上述两种办法可配合应用。使得在发送方不发送很小的报文段的同时,接管方也不要在缓存刚刚有了一点小的空间就急忙把这个很小的窗口大小信息告诉给发送方。
拥塞管制
拥塞管制和流量管制都能够减低发送端的发送速度, 他们的区别参考 辨别流量管制和拥塞管制
拥塞管制是基于拥塞窗口实现的, 发送方维持一个叫做拥塞窗口 cwnd (congestion window)的状态变量, 所以发送窗口的计算形式如下
发送窗口 = min(接管窗口, 拥塞窗口)
拥塞管制有四种算法,即慢开始 (slow-start)、拥塞防止(congestion avoidance)、快重传(fast retransmit) 和快复原(fast recovery)
咱们先假如接管窗口是无限大, 不会被流量管制限度, 咱们只思考网络拥塞的状况
慢开始和拥塞防止
首先在 3 次握手建设连贯的时候通信取得最大报文段 MSS (Max Segment Size), 以及阈值 ssthresh (slow start threshold)的大小
- 如果 cwnd < sshthresh, 慢开始算法, 拥塞窗口指数递增,
cwnd = cwnd * 2
- 如果 cwnd > sshthresh, 拥塞防止算法, 拥塞窗口线性递增,
cwnd = cwnd + 1
- 如果 cwnd = sshthresh, 2 种算法都能够
举个例子, 比方一开始的 ssthresh = 12 个 MSS
- 刚开始发送数据时, 先把拥塞窗口 cwnd 设置为 1
- 而后开始慢开始算法, 拥塞窗口指数递增, 1 2 4 8 16 …
- 当递增到 16 之后, cwnd > sshthresh, 拥塞防止算法, 拥塞窗口线性递增
- 当递增到 24 之后丢包了, 产生了 超时重传, 升高拥塞窗口
ssthresh = cwnd / 2 = 12, cwnd = 1
, 从新开始慢开始策略
快重传和快复原
快重传和快复原个别搭配应用, 能够参考下面的 快重传, 快重传算法首先要求接管方每收到一个失序的报文段后就立刻收回反复确认
留神: 超时重传 会触发慢开始, 快重传 会触发快复原
发送端承受到 3 次雷同的 ack 之后就马上重传的确的数据, 而后执行 快复原算法 , 拥塞窗口 ssthresh = cwnd / 2 = 1, cwnd = ssthresh = 12
, 而后开始拥塞防止算法
第三层网络层的随机晚期检测 RED
后面咱们介绍的都是第四层 TCP 解决网络拥塞的策略, 并没有和第三层网络层联合起来, 然而其实他们有着亲密的分割.
举个极其的例子:
- 路由器对于数据个别是采纳 FIFO 的形式进行转发, 新来的数据贮存到队列中, 队列满了就抛弃数据
- 路由器个别有很多 TCP 连贯, 所以抛弃数据的时候可能会影响大量的 TCP 连贯
- 这些 TCP 群体超时重传, 进入慢开始, 网络负载重很高忽然变得很低, 而后又逐步减少, 负载又很高 …(称之为 TCP 的全局同步)
如何能解决这种全局同步的景象呢?
思路就是 在可能要网络拥塞的时候就开始随机丢包, 让一部分 TCP 先慢下来, 这就是随机早起检测 RED 的根本思维
比方当队列长度达到一半 (最小门限) 时候就开始随机丢包, 丢包概率和随长度线性递增, 如下图所示
具体计算方法这里就不赘述了
建设连贯和断开连接
TCP 运输连贯有 3 个阶段
- 3 次握手建设连贯
- 数据传输
- 4 次挥手断开连接
刚刚介绍了第二个阶段数据传输, 咱们来看看其余两个阶段
3 次握手建设连贯
咱们首先要晓得
(1) SYN 包即便不携带数据也要占一个序列号, 比方发送第一个 SYN 包, 序列号为 1, 发送第二个包, 序列号为 2
(2) ACK 包返回的是冀望的下一个数据, 所以 ACK 号 = 收到的序列号 + 1
- 客户端 SYN 包(标记 SYN 为 1), 抉择一个初始序列号 seq = x
- 服务端 SYN/ACK 包(标记 SYN 为 1, ACK 为 1), 抉择一个初始序列号 seq = y, 确认号 ack = x + 1
- 客户端 ACK 包(标记 ACK 为 1), seq = x + 1, 确认号 ack = y + 1
- 如果客户端要持续发送数据, 应该从 x + 1 开始发送, 服务端应该从 y + 1 开始发送
为什么肯定要三次握手呢? 为什么不是两次或者四次呢?
这次要是为了避免已生效的连贯申请报文段忽然又传送到了 B,因此产生谬误
- A 发送了连贯申请 1, 连贯申请 1 在网络中滞留, A 超时重传连贯申请 2, B 收到连贯申请 2 建设连贯, 传输数据后断开连接
- 连贯申请 1 在网络中滞留完结, 传送到了 B, B 误认为是 A 收回的新的连贯申请, 于是向 A 收回确认, 然而 A 并没有收回连贯申请, 所以不会理会 B 的确认, B 就会始终期待 A 的确认, 造成资源节约
4 次挥手断开连接
咱们首先要晓得
(1) FIN 包即便不携带数据也要占一个序列号, 比方发送第一个 FIN 包, 序列号为 1, 发送第二个包, 序列号为 2
- A 的利用过程先向其 TCP 收回连贯开释报文段,并进行再发送数据,被动敞开 TCP 连贯。A 把连贯开释报文段首部的终止管制位 FIN 置 1,其序号 seq =u,它等于后面已传送过的数据的最初一个字节的序号加 1。这时 A 进入 FIN-WAIT-1(终止期待 1)状态,期待 B 的确认。
- B 收到连贯开释报文段后即收回确认,确认号是 ack = u + 1,而这个报文段本人的序号是 v,等于 B 后面已传送过的数据的最初一个字节的序号加 1。而后 B 就进入 CLOSE-WAIT(敞开期待)状态。TCP 服务器过程这时应告诉高层利用过程,因此从 A 到 B 这个方向的连贯就开释了,这时的 TCP 连贯处于半敞开 (half-close) 状态,即 A 曾经没有数据要发送了,但 B 若发送数据,A 仍要接管。也就是说,从 B 到 A 这个方向的连贯并未敞开,这个状态可能会继续一些工夫。
A 收到来自 B 的确认后,就进入 FIN-WAIT-2(终止期待 2)状态,期待 B 收回的连贯开释报文段。
- 若 B 曾经没有要向 A 发送的数据,其利用过程就告诉 TCP 开释连贯。这时 B 收回的连贯开释报文段必须使 FIN = 1。现假设 B 的序号为 w(在半敞开状态 B 可能又发送了一些数据)。B 还必须反复上次已发送过的确认号 ack = u + 1。这时 B 就进入 LAST-ACK(最初确认)状态,期待 A 的确认。
- A 在收到 B 的连贯开释报文段后,必须对此收回确认。在确认报文段中把 ACK 置 1,确认号 ack = w + 1,而本人的序号是 seq = u + 1(依据 TCP 规范,后面发送过的 FIN 报文段要耗费一个序号)。而后进入到 TIME-WAIT(工夫期待)状态。请留神,当初 TCP 连贯还没有开释掉。必须通过工夫期待计时器 (TIME-WAIT timer) 设置的工夫 2MSL 后,A 才进入到 CLOSED 状态。
为什么 A 在 TIME-WAIT 状态必须期待 2MSL 的工夫呢?这有两个理由:
第一,为了保障 A 发送的最初一个 ACK 报文段可能达到 B。这个 ACK 报文段有可能失落,因此使处在 LAST-ACK 状态的 B 收不到对已发送的 FIN + ACK 报文段的确认。B 会超时重传这个 FIN + ACK 报文段,而 A 就能在 2MSL 工夫内收到这个重传的 FIN + ACK 报文段。接着 A 重传一次确认,重新启动 2MSL 计时器。最初,A 和 B 都失常进入到 CLOSED 状态。如果 A 在 TIME-WAIT 状态不期待一段时间,而是在发送完 ACK 报文段后立刻开释连贯,那么就无奈收到 B 重传的 FIN + ACK 报文段,因此也不会再发送一次确认报文段。这样,B 就无奈依照失常步骤进入 CLOSED 状态。
第二,避免上一节提到的“已生效的连贯申请报文段”呈现在本连贯中。A 在发送完最初一个 ACK 报文段后,再通过工夫 2MSL,就能够使本连贯继续的工夫内所产生的所有报文段都从网络中隐没。这样就能够使下一个新的连贯中不会呈现这种旧的连贯申请报文段。
reference
计算机网络 - 谢希仁: https://weread.qq.com/web/boo…
kcp 协定
TCP 是为流量设计的(每秒内能够传输多少 KB 的数据),考究的是充分利用带宽。而 KCP 是为流速设计的(单个数据包从一端发送到一端须要多少工夫),以 10%-20% 带宽节约的代价换取了比 TCP 快 30%-40% 的传输速度。
KCP 是基于 UDP 协定实现的, 咱们看看 UPD 的协定报
UDP 协定报
- 源端口 源端口号。在须要对方回信时选用。不须要时可用全 0。
- 目标端口 目标端口号。这在起点交付报文时必须要应用到。
- 长度 UDP 用户数据报的长度,其最小值是 8(仅有首部)。
- 测验和 检测 UDP 用户数据报在传输中是否有错。有错就抛弃。
KCP 协定报
- 连贯标识 (4 字节): 这个连贯收回去的每个报文段都会带上
conv
, 它也只会接管conv
与之相等的报文段. 通信的单方必须先协商一对雷同的conv
. KCP 自身不提供任何握手机制, 协商conv
交给使用者自行实现, 比如说通过已有的 TCP 连贯协商 - 命令类型 (1 字节)
- 分片数量 (1 字节): 示意随后还有多少个报文属于同一个包. (数据包的大小可能会超过一个 MSS (Maximum Segment Size, 最大报文段大小). 这个时候须要进行分片, 分片数量示意随后还有多少个报文属于同一个包.)
- 窗口大小 (2 字节): 发送方残余接管窗口的大小. (相似 TCP 流量管制)
- 工夫戳 (4 字节): TCP 应用往返工夫计算 RTT 的, KCP 的工夫须要重内部传进来
- 序列号 (4 字节): 相似 TCP 的 seq 序列号
- 确认序列号 (4 字节): 相似 TCP 的 seq 序列号, 发送方的接收缓冲区中最小还未收到的报文段的编号. 也就是说, 编号比它小的报文段都已全副接管.
- 数据长度 (4 字节): 数据的长度 (TCP 没有数据长度, TCP 是面向流的)
- 数据 (长度可变)
kcp 协定报的构造体
type segment struct {
// 连贯标识
conv uint32
// 命令号
cmd uint8
// 分片数量
frg uint8
// 窗口大小
wnd uint16
// 工夫戳
ts uint32
// 序列号
sn uint32
// 确认序列号
una uint32
// 超时工夫, 通过来回 ts 计算的 RTT 进而计算出来的 RTO 和 TCP 的 RTO 相似
rto uint32
// 该报文传输的次数
xmit uint32
// 下次重发的工夫戳, 初始值为: current + rto
resendts uint32
// ACK 失序次数. 也就是 KCP Readme 中所说的 "跳过" 次数
fastack uint32
// 是否确认
acked uint32 // mark if the seg has acked
// 数据
data []byte}
KCP 实例
type KCP struct {
// conv 连贯标识
// mtu 最大传输单元
// mss 最大报文段大小
// state 状态, 0 示意连贯建设, -1 示意连贯断开. (留神 state 是 unsigned int, -1 实际上是 0xffffffff)
conv, mtu, mss, state uint32
// snd_una 发送缓冲区中最小还未确认送达的报文段的编号. 也就是说, 编号比它小的报文段都已确认送达.
// snd_nxt 下一个期待发送的报文段的编号
// rcv_nxt 下一个期待接管的报文段的编号
snd_una, snd_nxt, rcv_nxt uint32
// ts_recent 工夫戳
ssthresh uint32
// rx_rttval 用于计算 rx_rto 的变量
// rx_srtt 用于计算 rx_rto 的变量
rx_rttvar, rx_srtt int32
// rx_rto 重传超时工夫, 通过来回 ts 计算的 RTT 进而计算出来的 RTO 和 TCP 的 RTO 相似
// rx_minrto 最小重传超时工夫
rx_rto, rx_minrto uint32
// snd_wnd 发送窗口大小
// rcv_wnd 接管窗口大小
// rmt_wnd 远端窗口大小
// cwnd 拥塞窗口大小
// probe 拥塞探测标识
snd_wnd, rcv_wnd, rmt_wnd, cwnd, probe uint32
// interval 间隔时间, 用于更新 KCP 外部的工夫戳
// ts_flush 下次刷新的工夫戳
interval, ts_flush uint32
// nodelay 是否启用 nodelay 模式
// updated 是否更新了 nodelay 模式
nodelay, updated uint32
// ts_probe 下次探测的工夫戳
// probe_wait 探测等待时间
ts_probe, probe_wait uint32
// dead_link 下次检测 dead link 的工夫戳
// incr 拥塞窗口大小增量
dead_link, incr uint32
// fastresend 疾速重传模式, ACK 失序 fastresend 次时触发疾速重传
fastresend int32
// nocwnd 没有拥塞管制的模式
// stream 流模式
nocwnd, stream int32
// snd_queue 发送队列
snd_queue []segment
// rcv_queue 接管队列
rcv_queue []segment
// snd_buf 发送缓冲区
snd_buf []segment
// rcv_buf 接收缓冲区
rcv_buf []segment
// acklist 确认列表
acklist []ackItem
// buffer flush 时候的长期缓冲区
buffer []byte
// reserved 保留字段
reserved int
// output 输入的回调函数 func(buf []byte, size int)
output output_callback
}
队列和缓冲区
咱们先来看 snd_queue, rcv_queue, snd_buf 和 rcv_buf 这四个字段. 它们别离是发送队列, 接管队列, 发送缓冲区和接收缓冲区. 队列和缓冲区其实都是循环双链表, 链表节点的类型都是 struct IKCPSEG.
调用 ikcp_send 发送数据时会先将数据退出 snd_queue 中, 而后再伺机退出 snd_buf. 每次调用 ikcp_flush 时都将 snd_buf 中满足条件的报文段都发送进来. 之所以不将报文间接退出 snd_buf 是为了避免一次发送过多的报文导致拥塞, 须要再拥塞算法的管制下伺机退出 snd_buf 中.
调用 ikcp_input 收到的数据解包后会先放入 rcv_buf 中, 再在适合的状况下转移到 rcv_queue 中. 调用 ikcp_recv 接收数据时会从 rcv_queue 取出数据返回给调用者. 这样做是因为报文传输的过程中会呈现丢包, 失序等状况. 为了保障程序, 须要将收到的报文先放入 rcv_buf 中, 只有当 rcv_buf 中的报文段程序正确能力将其挪动到 rcv_queue 中供调用者接管. 如下图所示, rcv_buf 中节点为灰色示意能够挪动到 rcv_queue 中. 只有当 2 号报文重传胜利后, 能力将 2, 3, 4 号报文挪动到 rcv_queue 中.
总结如下
- 发送数据: 创立报文实例后增加到 snd_queue 中, 而后再伺机增加到 snd_buf 中, 最初调用 ikcp_flush 发送进来.
- 承受数据: 收到数据后增加到 rcv_buf 中, 而后再将 程序正确 的报文伺机增加到 rcv_queue 中, 最初调用 ikcp_recv 接收数据.
技术个性
TCP 是为流量设计的(每秒内能够传输多少 KB 的数据),考究的是充分利用带宽。而 KCP 是为流速设计的(单个数据包从一端发送到一端须要多少工夫),以 10%-20% 带宽节约的代价换取了比 TCP 快 30%-40% 的传输速度。TCP 信道是一条流速很慢,但每秒流量很大的大运河,而 KCP 是水流湍急的小洪流。KCP 有失常模式和疾速模式两种,通过以下策略达到进步流速的后果:
RTO 翻倍 vs 不翻倍:
TCP 超时计算是 RTOx2,这样间断丢三次包就变成 RTOx8 了,非常恐怖,而 KCP 启动疾速模式后不 x2,只是 x1.5(试验证实 1.5 这个值绝对比拟好),进步了传输速度。
选择性重传 vs 全副重传:
TCP 丢包时会全副重传从丢的那个包开始当前的数据,KCP 是选择性重传,只重传真正失落的数据包。
疾速重传:
发送端发送了 1,2,3,4,5 几个包,而后收到远端的 ACK: 1, 3, 4, 5,当收到 ACK3 时,KCP 晓得 2 被跳过 1 次,收到 ACK4 时,晓得 2 被跳过了 2 次,此时能够认为 2 号失落,不必等超时,间接重传 2 号包,大大改善了丢包时的传输速度。(TCP 的疾速重传写死了是 3 次, KCP 能够本人设置, 个别是是 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 都能流畅传输的成果。
KCP 最佳实际
前向纠错
为了进一步提高传输速度,上层协定兴许会应用前向纠错技术。须要留神,前向纠错会依据冗余信息解出原始数据包。雷同的原始数据包不要两次 input 到 KCP,否则将会导致 kcp 认为对方重发了,这样会产生更多的 ack 占用额定带宽。
比方上层协定应用最简略的冗余包:单个数据包除了本人外,还会反复存储一次上一个数据包,以及上上一个数据包的内容:
Fn = (Pn, Pn-1, Pn-2)
P0 = (0, X, X)
P1 = (1, 0, X)
P2 = (2, 1, 0)
P3 = (3, 2, 1)
这样几个包发送进来,接管方对于单个原始包都可能被解出 3 次来(前面两个包任然会反复该包内容),那么这里须要记录一下,一个上层数据包只会 input 给 kcp 一次,防止过多反复 ack 带来的节约。
治理大规模连贯
如果须要同时治理大规模的 KCP 连贯(比方大于 3000 个),比方你正在实现一套类 epoll 的机制,那么为了防止每秒钟对每个连贯调用大量的调用 ikcp_update,咱们能够应用 ikcp_check 来大大减少 ikcp_update 调用的次数。ikcp_check 返回值会通知你须要在什么工夫点再次调用 ikcp_update(如果中途没有 ikcp_send, ikcp_input 的话,否则中途调用了 ikcp_send, ikcp_input 的话,须要在下一次 interval 时调用 update)
规范程序是每次调用了 ikcp_update 后,应用 ikcp_check 决定下次什么工夫点再次调用 ikcp_update,而如果中途产生了 ikcp_send, ikcp_input 的话,在下一轮 interval 立马调用 ikcp_update 和 ikcp_check。应用该办法,原来在解决 2000 个 kcp 连贯且每
个连贯每 10ms 调用一次 update,改为 check 机制后,cpu 从 60% 升高到 15%。
防止缓存积攒提早
不论是 TCP/KCP,信道能力在那里放着,让你没法无限度的调用 send,请浏览:“如何防止缓存积攒提早”这篇 wiki。
协定栈分层组装
不要试图将任何加密或者 FEC 相干代码实现到 KCP 外面,请实现成不同协定单元并组装成你的协定栈,具体请看:协定栈分层组装
如何反对收发牢靠和非牢靠数据?
有的产品可能除了须要牢靠数据,还须要发送非牢靠数据,那么 KCP 如何反对这种需要呢?很简略,你本人实现:
connection.send(channel, pkt, size);
channel == 0 应用 kcp 发送牢靠包,channel == 1 应用 udp 发送非牢靠包。
因为传输是你本人实现的,你能够在发送 UDP 包的头部加一个字节,来代表这个 channel
,收到近程来的 UDP 当前,也能够判断 channel==0 的话,把剩下的数据给 ikcp_input
,否则剩下的数据为近程非牢靠包。
这样你失去了一个新的发送函数,用 channel 来区别你想发送牢靠数据还是非牢靠数据。再对立封装一个 connection.recv
函数,先去 ikcp_recv
那里尝试收包,收不到的话,看方才有没有收到 channel=1 的裸 UDP 包,有的话返回给下层用户。
如果你的服务端是混用 tcp/udp 的话,你还能够设计个 channel=2 应用 TCP 发送数据,针对一些比拟大的,提早不敏感的货色。
重设窗口大小
要解决下面的问题首先对你的应用带宽有一个预计,并依据下面的公式从新设置发送窗口和接管窗口大小,你写后端,想谋求 tcp 的性能,也会须要从新设置 tcp 的 sndbuf, rcvbuf 的大小,KCP 默认发送窗口和接管窗口大小都比拟小而已(默认 32 个包),你能够朝着 64, 128, 256, 512, 1024 等品位往上调,kcptun 默认发送窗口 1024,用来传高清视频曾经足够,游戏的话,32-256 应该满足。
不设置的话,如果默认 snd_wnd 太小,网络不是那么顺畅,你越来越多的数据会滞留在 snd_queue 里得不到发送,你的提早会越来越大。
设定了 snd_wnd,远端的 rcv_wnd 也须要相应扩充,并且不小于发送端的 snd_wnd 大小,否则设置没意义。
其次对于成熟的后端业务,不论用 TCP 还是 KCP,你都须要实现相干缓存控制策略:
缓存管制:传送文件
你用 tcp 传文件的话,当网络没能力了,你的 send 调用要不就是阻塞掉,要不就是 EAGAIN,而后须要通过 epoll 查看 EPOLL_OUT 事件来决定下次什么时候能够持续发送。
KCP 也一样,如果 ikcp_waitsnd 超过阈值,比方 2 倍 snd_wnd,那么进行调用 ikcp_send,ikcp_waitsnd 的值降下来,当然期间要放弃 ikcp_update 调用。
缓存管制:实时视频直播
视频点播和传文件一样,而视频直播,一旦 ikcp_waitsnd 超过阈值了,除了不再往 kcp 里发送新的数据包,你的视频应该进入一个“丢帧”状态,直到 ikcp_waitsnd 升高到阈值的 1/2,这样你的视频才不会有积攒提早。
这和应用 TCP 推流时碰到 EAGAIN 期间,要被动丢帧的逻辑时一样的。
同时,如果你能做的更好点,waitsnd 超过阈值了,代表一段时间内网络传输能力降落了,此时你应该动静升高视频品质,缩小码率,等网络复原了你再复原。
缓存管制:游戏控制数据
大部分逻辑紧密的 TCP 游戏服务器,都是应用无阻塞的 tcp 链接配套个 epoll 之类的货色,当后端业务向用户发送数据时会追加到用户空间的一块发送缓存,比方 ring buffer 之类,当 epoll 到 EPOLL_OUT 事件时(其实也就是 tcp 发送缓存有空余了,不会 EAGAIN/EWOULDBLOCK 的时候),再把 ring buffer 外面暂存的数据应用 send 传递给零碎的 SNDBUF,直到再次 EAGAIN。
那么 TCP SERVER 的后端业务继续向客户端发送数据,而客户端又迟迟没能力接管怎么办呢?此时 epoll 会长期不返回 EPOLL_OUT 事件,数据会沉积再该用户的 ring buffer 之中,如果沉积越来越多,ring buffer 会自增长的话就会把 server 的内存给耗尽。因而成熟的 tcp 游戏服务器的做法是:当客户端应用层发送缓存(非 tcp 的 sndbuf)中待发送数据超过肯定阈值,就断开 TCP 链接,因为该用户没有接管能力了,无奈继续接管游戏数据。
应用 KCP 发送游戏数据也一样,当 ikcp_waitsnd 返回值超过肯定限度时,你应该断开远端链接,因为他们没有能力接管了。
然而须要留神的是,KCP 的默认窗口都是 32,比 tcp 的默认窗口低很多,理论应用时应提前调大窗口,然而为了公平性也不要无止尽放大(不要超过 1024)。
累积缓存: 总结
缓存积攒这个问题,不论是 TCP 还是 KCP 你都要解决,因为 TCP 默认窗口比拟大,因而可能很多人并没有解决的意识。
当你碰到缓存提早时:
- 查看 snd_wnd, rcv_wnd 的值是否满足你的要求,依据下面的公式换算,每秒钟要发多少包,以后 snd_wnd 满足条件么?
- 确认关上了 ikcp_nodelay,让各项减速个性得以运行,并确认 nc 参数是否设置,以敞开默认的类 tcp 激进流控形式。
- 确认 ikcp_update 调用频率是否满足要求(比方 10ms 一次)。
如果你还想更激进:
- 确认 minrto 是否设置,比方设置成 10ms, nodelay 只是设置成 30ms,更激进能够设置成 10ms 或者 5ms。
- 确认 interval 是否设置,能够更激进的设置成 5ms,让外部始终循环更快。
- 每次发送完数据包后,手动调用 ikcp_flush
- 升高 mtu 到 470,同样数据尽管会发更多的包,然而小包在路由层优先级更高。
如果你还想更快,能够在 KCP 上层减少前向纠错协定。具体见:协定分层,最佳实际。
如何应用
贴一个疾速开始的示例
package main
import (
"crypto/sha1"
"io"
"log"
"testing"
"time"
"github.com/xtaci/kcp-go/v5"
"golang.org/x/crypto/pbkdf2"
)
func TestServer(t *testing.T) {main()
}
func TestClient(t *testing.T) {client()
}
func main() {key := pbkdf2.Key([]byte("demo pass"), []byte("demo salt"), 1024, 32, sha1.New)
block, _ := kcp.NewAESBlockCrypt(key)
if listener, err := kcp.ListenWithOptions("127.0.0.1:12345", block, 10, 3); err == nil {
// spin-up the client
go client()
for {s, err := listener.AcceptKCP()
if err != nil {log.Fatal(err)
}
go handleEcho(s)
}
} else {log.Fatal(err)
}
}
// handleEcho send back everything it received
func handleEcho(conn *kcp.UDPSession) {buf := make([]byte, 4096)
for {n, err := conn.Read(buf)
if err != nil {log.Println(err)
return
}
n, err = conn.Write(buf[:n])
if err != nil {log.Println(err)
return
}
}
}
func client() {key := pbkdf2.Key([]byte("demo pass"), []byte("demo salt"), 1024, 32, sha1.New)
block, _ := kcp.NewAESBlockCrypt(key)
// wait for server to become ready
time.Sleep(time.Second)
// dial to the echo server
if sess, err := kcp.DialWithOptions("127.0.0.1:12345", block, 10, 3); err == nil {
for {data := time.Now().String()
buf := make([]byte, len(data))
log.Println("sent:", data)
if _, err := sess.Write([]byte(data)); err == nil {
// read back the data
if _, err := io.ReadFull(sess, buf); err == nil {log.Println("recv:", string(buf))
} else {log.Fatal(err)
}
} else {log.Fatal(err)
}
time.Sleep(time.Second)
}
} else {log.Fatal(err)
}
}
reference
kcp Wiki: https://github.com/skywind300…
kcp: https://luyuhuang.tech/2020/1…
本文由 mdnice 多平台公布