Linux下 传输层协议TCP详解

欢迎来到我的频道!【点击跳转专栏】

本文所有代码已托管至码云:【点此转跳】

文章目录

  • [1. 认识TCP协议](#1. 认识TCP协议)
    • [1.1 理解TCP报头的相关问题(先了解 知道有就行)](#1.1 理解TCP报头的相关问题(先了解 知道有就行))
    • [1.2 TCP协议段格式](#1.2 TCP协议段格式)
    • [1.3 对可靠性的正确认识](#1.3 对可靠性的正确认识)
    • [1.4 确认应答(ACK)机制](#1.4 确认应答(ACK)机制)
    • [1.5 超时重传机制(序号的一个作用)](#1.5 超时重传机制(序号的一个作用))
    • [1.6 TCP通信的两种模式(通过序号、确认序号解决接收方的乱序问题)](#1.6 TCP通信的两种模式(通过序号、确认序号解决接收方的乱序问题))
    • [1.7 窗口大小的作用](#1.7 窗口大小的作用)
    • [1.8 6位标志位](#1.8 6位标志位)
      • [1. ACK](#1. ACK)
      • [2. SYN(简单理解一下三次握手建立链接、什么是链接)](#2. SYN(简单理解一下三次握手建立链接、什么是链接))
      • [3. FIN(4次挥手)](#3. FIN(4次挥手))
      • [4. 链接断开链接总结](#4. 链接断开链接总结)
      • [5. RST](#5. RST)
      • [6. PSH](#6. PSH)
      • [7. URG](#7. URG)
      • [8. 16位紧急指针](#8. 16位紧急指针)
  • [2. TCP协议的一些机制细节](#2. TCP协议的一些机制细节)
    • [2.1 超时重传机制时间的设置](#2.1 超时重传机制时间的设置)
    • [2.2 连接管理机制](#2.2 连接管理机制)
      • [2.2.1 再次聊聊3次握手](#2.2.1 再次聊聊3次握手)
      • [2.2.2 再次聊聊4次挥手](#2.2.2 再次聊聊4次挥手)
      • [2.2.3 shutdown(在应用层做到局部关闭)](#2.2.3 shutdown(在应用层做到局部关闭))
      • [2.2.4 理解listen第二个参数](#2.2.4 理解listen第二个参数)
      • [2.2.5 理解连理链接断开链接的状态转换](#2.2.5 理解连理链接断开链接的状态转换)
      • [2.2.6 理解TIME_WAIT状态](#2.2.6 理解TIME_WAIT状态)
      • [2.2.7 解决TIME_WAIT状态引起的bind失败的⽅法](#2.2.7 解决TIME_WAIT状态引起的bind失败的⽅法)
    • [2.3 流量控制](#2.3 流量控制)
    • [2.4 滑动窗口](#2.4 滑动窗口)
      • [2.4.1 理解滑动窗口](#2.4.1 理解滑动窗口)
      • [2.4.2 滑动窗口的工作过程](#2.4.2 滑动窗口的工作过程)
      • [2.4.3 出现了丢包, 如何进⾏重传](#2.4.3 出现了丢包, 如何进⾏重传)
      • [2.4.4 滑动窗口越界问题](#2.4.4 滑动窗口越界问题)
    • [2.5 拥塞控制](#2.5 拥塞控制)
    • [2.6 延迟应答](#2.6 延迟应答)
    • [2.7 捎带应答](#2.7 捎带应答)
    • [2.8 TCP小结](#2.8 TCP小结)
  • [3. TCP常见面试题](#3. TCP常见面试题)
    • [3.1 ⾯向字节流](#3.1 ⾯向字节流)
    • [3.2 粘包问题](#3.2 粘包问题)
    • [3.3 TCP异常问题](#3.3 TCP异常问题)

1. 认识TCP协议

TCP 全称为 "传输控制协议( Transmission Control Protocol ") . ⼈如其名, 要对数据的传输进⾏⼀个详细的控制

1.1 理解TCP报头的相关问题(先了解 知道有就行)

  • TCP报头本质: 结构体

1.2 TCP协议段格式

  • 源/目的端口号: 表示数据是从哪个进程来,到哪个进程去;
  • 32位序号/32位确认号: 后面详细讲;
  • 4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节);所以TCP头部最大长度是15 * 4 = 60
  • 6位标志位:
    • URG: 紧急指针是否有效
    • ACK: 确认号是否有效
    • PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
    • RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
    • SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
    • FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
  • 16位窗口大小: 后面再说
  • 16位校验和: 发送端填充,CRC校验。接收端校验不通过,则认为数据有问题。此处的检验和不光包含TCP首部,也包含TCP数据部分。
  • 16位紧急指针: 标识哪部分数据是紧急数据;
  • 40字节头部选项: 暂时忽略;

TCP这块为什么没有告诉我,它的数据多长??

  1. 因为TCP是面向字节流的,TCP只需要保证它的分片是排第几个,然后放在缓冲区里,将来读的时候让用户按顺序去读就可以了!它的性质就不需要长度!
  2. 对TCP来说只需要去掉报头,后面就是数据,至于后面数据是否是完整的,我TCP不管!TCP不为数据完整性负责!只需要保证报文给你了 后面多个报文组合形成更大TCP,你慢慢读就行,报文和报文边界由更上层觉定 TCP不管!

1.3 对可靠性的正确认识

所谓可靠性:就是对发出去的报文,要有应答!只要收到应答,历史报文100%被对方收到!!
极端情况:我们还需要对应答做应答 !但是你怎么保证对应答做应答能收到呢??这样就还要对应答的应答做应答。。。。。无限套娃下去!!!

有一点需要和大家说: 这个世界上是不存在100%可靠的协议的,因为 最新的报文永远没有应答!(处于不确定状态!)

但是,我们对历史报文的可靠性是可以100%确定的!基于这一点我们就可以设计TCP可靠性了!(因为我们只需要确保第一条发送的消息是可靠的就行了)

1.4 确认应答(ACK)机制

两个朝向上,发送报文,只要对报文进行应答,那么报文本身就是可靠的!!这个就叫确认应答机制!

这个不就是 全双工的可靠性!,其实 TCP可靠性主要体现在 你收到了,我得知道!而不是保证报文一定给你传过去(因为能不能收到主要看的是网络,不是TCP能解决的)!


如果A给B发,A没收到应答:

  • 情况1:报文真丢了,A不会收到应答。
  • 情况2:报文被B收到了,但是应答丢了。

最终表现:A没收到应答,A就会判断报文丢了!


那么A怎么知道虽然现在没有收到应答,未来不会收到呢?

所以A会设置一个deadline(超时时间)!超过时间则判定为 丢包!!

所以丢包只能是被规定出来的!(因为站在发送方视角,无法判断数据是真丢了还是应答丢了)

⚠️:确认应答机制的核心,不是保证数据100%发送给对方(这是网络决定的);而是对于发送方,对方有无收到报文,发送方有一个确定的结果!

1.5 超时重传机制(序号的一个作用)

主机A发送数据给B之后, 可能因为⽹络拥堵等原因, 数据⽆法到达主机B;如果主机A在⼀个特定时间间隔内没有收到B发来的确认应答, 就会进⾏重发!


但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了!

因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉!!

这个就是32位序列号所做的工作了(目的就是去重!出现序列号相同报文咱们就丢掉,一般是丢掉老的那个!然后再发个应答就行)

1.6 TCP通信的两种模式(通过序号、确认序号解决接收方的乱序问题)

  • 串行: 发一个,收到应答再发一个
  • 流水线: 一次性连续发一串报文;只要收到其中某个 ACK,可以继续发新报文,不用等全部 ACK 回来(先了解后面会详细解析)

由于网络情况的复杂性,无法保证先发的报文就一定先到,这样就会导致报文的乱序问题!

于是,就有了序号!于是主机A给B发报文,通过序号排序,就能保证接收方最终收到的所有信息顺序是正确的!


问题又来了:发了4个报文,拿到了3个应答,我们怎么知道哪个报文丢了??

于是报头有了个确认序号这个东西!

  • 确认序号:自己收到的序号+1,就是确认信号!
  • 含义:该序号之前的所有数据我收到了!(这个含义可以有效减少报文重发次数)
  • 比如:收到了1001、2001、4001的应答,于是我们就知道是序号3000的应答丢失了!!但是因为 我们收到4001的应答,于是 我们就知道丢的不是报文而是应答!就不对报文进行重传了!这个确认序号4001就是在告诉发送方,下次发送报文可以从4001开始发!!!

ps:如果1000、2000、3000、4000报文,3000丢了,此时序号4000的报文的应答就是2001(它有特殊机制,会让4000的报文不重传,后面会说)

一些细节问题(发送什么报文和应答)

站在传输层 ,发送的是TCP报文 = 报头 + 有效载荷!【本质就是结构体变量啊!!】

发送的是这个:

那么应答呢?应答是不是至少是这个:

问题又来了:那为什么序号和确认序号要分开呢??明明只需要一个啊?

主机A给B发消息,那么B也可以给A发消息(假设序号一个1000 一个10000开头)。A给B发消息的时候,B收到后准备给A发消息,同时也要应答,反正都是要发,为什么不能消息和应答放在一起呢??这个就叫捎带应答!!!

⚠️:我们看到的应答 可能既是消息也是应答,这种 序号和分离序号分别设计的机制,就可以让发送数据和发送确认同时进行了!

序号的作用

所以综上 两个序号的作用:

  • 去重
  • 按序到达
  • 捎带应答
  • 超时充传(传谁)

1.7 窗口大小的作用

接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.

因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);

问题1: 接收方,如何衡量自己的接收能力?

接收缓冲区,剩余空间的大小。

问题2:发送方如何得知对方的接收能力?

把自己的接受能力,填写到应答报文的16位窗口大小中!

  • 接收端将 自己可以接收的缓冲区剩余空间大小 放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
  • 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
  • 发送端接受到这个窗口之后, 就会减慢自己的发送速度;

问题3:如果发送方发的太慢呢??

ACK里面窗口大小调大一点,也能让发送方发快一点!

所以流量控制是涵盖 可靠性和效率的~!

如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.

1.8 6位标志位

报文分好多种,应答报文、建立连接的、断开链接的、数据报文等。TCP通过标志位来区别报文。

1. ACK

该标志位为1,则表明是应答报文!

由于携带应答的存在,大部分报文的ACK标志位都是置1的!

2. SYN(简单理解一下三次握手建立链接、什么是链接)

当该标志位为1时,表示该报文为链接建立的请求报文!

建立连接(connect) 需要进行 三次握手! 那么什么是 三次握手?

  • 主机A向主机B发送报文,标志位为SYN(注意: 发送的是TCP协议的报头 )
  • 主机B向主机A发送报文,标志位为ACK+SYN
  • 随后主机A向主机B发送报文,标志位为ACK

这个过程,就叫 三次握手!

怎么理解三次握手?

举个简单的列子:男孩怎么最快向女孩告白

  1. 男:可不可以做我女朋友(SYN)
  2. 女:好啊(SYN),什么时候(ACK)
  3. 男:就现在(ACK)

至此以最少字数,完成了建立链接的过程!


问题又来了:什么是链接?双方OS内部都会有大量链接,OS要怎么管理?

先描述再组织,构建链接结构体!

链接建立最明显的表现就是 当代码connect成功,就会返回文件描述符!,所谓的 链接 不就是 文件吗?即 tcp_sock

点进 inet_connection_sock inet_conn 里面就会有建立链接时的请求队列,以及超时充传的定时器相关内容。

其中里面的inet_sock,则是保存了套接字相关信息!

所以说 建立链接时有成本的!时间+空间 明显比UDP复杂的多!


那么为什么要有三次握手?

在通信前,要保证什么? 网络是通畅的! 站在A、B角度因为B会发SYN+ACK、然后A会回发ACK,这个过程就保证了 A、B两端都是可以发数据、收数据的!

以三次握手的方式, 用最小次数验证全双工!!

3. FIN(4次挥手)

如果想要断开通信呢?需要经历4次挥手!

FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段


为什么断开链接要进行4次挥手?

离婚需要双方都签字是不是(防止一方不愿意啊)?? 那么4次挥手,则是一方发出断开请求,一方同意,分手时双方都要同意的,所以要来回各一次!以最小成本争得双方同意,不再有纠纷赖账的事情了!

此时双方链接结构体也都释放了,代码 层面你只是做了个close(fd),实际上 是进行了4次挥手 断开链接 文件系统上则是关闭文件!


4次挥手本质是关闭全双工,A发SYN表示我再也不给你发消息了,B发ACK和SYN表示我知道了,我也再也不给你发消息了,A回答ACK,至此全双工关闭!! 这么做是防止一端关闭一端还给你发信息这种极端情况!

当然 4次挥手中的中间两次 ACK和FIN可以合并成一次 捎带应答!即 4次挥手也可以变成3次挥手。

那么同理 把三次握手中的SYN和ACK拆开不就是 4次握手了!因为建立关系也要双方同意!

4. 链接断开链接总结

总结:你要结婚(建立链接),需要双方同意(双端),双方家长同意(网络),即 双方主机建立通信共识,验证全双工(即网络畅通)!至此无人能阻挡你们通信了!!断开链接也是同理!

常见情况:为什么喜欢建立链接说3次握手;断开说4次挥手?明明建立断开本质都是一回事(即双方建立共识 )

虽然 4次挥手经常会变成3次挥手,但是3次握手很少会变成4次握手,因为 你服务器基本上必须无条件答应建立链接,所以SYN+ACK可以压缩在一起!!但是断开链接不一定了!你服务器有消息没发完(上层业务的事),经常会等发完消息,再断开链接,所以常常会分开来发 (类比 你要离婚 其中一方最常说的就是 等娃高考完咱再离婚(同意,但不立刻断开链接!))

5. RST

对于A端发出SYN 收到SYN+ACK,并发出ACK即3次握手完毕,但对于B端呢?是不是必须收到SYN,发出SYN+ACK,并收到ACK才三次握手完毕。

这就会导致最后一次ACK因为没应答,B端不一定能100%收到啊!但是对于A端来说已经握手完成,就有可能直接发送消息!B端收到消息后,发现明明没握手完成啊??于是B端就会发送RST !告诉A端链接出现意外了, 该重新建立链接了!!

这也反向说明 TCP建立链接本质上是在赌,赌你最后一次ACK收到了

6. PSH

众所周知 接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据,就会等!等是等多久呢?

我们知道 发送端此时会发送 一个窗口探测数据段(不发数据不代表不能发报头),然后接受端就会不断发ACK同时更新窗口大小,告诉发送端此时缓冲区大小!

那么如果窗口大小 一直是0呢??那么此时发送端就会开始 "不耐烦"了,就把自己 报头中的PSH标志位置为1 ,那么 接受端意识到 对面 忍耐度到极限了!于是通知应用层,尽快将数据取走!!

当然 上面的例子 有点极端了 !在常规通信中,PSH也能携带,比如说xshell、xterminal中,每次通信会把PSH带上,目的就是告诉bash,尽快把命令解析完把结果给我!

所以 应用层最好以读取数据为第一优先级,正常情况下OS都会等你有一定数据量的时候再统一调度进程进行读取(减少IO),那么PSH本质就是 让你的进程从等待队列放到就绪队列,你上层决定怎么读取,什么时候读取就不关OS的事情了!

7. URG

在TCP报头中 有16位紧急指针 URG说人话就是: 判断你紧急指针有没有效!

那么 紧急指针是什么呢?

8. 16位紧急指针

具有唯一且有一定指向型的数据都可以称为指针!(类似数组下标)!这里的指针本质是一段偏移量!

我们可以将 发送缓冲区 理解成char outbuffer[N],那么每一个字节不就天然拥有序号了吗??

那么我先发送前1000个字节(只算数据部分),那么我的序号不就1000吗?(当然真实情况底层会有一个随机启始序号,通过一定转换变成下标), 而我们只需要把 紧急指针理解成 应用层缓冲区的偏移量!【这里的偏移量 我们要理解成转换后的第N个字节!就是不管你是序号是多少,看的是你缓冲区的第几个!】

一般情况下,紧急指针指向数据 只有一个字节!


因为TCP是通过序号控制顺序,按序处理数据,如果有些数据突然要提前处理呢?

例如,客户端正在发送大量数据时,用户突然按下"停止"按钮,程序可以发送一段紧急数据,并设置:

text 复制代码
URG = 1
Urgent Pointer = 紧急数据的位置

服务器看到后,就知道这是一条需要优先处理的消息。【本质就是插队用的! 】


那么 OS怎么优先读取呢?

在recv接口中的flags参数中 ,有一个参数叫MSG_OOB

图中讲的是 MSG_OOB,它是 recv() 接收函数的一个标志,用来读取 TCP 的"带外数据"(Out-of-Band Data)。

cpp 复制代码
recv(sockfd, buffer, size, MSG_OOB);

普通情况下:

cpp 复制代码
recv(sockfd, buffer, size, 0);

只能读取正常数据流;如果发送方发送了紧急数据,接收方可以使用 MSG_OOB 单独读取它。

例如:

text 复制代码
普通数据:ABCDEF
紧急数据:STOP

使用 MSG_OOB 就是请求读取 STOP 这类紧急数据,而不是从普通数据流中读取。这就需要上层程序员要在应用层可以有处理紧急指针对应的逻辑!

当然现在程序很少用了,因为建立两条链接也能解决!

2. TCP协议的一些机制细节

2.1 超时重传机制时间的设置

  • 最理想的情况下,找到一个最小的时间,保证 "确认应答一定能在这个时间内返回".
  • 但是这个时间的长短,随着网络环境的不同,是有差异的, 所以必须随网络状态进行波动!
  • 如果 超时时间设的太长,会影响整体的重传效率;
  • 如果 超时时间设的太短,有可能会频繁发送重复的包;

TCP为了保证无论在任何环境下都能比较高性能的通信,因此会 动态计算这个最大超时时间.

  • Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍.
  • 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传.
  • 如果仍然得不到应答,等待 4*500ms 进行重传.依次类推,以指数形式递增.
  • 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接.

2.2 连接管理机制

在正常情况下, TCP要经过三次握⼿建⽴连接, 四次挥⼿断开连接

2.2.1 再次聊聊3次握手

结论: 三次握手,只考虑自己一方,每一次都是从发出或者收到开始的!由双方TCP层,自主完成!所以说应用层的connect并不关心三次握手细节,只是告诉底层要建立握手!而accept本质是获取已经建立好的链接!(自身是不参与链接过程的)


为什么是3次握手?(前面写过,这里总结答案)

阻碍通信的情况:

  1. 双方意愿
  2. 网络问题

三次握手,可以验证网络问题, 以最小次数验证全双工(本质就是以最小成本验证网络问题)!

一方的发送的SYN收到ACK,相当于得到了对端的许诺,该过程双方各进行了一次 相当于互相得到了许诺! 以最小成本验证双方意愿问题!

因为我们已经证明了3次可行,4次、5次就没必要了!我们现在只需要证明为什么2次不行即可!


那为什么2次不行?

A给B发SYN,B收到发ACK,A收到ACK!

该过程可以证明 对A来说 我能发、我能收;但对B来说 只能证明我能收,无法说明我能发给A(因为B不知道A会不会收ACK!不确定)

对B来说,无法验证B到A网络是否通畅,更无法验证A意愿问题(打个简单比方 万一A是渣男呢?我发起请求但是不接受你的信息),在网络中 必须双方都有明确请求回应才行!


⚠️:在目前网络中 发起建立链接的99.9%是客户端,而服务端不会拒绝!

2.2.2 再次聊聊4次挥手

要有个理解:双方都可以作为主动断开链接的一方!(我们后面以客户端主动断开为例子)

那么断开链接本质是什么?本质就是 只是一方不写数据了!

客户端发送完了(关闭了到服务端的信道),给服务端发FIN,但是服务端未必发完了,中间过程服务端还会给客户端持续发送数据,等服务端发完了,向客户端发FIN,意思是我也不写了(关闭到客户端的信道)

等双方信道都关闭了,那么通信也就没必要了!所以 此时链接结构体才正式关了!

所谓的断开链接,不是彻底释放链接,只是将链接标志位进行修改了!而一方释放链接,只是不写数据了,不是不发报头和 标志位了!!!

对于代码close只是应用层关闭了fd,关闭了读写端,对os来说就是告诉TCP层要进行4次挥手!

2.2.3 shutdown(在应用层做到局部关闭)

shutdown() 是用来关闭 socket 某个方向的通信接口,比 close() 更细致。

cpp 复制代码
#include <sys/socket.h>

int shutdown(int sockfd, int how);

how 有三种取值:

cpp 复制代码
SHUT_RD    // 关闭读方向
SHUT_WR    // 关闭写方向
SHUT_RDWR  // 读写方向都关闭

例如:

cpp 复制代码
shutdown(sockfd, SHUT_WR);

表示"我不再向对方发送数据了",但仍然可以继续接收对方的数据( 此时底层触发挥手! )。对方收到后,读取数据时通常会得到 0,表示对方已经关闭了发送方向。

shutdown(sockfd, SHUT_RDWR) 会关闭读写两个方向;而:

cpp 复制代码
close(sockfd);

通常是直接关闭文件描述符和 socket。简单说,shutdown() 用于控制通信方向,close() 用于彻底释放 socket。

2.2.4 理解listen第二个参数

cpp 复制代码
#include <sys/socket.h>

int listen(int sockfd, int backlog);

在没有accept前,链接依然能够建立成功

但是接受端维持的暂时不用accept到应用层的链接的个数是有限的,backlog+1!

而底层维持暂时不用accept到应用层的链接 叫 全链接队列(上限就是backlog+1)!

全连接队列可以理解为服务器门口的"等候区",本质是OS内部的一个缓冲结构:客户端完成 TCP 三次握手后,连接已经建立成功,但服务器程序还没来得及调用 accept() 把它取走时,这个连接就先放在全连接队列里等待。服务器调用一次 accept(),就从队列中取出一个已建立的连接进行通信;如果队列满了,后续客户端可能连接失败、超时或被延迟处理。

2.2.5 理解连理链接断开链接的状态转换

服务端状态转化:

  • `CLOSED -> LISTEN` 服务器端调用listen后进入LISTEN状态,等待客户端连接;
  • `LISTEN -> SYN_RCVD` 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文。
  • `SYN_RCVD -> ESTABLISHED` 服务端一旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了。
  • `ESTABLISHED -> CLOSE_WAIT` 当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入CLOSE_WAIT;
  • `CLOSE_WAIT -> LAST_ACK` 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据);当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK状态,等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)
  • `LAST_ACK -> CLOSED` 服务器收到了对FIN的ACK,彻底关闭连接。

客户端状态转化:

  • `CLOSED -> SYN_SENT` 客户端调用connect,发送同步报文段;
  • `SYN_SENT -> ESTABLISHED` connect调用成功,则进入ESTABLISHED状态,开始读写数据;
  • `ESTABLISHED -> FIN_WAIT_1` 客户端主动调用close时,向服务器发送结束报文段,同时进入FIN_WAIT_1;
  • `FIN_WAIT_1 -> FIN_WAIT_2` 客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2,开始等待服务器的结束报文段;
  • `FIN_WAIT_2 -> TIME_WAIT` 客户端收到服务器发来的结束报文段,进入TIME_WAIT,并发出LAST_ACK;
  • `TIME_WAIT -> CLOSED` 客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入CLOSED状态。

MSL(Maximum Segment Lifetime)是 TCP 的"报文最大生存时间",表示一个 TCP 报文段在网络中最多可以存在多久,超过这个时间就会失效!


下图是TCP状态转换的⼀个汇总

  • 较粗的虚线表⽰服务端的状态变化情况;
  • 较粗的实线表⽰客⼾端的状态变化情况;

2.2.6 理解TIME_WAIT状态

现在做⼀个测试,⾸先启动server,然后启动client,然后⽤Ctrl-C使server终⽌,这时⻢上再运⾏server, 结果是:

这是因为,虽然server的应⽤程序终⽌了,但TCP协议层的连接并没有完全断开,因此不能再次监 听同样的server端⼝. 我们⽤netstat命令查看⼀下

  • TCP协议规定,主动关闭连接的⼀⽅要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态.
  • 我们使⽤Ctrl-C终⽌了server, 所以server是主动关闭连接的⼀⽅, 在TIME_WAIT期间仍然不能再次监听同样的server端⼝;

可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值(不同系统该值不同)


为什么是 TIME_WAIT 的时间是 2MSL ?

text 复制代码
主动关闭方                         被动关闭方
    FIN  ------------------------>
        <------------------------  ACK
        <------------------------  FIN
    ACK  ------------------------>
             进入 TIME_WAIT

等待 2MSL 主要有两个原因:

  1. 确保最后的 ACK 能让对方收到
    如果最后一个 ACK 丢失,对方可能会重新发送 FIN。处于 TIME_WAIT 的一方虽然客⼾端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK;如果立即关闭,就无法处理这个重传。
  2. 让网络中的旧报文消失
    旧连接中可能还有延迟或重复的报文。 如果马上使用相同的 IP、端口和序列号建立新连接,旧报文可能被误认为是新连接的数据。等待 2MSL 可以降低这种风险。

例如,假设系统设置:

text 复制代码
MSL = 60 秒

那么主动关闭方通常需要保持:

text 复制代码
TIME_WAIT = 2MSL = 120 秒

简单说,MSL 就是"旧 TCP 报文最多在网络中存在多久",而 TIME_WAIT 等待 2MSL, 是为了确认连接真正结束,并防止旧报文干扰新连接。

将上述答案总结成两点就是:

  • 确保双方4次挥手都尽可能正确完成
  • 让陈旧报文,在网络中尽可能消散

2.2.7 解决TIME_WAIT状态引起的bind失败的⽅法

在 server 的 TCP 连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的:

高并发短连接场景下,如果服务端主动关闭TCP连接 ,会生成大量TIME_WAIT状态连接。

TIME_WAIT会占用通信五元组,服务端IP、端口固定,当新客户端的五元组和处于TIME_WAIT的连接发生复用冲突时,就会引发网络异常。

使⽤ setsockopt ()设置 socket 描述符的 选项SO_REUSEADDR 为 1 , 表⽰允许创建端⼝号相同,但IP地址不同的多个 socket 描述符

setsockopt() 用来设置 socket 的各种属性,让我们调整它的行为。

cpp 复制代码
#include <sys/socket.h>

int setsockopt(
    int sockfd,
    int level,
    int optname,
    const void *optval,
    socklen_t optlen
);

例如:

cpp 复制代码
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR,
           &opt, sizeof(opt));

这表示允许 socket 重用本地地址,服务器重启时可以更快重新绑定端口。

2.3 流量控制

接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继⽽引起丢包重传等等⼀系列连锁反应。

因此TCP⽀持根据接收端的处理能⼒, 来决定发送端的发送速度. 这个机制就叫做流量控制(FlowControl);

  • 接收端将自己可以接收的缓冲区剩余空间大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
  • 窗口大小字段越大, 说明网络的吞吐量越高; ( 例如,窗口只有 10 KB,发送完 10 KB 就必须等 ACK;窗口为 1 MB,就能先连续发送 1 MB,再等待确认,网络利用率更高。)
  • 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
  • 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
  • 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段(报头), 使接收端把窗口大小告诉发送端.(这一段可以看看1.8 的PSH部分 )

那么问题来了, 16位数字最⼤表⽰65535, 那么TCP窗⼝最⼤就是65535字节(64kb左右)么?

实际上, TCP⾸部40字节选项中还包含了⼀个窗⼝扩⼤因⼦M, 实际窗⼝⼤⼩是 窗⼝字段的值左移 M 位;


那么 当我三次握手完成,第一次发送数据的时候,应该发送多大的数据??

在三次握手的的时候,双方都互相交换过ACK,不就交换过窗口大小了嘛!

2.4 滑动窗口

对每⼀个发送的数据段, 都要给⼀个ACK确认应答. 收到ACK后再发送下⼀个数据段. 这样做有⼀个⽐较⼤的缺点, 就是性能较差. 尤其是数据往返的时间较⻓的时候

既然这样⼀发⼀收的⽅式性能较低, 那么我们⼀次发送多条数据, 就可以⼤大提⾼性能(其实是将多个段的等待时间重叠在⼀起了).

2.4.1 理解滑动窗口

那么 滑动窗口在哪里呢?

滑动窗口就是 发送缓冲区的一部分区域!

⚠️:为了方便理解,我们将发送接受缓冲区都当作数组 char buffer[N]看!(OS内核实际怎么做的咱们不考虑!因为逻辑上是线形结构的)

那么,什么是滑动窗口?

滑动窗口 实际上可以理解成就是start、end两组数组下标!表示在【start,end】这个区间内的数据,即 该区域内数据可以直接发出,暂时不收应答!

当然,也不能将其单纯理解成一纬数组,而是要理解成 以一纬数组为载体的被划分为三个区域的相关结构!

text 复制代码
已确认数据 | 已发送未确认数据 | 未发送

窗口随着 ACK 到达向右移动:

text 复制代码
原来:已确认 | 未确认 | 未发送 
后来:已确认更多 | 未确认 | 未发送 

综上: 滑动窗口是一种逻辑上的一维序列模型,数据按照序列号被划分成多个区域;窗口随着确认信息到达不断向右移动。

2.4.2 滑动窗口的工作过程

窗⼝⼤⼩指的是 ⽆需等待确认应答⽽可以继续发送数据的最⼤值!

问题:最开始的时候滑动窗口的大小应该怎么确定?

窗口大小 = min(ACK带来的窗口大小,真实发送的数据量)!


主机 A 一开始允许发送序号 1001~5000 的数据,窗口分成四个部分。A 先发送 1001~2000 的数据给主机 B。B 收到后返回确认消息,表示前面的数据已经收到,并请求下一个序号为 2001 的数据。A 收到确认后,窗口向右滑动,新的可发送范围变成 2001~6000,这样就可以继续发送后面的数据。


⚠️:实际滑动窗口区域会随ACK应答所带窗口大小进行动态调整,还是接上面例子 再接到2001的确认序号,实际窗口大小会变成【2001,2001+ min(ACK带来的窗口大小,真实发送的数据量)】!,最极端的情况就是右边窗口不变(即接收方一直不读)

而这种变化不就是流量控制底层的实现吗!

2.4.3 出现了丢包, 如何进⾏重传

情况1:数据包已经抵达, ACK被丢了

这种情况下, 部分ACK丢了并不要紧, 因为可以通过后续的ACK进⾏确认。


情况⼆: 数据包就直接丢了.

  • 当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是1001" 一样;
  • 如果发送端主机连续收到了3个 "1001" 这样的应答,就会将对应的数据 1001‑2000 立刻重新发送;
  • 这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了(因为2001‑7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中;

这种机制被称为 "高速重发控制"(也叫 "快重传")。

PS:快重传本质是提高传输效率的机制,不管怎么样都会有超时重传机制的兜底!


问题:那么在没有被ACK确认序号确认前,这些报文要保存起来,保存在哪里?

其实就是保存在滑动窗口内部!当滑动窗口区域数据被滑动到滑动窗口左侧后,本质就是对数据进行删除!【下次用户再次写数据的时候,用户就可以直接覆盖写了!】

2.4.4 滑动窗口越界问题

滑动窗口属于一个建立在发送缓冲区逻辑上的数组结构,那么是数组就可能越界,那怎么办? 其实你只需要把滑动窗口理解成逻辑上的 循环数组,里面会有一些特殊机制(如%运算)解决越界问题即可!

2.5 拥塞控制

虽然TCP有了滑动窗⼝这个⼤杀器, 能够⾼效可靠的发送⼤量的数据. 但是如果在刚开始阶段就发送⼤量的数据, 仍然可能引发问题

  • 如果10000个报文,丢2~3个,这是正常情况 ------ 直接重传即可
  • 如果10000个报文,丢9998个,这个肯定就是网络的问题(现实生活可能是底层路由器出问题了 ,但是TCP会统一会判定为网络拥堵【不是它先判断网络拥堵,而是作为TCP协议,它只能判断网络拥堵,更底层的问题它无法解决】)!,那么此时就不能重传了! )

因为⽹络上有很多的计算机, 可能当前的⽹络状态就已经⽐较拥堵. 在不清楚当前⽹络状态下, 贸然发送⼤量的数据, 是很有可能引起雪上加霜的(因为世界上的机子成百上千,如果真的是拥堵引起的,大家都少传一点,让网络将堆积的报文解决掉,网络问题自然就解决了!)

TCP引⼊ 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的⽹络拥堵状态, 再决定按照多⼤的速度传输数据

此处引⼊⼀个概念称为 拥塞窗⼝ !在TCP 协议栈,存在一个整型变量,用来衡量当前网络的拥塞情况,当发送报文大于这个变量时,就会判定为会可能会发生网络拥塞!即 衡量网络拥塞的指标!

而TCP就是根据这个指标来控制发送数据的多少的一个依据!


在TCP中 存在win窗口(即报头窗口的大小字段)、滑动窗口、拥塞窗口,在发送的报文足够大时,这三个窗口有什么关系呢?

滑动窗口 = min(win窗口,拥塞窗口)


拥塞控制的具体方式:

  • 发送开始的时候,定义拥塞窗口大小为2的0次方;
  • 每次收到一个ACK应答,拥塞窗口次方数➕1;
  • 每次发送数据包的时候,将 拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口;

这种指数增长的特点就是: 前期增长慢,后期增长快的特点!

像上面这样的拥塞窗口增长速度,是指数级别的的。"慢启动"只是指初使时慢,但是增长速度非常快。

  • 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。
  • 此处引入一个叫做慢启动的阈值(ssthresh)
  • 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是 按照线性方式增长
  • 当TCP开始启动的时候, 慢启动阈值等于接收窗口最大值;
  • 在每次超时重发的时候, 慢启动阈值会变成原来的一半,同时拥塞窗口置回1;

问题来了:为什么拥塞窗口大小时变化的?

因为网络状态是变化的,也说明了当前网络发生拥堵的情况也一定是变化的!

那么TCP怎么知道,这个拥塞窗口大小应该是多少?

所以TCP只能通过不断的试,才能知道 所以拥塞窗口指数变化、线性变化、减法减少的过程本质就是在 探索当前网络的接受能力!!

总结:正常情况下,丢包1%~1.5%就是正常;丢包3%则认为拥堵,5%则是非常拥堵!拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对⽅, 但是⼜要避免给⽹络造成太⼤压⼒的折中⽅案!

2.6 延迟应答

如果接收数据的主机立刻返回 ACK 应答,这时候返回的窗口可能比较小。

  • 假设接收端缓冲区为 1M,一次收到了 500K 的数据;如果立刻应答,返回的窗口就是 500K;
  • 但实际上可能处理端处理的速度很快,10ms 之内就把 500K 数据从缓冲区消费掉了;
  • 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;
  • 如果接收端稍微等一会再应答,比如等待 200ms 再应答,那么这个时候返回的窗口大小就是 1M。

一定要记得: 窗口越大,网络吞吐量就越大,传输效率就越高。 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

那么所有的包都可以延迟应答么?肯定也不是:

  • 数量限制 :每隔 N 个包就应答一次;
  • 时间限制:超过最大延迟时间就应答一次。

具体的数量和超时时间,依操作系统不同也有差异;一般 N 取 2,超时时间取 200ms。

延迟应答本质:通过延时,一定概率可以给发送方通告一个更大的接受窗口!

2.7 捎带应答

在延迟应答的基础上, 我们发现, 很多情况下, 客⼾端服务器在应⽤层也是 "⼀发⼀收" 的. 意味着客⼾端给服务器说了 "How are you", 服务器也会给客⼾端回⼀个 "Fine, thank you";

那么这个时候ACK就可以搭顺⻛⻋, 和服务器回应的 "Fine, thank you" ⼀起回给客⼾端。

那么3次握手的时候可以带数据吗?

在三次握手未能完成前,是不能进行通信的,但是对于客户端而言发送ACK的时候 三次握手已经完成了,所以客户端发送ACK的时候是可以带数据的!

所以捎带应答的本质是提高TCP传输数据的效率!

2.8 TCP小结

为什么 TCP 这么复杂?因为要保证可靠性 ,同时又尽可能提高性能。

可靠性

  • 校验和:检测数据在传输中是否损坏
  • 序列号:保证按序到达、去重
  • 确认应答:确认对方已收到报文
  • 超时重发:超时未收到应答则重传
  • 连接管理:三次握手建立、四次挥手断开
  • 流量控制:根据接收端能力调整发送速度
  • 拥塞控制:根据网络状况动态调整发送量

提高性能

  • 滑动窗口:批量发送,无需逐包等待应答
  • 快速重传:连续收到 3 个重复 ACK 立即重传
  • 延迟应答:稍等再应答,通告更大的接收窗口
  • 捎带应答:数据与 ACK 合并发送,减少报文数量

其他

  • 定时器:超时重传定时器、保活定时器、TIME_WAIT 定时器等

核心结论 : TCP 的复杂性源于它在可靠性与性能之间寻求平衡------可靠性机制保证数据不丢、不乱、不错,性能机制则让传输尽可能高效,二者共同构成了 TCP 的完整设计。

3. TCP常见面试题

3.1 ⾯向字节流

创建一个TCP的socket,同时在内核中创建一个发送缓冲区 和一个接收缓冲区;

  • 调用write时,数据会先写入 发送缓冲区中;
  • 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
  • 如果发送的字节数太短 ,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去;
  • 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
  • 然后应用程序可以调用read从 接收缓冲区拿数据;
  • 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据. 这个概念叫做 全双工

由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:

  • 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节;
  • 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次;

这种读写次数无规律,而且报文与报文之间的解释与边界问题,TCP不判定,而是由应用层来判断!字节流式的服务,一般需要自定义应用层协议,或者使用现成的基于TCP的应用层协议如HTTP(HTTP 本身不会 "额外解决" TCP 粘包,HTTP 依靠自己的协议格式,天然规避了粘包带来的解析错误)。

3.2 粘包问题

⾸先要明确, 粘包问题中的 "包" , 是指的应⽤层的数据包

传输层视角下TCP收到一个个报文,会依据序号排序存到缓冲区;但对应用层而言,只能读到一串连续字节,无法区分哪里是一条完整应用层数据包的边界,这就是TCP粘包问题的根源。

那么如何避免粘包问题呢? 归根结底就是⼀句话, 明确两个包之间的边界.

  1. 采用固定长度的报文
  2. 添加描述字段:如有效载荷的长度(HTTP 里面请求报头就有数据的长度)
  3. 特殊字符:类似用 空格、空行、\r\n 那样来划分边界(像HTTP)
  4. 采用上面多种组合的方式(如UDP就是1、2组合,HTTP就是2、3组合)

3.3 TCP异常问题

  1. 进程终止: 进程终止会释放文件描述符,仍然可以发送FIN. 和正常关闭没有什么区别。
  2. 机器重启: 和进程终止的情况相同,重启前会杀死进程。
  3. 机器掉电/网线断开:
  • 此时客户端没有发送FIN的机会,链接回直接自动关闭不挥手。站在服务端不知道对方链接关闭! 所以服务端会维持一段时间链接。
  • TCP层为了避免这种情况占用大量链接资源,就会内置一个保活定时器(一般10~20分钟),每次收到客户端报文就会将时间进行一次重置,如果对端一直在该时间内不活跃,服务端就会开始定时不断发送报头询问对⽅是否还在,如果连续多次询问收不到ACK,服务端就会自动释放链接!
  • 如果出问题的是服务端呢?客户端依然会向服务器发送数据(或者进行保活询问),如果此时服务端刚好恢复,服务端就会发送RST,与客户端重新进行三次握手!

虽然TCP有保活机制,但是保活一般都是应用层的某些协议做的,如HTTP每隔10s没有收到对端报文,就发送一个最简单的GET请求,得到最简单的response则说明链接活着,这个机制叫 心跳机制!该机制本质就是在保证对端活着!

相关推荐
web打印社区1 小时前
C-Lodop 提示未准备好或 WebSocket 没准备好:先让本机服务起来
javascript·网络·websocket·网络协议·pdf·html
2401_868534781 小时前
无源光网络
网络
别动我齐刘海1 小时前
简历技术栈全面复习总结
c++·人工智能·深度学习·学习·tcp/ip·算法·rpc
BIM云平台开发1 小时前
DAZ里,如何保存现在姿势,如果调错了,可以重新导回
linux·服务器·前端·daz基础教程
栖凤1 小时前
多 Agent 工作流实践:从单打独斗到协同作战
java·linux·服务器
ZealSinger1 小时前
Go1.25 FlightRecorder慢请求截trace
网络·数据库·go
Black蜡笔小新1 小时前
EasyCVR从“人盯人”到“AI盯岗”:可视化大屏+智能告警,岗位管理一屏掌控
网络·安全·easycvr
羔羊++2 小时前
20_实验十九_内核源码准备与配置
linux
askama002 小时前
Ubuntu安装Anaconda并配置环境
linux·python