网络协议:TCP/IP

OSI 七层模型回顾

参考: 网络协议1-网络分层模型

TCP 报头

TCP头

  • Source Port,Destination Port:TCP 的包是没有 IP 地址的,那是 IP 层上的事。但是有源端口和目标端口。
  • Sequence Number 是发出包的序号,用来解决网络包乱序(reordering)问题。
  • Acknowledgement Number:就是回复 ACK 序号,占 32 位,只有 ACK 标志位为 1 时,确认序号字段才有效,Ack=Seq+1。
  • TCP Flag ,也就是包的类型,主要是用于操控 TCP 的状态机的,Flag 共 6 个,即 URG、ACK、PSH、RST、SYN、FIN 等,具体含义如下:
    • URG:紧急指针(urgent pointer)有效。
    • ACK:Acknowledgement,确认收到。
    • SYN:Synchronize Sequence Numbers,一般在发起一个新连接的时候需双方重新同步 Seq Num;
    • PSH:接收方应该尽快将这个报文交给应用层。
    • RST:RESET,重置连接;
    • FIN:FINISH,释放一个连接。
  • Window 又叫 Advertised-Window,也就是著名的滑动窗口(Sliding Window),用于解决流控的。

Sequence Number

TCP 的三次、四次、数据传输过程中,发送的 Seq Num 和 Ack Num 的变化:

  • 发出数据段 Seq Num = 上次发送报文的 Seq + 上次发送报文的 Len
  • 回复 Ack Num = 上次收到的报文中的 Seq + Len(数据长度)

也就是说,接收方发出的 ACK Num,是下次期望收到的 Seq Num

SYN 报文或者 FIN 报文 计算 Seq Num 时按 1 算


对于建链接的 3 次握手,主要是要初始化 Sequence Number 的初始值。通信的双方要互相通知对方自己的初始化的 Sequence Number(缩写为 ISN:Inital Sequence Number)

发送方每发送一个数据包,下个 Seq Num 就增加该次发包的长度,比如第一次发送的包 Seq = 1,长度= 1440,第二次再发送数据包 Seq = 1441;
接收方收到 1440 长度的包,回复一个 ACK 消息(ack = 发送方的 Seq Num);

初始 SN
关于三次握手时,初始的 Seq Num 并不是从 0 开始,RFC793中说,初始 SN 会和一个假的时钟绑在一起,这个时钟会在每4微秒对 SN 做加一操作,直到超过2^32又从0开始。这个周期 ≈ 4.55个小时。
举例,在这个连接上(@doubt 唯一四元组?)不断进行握手挥手操作,那么每次握手时的初始 SN(Inital Sequence Number,ISN)是不同的,但每隔4.55个小时再次相同;

这样,一个连接的 Sequence Number 重复周期大约是。因为,我们假设我们的 TCP Segment 在网络上的存活时间不会超过 Maximum Segment Lifetime。所以,只要 MSL 的值小于 4.55 小时,那么,我们就不会重用到 Sequence Number。

如果你用 Wireshark 抓包程序看3次握手,你会发现握手后,初始 SeqNum 总是为0,其实是 Wireshark 为了显示更友好,使用了 Relative SeqNum——相对序号,你只要在右键菜单中的 protocol preference 中取消掉就可以看到“Absolute SeqNum”了

TCP Segment 最大存活时间:
Maximum Segment Lifetime(缩写为 MSL – Wikipedia语条),指的是 TCP 数据段在网络上最大存活时间,一般来说 MSL 设置值不能超过上面4.55小时,

三次握手、四次挥手

open and close

3次握手过程详解:
(1)第一次握手:

  • Client 将标志位 SYN 置为 1,随机产生一个值 seq=X,并将该数据包发送给 Server,Client 进入 SYN_SENT 状态,等待 Server 确认。

(2)第二次握手:

  • Server 收到 SYN 数据包后,也会回复一个 SYN(将标志位 SYN 和 ACK 都置为 1,ack=X+1,随机产生一个值 seq=Y),Server 进入 SYN_RCVD (半连接)状态。

(3)第三次握手:

  • Client 收到 SYN 确认后,回复 ACK(ACK 置为 1,ack=Y+1),并将该数据包发送给 Server, Server 进入 ESTABLISHED 状态

4次挥手过程详解:

(1)第一次挥手:

  • Client 发送一个 FIN,用来关闭 Client 到 Server 的数据传送,Client 进入 FIN_WAIT_1状态;

(2)第二次挥手:

  • Server 收到 FIN 后,发送一个 ACK 给 Client,确认序号为收到序号+1(与 SYN 相同,一个 FIN 占用一个序号),Server 进入 CLOSE_WAIT 状态。Client 接收到 ACK 进入 FIN_WAIT_2 状态;

(3)第三次挥手:

  • Server 发送一个 FIN,用来关闭 Server 到 Client 的数据传送,Server 进入 LAST_ACK 状态。Client 收到 FIN 进入 TIME_WAIT 状态;

(4)第四次挥手:

  • Client 收到 FIN 后,Client 进入 TIME_WAIT 状态,并发送一个 ACK 给 Server,Server 服务端收到后会进入 CLOSED 状态。Client 要等待 Maximum segment lifetime 的时间后也会进入 CLOSED

上面是 Client 主动关闭, Server 被动关闭的情况,实际情况下 Server 主动关闭的情况更多,谁主动关闭谁就有 TIME_WAIT。

为什么挥手是 4 次
由于 TCP 连接是全双工的(每一方都可以收&发),因此每个方向都必须要单独进行关闭,一方发送 FIN 仅仅代表这一方不会再发送数据报文了,但仍可以接收数据报文
当被动关闭的一端(这里是服务端)收到对方的 FIN 时,仅仅表示对方不再发送数据了,但是对方还能接收数据,如果被动方还有数据没发完,需要发完这些数据后,再发出 FIN 报文表示自己不再发数据了。因此,己方 ACK 和 FIN 一般都会分开发送。

关闭时的 TIME_WAIT

根据 TCP 状态机的图可知,主动发起断开连接的一端,收到对端的 FIN+ACK 后,会进入 TIME_WAIT 状态,从 TIME_WAIT 状态到 CLOSED 状态,有一个超时设置,这个超时设置是 两个 MSL(Maximum Segment Lifetime)。为什么要这有 TIME_WAIT?为什么不直接给转成 CLOSED 状态呢?

根据第三版《UNIX 网络编程卷1》2.7节,TIME_WAIT 状态的主要目的有两个:

  • (1)优雅的关闭 TCP 连接,也就是尽量保证被动关闭的一端收到它发出去的 FIN 报文的 ACK 报文;
  • (2)为了避免前后两次 TCP 连接(两个使用相同四元组的)中的旧连接发出去的报文,干扰后面的新连接通讯。等待 2MSL 让旧连接的数据包消逝;

对(1)的解释 :

  • 如果主动断开方等待的时间不够长,当被动方(服务端) 还没有收到 ACK 消息时,主动断开方又尝试重新与服务端建立 TCP 连接,这种情况下就会造成以下问题: 上次连接的被动断开方因为没有收到 ACK 消息,所以仍然认为当前连接是合法的,主动断开方重新发送的 SYN 消息请求握手时,收到服务端的 RST 消息,连接建立的过程就会被终止;
  • 另外一个原因,如果 被动方(服务端) 没有收到它自己发送的 FIN 的 ACK,那么被动方 会尝试重发一次 FIN,如果主动方 没有 TIME_WAIT 而是直接 Close 了,那就无法处理这个重发的 FIN,会导致被动方进入错误的状态,主动方多等一个 TIME_WAIT,如果这段时间里没有收到重传的 FIN,就说明被动方 正常收到了 ACK(在极端的网络条件下,这个 WAIT 机制也不能一定保证,但能解决大部分情形),所以主动方的 TIME_WAIT 的另一个原因是为了确保被动方正确关闭;

那为什么等待 2 个 MSL?

对(2)的解释:
为了保证新 TCP 连接的数据段不会与还在网络中传输的历史连接的数据段重复,TCP 连接在分配新的序列号之前需要至少静默 MSL:

To be sure that a TCP does not create a segment that carries a sequence number which may be duplicated by an old segment remaining in the network, the TCP must keep quiet for a maximum segment lifetime (MSL) before assigning any sequence numbers upon starting up or recovering from a crash in which memory of sequence numbers in use was lost. @ref: “Knowing When to Keep Quiet · Transmission Control Protocol RFC793” https://tools.ietf.org/html/rfc793

下图是一个例子: 服务端发送的 SEQ = 301 消息由于网络延迟直到 TCP 连接关闭后也没有收到;当使用相同端口号的 TCP 连接被重用后,SEQ = 301 的消息才发送到客户端,然而这个过期的消息却可能被客户端正常接收,这就会带来比较严重的问题

![[../_images/22.Network-Protocol-2023-04-22.png]]

RFC 793 中虽然指出了 TCP 连接需要在 TIME_WAIT 中等待 2 倍的 MSL,但是并没有解释清楚这里的两倍是从何而来,比较合理的解释是 — 网络中可能存在来自发起方的数据段,当这些发起方的数据段被服务端处理后又会向客户端发送响应,所以一来一回需要等待 2 倍的时间

参考:

连接时的 SYN 重发

在三次握手过程中,Server 发送 SYN-ACK 之后,收到 Client 的 ACK 之前的的 TCP 连接称为半连接(half-open connect),直到 Server 收到 ACK 后才转入 ESTABLISHED 状态。但如果 Server 一直都没收到 ACK,那么 Server 会重发 SYN-ACK。

在 Linux 下,默认情况下 Server 重发 SYN-ACK 的次数为 5 次,重试的间隔时间从 1 s 开始每次都翻倍,5 次的重试时间间隔为 1 s, 2 s, 4 s, 8 s, 16 s,总共 31 s,第 5 次发出后还要等 32 s 才能知道第 5 次也超时了,所以,总共需要 1 s + 2 s + 4 s+ 8 s+ 16 s + 32 s = 63 s 才会断开这个连接(期间 Server 一直处于半连接状态)。

SYN 攻击就是 Client 在短时间内伪造大量不存在的 IP 地址,并向 Server 不断地发送 SYN 包(或者发送完 SYN 包就立刻下线),正常情况 Server 回复 SYN-ACK,并等待 Client 的 ACK。

但如果 Server 端如果在一定时间内没有收到 Client 的 ACK 则会重发 SYN-ACK。由于 CLient 源地址是不存在的(或者已经下线),因此 Server 需要不断重发 SYN-ACK,这些无效的 SYN-ACK 包将长时间占用 Server 机器的SYN 队列,导致正常的 SYN 请求因为队列满而被丢弃,从而引起网络堵塞甚至系统瘫痪。

SYN 攻击时一种典型的 DDOS 攻击,检测 SYN 攻击的方式非常简单,即当 Server 上有大量半连接状态且源 IP 地址是随机的,则可以断定遭到 SYN 攻击了 : netstat -nat | grep

Linux 下给了一个叫 tcp_syncookies 的参数来应对这个问题 —— 当 SYN 队列满了后,TCP 会通过源地址端口、目标地址端口和时间戳打造出一个特别的 Sequence Number 发回去(又叫 cookie),如果是攻击者则不会有响应,如果是正常连接,则会把这个 SYN Cookie 发回来,然后服务端可以通过 cookie 建连接(即使 Client 不在 SYN 队列中)。请注意,请先千万别用 tcp_syncookies 来处理正常的大负载的连接的情况。因为,synccookies 是妥协版的 TCP 协议,并不严谨。

一般情况下为了防范 SYN 攻击,有三个 TCP 参数可供你选择,第一个是:tcp_synack_retries 来减少重试次数;第二个是:tcp_max_syn_backlog 可以增大 SYN 连接数;第三个是:tcp_abort_on_overflow 处理不过来干脆就直接拒绝连接了。

为什么要有半连接队列? 因为服务端要确保自己的 SYN+ACK 送达客户端,所以 SYN+ACK 会有重发机制,所以就需要暂存半连接。
tcp_syncookies 的作用和局限性? 如果半连接队列满了,且 tcp_syncookies = 1,这时会启用 syn cookie 的机制:服务端根据双方 IP、时间戳、MSS 等创建一个 cookie,并附在 SYN+ACK(二次握手)里,客户端返回的 ACK 需要携带此 cookie 才能成功完成三次握手;
但因为服务端并没有像半连接队列那样暂存客户端的信息,所以 syn cookie 机制没有重发功能;
虽然 tcp_syncookies 看似解决了 SYN 攻击,但是客户端(攻击者)可以采用另一种策略:伪造第三次的 ACK(里面是无效 cookie),让服务端解码无效的 cookie 浪费 CPU;

重传机制

@ref: TCP 的那些事儿(上) | 酷 壳 - CoolShell

发送端发了 Seq Num = 1,2,3,4,5一共五份数据,接收端收到了1,2,于是回 ack 3,然后收到了4(注意此时3没收到),此时的 TCP 会怎么办?

超时重传

如果发送方超过 timeout 还没有收到数据 3(Seq Num =3),会触发超时重传,超时时间(RTO)是动态计算的(参考「动态 RTO」)

发送方计算超时时间是动态的,但重发次数是内核配置的:net.ipv4.tcp_retries2

Fast Retransmit

TCP 引入了一种叫快速重传机制 (Fast Retransmit) 的算法。如果包没有连续到达,就 ack 最后那个可能被丢了的包,如果发送方连续收到 3 次相同的 ack,就重传。Fast Retransmit 的好处是不用等 timeout 了再重传。

举例:
如果发送方发出了 Seq Num = 1,2,3,4,5 份数据,
数据 1 先到送了,于是就接收方 ack 回 2,但数据 2 因为某些原因没收到,3 却到达了,于是接收方还是 ack 回2,
后面的 4 和 5 都收到了,接收方还是 ack 回 2,因为数据 2 仍旧没有收到,于是发送端收到了三个 ack=2 的确认。
发送方便知道 2 有可能丢了,于是就发送方重发送 2。然后接收端收到了 2,此时因为 3,4,5 都收到了,于是 ack 回6。

Fast Retransmit 的方案,和超时重传机制的区别:不以时间驱动,而以数据驱动,触发重传

SACK

有了超时重传和快速重传,可以决定什么情况触发重传了,但此时有个问题:是重传之前的一个还是重传之前的所有?对于上面的示例来说,是只重传2?还是重传 2、3、4 、5?

为了让需要重传的一方知道具体哪一个数据需要重传,这就需要 Selective Acknowledgment (SACK)机制:
这种机制下,ACK 还是 Fast Retransmit 机制的 ACK 一样,接收端缺少哪份数据就回复这份数据的 ACK,此外需要在 TCP 头里加一个 SACK 的东西,而 SACK 则是向发送方汇报收到的数据的 Seq Num 范围,发送方拿到这个 SACK 就知道哪些数据无需重传了。

这个协议需要两边都支持。在 Linux 下,可以通过tcp_sack参数打开这个功能(Linux 2.4后默认打开)

比如发送三分数据:100-299,300-499,500-699,中间一份数据丢失,接收方收到500-699之后,会回复 ACK=300,SACK=500-700
这样发送端通过 SACK 就知道: 500 前面的一段数据需要重发,而 500-700 的不需要重发。

ack_example

注意:SACK 会消费发送方的资源,试想,如果一个攻击者给数据发送方发一堆 SACK 的选项,这会导致发送方开始要重传甚至遍历已经发出的数据,这会消耗很多发送端的资源。详细的东西请参看《TCP SACK的性能权衡

Duplicate SACK

有了 SACK,还有没解决的问题,那就是接收方已收到了数据,但回复的 ACK 丢了,导致发送方触发了重传(毫无意义);

Duplicate SACK 又称 D-SACK,其主要复用了 SACK 字段来告诉发送方有哪些数据被重复接收了

D-SACK 使用了 SACK区间范围 的第一个数值来做标志:

  • 如果 SACK 范围的第一个数值,被 ACK 所覆盖,那么就是 D-SACK

  • 如果 SACK 范围的第一个数值,被 SACK 的第二个段覆盖,那么也是 D-SACK

Linux 下的 tcp_dsack 参数用于开启这个功能(Linux 2.4后默认打开)

示例一:ACK 丢包

下面的示例中,因为接收端发出的 ACK 丢失,导致发送端重传了数据包(3000-3499),于是接收端发现重复收到,于是回了一个 ACK=4000, SACK=3000-3500,因为 ACK =4000 意味着收到了4000之前的所有数据,4000 范围盖过了 SACK 的第一个数,所以这个 SACK 字段就不是代表SACK,而是 D-SACK。

发送端收到这个 ACK 后,就知道是接收端的 ACK 丢了,但数据已经送到了。

RTT 采样 & 动态 RTO

从前面的 TCP 重传机制我们知道 Timeout 的设置对于重传非常重要:

  • 太长的 Timeout 导致传输效率下降;
  • 太短的 Timeout 会导致可能并没有丢就重发。于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。

所以,TCP 的重传 Timeout (Retransmission TimeOut,RTO)是动态计算的,计算方式是采样一段时间的 RTT(Round Trip Time),平滑计算出 RTO。

RTT——Round Trip Time,也就是一个数据包从发出去到回来的时间

计算 RTO 的平滑算法:

(1)经典算法
(2)Karn / Partridge 算法
(3)Jacobson / Karels 算法:今天的 TCP 协议中在用的算法

流控、拥塞控制

@ref:: TCP 的那些事儿(下) | 酷 壳 - CoolShell

@tldr:

  • 流控:
    • 实现方式-滑动窗口(swnd
    • 流控目的?发送方需要控制给(每个 sock)的对端发送的数据快慢
    • 如果接收方把滑动窗口降维为 0,发送方的处理(Zero Window)
  • 拥塞控制:
    • 拥塞控制解决什么?以及和流控的区别?
      • 拥塞控制是为了避免所在网络的拥塞,流控是为了保证接收端的接收能力 @doubt
      • 拥塞控制是调节 cwnd,流控是条件 swnd
    • TCP 如何进行拥塞控制:拥塞控制调节的是 cwnd,单位是 MSS
      • 慢热启动:快速提速
      • 拥塞避免:cwnd 达到某个阈值,线性提速
      • 拥塞发生:降速,根据是超时重传 or 快速重传,下降的速度算法有区别
      • 快速恢复:一般和快速重传一起使用

@doubt:

  • 滑动窗口(swnd)控制的是某个 sock 连接的 sendQ,还是 TCP 全局的发送队列(RING BUF?)
  • 有的资料写:swnd = min( rwnd , cwnd * mss),即滑动窗口= min(接收窗口, 拥塞窗口 x MSS) @ ​TCP 拥塞控制详解

流控

TCP 使用 Sliding Window 来做网络流控。前面我们说过,TCP 头里有一个字段叫 Window,又叫 Advertised-Window,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来

为了说明滑动窗口,我们需要先看一下 TCP 缓冲区的一些数据结构:

(1)接收端 LastByteRead 指向了 TCP 缓冲区中读到的位置,NextByteExpected 指向的地方是收到的连续包的最后一个位置,LastByteRcved 指向的是收到的包的最后一个位置,我们可以看到中间有些数据还没有到达,所以有数据空白区:
../_images/网络协议2-TCP-2023-05-02-1.png

(2)- 发送端的 LastByteAcked 指向了被接收端 Ack 过的位置(表示成功发送确认),LastByteSent 表示发出去了,但还没有收到成功确认的 Ack,LastByteWritten 指向的是上层应用正在写的地方:
../_images/网络协议2-TCP-2023-05-02-2.png

于是:

  • 接收端在给发送端回 ACK 中会汇报自己的 AdvertisedWindow = MaxRcvBuffer – LastByteRcvd – 1;
  • 而发送方会根据这个窗口来控制发送数据的大小,以保证接收方可以处理。

下面我们来看一下发送方的滑动窗口示意图:
../_images/网络协议2-TCP-2023-05-02-3.png

其中那个黑框就是的滑动窗口,#1 - #4 数据段分别为:

  • Category #1 已收到 ack 确认的数据。
  • Category #2 发还没收到 ack 的。
  • Category #3 在窗口中还没有发出的(接收方还有空间)。
  • Category #4 窗口以外的数据(接收方没空间)

一个处理缓慢的 Server(接收端)是可以把 Client(发送端)的 TCP Sliding Window 给降成0 的,
解决这个问题,TCP使用了Zero Window Probe技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,第次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了。

拥塞控制

上面我们知道了,TCP 通过 Sliding Window 来做流控(Flow Control),但是 TCP 觉得这还不够,因为 Sliding Window 需要依赖于连接的发送端和接收端,其并不知道网络中间发生了什么。TCP 的设计者觉得,一个伟大而牛逼的协议仅仅做到流控并不够,因为流控只是网络模型4层以上的事,TCP 的还应该更聪明地知道整个网络上的事。

具体一点,我们知道TCP通过一个timer采样了RTT并计算RTO,但是,如果网络上的延时突然增加,那么,TCP对这个事做出的应对只有重传数据,但是,重传会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,于是,这个情况就会进入恶性循环被不断地放大。试想一下,如果一个网络内有成千上万的TCP连接都这么行事,那么马上就会形成“网络风暴”,TCP这个协议就会拖垮整个网络。这是一个灾难。

所以,TCP不能忽略网络上发生的事情,而无脑地一个劲地重发数据,对网络造成更大的伤害。对此TCP的设计理念是:TCP不是一个自私的协议,当拥塞发生的时候,要做自我牺牲。就像交通阻塞一样,每个车都应该把路让出来,而不要再去抢路了。

拥塞控制调节的是 cwnd(Congestion Window),单位是 MSS

何为 MSS:于以太网来说,MTU 最大是 1500 字节,除去 TCP+IP 头的 40 个字节,真正的数据传输最大 1460 字节,这就是所谓的 MSS(Max Segment Size);
RFC 791 里说了任何一个 IP 设备都得最少接收 576 字节的大小,(实际上来说 576 是拨号的网络的 MTU,而 576 减去 IP 头的 20 个字节就是 536)所以 TCP 的 RFC 定义这个 MSS 的默认值是 536;

拥塞控制主要是四个算法:1)慢启动2)拥塞避免3)拥塞发生4)快速恢复。这四个算法不是一天都搞出来的,这个四算法的发展经历了很多时间,到今天都还在优化中。

(1) 慢热启动算法 – Slow Start
(2) 拥塞避免算法 – Congestion Avoidance
(3) 拥塞状态时的算法
(4) 快速恢复算法 – Fast Recovery

一个简单的图示以同时看一下上面的各种算法的样子:
../_images/网络协议2-TCP-2023-05-02-4.png

[[../_attachments/TCP 的那些事儿(下) _ 酷 壳 - CoolShell.pdf]]


附:TCP 状态机

下图是“TCP 协议的状态机” :
state machie