目录
1. TCP 协议特点
在使用 TCP socket api 进行网络编程前, 我们已经简单提到过 TCP 协议的特点:
- 有连接: 和客户端交互时需要先 accept
- 全双工: 任意一方既能读也能写
- 可靠传输
- 面向字节流: 通过 InputStream 和 OutputStream 来读写数据
其中, TCP 是如何保证可靠传输的, 我会在下面的博文中细细道来.
2. TCP 报文格式
TCP 报文的格式如下:
如上图所示, 前 6 行是 TCP 报文的报头部分. 其中, 前 5 行是 TCP 报文的固定部分, 每行表示 2 个字节, 所以固定部分的大小为 20 字节.
第 6 行是选项部分, 长度是可变的.
TCP 报文的最后一行是载荷部分, 即一个完整的应用层数据报, 承载了要传输的业务数据.
2.1 源端口/目的端口
记录源端口和目的端口是传输层的核心内容, 表示数据从哪个进程来, 要到哪个进程去.
2.2 4位首部长度
首部长度的大小为 4个 bit 位, 表示首部大小的范围是 0 - 15.
注意在首部长度这里, 不是使用 字节 为单位, 而是使用 "4字节为单位".
所以, 首部(报头)的大小范围为 0 - 60 字节.
2.3 选项
为啥报头的部分是可变的呢?? 报头 = 固定部分 + 选项
而选项(optional)部分, 是可选的, 可以存在, 也可以不存在.
由于选项部分是可变的, 所以导致报头部分的长度也是可变的.
而报头最大为 60 字节, 固定部分为 20 字节, 所以选项部分最大为 40 字节.
2.4 保留位
我们知道, UDP 存在一个很棘手的问题, 就是报文能携带的数据有限(最多 64kb), 而各方面的原因导致 UDP 又不能进行扩展, 所以 UDP 现在积重难返.
而 TCP 中保留位的设计, 就是为了防止出现传输数据有限的问题.
TCP 报头先预留一些 "保留位". 现在先不用, 只占个位置, 等以后需要的时候, 就可以随时使用.
2.5 16位校验和
这里的校验和的作用和 UDP 中校验和的作用是相同的, 都是为了验证数据在传输过程中是否发生了修改, 是否出现了错误.
2.6 6位标志位
这 6 个标志位是 TCP 中最核心的标志位, 在下面的博文中细讲.
目前, 我们只浅聊了一下 TCP 中的几个部分.
还有一些重要的部分如: 序号, 确认序号, 窗口大小, 紧急指针... 等等没有提到.
这些部分会在下文 TCP 核心机制中详细讲解~
3. TCP 核心机制一: 确认应答
TCP 具有可靠传输的特点.
可靠传输, 不是说 A 向 B 发送了一个消息, 就能保证 B 100%会收到, 要知道网络通信是非常复杂的, 传输的过程中可能出现各种各样的情况.
可靠传输是指, A 向 B 发送消息后, 会尽可能保证 B 收到消息, 当消息因为一些原因没有送达 B 时, 会进行重传操作.
而是否进行重传操作的前提是, A 能够知道 B 是否收到收到了消息.
这就是 TCP 的核心机制一: 确认应答.
当接收方收到消息后, 会给对方返回一个 应答报文(acknowledge, ack), 当发送方收到应答报文, 就可以确定接收方收到消息了.
举个例子:
高中时代, 相信大家都约过自己的女神吃饭吧~
要想约女神出来吃饭, 就需要给女神发消息:
当我向女神发送消息后, 女神就会返回一个 应答报文, 我接收到应答报文后, 就可以确定女神收到消息了. 反之, 如果我没有接收到应答报文, 就可以确定我给女神发的消息给发丢了, 女神没有收到我的消息.(这里假设女神收到我的消息后一定会做出应答~~)
3.1 先发后至
但是, 上述流程是存在明显缺陷的.
由于网络上的情况是错综复杂的, 连续发送的多条数据可能会出现 "后发先至" 的情况.
后发先至, 就是指, 后发送的数据, 比先发送的数据, 更早的到达接收方.
为什么会出现后发先至的情况呢? 因为在网络上进行数据传输时, 数据需要经过路由器/交换机来进行转发, 每一个路由器/交换机就相当于一个 "十字路口", 当出现"堵塞"(路由器/交换机处理的请求过多)时, 就可能导致后发先至的情况发生.
当我一次性向女神发送多条消息时, 由于后发先至, 就可能出现问题:
3.2 序号/确认序号
针对以上后发先至的情况, TCP 的处理方案, 就是给传输的数据(载荷中的数据)进行编号.(TCP 是面向字节流的, 每一个字节都有一个序列号)
而接收方就可以针对收到的序号的数据做出相应的应答, 即确认序号.
序号和确认序号都是 TCP 报头中的一部分.
有了序号后, 女神就可以明确针对我的哪条消息做出应答:
注意:
序号字段, 不管是什么数据包, 都会用到.
而确认序号字段, 只在应答报文中才会使用(只有 ack 为 1 时, 才会读取 确认序号字段的内容).
如何区分当前报文是应答报文呢?? 那6个标志位就起到作用了:
当 TCP 报头中6个标志位中的 ack 为 1 时, 表示当前报文为应答报文:
注意, 这里提到的应答报文, 并非响应.
从业务的角度出发, 发送的数据分为 请求/响应.
而此处的应答报文, 是和业务无关的.
上文所举女神的例子, 为了方便大家理解, 对女神的应答中添加了业务内容, 实际上, 严谨的应答如下:
上文仅仅简单说明序号可以解决 后发先至的问题, 但是具体是怎样解决的, 还涉及很多细节(缓冲区中对序号进行排序), 接下来继续讲解.
3.2.1 如何编排
TCP 是面向字节流的, 所以对数据进行编排时, 也是按照 "字节" 为单位进行编排的.
(注意, 只有载荷中的数据才会进行编号)
每一个字节的数据, 都会分配一个序号, 并且分配的序号都是连续递增的.
可以, 一个 TCP 报文会携带多个字节的数据, 意味着一个 TCP 报文中有多个编号, 那么问题来了: TCP 报头中的序号字段, 应该写哪个字节的编号呢??
答: 报头中的序号字段, 填写的是载荷部分中第一个字节的序号. (由于序号是连续递增的, 可以根据报头中报文长度的属性, 确定后续数据的编号)
并且, 应答报文中确认序号的字段, 填写的是载荷部分中最后一个字节的序号 +1.
(表示该序号之前的数据, 都确认收到了)
确认序号为啥要这样填写呢?? 我们可以这样理解:
3.2.2 排序
由于后发先至的情况, 编号大的数据可能比编号小的数据先达到接收方, 而接收方的 TCP 就可以根据序号对接收到的数据进行排序, 确保应用程序通过 socket api 读到的数据顺序是正确的.
(即使出现后发先至, TCP 也能保证接收方通过 InputStream read 到的数据, 和 发送方通过 OutputStream write 的数据顺序是一致的)
那其中 TCP 具体是怎样进行排序的呢??
- TCP 会在接收方这里安排一个 "接收缓冲区"(内存空间, 操作系统内核中的), 接收方通过 socket api 读到数据会先放到缓冲区中, 并且这些数据会按照序号排列好(序号小的在前面, 序号大的在后面), 当序号小的数据到了之后, read 方法才会解除阻塞去读取, 否则 read 就会阻塞等待.
显然, 这也是一个生产者消费者模型的场景, 发送方是生成者, 接收方是消费者, 缓冲区是阻塞队列.
综上, 即使数据在传输过程中出现了后发先至, 但是到达接收方后, 也会在接收缓冲区进行排序, 从而解决掉这个问题.
所以, 我们在基于 TCP 编程时, 完全不必担心数据顺序的问题. 如果是 UDP, 要实现拆包组包的工作, 就需要大量的代码逻辑来保证数据的顺序问题.
4. TCP 核心机制二: 超时重传
上文所的讲的 TCP 核心机制一(确认应答), 是在没有发生丢包的情况下, TCP 来保证可靠传输的重要手段.
而我们接下来讲的 核心机制二(超时重传), 是在发生丢包的情况下, TCP 保证可靠传输的重要手段.
4.1 丢包
丢包, 就是指 A 向 B 发送了一条消息, 到达等待时间的上限, A 还没有收到 B 的 ack, 此时, 就可以认为数据在传输过程中丢包了.
4.1.1 丢包的原因
还是那句话, 网络结构是非常复杂的. 当数据到达一个路由器/交换机进行转发时, 该路由器/交换机可能正在处理非常多的数据, 导致需要转发的数据量超出了本身转发能力的上限.
此时, 数据就会消耗更多的时间才能到达对方(类似于马路堵车), 当情况更加严重时(路由器/交换机 根本处理不过来), 就会丢弃该数据.
丢包, 是不能避免的客观现象, 而重传, 是有效避免丢包的手段.
为啥说重传能够有效避免丢包呢??
假设当前网络丢包率为 10%, 则数据发送成功的概率为 90%.
- 那么连发两个包都丢的概率为: 10% * 10% = 1%
- 连发两个包有一个成功的概率为: 1 - 1% = 99%(重传会大大增加成功概率)
4.2 超时重传
引入超时时间, 来判断是否发生丢包.
TCP 中, 判断是否超时的时间阈值, 并非固定不变的, 而是动态变化的.
假设 A 向 B 发送数据, 当前的超时时间的阈值为 T, 若到达 T 后, B 仍未做出应答, 那么此时就会延长这个阈值并同时进行重传操作.
若再次到达阈值后, B 仍未做出应答, 那么会再次延迟阈值, 并进行数据重传操作.
但阈值并非无休止的延长, 当超时次数到达一定次数/等待时间到达一定程度后, 就会认为当前网络有严重故障, 就会放弃传输这个数据, 不再进行重传操作.
为啥当超时次数到达一定次数后, 就不会进行重传了呢??
因为随着数据的重传, 数据成功到达对方的概率就会越来越高, 若多次重传还不成, 那么说明原始的丢包率是极大的, 也就意味着网络大概率是出现了严重故障.
此时, 就算继续重传, 也没啥意义了, 所以为了减少系统资源的消耗, 就会停止重传.
4.3 去重操作
我们上文说到, 若 A 向 B 发送了一条消息, 到达等待时间的上限, A 还没有收到 B 的 ack, 此时, 就可以认为数据在传输过程中丢包了.
但是, 这里的丢包存在两种情况:
- A 向 B 发送的数据丢了
- B 向 A 返回的 ack 丢了
此时, A 是无法区分当前到底是哪种情况的丢包, A 都会进行一个相同的操作: 重传数据.
如果是 A 向 B 发送的数据丢了, 那么重传是正确的.
但是, 如果是 B 向 A 返回的 ack 丢了, 说明 B 已经收到了 A 的数据, 如果 A 再向 B 进行重传, 那么 B 就会收到两份一样的数据, 如果是 A 发送的是扣款数据, 那问题可就大了....
所以, A 进行重传数据后, TCP 在内部进行了去重操作:
- 当重传的数据到达接收缓冲区后, 会根据自己的序号, 在缓冲区中找一下存不存在重复的序号, 如果存在, 则说明 A 之前发送的数据没有丢包, 直接丢弃重传的数据; 如果不存在, 则说明 A 第一次发送的数据给丢了, 把重传的数据放进缓存区即可.
5. 小结: 保证 TCP 可靠传输的关键机制
TCP 中两个最核心的机制, 保证了 TCP 的可靠传输:
- 确认应答
- 超时重传
6. TCP 核心机制三: 连接管理
我们知道, TCP 一个关键的特性是: 有连接. 这里的连接, 是一种虚拟的, 抽象的, 逻辑上的连接.
而 TCP 的有连接, 就是指通信双方, 各自保存了对端的信息.
接下来就聊一聊 TCP 第三个核心机制: 连接管理.
连接管理, 分为以下两种:
- 建立连接("三次握手" 完成)
- 断开连接("四次挥手" 完成)
6.1 建立连接 [整个网络部分, 最高频面试题 ★★★]
TCP 建立连接, 是通过 "三次握手" 的方式完成的.
6.1.1 什么是握手
握手(handshake), 这里的握手和我们生活中的握手是一个意思, 并没有实际的业务, 只是用来 "打个招呼" 而已.
比如之前 HTTPS 博文中, 获取证书, 验证证书, 加密对称密钥, 传输对称密钥,... 这些不携带业务数据的操作, 就是 SSL 协议的 "握手流程".
TCP 这里也是一样, A 向 B 发送了一个不携带业务的数据, 通过这个数据和 B 打了个招呼, 建立起连接, 这就是 TCP 的握手.
6.1.2 TCP "三次握手"
建立连接的过程, 本质就是让通信双方保存一下对方的关键信息, 而这个过程, 就是由 "三次握手" 完成的.
具体流程如下(A 要和 B 建立连接):
1. A 首先会向 B 发送一个自己的同步报文(synchronized).
synchronized 是同步的意思, "同步" 这个术语, 在计算机中有多种含义.
在多线程加锁那里, 同步 表示 "互斥".
而在 TCP 这里, 同步 表示 "数据上的同步", 例如: A 要和 B 建立连接, 就会对 B 说, 接下来我要和你建立连接, 就需要你把我的关键信息保存好, 同时, 你也需要把你的关键信息同步发给我.(建立连接的本质, 就是保存对端的关键信息).
当 6 位标志位中的 SYN 这一位为 1 时, 表示当前报文为同步报文:
2. B 返回一个应答报文, 表示自己收到了.
3. 同时, B 也向 A 发送一个自己的同步报文(syn), 让 A 也存一下自己的关键信息.
4. A 返回一个应答报文, 表示自己收到了.
到这里, 同学就有疑问了, 不是三次握手吗?? 咋变成四次了??
其实, B 发出的应答报文 ack 和同步报文 syn 可以合并为一次 "握手". 因为 ack 和 syn 都是操作系统内核负责管理的, 和用户代码无关, 可以保证是同一时机的, 能够合并为一次的发送操作. 所以正确的 "三次握手" 流程图如下:
我们知道, 在进行网络传输的过程中, 数据是需要经过封装和分用的, 合并操作, 就能够减少封装分用的次数, 有效提高数据传输的速率.
注意:
同步报文 syn 和应答报文 ack 中是不携带载荷的, 因为建立连接时, 对端保存的关键信息, 是指 ip 和端口号这样的信息, 这些信息在 TCP 和 IP 报头中就有, 没有在载荷中.
所以不需要对 syn 和 ack(非业务数据)进行加密.
(一定是客户端主动发起的 syn).
6.1.3 三次握手中 TCP 的状态转换
在 TCP 三次握手中, 最重要的两个状态如下:
- LISTEN: 启动服务器时(new ServerSocket)的时候, 就会进入的状态. 表示服务器已经准备好了, 随时等待客户端发起连接.(手机信号良好, 随时可以有人给我打电话)
- ESTABLISHED: 已连接的, 表示客户端和服务器已经建立好连接, 接下来可以传输业务数据, 进行信息的交互了.(已经接通电话, 可以进行说话了)
6.1.4 TCP 三次握手的作用
TCP 为啥需要三次握手, 三次握手到底有啥用??
6.1.4.1 作用一
三次握手, 相当于 "投石问路", 初步探测网络的通信链路是否畅通.
(网络畅通是进行可靠传输的前提条件).
6.1.4.2 作用二
验证通信双方的发送能力和接收能力是否正常.
举个例子:
比如我和好哥们一起开黑打游戏, 因为我们在游戏中需要进行交流沟通, 所以在游戏开始前, 我们需要先验证双方的通信设备(耳机和麦克风)是否在问题.
耳机代表的就是接收能力. 麦克风代表的就是发送能力.
1. 我会先说一声 "喵".
2. 我哥们听见了我的 "喵", 证明我的麦克风正常, 他的耳机也正常. 此时他回复一个 "喵喵".
3. 我听见了他的 "喵喵", 潜台词就是他已经听到我的 "喵" 了(并做出回应), 我就知道我的麦克风正常, 他的耳机正常. 并且我听到他的回应, 说明我的耳机正常, 他的麦克风正常. 此时, 我已经可以确定我俩的四台设备全部正常了, 但是他还不知道, 所以我需要再次做出回应 "喵喵喵~".
我哥们听见我的 "喵喵喵" 后, 潜台词就是 "我收到了他的 "喵喵" ", 他就可以确定, 他的麦克风正常, 我的耳机正常. 此时, 他也可以确定四台设备全部正常了, 也就无需再做出回应了.
(面试中, 可以把这张图给面试官画出来, 讲清楚每次握手能够确定哪些设备是正常的.)
面试题: 为啥是 三次握手, 两次不行吗?? 四次不行吗??
- 如果只握手两次(没有最后一次的 "喵喵喵"), 那么通信双方是无法都得知设备是正常的, 只有客户端(主动建立连接的一边)才能得出, 但是服务器那边, 是无法得知的. 如果只有一方得知设备能够正常工作, 那么是无法保证可靠性传输的(还存在很多不确定因素, 必须保证双方都知道). 所以, 只握手两次, 不足以完成设备的验证过程.
- 如果握手四次, 可以, 但没必要. 将中间服务器一次发送的 ack + syn 拆成两次, 就是 4 次握手了. 但是, 这两次是能合并为一次的, 合并后能够提高效率.
综上, 建立连接操作, 只有 "三次握手", 才能使通信双方都确定对方的发送能力和接收能力均是正常的. 三次握手是最合适的策略, 两次太少, 四次又太多了.
6.1.4.3 作用三
在 三次握手 的过程中, 可以协商一些关键信息.
其中, TCP 要协商的一个非常关键的信息就是, 通信过程中, 序号从几开始.
上文说到, 在 TCP 报文中, 序号是连续递增的, 但是序号一般不从 0 开始, 此外, 两次不同的连接, 初始序号都是不同的, 并且往往差别很大.
在建立连接的三次握手的过程中, 就会协商好每次连接的初始序号是多少.
为啥不同的连接, 初始序号需要差别很大呢??
这是因为, 在第二次连接中, 是可能会收到第一次的连接中发出的数据的(数据可能因为一些网络上的原因导致 "迷路"), 而第二次的连接, 肯定是不应该处理这个数据的.
(两个连接的端口是相同的, 但不是同一个程序, 比如我们写的 Spring 代码, 写一点, 运行一下, 每次运行都是不同的程序, 但端口号是相同的).
当第二个连接收到数据后, 就可以根据初始序号进行区分, 若这个数据的序号和当前连接的数据的初始序号相差甚远, 就可以认为这个数据不是本次连接的数据, 就可以丢弃掉.
6.1.4.4 总结
TCP 三次握手的作用如下:
-
投石问路, 确定当前的通信链路是否畅通.
-
验证通信双方(发送方和接收方)的发送能力和接受能力, 是否正常.
-
协商关键数据.
注意:
保障 TCP 可靠传输的重要机制是: 确认应答 + 超时重传. 并非 三次握手.
三次握手只是在最开始建立连接的时候进行的, 和业务数据无关.
后续业务数据的可靠传输, 靠的是确认应答+超时重传. 虽然三次握手能够起到投石问路, 验证发送接收数据能力的作用, 虽然这些作用和可靠性相关, 但是仔细品一品, 三次握手也是建立在 确认应答+超时重传的基础上的.
6.2 断开连接 [高频面试题 ★★★]
6.2.1 TCP "四次挥手"
断开连接的操作, 需要四次挥手来完成:
- A 向 B 发送 fin 结束报文.
- B 向 A 返回一个 ack 应答报文.
- B 也向 A 发送一个 fin 结束报文.
- A 向 B 返回一个 ack 应答报文.
当 6位标志位中的 FIN 位为 1 时, 表示当前报文为结束报文, 表示要结束和对方的连接, 告知对方: 我要把你的关键信息删除了~~ (断开连接, 需要双方各自给对方发 fin. 但是, 单方面删除也是可以的, 后面讲).
FIN 结束报文, 并非由操作系统内核负责的, 是应用程序调用到 socket.close 方法(或者进程结束)时才会触发.
为啥是四次挥手呢?? 中间 B 返回的 ack 和 fin 能否合并为一次呢??
答: 如能(有时候能, 有时候不能)!!
为啥不能?? ---- 因为执行 ack 和 fin 的时机, 很可能是不同的!! 原因如下:
- ack 是操作系统内核负负责的(syn 也是), 一旦收到信息, 就会立即返回 ack.
- fin 是代码调用了 socket.close 或者结束进程才会触发.
而当对方延时应答时, ack 和 fin 是可以合并的.
注意:
三次挥手, 一定是客户端主动发起 syn 的.(第一次握手, 一定是客户端主动的)
四次握手, 客户端和服务器都可以主动发起 fin.(看谁先 close/结束进程), 但是从实践角度上看, 客户端主动断开连接的可能性更大.
6.2.2 四次挥手中 TCP 的状态转换
四次挥手中, 最重要的两个状态如下:
- CLOSE_WAIT: 被动发起 fin 的一方, 才会进入 CLOSE_WAIT
- TIME_WAIT: 主动发起 fin 的一方, 才会进入 TIME_WAIT
6.2.2.1 CLOSE_WAIT
当被动方, 接收到主动方发送的 fin 后, 就会进入 CLOSE_WAIT 状态, 表示等待被动方发送第二个 fin, 即等待被动方的应用程序代码调用 close 方法.
此时, 若被动方调用 close 方法, 就相当于发送了第二个 fin.
被动方接收到主动方的第一个 fin, 到被动方发送第二个 fin(调用 close), 这中间可能存在其他逻辑代码, 所以 ack 和 fin 可能不是同一时机的, 所以不能进行合并:
但是, CLOSE_WAIT 存在的时间是很短的, 正常来说是看不到这个状态的. 因为当被动房收到主动方的 fin 后, 就会尽快的调用 close 返回第二个 fin, 尽快的结束连接.
所以在开发中, 如果我们看到了 CLOSE_WAIT 这个状态, 意味着代码大概率有 bug, 就需要考虑是不是代码没有执行到 close.
若主动方 A 发送 fin 后, 等待了很久, 被动方 B 始终不发送 fin(不调用 close), 那么 A 就会主动释放连接, 进行单方面的删除.
B 这边, 由于 A 已经把自己的信息删了, 所以即使不释放连接, 也无法进行正常的通信了.
6.2.2.2 TIME_WAIT
在网络传输的过程中, 是随时可能出现丢包的. "三次握手" 和 "四次挥手" 也是一样, 也是会出现丢包的.(丢包是网络传输中, 不可避免的客观现象).
TIME_WAIT, 就是为最后一次的 ack 来托底的. 是主动方判断最后一次 ack 是否发生丢包, 进行等待时的状态.
为啥只是给最后一次的 ack 丢包进行托底呢?? 其他的数据丢包不需要托底吗??
答案是不需要的, 原因如下:
- 若主动方 A 第一次的 fin 出现丢包, 那么 A 就不能在规定时间内拿到 B 的 ack, 此时就会触发超时重传, A 会对 fin 进行重传操作.(无需托底)
- 如果 B 发送的 ack (第一个 ack)丢了, A 仍会再次重传, B 也会重新发送 ack.(无需托底)
- 如果 B 发送的 fin (第二个 fin) 丢了, 同样 B 不会在规定时间内拿到 A 的 ack, B 会再次重传.(无需托底)
以上为前三次的挥手操作, 若数据丢包, 重传均可解决, 无需托底.
但是, A 最后一次返回的 ack, 是需要进行托底的.
当 A 收到 B 的第二次 fin 后, 会返回 ack , 若返回 ack 后, A 直接释放连接, 就会出现问题:
- 若 A 返回的 ack 发生丢包, 则 B 没有在规定时间内收到 A 的 ack, 就会重传 fin, 此时 A 已经释放连接了, 就无人处理 B 重传的 fin 了, 当然会出现问题~~
所以, 在 A 返回最后一次 ack 后, 需要多等一会, 确定 B 是否会重传 fin(发送的 ack 可能会丢包), 而 A 在等待的这一段时间, 就处于 TIME_WAIT 状态.
那么, A 需要等多久呢??(TIME_WAIT 状态会持续多久呢??)
A 会等待 2 * MSL 的时间.
- MSL: 网络上任何两个节点传输过程中, 消耗的最大时间.
超过了这个时间, 如果还没有收到 B 重传的 fin, 那么 A 就可以确定 ack 没有丢包, 此时就可以释放连接了.
MSL, 是通常是 min 级别的. 而超时重传的时间阈值, 是 ms 级别的.
也就是说, A 等待的时间是一个非常充裕的时间, 足以判断 ack 是否丢包, B 是否对 fin 进行了重传.
7. TCP 核心机制四: 滑动窗口
TCP 有一个核心特性: 可靠传输.
要保证可靠传输, 那是要付出效率上的代价的.
我们上面说到, 保证可靠传输, 前提是需要 确认应答. 当 A 向 B 发送数据后, 需要收到 B 的 ack 后, 才可以发送下一个数据.
(发送 N 个数据, 需要等待 N 个 ack)
很明显, 这样一发一收的形式, 效率是很低的.
7.1 批量传输
为了解决上述效率低的问题, TCP 引入了滑动窗口. 能够对在这个窗口中的数据, 进行批量传输, 全部一次发送完毕, 将等待多组 ack 的时间, 重叠为等待一组 ack.
滑动窗口, 其中的窗口大小, 就是一次能发送的最大的数据量(无需等待).
注意,
窗口大小越大, 一次批量发送的数据就越多, 效率就越高.
但是窗口也不能无限大, 否则就相当于不需要收到 ack, 就不是可靠传输了(影响可靠性).
其中的滑动, 就是指发送完这次的数据后, 每收到一个 ack, 窗口就会向后移动, 就会发送下一条数据.
而这些数据返回的 ack 是很多很快的(多组数据的 ack), 所以从宏观上看, 就像一个滑动的窗口.
注意:
不同数据返回的 ack 的顺序是不能确定的, 可能确认序号大的 ack 比确认序号小的 ack 更先返回, 但是没关系, 窗口直接滑动到大序号的位置即可.
因为, 确认序号的含义是: 当前确认序号之前的数据, 已经全部确认接收了. 所以当我们收到了一个大的确认序号的 ack, 说明这个确认序号之前的数据, 已经全部确认收到了.
举个例子:
- 收到了 3001 ack, 即使 2001 ack 没有收到也没关系, 直接向右滑到 3001 即可. 因为 3001 ack 覆盖了 2001 ack 要传达的含义.
TCP 要保证可靠性, 效率必然是会受到折损的.
滑动窗口是在保证可靠传输的前提下引入的, 能够让折损的效率降低, 但不可能比没有可靠性的 UDP 的效率更高的.
7.2 快速重传
快速重传 是 超时重传 的应用于滑动窗口下的变种操作.
还是那句话, 丢包是网络传输中不可避免的客观现象.
所以, 在滑动窗口中, 自然也避免不了丢包.
丢包就需要进行重传, 怎样进行重传呢?? 需要对丢包的情况进行讨论:
- 数据成功到达, 但是返回的 ack 被丢了.
- 发送的数据直接就丢了.(应用到快速重传)
1. 数据成功到达, 但是返回的 ack 被丢了.
在这种情况下, 其实我们是不用做任何的处理的, 因为数据已经到达了, 即使前一个 ack 丢了, 后面返回的 ack 也会覆盖前面丢的 ack 要传达的含义的.
2. 发送的数据直接就丢了.
当 A 向 B 发送的数据被丢包时, 此时就需要 A 进行重传了. 此时, B 就需要提醒 A , 让 A 重传一下被丢包的数据.
如何提醒呢?? B 通过重复索要丢包的数据(重复发送相同的 ack)来提醒.
具体过程如下:
我们知道, 确认序号的含义是指, 当前序号前所有的数据, 全部确认接收. 所以, 当 A 使用滑动窗口批量发送的数据存在丢包时, B 只会返回丢包的数据的 ack, 即使后面的数据收到了, 也不会返回后面数据的 ack(可以理解为 B 向 A 重复索要丢包的数据).
当 B 重复发送了好几次相同的 ack 后, A 才会进行重传操作(重传前 A 都会向 B 发送新的数据, B 虽然接收到了, 但是仍然只返回丢包的数据的 ack, 索要丢包的数据).
A 重传后, B 接收到了之前被丢包的数据, 随后, B 会直接返回刚才 A 新发送的所有数据的 ack.(被丢包的数据相当于一个缺失的拼图, 通过重传, 一下子就把拼图补上了)
上述的重传操作, 就称为快速重传, 哪个数据丢包了, 就重传谁, 不需要对接收到的数据重传, 当重传完丢包的数据后, 对方也会快速应答之前接收到, 但是没有应答的数据.(整个过程是很快的)
既然有超时重传, 也有快速重传, 那么 TCP 在传输时到底要采用哪种机制?? 这两种机制矛盾吗??
答: 这两种重传机制, 是针对不同情况下的重传机制.
- 当发送的数据很少时(数据量不足以构成滑动窗口批量传输的形式), 使用超时重传.
- 当发送的数据量多, 能够使用滑动窗口来批量传输时, 使用快速重传.
8. TCP 核心机制五: 流量控制
8.1 窗口大小
上文说到, 滑动窗口的窗口越大, 效率就越高.
但是, 窗口是不能无限增大的, 因为接收方的数据处理能力是有上限的.(接收方处理数据的能力和代码实现有关)
发送方把窗口开的很大, 虽然一次能发送很多的数据, 但是接收方处理不了这么多数据, 发送方单方面搞这么快, 也是没用的.(这样就是为了效率, 牺牲了可靠性)
举个例子,
发送方 A 发送的数据, 会放到接收方 B 的接受缓冲区中, 我们可以把接收缓冲区当做一个水池.
A 每发送一个数据, 就相当于给水池蓄水; B 每读取一个数据, 就相当于给水池放水.
当水池已经满了的时候, 发送方再进行蓄水, 水就会溢出, 数据就会丢包.
而流量控制机制, 就是让接收方根据自身数据处理的速度, 去限制/调节发送方数据发送的速度.
接收方如何去限制发送方呢??
接收方可以设置其 应答报文 ack 中的 "窗口大小" 这一属性, 将这个属性值设置为其接收缓冲区剩余空间的大小, 发送方就会根据这个值, 重新设计其滑动窗口的大小.
所以, 发送方滑动窗口的大小会根据接收方接收缓冲区剩余空间的大小动态变换.
这里对缓冲区和缓存的概念进行一下区分:
相同点:
- 两者都可以提升 cpu 的效率, 但本质不同.
不同点:
- 缓冲区: 减少低效操作的次数, 将多次低效操作, 合并成一次. 既可以用来读数据, 也可以用来写数据. (例如: 嗑瓜子时, 我们会将磕下的瓜子皮放到手里, 手中的瓜子皮攒到一定量后才会扔垃圾桶里, 而不是磕一个瓜子皮就去垃圾桶扔一次. 此时, 我们的手就是一个缓冲区)
- 缓存: 也是减少低效操作的次数, 把之前读的数据, 或者之前读的数据的附近的数据, 放到一个距离 cpu 更近的地方, 方便 cpu 的下次读(距离近了, 读的速度就快了), 只能用来读, 和写没关系. (例如: 坐高铁时, 需要频繁的检查身份证等证件, 我们就可以将这些证件从行李箱中拿出来, 放到衣服的口袋中, 等下次检查时, 我们就可以直接从口袋中拿出证件, 而不用再从行李箱中拿了. 此时, 我们衣服的口袋就是一个缓存)
但, "滑动窗口" 这一属性的大小是 16 个 bit 位, 难道窗口的大小最多只能为 64kb 吗??
其实不然~~
在 TCP 报头的 选项 部分中, 存在一个特殊的属性 --- 窗口扩展因子.
真正设计出来的窗口为: 窗口大小属性值 << 窗口扩展因子
也就是说, 当窗口大小的属性值为 64kb, 窗口扩展因子为 2 时, 真实的窗口大小为 64kb << 2 即 256kb.
8.2 窗口探测
上文说到, 发送方滑动窗口的大小是跟随接收缓冲区的大小而改变的.
但是, 存在一种情况: 接收方接收数据后, 一直没有对数据进行读取操作. 缓冲区就会越来越满, 当接收缓冲区满了之后, 返回 ack 的窗口大小也就为 0 了.
那么此时, 发送方 A 的滑动窗口大小也只能为 0 了, 但是, 一旦 A 的滑动窗口为 0 , 就意味着发送方不能继续发送数据了, 也就意味着无法触发新的 ack, 无法知道 B 的接收缓冲区剩余容量为多少了. (就算 B 读走了数据, 接收缓冲区有空余了, A 也无法得知).
此时, 就会进行窗口探测. 当 A 窗口为 0, 无法触发 ack 时, 就会发送一个窗口探测包对 B 的接收缓冲区进行探测.
这个窗口探测包是不携带载荷数据的, 只是对 B 的接收缓冲区的剩余容量进行探测(实时探测).
9. TCP 核心机制六: 拥塞控制
上文提到的流量控制, 是根据接收方的数据处理速度, 来对发送方的数据发送速度进行的限制, 并且是根据剩余接收缓冲区的大小, 来定量限制发送方.
而在网络传输中, 不只是接收方的接收能力会影响可靠性, 两台设备之间数据链路的转发能力, 也是会影响数据传输的可靠性的.(发送方发送的数据很快很多, 但路由器的转发能力有限, 也是会丢包的).
拥塞控制, 就是依据传输链路的转发能力, 来对发送方进行限制的.
但是, 由于通信双方之间, 存在错综复杂的路由器/交换机, 我们是无法确定中间某台设备的转发能力的.
我们就可以把整个 "通信链路" 视为一个整体, 通过 "做实验的方式", 找到一个合适的窗口大小, 限制发送方的数据发送速度为一个合适的值.
所谓 "做实验的方法", 就是 "水多加面, 面多加水":
- 先按照一个小的窗口(小的速度)发送数据.
- 如果不丢包, 慢慢的加大速度.
- 如果丢包, 再减小速度.
- 不丢包了, 再加大速度.
- .....
上述就是拥塞控制的核心思想, 以实时改变窗口大小的方式, 来使数据发送速度达到一个动态平衡的值.
拥塞控制的工作过程如下图所示:
大体流程:
- 慢启动 (初始值很小)
- 指数增长 (使窗口快速变大)
- 线性增长 (到达阈值后, 即使不丢包, 也会变为线性增长, 不让窗口增长的那么快)
- 丢包
- 窗口变成较小值
这里有个问题:
流量控制和拥塞控制都能限制发送方窗口的大小, 那发送方具体窗口的大小, 到底应该按照哪个值来设计呢??
答: 两个值, 哪个值小, 哪个说了算.(木桶原理, 取决于最短的那个板子).
10. TCP 核心机制七: 延时应答
延时应答的机制, 依然是在保证 TCP 可靠性的前提下, 提升传输效率的一种机制. (降低可靠传输带来的效率损耗).
上文提到, ack 应答报文, 是由操作系统内核控制的, 默认情况下, 在接收方收到信息的第一时间, 就会返回 ack. 但是, 可以通过延时返回 ack 的形式来提高传输效率.
流量控制机制, 是根据接收缓冲区中剩余空间的大小来控制发送方的窗口大小的.
如果接收方收到发送方的数据后, 立即返回 ack, 那么此时接收方的应用程序还未在接收缓冲区中读取数据, 返回的窗口大小也是比较小的.
但是, 如果引入延时应答, 那么应用程序就可以利用延时时间, 来读取消耗掉接收缓冲区中的一部分数据. 后续返回 ack 时, 返回的就是一个更大的窗口大小了.(应用程序利用延时时间, 赶紧消费掉队列中的数据, 在应用程序能够处理的限度下, 尽可能的增加窗口的大小)
窗口越大, 网络吞吐量就越大, 传输效率就越高.
也就是说, 返回的窗口大小, 取决于接收方应用程序的数据处理能力.
但是, 延时应答也并非会 100% 的提高效率, 当应用程序在延时期间中, 收到了其他的数据, 那么接收缓冲区中的剩余容量就会更少, 返回的窗口大小也就更小了.(虽然有这种的情况, 但是通常来看, 延时应答是能够提升一定的效率的)
引入延时应答, 并非对所有的包都进行延时应答:
- 数量限制: 每隔 N 个包就应答⼀次 (传输数据密集时, 采用数量限制)
- 时间限制: 超过最大延迟时间就应答⼀次 (传输数据稀疏时, 采用时间限制)
其中, 隔多少包, 隔多少时间, 这样的参数都是可以根据实际情况来调整的.
注意, 延时应答不会因为 ack 少了而影响可靠性, 因为根据确认序号, 后一个 ack 涵盖了前一个 ack 的含义.
11. TCP 核心机制八: 捎带应答
捎带应答, 是基于 TCP 延时应答机制的基础上, 进一步为提升效率而引入的.(这里的提高效率依旧不是真的提高效率, 而是降低可靠性对效率带来的折扣)
捎带应答, 就是在返回业务数据的时候, 捎带上之前没返回的 ack, 将业务数据和 ack 合并为一个数据包, 一同返回.
任何一个数据包, 都会经过封装分用的过程(即使是 ack), 将两个数据包合并为一个, 只需一次封装分用即可.
一次的封装分用, 显然是比两次更快, 效率更高的~
为什么会有上次没返回的 ack 呢?? 这就是延时应答体现的, 我们可以将 ack 的返回往后延长一段时间, 当恰好要返回响应数据(业务数据)时, 就可以把这个 ack 代入到响应数据报中, 将响应和 ack 合并为一个数据包, 一同进行返回.
并且, 合并为 1 个包, 是不影响响应数据的传输的:
- ack 不携带业务数据, 其中的有效信息(ack: 1, 确认序号, 窗口大小....)等等, 都是在报头中的.
- 响应数据包中的业务数据, 是在载荷中的.
所以, 引入捎带应答后, 就可以将断开连接时的 四次挥手 合并为 三次挥手(对被动方的 ack 进行延时, 与 fin 合并为一个报文一同返回).
注意, 建立连接时, 三次挥手中的 ack + syn 本来就是同一个报文(syn 和 ack 都是由操作系统内核进行返回的, 是同一时机), 所以 三次挥手中的 ack + syn 不是捎带应答.
捎带应答, 本质上也是 "懒汉" 思想的延伸: 延时返回 ack, 等其他数据要返回时, 再带上 ack 一同返回.
举个例子,
我是一个非常懒的人, 尤其放假在家时~~
当我想喝水时, 我懒的起来去接水喝, 于是, 等我上厕所时, 再顺便去喝个水.
12. TCP 核心机制九: 面向字节流
大家都知道, TCP 协议是面向字节流的, 通过 InputStream 和 OutputStream 来进行传输.
虽然 TCP 字节流的方式在传输数据时更加灵活, 但是也带来了粘包问题.
12.1 粘包问题
什么是粘包?
举个例子, 以前过年时, 家里都会蒸粘豆包, 蒸之前, 每个豆包都是独立完整的, 但是蒸好掀开锅后, 我们就会发现每个粘豆包之间就好像被粘住了一样, 看不出豆包和豆包之间的边界了.
TCP 中的粘包, 和粘豆包的粘包是一样的, 只不过粘的是 "应用层数据包".
TCP 的粘包问题: 由于 TCP 是以字节流的方式进行传输的, 所以很容易混淆包和包之间的边界, 从而使得接收方无法区分从哪到哪是一个完整的应用层数据包.
UDP 就不存在这样的问题, UDP 是面向数据报的, 每一个 UDP 数据包都承载了一个完整的应用层数据包, 接收方每次 receive 得到的就是一个完整的应用层数据报.
12.2 如何解决
TCP 的粘包问题, 站在 TCP 层面上, 是无解的, 只能在应用层上来解决. 即, 在制定应用层协议时, 定义好包与包之间的边界(自定义应用层协议, 做的事情就是这个).
在应用层确定包与包之间的边界时, 通常有以下两种方式:
- 约定好包之间的分隔符(包的结束标记). 比如, 在写回显服务器时, 通过 \n 作为包的结束标记.
- 约定好每个包的长度. 比如, 约定好每个包开头的四字节, 表示数据包一共多长.
以上两种方案, 在 HTTP 协议中均有体现:
- GET 请求, 没有 body, 使用空行作为一个 HTTP 数据报的结束标记 => 约定好包之间的分隔符(包的结束标记)
- POST 请求, 有 body, 通过 header 中的 Content-Length 表示 body 的长度 => 约定好每个包的长度
13. TCP 核心机制十: 异常情况的处理
在通信过程中, 是会遇见很多的异常情况的:
- 某个进程崩溃
- 主机关机(正常关机)
- 主机掉电(异常关机)
- 网线断开
TCP 在面对这些异常情况时, 均进行了相应处理来保证可靠传输.
13.1 某个进程崩溃
进程崩溃, 其实和进程主动结束, 没有本质的区别. GC 都会对文件描述符表中的每个资源进行回收, 相当于调用 socket.close 方法, 也就是发送 fin, 进而触发四次挥手来断开连接(进程没了, 但是 TCP 的连接还在, 四次挥手仍然可以正常进行).
四次挥手, 并不依赖进程, 只要连接还在, 就可以通过连接, 在内核中完成四次挥手的过程.(相较于进程的结束, TCP 连接的释放时机, 会更晚一些)
也就是说, 进程崩溃就相当于正常发送 fin, 会正常四次挥手来断开连接.
13.2 主机关机(正常关机)
这里的主机关机是指, 连接未断开, 而一方的主机进行了关机操作的情况.
主机 A 进行关机, 本质上还是会结束掉电脑上的所有进程, 进而释放文件描述符表中的资源, 就相当于调用 close 方法发送 fin.
关机也是需要一定时间的, 若在这个时间内, B 返回了 fin, A 收到 B 的 fin 并成功返回 ack, 那么就正常的完成了四次挥手.(情况一)
但是, 如果 B 返回的 fin 太慢, 导致 A 已经关机完毕了(即无法处理 B 的 fin), 该如何呢??(情况二)
- B fin 发送的时机太晚, 导致 A 已经关机完毕, 那么 B 的 fin 就不会有 ack. B 就会进行重传 fin, B 多次重传后, 仍然没有 ack, 就会认为 A 那边出现了严重问题. 那么, B 就会主动释放连接.
最终, B 也会和 A 断开连接.
13.3 主机掉电
主机掉电, 通常是针对于 台式机 "拔电源" 这样的情况的.
主机掉电, 分为以下两种情况:
- 接收方掉电
- 发送方掉电
13.3.1 接收方掉电
假设 A (发送方) 源源不断的向 B (接收方) 发送数据.
若 B 突然掉电后, 那么 A 就不会收到 ack, 此时 A 就会触发超时重传.
由于 B 已经掉电, 所以 A 重传也不会收到 ack. 当重传达到一定的次数后, A 就会触发 "重置 TCP 连接", 向 B 发送一个 RST 复位报文.
(当标志位中的 rst 为 1 时, 表示该报文为复位报文, 表示重新建立连接)
但 A 发送的复位报文, 仍然是没有 ack 的, 此时 A 就会单方面释放连接.
(A 尝试重新建立连接, 依然没有效果, 就会主动释放连接)
13.3.2 发送方掉电
假设 A (发送方) 源源不断的向 B (接收方) 发送数据.
若 A 突然掉电后, 那么 B 此时是无法判断 A 是单纯的停止发送数据还是挂了, 只能进行等待.
但 B 不会无限的等, 当 B 等待一定时间后, 就会发送一个特殊的报文, "心跳包", 来验证对方是否挂了.
B 就会通过心跳包得知 A 没有心跳, 会发送 rst 尝试重新连接, 显然, 依旧没有 ack, 此时, B 就会单方面释放连接.
"心跳包", 不卸载载荷(业务数据), 只是为了触发 ack, 探测对方是否还在.
心跳包是周期性的, 每隔一段时间就会发送一个 "心跳包",
- 若发现对方有心跳(对方返回 ack), 那就继续等待.
- 若发现对方没有心跳(对方没有返回 ack), 就会发送 rst 尝试重新建立连接, 若还是没有 ack, 就会单方面释放连接.
通过 "心跳包" 来验证对方是否存活的方式, 称为 "保活机制".
在分布式系统中, 心跳包的思想方法, 是非常广泛使用的.
虽然 TCP 内置了心跳包, 但是由于 TCP 心跳包的周期太长(min 级别), 而在开发中, 为了在 ms 级别就能够发现对端是否出现异常, 往往都会在应用层重新实现心跳包.
13.4 网线断开
网线断开, 本质上和主机掉电是一样的, 最终也都能释放掉连接资源:
14. TCP 其他字段
到这里为止, TCP 的十个关键机制已经聊完了, 接下来说一说上文未涉及的 TCP 报文中的其他字段. (了解即可)
14.1 URG - 紧急指针位
6个标志位中的 URG, 表示紧急指针位.
上文说到, 发送方和接收方, 都是按照序号顺序来发送和接收数据的, 而紧急指针, 就相当于 "插队". 可以设置 16位紧急指针 为紧急序号, 读取时就可以直接跳过前面序号的数据, 直接从指定的序号开始读取.
14.2 PSH - 催促标志位
6个标志位中的 PSH, 表示催促标准位.
发送方给接收方发送的数据中带有这个标志位, 接收方就会尽快的将这个数据读取到应用程序中.
15. TCP 与 UDP
到这里, TCP 和 UDP 的知识已经全部讲解完毕了, 再稍微进行一下对比:
- TCP 可靠传输(大部分场景, 都优先使用 TCP, 如: HTTP, 浏览器/app 访问服务器)
- UDP 效率更高(对于性能要求高, 可靠性要求不高的场景下使用 UDP, 如: 机房内部)
经典面试题: 如何基于 UDP, 来实现可靠传输??
--- 把 TCP 保障可靠传输的机制说一遍即可(往 TCP 上套), 即确认应答(里面细节说一说) + 超时重传(细节点说一说)
END