互联网协议 — TCP 传输控制协议

目录

文章目录

  • 目录
  • TCP
  • 抓包分析
  • 三次握手
  • 数据传输
  • 四次挥手
  • 长连接与短连接
  • 重传机制
    • 超时重传
    • 快速重传
    • SACK 方法
    • Duplicate SACK
      • ACK 丢包场景
      • 网络延时场景
  • 滑动窗口
    • 发送方的滑动窗口
    • 接收方的滑动窗口
  • 流量控制
    • 当应用程序没有及时读取缓存时
    • 服务端系统资源非常紧张时
    • 窗口关闭
    • 糊涂窗口综合症
  • 拥塞控制
    • 慢启动算法
    • 拥塞避免算法
    • 拥塞发生算法
      • 发生超时重传的拥塞发生算法
      • 发生快速重传的拥塞发生算法
    • 快速恢复算法
    • BBR 算法
  • 粘包与拆包
  • TCP 状态变更
    • 客户端状态变更
    • 服务端状态变更
  • TCP 的高并发连接
  • 参考文章

TCP

TCP(Transmission Control Protocol,传输控制协议),是一种面向连接的可靠传输协议,提供可靠(无差错、不丢失、不重复、按顺序)的字节流数据传输服务。在传输效率和可靠性之间选择了后者,所以有开销大、传输速度慢的缺点。

  • 面向连接:在使用 TCP 通信之前,需要进行 “三次握手” 建立发收双方连接,通信结束后还要进行 “四次挥手” 确保断开连接。
  • 点对点:一条 TCP 连接只能连接两个端点。
  • 全双工通信:允许通信双方任意时刻双向发送数据,发送方设有发送缓存,接收方设有接收缓存。
  • 字节流传输:TCP 会将数据当作字节流进行处理,不尝试理解所传输的数据含义,仅把数据看作一连串的字节序列。

TCP 的可靠性传输具有非常复杂的实现细节,包括但不限于:

  • ACK 确定机制:当接收方接收到数据段后,会返回 ACK 确认
  • 定时重发:方发送方发送数据段后,会启动定时器,超时未接收到 ACK 确认,会重发该数据段
  • 数据校验:接收方会对数据段进行数据校验,如果发现数据段有差错,会将该数据段丢弃,等待超时重传
  • 顺序传输:TCP 字节流会为每个字节排序,确保数据传输顺序的正确性
  • 滑动窗口:TCP 数据段长度可根据收发双方的缓存、网络等状态而调整。接收方只允许发送方发送接收缓冲区所能接纳的数据,防止缓冲区溢出

数据段首部

互联网协议 — TCP 传输控制协议_第1张图片

  • Sequence Number 序列号:字节流中的每个字节都要按序编号,该字段值为本数据段数据部分的第一个字节的序号
  • Acknowledgment Number 确认号:确认序列号
  • Offset 偏移量:数据段首部的长度,字段值为首部长度除以 4
  • Reserved 预留:保留位,供今后使用
  • TCP flags 标签:标识数据段性质。
  • Window 窗口:标识发送者接收窗口的大小
  • CheckSum 校验值:用于检查数据段在传输过程中是否出现差错
  • Urgent Pointer 紧急指针:当字段值为 1 时生效,标识本数据段具有紧急数据

其中的 TCP Flags 字段,是非常重要的功能标识,占 8 位,分别为:

  • C(CWR)、E(ECE):用于支持 ECN(显示阻塞通告)
  • U(URGENT):当值为 1 时,标识此数据段有紧急数据(比如紧急关闭),应优先传送,要与紧急指针字段配合使用
  • A(ACK):仅当字段值为 1 时才有效,建立 TCP 连接后,所有数据段都必须把 ACK 字段值置为 1
  • P(PUSH):若 TCP 连接的一端希望另一端立即响应,PSH 字段便可以 “催促” 对方,不再等到缓存区填满才发送返回
  • R(RESET):若 TCP 连接出现严重差错,该字段的值置为 1,表示先断开 TCP 连接,再重连
  • S(SYN,Synchronize Sequence Numbers):用于建立和释放连接,当字段值为 1 时,表示建立连接。
  • F(FIN):用于释放连接,当字段值为 1 时,表明发送方已经发送完毕,要求释放 TCP 连接

下面以三次握手和四次挥手为例,看看数据段首部是怎么进行传输标识并以此来保存可靠性的。

抓包分析

Client IP:172.18.128.204
Server TCP Socket:(10.0.0.128, 80)
互联网协议 — TCP 传输控制协议_第2张图片

三次握手

互联网协议 — TCP 传输控制协议_第3张图片

  1. 建立连接时,客户端发送 (SYN=1,seq=x) 数据段到服务器,然后进入 SYN_SENT 状态并等待服务器确认;
  2. 服务器收到 (SYN=1,seq=x) 数据段后,返回 (ACK=1, ack=x+1, SYN=1, seq=y) 数据段,服务器进入 SYN_RECV 状态;
  3. 客户端收到服务器的 ACK 确认后,也会向服务器发送 ACK 确认 (ACK=1, ack=y+1),至此客户端和服务器都进入了 ESTABLISHED 状态。三次握手完成,TCP 连接建立成功。
  • Step1. 172.18.128.204.62534 > 10.0.0.128.80: Flags [S], cksum 0x8523 (correct), seq 3401804541, win 65535, options [mss 1460,nop,wscale 5,nop,nop,TS val 960632545 ecr 0,sackOK,eol], length 0

客户端执行系统调用 connect() 发出 SYN 请求建立 TCP 连接,此时客户端的 TCP 端口状态为 SYN_SENT(表示请求连接)。

  • Step 2. 10.0.0.128.80 > 172.18.128.204.62534: Flags [S.], cksum 0x378d (incorrect -> 0xf136), seq 2666924726, ack 3401804542, win 27960, options [mss 1410,sackOK,TS val 19959035 ecr 960632545,nop,wscale 7], length 0

服务端执行系统调用 listen() 监听到 SYN 请求,TCP 端口状态从 LISTEN 转为 SYN_RCVD 并第一次响应 ACK。

  • Step 3. 172.18.128.204.62534 > 10.0.0.128.80: Flags [.], cksum 0x7cfb (correct), seq 3401804542, ack 2666924727, win 4106, options [nop,nop,TS val 960632549 ecr 19959035], length 0

客户端接收到 ACK 响应之后,TCP 端口状态变成 ESTABLISHED(已建立连接,表示通信双方正在通信),再给服务端发送 ACK。服务端接收到 ACK 之后,服务端的 TCP 端口状态为 ESTABLISHED。

NOTE:Flags are some combination of S (SYN), F (FIN), P (PUSH), R (RST), W (ECN CWR) or E (ECN-Echo), or a single ‘.’ (no flags)

  • options 表示选项
    • mss 表示是发送端通告的最大报文长度
    • sackOK 表示发送端支持 SACK 选项,SACK 选项是为了更好的确定数据的准确接收的
    • TS val 发送端的时间戳 ecr 接收端的时间戳
    • wscale 表示窗口因子大小

为什么要使用三次握手来保证数据传输的可靠性

“3 次握手” 的作用就是双方都能明确自己和对方的收、发能力是正常的,并告知收发双方自己的 ISN(Initial Sequence Number,初始化序号),如上文 Client:seq=996980318 或 Server:seq=2180032179,这个 ISN 会被作为建立连接后进行「顺序」数据传输的依据。我们可以从数据段首部的 Sequence Number 和 Acknowledgment Number 都占 32 位得知,seq 和 ack 的取值范围均是 [0, 2^32-1],所以 ISN 实际上会被顺序循环使用。而且 seq 并非每次都是从 0 开始的,TCP 协议会以 4μs 一次的频率进行 ISN+=1 操作,以此来避免 TCP 重连时出现在同一条连接中存在两个及以上 seq number 相同的数据包,而最终导致顺序错乱。所以,三次握手实际上就是初始化通信双方的 seq ISN,保证数据包的有序传输

数据传输

双方建立通信之后,Client 向 Server 正式发出 HTTP 请求

IP (tos 0x0, ttl 60, id 0, offset 0, flags [DF], proto TCP (6), length 129)
    172.18.128.204.62534 > 10.0.0.128.80: Flags [P.], cksum 0xe98f (correct), seq 3401804542:3401804619, ack 2666924727, win 4106, options [nop,nop,TS val 960632549 ecr 19959035], length 77: HTTP, length: 77
	GET / HTTP/1.1
	Host: 172.18.22.208
	User-Agent: curl/7.54.0
	Accept: */*

IP (tos 0x0, ttl 64, id 34743, offset 0, flags [DF], proto TCP (6), length 52)
    10.0.0.128.80 > 172.18.128.204.62534: Flags [.], cksum 0x3785 (incorrect -> 0x8bd8), seq 2666924727, ack 3401804619, win 219, options [nop,nop,TS val 19959040 ecr 960632549], length 0
  • Client => Server:seq = x+1, ack = y+1 继承了第三次连接的 seq 和 ack number
  • 请求长度 length: 77,seq 3401804542:3401804619 == seq 3401804542:[3401804542+length]

Server 处理请求并响应

IP (tos 0x0, ttl 64, id 34744, offset 0, flags [DF], proto TCP (6), length 295)
    10.0.0.128.80 > 172.18.128.204.62534: Flags [P.], cksum 0x3878 (incorrect -> 0x528d), seq 2666924727:2666924970, ack 3401804619, win 219, options [nop,nop,TS val 19959044 ecr 960632549], length 243: HTTP, length: 243
	HTTP/1.1 200 OK
	Date: Thu, 24 Jan 2019 10:08:31 GMT
	Server: Apache/2.4.6 (CentOS)
	Last-Modified: Thu, 24 Jan 2019 08:26:02 GMT
	ETag: "4-5802ff5f8b6b4"
	Accept-Ranges: bytes
	Content-Length: 4
	Content-Type: text/html; charset=UTF-8

	123
IP (tos 0x0, ttl 60, id 0, offset 0, flags [DF], proto TCP (6), length 52)
    172.18.128.204.62534 > 10.0.0.128.80: Flags [.], cksum 0x7bae (correct), seq 3401804619, ack 2666924970, win 4099, options [nop,nop,TS val 960632560 ecr 19959044], length 0
  • Server => Client:seq S_ISN:[S_ISN+length], ack C_ISN

NOTE 1:在经过了三次握手之后(Client 和 Server 都确定了对方的 seq ISN),正式的 HTTP 数据传输是在有序进行的。
NOTE 2:可以看见每一个数据包的发出都有相应的 ACK 响应,确保接收方有确切的接收到数据包,否则发送方会启用超时重发。

四次挥手

互联网协议 — TCP 传输控制协议_第4张图片

TCP 协议规定,对于已经建立的连接,收发双方要进行四次挥手才能成功断开连接,如果缺少了其中某个步骤,都会使连接处于假死状态,连接本身所占用的资源不会被释放

  1. 断开连接时,客户端发送 (FIN=1, seq=m) 数据段,其中 m 的数值为客户端最后一次向服务器发送的数据段的最后一个字节的序号再加上 1,客户端进入 FIN-WAIT-1 状态。
  2. 服务器接收到 (FIN=1, seq=m) 之后,返回 (ACK=1, ack=m+1, seq=n) 确认数据段,服务器进入 CLOSE-WAIT 状态。
  3. 服务器再向客户端发送 (FIN=1, seq=u, ACK=1, ack=m+1) 数据段,服务器进入 LAST-ACK 状态。
  4. 客户端接收到 (FIN=1, seq=u, ACK=1, ack=m+1) 数据段后,返回 (ACK=1, ack=u+1, seq=m+1) 确认数据段,服务器和客户端都进入 CLOSED 状态。四次挥手完成,TCP 连接就此断开。
  • Step 1. 172.18.128.204.62534 > 10.0.0.128.80: Flags [F.], cksum 0x7bad (correct), seq 3401804619, ack 2666924970, win 4099, options [nop,nop,TS val 960632560 ecr 19959044], length 0

由 Client 提出断开连接请求 FIN(Flags [F.],),Client 的 TCP 端口进入 FIN-WAIT-1 状态。

  • Step 2、3. 10.0.0.128.80 > 172.18.128.204.62534: Flags [F.], cksum 0x3785 (incorrect -> 0x8acb), seq 2666924970, ack 3401804620, win 219, options [nop,nop,TS val 19959053 ecr 960632560], length 0

Server 发送 ACK 应答,Server 的 TCP 端口进入 CLOSE_WAIT 状态,Client 的 TCP 端口进入 FIN-WAIT-2 状态。几乎同时 Server 还发送了一个 FIN 端口连接请求,进入 LAST-ACK 状态。

  • Step 4. 172.18.128.204.62534 > 10.0.0.128.80: Flags [.], cksum 0x7b9a (correct), seq 3401804620, ack 2666924971, win 4099, options [nop,nop,TS val 960632569 ecr 19959053], length 0

Client 进行 ACK 应答(LAST ACK),进入 TIME-WAIT(两倍的分段最大生存期),并最终变成 CLOSED 状态。

为什么需要四次挥手

TCP 连接是双向传输的对等的模式,就是说双方都可以同时向对方发送或接收数据。当有一方要关闭连接时,会发送指令告知对方,我要关闭连接了。这时对方会回一个ACK,此时一个方向的连接关闭。但是另一个方向仍然可以继续传输数据,也就是说,服务端收到客户端的 FIN 标志,知道客户端想要断开这次连接了,但是,服务端还可能希望发数据,等到发送完了所有的数据后,会发送一个 FIN 段来关闭此方向上的连接。接收方发送 ACK确认关闭连接。客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。因为服务端在 LISTEN 状态下,收到建立连接请求的 SYN 报文后,把 ACK 和 SYN 放在一个报文里发送给客户端。而关闭连接时,当收到对方的 FIN 报文时,仅仅表示对方不再发送数据了但是还能接收数据,己方是否现在关闭发送数据通道,需要上层应用来决定,因此,己方 ACK 和 FIN 一般都会分开发。

长连接与短连接

短连接:Client 向 Server 发送消息,Server 回应 Client,然后一次读写就完成了,这时候双方任何一个都可以发起 close 操作,不过一般都是 Client 先发起 close 操作。短连接一般只会在 Client/Server 间传递一次读写操作。优点:管理起来比较简单,建立存在的连接都是有用的连接,不需要额外的控制手段。

长连接:Client 与 Server 完成一次读写之后,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。

在长连接的应用场景下,Client 端一般不会主动关闭它们之间的连接,Client 与 Server 之间的连接如果一直不关闭的话,随着客户端连接越来越多,Server 压力也越来越大,这时候 Server 端需要采取一些策略,如关闭一些长时间没有读写事件发生的连接,这样可以避免一些恶意连接导致 Server 端服务受损;如果条件再允许可以以客户端为颗粒度,限制每个客户端的最大长连接数,从而避免某个客户端连累后端的服务。

长连接和短连接的产生在于 Client 和 Server 采取的关闭策略,具体的应用场景采用具体的策略。

重传机制

常见的重传机制有:

  • 超时重传
  • 快速重传
  • SACK
  • D-SACK

超时重传

重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传。

TCP 会在以下两种情况发生超时重传:

  1. 数据包丢失
  2. 确认应答丢失

互联网协议 — TCP 传输控制协议_第5张图片

超时时间应该设置为多少呢?我们先来了解一下什么是 RTT(Round-Trip Time 往返时延),从下图我们就可以知道:

互联网协议 — TCP 传输控制协议_第6张图片

RTT 就是数据从网络一端传送到另一端所需的时间,也就是包的往返时间。超时重传时间是以 RTO (Retransmission Timeout 超时重传时间)表示。假设在重传的情况下,超时时间 RTO 「较长或较短」时,会发生什么事情呢?

互联网协议 — TCP 传输控制协议_第7张图片
上图中有两种超时时间不同的情况:

  1. 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
  2. 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。

精确的测量超时时间 RTO 的值是非常重要的,这可让我们的重传机制更高效。根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。

互联网协议 — TCP 传输控制协议_第8张图片

实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」 是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个动态变化的值。

我们来看看 Linux 是如何计算 RTO 的呢?估计往返时间,通常需要采样以下两个:

  1. 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化。
  2. 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况。

RFC6289 建议使用以下的公式计算 RTO:

互联网协议 — TCP 传输控制协议_第9张图片

其中 SRTT 是计算平滑的RTT ,DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。

如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?于是就可以用「快速重传」机制来解决超时重发的时间等待。

快速重传

TCP 还有另外一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动重传。

互联网协议 — TCP 传输控制协议_第10张图片

发送方发出了 1,2,3,4,5 份数据:

  1. 第一份 Seq1 先送到了,于是就 Ack 回 2;
  2. 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;
  3. 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
  4. 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。
  5. 最后,接收到收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。

所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传之前的一个,还是重传所有的问题。

比如对于上面的例子,是重传 Seq2 呢?还是重传 Seq2、Seq3、Seq4、Seq5 呢?因为发送端并不清楚这连续的三个 Ack 2 是谁传回来的。根据 TCP 不同的实现,以上两种情况都是有可能的。可见,这是一把双刃剑。为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。

SACK 方法

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

如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。

互联网协议 — TCP 传输控制协议_第11张图片
如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。

Duplicate SACK

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

ACK 丢包场景

互联网协议 — TCP 传输控制协议_第12张图片

  1. 「接收方」发给「发送方」的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499)。
  2. 于是「接收方」发现数据是重复收到的,于是回了一个 SACK = 3000-3500,告诉「发送方」 3000-3500 的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个 SACK 就代表着 D-SACK。
  3. 这样「发送方」就知道了,数据没有丢,是「接收方」的 ACK 确认报文丢了。

网络延时场景

互联网协议 — TCP 传输控制协议_第13张图片

  1. 数据包(1000-1499) 被网络延迟了,导致「发送方」没有收到 Ack 1500 的确认报文。
  2. 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000-1499)又到了「接收方」;
  3. 所以「接收方」回了一个 SACK=1000-1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包。
  4. 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。

可见,D-SACK 有这么几个好处:

  • 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
  • 可以知道是不是「发送方」的数据包被网络延迟了;
  • 可以知道网络中是不是把「发送方」的数据包给复制了;

在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。

滑动窗口

TCP 的 ACK 机制就像两个人面对面聊天,你一句我一句,可见这种方式的缺点是效率比较低的。数据包的往返时间越长,通信的效率就越低。
互联网协议 — TCP 传输控制协议_第14张图片

为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值

窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。

假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:

互联网协议 — TCP 传输控制协议_第15张图片
图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通话下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答。

TCP 头里有一个字段叫 Window,也就是窗口大小。这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以,通常窗口的大小是由接收方的决定的。发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。

发送方的滑动窗口

下图是发送方缓存的数据,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口:

互联网协议 — TCP 传输控制协议_第16张图片

  • #1 是已发送并收到 ACK确认的数据:1~31 字节
  • #2 是已发送但未收到 ACK确认的数据:32~45 字节
  • #3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51字节
  • #4 是未发送但总大小超过接收方处理范围(接收方没有空间):52字节以后

在下图,当发送方把数据「全部」都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。

互联网协议 — TCP 传输控制协议_第17张图片
在下图,当收到之前发送的数据 32-36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 52-56 字节又变成了可用窗口,那么后续也就可以发送 52-56 这 5 个字节的数据了。

互联网协议 — TCP 传输控制协议_第18张图片
程序是如何表示发送方的四个部分的呢?TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。

互联网协议 — TCP 传输控制协议_第19张图片

  • SND.WND:表示发送窗口的大小(大小是由接收方指定的);
  • SND.UNA:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
  • SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。
  • 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了。

那么可用窗口大小的计算就可以是:可用窗口大 = SND.WND -(SND.NXT - SND.UNA)。

接收方的滑动窗口

接收窗口相对简单一些,根据处理的情况划分成三个部分:

  1. #1 + #2 是已成功接收并确认的数据(等待应用进程读取);
  2. #3 是未收到数据但可以接收的数据;
  3. #4 未收到数据并不可以接收的数据;

互联网协议 — TCP 传输控制协议_第20张图片

其中三个接收部分,使用两个指针进行划分:

  1. RCV.WND:表示接收窗口的大小,它会通告给发送方。
  2. RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。
  3. 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了。

注意,接收窗口和发送窗口的大小并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。

流量控制

发送方不能无脑的发数据给接收方,要考虑接收方处理能力。如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制

流量控制是为了控制发送方发送速率,保证接收方来得及接收。接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。实际上,为了避免此问题的产生,发送端主机会时不时的发送一个叫做窗口探测的数据段,此数据段仅包含一个字节来获取最新的窗口大小信息。

假设以下场景:

  • 客户端是接收方,服务端是发送方
  • 假设接收窗口和发送窗口相同,都为 200
  • 假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响

互联网协议 — TCP 传输控制协议_第21张图片

  1. 客户端向服务端发送请求数据报文。这里要说明下,本次例子是把服务端作为发送方,所以没有画出服务端的接收窗口。
  2. 服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 Usable 减少为 120 字节,同时 SND.NXT 指针也向右偏移 80 字节后,指向 321,这意味着下次发送数据的时候,序列号是 321。
  3. 客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节,RCV.NXT 也就指向 321,这意味着客户端期望的下一个报文的序列号是 321,接着发送确认报文给服务端。
  4. 服务端再次发送了 120 字节数据,于是可用窗口耗尽为 0,服务端无法在继续发送数据。
  5. 客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节,RCV.NXT 也就指向 441,接着发送确认报文给服务端。
  6. 服务端收到对 80 字节数据的确认报文后,SND.UNA 指针往右偏移后指向 321,于是可用窗口 Usable 增大到 80。
  7. 服务端收到对 120 字节数据的确认报文后,SND.UNA 指针往右偏移后指向 441,于是可用窗口 Usable 增大到 200。
  8. 服务端可以继续发送了,于是发送了 160 字节的数据后,SND.NXT 指向 601,于是可用窗口 Usable 减少到 40。
  9. 客户端收到 160 字节后,接收窗口往右移动了 160 字节,RCV.NXT 也就是指向了 601,接着发送确认报文给服务端。
  10. 服务端收到对 160 字节数据的确认报文后,发送窗口往右移动了 160 字节,于是 SND.UNA 指针偏移了 160 后指向 601,可用窗口 Usable 也就增大至了 200。

操作系统缓冲区与滑动窗口的关系:前面的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整。当应用进程没办法及时读取缓冲区的内容时,也会对我们的缓冲区造成影响。

那操心系统的缓冲区,是如何影响发送窗口和接收窗口的呢?

当应用程序没有及时读取缓存时

考虑以下场景:

  • 客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 360;
  • 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据。

互联网协议 — TCP 传输控制协议_第22张图片

  1. 客户端发送 140 字节数据后,可用窗口变为 220(360 - 140)。
  2. 服务端收到 140 字节数据,但是服务端非常繁忙,应用进程只读取了 40 个字节,还有 100 字节占用着缓冲区,于是接收窗口收缩到了 260 (360 - 100),最后发送确认信息时,将窗口大小通过给客户端。
  3. 客户端收到确认和窗口通告报文后,发送窗口减少为 260。
  4. 客户端发送 180 字节数据,此时可用窗口减少到 80。
  5. 服务端收到 180 字节数据,但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区,于是接收窗口收缩到了 80 (260 - 180),并在发送确认信息时,通过窗口大小给客户端。
  6. 客户端收到确认和窗口通告报文后,发送窗口减少为 80。
  7. 客户端发送 80 字节数据后,可用窗口耗尽。
  8. 服务端收到 80 字节数据,但是应用程序依然没有读取任何数据,这 80 字节留在了缓冲区,于是接收窗口收缩到了 0,并在发送确认信息时,通过窗口大小给客户端。
  9. 客户端收到确认和窗口通告报文后,发送窗口减少为 0。

可见最后窗口都收缩为 0 了,也就是发生了窗口关闭。当发送方可用窗口变为 0 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变,这个内容后面会说,这里先简单提一下。

服务端系统资源非常紧张时

当服务端系统资源非常紧张的时候,操心系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。

互联网协议 — TCP 传输控制协议_第23张图片

  1. 客户端发送 140 字节的数据,于是可用窗口减少到了 220。
  2. 服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了 100 字节,当收到 对 140 数据确认报文后,又因为应用程序没有读取任何数据,所以 140 字节留在了缓冲区中,于是接收窗口大小从 360 收缩成了 100,最后发送确认信息时,通告窗口大小给对方。
  3. 此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了 100,客户端只会看自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是可用窗口减少到 40。
  4. 服务端收到了 180 字节数据时,发现数据大小超过了接收窗口的大小,于是就把数据包丢失了。
  5. 客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,把窗口的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值。

所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。

窗口关闭

在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。

窗口关闭潜在的危险:接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。那么,当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了。

互联网协议 — TCP 传输控制协议_第24张图片

这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如不不采取措施,这种相互等待的过程,会造成了死锁的现象。

为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。如果持续计时器超时,就会发送窗口探测 ( Windowprobe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。

互联网协议 — TCP 传输控制协议_第25张图片

  • 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器;
  • 如果接收窗口不是 0,那么死锁的局面就可以被打破了。

窗口探查探测的次数一般为 3 次,每次次大约 30-60 秒(不同的实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。

糊涂窗口综合症

如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。

要知道,我们的 TCP + IP 头有 40 个字节,为了传输那几个字节的数据,要达上这么大的开销,这太不经济了。就好像一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车。除非家里有矿的大巴司机,才敢这样玩,不然迟早破产。要解决这个问题也不难,大巴司机等乘客数量超过了 25 个,才认定可以发车。

考虑以下场景,接收方的窗口大小是 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层读取的能力如下:

  • 接收方每接收 3 个字节,应用程序就只能从缓冲区中读取 1 个字节的数据;
  • 在下一个发送方的 TCP 段到达之前,应用程序还从缓冲区中读取了 40 个额外的字节;

互联网协议 — TCP 传输控制协议_第26张图片

每个过程的窗口大小的变化,在图中都描述的很清楚了,可以发现窗口不断减少了,并且发送的数据都是比较小的了。

所以,糊涂窗口综合症的现象是可以发生在发送方和接收方:

  • 接收方可以通告一个小的窗口
  • 而发送方可以发送小数据

于是,要解决糊涂窗口综合症,就解决上面两个问题就可以了:

  • 让接收方不通告小窗口给发送方
  • 让发送方避免发送小数据

怎么让接收方不通告小窗口呢?接收方通常的策略如下:

  • 当「窗口大小」小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0,也就阻止了发送方再发数据过来。
  • 等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。

怎么让发送方避免发送小数据呢?发送方通常的策略,使用 Nagle 算法,该算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:

  • 要等到窗口大小 >= MSS 或是 数据大小 >= MSS
  • 收到之前发送数据的 ack 回包

只要没满足上面条件中的一条,发送方一直在囤积数据,直到满足上面的发送条件。另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。

可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)

setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));

拥塞控制

前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。

如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。

互联网协议 — TCP 传输控制协议_第27张图片

拥塞窗口和发送窗口有什么关系呢?拥塞窗口 cwnd 是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。

拥塞窗口 cwnd 变化的规则:

  • 只要网络中没有出现拥塞,cwnd 就会增大;
  • 但网络中出现了拥塞,cwnd 就减少;

那么怎么知道当前网络是否出现了拥塞呢?其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞。

拥塞控制算法:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

互联网协议 — TCP 传输控制协议_第28张图片

慢启动算法

TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,很容易给网络添堵。

慢启动的算法记住一个规则:当发送方每收到一个 ACK,就拥塞窗口 cwnd 的大小就会加 1。

这里假定拥塞窗口 cwnd 和发送窗口 swnd 相等:

  • 连接建立完成后,一开始初始化 cwnd = 1,表示可以传一个 MSS 大小的数据。
  • 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个。
  • 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个。
  • 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个。

互联网协议 — TCP 传输控制协议_第29张图片

可以看出慢启动算法,发包的个数是指数性的增长。

那慢启动涨到什么时候是个头呢?有一个叫慢启动门限 ssthresh (slow start threshold)状态变量。

  • 当 cwnd < ssthresh 时,使用慢启动算法。
  • 当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。

拥塞避免算法

前面说道,当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。一般来说 ssthresh 的大小是 65535 字节。那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。

接上前面的慢启动的例子,现假定 ssthresh 为 8:当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。

互联网协议 — TCP 传输控制协议_第30张图片

所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。

当触发了重传机制,也就进入了「拥塞发生算法」。

拥塞发生算法

当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:

  1. 超时重传
  2. 快速重传

这两种使用的拥塞发送算法是不同的,接下来分别来说说。

发生超时重传的拥塞发生算法

当发生了「超时重传」,则就会使用拥塞发生算法。这个时候,sshresh 和 cwnd 的值会发生变化:

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

互联网协议 — TCP 传输控制协议_第31张图片

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

发生快速重传的拥塞发生算法

还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。

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

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

快速恢复算法

快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈。

正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:

  • cwnd = cwnd/2 ,也就是设置为原来的一半;
  • ssthresh = cwnd;

然后,进入快速恢复算法如下:

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

互联网协议 — TCP 传输控制协议_第32张图片
也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。

BBR 算法

BBR 是谷歌在 2016 年提出的一种新的拥塞控制算法,已经在 Youtube 服务器和谷歌跨数据中心广域网上部署,据 Youtube 官方数据称,部署 BBR 后,在全球范围内访问 Youtube 的延迟降低了 53%,在时延较高的发展中国家,延迟降低了 80%。

BBR 算法不将出现丢包或时延增加作为拥塞的信号,而是认为当网络上的数据包总量大于瓶颈链路带宽和时延的乘积时才出现了拥塞,所以 BBR 也称为基于拥塞的拥塞控制算法(Congestion-Based Congestion Control),其适用网络为高带宽、高时延、有一定丢包率的长肥网络,可以有效降低传输时延,并保证较高的吞吐量,与其他两个常见算法发包速率对比如下:

互联网协议 — TCP 传输控制协议_第33张图片

BBR 算法周期性地探测网络的容量,交替测量一段时间内的带宽极大值和时延极小值,将其乘积作为作为拥塞窗口大小,使得拥塞窗口始的值始终与网络的容量保持一致。

所以 BBR 算法解决了两个比较主要的问题:

  1. 在有一定丢包率的网络链路上充分利用带宽。适合高延迟、高带宽的网络链路。
  2. 降低网络链路上的 buffer 占用率,从而降低延迟。适合慢速接入网络的用户。

粘包与拆包

假设 Client 向 Server 连续发送了两个数据包,用 packet1 和 packet2 来表示,那么服务端收到的数据可以分为三种情况,现列举如下:

  1. 接收端正常收到两个数据包,即没有发生拆包和粘包的现象。
    在这里插入图片描述
  2. 接收端只收到一个数据包,但是这一个数据包中包含了发送端发送的两个数据包的信息,这种现象即为粘包。这种情况由于接收端不知道这两个数据包的界限,所以对于接收端来说很难处理。
    在这里插入图片描述
  3. 这种情况有两种表现形式,如下图。接收端收到了两个数据包,但是这两个数据包要么是不完整的,要么就是多出来一块,这种情况即发生了拆包和粘包。这两种情况如果不加特殊处理,对于接收端同样是不好处理的。
    在这里插入图片描述

为什么会发生 TCP 粘包、拆包

  • 要发送的数据大于 TCP 发送缓冲区剩余空间大小,将会发生拆包。
  • 待发送数据大于 MSS(最大报文长度),TCP 在传输前将进行拆包。
  • 要发送的数据小于 TCP 发送缓冲区的大小,TCP 将多次写入缓冲区的数据一次发送出去,将会发生粘包。
  • 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。

粘包、拆包解决办法:由于 TCP 本身是面向字节流的,无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,归纳如下:

  • 消息定长:发送端将每个数据包封装为固定长度(不够的可以通过补 0 填充),这样接收端每次接收缓冲区中读取固定长度的数据就自然而然的把每个数据包拆分开来。
  • 设置消息边界:服务端从网络流中按消息边界分离出消息内容。在包尾增加回车换行符进行分割,例如 FTP 协议。
  • 将消息分为消息头和消息体:消息头中包含表示消息总长度(或者消息体长度)的字段。
  • 更复杂的应用层协议比如 Netty 中实现的一些协议都对粘包、拆包做了很好的处理。

TCP 状态变更

TCP 协议规定,对于已经建立的连接,收发双方要进行四次挥手才能成功断开连接,如果缺少了其中某个步骤,都会使连接处于假死状态,连接本身所占用的资源不会被释放。实际上,一个网络服务器经常要同时管理大量的并发连接,所以需要保证无用的连接被完全断开,否则大量假死的连接会占用许多服务器资源。

对于这个问题,我们要关注 TCP 端口的:CLOSE_WAIT 和 TIME_WAIT 状态,与 TCP 四次挥手过程密切相关。

可以通过下面方法来查看 TCP 端口状态数量

╭─mickeyfan@localhost  ~
╰─$ netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'                                                                                                                                                                                                  127 ↵
CLOSE_WAIT 1
TIME_WAIT 1
ESTABLISHED 17

客户端状态变更

互联网协议 — TCP 传输控制协议_第34张图片

服务端状态变更

互联网协议 — TCP 传输控制协议_第35张图片

  • LISTENING:网络服务启动后首先会处于监听的状态

  • SYN_SENT:表示收发方请求建立连接

  • ESTABLISHED:表示成功建立连接,当你要访问其它的计算机的网络服务时首先要发个 SYN 信号到特定端口,此时当前服务器的 TCP 端口状态为 SYN_SENT,如果连接成功了则会变为 ESTABLISHED。

NOTE:SYN_SENT 状态一般非常短暂,但如果发现 SYN_SENT 的数量非常多并且在向不同的网络服务器发出,那当前机器可能中了冲击波或震荡波之类的病毒。这类病毒为了感染别的计算机,它就要扫描别的计算机,在扫描的过程中对每个要扫描的计算机都要发出了 SYN 请求,这也是出现许多 SYN_SENT 的原因。

  • TIME_WAIT:当我方主动执行系统调用 close() 断开连接,并且收到对方的 ACK 确认后,我方的 TCP 端口状态就会变为 TIME_WAIT。

NOTE:TCP 协议规定 TIME_WAIT 状态会一直持续 2MSL(两倍的分段最大生存期,240s),以此来保证重新分配的 Socket 不会受到之前残留的延迟重发报文的影响(保证旧的连接状态不会对新连接产生影响)。处于 TIME_WAIT 状态的连接(Socket)占用的资源不会被内核释放,所以作为网络服务器,在可能的情况下,尽量不要主动断开连接。尤其对于要处理大量短连接的服务器,应该由客户端来主动提出断开,以减少 TIME_WAIT 状态造成的资源浪费。如果发现服务器存在大量的 TIME_WAIT,那么你应该检查是否有大量的自动断开连接动作存在服务器上。还有这样的情况,又我方提出断开连接,但对方一直不给 ACK 应答,我方就会卡在 FIN_WAIT_2 状态,此时我方默认等到 60 秒(可修改,参考 tcp_max_orphans)。所以这种情况下我方内存也会被大量无效数据报填满。

  • CLOSE_WAIT:对方主动关闭连接(或连接异常中断),我方的状态就会变成 CLOSE_WAIT。此时我方会主动调用 close() 来使得连接被正确关闭。

NOTE:CLOSE_WAIT 表示我方被动断开连接,如果存在大量的 CLOSE_WAIT,表示我方只在第二次挥手时向对方应答 ACK,并没有完成第三次挥手,向对方发送 FIN 请求,这时就可能是因为在关闭连接之前网络服务器还有大量的数据要发送或者其他事要做,导致没有发送这个 FIN packet。一般是由网络服务器负载过高,或出现了不可预料的问题导致的。一个 CLOSE_WAIT 会维持至少 2 个小时,如果存在由于负载一直居高不下,生产了大量的 CLOSE_WAIT,就会造成资源极大的损耗,那么通常是等不到释放的那一刻,系统就已经解决崩溃了。

相关的内核参数

vi /etc/sysctl.conf

  • net.ipv4.tcp_syncookies = 1:表示开启 SYN Cookies。当出现 SYN 等待队列溢出时,启用 Cookies 来处理,可防范少量的 SYN 攻击,默认为 0,表示关闭.
  • net.ipv4.tcp_tw_reuse = 1:表示开启重用。允许将 TIME-WAIT Sockets 重新用于新的 TCP 连接,默认为 0,表示关闭。
  • net.ipv4.tcp_tw_recycle = 1:表示开启 TCP 连接中 TIME-WAIT Sockets 的快速回收,默认为 0,表示关闭。
  • net.ipv4.tcp_fin_timeout:系統等待 FIN_WAIT 超时时间。

TCP 的高并发连接

是什么限制了 TCP 连接
互联网协议 — TCP 传输控制协议_第36张图片

当客户端调用 connect() 时候就会发起三次握手,这时 Socket 唯一确定了这次通信,一旦握手完成后,服务端会在返回另一个 Socket 来专门用来后续的数据传输。所以我们使用 “监听 Socket” 和 “传输 Socket” 来区分两者。

提高连接常用套路有:

  1. 多进程:也就是监听 Socket 是一个进程,一旦 Accept 后,对于传输 Socket 就 fork 一个新的子进程来处理。这种方式的缺点就是太重了,fork、销毁一个进程都是特别费事的。而且单机对进程的创建上限也是有限制的。
  2. 多线程:线程比进程要轻量级的多,它会共享父进程的很多资源,比如:文件描述符、进程空间,仅是多了一个引用。因此线程的创建、销毁更加容易。为每一个传输 Socket 就新建一个线程来处理。但是不管是多进程、还是多线程都存在一个问题,一个连接对应一个进程或者协程。这都很难逃脱 C10K 的问题。
  3. IO 多路复用:上面单纯的多进程、多线程模型中,一个进程或线程只能处理一个连接。而 IO 多路复用(e.g. select、epoll)可以使得一个进程或线程能够处理多个连接。

以 Nginx 为例:它的结构是 Master + Worker。Worker 会在 80、443端口上来监听请求,Worker 的数量一般设置为 CPU 的 cores 数,并且每个 Worker 都采用了 epoll 模型。处于监听状态的 Worker,会把所有的监听 Socket 加入到自己的 epoll 中。当这些 Socket 都在 epoll 中时,当某个 Socket 有事件发生就会立即被回调唤醒。可见,这种模式下大大增加了每个进程可以管理的 Socket 数量,上限直接可以上升到进程能够操作的最大文件描述符。而一般机器可以设置百万级别文件描述符,所以单机单进程就是百万连接,epoll 是解决 C10K 的利器,很多开源软件用到了它。

我们常说连接数受限于文件描述符,这是为什么

因为在 Linux 上一切皆文件,故每一个 Socket 都是被当作一个文件看待,那么每个文件就会有一个文件描述符。在 Linux 中每一个进程中都有一个数组保存了该进程需要的所有文件描述符。这个文件描述符其实就是这个数组的 key ,它的 value 是一个指针,指向的就是打开的对应文件。

关于文件描述符有两点注意:

  1. 它对应的其实是一个 Linux 上的文件
  2. 文件描述符本身这个值在不同进程中是可以重复的

另外,单机设置 ulimit 的上限受制于系统的两个配置:

  1. fs.nr_open,进程级别
  2. fs.file-max,系统级别

fs.nr_open 总是应该小于等于 fs.file-max,这两个值的设置也不是随意可以操作,因为设置的越大,系统资源消耗越多,所以需要根据真实情况来进行设置。

参考文章

https://mp.weixin.qq.com/s/IxipRdFAgcmrqAagpWnPpQ
https://mp.weixin.qq.com/s/ZDg5Kvadc6jcEtZytfF7XA
https://mp.weixin.qq.com/s/pDgCn6bEczH45sYoiNU0IA

你可能感兴趣的:(计算机网络)