【Linux】: 传输层协议 TCP
📃个人主页:island1314
🔥个人专栏:Linux—登神长阶
⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏 💞 💞 💞
- 生活总是不会一帆风顺,前进的道路也不会永远一马平川,如何面对挫折影响人生走向 – 《人民日报》
🔥 目录
- 一、前言
- 1. TCP 协议段格式
- 2. 封装与分用
- 二、理解 TCP 可靠性
- 1. 网络传输中的不可靠
- 2. 可靠性保证 -- 确认应答
- 3. TCP 工作模式
- 4. 超时重传机制 & 丢包问题
- 三、理解 TCP 报头
- 1. 32 位序号和确认序号:TCP全双工通信&确认应答机制
- 1.1 32 位 序号
- 1.2 32 位确认序号(Acknowledgment Number)
- 1.3 确认应答机制
- 2. 16 位窗口大小:TCP缓冲区&流量控制
- 3. 6 个标志位
- 四、连接管理机制
- 1. 三次握手
- 1.1 三次握手的可靠性
- 1.2 1/2/3 次握手对比
- 1.3 三次握手的必要性
- 关于三次握手更深入理解(思考)
- 2. 四次挥手
- 2.1 基本理解
- 2.2 特殊状态
- 理解 TIME_WAIT
- 解决 TIME_WAIT 状态引起的 bind 失败的方法
- 理解 CLOSE_WAIT
- 2.3 为什么需要四次挥手
- 2.4 四次挥手失败会发生什么?
- 3. 状态转化小结
- 五、其他
- 1. 滑动窗口
- 快重传
- 2. 流量控制
- 3. 延迟应答
- 4. 捎带应答
- 5. 拥塞控制
- 5.1 引入拥塞控制
- 5.2 问题-- 慢启动何时结束?
- 5.3 拥塞避免算法
- 5.4 快速恢复算法
- 6. 面向字节流
- 7. 粘包问题
- 8. TCP 异常情况
- 六、小结🔥
一、前言
🔥 之前在这篇文章 传输层协议 UDP 中已经说过关于传输层的部分内容,现在我们来了解一下传输层 TCP 的内容吧
- 🧑💻 TCP 全称为 "传输控制协议(
Transmission Control Protocol"
). 它对数据传输进行了详细的控制。TCP头部包含多个字段,每个字段都有特定的功能,以确保数据能够可靠地从一个端点传输到另一个端点。
1. TCP 协议段格式
理解TCP的报头:
Linux
内核是C语言写的,在UDP
说过报头是协议的表现,而协议本质就是结构体数据。所有 tcp报头 就是一个结构化或位段。
struct tcphdr
这是一个类型,可以定义出一个对象。把应用层的数据拷贝到缓冲区里,然后把报头拷贝到前面,这不就是添加报头嘛
TCP协议报文也有自己的 报头+有效载荷,这个 有效载荷 是 应用层的报文,当然包含应用层报头和有效载荷
- 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
- 32 位序号: 当前报文段数据的第一个字节的序列号;用于保证数据的有序性和可靠性;解决网络包乱序问题,每次发送数据时累加数据字节数
- 32 位确认序号: 期望收到的下一个报文段的序列号;仅在
ACK
标志位为 1 时有效;帮助解决丢包问题 - 4 位 TCP 报头长度: 表示该 TCP 头部有多少个 32 位 bit (有多少个 4 字节),因此
TCP
头部最大长度是 15 * 4 = 60
6 位标志位:
- URG (
Urgent
): 紧急指针是否有效- ACK(
Acknowledgment
): 确认序号是否有效- PSH(
Push
): 提示接收端应用程序立刻从TCP
缓冲区把数据读走- RST(
Reset
): 对方要求重新建立连接; 我们把携带RST
标识的称为 复位 报文段- SYN(
Synchronize
): 请求建立连接; 我们把携带SYN
标识的称为 同步 报文段- FIN(
Finish
): 通知对方, 本端要关闭了, 我们称携带FIN
标识的为 结束 报文段
- 16 位窗口大小: 接收方当前可接收的数据量(以字节为单位)
- 16 位校验和: 发送端填充, CRC 校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含 TCP 首部, 也包含 TCP 数据部分(完整性验证)
- 16 位紧急指针: 标识哪部分数据是紧急数据;
- 40 字节头部选项: 可选部分(Options,最多 40 字节)。接收方需要通过 “4 位 TCP 报头长度” 字段动态确定:
- 1️⃣ 头部结束位置
- 2️⃣ 载荷数据(Payload)的起始位置;
TCP协议的特点
- 面向连接:TCP必须先建立连接才能进行数据传输。
- 可靠:无论网络状况如何变化,TCP都能保证数据的正确传递。
- 字节流:消息无边界,可以传输任意大小的数据,并且保持顺序。
2. 封装与分用
报文宽度:0-31 bit 是这个报文的宽度。每行4个字节,总共5行,因此标准 TCP 报文的长度是20字节,选项部分暂不考虑
TCP 报文标准长度:标准 TCP 报文长度是20字节
如何封装解包,如何分用?
在了解如何分用之前,需要先来看看作为接收方,其 如何保证把一个 TCP 报文全部读完呢?其实很简单,具体步骤如下:
① 读取 TCP 标准报头
- TCP 协议有标准长度:20字节。因此,先读取前 20 字节。
- 这 20 字节转换为结构化数据后,立刻提取报头中的 4 位首部长度字段。
② 计算 TCP 报头总长度
- 4 位首部长度字段表示的 TCP 报头总长度。其值范围为 0000-1111,即 [0,15]。
- 4 位首部长度字段单位为 4 字节,因此 TCP 报头总长度 = 4 位首部长度 * 4 字节。报头长度范围为 [0,60] 字节。由于标准报头长度是 20 字节,因此最终 TCP 报头长度范围为 [20,60] 字节。
③ 确定报头长度的计算
- 若报头长度是 20 字节,则 4 位首部长度应填写为:x * 4 = 20,因此 x = 5,即 0101。
④ 计算选项长度
- 如果 TCP 报头长度为 x * 4,则减去 20 字节的标准长度后,剩下的即是选项的长度字节数。
- 若无选项,则 x * 4 - 20 = 0;若有选项,则继续读取选项部分长度。
分离有效载荷
🍎 一旦读取完 TCP 报头,剩下的数据即为 有效载荷 ,将其放入 TCP 接收缓冲区供上层继续读取。
隐含问题:TCP 与 UDP 报头的区别
- UDP 报头:包含了 UDP 报文的长度,因此很容易确定 UDP 有效载荷的长度
- TCP 报头:仅包含 TCP 报头长度,但并未明确有效载荷长度。这是因为 TCP 是面向字节流的协议
封装和解包的逆向过程
🍎 解包完成后,封装的过程也可以反向推导出来。只要能解包,就可以逆向封装报文
如何分用 TCP 报文
🍎 在 TCP 报头中有 目的端口号,通过该端口号可以定位应用层的进程,将数据交付给相应进程。
如何通过端口号找到绑定的进程?
当接收到一个报文,如何找到绑定了特定端口的进程呢?以下是过程解析:
1、网络协议栈与文件的关系:
- 虽然 PCB(协议控制块)通过双链表进行组织和管理,但为了快速定位进程,系统将每个 PCB 添加到一个数据结构中——哈希表。
- 系统中,bind 绑定一个端口时,会在 OS 中以端口号作为 key 维护一张哈希表。
2、通过哈希表定位进程:
- 当收到一个目的端口号 8080 的报头时,OS 会使用端口号查找哈希表,迅速找到与该端口绑定的进程 PCB。
数据报文经过 OS 各层处理,最终将有效载荷存放到文件的缓冲区中。上层应用可以通过文件的方式统一读取网络数据,实现了网络数据的封装与解包。
二、理解 TCP 可靠性
1. 网络传输中的不可靠
谈 TCP 必谈可靠性,但在讨论可靠性之前,先考虑几个问题:❓
为什么网络传输时会存在不可靠的问题?
不可靠问题常见的场景有哪些?
TCP 的可靠性如何保证?
以前我们学过冯诺依曼体系结构,里面包括 CPU、内存、外设(如显示器、键盘、鼠标、磁盘等),这些设备都是独立的。但我们可以将键盘的数据放入内存,也可以将内存中的数据传送到 CPU
,这说明各个硬件并非孤立的,它们之间是有联系的。这些设备通过计算机中的“线”连接。
- 内存和外设之间通信:通过 I/O 总线。
- 内存和 CPU 之间通信:通过 系统总线。
内存和外设之间的通信也有自己的协议。因为有协议,所以可以控制外设。而这类协议的开发者通常属于“嵌入式”领域
虽然内存和外设之间有通信协议,但我们并未讨论它们之间的可靠性问题。原因在于它们之间的距离很近,不存在网络传输中的可靠性问题
网络传输中的不可靠性场景
为什么网络传输时会存在不可靠的问题?
原因:传输距离变长了
常见的不可靠场景有哪些?
- 丢包
- 乱序
- 重复
- 校验错误
2. 可靠性保证 – 确认应答
如何理解 TCP 的可靠性?
假设两个人 A 和 B 之间相隔 500 米,A 问 B:“你吃饭了吗?” A 不能确定 B 听到了,除非 A 收到 B 的应答。所以,只有在收到应答的情况下,A 才能确认 B 听到了这句话。
但当 B 给 A 回复“我吃了”时,B 也无法确定 A 是否收到了这条信息,如果 B 没有给回应的话, A 也会继续进行询问。同样,只有当 A 回应后,B 才能确定 A 收到了“我吃了”这条信息,如果 A 没有给回应的话, B 也会继续进行询问
这个例子说明了以下三点:
1️⃣ 只有收到了应答,才能100%确认对方收到了之前的信息。
—— 确认应答后,消息才算可靠
2️⃣ 通信中总会存在最新消息没有得到应答的情况。
—— 最新消息一般无法保证可靠性,原因:总有一条最新的消息是没有应答的(这句话也就是表示 相当于老信息是有应答的, 100% 可靠 )
因此,传输距离变长后,不可能存在绝对可靠性,只能保证相对可靠性。只要收到应答,就能保证该报文的可靠性。这就是 TCP 可靠性的基础:确认应答机制
3. TCP 工作模式
实际通信过程中 TCP 工作模式如下:
- client 发起请求,Server 必须给确认。
由于应答的存在,Client 可以确认 Server 100% 收到了请求,因此可以保证 Client->Server 的可靠性。 - Server 回应 Client,Client 也必须给确认。
同理,这也保证了 Server->Client 的可靠性。
因此,通过确认应答机制,双方的数据传输都能保证可靠性。这些请求和应答是通过封装成 TCP 报文进行发送的。在实际通信中,除了正常的数据段,通信时也包含 确认数据段
捎带应答机制
在实际工作模式中,确认应答可以与对请求的响应一起打包发送。以 A 和 B 的例子为例,A 问 B “你吃饭了吗?” B 本来应先确认收到消息,再回复“我吃了”。但 B 可以直接回复“我吃了”,这一条消息既是对 A 的确认应答,也是 B 给 A 的新消息 这就是所谓的 捎带应答
批量确认的工作模式
另一种工作模式是 批量确认。Client 可以一次性给 Server 发出多个请求,Server 则可以批量确认这些请求,而非逐条应答。这种模式下,请求和应答是并发的。
结论:
不管是串行确认还是批量确认,原则上,无论是 C->S 还是 S->C,每个正常的数据段都需要应答来保证可靠性。但最新的一条消息是没有的
4. 超时重传机制 & 丢包问题
🫧 主机 A 发送数据给 B 之后, 可能因为网络拥堵等原因, 数据无法到达主机 B;
🫧 如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答, 就会进行 重发
🎯 但是, 主机 A 未收到 B 发来的确认应答, 也可能是因为 ACK 丢失了;
- 对于主机 A 来说,无论是数据丢了还是应答丢了,都是一样的
🌊 因此主机 B 会收到很多重复数据. 那么 TCP 协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉.
- 去重:那么对于重复报文(报文 = 报头 + 有效载荷),由于报头涵盖 序号字段,因此就可以用到 序号(下面要讲的)来进行去重
那么, 如果超时的时间如何确定?
最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”.
但是这个时间的长短, 随着网络环境的不同, 是有差异的.
- 如果超时时间设的太长, 会影响整体的重传效率;
- 如果超时时间设的太短, 有可能会频繁发送重复的包;
TCP
为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.
- Linux 中 (
BSD Unix
和Windows
也是如此), 超时以500ms
为一个单位进行控制, 每次判定超时重发的超时时间都是 500ms 的整数倍.- 如果重发一次之后, 仍然得不到应答,等待 2 ∗ * ∗ 500ms 后再进行重传。
- 如果仍然得不到应答,等待 4 ∗ * ∗ 500ms 进行重传. 依次类推,以指数形式递增,累计到一定的重传次数, TCP 认为网络或者对端主机出现异常, 强制关闭连接。
三、理解 TCP 报头
16位校验和+选项我们不考虑,然后 4 位首部长度下面也不管,接下来学习 tcp
32 位序号 和 32 位确认序号 以及 16 位窗口 还有六个标志位
1. 32 位序号和确认序号:TCP全双工通信&确认应答机制
1.1 32 位 序号
作用
- 标识数据字节的顺序: 每个 TCP 报文段中的序号表示该报文段中第一个数据字节在整个数据流中的位置。
- 解决乱序问题: 接收方可以根据序号重新排序接收到的报文段,确保数据按正确顺序交付给应用层
计算规则
- 在 TCP 连接建立时,双方会随机生成一个初始序号(ISN,InitialSequence Number)。
- 后续的序号基于初始序号递增,递增的值为前一个报文段中数据字节的长度。
1.2 32 位确认序号(Acknowledgment Number)
作用
- 确认已接收的数据: 确认序号表示接收方期望接收的下一个字节的序号
- 实现可靠传输: 通过确认序号,发送方可以知道哪些数据已被成功接收,哪些数据需要重传。.
计算规则
- 确认序号等于接收方已成功接收的最后一个字节的序号加1
- 只有当 TCP 报文的 ACK 标志位为 1 时,确认序号才有效
1.3 确认应答机制
今天,客户端 © 可能向服务器 (s) 发送信息,也可能是服务器向客户端发送信息。由于双方都使用 TCP 协议,所以 TCP 的双方地位是对等的。要了解 TCP,只需要搞清楚一个方向的通信过程,反过来,另一个方向的通信也是一样的。
① TCP 真实工作模式
🔥Client
可能一次给 Server
发送多个请求报文,而 Server
也可以一次给 Client
发送多个确认应答。
② 问题分析
1️⃣ 数据的顺序问题:
- 如果客户端一次给服务器发送多个请求,数据到达对方的顺序是否和发送顺序一样?
答案是不一定!数据在网络传输中可能乱序到达
2️⃣ 确认与请求的对应关系:
- 当 Server 连续收到多个请求后,要对请求进行确认。那么,Client 如何知道这些确认是对应哪个请求的呢?
假设客户端发了4个请求,服务器只回了3个确认,那客户端必须知道自己发了4个请求,且只收到了3个确认,这样才能判断哪个报文丢失了。
③ TCP 报文序号机制
为了保证每个请求和应答可以对应上,TCP 请求报文(数据段)需要有方式标识数据段本身,因此每个数据段都有自己的 32位序号。
- 每一个请求和确认应答都是一个 TCP 报文,有数据的包含有效载荷,没数据的只包含 TCP 报头。
- 每个 TCP 报文都会填充一个序号,确保报文有序。
④ 确认应答的机制
Server 给 Client 的应答报文中,需要和请求报文一一对应。因此,应答报文的报头中会包含确认序号,这样 Client 可以知道应答是对哪个请求的。
序号与确认序号的对应规则:
- 假设 Client 发送了序号为 1000 的报文,Server 的确认序号将是 1001。
- 如果 Client 发送了序号为 2000 的报文,Server 的确认序号则为 2001。
- 也就是说,你发过来的报文序号是多少,确认序号就是发过来的序号 + 1。
确认序号的含义:
确认序号表示接收方已经收到了该序号之前的所有报文(连续且无遗漏),并告知对方下次发送从该确认序号开始。
⑤ 丢包场景下的应答机制
如果某个报文丢失了,比如:
- Client 发送了序号为 2000 的报文丢失了,Server 只收到了序号为 1000 和 3000 的报文。
- Server 对序号为 1000 的报文返回确认序号 1001,但由于 2000 丢失了,即使收到了 3000 号报文,确认序号依然是 1001,因为 2000 之前的报文不是连续的。
为什么这样设计?
这是为了支持 TCP 的滑动窗口机制,使得确认序号可以线性右移,从而保持可靠的数据传输。
⑥ 两组序号的必要性
❓为什么需要 32 位序号和 32 位确认序号?缺一不可的主要原因:
序号的作用
- 标识数据字节的位置: 序号用于标识每个字节在数据流中的位置,确保数据可以
- 正确顺序重组解决乱序问题: 在网络中,报文段可能会乱序到达,序号可以帮助接收方重新排序。
确认序号的作用
- 确认接收状态: 确认序号告诉发送方哪些数据已被成功接收,哪些数据需要重传。
- 实现滑动窗口: 确认序号是滑动窗口协议的基础,用于控制流量和实现可靠传输。
为什么不能只凭序号?
- 无法确认接收状态: 如果只有序号,发送方无法知道接收方是否成功接收了数据
- 无法实现流量控制: 确认序号是滑动窗口协议的关键,用于动态调整发送方的发送速率
结论:我们可以把我们上面所说的东西总结为如下流程图:
🌲 TCP 将每个字节的数据都进行了编号. 即为序列号(这里我们可以想像成字节数组
⚡️ 每一个 ACK 都带有对应的确认序列号, 意思是告诉发送者, 确认序列号之前的报文我已经全部收到了; 下一次你从哪一个序号开始发.
注意:有时候也会出现序号不够用的情况,那么就会进行 回绕,但是在正常情况下,一个通信周期内基本不会,毕竟真到了回绕,历史报文也早消失了, 因为缓冲区大小是有限的
- 序号回绕:档序号达到最大值时,会回绕到 0 ,TCP 通过时间戳和序号回绕保护机制来处理这种情况
2. 16 位窗口大小:TCP缓冲区&流量控制
缓冲区的作用
- 发送缓冲区:用于暂时保存应用层通过IO接口拷贝过来的数据,等待通过网络发送。
- 接收缓冲区:用于暂时保存从网络接收到的数据,直到被应用层读取。
流量控制的必要性
- 地理位置远近:客户端与服务器可能相隔很远。
- 发送速度不匹配:
- 如果客户端发送数据过快,而服务器来不及处理,会导致服务器接收缓冲区溢出,后续到达的数据包将被丢弃(造成了 资源浪费)
- 反之,如果发送速度过慢,则会影响对方上层业务的正常处理速度。
因此此时就需要一个合适的速度:为了确保数据传输既不过快也不过慢,需要一种机制来调节发送速率 — 流量控制
但是这里有个问题:客户端凭什么进行流量控制,它是怎么知道对方来不及进行接收了呢?
流量控制机制
- 反馈机制:发送方需要知道接收方的接收缓冲区剩余空间大小(也就是对方的接受能力),以调整自己的发送速率
- 16位窗口大小:TCP头部中的16位窗口大小字段表示的是接收方当前接收缓冲区的剩余空间大小。
- 发送方根据这个窗口大小调整其发送速率,比如当接收方接受能力很强的时候,也可以提高发送,因此需要注意:这里的控制 != 减少发送
但是现在有个问题:由于双方都要发信息和应答,因此双方都要进行 流量控制, 那么 这个 16 位窗口大小应该填谁的?
- 由于通信双方的报文都是发给对方的,那么应该填自己的剩余空间大小,因为我要把自己的接受能力通告给对方
因此上面这个字段填入的是接收方的接收缓冲区剩余空间大小,而不是发送方的。
全双工通信
- 双向控制:在全双工通信中,双方都需要知道对方的接收缓冲区剩余空间大小。
- 客户端和服务器都需要保证对方能够以适当的速度接收数据。
💡 结论:
因此,每个方向上的TCP报文都包含一个16位窗口大小字段,表示自己的接收缓冲区剩余空间大小。
交换接收能力
- 双向流量控制:这套规则对客户端和服务器同样适用,实现了双方在两个方向上的流量控制。
- 目的:确保双方都能以合适的速度进行数据交换,避免缓冲区溢出或处理速度跟不上。
通过这种方式,TCP协议不仅确保了数据的可靠传输,还有效地管理了网络带宽的使用,提高了整体的通信效率。
3. 6 个标志位
虽然有的 TCP 标准包含 8 个标记位,但我们主要学习其中 6 个最常用的。
上面说过数据段在来回通信的时候,有的是正常的数据报文,有的是确认报文。 这里我们就可以 理解 tcp
报文也是有类型的!
6 个标志位来表示不同类型的报文。
站在服务器的角度它一定会收到各种各样的tcp报文!所以接收方要根据不同的tcp报文,要有不同的处理动作!
1、SYN 标记位
- SYN 标记位用于表示连接请求报文,请求建立连接。
- 默认情况下,SYN 标记位为 0,只有在建立连接时该标记位才会设置为 1。
- 携带 SYN 标识的报文称为 同步报文段
2、FIN 标记位
FIN 标记位用于表示断开连接请求报文,默认情况下该标记位为 0,当置为 1 时表示这是一个断开连接的请求。携带 FIN 标识的报文称为结束报文段。
3、ACK 标记位
- ACK 标记位用于表示确认应答报文。当通信双方进行数据传输时,客户端发出请求,服务器返回确认,确认报文的 ACK 标记位会被置为 1。
- 即使在正常的数据传输报文中,若该报文包含确认能力,ACK 也会置为 1。
- 三次握手之后,几乎所有的报文都带有 ACK 标记
4、PSH 标记位
- PSH 是 PUSH 的简写,用于催促接收方尽快将数据从接收缓冲区取走。
- 当接收方上层处理数据较慢,导致接收缓冲区积压数据时,发送方可能会发送一个 PSH 报文,要求接收方立即将数据取走。如何催促呢?后面在 多路转接 那再理解
5、URG 标记位(紧急插队)
- URG 标记位用于表示紧急数据。当报文中包含需要紧急处理的数据时,URG 标记位会置为 1,并且使用 16 位的紧急指针来标识紧急数据在有效载荷中的位置。紧急数据会被优先处理,不需要排队。
💡 举个例子:
通信时给对方发大量数据,对方的接受缓冲区拉满了,但是突然又终止通信了,然后就会发一个终止的报文,然后对端也需要先把之前的数据接受完了,才能接收到终止报文才可以终止掉请求,但是这种情况终止的时间可能会特别长
因此在这种情况下,我们想立即终止,把 URG=1,此时标识当前报文为紧急报文,该紧急报文一旦被对方接受,即便对方的接受缓冲区还残留量一些数据,那么OS 也会把该报文的有效数据提取出去,方便让上层在 read 的时候优先读到,那么可以提前终止我们的请求了
注意:这里报文中有效载荷并不都是紧急数据!
那么此时有个问题:将URG标志位置为1 ,上层怎么读数据❓
- 因此在 tcp 报头中还还包含了一个 16 位紧急指针,紧急指针中涵盖了有效载荷当中的一个偏移量,标识紧急数据在什么位置,这样就可以被
read
优先读取 - 假如紧急指针写个20,也就是说该报文中有效载荷偏移量为20的数据开始是要紧急处理的!
现在这个紧急指针偏移量我知道 ,那这个紧急数据到哪里结束呢? --> 难道到有效载荷的结尾?
并不是,根据16位紧急指针找到偏移量以 字节 为单位,往后读取一个字节就是紧急数据。紧急数据不需要排队直接被上层读取,一般这个URG这个1字节数据也成为 带外数据
带外数据并不是tcp帮我们主动弄这个功能,而是tcp提供这个功能供上层选择,我们自己在写服务器的时候可以自己选择正常读数据之前有没有 带外数据
6、RST 标记位
在看 这个之前,可以先把后面的三次握手、四次挥手先理解了,再看
RST
是 reset
的简写。在写套接字TCP协议的时候我们曾经说过,通信双方在通信之前必须要把三次握手建立好才能进行通信。
应用场景分析:
❓ 三次握手建立连接,三次握手一定能保证握手成功吗?不一定!
这个世界上没有100%一定成功的,并且我们也知道三次握手最后一次ACK是没有应答的,可能会出现握手失败的情况。
同理四次挥手也一样!人家只是在TCP这里设立了建立连接三次握手、断开连接四次挥手,但可没说一定成功。
- 但是能保证只要把三次握手四次挥手走完就保证算你连接建立成功和断开连接成功。
- 其次,即便是连接建立成功了,我们通信过程中也有可能出现单方面出现问题!如服务器电源拔掉了。
- 然后插上电之后服务器重启了,但是现在这个服务器操作系统并没有意识到历史还断连接的,虽然这个连接在物理就被干掉了。
❓ 但是客户端知不知道服务器重启过呢?并不知道,你服务器又没有给我四次挥手。
所以就可能会存在
client
认为连接还存在,服务器认为连接不存在。
如果 client
认为连接还存在会出现什么问题?是不是就直接发报文了。
- 可是报文是有类型,就注定了这个报文不会携带
SYN
,服务器收到这个报文很奇怪,我和你并没有建立连接,我们协议规定好我们通信之前要先建立连接,客户端你直接把数据发过来了 - 所以服务器此时直接给客户端回一个报文,而这个报文回携带RST标记位。告诉客户端当前这个连接异常,需要关闭现在的连接然后重新和我建立连接吧!
- RST : 对方要求重新建立连接。我们把携带
RST
标识的称为 复位报文段 。有了RST标记位,双方连接建立一方认为成功一方认为不成功,那么后序在通信的时候,认为不成功的一方就把 连接重置了。
四、连接管理机制
☕️ 在正常情况下,TCP 要经过 三次握手 建立连接, 四次挥手 断开连接
连接的理解
- 大量连接:未来可能会有大量的客户端(Client)连接到服务器(Server),因此服务器端会存在大量的连接。
- 操作系统管理:操作系统(OS)需要管理这些连接,包括连接的状态、异常等。
- 数据结构:每个连接本质上是内核中的一种数据结构。建立连接成功时,会在内存中创建对应的连接对象,并通过某种数据结构组织多个连接对象。所以 先描述,再组织
- 成本:维护连接是有成本的,包括CPU和内存资源。
1. 三次握手
理解:双方通信之前必须先建立连接,经历三次握手。所谓的三次握手就是双方有来有回的吞吐了三个报文
- SYN报文:第一个报文中只有报头,SYN置为1,表示连接请求。
- SYN+ACK报文:第二个报文中SYN和ACK都置为1,表示同意建立连接并确认上一个报文
- ACK报文:第三个报文中只有ACK置为1,确认上一个报文
- 状态变化:
- 客户端发送 SYN ,状态变为
SYN_SENT
- 服务器收到 SYN 后,状态变为
SYN_RCVD
,然后发送 SYN + ACK - 客户端收到 SYN+ACK 后,状态变为
ESTABLISHED
,然后发送 ACK - 服务器收到 ACK 后,状态也变为
ESTABLISHED
- 客户端发送 SYN ,状态变为
1.1 三次握手的可靠性
(关于三次握手关注问题的角度
三次握手是建立连接的机制,可没有说一定能保证握手成功。包括四次挥手也是一样的
- 不一定成功:三次握手过程中,最后一个ACK可能丢失,但有超时重传机制。
- 超时重传:如果最后一个 ACK丢失,服务器没有收到应答,会触发超时重传。
- RST报文:如果客户端认为连接已建立但服务器未收到ACK,服务器会返回RST报文让客户端重置连接。
- 客户端和服务器连接是要被OS管理起来的,如何管理? --> 先描述,在组织。但维护一个连接是有成本的,因此三次握手也是 对服务器资源被占用的最小化
1.2 1/2/3 次握手对比
① 一次握手:不可行,会导致 SYN
洪水攻击。占用了大量服务器资源
② 二次握手:不可行,客户端未收到 ACK
时,服务器可能认为连接已建立(浪费了服务器的资源
③ 三次握手:最小成本验证全双工通信信道通畅,防止单主机对服务器进行攻击。
1.3 三次握手的必要性
1️⃣ 减少服务器受到的 SYN 洪水攻击
❓ 问题:三次握手能否完全防止服务器受到 SYN
洪水攻击
回答:
- 服务器受到攻击本质上不是通过
TCP
握手来解决的。如果攻击者使用多台机器进行攻击,即使有三次握手机制,服务器仍然可能受到SYN
洪水攻击。 - 如果服务器存在明显漏洞,那么这是服务器自身的问题。
- 三次握手能够显著减少
SYN
洪水攻击的影响,但其初衷是为了避免连接建立过程中的明显漏洞,而不是专门针对SYN
洪水攻击设计的。
2️⃣ 三次握手才可以阻止重复历史连接的初始化
– 双方验证全双工信道的通畅性
假设有这样一种场景,客户端给服务端发送了一个 SYN
报文(seq=100
),但是这个报文由于网络波动阻塞了,于是客户端又重新发送了一个新的 SYN
报文(seq=200
),注意不是重传,重传的 SYN
的序列号是一样的。
- 从上图可以看出,当客户端发送的
SYN
报文被网络阻塞后,再次发送新的SYN
报文,由于旧的报文比新的报文先抵达服务端,服务端肯定会回一个SYN+ACK
报文给客户端,此时客户端就可以根据这个报文来判断这是一个 历史连接,由此客户端就会发送一个RST
报文,要求断开此次连接(即历史连接)。等新的SYN
报文抵达服务端后,才会正式的建立连接。
如果是两次握手,那就不能阻止历史连接
- 当服务端收到客户端发来的
SYN
报文后,就进入了ESTABLISHED
状态,这就意味着服务端此时就可以给客户端发送数据了,但是客户端还没有进入ESTABLISHED
状态,必须收到服务端的SYN + ACK
报文后,才会进入ESTABLISHED
状态。
在上面可以看出,当服务端收到第一个
SYN
报文后(旧的)就已经建立了连接(服务端并不知道这是历史连接),并且在回复给客户端的报文中携带了数据,但是客户端通过收到的SYN + ACK
报文,发现这是 历史连接
- 对于客户端来说,它根本来不及在服务端给客户端发送数据前来阻止这个历史连接,导致这个历史连接被创建,服务端白白发送了数据(数据创建是需要时间和空间的),造成资源浪费;只有在收到客户端发来的
RST
报文后才会断开连接。
因此,要解决这样的问题,客户端就必须在服务端发送数据之前来阻止掉这个历史连接,而要实现这个功能就需要三次握手。
3️⃣ 三次握手才可以同步双方的初始化序列号
⭕TCP协议通信的双方,都必须要维护序列号,序列号是实现可靠传输的一个关键因素,其作用如下:
- 接收端可以根据序列号进行重复数据的 去重
- 接收端可以根据序列号 按序接受
- 通过ACK报文中的序列号可以识别发出去的数据包中,哪些已经被对方收到了(识别数据接收)
当客户端给服务端发送SYN(携带着自己的序列号)报文的时候,需要服务端回一个 ACK
应答报文,表明客户端的SYN报文已经成功接收;
- 同样,在这个报文中除了
ACK
应答号还有服务端自己的序列号(发送的是SYN + ACK
报文),也需要客户端回一个ACK应答报文,来确保服务端的SYN
被成功接收;
这样一来一回就能保证双方的初始化序列号被可靠同步。
关于三次握手更深入理解(思考)
① 三次握手其实是 “四次”
🌋三次握手的另一种解释:
- 三次握手实际上也可以理解为四次握手
- 原因:在第二次握手中,SYN和ACK通常是合并在一起发送的。在正常情况下,
SYN
和ACK
应该是两个独立的报文,但由于效率考虑,通常将它们合并成一个报文发送。
- 四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了三次握手。
- 🧭注意: 当然肯定不能继续优化成二次握手辣!!!
- 原因:两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。
② 四次、五次或更多次握手是否可行
🌌四次握手:
- 四次握手也能验证全双工通信,但存在一个问题:最后一次
ACK
是由服务器发送的。 - 这意味着服务器必须先完成连接建立,这与两次握手的问题类似。偶数次握手 都会面临这个问题,即最后一方需要先完成连接建立
🌌五次、六次等更多次握手:
- 奇数次握手(如五次、七次等)在理论上是可行的,因为它们可以像三次握手一样验证全双工通信。
- 但是,从实际应用和效率的角度来看,三次握手已经足够有效,没有必要增加更多的握手次数。三次握手已经能够满足需求,再增加握手次数只会增加复杂性和延迟。
③ 为什么要连接?
- 保证可靠性:连接本身不直接保证可靠性,但间接支持可靠性特性,如超时重传、流量控制、拥塞控制等。
- 连接结构体:三次握手帮助创建连接结构体,存储连接状态和相关参数。
④ UDP不需要握手
原因:无需维护状态(UDP不需要维护双方通信状态,因此也不需要握手)
- UDP 是无连接的协议,不需要建立或关闭连接。
- UDP 不保证可靠性,因此不需要像 TCP 那样复杂的挥手过程。
⑤ 三次握手失败发生什么
2. 四次挥手
客户端和服务端都可以先发起FIN(结束报文)
这里我们假定客户端先发起。
2.1 基本理解
理解:虽然断开连接是双方的事,但是又由于 tcp
是全双工的,所以断开连接还需要征得双方同意。
状态变化:
- 客户端发送 FIN,表示不再发送数据,进入
FIN_WAIT_1
状态。 - 服务器收到 FIN 后,发送ACK,进入
CLOSE_WAIT
状态。 - 服务器发送 FIN,表示不再发送数据,进入
LAST_ACK
状态。 - 客户端收到 FIN 后,发送ACK,进入
TIME_WAIT
状态。 - 服务器收到 ACK 应答报文之后,就进入了
CLOSED
状态,至此服务器就已经完成连接的关闭 - 客户端在经过 2MSL 一段时间后,自动进入 CLOSED 状态,至此客户端也完成连接的关闭
注意:只有主动关闭连接的,才有 TIME_WAIT
状态
2.2 特殊状态
我们先来看看一些前置知识 – MSL,来方便对其的理解
MSL(Maximum Segment Lifetime)
定义:MSL 是 TCP 报文段在网络中能够存活的最长时间。超过这个时间后,报文段会被丢弃。
作用:
- 确保网络中旧的、重复的 TCP 报文段不会干扰新的连接。
- 在 TCP 连接关闭时,MSL 用于确定 TIME_WAIT 状态的持续时间。
典型值:
- MSL 的默认值通常是 30 秒 到 2 分钟,具体取决于操作系统实现。
- 因此,TIME_WAIT 状态通常持续 1 分钟 到 4 分钟。
OK,现在可以开始进入正题了,如下:
- 主动断开连接的一方,最终状态是
TIME_WAIT
状态。 - 被动断开连接的一方(故意不关 fd),此时只会完成两次挥手完成,会进入
CLOSE_WAIT
状态。 - 长时间保持
CLOSE_WAIT
状态:如果服务器不调用close
,则不会发送FIN
,一直保持CLOSE_WAIT
状态。
理解 TIME_WAIT
因此我们现在也可以知道:服务器端一旦使用完毕
sockfd
, 就需要关闭掉不用的sockfd
, 防止 fd 泄露 和 内存泄露(连接没有释放,而且这个也是占空间的)
现在做一个测试:首先启动 server,然后启动 client,然后用 Ctrl-C 使 server 终止,这时马上再运行 server,结果是如下:
TCP 协议规定:主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个 MSL (maximum segment lifetime) 的时间后才能回到 CLOSED 状态.
- 我们使用 Ctrl-C 终止了 server,所以 server 是主动关闭连接的一方, 在 TIME_WAIT 期间仍然不能再次监听同样的 server 端口;
- MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同, 在 Centos7 上默认配置的值是 60s
可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看 MSL 的值;
root$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60
🤔 想一想, 为什么是 TIME_WAIT 的时间是 2MSL?
- MSL 是 TCP 报文的最大生存时间, 因此 TIME_WAIT 持续存在 2MSL 的话。
- 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失 (否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
- 同时也是在理论上保证最后一个报文可靠到达 (假设最后一个 ACK 丢失, 那么服务器会再重发一个 FIN. 这时虽然客户端的进程不在了, 但是 TCP 连接还在, 仍然可以重发 LAST_ACK);
TIME_WAIT 状态作用
① 防止旧连接的报文干扰新连接(游离报文):
- 如果客户端在关闭连接后立即建立新连接,网络中可能还有旧连接的延迟报文,这会导致数据混乱。
② 确保服务器收到最后一个 ACK:
- 如果服务器没有收到最后一个 ACK,会重传 FIN 报文,TIME_WAIT 状态允许客户端重新发送 ACK。
解决 TIME_WAIT 状态引起的 bind 失败的方法
🧑💻 在 server 的 TCP 连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的:- 服务器需要处理非常大量的客户端的连接 (每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求).
- 这个时候如果由服务器端主动关闭连接 (比如某些客户端不活跃, 就需要被服务器端主动清理掉), 就会产生大量 TIME_WAIT 连接.
- 由于我们的请求量很大, 就可能导致 TIME_WAIT 的连接数很多, 每个连接都会占用一个通信五元组 (源 ip, 源端口, 目的 ip, 目的端口, 协议). 其中服务器的 ip 和端口和协议是固定的. 如果新来的客户端连接的 ip 和端口号和 TIME_WAIT 占用的链接重复了, 就会出现问题.
📚 使用 setsockopt()
设置 socket
描述符的 选项 SO_REUSEADDR
为 1, 表示允许创建端口号相同但 IP 地址不同的多个 socket 描述符:
// 保证服务器,异常断开之后,可以立即重启,不会有bind问题
int opt = 1;
int n = ::setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
(void)n;
为什么会有TIME_WAI?
当服务器给客户端发送FIN后,客户端不能立即释放TCP连接(因为此时客户端还没有发送ACK给服务器,服务器一段时间没收到ACK就会重传FIN,当服务器重传FIN后,客户端还要将ACK返回给服务器,需要服务器的端口号等信息,所以不能立即释放),在一定时间段内,没有收到服务器重传的FIN,就说明ACK已经被服务器所接收。
理解 CLOSE_WAIT
在服务器看到大量的CLOSE_WAIT,可能是什么原因?
- close未调用
- close调用不及时
LISTEN - 侦听来自远方TCP端口的连接请求;
SYN-SENT - 在发送连接请求后等待匹配的连接请求;
SYN-RECEIVED - 在收到和发送一个连接请求后等待对连接请求的确认;
ESTABLISHED - 代表一个打开的连接,数据可以传送给用户;
FIN-WAIT-1 - 等待远程TCP的连接中断请求,或先前的连接中断请求的确认;
FIN-WAIT-2 - 从远程TCP等待连接中断请求;
CLOSE-WAIT - 等待从本地用户发来的连接中断请求;
CLOSING - 等待远程TCP对连接中断的确认;
LAST-ACK - 等待原来发向远程TCP的连接中断请求的确认;
TIME-WAIT - 等待足够的时间以确保远程TCP接收到连接中断请求的确认;
CLOSED - 没有任何连接状态;
以之前写过的 TCP 服务器为例, 我们稍加修改将 套接字的文件描述符关闭的 close(); 这个代码去掉.
- 我们编译运行服务器. 启动客户端链接, 查看 TCP 状态, 客户端服务器都为 ESTABLELISHED 状态, 没有问题.
- 然后我们关闭客户端程序, 观察 TCP 状态
tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN 5038/./server
tcp 0 0 127.0.0.1:49958 127.0.0.1:9090 FIN_WAIT2 -
tcp 0 0 127.0.0.1:9090 127.0.0.1:49958 CLOSE_WAIT 5038/./server
对于服务器上出现大量的 CLOSE_WAIT
状态, 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确完成。这是一个 BUG. 只需要加上对应的 close
即可解决问题.
注意:无论是主动还是被动 和 是否客户端或是服务器没无关,因为TCP是地位对等的协议
虽然此时服务是处于
close_wait
状态,但是如果我们把进程直接关掉,不管是服务器还是客户端都会变成LAST_ACK
, 因为服务器生命周期是随进程的 就直接 close 了,而客户端也早已不在了
❓ 如何让服务器一直处于 CLOSE_WAIT 状态,不继续往下走?
- 让服务器不要调用
close
!那服务器只是被动触发完成两次挥手,因为不会调用close所以也不会给客户端发送FIN也就不会进入LAST_ACK状态。服务器一直处于CLOSE_WAIT状态。
2.3 为什么需要四次挥手
主要原因:服务器端通常需要等待完成数据的发送和处理,所以服务器端的 ACK 和 FIN 一般都会分开进行发送,导致比三次握手多了一次
🦇 四次挥手为啥不能变成三次挥手?
原因 1:TCP 是全双工协议
- TCP 连接是全双工的,即数据可以同时在两个方向上传输
- 客户端发送 FIN 只表示客户端不再发送数据,但服务器可能还有数据要发送。
- 服务器需要在发送完所有数据后,才能发送自己的 FIN 报文。
原因 2:确保数据完整性
- 服务器在收到客户端的 FIN 后,可能还需要发送一些剩余的数据。
- 如果服务器在发送 ACK 的同时捎带 FIN,可能会导致数据丢失或不完整。
原因 3:防止过早关闭连接
- 如果服务器在发送 ACK 的同时捎带 FIN,客户端可能会误认为连接已经关闭,导致服务器发送的数据丢失。
- 四次挥手确保了双方都能安全地关闭连接,而不会丢失数据。
🦇 为什么不能将 FIN 变成捎带应答?(四次挥手的FIN和ACK报文可以合并吗?)
在特殊情况下是可以的,但一般来说是不可以的。
一般情况
- 当收到FIN报文时,内核直接就返回ACK报文,而FIN报文是由应用程序来控制的(socket.close),不是同一时刻,所有不能合并。
- 三次握手是因为SYN和ACK都是由内核控制的,所以可以合并。
特殊情况
- TCP有一个延时应答机制,在回复ACK数据报时,不是立即回复,而是等待一段时间,这时ACK和FIN就可以合并成一个报文了。
2.4 四次挥手失败会发生什么?
① 第一次挥手失败,发生如下:
如果第一次挥手丢失了,那么客户端会迟迟收不到被动方的 ACK ,这样的话就会触发 超时重传 机制,重传 FIN
报文
② 第二次挥手失败,发生如下:
由于前面已经提到了 ACK 报文是不会进行重传的,因此如果服务器的第二次挥手丢失,那么客户端依然会触发超时重传 机制,重传 FIN
报文,直到收到服务端的第二次挥手 或者 达到最大的重传次数
对于
close
函数的连接,由于无法再发送 和 接收数据,所以FIN_WAIT_2
状态也不可以持续太久,而tcp_fln_timeout
控制了这个状态下连续的持续时长,默认值是 60 s
③ 第三次挥手失败,发生如下:
如果迟迟收不到这个 ACK,服务器端就会重发 FIN
报文,重发次数仍然由 tcp_orphan_retries
参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的
④ 第四次挥手失败,发生如下:
在 Linux 系统中,TIME_WAIT
状态会持续 2MSL 后才会进入关闭状态
3. 状态转化小结
💻 服务端状态转化:
- [
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
状态.
下图是 TCP 状态转换的一个汇总:
较粗的虚线表示服务端的状态变化情况,较粗的实线表示客户端的状态变化情况, CLOSED
是一个假想的起始点, 不是真实状态
五、其他
1. 滑动窗口
💻 在上面已经讨论了确认应答策略:对每一个发送的数据段,都要给一个 ACK 确认应答,收到 ACK 后再发送下一个数据段。这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候.
‼️ 既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了),就引入了滑动窗口的概念(也是我们算法中 说的 那个)
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值(由接收方的缓冲区剩余空间决定). 上图的窗口大小就是 4000 个字节(四个段).
- 发送前四个段的时候, 不需要等待任何 ACK, 直接发送;
- 收到第一个 ACK 后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
- 操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;
- 窗口越大, 则网络的吞吐率就越高
滑动窗口的大小实际上就是对方接收缓冲区剩余空间的大小,左侧是已经发送完且 ACK 完毕的
滑动窗口如何滑动?
窗口的边界:
- Start:窗口的起始位置,通常是接收方已确认的最后一个字节的序号(ACK 确认序号)
- End:窗口的结束位置,计算公式为:End = Start + 接收方缓冲区剩余空间的大小
窗口的滑动:
- 窗口会根据接收方的确认信息(ACK)和缓冲区剩余空间动态调整。
- 窗口只会向右滑动(即序号递增),但窗口的大小可以不变、变大、变小,甚至变为 0。
环形缓冲区:
- 在逻辑上,TCP 的序号空间是环形的(32 位序号)
- 当序号达到最大值时,会回绕到 0,因此不会越界,但是会满
滑动窗口丢包问题
🎯 那么如果出现了丢包, 如何进行重传? 这里分两种情况讨论.
情况一:数据包已经抵达, ACK 被丢了.
这种情况下, 部分 ACK 丢了并不要紧, 为后续的应答数据报的确认序列会告诉进程,这个序列号之前的数据,都已经接收了。
情况二:数据包就直接丢了
有影响:
- 当某一段报文段丢失之后(例如这个窗口是1~4000,其中1-1000的这个数据报丢失了),那么接收端就会一直向发送端发送同一个应答数据报(要求下一个传输的数据报是从1001开始,因为接收端没有接收到1-1000的数据报),比如上面 发送端会一直收到 1001 这样的 ACK, 就像是在提醒发送端 “我想要的是 1001” 一样;
- 当发送端连续收到这样的同一个应答数据报,就会重新发送501-1000的数据报,比如上面发送端主机连续三次收到了同样一个 “1001” 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
- 这个时候接收端收到了 1001 之后,那么就会继续执行,下次执行的就是正在运行的正常的业务的应答数据报。比如上面 再次返回的 ACK 就是 7001 了(因为 2001 - 7000) 接收端其实之前就已经收到了(因为在这个过程中,操作系统内核会有一个接收缓冲区,会将正常的数据报都放在这里面,等处理完异常的情况后,就会根据缓冲区里的数据来继续执行)。
如上这样的处理方式称为快速重传
滑动窗口这样有什么意义呢?
TCP为了保证可靠性,牺牲了很多的效率,滑动窗口就是为了弥补效率的牺牲,而采取的一种办法。
但是即使采取了措施,效率还是没有UDP快。
快重传
📚 这种机制被称为 “高速重发控制” (也叫 “快重传”).
在这种机制下,如果滑动窗口最左侧的数据丢失,接收方会触发快速重传机制,要求发送方重新发送丢失的数据包。
- 如果滑动窗口中间的数据丢失,接收方会通过确认应答(ACK)指出丢失数据包的起始位置,此时问题会转化为滑动窗口最左侧数据丢失的情况,从而同样触发快速重传。
- 类似地,如果滑动窗口最右侧的数据丢失,问题也会被转换为最左侧数据丢失的情况,最终通过快速重传机制解决。
🧑💻 通过这种设计,滑动窗口机制能够高效处理不同位置的数据丢失问题,确保数据传输的可靠性和连续性
注意:虽然这里的快重传不仅快,还能重传,但是还是需要超时重传,因为快重传是用来提高效率的(其是有条件的),而超时重传是兜底的
条件:收到 3 个同样确认应答时则进行重发
滑动窗口的特点
- 动态调整:窗口大小根据接收方的缓冲区剩余空间动态调整。
- 流量控制:通过调整窗口大小,防止发送方发送过多数据导致接收方缓冲区溢出。
- 可靠性:通过确认序号和窗口滑动,确保数据按序到达且不丢失。
2. 流量控制
需要借助滑动窗口实现
定义与目的:
- 接收端处理数据的速度是有限的。
- 如果发送端发送速度过快,接收端缓冲区可能被打满,导致丢包及重传等连锁反应。
- TCP支持根据 接收端处理能力调整发送端的发送速率,此机制称为流量控制(
Flow Control
)
如何确定对方初始接收能力:
- 在正式通信前,双方通过三次握手过程交换信息。
- 三次握手期间,TCP报文头部包含窗口大小字段,用于指示接收方当前能够接受的数据量。
动态调整窗口大小:
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过 ACK 端通知发送端;
- 窗口大小字段越大, 说明网络的吞吐量越高;
- 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端,发送端接受到这个窗口后,就会减慢自己的发送速度;
- 如果接收端缓冲区满了, 就会将 窗口置为 0,这时发送方暂停发送数据,但需定期发送一个窗口探测数据段,使接收端把最新的窗口大小告诉发送端;
注意:接收端如何把窗口大小告诉发送端呢? 回忆我们的 TCP 首部中,有一个 16 位窗口字段,就是存放了窗口大小信息(上面有说的)
关于 TCP 大小:
- TCP头部含有一个16位长的窗口字段,理论上最大表示65535字节。
- 然而,通过使用TCP选项里的窗口扩大因子M,实际窗口大小可远超这个数值。具体来说,实际窗口大小等于窗口字段值左移M位的结果。
3. 延迟应答
① 立即应答问题
- 接收数据的主机若立刻返回ACK应答,可能返回的窗口较小。
- 例如:接收端缓冲区为1M,一次收到500K数据,如果立即应答,则返回的窗口大小为500K。
② 延迟应答
等待一段时间后,再返回窗口大小
举例:
- 接收端处理速度快,10ms内可将500K数据从缓冲区消费掉,在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些也能处理过来
- 等待200ms应答,返回的窗口大小为1M,可以增加网络吞吐量。
- 目标:保证网络不拥塞的情况下提高传输效率。
那么所有的包都可以延迟应答么? 肯定也不是;
③ 延迟应答限制
- 数量限制:每隔N个包应答一次,N一般取2。
- 时间限制:超过最大延迟时间应答,一般取200ms。
- 延时应答时间不能超过超时重传时间。
④ 确认序号
TCP不一定对每个报文都应答,确认序号表示之前连续报文已收到。
4. 捎带应答
🧑💻 在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 “一发一收” 的. 意味着客户端给服务器说了 “How are you”, 服务器也会给客户端回一个 “Fine, thank you”;
那么这个时候 ACK 就可以搭顺风车, 和服务器回应的 “Fine, thank you” 一起回给客户端
① 报头应答
主机A给主机B发信息,主机B 返回 ACK+发给 A 的 data
② 捎带应答实现
主机 B 可以携带报头它也有序号也可以携带有效载荷,发送数据
主机B给主机A应答时,将ACK标记位设为1,并携带B给A的信息。
5. 拥塞控制
5.1 引入拥塞控制
TCP的可靠性机制:
- 包括超时重传、连接管理、丢包重传、按序到达、去重、滑动窗口和流量控制等。
- 这些策略主要针对端到端的问题,但网络本身也可能出现问题。
网络问题与重传决策:(与超时重传对比学习)
- 如果客户端发送1万个报文,而服务器只响应了一个ACK,这表明可能不是接收方的问题而是网络拥塞导致的大量丢失。
- 在这种情况下,不应该简单地进行大量的超时重传,因为这样会加重已经拥堵的网络状况。
因此虽然 TCP 有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题.
拥塞控制的重要性:
- TCP不仅考虑了双方主机的可靠性,还考虑了网络路径上的问题。
- 拥塞控制避免在网络故障时大量重传,从而减轻网络压力,等待网络恢复后再正常传输数据。
🧑💻 因此TCP 引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
- 慢启动通过先发送少量数据来探测当前网络状态,再根据反馈调整发送速率。
此处引入一个概念称为拥塞窗口
- 拥塞窗口是一个用于表示网络接收能力的数值。初始值设为1,每次收到ACK后增加1
- 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;
⭕拥塞窗口与滑动窗口的关系:
① 客户端:发送窗口
② 网络:拥塞窗口
③ 服务器:接收窗口(自己的接收能力)
发送窗口的最终上限由谁决定?
🧑💻 发送方的实际发送窗口大小 发送窗口 = min(rwnd, cwnd)。
- rwnd(接收窗口):由接收方通过 ACK 报文通告的剩余缓冲区大小。
- cwnd(拥塞窗口):由发送方根据网络拥塞状态动态调整。
💻 像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指:初始时慢, 但是增长速度非常快.
慢启动阈值:
- 初始阶段增长迅速(指数级),但当超过 慢启动阈值 时转为线性增长。
- 慢启动阈值在超时重传时 减半,并且拥塞窗口重新置为1。
慢开始: 先以小的窗口传输数据,主要是检测通信路径是否通畅。
扩大窗口①:以指数的形式来扩大窗口。
扩大窗口②:到达某个阈值,就开始线性扩大窗口
缩小窗口:
①:窗口直接缩小到0,再重复上述流程,
②:窗口缩小一半,然后线性的增长窗口。
💡动态调整与拥塞检测:
- 当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值;
- 在 每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回 1;
- 少量丢包触发超时重传;大量丢包则视为网络拥塞。
- 当 TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降
拥塞控制:归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又避免给网络造成太大压力的折中方案 ,旨在快速传输数据同时防止网络过载。
5.2 问题-- 慢启动何时结束?
慢启动不会无限进行,其增长会在达到一个特定阈值时停止,这个阈值称为慢启动门限(ssthresh)。
慢启动门限的定义:慢启动门限是一个状态变量,用于控制慢启动和拥塞避免算法的切换。
慢启动与拥塞避免的切换条件
- 当拥塞窗口(cwnd)小于ssthresh时,使用慢启动算法。
- 当cwnd大于或等于ssthresh时,使用拥塞避免算法。
5.3 拥塞避免算法
拥塞避免的触发条件
- 当cwnd超过
ssthresh
时,进入拥塞避免算法。
拥塞避免的ssthresh值通常设置为65535字节。
⭕ 拥塞避免的增长规则
- 每收到一个ACK,cwnd增加1/cwnd。
- 以线性增长代替慢启动的指数增长,增长速度放缓。
拥塞状况的识别
随着cwnd的增长,网络逐渐进入拥塞状态,出现丢包现象。
拥塞发生时的处理:重传机制(包括超时重传 和 快速重传)
超时重传的拥塞发生算法
- ssthresh设为cwnd/2
- cwnd重置为1
- 重新开始慢启动,数据流突然减少。
快速重传的拥塞发生算法
- cwnd设为原来的一半
- ssthresh设为更新后的cwnd
- 进入快速恢复算法
5.4 快速恢复算法
快速恢复的前提
- 快速重传和快速恢复算法通常同时使用。
- 认为网络状况不那么糟糕,因为还能收到3个重复ACK。
快速恢复的步骤
- cwnd设为ssthresh + 3
- 重传丢失的数据包
- 收到重复ACK时,cwnd增加1
- 收到新数据的ACK后,cwnd设为第一步中的ssthresh值,重新进入拥塞避免状态。
6. 面向字节流
💦 创建一个 TCP 的 socket, 同时在内核中创建一个 发送缓冲区 和 一个 接收缓冲区
1、调用 write
时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个 TCP 的数据包发出;
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
2、接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
3、然后应用程序可以调用 read
从接收缓冲区拿数据;
另一方面,TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据。这个概念叫做 全双工
💻 由于缓冲区的存在, TCP 程序的读和写不需要一一匹配,例如:
- 写 100 个字节数据时,可以调用一次 write 写 100 个字节,也可以调用 100 次write,每次写一个字节;
- 读 100 个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100 个字节,也可以一次 read 一个字节, 重复 100 次;
7. 粘包问题
首先要明确, 粘包问题中的 “包” , 是指的 应用层的数据包.
为什么会出现这样的情况?
- 在 TCP 的协议头中, 没有如同 UDP 一样的 “报文长度” 这样的字段, 但是有一个序号这样的字段.
- 站在传输层的角度, TCP 是一个一个报文过来的. 按照序号排好序放在缓冲区中.
- 站在应用层的角度, 看到的只是一串连续的字节数据.
- 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.
如何避免粘包问题❓
本质:明确两个包之间的边界.
- 对于定长的包, 保证每次都按固定大小读取即可; 例如上面的 Request 结构, 是固定大小的, 那么就从缓冲区从头开始按 sizeof(Request)依次读取即可;
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可);
💡 思考: 对于 UDP 协议来说, 是否也存在 “粘包问题” 呢
对于 UDP, 如果还没有上层交付数据, UDP 的报文长度仍然在. 同时, UDP 是一个一个把数据交付给应用层. 就有很明确的数据边界。
站在应用层的站在应用层的角度, 使用 UDP 的时候, 要么收到完整的 UDP 报文, 要么不收. 不会出现"半个"的情况。
8. TCP 异常情况
🎃进程终止:进程终止会释放文件描述符, 仍然可以发送 FIN. 和正常关闭没有什么区别.
🎃机器重启:和进程终止的情况相同.
🎃机器掉电/网线断开:接收端认为连接还在, 一旦接收端有写入操作,接收端发现连接已经不在了, 就会进行 reset. 即使没有写入操作, TCP 自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
另外, 应用层的某些协议, 也有一些这样的检测机制. 例如 HTTP 长连接中, 也会定期检测对方的状态. 例如 QQ, 在 QQ 断线之后, 也会定期尝试重新连接.
六、小结🔥
为什么 TCP 这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能.
可靠性:
校验和、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制
提高性能:
滑动窗口、快速重传、延迟应答、捎带应答
其他:
定时器(超时重传定时器, 保活定时器, TIME_WAIT 定时器等)
基于 TCP 应用层协议的如下:
HTTP、HTTPS、SSH、Telnet、FTP、SMTP
当然也包括我们自己写 TCP 程序时自定义的应用层协议;
TCP vs UDP
🦁 我们说了 TCP 是可靠连接, 那么是不是 TCP 一定就优于 UDP 呢? TCP 和 UDP 之间的优点和缺点, 不能简单, 绝对的进行比较。
- TCP 用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
- UDP 用于对高速传输和实时性要求较高的通信领域, 例如, 早期的 QQ, 视频传输等. 另外 UDP 可以用于广播;
👻 归根结底, TCP 和 UDP 都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定.
用 UDP 实现可靠传输(经典面试题)
参考 TCP 的可靠性机制, 在应用层实现类似的逻辑;
🧑💻 例如:
- 引入序列号, 保证数据顺序和完整性;
- 引入确认应答, 确保对端收到了数据;
- 引入超时重传, 如果隔一段时间没有应答, 就重发数据;
…
【★,°:.☆( ̄▽ ̄)/$:.°★ 】那么本篇到此就结束啦,如果有不懂 和 发现问题的小伙伴可以在评论区说出来哦,同时我还会继续更新关于【Linux】的内容,请持续关注我 !!