2、TCP

点击阅读更多查看文章内容

三次握手

TCP 三次握手
  • 一开始,客户端和服务端都处于 CLOSE 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
  • 客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
  • 服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
  • 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
  • 服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。

为什么不是两次、四次?

避免历史连接

防止旧的重复连接初始化造成混乱,我们考虑一个场景,客户端先发送了 SYN(seq = 90)报文,然后客户端宕机了,而且这个 SYN 报文还被网络阻塞了,服务端并没有收到,接着客户端重启后,又重新向服务端建立连接,发送了 SYN(seq = 100)报文(注意!不是重传 SYN,重传的 SYN 的序列号是一样的)

三次握手避免历史连接
  • 一个「旧 SYN 报文」比「最新的 SYN」 报文早到达了服务端,那么此时服务端就会回一个 SYN + ACK 报文给客户端,此报文中的确认号是 91(90+1)。
  • 客户端收到后,发现自己期望收到的确认号应该是 100 + 1,而不是 90 + 1,于是就会回 RST 报文。
  • 服务端收到 RST 报文后,就会释放连接。
  • 后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。

上述中的「旧 SYN 报文」称为历史连接,TCP 使用三次握手建立连接的最主要原因就是防止「历史连接」初始化了连接。

在两次握手的情况下,服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接,造成资源浪费

同步双方初始序列号

序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。

四次握手可以合并成三次握手

四次握手与三次握手

避免资源浪费

由于没有第三次握手,服务端不清楚客户端是否收到了自己回复的 ACK 报文,所以服务端每收到一个 SYN 就只能先主动建立一个连接,这会造成什么情况呢? 如果客户端发送的 SYN 报文在网络中阻塞了,重复发送多次 SYN 报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。(历史连接问题)


为什么建立TCP连接的初始化序列号都要求不一样

  • 为了防止历史报文被下一个相同四元组的连接接收(主要方面)
  • 防止黑客伪造相同序列号的TCP报文被对方接受

假设每次建立连接,客户端和服务器的初始序列号都从0开始

img
  • 客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。
  • 紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;
  • 在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱

既然IP层会分片为什么TCP层还需要MSS

MTU 与 MSS
  • MTU:一个网络包的最大长度,以太网中一般为 1500 字节
  • MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度

假设有一份数据,在TCP层不分段,如果这份数据在发送的过程中出现丢包现象,TCP会发生重传,那么重传的就是这一大份数据(虽然IP层会把数据切分为MTU长度的N多个小包,但是TCP重传的单位却是那一大份数据);如果TCP把这份数据,分段为N个小于等于MSS长度的数据包,到了IP层后加上IP头和TCP头,还是小于MTU,那么IP层也不会再进行分包。此时在传输路上发生了丢包,那么TCP重传的时候也只是重传那一小部分的MSS段。效率会比TCP不分段时更高。

IP层本身没有超时重传机制,超时重传都是由TCP负责的,而TCP的重传机制是基于字节流的,而不是IP数据包的大小。因此,当TCP发送大量数据时,即使在IP层数据已经被分割成多个小包(比如每个包的大小小于MTU),TCP依然是以完整的大块数据为单位来管理和重传的。(TCP将一整段数据交付给IP层,IP层分片发送,如果其中某个分片丢失,而接收的TCP并不知道该分片具体的序号也就无法针对该分片进行重传,只有在TCP层分段为每个数据段都设置对应的序号,接收方才能分别确认各个数据段,才能判断哪一个数据段没有收到进行重传)


丢失重传

  • 第一次握手丢失:
    • 客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的。
  • 第二次握手丢失:
    • 第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以,如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文。
    • 因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。 那么,如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边会触发超时重传机制,重传 SYN-ACK 报文。
  • 第三次握手丢失:
    • 因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。
    • 注意:ACK 报文时不会重传的,如果ACK丢失了,就由对方重传对应的报文

四次挥手

客户端主动关闭连接 —— TCP 四次挥手
  • 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
  • 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSE_WAIT 状态。
  • 客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。
  • 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。
  • 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
  • 服务端收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
  • 客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。

为什么需要四次挥手?

  • 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
  • 服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
  • 从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,因此是需要四次挥手。

为什么TIME_WAIT等待的时间是2MSL

MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。MSL应该要大于等于TTL消耗为0的时间,以确保报文已被自然消亡。

1. TIME_WAIT 等待 2 倍的 MSL,是因为如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方。 一来一去正好 2 个 MSL。可以看到 2MSL时长 这其实是相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对

2. 防止已失效的连接请求报文段出现在本连接中。客户端在发送完最后一个 ACK 报文段后,再经过时间 2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样就可以使下一个连接中不会出现这种旧的连接请求报文段。


服务器出现大量TIME_WAIT状态的原因有哪些?

HTTP没有使用长连接

在 HTTP/1.1 中默认是打开的,请求的 header 中:Connection: Keep-Alive,响应header中Connection: Keep-Alive

短连接每次请求响应都要创建关闭TCP连接,因此会有大量的TIME_WAIT状态,长连接只会创建一个TCP连接多次使用。

HTTP长连接超时

如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。如果有大量客户端建立完TCP连接后没有使用就会导致服务端主动关闭连接,产生大量TIME_WAIT。可以往网络问题排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收。

HTTP长连接的请求数量达到上限

Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。 比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。


服务器出现大量CLOSE_WAIT状态的原因有哪些?

CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。 所以,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接


Socket

Socket是一个抽象的概念,它代表了网络中不同主机上的应用进程之间的双向通信的端点。每一个Socket都有一个唯一的标识符,这个标识符由IP地址和端口号组成。通过这种方式,不同的应用程序可以通过网络相互通信。

Socket是对TCP/IP协议族的一个抽象层,它隐藏了底层复杂的细节,提供了一组简单的接口供应用层调用以实现网络通信。实际上,Socket并不直接对应于某个具体的协议,而是作为一个中间层工具存在于应用层和传输层之间

这里写图片描述 这里写图片描述

服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接(close),服务端会读取到EOF,待处理完数据后服务端调用close表示连接关闭

注意:服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。 所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。 (accept() 返回的 Socket 不占用新端口,TCP 协议使用四元组(源IP、源端口、目标IP、目标端口)区分连接。服务端的监听端口(如 8080)可以同时处理多个客户端连接,因为每个客户端的 源IP+源端口 不同)成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

accept发生在三次握手的哪一步?

socket 三次握手

客户端调用close连接断开的流程

客户端调用 close 过程
  • 客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态;
  • 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态;
  • 接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态;
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态; 客户端经过 2MSL 时间之后,也进入 CLOSE 状态;

重传、滑动窗口、流量控制、拥塞控制

重传机制

TCP 实现可靠传输的方式之一,是通过序列号与确认应答。 在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。

正常的数据传输

TCP针对数据包丢失的情况,会用重传机制解决。

超时重传

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

超时重传的两种情况

超时时间计算:超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。


快速重传

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

快速重传机制

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

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

快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。

快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传一个,还是重传所有的问题。 举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的, 那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、 Seq4、Seq5、 Seq6) 呢?

  • 如果只选择重传 Seq2 一个报文,那么重传的效率很低。因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。
  • 如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。

SACK方法

SACK( Selective Acknowledgment), 选择性确认。

这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。 如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。

选择性确认

滑动窗口

TCP 每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。 效率比较低的。

按数据包进行确认应答

窗口:无需等待确认应答,可以继续发送数据的最大值。

窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。 假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:

用滑动窗口方式并行处理

图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答。


拥塞控制

滑动窗口用于流量控制,避免发送方的数据填满接收方的缓存

拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。 我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。 拥塞窗口 cwnd 变化的规则: 只要网络中没有出现拥塞,cwnd 就会增大; 但网络中出现了拥塞,cwnd 就减少;

如果发送方没有在规定时间内接收到ACK应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。

慢启动

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 个。
慢启动算法

慢启动门限:当cwnd小于ssthresh时使用慢启动算法,当cwnd>=ssthresh时使用拥塞避免算法

拥塞避免

一般来说 ssthresh 的大小是 65535 字节。 那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。 接上前面的慢启动的栗子,现假定 ssthresh 为 8:当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。

拥塞避免

拥塞发生

当网络出现拥塞时会发生数据包重传,重传有两种:超时重传和快速重传。

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

  • ssthresh 设为 cwnd/2
  • cwnd 重置为 1 (是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)

之后就重新开始慢启动

拥塞发送 —— 超时重传

当发生快速重传时,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大小提高发送效率尽快将数据包发送给目标)
  • 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
快速重传和快速恢复

半连接队列与全连接队列

在 TCP 三次握手的时候,每一个socket执行listen时,内核都会自动创建一个半连接队列和全连接队列。 半连接队列,也称 SYN 队列; 全连接队列,也称 accept 队列;

服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。

半连接队列与全连接队列

不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。

如果 SYN 半连接队列已满,只能丢弃连接吗? 并不是这样,开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接。 syncookies 的工作原理:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。

开启 syncookies 功能

绕过三次握手 TCP Fast Open

首次连接:需要三次握手,生成cookie

  • 客户端发送普通SYN报文(不带数据)。
  • 服务器生成一个加密的TFO Cookie(通常基于客户端IP等信息),通过SYN-ACK报文返回。
  • 客户端完成三次握手后,保存该Cookie。

后续连接(绕过三次握手)

  • 客户端发送SYN报文时,携带已存储的Cookie和用户数据
  • 服务器验证Cookie合法性:
    • 若合法:服务器直接处理SYN报文中的数据,并在SYN-ACK报文中返回响应数据。
    • 若不合法:丢弃数据部分,退化为普通三次握手。
  • 客户端收到SYN-ACK后,发送ACK确认服务器返回的SYN以及数据,整个过程仅需1个RTT即可完成数据传输

通过cookie验证身份,传统的三次握手用于确保双方可达且序列号同步,而TFO通过加密的Cookie验证客户端身份。服务器通过验证Cookie的合法性,确认客户端是此前成功建立过连接的合法实体,从而允许跳过握手直接接受数据。客户端在发送SYN报文时即可携带数据(前提是Cookie有效),服务器在回复SYN-ACK时也可携带响应数据,无需等待ACK确认。

开启 TCP Fast Open 功能

close和shutdown

关闭连接的方式通常有两种,分别是 RST 报文关闭和 FIN 报文关闭。 如果进程收到 RST 报文,就直接关闭连接了,不需要走四次挥手流程,是一个暴力关闭连接的方式。

安全关闭连接的方式必须通过四次挥手,它由进程调用 close 和 shutdown 函数发起 FIN 报文(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。

调用 close 函数和 shutdown 函数有什么区别?

  • close 函数。意味着完全断开连接,完全断开不仅指无法接受数据,而且也不能发送数据。
  • shutdown 函数,它可以控制只关闭(读或写)一个方向的连接

close 函数关闭

  1. 在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
  2. 在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个 FIN 报文,接下来如果再对该套接字进行写操作会返回异常。
  3. 如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个 RST 报文,告诉对端:“Hi, 我已经关闭了,别再给我发数据了。”
  4. 我们会发现,close 函数并不能帮助我们关闭连接的一个方向, shutdown 函数才可以。

shutdown函数关闭

  • SHUT_RD(0):关闭连接的“读”这个方向,对该套接字进行读操作直接返回 EOF。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
  • SHUT_WR(1):关闭连接的“写”这个方向,这就是常被称为”半关闭“的连接。此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去,并发送一个 FIN 报文给对端。应用程序如果对该套接字进行写操作会报错。
  • SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。

如何理解TCP是面向字节流的协议

当用户消息通过 UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。

你可能会问,如果收到了两个 UDP 报文,操作系统是怎么区分开的? 操作系统在收到 UDP 报文后,会将其插入到队列里,队列里的每一个元素就是一个 UDP 报文,这样当用户调用 recvfrom() 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。

图片

当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。 这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。

举个实际的例子来说明。 发送方准备发送 「Hi.」和「I am Xiaolin」这两个消息。 在发送端,当我们调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。 至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,我们不能认为每次 send 调用发送的数据,都会作为一个整体完整地消息被发送出去。 如果我们考虑实际网络传输过程中的各种影响,假设发送端陆续调用 send 函数先后发送 「Hi.」和「I am Xiaolin」 报文,那么实际的发送很有可能有多种情况。

图片图片图片

因此,我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。 当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。 要解决这个问题,要交给应用程序

  • 固定长度消息

  • 特殊字符边界

    • HTTP协议

      图片
  • 自定义消息结构

    • 数据长度+数据内容

已建立连接的TCP收到SYN会发生什么

一个已经建立的 TCP 连接,客户端中途宕机了,而服务端此时也没有数据要发送,一直处于 Established 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理

端口号不同:建立新的连接,旧连接中如果客户端收到服务端的数据包会返回RST报文,如果服务端一直没收到客户端数据则超过一段时间后会启动TCP保活机制,服务端自动释放连接

端口号相同:处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。 接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接。

img

TCP Keepalive

注意与HTTP中的Keep-Alive不是一个东西,HTTP中的是启用长连接,是由应用程序实现的。

TCP 的 Keepalive 是 TCP 的保活机制,是由内核实现的。

如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。

  • 如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
  • 如果对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活,这个工作是在内核完成的。
TCP 保活机制

TCP协议缺陷

升级困难

TCP 协议是在内核中实现的,应用程序只能使用不能修改,如果要想升级 TCP 协议,那么只能升级内核。 而升级内核这个工作是很麻烦的事情,麻烦的事情不是说升级内核这个操作很麻烦,而是由于内核升级涉及到底层软件和运行库的更新,我们的服务程序就需要回归测试是否兼容新的内核版本,所以服务器的内核升级也比较保守和缓慢。

很多 TCP 协议的新特性,都是需要客户端和服务端同时支持才能生效的,比如 TCP Fast Open 这个特性,虽然在2013 年就被提出了,但是 Windows 很多系统版本依然不支持它,这是因为 PC 端的系统升级滞后很严重,W indows Xp 现在还有大量用户在使用,尽管它已经存在快 20 年。 所以,即使 TCP 有比较好的特性更新,也很难快速推广,用户往往要几年或者十年才能体验到

队头阻塞

TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据。如下图:

img

图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet #3 在网络中丢失了,即使 packet #4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 packet #3 重传后,接收方的应用层才可以从内核中读取到数据。 这就是 TCP 队头阻塞问题,但这也不能怪 TCP ,因为只有这样做才能保证数据的有序性。

网络迁移

基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。

TCP 四元组

那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接。 而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的


TCP和UDP可以绑定同一个端口吗

可以的。

在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。 所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。

传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。 当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理

img

重启 TCP 服务进程时,为什么会有“Address in use”的报错信息?

当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。 而等 TIME_WAIT 状态的连接结束后,重启 TCP 服务进程就能成功。

客户端同一个端口可以绑定多个连接吗?

TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。

只要客户端连接的服务器不同,端口资源可以重复使用


不使用listen可以建立TCP连接吗?

可以的,客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有listen,就能建立连接

我们知道执行 listen 方法时,会创建半连接队列和全连接队列。 三次握手的过程中会在这两个队列中暂存连接信息。

所以形成连接,前提是你得有个地方存放着,方便握手的时候能根据 IP + 端口等信息找到对应的 socket。 那么客户端会有半连接队列吗? 显然没有,因为客户端没有执行listen,因为半连接队列和全连接队列都是在执行 listen 方法时,内核自动创建的。 但内核还有个全局 hash 表,可以用于存放 sock 连接的信息。

在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局 hash 表中,然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接。 TCP 同时打开的情况也类似,只不过从一个客户端变成了两个客户端而已。


没有accept,能建立TCP连接吗?

一般情况下,如果启动服务器,会发现最后程序会阻塞在accept()里。 此时服务端就算ok了,就等客户端了。客户端比较简单,创建好socket之后,直接就发起connect方法。 此时回到服务端,会发现之前一直阻塞的accept方法,返回结果了。 这就算两端成功建立好了一条连接。之后就可以愉快的进行读写操作了。 那么,我们今天的问题是,如果没有这个accept方法,TCP连接还能建立起来

就算不执行accept()方法,三次握手照样进行,并顺利建立连接。

  • 每一个socket执行listen时,内核都会自动创建一个半连接队列和全连接队列。
  • 第三次握手前,TCP连接会放在半连接队列中,直到第三次握手到来,才会被放到全连接队列中。
  • accept方法只是为了从全连接队列中拿出一条连接,本身跟三次握手几乎毫无关系。
  • 出于效率考虑,虽然都叫队列,但半连接队列其实被设计成了哈希表,而全连接队列本质是链表
  • 全连接队列满了,再来第三次握手也会丢弃,此时如果tcp_abort_on_overflow=1,还会直接发RST给客户端。
  • 半连接队列满了,可能是因为受到了SYN Flood攻击,可以设置tcp_syncookies,绕开半连接队列。
    • 成一个cookies,这个cookies会跟着第二次握手,发回客户端。客户端在发第三次握手的时候带上这个cookies,服务端验证到它就是当初发出去的那个,就会建立连接并放入到全连接队列中。可以看出整个过程不再需要半连接队列的参与。
  • 客户端没有半连接队列和全连接队列,但有一个全局hash,可以通过它实现自连接或TCP同时打开

数据包的发送流程

为了发送数据包,两端首先会通过三次握手,建立TCP连接。 一个数据包,从聊天框里发出,消息会从聊天软件所在的用户空间拷贝到内核空间的发送缓冲区(send buffer),数据包就这样顺着传输层、网络层,进入到数据链路层,在这里数据包会经过流控(qdisc),再通过RingBuffer发到物理层的网卡。数据就这样顺着网卡发到了纷繁复杂的网络世界里。这里头数据会经过n多个路由器和交换机之间的跳转,最后到达目的机器的网卡处。 此时目的机器的网卡会通知DMA将数据包信息放到RingBuffer中,再触发一个硬中断给CPU,CPU触发软中断让ksoftirqd去RingBuffer收包,于是一个数据包就这样顺着物理层,数据链路层,网络层,传输层,最后从内核空间拷贝到用户空间里的聊天软件里。

网络发包收包全景图

TCP四次挥手,可以变成三次吗?

粗暴关闭 vs 优雅关闭

前面介绍 TCP 四次挥手的时候,并没有详细介绍关闭连接的函数,其实关闭的连接的函数有两种函数:

  • close 函数,同时 socket 关闭发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力。如果有多进程/多线程共享同一个 socket,如果有一个进程调用了 close 关闭只是让 socket 引用计数 -1,并不会导致 socket 不可用,同时也不会发出 FIN 报文,其他进程还是可以正常读写该 socket,直到引用计数变为 0,才会发出 FIN 报文。

  • shutdown 函数,可以指定 socket 只关闭发送方向而不关闭读取方向,也就是 socket 不再有发送数据的能力,但是还是具有接收数据的能力。如果有多进程/多线程共享同一个 socket,shutdown 则不管引用计数,直接使得该 socket 不可用,然后发出 FIN 报文,如果有别的进程企图使用该 socket,将会受到影响。

如果客户端是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的数据,由于客户端已经不再具有发送和接收数据的能力,所以客户端的内核会回 RST 报文给服务端,然后内核会释放连接,这时就不会经历完成的 TCP 四次挥手,所以我们常说,调用 close 是粗暴的关闭

在这里插入图片描述

相对的,shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手,所以我们常说,调用 shutdown 是优雅的关闭。但是注意,shutdown 函数也可以指定「只关闭读取方向,而不关闭发送方向」,但是这时候内核是不会发送 FIN 报文的,因为发送 FIN 报文是意味着我方将不再发送任何数据,而 shutdown 如果指定「不关闭发送方向」,就意味着 socket 还有发送数据的能力,所以内核就不会发送 FIN。

优雅关闭.drawio.png

什么情况会出现三次挥手?

当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。

在这里插入图片描述

然后因为 TCP 延迟确认机制是默认开启的,所以导致我们抓包时,看见三次挥手的次数比四次挥手还多。 什么是 TCP 延迟确认机制? 当发送没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。 为了解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。 TCP 延迟确认的策略:

  • 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
  • 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
  • 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
img

序列号与确认号

  • 序列号 = 上一次发送的序列号 + len(数据长度)。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 上一次发送的序列号 + 1。

  • 确认号 = 上一次收到的报文中的序列号 + len(数据长度)。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1。

在这里插入图片描述

序列号:在建立连接时由内核生成的随机数作为其初始值,通过 SYN 报文传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。

确认号:指下一次「期望」收到的数据的序列号,发送端收到接收方发来的 ACK 确认报文以后,就可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。

控制位:用来标识 TCP 报文是什么类型的报文,比如是 SYN 报文、数据报文、ACK 报文,FIN 报文等

作者

ShiHaonan

发布于

2025-03-03

更新于

2025-04-02

许可协议

评论