Post

谈一谈 TCP 协议

前面我将网络通信中,主要是协议层和软件层做了一下串联和分析。大致了解了整个通信的过程,一个信息要经过非常多的中间节点、协议转换才能最终到达目的地。中间的过程要怎么控制?怎么尽量保证传输过程的可靠性。在 TCP/IP 协议族中,就是 TCP 传输控制协议了。谈到 TCP 协议,我们都能够想到,面向连接、可靠、字节流传输、拆包粘包、三次握手、四次挥手等等。

我们先看一下 TCP 的报文结构

TCP

其实协议说到底就是共识,首先能够表达自己想要表达的内容,其次接收方也能够理解。一般来讲只要看协议的内容,就可以猜测出协议大致要完成的事情。首先,两个端口号,用于区分发送和接受的进程;序号,则是为了控制报文发送和接收的顺序;TCP为了保证报文传输的可靠性,没传输一个数据报,都要进行确认;状态位,SYN是发起一个连接同步端到端序号,ACK是应答,RST是重新连接,FIN是结束连接等;窗口大小,通信的两端都可以根据自己的处理能力,声明一个窗口大小,用于进行流量控制;校验和,保证数据的完整性。

建立连接和关闭连接

我们首先来讲一讲为什么 TCP 是面向连接的,所谓的连接其实并不像现实生活中,有一条具体的线连接端到段来进行通信。而是在客户端和服务端中,维护一组状态,通过状态来判断两边的通道是否畅通。而我们常说的建立连接的三次握手,也真是在客户端和服务端建立起这样的状态映射。而四次挥手,则是重置两端的状态。

Handshake

一开始客户端和服务端都处于 CLOSED 状态;先是服务端监听某个端口,处于 LISTEN 状态;接着客户端发起连接,发送 SYN,接着客户端处于 SYN-SENT 状态;服务端收到后,发送应答,发送 SYN 接收到的 客户端 SYN 的应答 ACK,并且将自己的状态修改为 SYN-RCVD;客户端接收到服务端的 ACK 之后,发送一个 ACK 的应答 ACK,并且将状态修改为 ESTABLISHED状态;服务端收到 ACK 之后,也修改为 ESTABLISHED。建立连接后,都处于 ESTABLISHED 的两端,就可以进行端到端的通信了。

通过抓包工具,我们也可以看到这个过程

wireshark

有没有想过,为什么建立连接要是三次握手呢?两次可以吗?四次呢?其实客户端和服务端之间的网络是不可靠的,任何的信息在传递的过程中都可能丢失,这也是互联网的实际情况。客户端发送请求,收不到响应,可能是因为发送的请求丢失了,也可能是返回的响应丢失了,也可能是根本服务端就没有发送响应,而只有当客户端一发一收,才可能认为和服务端建立了连接。对于服务端来说,也是一样的,收到了请求,发送出去的响应,最好也能够让自己知道客户端已经收到,这样也建立一收一发的机制。才可以比较可靠地建立连接。当然如果你细究,其实三次也是不够的,客户端还会想再得到 ACK 的 ACK 的 ACK,这样就算三百次也不能够完全可信,因为响应发送出去之后,服务端或者客户端都有可能出现故障,而无法建立连接,这种情况其实和分布式的一致性问题本质是相同的。对于TCP协议来讲,保持两端都能够一收一发,就是选择三次握手的原因。而因为特殊情况导致的连接状态异常,则可以通过类似超时断开连接、keeplive探测等来处理。

接下来说一下关闭连接,也就是常说的四次挥手。

由于 TCP 连接是双工的,也就是建立连接之后,两端都可以独立发送数据的写通道和接受数据的读通道。因此每个方向都需要单独进行关闭。原则是:当一方完成数据发送后就能发送一个 FIN 来终止这个方向的连接。收到一个 FIN 意味着这个方向上没有数据流动,一个 TCP 连接在收到一个 FIN 后仍能发送数据,也就是出于半关闭状态。首先进行关闭的一方执行主动关闭,另一方执行被动关闭,最终将两端状态重置,连接完全关闭。

fourwayhandshake

我们假设主动发起关闭的一方为 客户端 A,被动关闭的一方为 服务器 B,反之也是一样。通信两端是平等的。

第一阶段:A 主动关闭连接

  • 客户端 A 发送一个 FIN , seq = p,声明要关闭 A 到 B 的数据传送。A 进入 FIN_WAIT_1 状态。
  • 服务端 B 收到这个 FIN 后,返回一个 ACK , ack = p+1,因为 FIN 也要占用一个序号。B 进入 CLOSE_WAIT 状态并关闭自己的读通道
  • A 收到 ACK 之后,进入FIN_WAIT_2 状态,并关闭自己的写通道,如果 B 就此下线,A会永远处于这个状态,在 Linux 中,可以设置 tcp_fin_timeout 来控制超时。

此时,客户端 A 仍能通过读通道读取服务器的数据,服务器 B 仍能通过写通道写数据

第二阶段:B 发送要发送的数据后,被动关闭连接

  • 服务器 B 关闭和客户端 A 的连接,发送一个 FIN , seq = q , ack = p+1,进入 LAST_ACK 状态
  • 客户端 A 返回 ACK , ack = q +1,进入 TIME_WAIT 状态,并关闭自己的读通道,等待 2MSL 之后进入 CLOSED 状态,完成关闭。
  • 服务器 B 收到 ACK, ack = q +1 后,关闭服务器写通道,进入 CLOSED 状态,完成关闭。

MSL(Max Segment LifeTime)报文最大生存时间,它是任何报文在网络上存在的最大时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路有数,每经过一个处理他的路由器 TTL 的值就减 1,当此值为 0 这数据报被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2分钟。

对应的网络抓包

fourwayhandshake

这里需要额外讲的是 TIME_WAIT 状态。之前有一个项目,使用 Redis 作为共享缓存,因为连接池、Redis 集群 keepalive 设置不当,导致应用服务器大量的处于 TIME_WAIT 状态的连接;也有一次使用 Jmeter做压力测试,Nginx Keepalive 参数的配置不当,也导致部分 Tomcat 节点大量 TIME_WAIT 状态。两次都对性能有比较大的影响。

从上面的分析我们知道,TCP 的连接实际上是在通信两端维护状态,四次挥手过程中,主动发起关闭的一方,会进入 TIME_WAIT 状态,并持续 2MSL,TIME_WAIT 状态下的连接不能被回收使用(需要消耗资源维护状态,并占用端口)。具体现象是对于一个发起大量短连接的服务器,如果是由服务器主动关闭连接,将导致服务器端存在大量处于 TIME_WAIT 状态的连接,在高并发的场景下,这个数值可能会非常多。比如上面两个例子中都超过了 10k,这会严重影响整个服务器的处理能力。

TIME_WAIT 的存在,实际上是TCP 用于保护被重新分配的连接(端口),不会受到延迟到达的包的影响,等待 2MSL,也就是等待正在两端网络中通信的包都已经失效了。

举一个例子,当上面的客户端 A 最后一次发送 ACK 出去的时候,这个ACK 可能在发送的过程中丢失。如果客户端 A 直接进入 CLOSED 状态,那么没有收到 ACK 的服务器 B 会再次发送 FIN ,这个时候回收到 RST,对于服务器 B 来说,这实际上是一次连接错误,没有按照 TCP 协议规定返回 ACK。当然就算等待 2MSL 也会出现 RST 的情况,但是这已经是 A 能做到的一切了。

还有一种情况,如果 A 直接进入 CLOSED 状态,那么对应的端口,可能马上会被其他的进程占用。而此时网络上,还有部分 B 发往 A 的包,虽然 seq 会重新生成,但是也是存在混淆的可能性。

讲完了问题的前因后果,当然也有解决的方法。TIME_WAIT 状态相关的系统参数一般有几个个,位于 /etc/sysctrl.conf

1
2
3
4
net.ipv4.ip_local_port_range = 1024  65535
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 30

net.ipv4.ip_local_port_range 扩大端口范围

net.ipv4.tcp_fin_timeout,默认为 60s,减少这个参数可以减少服务端主动发起关闭后等待的超时时间。

net.ipv4.tcp_tw_reuse = 1 允许将 TIME_WAIT 的连接的端口,用于新的 TCP 连接,默认为 0,关闭

net.ipv4.tcp_tw_recycle = 1 开启对 TIME_WAIT 的连接的快速回收,默认为 0 关闭

但是这些参数的调整,实际上会影响 TIME_WAIT 设计的初衷,极端情况下可能会导致系统不稳定,系统的端口资源也没有办法无限制使用。

要从本质上解决,需要优化系统的架构设计,减少这些不必要的短连接请求,采用连接池,长连接的方式,才能是系统资源利用达到最优,而又不影响稳定性。

流和包

TCP是基于流(stream)的协议。可以这么来理解流的概念,整个 HTTP 的请求过程,包含三次握手在内,所有发送的数据,可以看成是一串由 bit 组成的数据流。三次握手中的 SYN、SYN+ACK、ACK 则是按照一定的规则切分成的一个个包(packet),是 TCP 流最开始前的部分数据。就像是一根连接客户端和服务端的管道,管道中流动着 bit 数据,有时候管道是满的,有时候是空的,每当调用一次发送的时候,就会有大量的 bit 数据,从一端流动到另一端,但是每一次调用 write 发送的数据,都是在这个管道里面传输,可以保证发送的数据是有序的,却没有明显的边界来区分每一次 write 发送的数据区间,这就是说 TCP 是基于流的协议。

stream

包的概念呢?这和数据链路层传输数据的方式有关系。数据链路层位于物理层和网络层之间,其设计的目的就是顺利为网络层提供数据服务。链路层的数据传输单元称之为帧(Frame),数据链路层的任务就是将上层的数据封装成帧交给物理层传输,其格式为:

Frame

帧的大小有一定的限制称为 MTU(Maxinum Transmission Unit 最大传输单元)。在因特网协议中,一条因特网传输路径的 MTU 被定义为从源地址到目的地址所经过“路径”上的所有IP跳的最大传输单元的最小值,这个值和传输路径中的各个网卡和串口的设置有关系。比如典型的局域网——以太网这个大小为 1500字节。

如果IP层要传输一个数据,且数据的长度比链路层的MTU还大,那么IP层就要进行分片(fragmentation),把数据报分成若干片,这样每一个分片都小于MTU。

RFC 1191 描述了“路径最大传输单元发现方法”,这是一种确定两个 IP 主机之间路径最大传输单元的技术,其目的是为了避免 IP 分片。在这项技术中,源地址将设置数据报的 DF(Don’t Fragment,不要分片)标记位,再逐渐增大发送的数据报的大小——路径上任何需要将分组进行分片的设备都会将这种数据报丢弃并返回一个“数据报过大”的 ICMP 响应到源地址——这样,源主机就“获取”到了不用进行分片就能通过这条路径的最大的最大传输单元了。

把一份IP数据报进行分片以后,由到达目的端的IP层来进行重新组装,其目的是使分片和重新组装过程对运输层(TCP/UDP)是透明的。分片之后的 IP 数据包,在传输的过程中可能出现丢失,由于 IP 层没有重传机制,因此无法重组完整的 IP 报文。这对于 TCP 层来说,就是整个 TCP 报文出现了丢失,会重新传输整个 TCP 报文,而不是只重传 IP 分片。为了避免这种情况,TCP 层会尽量避免出现 IP 分片的情况,而是在 TCP 层就进行分段(Segment)。分段的大小依据这是 MSS(Maxitum Segment Size 最大分段大小),也就是TCP数据包每次能够传输的最大数据分段。为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,在发送 SYN 的时候,放在 OPTIONS 字段,这个值 TCP 协议在实现的时候往往用 MTU 值代替(需要减去IP数据包包头的大小 20 字节,TCP数据段的包头 20 字节)所以往往 MSS 为 1460 字节。通讯双方会根据双方提供的MSS值得最小值确定为这次连接的最大MSS值。每一个 TCP 数据报在生成的时候,就会收到这个值的约束,最终起到避免 IP 分片的效果。表现上,TCP 也就有了一个个的段(Segment)了。

让我们来回顾一下一个端到端传送一个较大文件(8 * 1460 字节)的场景:首先端到端建立 TCP 连接,并协商到一个 MSS 假设为 1460 字节。发送方的应用层调用一次 write,对应用层来讲相当于是发送了一个数据(Data)可以看成是一串 bit 流。TCP 接收到请求后,根据 MSS 将 bit 流拆分称为8个报文段(Segment),每一个报文段都有独立的 TCP 头,依次通过网络发送。而传输的过程,因为 MSS <= MTU 所以不会触发 IP 分片,而是封装成一个个 IP 包(Packet),最后转换为链路层的帧(Frame)。而在另一端,TCP 层依次收到报文段后,会逐一放入缓冲区中提供给应用层读取,应用层使用 Socket 套接字的 read 接收数据,在接收方的角度看来,read 到的,就是一串源源不断的流(stream),如果要还原成发送端发送的数据(Data),则需要在接收方应用层来实现。

刚才讲的是发送的数据比较大的情况,会发生拆包,本来逻辑上的整体,被拆分成一个个独立的包来发送。此外,如果发送端,每次发送的数据非常小,如果每次都生成一个 TCP 报文,都需要附带头信息,例如SSH登录到远程服务器,每次敲命令都是一个个字符,如果每个字符都为 1 字节,但是封装成包(packet)之后,需要增加 TCP 首部 20字节和 IP 首部 20字节,这样相当于放大了 40倍的流量,这对于资源的利用和整体网络性能肯定是不利的,因此 TCP 通过 Nagle 算法来解决这种频繁发送小报文的问题,Nagle 的策略很简单,发送一个小包后,如果这个小包还没有被 ack,后面等待发送的少量数据,则会在 buffer 中等待一小段时间,如此,等待的少量数据就有可能会被集中一起发送出去,本来逻辑上不同的数据个体,被合并称为一个包来发送,这就是粘包,同样接收方在接受到流的时候,要还原成原始的数据,也需要在应用层进行处理。这是一种用延迟来换取带宽利用率的机制,如果有一些对延时敏感的应用,比如远程桌面,可以通过 NO_DELAY 选项来禁用 Nagle 算法。

此外,还有一个和 Nagle 算法相对应的的延迟确认机制(Delayed Ack),Delayed Ack 是站在数据接收方的角度,尝试减少接收方所发送的 ack 数量。因为 TCP 每一个发送的包都需要被 ack,频发接收包的时候,一个明显可以实施的优化是,将多个 ack 打包一起发送,这也正是 Delayed Ack 的做法,在收到第一个包的时候,延迟发送 ack,同样以延迟来换取带宽利用率。当发送方开启 Nagle 算法,同时接收方启用 Delayed ack 的时候,双方各自给通讯增加了一定的延迟,如果各几百毫秒的话,会给整个应用带来非常糟糕的用户体验。这两种算法都是为了减少频发小数据包带来的带宽浪费,如果有高响应要求的场景,可以在应用层适当进行报文合并,使其接近 MSS,一次性发送。或者将这两个优化都关闭,换取更好的响应性能。

最后讲一下,接收端的应用层,怎么从流中还原出对应的数据(Data),由于 TCP 层面对的是一串无边界的流,因此只能够定义一个应用层的协议,来进行数据边界识别,常用的解决办法有:

  1. 消息定长,报文大小固定长度,例如每个报文的长度固定为200字节,如果不够空位补空格;
  2. 包尾添加特殊分隔符,例如每条报文结束都添加回车换行符(例如FTP协议)或者指定特殊字符作为报文分隔符,接收方通过特殊分隔符切分报文区分;
  3. 将消息分为消息头和消息体,消息头中包含表示信息的总长度(或者消息体长度)的字段,读取消息头中长度信息后,继续读取对应长度的数据作为消息体,然后继续读取下一个消息头;
  4. 更复杂的自定义应用层协议,常用例如 protobuf。

上面就是TCP/IP 中流和包的主要内容。了解这些内容,对于编写高效和正确的网络通信协议会有一定的帮助,比如在对TPS要求比较高的场景,消息的大小应该尽量接近 MSS,避免分段;关闭系统默认的延迟策略等等。

TCP 传输架构

前面讲了 TCP 建立连接,以及传输数据的过程。但是传输的过程存在很多不稳定的因素,比如数据的破坏、丢包、重复、数据包乱序,这些都会影响数据传输的可靠性,同时不同的网络设备能接收和处理的数据不一样,还需要进行流量控制,避免出现设备过载。因此 TCP 协议最核心要解决的问题就是可靠性保证和流量控制。其通过校验和、序列号、确认应答、重发机制、重复控制、连接管理、滑动窗口控制等来实现。

序列号

为了保证传输的顺序性,每一个 TCP 的报文,都要分配一个序号 seq,这个序号的起始值会在连接建立的时候,会生成一个初始序号(Initial Sequence Number ISN),这是一个在 [0 - 2^32) 范围内取值,起始值为0或者1,4us加一的计数器,大概四个多小时会重置一次,这样做的主要目的是为了防止延迟送达的包被错误确认。下面简单说一下 TCP 通信流中包的 seq 编号规则。

三次握手

Client -> [SYN] seq = x,ack = 0 -> Server Client <- [SYN, ACK] seq = y , ack = x+1 <- Server Client -> [ACK] seq = x+1,ack = y+1 -> Server

x 是 客户端的 ISN,y 是服务端的 ISN,建立连接之后,数据包从各自的 ISN + 1 开始(这是因为包含 SYN和FIN 标志位的包,虽然没有数据,但是本身也会占用一个序号,而 ACK 不占用序列号)。

开始进行数据传输。为了下面方便说明,我们假设x = 55554 , y = 22221

Client -> [PSH, ACK] seq = 55555 (=55554+1),ack = 22222 (=22221+1),len = 11 -> Server Cleint <- [ACK] seq = 22222 (=22221+1),ack= 55566 (=55555+ 11),len=0 <- Server

客户端发送一个长度为 11 字节的包,使用的 seq 是自己的 seq,ack 因为创建连接之后还没有收到服务端的任何数据,因此继续是上一次和客户端通信得到的 ack。

服务端收到数据包后要返回一个确认应答,seq 是自己的 seq,保持不变,ack 因为收到了客户端发送的 11 字节的数据,因此需要再上一次确认的 ack 的基础上增加 11,表示收到了 11 字节的数据,因为 ACK 报文没有有效数据负载,因此 len 等于 0。

Client <- [PSH,ACK] seq = 22222,ack = 55566,len = 22 <- Server Client -> [ACK] seq = 55566,ack = 22244 (= 22222 + 22),len = 0 -> Server

接着服务端发送一个长度为 22 直接的包,因为建立连接之后,服务端还没有发送过任何数据,因此 seq 还是保持不变,ack 这是上一次确认收到客户端序号的 55566。

客户端收到这个数据包之后,返回 ack,同样 seq 保持原来的 55566 不变,ack 则为上一次确认的 ack 的基础上增加 22 等于 22244。

总结一下规律,当开始发送数据的时候,不论是客户端还是服务端,seq 都是等于本端 ISN+已经发送成功(收到对端ACK)的数据包的字节长度,ack 则是本端已经接受到的数据包的字节长度 + 对端ISN,其中数据包的字节长度,不包括TCP和IP头,而是有效的数据负载,并且发送 SYN 和 FIN 占用一个字节,ACK 不占用字节 。

缓冲区

确定了序号之后,就开始发送数据了。前面我们讲了 TCP 为什么是面向流的协议,以及怎么将数据进行分段,每一个段都会有一个 seq 编号。

如果要发送的数据很多,显然没有办法一次性发送,接收端也没有办法一次性全部读取。因此,发送端和接收端分别都有缓冲区来保存这些分段。

先说发送端的缓冲区,当应用层调用 write 的时候,系统内核将应用进程的缓冲区数据写到对应 Socket 的发送缓冲区,如果Socket 的发送缓冲区容纳不了应用进程的数据,并且Socket 是堵塞的,那么应用进程会进入 sleep。内核不会从 write 系统回调,直到应用进程缓冲区中的所有数据都复制到 Socket 的发送缓冲区。因此,Socket 的 write 调用成功,实际上只是代表我们可以重新使用原来的应用缓冲区,也就是应用缓冲区已经写入了Socket 发送缓冲区,并不代表接收方已经接收到数据。发送缓冲区大小可以通过 SO_SNDBUF 来设置。

缓冲区中的数据,会根据 MSS 的大小进行分段,生成对应的 TCP 首部,构建 TCP 报文,并投递到 IP 层开始向接收端发送。为了保证每个TCP报文接收端都收到,TCP 采用的是应答确认机制,其实前面已经提到过很多次了,就是每一个数据包发送出去之后,必须收到一个对应这个数据包的 Ack 应答才能确认数据包发送成功,也才能发送后面的数据包。根据某一个时刻的状态,整个发送缓冲区可以分成四部分。

  1. 已发送确认:数据流中最早的字节已经发送并得到确认。
  2. 已发送但未确认:已发送但是尚未得到确认的字段。
  3. 未发送而接收方已经 Ready:还未将数据发出,但是接收方根据最近一次关于发送方一次要发送多少字节确定自己有足够空间。
  4. 未发送而接收方 Not Read:接收方 not ready,这部分数据不允许发送。

我们假设每一个字节为一个TCP报文段

Tcpsendbuf

  • LastByteAcked:第一部分和第二部分的分界线
  • LastByteSent:第二部分和第三部分的分界线
  • LastByteAcked + AdvertisedWindow:第三部分和第四部分的分界线

其中的 3 和 4 就涉及到流量控制,和接收端有关系。接收端会给发送端报一个窗口的大小,叫做 Advertised windows(也称为滑动窗口)。这个窗口的大小应该等于上面的第二部分加上第三部分。在这个窗口之内,发送端可以同时发送多个数据包,不需要等待前一个收到 ACK 后再发送下一个。超过这个窗口的,接收端收不过来,就不能发送了,这就起到了流量控制的作用。

接收方的缓冲区也是采用类似的机制,不过对应的应用层的就是 read 并且方向是从 Socket 缓冲区读取到应用进程缓冲区中。按照处理的状态,接收方缓冲区也可以分成三个部分:

  1. 已接收并已确认。
  2. 尚未接收但准备好接收。
  3. 尚未接收并且尚未准备接收。

tcpreceivedbuf

  • MaxRcvBuffer:最大缓存的量;
  • LastByteRead 之后是已经接收了,但是还没有被应用层读取的;
  • NextByteExpected 是第一部分和第二部分的分界线。

第二部分的窗口有多大呢?

NextByteExpected 和 LastByteRead 的差其实是被应用层读取的部分占用掉的 MaxRcvBuffer 的量,我们定义为 A。AdvertisedWindow 其实是 MaxRcvBuffer 减去 A,也就是:AdvertisedWindow = MaxRcvBuffer - ( ( NextByteExpected -1 ) - LastByteRead )。如上图为 14 - ((6-1) - 0) = 9。NextByteExpected 加 AdvertisedWindow 就是第二部分和第三部分的分界线,其实也就是 LastByteRead 加上 MaxRcvBuffer。

其中第二部分里面,由于收到包可能不是顺序的,会出现空挡,只有第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了。例如上图中,8、9收到了,但是 6和7 还未收到。因此还不能进行 ACK,因为如果 ACK 了 8 、9那发送方会认为 8、9之前的所有的包都接收到了,这会导致数据的丢失,这种机制叫做称为累计确认或者累计应答(cumulative acknowledgment)。

顺序问题和丢包问题

为了方便说明,还是使用前面的两张图来表示发送端和接收端的缓冲区快照。

Tcpsendbuf

在发送端来看,1、2、3已经发送并确认;4、5、6、7、8、9 都是发送了还没确认;10、11、12 是还没发出的;13、14、15 是接收方没有空间,不准备发的。

tcpreceivedbuf

在接收端看来,1、2、3、4、5是已经完成 ACK,但是没有读取的;6、7是等待接受的;8、9是已经接受,但是没有 ACK 的(注意虚线)。

接收端和发送端分属于两台机器,两边的状态并不同步

  • 1、2、3没有问题,双方达成一致。
  • 4、5 接收方说 ACK 了,但是发送方还没有收到,有可能丢了,有可能在路上。
  • 6、7、8、9肯定都发了,但是8、9已经到了,但是6、7没到,出现了乱序,接收方只能缓存着但是没办法 ACK。

这里例子里面,乱序和丢包都有可能发生,TCP 采用确认和重传来解决这两个问题。

  1. 假设 4 的 ACK 到了,不幸的是 5 的 ACK 丢了,6、7的数据包丢了,这该怎么办呢?

对于接收方来说,最直接的方式就是重传,也就是对每一个已经发送,但是还没有收到 ACK 的包,都设置一个超时时间,超过了这个时间,就进行重传。这个超时时间的确定就比较关键,太短了,容易出现很多无谓的重传,太长了则整个网络响应速度会下降。在TCP里面采用的是 RTT(包往返时间,可以理解成建立连接的 SYN 发送到返回 SYN-ACK 的这段时间)然后进行加权平均,算出一个值。因为网络状况不断的变化,除了采样 RTT,还要采用 RTT 的波动范围,采用自适应算法动态计算这个值。

如果过一段时间,5、6、7都超时了,就会重新发送。接收方发现 5 原来接受过,于是丢弃 5;6 收到了,发送 ACK,要求下一个是7,7 不幸又丢了。当 7 再次超时的时候,有需要重传的时候,TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

有一个可以快速重传的机制,当接收方收到一个序号大于下一个所期待的报文时,就检测到了数据流中的一个间隔,于是发送三个冗余的 ACK,客户端收到后,就在定时器过期之前,重传丢失的报文段。例如,接收方发现 6、8、9都已经接收了,就是 7 没来,那肯定是丢了,于是发送三个 6 的 ACK,要求下一个是 7。客户端收到三个连续 6 的 ACK,就会发现 7 的确又丢了,不等超时,马上重发。

处理超时重传的方式也有两种:

  • 仅重传超时数据段:这是比较乐观保守的做法,每一次仅重传已经超时的数据段,希望其他数据段能够接收成功。如果其他分段确实接收了,这是最佳的处理方式。但是如果其他的分段也丢失了,则要一次等待分段逐个超时。

  • 重传所有数据段:这是比较悲观和激进的做法。只要一个分段超时,不仅重传该分段,还会重传其他所有尚未确认的分段。在所有的分段都丢失的情况下,这种显然优于第一种处理,带来更低的时延。但是如果其他分段收到了,则一大部分重传是不必要的,则会带来带宽的浪费。

出现两种方式的主要原因是:发送端并不知道其他分段是否已经被收到,而接收端无法确认非连续分段,因此只能选择上面两种处理方式的其中一种。

解决方式是对TCP滑动窗口算法进行扩展,添加允许设备分别确认非连续片段的功能。这一功能称为选择确认(selective acknowledgment, SACK)。

如果连接的两端设备都支持 SACK,并在连接时候协商启用 SACK。在之后的 TCP 报文段中,就可以使用 SACK 选项,该选项包含一个关于已经接收,但是未确认分段的 seq 范围列表,这个列表是非连续的,例如,发送ACK6、SACK8、SACK9。这样发送端就可以根据这个列表对重传队列进行修改,只重传 7 就可以了。

流量控制

在对于包的确认中,同时会携带一个窗口的大小(win),例如

wireshark

我们继续以前面的图为例,我们先假设窗口不变的情况,窗口始终为 9。

tcpsndbuf

4 的 ACK 来的时候,会右移一个,这个时候第 13 个包也可以发送了。假设发送端将第三部分的 10、11、12、13全部发送完毕,之后就停止发送了,未发送可发送部分为 0。

tcpsndbuf2

当对于包 5 的确认到达的时候,在客户端相当于窗口再滑动了一格,这个时候,才可以有更多的包可以发送了,例如第 14 个包材可以发送。

tcpsndbuf3

如果接收方实在处理得太慢,导致缓冲中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为 0,这发送方将暂停发送。

我们假设一个极端情况,接收端的应用一直不读取缓存中的数据,LastByteRead 保持不变。当数据包 6 确认后,窗口大小就不能再是 9 了,就要缩小一个变成 8。

tcprevbuf

如果接收端还是一直不处理数据,随着确认的包越来越多,窗口越来越小,直到为 0。

tcprevbuf2

当这个窗口通过包 14 的确认到达发送端的时候,发送端的窗口也调整为 0,停止发送。

tcpsndbuf

如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止低能窗口综合征,别空出一个字节来就赶快告诉发送方,然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口。

这就是整个 TCP 的流控过程,通过 ACK 交互滑动窗口,再通过缓冲区处理情况决定滑动窗口的大小,从而在端到端的通信中协商合适的发送速度。

这里和应用层流量控制中,使用背压 Backpressure 的模式很类似

拥塞控制

前面的滑动窗口(WRND)是避免发送方把接收方打垮,而拥塞窗口(CWND)则是避免把整个网络塞满。

TCP 拥塞控制的目的是在网络不堵塞、不丢包的情况下,尽量利用带宽传输最多的数据。如果把网络比喻成是一个水管。水管有粗细,网络有带宽,也即每秒钟能够发送多少数据;水管有长度,端到端有延时。在理想状态下,水管里面水的量 = 水管粗细 * 水管长度。对应到网络上,通道的容量 = 带宽 * 往返时延。

这里有一个公式 发送窗口 = LastByteSent - LastByteAcked <= min { 滑动窗口, 拥塞窗口 },是滑动窗口和拥塞窗口共同控制发送的速度。如果我们设置发送窗口,使得发送但未确认的包为通道的容量,就能够撑满整个管道。

congestioncontrol

如图所示,假设往往时间为 8s,去 4s,回 4s,每秒发送一个包,每个包 1024 字节。已经过去了 8s,则 8 个包都发出去了,其中前 4 个包已经到达接收端,但是 ACK 还没有返回,不能算发送成功。5-8 后四个包还在路上,还没被接受。这个时候,整个管道正好撑满,在发送端,已经发送未确认的为 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以来回时间 8s。

如果在这个基础上调大窗口,使得单位时间内更多的包可以发送,会出现什么现象呢?

原来发送一个包,从一端到达另一端,假设一共经过四个设备,每个设备处理一个包时间消耗 1s ,所以到达另一端需要耗时 4s,如果发送得更加快速,这单位时间内,会有更多的包到达这些中间设备,这些设备还是只能每秒处理一个包的话,多出来的包就会被丢弃,如果在这些设备上加缓存,处理不过来的在队列里面排着,这样包就不会丢失,但是缺点是会增加延时,这个缓存的包,4s 肯定到达不了接收端了,如果延时达到一定程度,就会超时重传。

丢包和重传,都是我们不想看到的,一旦出现这种现象,说明发送的速度太快了。因此TCP 使用慢启动的方式来解决这个问题。

一条 TCP 连接开始,CWND(通过内核 TCP_INIT_CWND 控制,默认为 10) 设置为一个报文段,一次只能发送一个;当收到一个确认的时候,CWND加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认CWND加一,两个确认CWND加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 CWND 加一,四个确认 CWND 加四,于是一次能够发送八个。可以看出这是指数性的增长。

涨到什么时候是个头呢?有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。

每收到一个确认后,CWND 增加 1/CWND,我们接着上面的过程来,一次发送 八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一个 CWND 增加 1,于是一次能够发送九个,变成了线性增长。

线性的增长虽然缓慢,但是还是可能最终导致拥塞。拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 CWND/2,将 CWND 设为 1,重新开始慢启动。这正是一旦超时重传,马上回到慢启动了。但是这种方式太激进了,将一个高速的传输速度一下子停下来,会造成网络卡顿。

前面我们讲过快速重传算法。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速的重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,CWND减半为 CWND/2,然后sshthresh = CWND,当三个包返回的时候,CWND = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。

congestioncontrol

这个拥塞控制模型,也是存在一定的问题

  1. 网络在不繁忙的情况下也会出现丢包,不应该出现重传就降速
  2. 这个模型要把中间设备都填满,才发现丢包,降低速度。其实 TCP 只要把网络容量打满就可以了,不需要连缓存也占满。

Google 提出的,TCP BBR 拥塞算法。它企图找到一个平衡点,就是通过不断的加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。

参考资料

  • Geektime 趣谈网络协议
  • TCP/IP 详解卷一
  • https://wizardforcel.gitbooks.io/network-basic/content/7.html
This post is licensed under CC BY 4.0 by the author.