目录
- 一、传输层协议TCP
-
- 1.1 TCP协议格式
- 1.2 对TCP的正确认识
- 1.3 关于流量控制
- 1.4 关于标志位
-
- 关于三次握手
- 关于四次挥手
- 重谈三次握手
- 其他标志位
- 1.5 连接管理机制
- 1.6 再谈流量控制
- 1.7 关于滑动窗口
- 1.8 关于拥塞控制
- 1.9 其它机制
-
- 延迟应答
- 捎带应答
- 1.10 TCP异常情况

个人主页:矢望 个人专栏:C++、Linux系统编程、Linux网络编程、C语言、数据结构、Coze-AI、MySQL
一、传输层协议TCP
1.1 TCP协议格式
如上图,TCP协议包含如上的字段。TCP报头的本质和UDP一样也是协议结构体。
linux-2.6.18/include/linux/tcp.h:
struct tcphdr {
__u16source; // 源端口
__u16dest; // 目的端口
__u32seq; // 32位序号
__u32ack_seq; // 32位确认序号
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u16res1:4,
doff:4,
fin:1,
syn:1,
rst:1,
psh:1,
ack:1,
urg:1,
ece:1,
cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u16doff:4,
res1:4,
cwr:1,
ece:1,
urg:1,
ack:1,
psh:1,
rst:1,
syn:1,
fin:1;
#else
#error"Adjust your <asm/byteorder.h> defines"
#endif
__u16window; // 16位窗口大小
__u16check; // 16位校验和
__u16urg_ptr; // 16位紧急指针
};
在收到TCP报文之后总要解决两个问题,第一个:报头和有效载荷分离的问题;第二个有效载荷分用的问题。 如上图,tcp一行就是32位,所以去掉选项,前五行就是20字节的数据,4位首部字段标识报头的长度(包含选项),所以无论带不带选项字段,都可以把tcp报头和有效载荷分离。
tcp报头中存在一个16位目的端口号,它可以标明有效载荷应该交给上层绑定目的端口号的进程,这也就解决了有效载荷分用的问题。
4位首部长度的取值是0~15,但是它读取之后需要*4来表示实际报头长度,由于选项字段可带可不带,所以首部长度的取值范围就是[20/5, 15],它表示的报头实际取值范围就是[20, 15 * 4],也就是20~60。假设报头实际长度是40,那么4位首部长度应该填写40/4=10。
1.2 对TCP的正确认识
TCP叫做可靠性协议,如何理解呢?
假设有两个主机正在通信:
如上,主机A向主机B发送报文之后,主机A是不知道主机B是否收到报文的,只有当主机B对主机A的报文做回应时,主机A才能够确认之前的报文主机B收到了。但是同样的,主机B做出应答之后主机B并不知道主机A是否受到了应答,除非主机A对主机B的应答做出回应。
所以我们发现没有百分百可靠的协议,因为最新发送的报文永远没有应答!但是对历史报文的可靠性是百分百可以确定的。
所以TCP叫做可靠性协议,可以理解为对历史报文的可靠性。
TCP存在两种工作机制。
1、第一种:
TCP真正靠保证可靠的是报文的可靠性,所以当一方给另一方发送报文时,对方必须自动应答报文,这样发送方就知道送达了,应答方也受到了报文。所以这样就保证了报文的可靠性。 另一方发送报文也一样,收到报文的一方必须自动应答报文。因此这样就保证了全双工的可靠性。 这种双方朝向上,发送报文,对报文做应答保证报文本身可靠性的机制叫做确认应答机制! 
可靠性有两个方面:一方面是你收到了报文,发送方需要知道;另一方面是你没有收到报文,发送方也得知道。 第一方面发送方收到应答时,就保证了; 可是第二方面有两种情况,一种是对方确实没有收到报文,另一种是对方收到了报文但是给发送方的应答丢了,它们的结果都一样,发送方没有收到应答。 所以发送方会给报文设置等待时间deadline,如果超过这个时间,发送方就认为对方没有收到报文。因此对于发送方而言,对方有没有收到报文,发送方都有一个确定的结果!
当发送方认为对方没有收到报文时,就会使用超时重传策略(对方没有收到报文之后的补发措施),给对方再次发送一份报文。在接收方角度,这个报文它之前可能收到过了,也可能没有收到,所以他需要有辨别报文是否收到的能力。因此tcp报头中有一个32位序号字段,如果这个报文携带的序号他本来就有,说明报文重复了,就去重,所以32位序号字段有一个去重的功能。
关于超时重传的特定时间间隔: 最理想的情况下,会找到一个最小的时间,保证"确认应答一定能在这个时间内返回"。但是这个时间的长短,随着网络环境的不同,是有差异的。如果超时时间设得太长,会影响整体的重传效率;如果超时时间设得太短,有可能会频繁发送重复的包;
TCP为了保证无论在何种环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
- Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
- 如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增。
- 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
2、第二种: 在实际的发送过程中没有使用第一种,因为它的效率太低了,一次只发送一个报文。实际情况是一次发送一批报文。
接收方在收到这一批报文时需要对每个报文都做应答。
由于发送的是一批报文,并且网络的拓扑结构十分复杂,所以接收方在收到之后可能存在乱序问题,比如发送方是1、2、3、4发的,但接收方是3、2、4、1收到的。乱序就是一种不可靠,所以需要解决乱序问题。
如何解决乱序问题呢?TCP协议报头中存在32位序号,接收方只要按照报文序号排序就可以解决乱序问题了。
由于发送方需要接受到对方对这一批报文的应答,但是它可能会一下子收到很多应答,很容易分不清,所以tcp协议报头中还有一个32位确认序号,这个确认序号是自己收到的报文序号+1。它的含义是该序号之前的所有数据,我都收到了。注意是所有!
这个确认需要简直是天才设计,如果发送方发送了序号为1000、2000、3000、4000的报文,但应答只收到了1001、4001,另外两个没有收到,那么发送方就知道4001之前的所有报文,对方都收到了!这有效减少了报文的重发次数,提高了效率。 此外,如果接收方只接收到了1000、2000、4000,那么在填写这三个的确认序号时就是1001、2001、2001。
注意:站在传输层角度,不要认为双方发送、应答的是数据这个模糊的概念,它们发送、应答的是TCP报文!包含TCP报头+有效载荷!
捎带应答: 看到上面的TCP报头它有两个序号,一个序号,一个确认序号。但是不是只需要一个就好了吗?发送方有一个序号就好了,接收方有一个确认应答就好了呀。这是因为TCP是全双工通信,对方在应答的同时可能还想携带数据,这就是捎带应答。你看到的应答可能既是应答又存在有效载荷。所以有了序号和确认序号,发送数据和发送确认就可以同时进行了。
因此序号和确认序号能够做到去重、按序到达、捎带应答、超时重传等机制。
1.3 关于流量控制
如上,双方在通信的时候都是有接收缓冲区的,如果接收缓冲区的空间不足以容纳对方发来的报文,那么这个时候就是不可靠的。TCP要保证可靠性,所以就需要进行流量控制,要发送对方能够接收大小的数据。
接收方的接收能力就是自己的接收缓冲区剩余空间的大小。在TCP协议报头中有一个字段是16位窗口大小,想要让发送方知道自己的接受能力,只需要把剩余空间大小,填写在应答报文的16位窗口大小中就好了。自己的协议报头16位窗口大小是填写给对方的,因为构建的报文都是发送给对方的。
提到流量控制,不要只想到是对方发的报文太多了,让它发的慢一点;也可以是对方发的太慢了,让它快一点。所以流量控制是包含可靠性和效率的。
1.4 关于标志位
在客户端和服务端通信的过程中,可能会有很多的客户端向服务端发送报文,这些报文本身是有类型的,例如有的报文是应答报文,有的是建立连接报文,有的是断开连接报文,有的是数据报文等等,所以在TCP的协议报头中,就必须有对应的字段表明报文的类型。
如上在协议报头中就有了常见的6个标志位区分报文类型。
| SYN | 同步位 | 请求建立连接。在三次握手时,发送方将此位置1,表示希望与对方同步序列号。 |
| ACK | 确认位 | 确认数据已收到。当此位置1时,报头中的确认序号字段才有效。 |
| FIN | 结束位 | 请求释放连接。发送方完成数据发送后,将此位置1,表示不再发送数据,请求关闭连接。 |
| RST | 复位位 | 强制终止连接。当发生不可恢复的错误(如连接请求被拒绝、异常中断)时置1,立即重置连接。 |
| PSH | 推送位 | 催促接收方尽快将数据交付给应用层,而非等待缓冲区填满。 |
| URG | 紧急位 | 标记数据中有紧急内容,需优先处理。与16位紧急指针配合使用,指明紧急数据在流中的位置。 |
关于三次握手
TCP通过三次握手建立连接。
首先什么是连接呀? 客户端可能会访问各种app,服务端可能会收到多个客户端的报文,所以双方的操作系统都会存在大量的连接,OS需要对这些连接进行管理,也就是先描述,再组织。所以就需要构建连接结构体,在Linux内核源码中,连接结构体叫做tcp_sock。
如上,一个TCP连接在Linux内核中就是由一个 struct tcp_sock 结构体来代表的。
三次握手的过程:
如上,注意它们发的并不是单单一个标志位,而是TCP协议报文。
为什么非要是三次握手呢? 双方在进行网络通信之前,首先要保证网络是通畅的,通过验证全双工可以确保网络是通畅的。如上图,发送方主机A在收到接收方B给它的应答后,主机A就确认它既能发,也能收了。但是此时主机B只能确认自己能收报文,能不能发报文它不确定,所以就需要主机A给它发送应答报文,确保让主机B确认它可以发报文。 采用三次握手的方式就确保了双方都验证了自己的全双工通信。三次握手是最少的保证次数。
关于四次挥手
TCP通过四次挥手断开连接。
如上,双方通信是全双工通信,当客户端放送FIN,并受到ACK之后,此时就关闭了客户端向服务器发消息的通道。但是服务端向客户端发消息的通道还打开着呢,所以客户端发送FIN,收到ACK之后,全双工通道就关闭了。 为什么是四次挥手呢?因为四次挥手是以最小成本的方式争得了通信双方的同意。
重谈三次握手
你会发现我们之前说的三次握手中间有个捎带应答,SYN+ACK。
如上图,所以三次握手的本质是四次握手,可是它为什么要叫三次握手呢? 因为当客户端申请向服务器建立连接时,服务器必须无条件答应,服务端愿意和客户端建立连接,因此常常写成三次握手。
但是客户端向服务端申请断开连接的时候,服务端不会立刻答应,服务端可能还有数据没有给客户端发完。所以服务端会应答客户端的断开连接请求,但是它不向客户端申请断开连接,也就是“你客户端不和我服务端发送消息了,好的,可是我服务端可以给你发送消息呀!”
因此建立连接是三次握手,断开连接是四次挥手。
总结:为什么TCP建立连接是三次握手呢?以最小成本的方式验证了网络通信问题,也就是验证了全双工通道,以最小成本的方式验证了双方通信意愿的问题,所以三次握手是最小的建立TCP连接的成本。
其他标志位
RST:对方要求重新建立连接;我们把携带RST标识的称为复位报文段。
如上,如上tcp三次握手建立连接的时候,在客户端角度,只要第三次握手的ACK一发出去,它就立即认为建立好了tcp连接。但服务端是要收到第三次握手信息时才认为tcp连接建立完成。但客户端认为建立连接之后就要开始发消息了。 如果第三次握手的应答报文丢失了,服务端收到消息报文后就会很疑惑,服务端认为客户端应该给它发送应答报文,而不是消息,所以服务端就断定建立连接出错了,此时就会向客户端发送RST标志位为1的报文,要求重新建立连接。
PSH:提示接收端应用程序立刻把数据从TCP缓冲区读走。
当客户端和服务端进行网络通信的时候,它们都会互发它们的接受缓冲区的剩余空间大小。假设服务端的接收缓冲区满了,此时不能接收数据了。虽然无法接收数据了,但客户端和服务端还可以互换TCP报头的,客户端可以和服务端互换报头查询服务端接收缓冲区剩余空间的大小,如果剩余空间的大小一直过少,客户端可以在发送报文的时候,给PSH标志位置为1,催促对方抓紧把数据从缓冲区中读走。
URG的作用就是标识紧急指针是否有效。
如上,紧急指针是TCP协议报头中的一个字段。
在理解它之前首先穿插一下序号如何理解。
如上,你可以认为发送缓冲区中的数据是由一个char类型的数组存储的,所以序号就很好理解了,你可以认为序号就是数组下标。
紧急指针是一个16位的字段,它与URG标志位配合使用,用于标记数据流中需要被接收方优先处理的紧急数据段的位置。URG为1时,紧急指针有效,为0时,紧急指针无效。 它告诉接收方:在这段数据流中,从序号seq到seq + 紧急指针之间的数据是紧急的,需要优先处理。接收方会立即从数据流中提取这部分数据,而不必等待前面的数据按序到达。
也就是把这段标识的数据插队处理。 将来在读取紧急数据时,也需要搭配标志位进行读取。TCP设计上提供了一种“带外数据”(Out-of-Band Data)的概念,URG和紧急指针就是实现它的机制。它允许发送方在正常数据流中插入一个紧急信号。在recv读取接口中有一个flags参数,它有一个这样的标志位MSG_OOB:
recv可以通过这个MSG_OOB标志位读取紧急数据。
紧急指针主要用于在传统交互式协议(如远程登录时按下Ctrl+C中断命令、FTP传输大文件时取消传输)中发送“立即中止”或“中断”等控制信号的场景。
1.5 连接管理机制
如上图,上面的所有的通信过程是双方的TCP协议自主控制的,最多双方应用层只是发起或者参与一部分。例如建立连接时,客户端调用connect只负责发起建立连接请求,建立连接的工作是TCP协议自己做的。服务端调用accept接收连接时获取TCP协议已经建立好的连接,它只负责获取连接工作。
在TCP的四次挥手的过程中,双方都有可能是主动断开连接的一方。一方断开连接的本质是“我不给你发消息了”,也就是一方不写数据了。但是不写数据,不代表它不能发送TCP报头进行应答工作。所以断开连接不一定是彻底释放连接,连接结构体还在,只不过连接设置成了某种状态。 这里还有一个问题,就是应用层调用close就代表直接关闭文件描述符fd的读写端。而一端断开连接之后,另一端还可以给它发送数据呀。 可是读端不是被close关闭了吗?事实上,一端关闭连接一端保留连接的这种半关闭状态(半双工)很少见,绝大数根本用不到半关闭状态。所以为了能够让本端关闭连接之后依旧可以读取对端发送来的数据,就必须有一个可以局部关闭文件描述符fd的系统调用,它就是shutdown。这个系统调用可以选择关闭文件描述符的读端或者写端或者两端都关闭。
因此通过这个系统调用就可以使用于“我要结束发送、但还想继续接收对端后续数据”的场景。 而在平时close就覆盖了所有需求。close() 立即释放 fd、断开所有关联,不容易泄漏资源;shutdown(SHUT_WR) 之后你还得记得后续再 close,多一步状态管理,用错容易泄漏 fd。
下面做一些验证工作。
我把之前服务器的accept获取连接等的工作全部注释掉了,接下来编译测试:
如上图,即使服务端没有accept获取连接,客户端依旧和服务端建立了连接。所以建立连接的工作是TCP协议自己做的。所以没有accept,连接依旧能够建立成功。
backlog是“已完成三次握手、但还未被 accept 取走”的连接队列的最大长度。
接下来,我把代码中的listen的第二个参数设置为1,我们再次进行测试:
如上,当backlog为1时,客户端向服务端发起了三次建立连接请求,前两次成功了,第三次失败了,一直是SYN_SENT的请求建立连接状态。 所以接受端维持的暂时不需要accept到应用层的连接数量是有上限的。这个被接受端维持的没有被accept的连接队列我们叫做全连接队列。全连接队列的上限是backlog+1。
全连接队列是内核中专门存放已完成三次握手、等待应用层通过 accept() 取走的连接队列。 它的作用: 解耦握手与处理:让内核协议栈自动完成三次握手,而应用层可以慢悠悠地 accept,两者互不阻塞。握手是内核的硬件级操作,accept 是应用层的取件动作。 缓冲突发流量:当瞬间大量客户端同时完成握手,但服务器主线程正在处理其他业务来不及 accept 时,这些连接可以暂存在队列中,避免连接直接被丢弃。
注意:backlog 不是越大越好,设太大只是把问题往后推,内核内存会先撑不住。高并发场景通常设为 1024 或 2048,并配合独立的 accept 线程及时取走连接,保证队列不积压。
关于TCP四次挥手过程中的几种连接状态。 
主动关闭方视角:
| FIN_WAIT_1 | 客户端调用 close(),发送第一个 FIN | 客户端表示"我不想再发数据了",等待服务端确认 |
| FIN_WAIT_2 | 服务端回复 ACK(第二次挥手) | 客户端收到确认,知道自己的 FIN 被收到,等待服务端主动发 FIN |
| TIME_WAIT | 服务端发送 FIN(第三次挥手)→ 客户端回复最后一个 ACK(第四次挥手) | 客户端进入等待期,持续 2MSL(报文最大生存时间,通常 60 秒) |
被动关闭方视角:
| CLOSE_WAIT | 收到客户端的 FIN,回复 ACK 后立即进入 | 服务端知道客户端不会再发数据了,但自己还有数据要发的话可以继续发,发送完毕后主动调用 close() |
| LAST_ACK | 服务端发送自己的 FIN 后 | 等待客户端对 FIN 的最后一个 ACK 确认 |
我想要看到这几种状态的变化过程,所以我在服务端的接口中做了如下调整:
void Loop()
{
signal(SIGCHLD, SIG_IGN); // 不需要回收子进程
while(true)
{
// 获取连接
InetAddr clientaddr;
std::shared_ptr<Socket> sockfd = _listen_sockfd->Accepter(clientaddr);
if(sockfd == nullptr)
{
continue;
}
LOG(LogLevel::DEBUG) << "accept a new link, address: " << clientaddr.ToString() << ", sockfd: " << sockfd->Sockfd();
// // 处理新的 sockfd
// if(fork() == 0)
// {
// // 子进程
// service(sockfd, clientaddr);
// sockfd->Close(); // 服务完毕,关闭文件描述符
// exit(0);
// }
int cnt = 15;
while(cnt—)
{
std::cout << "server wait" << cnt << std::endl;
sleep(1);
}
sockfd->Close(); // 服务端关闭连接
std::cout << "server close" << std::endl;
sleep(5);
}
}
如上,收到连接之后,不对连接进行处理,等待15秒后关闭连接。
在测试的时候,我们让客户端先关闭连接,服务端等待15秒之后再关闭连接。
如上,当客户端主动断开连接之后,服务端会对FIN进行应答,客户端收到应答之后,状态会变成FIN_WAIT_2,服务端状态变成CLOSE_WAIT。如果服务端不调用close(fd),这种状态就会维持住。所以如果你的服务器端出现了大量的CLOSE_WAIT,那么此时你的服务器可能就存在bug了,他可能没有及时关闭文件描述符fd。 当服务端调用close(fd)之后,客户端会收到服务端的FIN请求,此时客户端的连接状态就会变成TIME_WAIT,而服务端的连接状态变成了CLOSED,已经查不到连接了。
因此四次挥手之后,主动断开连接的一方会进入TIME_WAIT状态,被动断开连接的一方会立即释放连接。进入TIME_WAIT的一方,它的连接并没有释放,所以它的ip、port还是被占用的。这就是为什么服务端主动断开连接之后,立即再次重启,端口号绑定失败的原因!因为底层的连接还是TIME_WAIT状态,端口还是被占用的,所以才需要通过setsockopt函数设置端口复用。设置端口复用之后,无论端口是否被占用,就都可以使用该端口了。
此外,TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。 MSL 是 Maximum Segment Lifetime(最大报文生存时间)的缩写。它指的是TCP 报文段在网络中可以存在的最大时间,超过这个时间,报文就会被网络丢弃。
为什么TMIE_WAIT要等待2倍的MSL时间? 原因一:确保双方四次挥手都尽可能完成。四次挥手中,主动关闭方发送最后一个 ACK 后进入 TIME_WAIT。这个 ACK 有可能在网络中丢失。如果丢失,被动关闭方(服务端)会因为收不到 ACK 而超时,重发它的 FIN(第三次挥手)。TIME_WAIT 状态必须持续足够长,以便能收到对方重发的 FIN,并再次回复 ACK。2MSL 正好覆盖:重发 FIN 到达的最长时间(1 MSL)+ 主动关闭方回复 ACK 到达的最长时间(1 MSL)= 2MSL。如果 TIME_WAIT 太短,收不到重发的 FIN,被动关闭方会一直重发 FIN,最终因收不到 ACK 而异常关闭。 原因二:让陈旧报文在网络中尽可能消散。网络延迟无法预测,一个旧连接的数据包可能因为路由环路、拥塞等原因,在发送后很久(但小于 MSL)才到达。如果没有 TIME_WAIT 的冷却期,旧连接关闭后立即重用相同的 IP + 端口组合建立新连接。这个迟到的旧数据包到达时,会被新连接误认为是有效数据,造成数据混乱或安全漏洞。等待 2MSL 后,旧连接的所有报文都已在网络中消失。
1.6 再谈流量控制
在之前已经谈过流量控制了,这里解决一个问题: 当TCP三次握手建立连接之后,第一次发送数据时,应该发送多大的数据呀?
虽然此时我们时第一次发送携带数据的报文,但这不是我们第一次发送报文!我们在TCP三次握手的时候就交换过报头,也就是交换过窗口的大小了。所以发送多大的数据自然也就不用担心了。
扩充:TCP报头中的表示窗口大小字段是16位的,16位最大表示65535,那么TCP窗口最大就是65535字节么?实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移 M 位。 
1.7 关于滑动窗口
首先TCP的发送缓冲区,接受缓冲区你可以把它当作数组来看待。
滑动窗口是什么呢?在逻辑上,你可以认为滑动窗口是发送缓冲区的一部分区域。逻辑上将它当作线性结构。
滑动窗口所处的区域是可以暂时不收应答直接发送的区域,发送完之后等待这一段区域的应答报文。滑动窗口左侧的区域是已经发送的且已经经过确认应答的数据。滑动窗口的右侧是待发送数据区域。
滑动窗口区域的就是可以发送的一批报文。 
滑动窗口的工作过程:
最开始的时候,建立连接时,双方就互相交换了窗口剩余大小,此时双方都收到了对方发的ACK_WIN。所以滑动窗口的大小就可以确定了start_win=0,end_win=ACK_WIN。所以滑动窗口的发送区域大小是以对方的接受能力确定的。
如上图,假设start_win是1001,end_win是5001,那么此时发送方就可以发送1001-2000、2001-3000、3001-4000、4001-5000的数据报文,然后等待发送方传来应答报文。
对方每次应答时都会带上缓冲区的剩余接受大小。如果应答的缓冲区大小和上次相比变大了,那么滑动窗口的大小就变大了,如果和上次的大小相比变小了,那么滑动窗口的大小就变小了,如果不变那么滑动窗口不变。
如果收到应答报文2001,那么在调整滑动窗口时,start_end直接赋值2001即可,因为2001的意思是2001之前的全部收到了!end_win=start_win+ACK_WIN。
滑动窗口可能向左滑动吗?不可能向左滑动。 发送方发送了一批报文,最糟糕的情况是发送方一个应答报文也没有收到,此时它就超时重传这一批报文。而滑动窗口左侧的报文都是已经经过应答确认过的。所以滑动窗口最坏的情况是左边界不变。而滑动窗口的右边界的浮动变化和接收方的缓冲区的接受能力有关。
最左侧报文丢失:
如上是发生快重传的情况,当发送方连续收到三个同样的应答序号就立刻补发这段报文。
快重传机制:当发送端连续收到3个对同一个丢失报文段的重复ACK时,立即重传该报文段,无需等待重传计时器超时。超时重传机制是对快重传机制的兜底工作。
依旧是上面这张图,如果在发送报文时1001-2000的报文丢失了,那么之后的报文的应答序号都是1001,当出现3次连续相同的ack时,发送方就会对这个报文进行补发,如果没有连续3个,那会有超时重传对报文做补发。 所以应答序号是指XXX序号之前的所有报文都全部收到了,它也支持了滑动窗口的左侧下标不会跳过任何没有经过确认的报文!
扩充:当发送方发送了报文之后,发送方不能立即丢弃这个报文,因为报文可能会丢失,所以就需要把这段报文临时存储起来,存储到了哪里呢?就是在滑动窗口内部存储!滑动窗口内的数据被丢弃到左侧,滑动窗口右移,本质就是删除数据!
中间报文丢失和最右侧报文丢失:
当它们发生时,丢失报文的右侧被收到的报文的应答序号都变成了丢失报文的起始序号比如3001等,此时滑动窗口的start_win就是赋值为3001。总之发送方收到的最大应答序号就是丢失报文的起始序号,start_win=丢失报文的起始序号,此时丢失的报文就变成了最左侧报文丢失,因此中间报文丢失和最右侧报文丢失本质都可以转化成最左侧报文丢失!
补充:如果滑动窗口右侧越界怎么办?你可以认为发送缓冲区,接受缓冲区是一个环形区域,所以start_win、end_win都可以认为是取模之后确定的边界。
1.8 关于拥塞控制
TCP协议在设计的时候,不仅仅考虑了发送端和接收端这两端的问题。网络通信除了和这两端有关它还和网络有关,所以TCP协议还考虑到了网络问题。
当网络通信过程中出现少量丢包的情况,可以采用重传机制重新发送报文。而当网络通信中出现大量丢包的情况,TCP协议就认为网络出问题了,网络拥塞了。此时就不可以对报文做重传了,一旦TCP协议允许此时重传,那么网络中大量的使用该协议通信的主机都会在这种情况下重传,此时就会加重网络的拥堵情况。所以应该采用的不是重传,而是采用拥塞控制。
通常情况下丢包率占比小于1.5%属于正常丢包,而在1.5%~2%之间属于网络拥塞,丢包率超过2%就是严重拥塞。
在当前的网络状态拥堵的情况下,贸然发送大量的数据,是很有可能引起雪上加霜的。 所以TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
如上,先发送1个,可以通信,就翻倍增长,这就是慢启动。它通常是指数级别增长的,但为什么叫慢启动呢?因为它的前期慢,启动慢,但是一旦网络状态良好,它的中期恢复快。所以慢启动在可靠性和效率中寻找了平衡点。 
这里引入一个拥塞窗口的概念。 拥塞窗口:发送方根据网络拥塞程度(如丢包)动态估算的上限值,用于避免过度注入数据导致网络瘫痪,核心是拥塞控制。 拥塞窗口是用来衡量网络拥塞的指标。
现在已经出现了三个窗口,接收窗口(TCP报头中16位窗口大小)、滑动窗口、拥塞窗口。它们三者窗口的关系是滑动窗口=min(接收窗口, 拥塞窗口);,滑动窗口衡量的是能够发送的报文数量,它受到对方接受能力和网络状况的影响。
有了拥塞窗口,所以上方的慢启动是如何做到的呢? 很简单,把拥塞窗口设置成1即可,滑动窗口取接收窗口和拥塞窗口的最小值进行发送,当发送1报文成功收到应答之后,再把拥塞窗口设置成之前的1倍,再次试探网络拥堵状态,以此类推。
当然不能一直让拥塞窗口指数增长下去,不然的话不合理。为了不增长得那么快,就引入一个叫做慢启动的阈值。当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
如上,超过阈值之后,拥塞窗口就会线性增长。 这个慢启动阈值初始值等于TCP开始启动时候接收方通告的窗口大小。在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1。拥塞窗口再次进行慢启动等步骤。
所以拥塞窗口一定是变化的,因为网络状况是变化的。TCP也不知道这个拥塞窗口应该是多大,只有不断的尝试才能知道。拥塞窗口本质还是探索当前网络的接受能力。
1.9 其它机制
延迟应答
延迟应答是指接收端不立即对每个数据包发送ACK确认,而是等待一小段时间(通常200ms),尝试将多个ACK合并成一个,或利用此机会捎带发送数据。
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。延时应答的本质:通过延时,一定概率可以给发送方通告一个更大的接受窗口。
假设接收端缓冲区为1M。一次收到了500K的数据;如果立刻应答,返回的窗口就是500K;但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了;在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M; 只要窗口越大,网络吞吐量就越大,传输效率就越高。
那么所有的包都可以延迟应答么?也并不是这样。 数量限制:每隔N个包就应答一次;时间限制:超过最大延迟时间就应答一次; 具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms。
所以延迟应答有利于提升网络传输效率。
捎带应答
捎带应答(Piggybacking) 是指接收端在发送数据报文时,将原本需要单独发送的ACK确认信息,附带在数据报文的TCP头部中一起发送出去,从而省掉一个独立的ACK包。
在TCP双方互相发消息的场景下,捎带应答十分常见,它能够提高报文发送的效率。 此外在TCP三次握手建立连接的时候,提出建立连接的一方在第三次握手发出去的时候就认为已经建立好连接了,所以第三次握手也是可以捎带应答携带数据的。
1.10 TCP异常情况
进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。 机器重启:和进程终止的情况相同。 机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行RST(reset)。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。 另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态。例如QQ,在QQ断线之后,也会定期尝试重新连接。
| 进程终止 | 正常发送FIN,完成四次挥手 | 操作系统会主动清理该进程持有的所有文件描述符(fd),包括socket,触发正常的关闭流程。 |
| 机器重启 | 同进程终止,发送FIN | 操作系统在关机/重启时,会询问用户是否关闭所有进程,用户应答是后,此时会向所有活跃连接发送FIN,尝试优雅关闭。 |
| 机器掉电/网线断开 | 无FIN,对方无法立即感知 | 连接瞬间中断,TCP协议栈来不及发送任何包。依赖后续机制发现。 |
扩充:保活计时器(Keep-Alive Timer)是TCP协议栈在内核层面提供的底层探活机制,默认关闭,开启后会在连接空闲超时(如Linux默认7200秒)后发送探测包,若多次无响应则判定连接死亡并释放资源,主要作用是对应用层未处理到的异常进行兜底清理,避免僵尸连接耗尽系统资源; 而心跳机制是应用层主动实现的周期性探测手段,由应用程序在业务层面定期(如30秒)发送自定义心跳包并等待响应,其作用不仅是快速检测连接存活(秒级响应),还能携带业务状态、维持NAT映射和防止中间设备超时断开,是保障长连接实时性和可靠性的核心方法。 两者通常结合使用:应用层心跳承担主动监控职责,内核保活作为最后一道安全防线,共同确保TCP连接在异常网络环境下的健壮性。
补充:创建tcp socket: 
总结: 以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力! 技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长! (~ ̄▽ ̄)~
网硕互联帮助中心



评论前必须登录!
注册