TCP 协议详解
概述
本文基于 TCP 协议报文格式深入讲解,学习它的三次握手、四次挥手、滑动窗口等机制,帮助理解底层通信原理。
![图片]
![图片]
一、TCP 协议格式
TCP 全称为传输控制协议(Transmission Control Protocol)。人如其名,要对数据的传输进行详细的控制。
1. 十六位窗口大小
对于 TCP 来说,如果接收缓冲区满了,再发送机会被丢弃。因此发送前需要知道对方的接收缓冲区的剩余长度。按量按需发送,必须知道对方的接收缓冲区中剩余空间的大小,因此每次发送的 TCP 报文都要带有自己剩余接收缓冲区的长度。
2. 四位首部长度
首先我们要知道 TCP 光报头就至少 20 字节(不包含选项)。对于 4 位首部长度最多是 0-15,因此可以推导出表示的范围是 [0, 60],真正报头的长度是与它为 4 倍关系。也就是首部长度最少就是 5,也就是 101。
此时有个疑问:得到对应的 TCP 直接读取首部长度然后越过这些字节往后读,就是正文了,那么有报头大小,报文大小呢?
对于 UDP 它是面向用户数据报的,需要一次性发全部与读取,因此需要报文大小;但是 TCP 它是面向字节流的,因此如何读,读多少都交给用户自己控制。如果带了报文大小就一定是读取到完整的,而 TCP 是面向字节流,读取的时候需要控制,可能不完整,因此为了符合特性无正文大小。
下面再提下 TCP 的可靠性:
目前可以把 TCP 通信过程理解成这样(后续会变化):
![图片]
- TCP 这种应答机制保证对历史消息的可靠性。
- 通信中,最新的报文,永远没有应答,最新可靠性无法保证。
- 因此如果让 TCP 发的那些消息可靠,因此只需要确保你要保证可靠的消息不是最新消息即可。
但是实际是这样的:
![图片]
- 接受方收到的指定报文序号之前的所有的信息,下一次发送,从确认序号开始(确认序号 = 序号 + 1,当只有一字节数据可以这么理解)。
为什么会有两个序号啊,一个序号就可以吧?
- 应答也是 TCP 报文自己也要有序列号,比如说 s 给 c 发了一个信息序号是 9,此时 s 回复的也是第 9 条然后确认序号就是 10,此时告诉 s 第 9 条正常接收,下一次从 10 开始发送。
此外还可以补充一点:应答也是有数据的,也是可以承载发送的信息的(因为每次都是以 TCP 形式发送,肯定可以携带答复信息 (当确认应答的时候))。
其他通信过程的重点及细节我们后续再谈。
3. 标记位
URG:紧急指针是否有效。ACK:确认号是否有效。PSH:提示接收端应用程序立刻从 TCP 缓冲区把数据读走。RST:对方要求重新建立连接;我们把携带 RST 标识的称为复位报文段。SYN:请求建立连接;我们把携带 SYN 标识的称为同步报文段。FIN:通知对方,本端要关闭了,我们称携带 FIN 标识的为结束报文段。
这里本质就是报头中的比特位,确定不同状态有不同处理方式。
认识下它,也就是三次握手与四次挥手:
首先请看图:
![图片]
- 以上就是三次握手,四次挥手的简化版。
- 根据握手存在时间线:因此明白客户端知道服务端能收能发与服务端确定客户端能收能发存在时间间隔。
- 前两次握手,不能携带数据,因为三次握手没有完成,第三次可以携带数据,但是三次握手好了不一定立刻发消息(比如最后一次 ACK 不一定带消息,比如等一会再发)。
那么为什么要进行三次握手呢?
- 因为三次握手才确定了:服务端确定客户端能发能收,客户端确定服务端能发能收,这样才能说明双方互相通信没问题。
对于四次挥手:互相知道对方想断开连接,并互相同意!
上面是成功的情况,但是有没有可能失败?
如下图所示的情况:
![图片]
- 比如此时当服务端确定客户端能收到的那个 ACK 丢包了,此时 client 接下来就会给 server 发数据,但是 server 还没确定好 三次握手完成,因此此时收到对应数据,就会立刻给 client 发送 RST。(此次连接失败了!)
比如在访问网站的时候进程遇到的情况:
![图片]
- 这就是三次握手失败了,此时 RST 发送了也没用导致的 TCP 连接直接全部断开。
✅对于 PSH 标记位:
告诉服务端让操作系统快速让上层程序把对应的接收缓冲区内容读走,为接收报文留下更大空间!(也就是当接收缓冲区快满,对应的发送的窗口大小过小的时候发送)!
✅对于 URG 标记位:
- 此时把对应的接收缓冲区想象成一个长的 char 数组,而上面所说的序列号理解成数组下标。
- TCP 保证可靠性,根据序号大小,保证报文按序到达,也就是按照下标顺序进入,按照下标顺序读取。
- 如果我们有数据,想被先读取,优先处理,因此就引入了 URG 标志位与紧急指针。
这个 URG 通俗理解成在接收缓冲区进行了对特别数据的插队处理即可。
这种方式不是 TCP 的主流,只针对特别情况,大多数情况都是按照序列号走的,也就是这样才能保证 TCP 通信靠性!
发送接收缓冲区可以暂时这么理解,如图:
![图片]
- 但是有个问题,每次我们都会发送确认序列号也就是下标,告诉对方下次从它开始,但是不定是它,这里可以理解成下一次发送的序列号 (数组下标) 大于等于确认序列号。
4. 源/目的端口号
源/目的端口号:表示数据是从哪个进程来,到哪个进程去!
5. 16 位校验和
- 发送端填充,
CRC 校验。接收端校验不通过,则认为数据有问题。此处的检验和不光包含 TCP 首部,也包含 TCP 数据部分。
6. 16 位紧急指针
- 标识哪部分数据是紧急数据,配合标志位 URG 使用。
7. 选项
- TCP 头部包含一个选项字段,通常用于扩展 TCP 的功能。标准 TCP 头部长度为 20 字节,选项字段可以在此基础上扩展。TCP 选项字段的最大长度为 40 字节,这使得 TCP 头部可以达到 60 字节。
比如可以填写 MSS(指定发送方能够接收的最大段大小。通常在连接建立时通过 SYN 包进行协商),以及之后我们会说到的窗口扩大因子 M 以及相关网络通信需要的字段,保证 TCP 报文可靠性的字段等等(这里了解即可)。
8. 32 位序号与确认序号
先来理解下丢包(正向,反向):
也就是 c 发给 s 的时候丢包或者 s 发给 c 应答的时候丢包,这两种丢包是无法确定是哪种的。
超时重传机制:
![图片]
- 主机 A 发送数据给 B 之后,可能因为网络拥堵等原因,数据无法到达主机 B;如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答,就会进行重发;tcp 对应的发送缓冲区有库存,只有确认收到后才会清空。
![图片]
- 但是,主机 A 未收到 B 发来的确认应答,也可能是因为 ACK 丢失了;因此主机 B 会收到很多重复数据。那么 TCP 协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉;只要服务端接受到了信息就会有对应的序列号,根据它判断去重。
那么上面提到的这个特定时间如何理解呢?
可以知道网络好:时间间隔就短,网络差:时间间隔就长。
Linux 中(BSD Unix 和 Windows 也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。
- 比如 c 给 s 传递的时候由于网络慢等问题,过了 500 秒后没有收到应答,因此 c 会再次发然后特定时间也就是下一次重发时间变成 2*500,依次类推,当重发达到一定次数,也就是 TCP 认为网络或者对端主机出现异常,强制关闭连接如果是 s 给 c 应答丢包了,也是这样,重发只不过 s 进行去重操作。
因此可以理解成,对应的是 c 到路径的网络情况,以及 s 到 c 的网络情况,如果一直重发,当特定时间达到某个上限还没有双方都确定好,此时就是 tcp 通信存在网络问题,因此就会关闭本次连接,只好再重来三次握手了。
理解 accept 与 connect
connect发起三次握手,accept不参与三次握手,connect发起后,后面全程由 OS 自动完成。- 也就是他俩都不参与三次握手;只要 connect 发起后就由 os 自己完成三次握手,当没有 Accept 的时候服务端也能和客户端建立连接,双方都是可以连接的!只是服务端此时收不到信息。
- 因此这里能理解 三次握手后,c 到 s 的通路建立,s 到 c 的通路建立;至于服务端能不能正确达到信息还要看 accept 了。
再理解为什么要三次握手
本质是四次握手,只不过把中间的 SYN 和 ACK 给合并了而已,所以说是三次握手。
目的就是为了达到:
- 以最小成本,100% 确认双方通信意愿。
- 是以最短的方式,进行验证全双工。
简单理解就是通信前提,也就是双方连接完成的前提:1·用户与服务端的同意(双方发的两次 SYN 与 ACK)2·通信的时候网络通畅(双方都能发送 SYN 与收到 ACK)。
疑问就是三次握手可以两次或者一次吗???
![图片]
- 如果是一次的话,客户端不能确定服务端有没有收到信息,理想的话就是客户端能发,服务端能收,如果是两次的话 (合并) 其他都能保证,但是服条端不知道客户端是否能收到信息因此至少要这三次。
- 第三次也就是最后一次 ACK 是可以携带数据的,因为只要第三次 ACk 给了服务端,双方就都能知道对方能收能发了,因此此时对手最后一条信息拿到 ACK 后就可以直接被服务端读数据了。
二、连接管理机制
下面来进行 TCP 通信时候的状态分析:
先看图:
![图片]
服务端状态转化:
[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 状态。
下面白话叙述下:
server与client通信的时候,首先先绑定号对应的 ip 与端口号;各自准备好对应的工作。- 接着
client的connect发起三次握手 (进行互相连接以及交换对应的随机序号) 服务端accept的时候不参与握手;然后完成对应的三次握手后,状态变成establish之后就可以data+ack了。 - 接着就是结束通信的四次挥手了:客户端断开连接发送
FIN,就关闭对应的 fd,然后服务端收到后ack一下 (此时服务端变成CLOSE_WAIT,别忘关 fd 否则四次挥手不能完成! ),然后等处理完客户端对应的数据(服务端对应的缓冲区里)之后就关闭对应的 fd(关闭 fd 后系统自动发送FIN),再通知一下客户端FIN,当客户端收到这个FIN后状态变成TIME_WAIT,ack一下等到特定时间 (保证服务端收到后再彻底close_link);而服务端只需要收到最后一次ack就close_link了。
⚠️ 需要注意的是:开始的 SYN 等也是 os 自动发的,这里类似 FIN 都是关闭 fd 时候自己的 os 自动发的,总之类似这些以及 ack 都是系统默认发的。
下图是 TCP 状态转换的一个汇总:
![图片]
- 较粗的虚线表示服务端的状态变化情况。
- 较粗的实线表示客户端的状态变化情况。
- CLOSED 是一个假想的起始点,不是真实状态。
这里有个疑问为什么三次握手可以拆开,但是四次握手不能合并呢?
- 其实 ack 和 FIN 也是
可以合并的,但是断开时间不同 (比如此时客户端关闭后但是服务端对应的缓冲区还有没有读取完的,因此它之后 ack 一下等彻底读完了才给客户端 FIN!)所以一般服务器的 ack 和 FIN 就不是捎带应答那样的合并了;因此无论合并成三次还是就是四次,都称作四次挥手,因为这样叫四次挥手相当于具有普遍性,因此这么叫,具体的话还是由实际情况来说的,但常叫四次挥手。
⚠️注意:当写通信程序的时候断开连接的时候一定不要忘了适当时候关闭 fd;因为对应的进程的 fd 也是有上限的,比如服务端如果不关闭的话,对应客户端多了就可能崩了 (尤其是服务端器)。
使用命令可以查看 fd 相关信息:
ls /proc/2202614/fd -l
查询结果如下:
![图片]
- 这里我们的服务器只进行启动 accept 了;故有一个 listenfd 的套接字对应的 fd。
双方通信的时候:
这里比如我们只想关闭 fd 特定功能比如让它只能发不能收,或者让它只能收不能发;因此可以考虑
shutdown。
#include<sys/socket.h>
int shutdown(int sockfd, int how);
- 这里不常用了解即可;常用的还是一口气都关掉即对 fd 的
close。
TIME WAIT 与 CLOSE WAIT 状态的探究
服务端和用户端哪个先退出也就是先 FIN 哪个就会处于这个 TIME_WAIT状态!
现在做一个测试,首先启动 server,然后启动 client,然后用 Ctrl-C 使 server 终止,这时马上再运行 server,结果是:
![图片]
这是因为,虽然 server 的应用程序终止了,但 TCP 协议层的连接并没有完全断开,因此不能再次监听同样的 server 端口。我们用 netstat 命令查看一下:
![图片]
- 总之就是我们先退出后,对应的 tcp 并没有完全断开,因为四次挥手没完成呢。
此时有个疑问为啥关都关了还要有 TIME_WAIT 这个状态呢?以及为什么要有 2MSL 的等待呢?
- 先说下这里的机制,当第一端退出后直接接收到对方的 FIN(对方读取完了对应缓冲区数据后);然后第一端就处于这个状态,并发出了 ack 等到特定时间才完全 close(此时对方早关了);最后以第一端完全 close 才断开整个 tcp 连接!
说一下第一端会出现这个状态的两大作用:
- 就是可以确保此次断开的连接之前进行通信时候发的还没到达而是在网络中游荡的数据不会干扰之后的通信。
- 假设客户端处于这个状态::比如之前互相还有没到达的数据;如果到达服务端就会被知道 (因为客户端关闭了不会再发信息了) 而清除,如果是到达客户端,因为客户端关闭了 fd 收不到信息,因此这样就消散了之前网络中游荡的数据了。
- 如果没有这机制的话,假设没有这段时间,服务器再次工作就会收到之前的数据---->出错;或者当服务端重启后,有一个与之前
ip+port 相同的进程进行通信的时候,如果服务器收到之前的数据就会造成干扰。
- 确保它发送的最后一次 ACK 能够被对方收到,也就是确保最后一次报文的可靠性。
- 比如发送最后一次 ACK 的时候网络慢;有这个
2MSL的时间就极大保证了它的到达。 - 或者假设最后一次
ACK 丢包,那么此时服务端可能会再发一个 FIN;但是由于客户端处于这个TINE_WAIT状态那么此时 TCP 连接其实还没完全关闭,此时还是可以收到系统发的FIN或者ACK等通知的,防范丢包。 - 同时也是在理论上保证最后一个报文可靠到达 (假设最后一个
ACK 丢失,那么服务器会再重发一个FIN.这时虽然客户端的进程不在了,但是 TCP 连接还在,仍然可以重发LAST ACK)。
查看 msl 的值:
cat /proc/sys/net/ipv4/tcp_fin_timeout
![图片]
⚠️一句话总结就是:确保自己最后一次 ack 被对方收到;确保对方丢包后能超时重传;确保网络中先前存在未达数据
不会对下次通信造成干扰。
保证网络中之前的数据不干扰之后,比如每次在三次握手期间双方都会交换自己的随机序号作为每次自己发报文的起始序号;这样当收到之前数据,就可以与起始报文序号以及记录的自己应该处于的序号进行对比,直接排出这种干扰了,就是 TIME WAIT 机制了。
关于服务端 bind 绑定失败问题
为什么之前服务器挂掉后再次绑定对应的端口号就显示 bind 失败需要重新绑定其他的呢?
先演示下效果:
![图片]
查看下连接状态:
netstat -tnp | grep 8080
![图片]
- 此时发现对应的双方之间的连接并没有完全关闭 (因此
bind这个端口就会失败),而且服务端等到客户端退出就处于TIME_WAIT状态,也就是客户端close掉对应的对应的 fd 就会走到下面过程。
![图片]
- 下面我们客户端关 fd 发送
SYN然后接收ACK就彻底挂了,此时等到2MSL时间结束,就能再次绑定相同的端口了。
解释下:
这里客户端收到了最后一次 ack 就彻底断开了,但是服务器是主动断开的一方因此还需要等这个
2MSL 结束呢!
其实这里就用到了 TIME_WAIT这个状态了,下面解释下:
- 首先当服务端连接客户端之后退出,那么此时服务端必然会出现
TIME_WAIT这个状态,也就是此时这个 TCP 连接并没有完全断开,我们就去再次绑定这个端口了此时服务端这个端口还被绑定用于一个 TCP 通信呢,因此不能再次被绑定了,因此说这个机制也是很好的。
但是也有不合理之处:
- 比如服务端肯定会连接大量的客户端然后存在,比如为了节省资源服务器会主动活开不活跃的客户端连接,因此就会存在很多关于
TIME_WAIT状态,此时假设来了一个新用户拿着和之前被挂掉的用户的五大元组 (目的 ip 目的端口,源 ip 源端口以及协议)那么就会去请求这个TIME_WAIT状态的连接了,此时就会出问题了。
那么怎么解决的呢?
使用这个函数:
#include <sys/types.h>
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
使用方法:
int opt =1;
setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
- 这个函数的作用就是让我们向上面的情况直接绑定同一个端口就
不会 bind 失败。 - 其中选项
SO_REUSEADDR为 1,表示允许创建端口号相同但 IP 地址不同的多个socket描述符。
具体流程:
- 此时如果服务端绑定对应的 8080 端口,此时自己会发现这个端口拿着之前的 ip 还处于 TCP 连接状态,因此这里之后就不会用之前的 ip 了但会用 8080 端口;假设此时和之前挂掉的客户端的五元组相同的客户端再次连接,此时 i 会被域名解析时候发现对应的五元组信息处于 TIME_WAIT 状态,因此给它换个对应客户端 port 构成新的五元组去访问,此时找到服务端对应的进程,发现和之前 TME WAIT 的连接不冲突因此新开辟连接进行通信。
⚠️注意:
- 上面的操作仅解决服务端绑定问题,
不改变 TCP 对五元组冲突的处理逻辑!也就是如果手动目的 ip+port 去连接需要换服务端的 ip 了一般如果遇到相同的可能会被解析成非冲突的 ip 进行正常通信!
下面我们测试下 setsockopt这个函数:
当给服务器代码加上这个的时候,就不会上面的 bind失败了,但是五元组冲突问题还是不能解决:
效果如下:
![图片]
下面测试下不 close 掉 fd 直接退出是什么样:
![图片]
- 如果对应的服务端挂掉了,客户端没关 fd 直接结束,此时就一直有
CLOSE_WAIT这个状态不会取消,此时四次挥手未完成,TCP 连接没有彻底断开,因此被动方勿忘 fd 主动关闭。
⚠️建立和断开连接的本质:
可以理解成:这里需要 双方决定建立/断开;然后双方同意对方的建立/断开。
三、滑动窗口
如果每次都按照我们最初认识的一条条发和接:
![图片]
- 这样效率是非常低的,因此优化成:
![图片]
- 此时这样的模式就引入了滑动窗口。
滑动窗口机制
首先知道滑动窗口是 发送缓冲区的一部分,报头的 16 位窗口大小就是和它有关。
形象理解下滑动窗口:
![图片]
其中:
start=确认序号end=start+此次收到报文的窗口大小(暂时这么理解)
这里不需要刻意清理缓冲区,方便到时候写满了回到这里接续写进去,然后窗口重新定位。
那么它是如何工作的呢?
![图片]
- 当发完信息收到 ack 报文的时候里面有窗口大小,然后还有确认序号等,这个确认序号就是下一次 start 的位置,而 end 的位置就是 start+窗口大小。
- 这样每次发送信息收到 ack(可以携带数据) 就能调整窗口位置及大小,因此形象的就可以把它看成滑动窗口了。
- 因为确认序号是不断累加的,因此窗口要是移动就会向右,因此,可以知道滑动窗口的大小是由对方此时接收缓冲区的空闲量决定的。
探讨下几个关于滑动窗口的问题:
- 可以向左滑动吗?
- 不会,因为确认序号决定 start,而确认序号要么不变要么变大不会变小。
- 滑动窗口的大小可以变大 变小 不变 为 0 吗?
- 可以,根据对方发来报文的窗口大小决定。
- 滑动窗口一直右移会溢出吗?
- 这里可以把它想象成一个环形的窗口,窗口右侧到了发送缓冲区的右边界,就会回到起始 (因为滑动窗口左侧的左边都是可以被写入新的数据的,因此如果回来之后的窗口内又被写入了新的数据,接着发送) 继续又滑。
- 如果丢报了怎么办,滑动窗口会直接跳过吗?
- 肯定是不会的,它一定会重发的。(下面来探讨)
丢失报文分三种丢失情况:
- 最左侧丢失
- 中间报文丢失
- 最右侧丢失
下面我们从最左侧丢失来举例:
情况一:数据包已经抵达,ACK 被丢了:
![图片]
- 这里双方都知道了对方的随机序号后,开始的通信:
消息是全部被对方收到,此时对方接收缓冲区对应的位置就放好了数据,然后每个对应的给它
ack一下 (加上对应的确认序号),第一个丢了,剩下的ack没丢;而主机 A 每次都会等段时间拿到最大的确认序号进行发送,此时拿到的不就是6001,然后作为start的位置,并获取对应窗口大小完成滑动窗口的移动,进行下面操作。
情况二:数据包就直接丢了:
![图片]
- 这里双方都知道了对方的随机序号后,开始的通信:
假设第一次报文数据就丢失了然后,其他报文对应落到对方接收缓冲区对应位置,然后,因为它知道上一次发的确认序号是 1001 但是没有收到 1001 因此剩下的接收到的报文的确认序号都变成 1001 让主机 A 再次发这条报文!然后主机 A 收到后就发送,然后因为之前后面的数据都被收到了:因此下一次对方直接给主机 A 确认序号是 7001;直接发剩下的了—>快重传机制。
![图片]
因此得出结论:
确认消息一定是连续确认的,发送必须连续发送,滑动窗口不能跳跃。
那其他那两种情况呢 (中间丢失或者右侧)?
这不就暗含这前面的报文没有丢失,此时也就是再次按照:
1·数据丢失:
- 前面都没丢失,那么窗口不就移动到这个位置了,它不就作为最左侧了,和上面说的一样了。
2·应答丢失来考虑:
- 应答丢失,前面的都没丢失然后按照确认序号不断增加,滑动窗口进行右移,同时也在发送窗口内没发送的数据,然后丢失数据后面的序号位置报文也发出去了,这不就又转化成最左侧丢失问题了。
对上面小结下:
- 多次发送确认序号回去,会拿到最大的确认序号,然后进行待发送的数据的发送。
从上面可以得到:确认序号机制保证了对方下一次发送数据的正确性,也就是只要确认信号正确下一次数据就发的正确 (不考虑丢失等)。
关于快重传和超时重传
- 快重传:提高效率。
- 超时重传:做最底层的保护,兜底。
假设就是之前的最左侧数据丢失问题:
- 此时如果后面的 data ack 都被收到了;因此就会重新发送 1001,然后瞬间被对方收到发现后面的数据已经就位,发回来的确认序号就是 7001,直接从 7001 发;这就叫
快重传。 - 再比如剩下的 ack 都丢包,此时主机 A 等待一定时间没收到就再次发送 1001 然后更新下一次间隔时间,这里就出发了
超时重传。
理解下滑动窗口的本质:
是流量控制的具体实现方案。
四、流量控制
TCP 可靠性的一种保证,因此 TCP 支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做 流量控制(Flow Control)。
- 比如 B 给 A 发送了窗口大小是 0,此时 A 等待,当过了自己的重发超时时间后还没收到对应的窗口大小,就会发一条信息来向 B 获取对应的窗口大小,依次重复,这里可以简单理解成如果 B 告诉 A 自己满了,下面 A 就会时不时给 B 发送试探窗口大小信息 (防止 B 发的过程丢包等)。
- TCP 报头的 16 位窗口字段来告诉对方自己的窗口大小信息(每次都及时调整),而 TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M,实际窗口大小是 窗口字段的值左移 M 位,因此是不确定的。
这里我们不考虑网络差等问题:当给对方发信息,对方 ACK 的同时就会把自己接收缓冲区的大小放在窗口大小中随着 ACK 发给对面,好让对面知道它的接收能力,及时调整窗口大小,防止满了导致的丢包现象,如果满了的话就发送 0,此时发送方就不能发了,每隔一段时间,发送一份报文进行获取对面窗口大小,合适了再进行发送。
![图片]
五、拥塞控制
虽然 TCP 有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题,网络中可能是大量客户端访问同一个服务器,就有可能造成网络阻塞,因此遇到这种情况 TCP 就要进行干预了。
因此:
少量的丢包,我们仅仅是触发超时重传。大量的丢包,我们就认为网络拥塞。
TCP 引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
下面我们以通俗易懂方式讲解:
其实还维护了一个拥塞窗口:
![图片]
下面分析下这条增长曲线:
- 慢开始:
- 为了适应网络状态 (多个客户端同时连一个服务端),有可能网络特差,然后连接的人又多,开头就又可
ssthresh的初始值 16 能阻塞,因此开始慢一点更容易,更精准摸清楚网络健康状态。
- 后期指数暴涨:
- 因为如果发现了当前网络并没有阻塞,肯定想尽可能多发数据过去 (这里也不是百分百,因为还有对面的窗口大小决定),因此会更快的增长,但是会达到之前的网络阻塞窗口的值,就开始线性了。
- 线性增长:
- 当达到上次网络拥塞窗口值后,就要担心后面会开始阻塞了,因此后面改成线性的增大更加精准的探测到下次网络拥塞窗口的值,方便下一次重复的时候作为新的分界点。
这里每次
ssthresh的值就是上一次探测到的拥塞窗口值的 1/2,作为指数与线性的分界点,而最一开始的ssthresh是由建立三次握手的时候对方窗口确定的。
这里还有两个疑问:
- 拥寒窗口在增加,我发送的数据量一定在增加吗?
- 这里可以通过那个滑动窗口的公式得到,发的数量即滑动窗口大小,它还收到对方窗口大小决定,因此不一定。
- 拥塞窗口在线性探测的过程中,会一直增大吗?
- 这里不要忘了一个服务器可能有多个客户连接,网络也是有上限的 (里面发送的数据达到一定程度就会卡也就是网络阻塞),因此肯定线性增加某个值就变成了网络拥塞窗口了。
因此,拥塞控制,归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
下面再结合下滑动窗口:
滑动窗口大小=min(对方发来窗口大小,拥塞窗口)。
因此在对上面详解下:
- 每次给对面发送信息都要走那条曲线来得到对应的拥塞窗口,每次拿到都会通过滑动窗口计算公式来计算每次发送的大小进行发送,当达到网络阻塞的时候的窗口(也就是存在大量丢包)﹔机会重新开始走这条路,之后 TCP 通信就重复走这条路。
- 因此 TCP 在排除硬件异常外,需用对
1·网络是否拥塞 2·双方承受能力两方面进行及时调节来保证基于 TCP 通信能正常进行。
六、延迟应答
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
这里举个例子理解:
- 比如对方缓冲区 64KB;但是我们发了 60KB 的数据,而对方此时并不会直接给我们发返回 4KB 的窗口,而是等一段时间 (上层把对应的数据读完) 然后再给我们返回 64KB 的窗口大小,这样每次我们就能发更多数据了,提高了 TCP 发送的效率!
![图片]
但是不是所有的包都会延迟应答:
- 当发送的包达到一个量,此时对方不想让发送方等太久因此直接发送应答;但是它也会等一段时间供上层处理完腾出更大 1001 ~2000(窗口然后发回去 (延迟应答)。
然而还有两个制约因素:
数量限制:每隔 N 个包就应答一次(减少 ACK 个数,具体的数量和超时时间,依操作系统不同也有差异;一般 N 取 2,超时时间取 200ms)。时间限制:超过最大延迟时间就应答一次(如果等上层处理数据也有时间限制的,只要到了这个时间就必须 ACK)
总结:
延迟应答是一种'权衡型'优化机制,通过数量限制和时间限制的双重约束,在'减少 ACK 开销'和'保证传输效率'之间取得平衡。实际应用中,需根据网络环境和业务类型灵活配置参数 (如 N 值、最大延迟时间),避免因过度延迟导致性能下降。
七、捎带应答
![图片]
- 也就是给对面发信息的时候对面 ACK 的同时携带着自己要发的数据,这样其实就减少双方通信发送的信息量,提高了通信效率。
- 因此之后每次发送数据的时候 TCP 报文 ACK 都是 1,此时这就是所谓的捎带应答来提高效率。
这里我们引出之前三次握手的携带数据问题:
前两次握手不能携带数据:因为双方并不能完全确定双方是否都可以发信息与收信息,也就是真正的连接并没有完全建立!而最后一次握手 (ACK) 就可以了,因为只要收到了这次 ACK 就算完成了三次握手,因此就可以顺便读取这里面的数据了!
八、TCP 的面向字节流
解释:
write 把数据按照字节写入对应的发送缓冲区,然后积累到一定量或者等到合适的时间,把它分成多个 TCP 包进行发出去,然后对方通过网卡驱动程序读到自己的接收缓冲区,之后按照自己的要求字节流读到应用层。
其次就是 TCP 的时候对字节流可以理解成:想怎么写就怎么写,想怎么读就怎么读;读写互不影响,比如:
写 100 个字节数据时,可以调用一次 write 写 100 个字节,也可以调用 100 次 write,每次写一个字节。读 100 个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100 个字节,也可以一次 read 一个字节,重复 100 次。
其次就是 TCP 的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据,称之为它的 全双工。
九、粘包问题
起因就是 TCP 是基于面向字节流的。
TCP 报文的数据放到对方接收缓冲区后该如何操作呢?
对于定长包,上层就会按照指定字节大小每次来读取。如果是变长的,上层就会按照某种规则读取,按照某种规则解析。- 也就是按照自定义应用层协议 (类似
序列化与反序列化等) 来避免粘包问题的发生。
关于粘包问题,udp 与 tcp 怎么讲?
- 因为 udp 是面向用户报;而且 udp 报文会标注携带数据大小;因此就造成了只要对面读取接受缓冲区内容就会读取正确报文数据大小;也就是要么读完要么不读;而 tcp 就不同了;是字节流了也就是上面所讲的了!
十、TCP 异常情况
tcp 连接的时候如果进程挂掉等现象发生,此时自动就把对应的 fd 等关闭,自己断开连接,但是当对面发送的时候会发现,然后就会发起是否要重新连接的请求:如果没回应或者拒绝,就直接断开这个连接。
另外,应用层的某些协议,也有一些这样的检测机制。例如 HTTP 长连接中,也会定期检测对方的状态。例如 QQ,在 QQ 断线之后,也会定期尝试重新连接。
比如我们重启电脑的时候;就会有提示说当前有进程正在运行 (tcp 连接没有断开) 是否继续重启 (也就是客户端断开,然后服务端请求无回应,整个 tcp 连接就全断于了),此时四次挥手会有 TIME_WAIT 时间,因此我们重启时间比较长!
因此:
TCP 对一定错误存在容错处理:比如 client 不活跃或者网络问题意外断开,这里就涉及到 TCP 保活机制比如定期给 c 发送保活报文问问它还在不在等 (几十分钟级别)。
十一、TCP 小结
为什么 TCP 这么复杂?
- 因为要保证
可靠性,同时又尽可能的提高性能。
可靠性的保障:
- 校验和
- 序列号 (按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他:
- 定时器 (超时重传定时器,保活定时器,TIME_WAIT 定时器等)
底层基于 TCP 应用层协议:
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然,也包括之前写的 TCP 程序时自定义的应用层协议。
十二、TCP 与 UDP 对比
- TCP 用于可靠传输的情况,应用于文件传输,重要状态更新等场景。
因此 TCP 适用于一些对可靠性要求高 (不允许丢包),实时性不高 (也就是允许有长处理时间) 的通信。
- UDP 用于对高速传输和实时性要求较高的通信领域,例如,早期的 QQ,视频传输等。另外 UDP 可以用于广播。
因此 UDP 适用于一些可靠性要求不高,但是实时性非常高的 (如视频通话 直播等)。
归根结底,TCP 和 UDP 都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
十三、从底层认识 TCP 与 UDP 关系
理解这张图即可:
![图片]
解释下:
- TCP/UDP 共用的这套机制,使用相关接口的时候,首先进程先创建这个结构然后根据输入的参数进行填充,UDP 就转成 UDP 格式无连接的 sock,而 TCP 就强转成 TCP 版指针,然后进行填充即可;应用层发收消息共同的缓冲区就是那个 file(底层交给 os 自动打包接收发送)。
十四、本篇小结
本篇继上次进行的 TCP 网络通信编程代码的实现,来底层真正认识 TCP 报文的结构以及相关性质等,采取理论与通俗理解相结合来讲解。文章旨在帮助大家学习 TCP 协议这块有所帮助,如有误,请指出。


