沧海月明

With great power comes great responsibility

0%

理解 TCP

此文章仅为笔记,不推荐大家观看。

TCP Header

img

上面每一个方格代表 8 位,所以序列号有 4x8 = 32 位

  • 源端口,目标端口:TCP 里面不包含 IP 地址,因为那是网络层(IPv4)应该干的事情。
    • TCP 通过源 IP,端口,目标 IP,端口 4 个特征标识一个 TCP 连接。
    • 本地向远程80端口发起请求时,本地的端口是随机申请的。
  • 序列号:Sequence number,为 32 位的无符号整数。用于接收端收到数据进行排序,因为 IP 层不保证数据有序。
    • 一开始序列号会随机生成一个初识序列号。在建立连接时,通信双方通过 SYN 报文交换彼此的 ISN。
    • 序列号回绕处理,因为序列号随机生成,所以同一个连接的序列号是有可能溢出回绕(sequence wraparound)的。Linux 通过 (_s32)(seq1-seq2) < 0 来处理,即使绕回了,因为从无符号强制转换为符号,也会变成负数。
  • 确认号:TCP 使用确认号(Acknowledgment number, ACK)来告知对方下一个期望接收的序列号,小于此确认号的所有字节都已经收到
    • 不是所有包需要确认,比如 ACK,不然不是无限循环确认了。
    • 不是收到了数据包就立马需要确认的,可以延迟一会再确认。
    • 确认号永远是表示小于此确认号的字节都已经收到。
  • Flags:我们通常所说的 SYN、ACK、FIN、RST 其实只是把 flags 对应的 bit 位置为 1 而已,这些标记可以组合使用,比如 SYN+ACK,FIN+ACK 等
    • SYN(Synchronize):用于发起连接数据包同步双方的初始序列号
    • ACK(Acknowledge):确认数据包
    • RST(Reset):这个标记用来强制断开连接,通常是之前建立的连接已经不在了、包不合法、或者实在无能为力处理
    • FIN(Finish):通知对方我发完了所有数据,准备断开连接,后面我不会再发数据包给你了。
    • PSH(Push):告知对方这些数据包收到以后应该马上交给上层应用,不能缓存起来
  • 窗口大小:上面只有 16 位(窗口最大只有64KB),这是历史设计问题。所以现在 TCP 引入了 窗口缩放因子。缩放因子可以把窗口扩大到原来的 $2^n$ 次方。比如缩放因子为7,则原来的窗口大小乘以 $2^7=128$ 。例如下图,窗口大小为 $6379 \cdot 64=408256$ 。

image-20210602211449596

  • 可选项:格式如下:

img

  • 常用的选项有以下几个:
    • MSS:最大段大小选项,是 TCP 允许的从对方接收的最大报文段
    • SACK:选择确认选项
    • Window Scale:窗口缩放选项
    • 时间戳

      TCP选项之时间戳

TCP 时间戳选项用于 high performance。如果有一端没有开启时间戳选项,那么就不会使用了。

Timestamps 由 TSval 和 Tsecr 组成。

TSval:Timestamps value,发送时报文时携带本机的时间戳。

TSecr:Timestamps echo reply,发送时,将接收到的时间戳放在这里。

img

作用:

两端往返时延测量(RTTM):

发送端在收到接收方发出的 ACK 报文以后,就可以通过这个响应报文的 TSecr

在启用 timestamp 选项之前,测量 RTT 的过程如下。

timestamps_rttm2

TCP 在发送一个包时,会记录这个包的发送的时间 t1,用收到这个包的确认包时 t2 减去 t1 就可以得到这次的 RTT。这里有一个问题,如果发出的包出现重传,计算就变得复杂起来,如下所示。

timestamps_rttm

这里的 RTT 到底是 t3 - t1 还是 t3 - t2 呢?这两种方式无论选择哪一种都不太合适,无法得知收到的确认 ACK 是对第一次包还是重传包的的确认。TCP RFC6298 对这种行为的处理是不对重传包进行 RTT 计算,这样计算不会带来错误,但当所有包都出现重传的情况下,将没有包可用来计算 RTT。

在启用 Timestamps 选项以后,因为 ACK 包里包含了 TSval 和 TSecr,这样无论是正常确认包,还是重传确认包,都可以通过这两个值计算出 RTT。

序列号回绕(PAWS):

在高性能网络中,一个 TCP 包的大小最后会放大到很大。TCP 的序列号用 32bit 来表示,因此在 $2^{32}$ 字节的数据传输后序列号就会溢出回绕。TCP 的窗口经过窗口缩放可以最高到 1GB($2^{30}$),在高速网络中,序列号在很短的时间内就会被重复使用。

下面以一个实际的例子来说明,如下图所示。

paws

假设发送了 6 个数据包,每个数据包的大小为 1GB,第 5 个包序列号发生回绕。第 2 个包因为某些原因延迟导致重传,但没有丢失到时间 t7 才到达。这个迷途数据包与后面要发送的第 6 个包序列号完全相同,如果没有一些措施进行区分,将会造成数据的紊乱。

如果有 Timestamps 的存在,内核会维护一个为每个连接维护一个 ts_recent 值,记录最后一次通信的的 timestamps 值,在 t7 时间点收到迷途数据包 2 时,由于数据包 2 的 timestamps 值小于 ts_recent 值,就会丢弃掉这个数据包。等 t8 时间点真正的数据包 6 到达以后,由于数据包 6 的 timestamps 值大于 ts_recent,这个包可以被正常接收。

补充说明:

  • timestamps 值是一个单调递增的值,与我们所知的 epoch 时间戳不是一回事,这个选项不要求两台主机进行时钟同步。两端 timestamps 值增加的间隔也可能步调不一致,比如一条主机以每 1ms 加一的方式递增,另外一条主机可以以每 1s 加一的方式递增。
  • 与序列号一样,既然是递增 timestamps 值也是会溢出回绕的。
  • timestamps 是一个双向的选项,如果只要有一方不开启,双方都将停用 timestamps。比如下面是curl www.baidu.com得到的包。

img

可以看到客户端发起 SYN 包时带上了自己的 TSval,服务器回复的 SYN+ACK 包没有 TSval和TSecr,从此之后的包都没有带上时间戳选项了。

  • 三次握手中的第二步,如果服务端回复 SYN+ACK 包中的 TSecr 不等于握手第一步客户端发送 SYN 包中的 TSval,客户端在对 SYN+ACK 回复 RST。示例包如下所示。

    img

网络包大小

MTU(Maximum Transmission Unit)

最大传输单元,受限于硬件。所以当 IP 发送数据时,如果 IP 数据包的大小大于 MTU,那么 IP 则会进行分片。

img

MTU 的大小不包含目标地址,源地址,长度类型和 CRC 校验。上图是以太网的帧格式,以太网的帧最小的帧是 64 字节,除去 14 字节头部和 4 字节 CRC 字段,有效荷载最小为 46 字节。最大的帧是 1518 字节,除去 14 字节头部和 4 字节 CRC,有效荷载最大为 1500,这个值就是以太网的 MTU。因此如果传输 100KB 的数据,至少需要 (100 * 1024 / 1500) = 69 个以太网帧。

MSS(Max Segment Size)

TCP 为了避免被发送方分片,会主动把数据分割成小段再交给网络层,最大分段大小叫 MSS(不包含TCP和IP的选项字段)

MSS = MTU - IP header头大小 - TCP 头大小。这样一个 MSS 能够正好装进 MTU,使得 IP 层不需要分片。

所以 MSS 的最小大小为:40b(TCP最大选项空间) + 40b(IP层最大选项空间) + 8b(应用层要求最小大小) = 88b

通常MSS大小为1460,1500的MTU-20的IP头-20的tcp头=1460。

TCP Segmentation Offloading(TSO)

为了减轻 CPU 处理 TCP 分片的负担,TSO 技术可以使得 TCP 分片合并的操作 offload(卸载)到网卡上面,由网卡进行处理(TCP,IP 层不在需要管理分片)。

img

如上图,把分片合并操作下放到硬件,解放了 CPU。

Port

img

在 linux 上 这个端口的取值范围由 /proc/sys/net/ipv4/ip_local_port_range 文件的值决定,在我的 CentOS 机器上,临时端口的范围是 32768~60999。

有两种典型的使用方式会生成临时端口:

  • 调用 bind 函数不指定端口

  • 调用 connect 函数

TCP three-way handshake

三次握手的最重要的是交换彼此的 ISN(初始序列号)。

ISN 随机生成,(递增的)。初始的序列号并非从 0 开始,通信双方各自生成,一般情况下两端生成的序列号不会相同。生成的算法是 ISN 随时间而变化,会递增的分配给后续的 TCP 连接的 ISN。

发送流程:

  1. 客户端发送一个 SYN 报文,该报文的 flag 中设置为 SYN。SYN 报文不携带数据,但是占用一个序号,下次发送的数据的序列化加一。

为什么 SYN 段不携带数据却要消耗一个序列号呢?

这是一个好问题,不占用序列号的段是不需要确认的(都没有内容确认个啥),比如 ACK 段。SYN 段需要对方的确认,需要占用一个序列号。

凡是消耗序列号的 TCP 报文段,一定需要对端确认。如果这个段没有收到确认,会一直重传直到达到指定的次数为止。

  1. 服务端收到客户端的 SYN 报文,也回复一个 ACK。同时也要包含 SYN(因为它也要把自己的 ISN 发送给客户端)。
  2. 客户端收到服务端的 SYN+ACK 报文,回复一个 ACK,表示收到服务端的 SYN。(不增加自己的 ISN)。

除了交换彼此的初始序列号,三次握手的另一个重要作用是交换一些辅助信息,比如最大段大小(MSS)、窗口大小(Win)、窗口缩放因子(WS)、是否支持选择确认(SACK_PERM)等,这些都会在后面的文章中重点介绍。

img

img

同时打开

img

同时打开的要求是 ip 和 端口号均相同,就是打开同一个 TCP 的连接。

以其中一方为例,记为 A,另外一方记为 B

  • 最初的状态是CLOSED
  • A 发起主动打开,发送 SYN 给 B,然后进入SYN-SENT状态
  • A 还在等待 B 回复的 ACK 的过程中,收到了 B 发过来的 SYN,what are you 弄啥咧,A 没有办法,只能硬着头皮回复SYN+ACK,随后进入SYN-RCVD
  • A 依旧死等 B 的 ACK
  • 好不容易等到了 B 的 ACK,对于 A 来说连接建立成功

TCP 自连接相当于同时打开

自连接包交互过程

就是比如我本地监听端口 50000,然后本地连接自己,分配的临时端口也为 50000,就相当于同时打开了。

当一方主动发起连接时,操作系统会自动分配一个临时端口号给连接主动发起方。如果刚好分配的临时端口是 50000 端口,过程如下。

  • 第一个包是发送 SYN 包给 50000 端口
  • 对于发送方而已,它收到了这个 SYN 包,以为对方是想同时打开,会回复 SYN+ACK
  • 回复 SYN+ACK 以后,它自己就会收到这个 SYN+ACK,以为是对方回的,对它而已握手成功,进入 ESTABLISHED 状态

全连接队列和半连接队列和 backlog

img

backlog:backlog、somaxconn(内核里面定义)、max_syn_backlog(内核里面定义) 共同决定这半连接,全连接队列的大小,backlog 在创建 socket 的时候可以申明,具体用到再看。

半连接队列:当客户端发起 SYN 到服务端,服务端收到以后会回 ACK 和自己的 SYN。这时服务端这边的 TCP 从 listen 状态变为 SYN_RCVD (SYN Received),此时会将这个连接信息放入「半连接队列」(SYN Queue)。

全连接队列:半连接队列中一旦收到客户端的 ACK,服务端就开始尝试把它加入另外一个全连接队列(Accept Queue)(「全连接队列」包含了服务端所有完成了三次握手,但是还未被应用调用 accept 取走的连接队列。此时的 socket 处于 ESTABLISHED 状态。每次应用调用 accept() 函数会移除队列头的连接。如果队列为空,accept() 通常会阻塞。全连接队列也被称为 Accept 队列。)。

半连接队列和全连接队列占满了,服务端会直接丢弃(当然你可以设置系统策略,也可以选择发送 RST 直接重置连接),这样客户端收不到 ACK 会尝试重发。

在大流量情况下,tcp 连接来的很快,全连接队列马上被占满了,而应用程序却来不及 accept() 消化,此时就会使得 tcp 连接无法及时处理了。所以可以通过增大全连接队列的大小来解决。

SYN Flood 攻击

SYN Flood 是一种广为人知的 DoS(拒绝服务攻击) 想象一个场景:客户端大量伪造 IP 发送 SYN 包,服务端回复的 ACK+SYN 去到了一个「未知」的 IP 地址,势必会造成服务端大量的连接处于 SYN_RCVD 状态,而服务器的半连接队列大小也是有限的,如果半连接队列满,也会出现无法处理正常请求的情况。

SYN Cookie

现在服务器上面都是半启用 tcp_syncookies 的,就是当半连接队列满的时候启用,不满的时候不用。

img

如上图,通过 cookie 实现了无状态的连接(就是通过 hash,把 TCP 一些常用的属性合起来,计算成一串 cookie。客户端收到 cookie 后可以反推得到对应的 seq 等等)。但是这种 cookie 有些高级的 tcp 参数不支持,比如 mss 大小只有规定的几种,不支持 tcp 选项等等。

TCP Fast Open

每一次发送数据都需要三次握手建立 TCP 连接,太麻烦了,所以有了各种连接重用技术。

TFO 是在原来 TCP 协议上的扩展协议,它的主要原理就在发送第一个 SYN 包的时候就开始传数据了,不过它要求当前客户端之前已经完成过「正常」的三次握手。快速打开分两个阶段:请求 Fast Open Cookie 和 真正开始 TCP Fast Open

请求 Fast Open Cookie 的过程如下:

  • 客户端发送一个 SYN 包,头部包含 Fast Open 选项,且该选项的Cookie 为空,这表明客户端请求 Fast Open Cookie
  • 服务端收取 SYN 包以后,生成一个 cookie 值(一串字符串)
  • 服务端发送 SYN + ACK 包,在 Options 的 Fast Open 选项中设置 cookie 的值
  • 客户端缓存服务端的 IP 和收到的 cookie 值

img

第一次过后,客户端就有了缓存在本地的 cookie 值,后面的握手和数据传输过程如下:

  • 客户端发送 SYN 数据包,里面包含数据和之前缓存在本地的 Fast Open Cookie。(注意我们此前介绍的所有 SYN 包都不能包含数据)
  • 服务端检验收到的 TFO Cookie 和传输的数据是否合法。如果合法就会返回 SYN + ACK 包进行确认并将数据包传递给应用层,如果不合法就会丢弃数据包,走正常三次握手流程(只会确认 SYN)
  • 服务端程序收到数据以后可以握手完成之前发送响应数据给客户端了
  • 客户端发送 ACK 包,确认第二步的 SYN 包和数据(如果有的话)
  • 后面的过程就跟非 TFO 连接过程一样了

img

TCP Fast Open 一个最显著的优点是可以利用握手去除一个往返 RTT,如下图所示:

img

在开启 TCP Fast Open以后,从第二次请求开始,就可以在一个 RTT 时间拿到响应的数据。

还有一些其它的优点,比如可以防止 SYN-Flood 攻击之类的。

TCP four-way handshake

img

  1. 客户端调用 close() 方法,发送 FIN 包。(此后客户端不能再发送任何数据给服务端了,而服务端在发送自己的 FIN 包时还是可以的)。

FIN 段是可以携带数据的,比如客户端可以在它最后要发送的数据块可以“捎带” FIN 段。当然也可以不携带数据。不管 FIN 段是否携带数据,都需要消耗一个序列号。

客户端发送 FIN 包以后不能再发送数据给服务端,但是还可以接受服务端发送的数据。这个状态就是所谓的「半关闭(half-close)」

  1. 服务端收到 FIN 包以后回复确认 ACK 报文给客户端,服务端进入 CLOSE_WAIT,客户端收到 ACK 以后进入FIN-WAIT-2状态。

此时服务端知道自己马上要关闭了,会把一些缓冲残留的数据都发送给客户端,当然也可以把 FIN 和数据一起发送。

  1. 服务端发送 FIN 包(同样可以选择包含数据)。
  2. 客户端收到服务端的 FIN 报文以后,回复 ACK 报文用来确认第三步里的 FIN 报文,进入TIME_WAIT状态,等待 2 个 MSL 以后进入 CLOSED状态。服务端收到 ACK 以后进入CLOSED状态。TIME_WAIT是一个很神奇的状态,后面有文章会专门介绍。

挥手可以变成 3 次吗?

可以的,就是你服务端哪里把第2步的第3步合并一起,直接回复 ACK+FIN 就行了(但是前提是你没有额外的数据需要发送了,比如清空缓冲区还未发送的数据)。

同样挥手时也存在同时关闭

TIME-WAIT

TCP 中只有主动断开的那一方会进入 TIME_WAIT 状态,而且会持续 2 个 MSL(Max Segment Lifetime)。

MSL(报文最大生存时间)是 TCP 报文在网络中的最大生存时间。Linux 的套接字实现假设 MSL 为 30 秒,因此在 Linux 机器上 TIME_WAIT 状态将持续 60秒。

TIME-WAIT 存在原因

第一个原因是:数据报文可能在发送途中延迟但最终会到达,因此要等老的“迷路”的重复报文段在网络中过期失效,这样可以避免用相同源端口和目标端口创建新连接时收到旧连接姗姗来迟的数据包,造成数据错乱。

img

如上图,SEQ=3 的数据姗姗来迟,如果没有 TIME_WAIT,那么就会和后面的新连接造成混乱。

TIME_WAIT 等待时间是 2 个 MSL,已经足够让一个方向上的包最多存活 MSL 秒就被丢弃,保证了在创建新的 TCP 连接以后,老连接姗姗来迟的包已经在网络中被丢弃消逝,不会干扰新的连接。

第二个原因是确保可靠实现 TCP 全双工终止连接。关闭连接的四次挥手中,最终的 ACK 由主动关闭方发出,如果这个 ACK 丢失,对端(被动关闭方)将重发 FIN,如果主动关闭方不维持 TIME_WAIT 直接进入 CLOSED 状态,则无法重传 ACK,被动关闭方因此不能及时可靠释放(被动关闭方会重复发送 FIN)。

img

如果四次挥手的第 4 步中客户端发送了给服务端的确认 ACK 报文以后不进入 TIME_WAIT 状态,直接进入 CLOSED状态,然后重用端口建立新连接会发生什么呢?如下图所示

img

主动关闭方如果马上进入 CLOSED 状态,被动关闭方这个时候还处于LAST-ACK状态,主动关闭方认为连接已经释放,端口可以重用了,如果使用相同的端口三次握手发送 SYN 包,会被处于 LAST-ACK状态状态的被动关闭方返回一个 RST,三次握手失败。

为什么时间是两个 MSL?

  • 1 个 MSL 确保四次挥手中主动关闭方最后的 ACK 报文最终能达到对端
  • 1 个 MSL 确保对端没有收到 ACK 重传的 FIN 报文可以到达

2MS = 去向 ACK 消息最大存活时间(MSL) + 来向 FIN 消息的最大存活时间(MSL)

为了应对 TIME_WAIT 持续时间过长的问题,Linux 提供几个选项,net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_tw_recycle。这两个参数都依赖于 tcp 头部选项 timestamp。(就是通过 timestamp 高效重复利用 TIME_WAIT 这段时间)。

SO_REUSEADDR

根据四次挥手原则,主动断开的一方需要等待 2 个 MSL 才能最终释放连接。假设你写了一个 web 服务器,崩溃以后要等待 1 分钟才能重启成功(不然会报端口已经被占用),这是不行的。

服务端主动断开连接以后,需要等 2 个 MSL 以后才最终释放这个连接,重启以后要绑定同一个端口,默认情况下,操作系统的实现都会阻止新的监听套接字绑定到这个端口上。

启用 SO_REUSEADDR 套接字选项可以解除这个限制,默认情况下这个值都为 0,表示关闭。在 Java 中,reuseAddress 不同的 JVM 有不同的实现,在我本机上,这个值默认为 1 允许端口重用。但是为了保险起见,写 TCP、HTTP 服务一定要主动设置这个参数为 1。

并不是处于 TIME_WAIT 才允许端口复用,假设因为网络的原因,客户端没有回发 FIN 包,导致服务器端处于 FIN_WAIT2 状态,而非 TIME_WAIT 状态,那设置 SO_REUSEADDR 同样会生效。

img

为什么通常不会在客户端上出现?

通常情况下都是客户端主动关闭连接,那客户端那边为什么不会有问题呢?

因为客户端都是用的临时端口,这些临时端口与处于 TIME_WAIT 状态的端口恰好相同的可能性不大,就算相同换一个新的临时端口就好了。

SO_REUSEPORT

就是共用端口,多个 socket 监听同一个 socket。可以解决惊群效应(5个线程都在监听同一个端口,突然来了请求,大家就抢起来了,然后只有一个人能够得到)。

reuseport

开启重用端口后,linux 会把accept 的 socket 平均分配给各个线程,大家不需要抢。

SO_LINGER

默认行为下是调用 close 立即返回,但是如果有数据残留在套接字发送缓冲区中,系统将试着把这些数据发送给对端,SO_LINGER 可以改变这个默认设置,具体的规则见下面的思维导图。

img

禁用 linger,只是试着把缓冲区残留数据发送完,不保证一定发完(另一端也压根不知道有没有发完)。

启用 linger,时间设置为 0,没发完至少会发送一个 RST,这样对面知道会有问题(接收到的数据是不完整的)。

RST 包

产生情况:

  • 端口未监听,服务端对访问未监听的端口的 SYN 请求,会直接返回 RST(Connection Reset 或 Connection refused)。
  • 服务端突然断电重启,之前建立的连接信息丢失,另一方不知道。

img

  • 调用 close 函数,且 SO_LINGER 为 true。

收到 RST 包后,会马上释放连接进入 CLOSED 状态。

img

RST 包不需要确认,即使客户端没有收到 RST 包也没事,因为它会继续重传,然后服务端又会发送 RST。即使客户端一辈子收不到 RST,那客户端自己也会超时自动关闭。

Broken pipi:在一个已经 RST 的 socket 里面继续写数据。

Connection reset by peer:就是你尝试访问别人,别人直接给你发 RST 的时候。

TCP 重传

超时重传:每一个发送的数据包上面都有一个定时器,如果超过 RTO(retransmission timeout),那就会自动重传。每一次重试间隔是指数级避退。(比如第一次2s,第二次就4s,第三次8s,等等)。

RTO 的值通过一套数学公式基于 ttl 计算(一个包从发送到收到其ack的时间长度叫 ttl)。

img

快速重传:但是有种可能,比如我一次性发了,2,3,4,5 包。然后3,4,5的包收到了,回复的 ack 均为 2(因为 2 没收到)。如果此时采用超时重传就太磨蹭了,要等 2 包的定时器走完。所以有了快速重传,当收到重复的 ack 3次,如上面的例子,收到 ack=2 的包重复三次,客户端会马上重新发送 2 号包。

SACK(Selective Acknowledgment):

ACK 永远只表示之前的包已经收到,但是如下图:发送端是如何知道只需要重传1001:2001 即可。(ack 返回的永远是 1001),这时候 SACK 能够解决,选择性重传。

这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将缓存的地图发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据

img

img

img

打开单个包的详情,在 ACK 包的 option 选项里,包含了 SACK 的信息,如下图:

img

如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。

滑动窗口

如果 TCP 采用发一个包,收到 ack,再发下一个包,这样,势必很慢。所以 tcp 引入滑动窗口,比如一次性发送3个包,然后等待ack。。。

窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值

为什么接收处窗口里面的数据不能被应用层及时消耗掉?

通常情况下来说,是能及时消耗的,但是当负载很大的时候,就会出现应用层无法及时取走缓存的数据。

每一个 tcp 请求里面都有一个字段叫 Window,就是告诉对方自己还有多少大的空间用于接收数据。如下图,是告诉对方自己的接收窗口(rwnd)。

img

TCP 包分类

从 TCP 角度而言,数据包的状态可以分为如下图的四种

img

  • 粉色部分#1 (Bytes Sent and Acknowledged):表示已发送且已收到 ACK 确认的数据包。
  • 蓝色部分#2 (Bytes Sent but Not Yet Acknowledged):表示已发送但未收到 ACK 的数据包。发送方不确定这部分数据对端有没有收到,如果在一段时间内没有收到 ACK,发送端需要重传这部分数据包。
  • 绿色部分#3 (Bytes Not Yet Sent for Which Recipient Is Ready):表示未发送但接收端已经准备就绪可以接收的数据包(有空间可以接收)
  • 黄色部分#4 (Bytes Not Yet Sent,Not Ready to Receive):表示还未发送,且这部分接收端没有空间接收

示例分析

示例:

img

  • 1~3:三次握手,确认双方的 Window。
  • 4-7:每次发送 1000 长度,把对方窗口占满了(因为没有收到对方的ack,所以暂停发送)
  • 8-11:实在太久没有收到 ack 了,怀疑丢包了,重传第一个 1000 的包

实例

img

  • 第8包,知道客户端窗口满了,不再发送
  • 第9包,客户端返回 ack,同时自己的 Win=0,说明自己也已经满了
  • 10包以后,客户端不断的发送零窗口探测包,来知道客户端是不是有空闲的窗口了。

现在发送端的滑动窗口变为 0 了,经过一段时间接收端从高负载中缓过来,可以处理更多的数据包,如果发送端不知道这个情况,它就会永远傻傻的等待了。于是乎,TCP 又设计了零窗口探测的机制(Zero window probe),用来向接收端探测,你的接收窗口变大了吗?我可以发数据了吗?

零窗口探测包其实就是一个 ACK 包。

TCP window full 与 TCP zero window

这两者都是发送速率控制的手段,

  • TCP Window Full 是站在发送端角度说的,表示在途字节数等于对方接收窗口的情况,此时发送端不能再发数据给对方直到发送的数据包得到 ACK。
  • TCP zero window 是站在接收端角度来说的,是接收端接收窗口满,告知对方不能再发送数据给自己。

拥塞控制

滑动窗口只考虑到接收方处理数据的能力,而没有考虑网络拥堵情况,tcp 引入了拥塞窗口(Congestion Window,cwnd)来解决此问题。

它与前面介绍的接收窗口(rwnd)有什么区别呢?

  • 接收窗口(rwnd)是接收端的限制,是接收端还能接收的数据量大小
  • 拥塞窗口(cwnd)是发送端的限制,是发送端在还未收到对端 ACK 之前还能发送的数据量大小

拥塞窗口初始值等于操作系统的一个变量 initcwnd,最新的 linux 系统 initcwnd 默认值等于 10。

真正的发送窗口大小 = 「接收端接收窗口大小」 与 「发送端自己拥塞窗口大小」 两者的最小值

如果接收窗口比拥塞窗口小,表示接收端处理能力不够。如果拥塞窗口小于接收窗口,表示接收端处理能力 ok,但网络拥塞。

这也很好理解,发送端能发送多少数据,取决于两个因素

  • 对方能接收多少数据(接收窗口)
  • 自己为了避免网络拥塞主动控制不要发送过多的数据(拥塞窗口)

发送端和接收端不会交换 cwnd 这个值,这个值是维护在发送端本地内存中的一个值,发送端和接收端最大的在途字节数(未经确认的)数据包大小只能是 rwnd 和 cwnd 的最小值。

慢启动

一个拥塞窗口表示可以传一个 MSS 大小的数据。

  1. 三次握手,沟通对方自己的接收窗口
  2. 初始化自己的拥塞窗口,默认为10。(也就是一次性发送10个MSS大小的包)
  3. 拥塞窗口较小时(未超过阀值),没收到一个ack,cwnd+1。也就是每经过一个RTT,cwnd变为之前的两倍。

img

在初始拥塞窗口为 10 的情况下,拥塞窗口随时间的变化关系如下图:

img

示例

假设MSS为100,8080端口向客户端写入大量数据。

第一步:8080端口一口气发送了 10 个 MSS 大小的包

img

第二步:8080收到ack,开始一口气发20个包

img

第三部:再次收到ack,开始一口气发40个包

img

拥塞避免

慢启动拥塞窗口(cwnd)肯定不能无止境的指数级增长下去,否则拥塞控制就变成了「拥塞失控」了,它的阈值称为「慢启动阈值」(Slow Start Threshold,ssthresh),这是文章开头介绍的拥塞控制的第二个核心状态值。ssthresh 就是一道刹车,让拥塞窗口别涨那么快。一般来说 ssthresh 的大小是 65535 字节。

  • 当 cwnd < ssthresh 时,拥塞窗口按指数级增长(慢启动)
  • 当 cwnd > ssthresh 时,拥塞窗口按线性增长(拥塞避免)

当 cwnd > ssthresh 时,拥塞窗口进入「拥塞避免」阶段,在这个阶段,每一个往返 RTT,拥塞窗口大约增加 1 个 MSS 大小,直到检测到拥塞为止。

img

与慢启动的区别在于

  • 慢启动的做法是 RTT 时间内每收到一个 ACK,拥塞窗口 cwnd 就加 1,也就是每经过 1 个 RTT,cwnd 翻倍
  • 拥塞避免的做法保守的多,每经过一个RTT 才将拥塞窗口加 1,不管期间收到多少个 ACK

img

实际的算法是如下:,

  • 每收到一个 ACK,将拥塞窗口增加一点点(1 / cwnd):cwnd += 1 / cwnd

以初始 cwnd = 1 为例,cwnd 变化的过程如下图

img

所以是每经过 1 个 RTT,拥塞窗口「大约」增加 1。

拥塞发生

当发现包重传的时候,说明网络开始有问题了。有两种拥塞,分别为超时重传和快速重传。

超时重传

当发生超时重传时,说明网络很差了,会使用如下算法。

  • ssthresh 设为 cwnd/2
  • cwnd 重置为 1

img

接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。

快速重传

就是能收到三个重复的 ack 包,说明网络其实还行。

TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthreshcwnd 变化如下:

  • cwnd = cwnd/2 ,也就是设置为原来的一半;
  • ssthresh = cwnd;
  • 进入快速恢复算法

然后,进入快速恢复算法如下(具体算法有些不同,看其具体实现,有很多种算法):

  • 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了)
  • 重传丢失的数据包
  • 如果再收到重复的 ACK,那么 cwnd 增加 1
  • 如果收到新数据的 ACK 后,设置 cwnd 为 ssthresh,接着就进入了拥塞避免算法

img

快速重传和快速恢复

也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。

Nagle 算法

简单来讲 nagle 算法讲的是减少发送端频繁的发送小包给对方。

Nagle 算法要求,当一个 TCP 连接中有在传数据(已经发出但还未确认的数据)时,小于 MSS 的报文段就不能被发送,直到所有的在传数据都收到了 ACK。同时收到 ACK 后,TCP 还不会马上就发送数据,会收集小包合并一起发送。

1
2
3
4
5
6
7
8
9
10
11
if there is new data to send
if the window size >= MSS and available data is >= MSS
send complete MSS segment now
else
if there is unconfirmed data still in the pipe
enqueue data in the buffer until an acknowledge is received
else
send data immediately
end if
end if
end if

默认情况下 Nagle 算法是启用的。

img

Nagle 算法的作用是减少小包在客户端和服务端直接传输,一个包的 TCP 头和 IP 头加起来至少都有 40 个字节,如果携带的数据比较小的话,那就非常浪费了。就好比开着一辆大货车运一箱苹果一样。

Nagle 算法在通信时延较低的场景下意义不大。在 Nagle 算法中 ACK 返回越快,下次数据传输就越早。

延迟确认

首先必须明确两个观点:

  • 不是每个数据包都对应一个 ACK 包,因为可以合并确认。
  • 也不是接收端收到数据以后必须立刻马上回复确认包。

如果收到一个数据包以后暂时没有数据要分给对端,它可以等一段时间(Linux 上是 40ms)再确认。如果这段时间刚好有数据要传给对端,ACK 就可以随着数据一起发出去了。如果超过时间还没有数据要发送,也发送 ACK,以免对端以为丢包了。这种方式成为「延迟确认」。

这个原因跟 Nagle 算法其实一样,回复一个空的 ACK 太浪费了。

  • 如果接收端这个时候恰好有数据要回复客户端,那么 ACK 搭上顺风车一块发送。
  • 如果期间又有客户端的数据传过来,那可以把多次 ACK 合并成一个立刻发送出去
  • 如果一段时间没有顺风车,那么没办法,不能让接收端等太久,一个空包也得发。

这种机制被称为延迟确认(delayed ack),思破哥的文章把延迟确认(delayed-ack)称为「磨叽姐」,挺形象的。TCP 要求 ACK 延迟的时延必须小于500ms,一般操作系统实现都不会超过200ms。

延迟确认在很多 linux 机器上是没有办法关闭的。

如何 Nagle 和延迟确认配合一起使用,就会有严重的性能问题。Nagle 攒着包一次发一个,延迟确认收到包不马上回。

Keepalive

假设 tcp 三次握手后建立了连接,然后一方被踢了,然后另一方不知道,还傻傻的维持的 tcp 连接。

上面所说的情况就是典型的 TCP「半打开 half open」

这一个情况就是如果在未告知另一端的情况下通信的一端关闭或终止连接,那么就认为该条TCP连接处于半打开状态。 这种情况发现在通信的一方的主机崩溃、电源断掉的情况下。 只要不尝试通过半开连接来传输数据,正常工作的一端将不会检测出另外一端已经崩溃。

TCP 协议的设计者考虑到了这种检测长时间死连接的需求,于是乎设计了 keepalive 机制。 在 CentOS 机器上,keepalive 探测包发送数据 7200s,探测 9 次,每次探测间隔 75s,这些值都有对应的参数可以配置。

img

  • 1-3 三次握手,之后客户端断网
  • 从 4 开始,(已经过了 30s),服务端发送 keep-alive 探测包。
  • 因为一直没有响应,每隔 10s 发送一个探测包。
  • 服务端觉得没希望了,9 号包发送 RST 重置连接。

为什么大部分程序没有开启 keepalive 选项?

现在大部分应用程序(比如我们刚用的 nc)都没有开启 keepalive 选项,一个很大的原因就是默认的超时时间太长了,从没有数据交互到最终判断连接失效,需要花 2.1875 小时(7200 + 75 * 9),显然太长了。但如果修改这个值到比较小,又违背了 keepalive 的设计初衷(为了检查长时间死连接)

所以应用层面通常会使用心跳包进行检测,自己每次发送心跳包。

杂项

ESTABLISHED 状态的连接收到 SYN 如何处理?

estab_syn

如上图,B端收到一个乱序的 SYN 包,它会返回包含其当前正常SEQ的ACK(叫 challenge ack),这是A端知道了问题所在,发送一个RST(只有正确的SEQ的RST才有用)来关闭连接(这样方便后面的重新创建tcp连接)。

如果攻击者疯狂发送假的乱序包,接收端也跟着回复 Challenge ACK,会耗费大量的 CPU 和带宽资源。于是 RFC 5961 提出了 ACK Throttling 方案,限制了每秒钟发送 Challenge ACK 报文的数量,这个值由 net.ipv4.tcp_challenge_ack_limit 系统变量决定,默认值是 1000,也就是 1s 内最多允许 1000 个 Challenge ACK 报文。