传输层协议 TCP(下)
接下来,我们来谈论 TCP 具体的机制!
具体 TCP 机制
确认应答(ACK)机制
TCP 将每个字节的数据都进行了编号,即为序列号。

这个 ACK 的理解我们在上一篇的时候就已经谈论清楚了!
不过要注意:确认应答必须将 ACK 标志位置为1。
下面,我们来好好理解一下序号!!!

我们知道 TCP 是有两个缓冲区的:一个发送缓冲区,一个接收缓冲区!而且是面向字节流的,所以我们可以将发送缓冲区看成是一个 char 类型的字符数组!
char outbuff[N];
那么操作系统看待缓冲区不就是看待一个字符数组了嘛,这不就是字节流了吗?!流就是数组,那么从发送缓冲区拷贝下来的每一个字节,天然的不就有了编号了吗!不就是在发送缓冲区对应的数组下标了嘛!

我们的这个理解并不严谨,我们待会在滑动窗口中会有更好的理解!
取数据不就是用确认应答的确认序号到发送序号所对应的下标的整体数组范围嘛!这是我们第一版的理解!【Ack = 发送方发的 Seq + 发送的数据长度】
发送序号(Seq) 和 确认序号(Ack) 是一对 “你发我收” 的情侣配对,死死绑定!
- Seq = 我发出去的数据的开头编号
- Ack = 我希望你下次发的开头编号
接收方回的 Ack = 发送方发的 Seq + 发送的数据长度
换句话说:
你发了多少,我就确认到多少,然后让你从下一个开始发!
可靠性的本质就是我收到应答了,那么说明我刚发送的对方收到了!!!--- ACK。
超时重传机制
我们要谈重传,就需要先好好理解丢包!那么什么是丢包呢?如何理解?
我们以一个报文为例:丢包的情况就两种:
1. 应答前丢:真的是数据包丢了
主机 A 发送数据给 B 之后,可能因为网络拥堵等原因,数据无法到达主机 B;
如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答,就会进行重发;

2. 应答后丢:数据包收到了,但是应答数据丢了【对端收到数据,进行请求处理,返回响应】
但是,主机 A 未收到 B 发来的确认应答,也可能是因为 ACK 应答丢失了:
结合上面的两种情况,所以对于发送方,没有收到应答 ACK,意味着什么?意味着丢包吗?不完全是,只能意味着数据可能丢失,无法 100% 确认对方收到消息,也就是无法保证可靠性。当前情况下,也就是看上图是无法确认是数据丢了还是应答丢了(后面会有解决办法)!
这就需要等待特定的时间间隔,如果发送方在特定的时间间隔内没有收到对端的 ACK 应答,那么发送方就会判定报文丢失!!!是主观判定,不是客观事实【要不然说是可能丢包】!
所以说:超时重传的策略就出来了!
还有对于第二种丢包问题,不就是会导致重复问题吗?
这个是可以甄别出来的!因为报文有发送序号的!!!主机 B 会收到很多重复数据。那么 TCP 协议需要能够识别出哪些包是重复的包,并且把重复的丢弃掉。序列号,就可以很容易做到去重的效果。
所以这意味着只靠超时重传还不够。因为数据其实已经被 B 正常收到,只是确认包丢了。如果 A 无脑重发,就会导致 B 收到重复数据。所以 TCP 必须依靠序号 + 确认序号,让接收方能识别重复数据、丢弃重复段,保证交给应用层的数据只出现一次。

发送端(主机 A)
-
先发送 序号 = 100 的数据包(比如内容:
Hello) -
因为没收到 ACK,超时后重传一次序号 = 100 的相同数据包
接收端(主机 B)
-
第一次收到 序号 = 100:记录当前接收窗口位置,处理数据并回传 ACK
-
第二次收到 序号 = 100:发现序号已经在已接收范围内,直接丢弃,不做任何处理,也不会重复向上层交付数据
核心原理
-
接收端会维护一个「已接收序列号范围」,只要新包序号落在这个范围内,就判定为重复包,直接丢弃【后面滑动窗口详细说明】
-
只有超出这个范围的新序号包,才会被接收、处理并更新窗口
「同一个序列号,只认第一次,后面来的全扔掉」 ✅
所以发送序号的作用:确认应答,按序达到,去重!!!
我们现在就可以根据特定的时间间隔,如果收不到应答,发送端就会判定报文丢失!所以,这个特定的时间间隔应该是多长啊?
发送端等的就是应答,那么我们是知道的:数据的传输是需要经过网络的,如果网不好,那么一来一回就会比较久,要排队之类的,时间肯定变长了。如果等少了,就可能无效重传;如果网络特别好,时间间隔反而设得很长,就会导致浪费时间了。所以因为网络是动态变化的,因此:
等待的时长必须是变化的!那么具体是怎么变的呢?
TCP 为了保证无论在任何环境下都能比较高性能地通信,因此会动态计算这个最大超时时间。
Linux 中(BSD Unix 和 Windows 也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。
如果重发一次之后仍然得不到应答,等待 2×500ms 后再重传。如果仍然得不到应答,等待 4×500ms 再重传,依次类推,以指数形式递增。
累计到一定的重传次数,TCP 认为网络或者对端主机出现异常,强制关闭连接。
不断改变时间进行重传,就是在探索网络当前的合适超时时长!如果下一次 4×500ms 后接收到了应答报文,那么下一次就直接使用 4×500ms 作为首次等待时长。
连接管理机制
在正常情况下,TCP 要经过三次握手建立连接,四次挥手断开连接。我们之前为了更好理解相关概念,提前浅浅谈了一下,下面我们来详细说说!
三次握手:建立连接

图中的 SYN_SENT、SYN_RCVD 等等,都是表示双方主机的状态,整个过程就是状态变化。在内核层面上,所谓的状态其实就是一个整数!也就是宏值!
多个客户端可以向服务端建立连接,每个还处于不同状态,这不就是在服务端需要进行管理吗!?不就是先描述再组织吗!?所以服务端需要管理这些连接!
那不就是可以用结构体规范、管理起来吗?在内核中,用于管理 TCP 连接的结构体通常是 struct tcp_sock(在 Linux 内核中)。这个结构体包含了 TCP 连接的所有相关信息,包括连接的状态、发送和接收缓冲区、拥塞控制信息、定时器等。通过这个结构体,内核能够有效地管理和跟踪每个 TCP 连接的状态和行为。
所以在 TCP 中,连接本质上是通信双方操作系统内核,为一次完整会话共同维护的一套专用上下文结构与状态约定,具体体现为:基于源 IP、源端口、目标 IP、目标端口构成的唯一四元组标识,在内核中通过 struct tcp_sock 这类结构体保存连接状态(如 ESTABLISHED、SYN_SENT 等)、发送 / 接收字节流缓冲区、数据序列号与确认号、超时重传定时器、滑动窗口与拥塞控制参数等所有可靠传输所需信息,同时双方严格遵循三次握手建立同步、四次挥手完成关闭,以及丢包重传、去重、按序交付、流量控制等协议规则,这种双向绑定、有状态、可可靠交互的内核级会话上下文,就是 TCP 里真正意义上的 “连接”,而 UDP 虽有套接字文件描述符,却无此类状态维护与可靠传输机制,因此属于无连接协议。
其实当我再次读到这里,有点自己的想法想说:【下面这个你们要是看的难受,可以略过,等后面学多了再来看看】
TCP 的连接管理,本质是传输层为通信双方在内核中维护的一条可靠通道。这条连接与上层应用无关,它只负责一件事:通过序列号、确认应答、重传、排序、去重等机制,保证数据能完整、有序地送到对端主机的内核缓冲区。
不管上层是
HTTP这种典型的短连接,还是WebSocket这种长连接,底层依赖的都是同一条TCP连接。TCP 并不关心上层用什么协议、传什么格式、多久发一次数据,它只保证传输的可靠性。上层所谓长短连接,本质只是应用层自己决定什么时候关闭套接字,并不是TCP本身有什么区别。所以,即便上层跑的是
HTTP协议,我们也完全可以选择不立即关闭底层TCP连接,让它持续保持连通状态,像WebSocket一样反复收发数据。这并不是改变TCP的行为,只是不再由应用层主动断开连接。TCP本身完全支持这种长期复用的模式,HTTP keep-alive、HTTP/2多路复用,本质都是这么做的。简单说:
TCP只负责可靠送达,上层是短是长、是HTTP还是WebSocket,全由应用层说了算。
struct tcp_sock(简化)
#include <linux/inet.h>
#include <linux/sk_buff.h>
#include <linux/tcp.h>
// 定义TCP套接字结构体
struct tcp_sock {
// 继承自inet_connection_sock,包含通用的连接套接字字段
struct inet_connection_sock inet_conn;
// TCP头部长度
u16 tcp_header_len;
// 快速路径处理数据的头部预测标志
__be32 pred_flags;
// 下一个期望接收的序列号
u32 rcv_nxt;
// 未读数据的头部
u32 copied_seq;
// 最后一次窗口更新发送时的rcv_nxt
u32 rcv_wup;
// 下一个要发送的序列号
u32 snd_nxt;
// 要发送的下一个确认号
u32 snd_una;
// 接收窗口大小
u32 rcv_wnd;
// 接收窗口左边缘
u32 rcv_wnd_left;
// 发送窗口大小
u32 snd_wnd;
// 发送窗口左边缘
u32 snd_wnd_left;
// 其他TCP相关字段...
};
正因为需要创建数据结构对象,花时间,花空间,所以 TCP 建立连接就会有成本!这也是为什么学校选课会卡,就是连接不断的被建立,越来越多就会导致管理成本增加,甚至到内存不足,以至于操作系统杀进程(服务),也就服务器崩掉了!【服务器崩掉的原因之一】
由图中的服务端:socket - bind - listen - accept,到这就阻塞住了,然后到客户端:connect 发起连接请求!connect 是发起三次握手的,也就是为什么需要传入 IP 和端口!当然了,是发起三次握手,后续的三次握手具体的过程是由客户端 Client OS 自己完成的!跟 connect 没有关系!等连接成功了,connect 才会返回!
对于 accept,我们之前的 TCP 编程的时候,其实不使用 accept 接口,就设置 listen 状态,其实好像也是可以连接上服务器的。所以这说明了什么?就是 accept 不参与三次握手!!!三次握手依旧是由 server OS 和 client OS 双方操作系统自动完成!
accept 直接将建立好的连接拿上去就行了!什么叫做把连接拿上去呢?我们后面会说!大概就是拿到文件描述符,这就相当于在 accept 的时候可以创建 struct file 对象,然后将来再让这个 struct file 和获得的这个连接建立某种关联,这不就可以通过文件访问这个连接了嘛!后面说!😜
三次握手是 TCP 进行通信之前必须要做的,为什么需要三次握手啊???--- 两个原因:
-
三次握手是以最短的方式进行验证全双工的!客户端能发能收就是客户端的全双工,服务端能发能收就是服务端的全双工!(结合
ACK机制体现,双方地位是需要对等的)(验证全双工本质就是验证我们两个所处的网络是通畅的,能够支持全双工) -
三次握手是四次握手中由捎带应答压缩带来的产物,双向的
SYN其实就是再说你愿意吗 --- 我愿意,所以就是在以最小成本,100% 确认双方的通信意愿(你情我愿) -
然后结婚!

三次握手的本质其实就是四次握手,因为服务端默认都会对客户端做应答,还有捎带应答的机制,所以可以压缩为三次握手!
记下来,我们来谈谈四次挥手:断开连接

断开连接的本质就是建立双方断开连接的共识!具体的做法就是必须要保证客户端向服务端 100% 发送对应的断开连接请求,服务端向客户端 100% 发送自己也要断开的请求!因为 TCP 是全双工的,也就意味着双方之间发送消息是可以同时进行的,建立双方断开连接的共识,一个具象化的认识就是:
客户端向服务器发送 FIN,本质就是客户端给服务器说:“我要发的数据已经发完了,我要和你断开连接。” 本质就是断开全双工的一条,即断开 client 到 server;
ACK 就是确保客户端向服务端说的话,100% 收到了!
同样的道理,TCP 是全双工的,此时服务端可能向客户端的数据还没有发完(这也是不能合并的原因,双方的关闭时间不一定是同一时刻,不太好压缩),就需要等发送完了,才会向客户端发送 FIN:“我也要断开连接了”!
ACK 就是确保服务端向客户端说的话,100% 收到了!
这时候,双方就建立了断开连接的共识了,也就是四次挥手!
为什么需要四次挥手来断开链接呢?
四次挥手以最短次数,最小成本来建立了双方在全双工之下的断开连接的请求!
不过,客户端四次挥手的时候,将文件描述符 close 了,那么对端服务器还需要向客户端发送数据的时候,此时客户端的文件描述符不是已经关闭了吗?客户端不是读不了了吗???那该怎么办?
所以 Linux 系统为了支持我们能够对连接进行半关闭,也就是说客户端想要断开连接了,想要关闭自己的写端,那就将写端关闭,但是文件描述符不做释放,后面还可以进行读取数据!这就由全双工退化成半 / 单双工了!
在 Linux 中,shutdown 系统调用用于关闭一个套接字的特定端(读端、写端或两者)。这个调用可以用于 TCP 连接,以优雅地关闭连接的一部分,从而允许数据的完全传输。
#include <sys/socket.h>
int shutdown(int sockfd, int how);
sockfd:套接字文件描述符,必须是通过 socket() 调用创建的。
how:指定如何关闭套接字。可以是以下值之一:
-
SHUT_RD:关闭读端。不再从套接字接收数据。 -
SHUT_WR:关闭写端。不再向套接字发送数据。 -
SHUT_RDWR:关闭读写两端。
其实一般我们在 TCP 网络套接字编程的时候,我们使用 close 接口就行了,我们前面数据该发的我们都做了,另外四次挥手本质上也是由双方的操作系统自己完成的:
在 Linux 的 TCP 套接字编程中,close 并非智能识别连接状态,而是内核基于 TCP 协议特性,为其封装了标准化的连接关闭逻辑,因此日常开发中只用 close 就足够满足需求;
而 shutdown 是更精细的手动控制接口,用于特殊的半关闭场景,二者核心差异由内核的连接管理逻辑和 TCP 全双工特性决定。
close 调用会对套接字文件描述符做引用计数减 1,当计数归 0 时,内核会自动完成后续所有操作:先将发送缓冲区剩余数据发送完毕,再主动发送 FIN 包触发四次挥手,全程自动处理挥手的应答、状态切换,最后释放连接对应的 TCB、内存等内核资源,整个 TCP 连接的关闭流程无需开发者手动干预;
而 shutdown 的作用是单独切断套接字读、写或双向的数据流,仅发送对应 FIN 包(如 SHUT_WR 关闭写端发 FIN),但不会释放套接字文件描述符和内核连接资源,也不会改变引用计数,仅用于 “告知对方本方已停止发数据,但仍需继续接收对方数据” 的半关闭场景。
简单来说,close 是一站式的连接关闭操作,内核兜底完成四次挥手和资源释放,适配绝大多数常规 TCP 编程;
shutdown 是精细化的数据流控制手段,不做资源释放,仅用于特殊的半关闭需求,而 TCP 四次挥手的核心流程,本质上始终由双方操作系统内核自动完成,无论是 close 触发还是 shutdown 触发,开发者都无需手动参与挥手的具体过程。
需要注意的是:
当客户端向服务端发送关闭连接请求,服务端就会处于 CLOSE_WAIT 的状态,依旧占用文件描述符,连接也没有释放,这就带来如果不关,可用的文件描述符就会越来越少,这就是文件描述符泄漏问题,文件描述符也是有限的资源的!所以 fd 用完了,就必须要关掉!
主动断开连接的一方,在将最后一次发送 ACK 的时候,就代表着四次挥手完成了,但是不能说主动方就直接变为 CLOSED 状态而是转为 TIME_WAIT 状态,后续还需要等待一定的时间,才能设置为 CLOSED 状态!
现在做一个测试,首先启动 server,然后启动 client,然后用 Ctrl+C 使 server 终止,这时马上再运行 server,结果是:
这是因为,虽然 server 的应用程序终止了,但 TCP 协议层的连接并没有完全断开,因此不能再次监听同样的 server 端口。我们用 netstat 命令查看一下:

TIME_WAIT 的时间是 2MSL,也就是 2 倍最大报文生存时间。在 Linux 下,MSL 通常是 30 秒,所以 TIME_WAIT 一共是 60 秒。
TCP 协议规定,主动关闭连接的一方要处于 TIME_WAIT 状态,等待两个 MSL(maximum segment lifetime)(最大报文的存活时间)的时间后才能回到 CLOSED 状态。说人话就是,我把一个报文从我的主机发送到网络里,我历史上发送了很多报文,发送出去的报文,其中有一个统计数据,是用来衡量一个报文在网络里存放的最长时间,也就是将一个报文发送到网络当中,历史发了许多报文,基于历史做统计,我能大概估算出来,我发出去的报文曾经在网络存活的最长时间,这就是报文的最大存活时间。
为什么又是两个 MSL 呢?MSL 是 TCP 报文的最大生存时间,因此 TIME_WAIT 持续存在 2MSL 的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个 ACK 丢失,那么服务器会再重发一个 FIN。这时虽然客户端的进程不在了,但是 TCP 连接还在,仍然可以重发 LAST_ACK);因为是双向的,两个MSL就是保证两个方向上的报文能够在网络当中消散!
“保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失”,其实我们就是可以理解为有一种情况:发送的数据DATA没有丢,还在某一个路由器中排队,而且发送方已经判定超时重传了,后续甚至连接关闭了,报文还在网络中存活,也就是说一旦我们将连接全部关闭了,不考虑什么TIME_WAIT,假设四次挥手完成了,双方连接直接关闭,那么在网络中可能会残存上一次补发的数据,后来这个客户端立即再重启,端口和之前的一样,那么在网络中的报文就可能到服务端了,这个报文到来的时候,就可能会影响下一次连接的建立和通信的过程!
所以我们需要 TIME_WAIT,本质就是需要两个 MSL 的时间来确保安全性!
所以在 TIME_WAIT 期间,老端口就不能够被使用,如果对应的客户端想要再次重启,就需要强制更换端口号,就能保证历史上没有消散的报文,源端口和目的端口就和新建立的源端口和目的端口配不上,所以客户端和服务端就可以对这样的报文进行自动丢弃了!
在电商秒杀、618 / 双十一大促这种高并发场景下,如果服务器因为压力过大崩溃或主动重启,作为主动断开连接的一方,服务器会陷入大量 TIME_WAIT 状态,这些连接会占用端口和内核资源,导致服务器无法立刻重启绑定端口 —— 而电商场景耽误一秒就是巨额损失。
但我们完全可以在应用编程层面解决这个矛盾:既让 TCP 正常执行 TIME_WAIT 保证可靠性,又让服务器能立即重启。
解决方案就是在创建 socket 之后、bind 绑定端口之前,通过setsockopt()系统调用,将套接字选项SO_REUSEADDR 设置为 1,它的作用是允许程序绑定同一个端口(即使该端口正处于 TIME_WAIT 状态),让新的服务进程可以立刻复用端口启动,而无需等待几十秒的 TIME_WAIT 结束。
这是高并发服务器开发的标准必备优化,既不破坏 TCP 的可靠性,又解决了服务器快速重启的核心问题。
#include <sys/types.h>
#include <sys/socket.h>
int pot = 1;
int setsockopt(int listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
可是上面不是才刚刚说过吗?可能会有历史的报文会影响连接和通信?那肯定是可能的!
我们之所以敢用 setsockopt 设置 SO_REUSEADDR 让服务器复用端口、实现立即重启,哪怕主动断开连接的服务器有大量 TIME_WAIT 状态,也不用担心历史遗留报文干扰新连接的通信,核心原因就是:TIME_WAIT 的 2 个 MSL 等待策略只是 TCP 应对历史报文的第一道防线,而 TCP 报文里的发送序列号,才是应对历史陈旧报文、保证新连接通信安全的核心大杀器,哪怕跳过 TIME_WAIT 直接复用端口,序列号机制也能几乎百分百过滤掉旧报文,避免其对新连接造成影响。
TCP 序列号确实是发送端字节流的下标,但同一个端口、不同连接,这个起始下标(ISN)是随机重置的。上一次连接用的是 0~10000 这套编号,新连接直接跳到 23456789 这套全新编号,旧报文带着老编号过来,新连接一看不在当前接收窗口里,直接扔掉,所以就算复用端口也不会被旧报文干扰。
具体来说,TCP 协议设计时就考虑到了 “旧报文残留” 的问题,所以做了两个关键设计,让新连接能精准识别并丢弃旧报文:第一,TCP 的连接初始序列号(ISN)是随机生成的,不是固定从某个数值开始,每一次新建的 TCP 连接,双方协商的起始序列号都是完全不同的,这就从根源上让历史连接的旧报文序列号,几乎不可能和新连接的序列号重合;第二,TCP 通信过程中,服务器会始终维护一个期望接收的序列号范围(接收窗口),这个范围是和当前连接的通信进度、服务器接收能力强绑定的,比如三次握手完成后,服务器告知客户端自己的接收窗口大小是 5000 个字节,而新连接协商的客户端起始发送序列号是 1000,那服务器此时的接收窗口就限定为 1000~6000,只有序列号落在这个范围内的报文,服务器才会正常接收、处理并回复 ACK;但凡报文的序列号超出这个范围,不管是比 1000 小,还是比 6000 大,服务器都会直接判定为无效报文,二话不说直接丢弃。
每条新连接,都会自己维护一套独立的接收窗口,而且起始序号(ISN)是随机重新开始的,和上一条连接完全无关。
再精炼到最核心:
- 序列号 = 字节流下标 ✔
- 每条连接 = 独立一套字节流 + 独立一套序号 ✔
- 新连接窗口起始下标 ≠ 旧连接窗口起始下标 ✔
所以旧报文就算过来,序号对不上新窗口,直接被扔掉,不会干扰新连接。
举个例子,服务器上一次连接崩溃前,有一个还没被处理的陈旧报文,其序列号是 7000,当服务器重启复用端口、和新客户端建立连接后,这个旧报文才姗姗来迟到达服务器。此时新连接的接收窗口是 1000~6000,旧报文的 7000 完全在窗口之外,服务器会直接识别出这个序列号和当前新连接的期望序列号不匹配,直接将其丢弃,不会对新连接的正常通信产生任何干扰;哪怕是序列号偏小的旧报文,比如 500,同样会因为落在接收窗口之外被直接过滤,根本不会被服务器处理,也不会返回错误的 ACK,自然就不会影响新连接的数据包传输、确认应答等核心流程。
当然,我们必须承认,从理论上来说,存在一种极端情况:旧报文的序列号恰好落在了新连接的接收窗口范围内,这种情况下确实可能出现旧报文干扰新连接的问题,但这种概率极低 —— 一方面因为初始序列号是随机生成的,数值跨度极大,旧报文序列号和新连接窗口重合的可能性微乎其微;另一方面,就算真的出现这种极端情况,TCP 协议还设计了时间戳选项(TCP Timestamps)等补充策略,服务器可以通过报文的时间戳进一步判断报文是否为历史陈旧报文,哪怕序列号匹配,时间戳异常的报文依然会被丢弃。
所以总结来说,我们在应用编程层面设置 SO_REUSEADDR,让服务器跳过 TIME_WAIT 的等待、实现立即重启,并不是放弃了 TCP 对历史报文的防护,而是因为 TCP 的序列号 + 接收窗口机制,已经形成了比 TIME_WAIT 更精准、更核心的防护体系,足以应对绝大多数历史报文问题;而 TIME_WAIT 只是一道额外的保险,序列号才是真正的核心防护手段。即便开启了端口复用,新连接的通信安全依然能得到充分保证,虽然理论上存在极端异常的可能,但实际生产环境中这个概率低到可以忽略不计,再加上 TCP 的其他补充策略,完全可以放心使用这个配置,这也是为什么电商大促、高并发秒杀等场景下,各大互联网公司的服务器都会默认开启 SO_REUSEADDR 的核心原因。
举个具体例子:客户端点击购买按钮发出请求,报文刚发到服务器时服务器突然崩溃,重启后通过 SO_REUSEADDR 复用了端口并建立新连接;旧的购买请求报文属于上一条连接,序列号和新连接的接收窗口完全不匹配,会被直接丢弃,这次购买操作因此失败,而 SO_REUSEADDR 只是保证这个旧报文不会被新连接误处理、不会造成重复下单或数据错乱,让新用户可以正常发起新的购买请求,并不负责恢复崩溃前没处理完的这笔订单。
客户端点击购买发送了一个序列号为 1000 的下单请求,服务器处理完订单后崩溃未回复 ACK,客户端重试,服务器重启复用端口后,若没有序列号校验,之前已处理过的旧下单报文延迟到达会被当成新请求再次执行,从而造成重复下单;但由于新连接使用随机初始序列号并维护独立接收窗口,这个旧报文的序列号会落在新窗口之外被直接丢弃,因此不会重复处理,也就避免了重复下单。
说这么多,简单来说就是:
TIME_WAIT 的作用确实是过滤历史旧报文,但它并不是唯一防线。真正的大杀器,是 TCP 报文里的序列号。
当我们开启 SO_REUSEADDR 让服务器快速重启后,即使网络中真的残留了上一次连接的旧报文,等到新连接建立时,新连接的初始序列号(ISN)是随机生成的,旧报文的序列号几乎不可能落在新连接的接收窗口范围内。服务器会严格校验序号:不在期望窗口内的报文,直接丢弃!
所以:旧报文就算真的到达新连接,序号不匹配 = 直接被扔掉,根本不会影响正常通信。虽然理论上存在极端冲突概率,但已经低到可以忽略不计。
再加上 TCP 还有时间戳、窗口检查、序列号范围校验等多层保护机制,SO_REUSEADDR 既安全又高效,完全可以放心使用。
MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同,在 Centos7 上,默认配置的值是 60s。可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看 msl 的值:
lfz@hcss-ecs-ff0f:~$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60
滑动窗口
对于滑动窗口的知识,我们前文做了铺垫:下面我们来回顾一下:
通过确认应答【ACK】策略,对每一个发送的数据段,都要给一个 ACK 确认应答。收到 ACK 后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差,尤其是在数据往返时间较长的时候。

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

为了能够让主机 A 向主机 B 发送批量消息,我们就需要在主机 A 中规定一个数字 —— 一个无需等待确认应答而可以继续发送数据的最大值,这个值就是窗口大小,那么这个窗口就是滑动窗口!
那么现在主机 A 可以一次性批量地向主机 B 发送多条报文了,那么主机 A 可以发送多少个报文是由什么决定的?
那就是由滑动窗口大小所决定了!
那么什么是滑动窗口啊?在哪里体现的滑动窗口啊?

在 TCP 协议中,我们知道 TCP 有发送缓冲区和接收缓冲区:数据的实际发送时机并不由用户直接控制,因为调用 send/write 只是把待发送数据从用户态拷贝到本机 TCP 的发送缓冲区,真正什么时候发、发多少,是由操作系统内核和滑动窗口机制共同决定的。
滑动窗口的作用是允许发送方在未收到对方 ACK 应答前,连续发送多段数据,它本质上就是主机 A 发送缓冲区中的一段逻辑范围,这段范围里的数据可以被流水线式地发送出去,无需等待前序报文的确认,从而提升传输效率。【下面我们来看看滑动窗口是如何做到的允许发送方未收到应答可以发送数据】

我们其实可以将缓冲区看成:char 类型的一维数组:
char outbuffer[N]; // 逻辑上的认知
然后滑动窗口就有相对应的 start 和 end 的下标,[start,end] 以内的就是直接可以向对端主机 B 发送的数据了!

正因为有滑动窗口的存在,就将发送缓冲区分成了三部分:
-
已发送已确认
-
可以直接发,暂时不要应答
-
待发送/未发送

左边部分:已发送、收到 ACK 的,不就是这部分数据无效了,这部分空间可以再被利用了!所以在网络通信中,我们不需要刻意清空缓冲区,只要让数据无效就可以,只要落在滑动窗口的左侧,就体现为无效数据了!【效率提升的体现】
我们知道,序号在发送的轮次中数字是不断增大的,也就意味着滑动窗口未来需要向右滑动,那么滑动窗口的本质不就是让 start 和 end 下标不断增加嘛!
滑动窗口以内的数据是可以直接发送的,所以滑动窗口的大小是由什么来决定的?
滑动窗口的定义是可以直接发送、暂时不需要应答。如果把滑动窗口扩得太大,就可能导致给对方发送太多数据,对方来不及接收;相反,如果太小,单次发送数据量太少,效率就低。所以滑动窗口的大小最终是由对端的接收能力决定的!
所以!滑动窗口的本质是流量控制的具体实现方案!!!滑动窗口是决定发多少的依据!
我们之前认识到 TCP 协议中的窗口字段,对端 ACK 会告诉发送端当前的接收能力。在收到对端对应的报文时,怎么调整滑动窗口对应的大小呢?
start = 对端ACK的报文中的确认序号
end = start + 对端给我通告的窗口大小
接下来,我们来谈谈几个问题:
滑动窗口可以向左滑动吗?
滑动窗口是不会向左滑动的,因为从概念上讲,滑动窗口的左侧是已发送已确认数据,如果将滑动窗口向左滑动,就会将已发送已确认的数据再发送一遍,这是不合理的!况且还有上面的因为对端缓冲区接受能力促使的右移的计算方法,显然是不能向左移动的!
滑动窗口,可以变大吗?可以变小吗?可以不变吗?可以为 0 吗?
滑动窗口的大小是一个数字,通常用字节(Bytes)来表示。它反映了接收方当前可用的缓冲区大小。窗口大小会根据接收方的缓冲区情况动态调整。
-
窗口大小增大:表示接收方的缓冲区空间增加,发送方可以发送更多的数据。
-
窗口大小减小:表示接收方的缓冲区空间减少,发送方需要减少发送的数据量。
-
窗口大小不变:表示接收方的缓冲区空间没变,发送方可以按照上一次的数据量进行发送。
-
窗口大小为0:表示接收方的缓冲区已满,发送方需要停止发送数据,直到接收方处理完部分数据并释放缓冲区。(样例:
start++,end不变,直到start == end)
如果报文丢了怎么办?滑动窗口,会不会跳过报文进行应答?(重点)

我们假设当前的滑动窗口的范围是[1001,5001],假设发送1001到2000的报文,没有收到对应的应答,那么我们将来肯定是要将该报文进行重发了,那么没有收到对应的应答,滑动窗口会进行更新吗?
对于丢包问题,宏观上无非就三种情形:实际丢包肯定更复杂,是下面三种情况的自由组合!
-
在滑动窗口的数据段中,最左侧的对应报文丢失。
-
中间的报文数据丢失。
-
最右侧的对应的报文丢失。
对于最左侧的情形: (最左侧就是要点)

情况一: 数据包丢了。

假设主机 B 收到了[2001,5000]的三个报文,然而[1001,2000]的最左侧的报文数据丢失了,那么对没有丢失的三个报文的应答的确认序号应该是多少呢?
填的都是1001!!!所以滑动窗口将来左侧不做更新,即start的位置没有发生改变,因为确认序号的意思是该确认序号之前的报文已经全部收到了!
当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,就是在提醒发送端 “下一次应该从序号为1001的字节数据开始发送”。如果发送端连续收到三次确认序号为1001的响应报文,此时就会将1001-2000的数据包重新进行发送。此时当接收端收到1001-2000的数据包后,就会直接发送确认序号为6001的响应报文,因为2001-6000的数据接收端其实在之前就已经收到了。这种机制被称为高速重发控制,也叫做快重传。
需要注意的是,快重传需要在大量的数据重传和个别的数据重传之间做平衡,实际这个例子当中发送端并不知道是1001-2000这个数据包丢了,当发送端重复收到确认序号为1001的响应报文时,理论上发送端应该将1001-5000的数据全部进行重传,但这样可能会导致大量数据被重复传送,所以发送端可以尝试先把1001-2000的数据包进行重发,然后根据重发后的得到的确认序号继续决定是否需要重发其它数据包。
在TCP中,快重传和超时重传都是应对报文丢失的重传机制,但分工不同:
- 快重传:是效率优化手段,触发条件是收到 3 个及以上重复的
ACK,说明某个报文大概率丢失,无需等待超时,直接重传对应报文,以此减少等待时间、提升传输效率。 - 超时重传:是兜底保障机制,触发条件是等待
ACK超过设定的超时时间,只要在超时前没收到确认,就会重传。
如果只发送了 2 个报文,其中 1 个丢失,接收端最多只能回复 1 个重复ACK,远达不到快重传要求的 3 个重复ACK阈值,此时快重传无法触发,就必须依靠超时重传来兜底,保证丢失的报文最终能被重传。
所以:
- 快重传是在丢包场景下,尽量减少等待时间、提升重传效率的优化方案;
- 超时重传是
TCP可靠性的最后一道防线,无论什么情况,只要超时就会重传,保证数据不会永远丢失。
情况二: 数据包已经抵达,ACK 应答丢包。

如果[1001,2000]不是报文数据丢了,而是应答丢了,也就是说主机 B 这四个报文全部收到了,只不过在应答的时候,对于最左侧的应答报文主机 A 没有收到,那么对于另外三个报文的应答的确认序号应该是多少呢?
收到[2001,3000]填的是3001,收到[3001,4000]填的是4001,收到[4001,5000]填的是5001!所以即便是[1001,2000]对应的ACK丢失了,只要后面的报文是收到的,就会让滑动窗口向右更新。
所以,对于最左侧的丢失:有两种情况:
-
数据真的丢了:滑动窗口左侧不变
-
数据收到了,应答丢了:滑动窗口正常工作(因为确认序号的定义就是确认序号之前的已经全部收到了,难道不是吗?只是说应答丢了,不影响!!!😝)(在发送端连续发送多个报文数据时,部分
ACK丢包并不要紧,此时可以通过后续的ACK进行确认)
还有通过丢包问题,我们就可以理解说:TCP 发出的报文,暂时还没有应答的时候,必须让对应的报文暂时保存起来,以方便后续的重传!!!那么对已发送的报文保存起来,是保存到哪里?如何理解保存?
滑动窗口的定义不就是把报文数据统一发出去,可以暂时不需要应答,但是在将滑动窗口中的数据发送出去的时候,没有任何应答时,滑动窗口不会向右滑动,当收到响应的应答的时候,才有可能向右滑动,就像上面丢了最左侧的就start就不会动了😊所以向右滑动的本质就是把对应数据删除!!!所以保存在滑动窗口当中!怎么理解这个保存,就是这个窗口不要动!
这也就是为什么 TCP 必须要有发送缓冲区,而 UDP 可以不需要有发送缓冲区,因为 UDP 是不可靠的!
所以超时重传和快重传的底层支持是滑动窗口!!!不仅仅与流量控制有关,还与重传有关!!!
如果中间报文丢失,意思就是左边报文收到了,滑动窗口首先是会向右滑动的!!!本质就转化为最左侧报文丢失的情况了!!!最右侧丢失何尝不也是会转化为最左侧报文丢失了嘛!!!
报文丢失 --- 最左侧丢失 --- 超时重传 / 快重传,我们注意的是:
确认序号一定是连续的!!!--- 发送必须连续发送!!!--- 滑动窗口不能跳跃!!!(支撑)
【看到这里,ACK 机制是不是很牛😍】
滑动窗口,一直向右,会不会溢出?
这肯定是不会溢出的!重点是我们如何理解为什么不会溢出!我们对于缓冲区的数组认知其实是抽象出来的,我们可以将其再加上一层抽象认知:我们可将 char 类型的数组想象成一个环形区域!(TCP 确实也是这么设计的,但是它的环形并不是基于数组的,底层的报文都是以队列的形式呈现的,报文节点不连续。)
-
数据进入是先进先出,行为上像队列
-
但它不是数组实现的环形队列,而是链表式队列
这好像和上面分成三部分(已发已收 - 待应答 - 待发)冲突,不过在环形中有一个分界点,一样的还是左侧已发送已应答,右边待发送!
流量控制
其实之前的文章的内容中基本就已经将流量控制说完了,我们可以复习一下,顺便补充几点知识!
接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。在这里,其实并不影响可靠性本身,但是本质上这是对资源的一种浪费(浪费了那么多电力,带宽等等网络资源!“泥他🐎不要了!?”),因此 TCP 支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。(UDP 是没有考虑这种问题的,只会无脑 sendto)
-
接收端将自己可以接收的缓冲区剩余空间的大小放入
TCP首部中的 “16 位窗口大小” 字段,通过ACK报文通知发送端。 -
窗口大小字段越大,说明网络的吞吐量越高。
-
接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
-
发送端接收到这个窗口之后,就会减慢自己的发送速度。
-
如果接收端缓冲区满了,就会将窗口置为 0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
那么下面有几个子问题需要解决:
在 TCP 三次握手的时候,难道第三次握手只发送 ACK 吗,ACK 可以携带数据吗?
其实是可以的!因为合理!客户端发送 ACK 出去的时候,就代表客户端三次握手就已经完成了!那么客户端可能就会发现自己还有数据,那么客户端就可能将应答和用户数据做捎带应答!【这何尝不是流量控制的一种体现】
第一和第二次可以携带数据吗?
不可以!!!因为不管是第一次还是第二次,双方三次握手都不算完成!在前两次握手发送的是报头,这样也就可以知道了双方的当前缓冲区的剩余空间情况,为流量控制做好了准备,后续发送数据的时候就可以正常进行流量控制了!!!
如果主机 A 给主机 B 发送消息,主机 B 上层一直没有读取对应的数据,直到将自己主机 B 的接收缓冲区打满了,那么主机 A 就会停止向主句 B 发送数据,我们就可以将滑动窗口设置为 0,主机 A 停止发送!
那么什么时候可以继续进行数据包的传送与接收呢?(对方上层什么时候才取走呀!取走了主机 A 他知道吗?😭)两种办法:
第一种策略:
主机 A 也没有办法,只能周期性的给主机 B 发送窗口探测,也就是发送一个携带序号的 TCP 报头,是报头,不携带正文数据,所以并不会放到主机 B 的接收缓冲区当中,所以不会导致丢包,根据 TCP 定义,没有数据,但是也需要应答,只要应答,就会将主机 B 的缓冲区剩余空间大小通告给主机 A!所以通过这种方式,就可以知道对端窗口有没有更新!
窗口探测报文是一个特殊的 TCP 报文,它不携带数据(数据长度为 0),但包含 TCP 报头。
它的主要目的是触发接收端发送一个 ACK 报文,从而获取接收端当前的窗口大小。
第二种策略:
在 TCP 通信中,通信双方的地位是完全对等的,这和 HTTP 那种 “客户端只能主动请求、服务器只能被动应答” 的不对等模式完全不同。TCP 是全双工通信,连接一旦建立,主机 A 可以随时发数据给主机 B,主机 B 也可以随时主动发数据给主机 A,不需要等待谁的请求。正因如此,当主机 B 的接收窗口大小发生变化(比如缓冲区腾出了新空间),它不需要等待主机 A 询问,就可以主动向主机 A 发送窗口更新通知,告诉对方 “现在可以发更多数据了”。
而这一切能够实现的根本原因,就是 TCP 是一条长连接,并且服务器端通过 accept() 获取到了一个与客户端唯一绑定的套接字文件描述符(connfd)。这个文件描述符就相当于双方通信的 “专属通道”,内核会通过它找到对应的 TCP 连接、发送缓冲区、接收缓冲区、滑动窗口以及连接状态信息。只要连接不关闭,任何一方都可以通过这个对应的 sockfd 随时向对方发送数据,不需要重新建立连接,也不需要等待请求,真正实现全双工、对等、自由的双向通信。
代码体现如下:
只做一件事:TCP 全双工 → 服务器主动发消息给客户端
// 服务器代码 —— 体现:accept 返回的 fd 绑定连接,服务器可以主动发数据
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
// 1. 创建 socket + bind + listen(省略细节)
int lfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
listen(lfd, 10);
// 2. 接受客户端连接 → 返回【与客户端绑定的文件描述符 cfd】
// ✅ 这个 cfd 就代表一条【全双工长连接】
int cfd = accept(lfd, nullptr, nullptr);
printf("客户端已连接 cfd = %d\n", cfd);
// ==============================================
// ✨ 核心体现:服务器【主动】给客户端发数据!不是等请求!
// TCP 全双工:双方地位对等,谁都能主动发
// ==============================================
const char* msg = "我是服务器,我主动发消息给你!";
send(cfd, msg, strlen(msg), 0); // 服务器主动发!
// 也可以随时接收客户端发来的消息
char buf[1024];
recv(cfd, buf, 1024, 0);
close(cfd);
close(lfd);
return 0;
}
补充一下:可能上面的连接管理机制大家可能就有点疑问了:
我们在 TCP 编程时,服务器 accept 之后只拿到一个数字 sockfd,它到底是怎么和一条真实的 TCP 连接关联起来的?为什么用这个数字就能收发数据?
sockfd 本质只是操作系统返回给应用程序的一个数字编号,并不是连接本身;真正的 TCP 连接是在内核中创建的,包含 TCB 控制块、发送缓冲区、接收缓冲区、滑动窗口、双方 IP 端口、连接状态等所有信息。操作系统内部维护了一张文件描述符表,会把这个数字 sockfd 直接映射到内核里的 struct socket 结构体,而这个结构体又和一条完整的 TCP 连接强绑定。所以当我们使用这个 sockfd 调用 send/recv 时,内核会通过这个数字找到对应的结构体,再找到对应的 TCP 连接,最终把数据写入发送缓冲区或从接收缓冲区读取,实现双向通信。
在真实的情况下,这两种策略同时被采用!
如果单独的使用第二种策略,就会导致:如下的类似死锁的现象:

我们添加第一种策略!

这时候,我们加上第一种策略的话, 就可以很好的相辅相成了!不仅保证了可靠性,还考虑了性能!!!

接收端如何把窗口大小告诉发送端呢?回忆我们的 TCP 首部中,有一个 16 位窗口字段,就是存放了窗口大小信息。
那么问题来了,16 位数字最大表示 65535,那么 TCP 窗口最大就是 65535 字节么?
实际上,TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M,实际窗口大小是窗口字段的值左移 M 位。
拥塞控制
一个例子:发送方向接收方一次发送 1000 个报文,报文丢了 2,3 个,那么很正常,发送方补发就可以了,但是如果 1000 个报文,就 2,3 个收到,其他全部丢了呢?!就好比大学期末考试,100 个人挂科 2,3 个很正常,可能就是没好好学,但是如果挂了 98 个人的话,那很可能就是阅卷老师的问题了。
所以,对于丢包数量,丢包相对多,丢包相对少是会给出不同的结论!
所以 TCP 不仅仅考虑了双方主机的问题,还考虑了网络本身的问题!但是请不要幻想 TCP 能将各种网络问题怎么解决!如果网络中的硬件设备,比如说路由器挂掉了,运营商大量机器挂掉了等等问题,能让 TCP 干啥?!是干不了的!所以 TCP 说他考虑网络问题,其实是只能考虑一些能恢复的网络问题,这是在硬件无障碍的前提下!
在我们进行网络通信时,如果出现了少量的数据包丢失,在我们看来,就是这几个报文的问题,是不是路由器走错了,是不是报文转发的时候,到对端校验和失败了...... 反正就是这一两个报文的问题,如果一旦丢包太多了,那么发送方就会判定网络出现问题!网络出现拥塞问题!
那么发送方判定出网络拥塞,这些报文还需不需要重发呢?
是不能立即重发的!!! 就像一个十字路口已经堵得不成样了,还让进去吗!!!如果立即重发就会增加网络的压力负载,让网络变得更加拥堵!就用客户端 - 服务端,客户端肯定是有多个的!就像我们在宿舍同一个网络下访问 CSDN 的时候,卡的不仅仅是自己,舍友肯定也卡了,那么所有的主机(客户端),TCP 就会将所有主机接管,大家会采用相同的策略处理!就需要进行拥塞控制!
想要理解拥塞控制,我们就需要时刻告诉自己,拥塞控制,会让发送端的多个主机都采用拥塞控制的策略!--- 慢启动!
虽然滑动窗口能够高效可靠的发送大量的数据,但如果在刚开始阶段就发送大量的数据,就可能会引发某些问题。因为网络上有很多的计算机,有可能当前的网络状态就已经比较拥塞了,因此在不清楚当前网络状态的情况下,贸然发送大量的数据,就可能会引起网络拥塞问题。
因此 TCP 引入了慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。(刚开始大家都很慢)

当判定网络拥堵了,就会引入慢启动机制,前期慢一点,本质就是为了探测网络是否慢慢恢复,一旦判定出网络已经不怎么卡了,后期就会慢慢恢复网络通信过程!
每收到一个 ACK 应答拥塞窗口的值就加一,此时拥塞窗口就是以指数级别进行增长的,如果先不考虑对方接收数据的能力,那么滑动窗口的大小就只取决于拥塞窗口的大小,此时拥塞窗口的大小变化情况如下:
| 拥塞窗口 | 滑动窗口 |
|---|---|
| 1 | |
| 2 | |
| 4 | |
| 8 | |
| ... | ... |
可是上面不是说好了,发送多少数据,由滑动窗口决定啊!但是滑动窗口不是受对方当前的接受能力的影响吗?
所以为了支持拥塞控制算法(慢启动),我们需要再提出一个新的概念 --- 拥塞窗口!
拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。(一个临界值,值以下,网络较大概率不阻塞,值以上,网络可能阻塞!)
网络肯定是变化的,这也就决定了这个拥塞窗口一定要进行跟更新变化!
所以,我们对滑动窗口的认知就要更丰富,更细致了:
滑动窗口 = min(对方接收缓冲区剩余空间的大小,拥塞窗口);
刚开始发送数据的时候拥塞窗口大小定义为 1,每收到一个 ACK 应答拥塞窗口的值就加一。每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小。所以我们发送数据的时候,发送数据的量,不会一直进行指数级增长,即便是拥塞窗口再怎么增长,再大的话,主要矛盾就会转化为对方目前的接受能力的大小了!
可是,拥塞窗口大小这个数字总不能一直指数增长吧?32 位机器下会很快就会溢出了!利用指数增长,前期慢,后期快,目的是为了尽快探测并恢复网络通信,在我们当前计算机内,拥塞窗口的大小是衡量网络是否会拥堵的一个指标!!!再说了,网络是变化的,也就决定了这个拥塞窗口的大小是一定要进行更新变化的!然而指数增长是为了应对异常的,是用来恢复和处理网络问题的,在拥塞窗口不做指数增长之后,还是需要变化的,这是取决于网络变化!!!其实变化就是由指数增长转化为线性增长了!这个线性变化本质就是在不断探测新的拥塞窗口的值!我们将由指数增长转为线性增长的转折点称为 ssthresh
也就是说:
为了避免短时间内再次导致网络拥塞,因此不能一直让拥塞窗口按指数级的方式进行增长。此时就引入了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长。当 TCP 刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。在每次超时重发的时候,慢启动阈值会变成当前拥塞窗口的一半,同时拥塞窗口的值被重新置为 1,如此循环下去。拥塞窗口在增加,我们发送的数据量一定在增加吗?不一定的哈?当拥塞窗口增加到一定层度的话,主要矛盾就会变为对端的接受能力了!上面才说过!是取 min!拥塞窗口特别大不就是网络特别好嘛!
所以,当触发网络拥塞时,TCP 会进入慢启动阶段,拥塞窗口会重新开始指数增长。慢启动阶段的指数增长是为了快速探测网络的可用带宽,直到拥塞窗口达到一个阈值(ssthresh)。每一次都有应答,一旦达到阈值,TCP 会进入拥塞避免阶段,此时拥塞窗口的增长速度会变慢(通常是线性增长)。 因为发送了 10 轮,100 轮,1000 轮.... 都不发生丢包,时间越久,就代表网络越好,那么拥塞窗口值就应该越大,拥塞窗口在线性增长本质就是在衡量当前网络的通畅程度的!但是,在发送数据的时候,突然发生了网络拥塞,这时候就需要重置慢启动,就是在下图的 24 发生拥塞了,本质不就是我们探测出来了的当前的拥塞窗口了嘛!然后重新开始,除了是支持慢启动,本质也是重新开始探测网络健康!而且下一次从指数探测到线性探测的阈值根据算法规定,我们新的ssthresh值由上一次网络拥堵时的窗口大小去 "÷ 2"
如下图:

不过在极端情形下,就是网络非常好,也就是说拥塞窗口在线性探测的过程中,会一直增大吗?
连续正常发送一个月的数据,不就是网络很好嘛!从逻辑上来说,就是需要不断增大!要大就大到整型的最大值就不变呗!但是理论上是不会一直增大的! 从带宽利用率的角度来看,即使网络条件很好,TCP 拥塞窗口也不会无限制增长。因为 TCP 的拥塞控制机制会动态调整窗口大小,使其在充分利用可用带宽的同时,避免过度占用导致网络拥塞,从而保持网络的稳定性和高效性。其实再大也就没有什么意义了!
延时应答
为什么要延迟?
每次接收方回复确认应答的时候,会在 TCP 头部当中携带窗口大小,来告知发送方自己的接收能力,发送方是要通过接收方通告的窗口大小来调整发送窗口的。假设不考虑网络的情况下:
接收方通告的窗口大小越大,则发送窗口越大,则发送方发送的数据越多接收方通告的窗口大小越小,则发送窗口越小,则发送方发送的数据越少
如果接收方收到数据就立即返回 ACK 应答,这时候的缓冲区中接收的数据许多还没能够处理,缓冲区的剩余大小就是窗口大小,所以此时返回的窗口值会比较小。在收到数据以后并不立即返回确认应答,延迟一小会,等待缓冲区中数据被处理,接收缓冲区空间变大一些,再进行应答(此时确认应答的窗口值会大一些)—— 这是延迟 / 延时应答。
假设接收端缓冲区为 1M。一次收到了 500K 的数据;如果立刻应答,返回的窗口就是 500K;但实际上可能处理端处理的速度很快,10ms 之内就把 500K 数据从缓冲区消费掉了;在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;如果接收端稍微等一会再应答,比如等待 200ms 再应答,那么这个时候返回的窗口大小就是 1M。
窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥堵的情况下尽量提高传输效率!
那么所有的包都可以延迟应答吗??不是的,与以下有关:
-
数量限制:每隔 N 个包就延时应答一次;
-
时间限制:超过最大延迟时间就应答一次;
具体的数量和超时时间,依操作系统不同也有差异;一般 N 取 2,超时时间取 200ms;
在系统中,有一个固定的定时器每隔 200ms 会来检查是否需要发送 ACK 包,这样做有两个目的。
-
这样做的目的是
ACK是可以合并的,也就是指如果连续收到两个TCP包,并不一定需要ACK两次,只要回复最终的ACK就可以了(每一次数据都未必需要应答了),可以降低网络流量; -
如果接收方有数据要发送,那么就会在发送数据的
TCP数据包里,带上ACK信息,也就是捎带应答,这样做,可以避免大量的ACK以一个单独的TCP包发送,减少了网络流量。

捎带应答
我们之前对捎带应答已经有了比较多的认识了,捎带应答本质就是为了用来提高效率的!下面我们来稍微回顾一下:
在延迟应答的基础上,我们发现,在很多情况下,客户端服务器在应用层也是 “一发一收” 的。这意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you”。那么这个时候 ACK 就可以搭顺风车,和服务器回应的 “Fine, thank you” 一起回给客户端。

TCP 小结
| 分类 | 内容 |
|---|---|
| 可靠性 | 校验和、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制 |
| 提高性能 | 滑动窗口、快速重传、延迟应答、捎带应答 |
| 其他 | 定时器(超时重传定时器、保活定时器、TIME_WAIT 定时器等)(在系统部分,信号专题,闹钟alarm就是很好的例子,说明操作系统本身就是可以计时的!) |
下面我们来谈谈相关的其他话题:
面向字节流
这个我们之前也讲过了! 我们主要还是回顾一下:
创建一个 TCP 的 socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区:
-
调用 write 时, 数据会先写入发送缓冲区中;
-
如果发送的字节数太长, 会被拆分成多个 TCP 的数据包发出;
-
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
-
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
-
然后应用程序可以调用 read 从接收缓冲区拿数据;
-
另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在, TCP 程序的读和写不需要一一匹配, 例如:
-
写 100 个字节数据时, 可以调用一次 write 写 100 个字节, 也可以调用 100 次 write, 每次写一个字节;
-
读 100 个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次 read 100 个字节, 也可以一次 read 一个字节, 重复 100 次;
粘包问题
【八戒吃馒头例子】
-
首先要明确,粘包问题中的“包”,是指的应用层的数据包。
-
在 TCP 的协议头中,没有如同 UDP 一样的“报文长度”这样的字段,但是有一个序号这样的字段。
-
站在传输层的角度,TCP 是一个一个报文过来的。按照序号排好序放在缓冲区中。
-
站在应用层的角度,看到的只是一串连续的字节数据。
-
那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
那么如何避免粘包问题呢?归根结底就是一句话,明确两个包之间的边界。(其实就是自定义协议 --- 用户自己来解决!)
-
对于定长的包,保证每次都按固定大小读取即可;例如上面的 Request 结构,是固定大小的,那么就从缓冲区从头开始按 sizeof(Request)依次读取即可;
-
对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置;
-
对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可);
思考:对于 UDP 协议来说,是否也存在“粘包问题”呢?
-
对于 UDP,如果还没有上层交付数据,UDP 的报文长度仍然在。同时,UDP 是一个一个把数据交付给应用层。就有很明确的数据边界。
-
站在应用层的站在应用层的角度,使用 UDP 的时候,要么收到完整的 UDP 报文,要么不收。不会出现“半个”的情况。
TCP 异常情况
进程终止:进程终止会释放文件描述符,仍然可以发送 FIN。和正常关闭没有什么区别。
双方建立的两个连接不就是双方(客户端和服务端的两个进程建立的嘛,就好比服务器的 accpet 的文件描述符就是一个连接,相关话题我们后续的文章会说到!!!)(进程退出,进程打开的文件基本就不在了,引用计数到0的时候,这时候连接就会自动进行自动挥手)
机器重启:和进程终止的情况相同。因为关机的话,操作系统会关闭所有的进程!
机器掉电/网线断开:对于被拔掉网线的一方,因为操作系统是软硬件资源的管理者,就会立马意识到当前的网络连接出现问题了!硬件上识别到之后,软件上理所应当是对应的连接没了!就像我们浏览器上,也就是客户端的网络断开了,会出现:

可是这个客户端是没有机会和服务器发生四次挥手的!因为网线一拔,都来不及发送报文了!所以接收端也就是服务端还认为连接在,一旦接收端有写入操作(服务端向客户端发消息),接收端发现连接已经不在了,就会进行 reset(这个就是我拔掉网线后,重连了,可是通信之前需要进行三次握手呀,所以发reset进行连接重置!)。即使没有写入操作,TCP 自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
对于连接的保活机制其实是 TCP 协议报头选项的一种功能!是 TCP 自带的,是大几十分钟级别的,或者小时级的!但是对于保活机制,我们基本都是在应用层自己完成的!来符合应用层的需要与控制!
另外,应用层的某些协议,也有一些这样的检测机制。例如 HTTP 长连接场景中,也会定期检测对方的状态。例如 QQ,在 QQ 断线之后,也会定期尝试重新连接。
综上:TCP 对于连接异常,有很强的容错性!
基于 TCP 应用层的协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
- 当然,我们自己写的TCP程序时自定义的应用层协议!就如前面的网络版本计算器!
TCP VS UDP
TCP 和 UDP的优缺点不能简单绝对地进行比较。
- TCP 用于可靠传输的情况,适用于文件传输、重要状态更新等场景;
- UDP 用于对高速传输和实时性要求较高的通信领域,例如早期的 QQ、视频传输等,另外 UDP 还可以用于广播。
归根结底,TCP 和 UDP 都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
我们在选择协议上,到底是选择 TCP 还是 UDP,基本的原则是:
只要在网络通信时,要求的是足够简单,而且对报文丢包的容忍度比较高,这就可以选择UDP,就像直播,允许点画面糊掉了,没有多大关系!除此之外,一律选择 TCP!就好比登录注册,支付转账等等!
用 UDP 实现可靠传输(经典面试题)
有一种特殊要求:TCP 通信的时候稍微优点过重了!还需要建立连接之类的,我不想建立连接,而且我还要用 UDP 来保证基本的可靠性,在应用层,如何使用 UDP 来实现对应的可靠性?
在UDP协议的基础上实现可靠传输是一个经典的面试题,通常考察面试者对 TCP 可靠性机制的理解以及对协议设计的思考。UDP 本身是一种无连接、不可靠的传输层协议,不保证数据包的顺序、完整性和可靠性。而 TCP 的可靠性机制包括序列号、确认应答、超时重传、拥塞控制等。如果要在应用层基于 UDP 实现类似 TCP 的可靠传输,可以参考以下设计思路:
1. 引入序列号
序列号的作用是为每个发送的数据包分配一个唯一的编号,接收方可以根据序列号来判断数据包是否丢失、重复或乱序。
-
实现方式:在每个UDP数据报中添加一个序列号字段。发送方在发送数据时,为每个数据包依次分配序列号(例如从1开始递增)。
-
优势:接收方可以通过序列号检测数据包的顺序,如果发现序列号不连续,可以判断中间的数据包丢失。
2. 确认应答(ACK)
发送方需要接收方的确认应答来确认数据包是否被正确接收。如果没有收到确认应答,发送方可以重传数据。
-
实现方式:接收方在收到数据包后,发送一个确认应答(ACK)消息,ACK消息中包含已成功接收的最高序列号。发送方收到ACK后,知道该序列号及之前的数据包已被接收。
-
优势:通过ACK机制,发送方可以明确知道哪些数据包需要重传,从而提高传输的可靠性。
3. 超时重传
如果发送方在一定时间内没有收到确认应答,可以认为数据包可能丢失,需要重传。
-
实现方式:发送方为每个未确认的数据包设置一个计时器。如果在计时器超时后仍未收到ACK,就重传该数据包。
-
优势:通过超时重传机制,可以弥补UDP不保证数据包可靠传输的缺陷。
4. 滑动窗口机制
为了提高传输效率,可以引入滑动窗口机制,允许发送方在等待确认应答之前发送多个数据包。
-
实现方式:发送方维护一个滑动窗口,窗口大小表示可以发送但尚未收到确认的数据包数量。接收方也维护一个滑动窗口,用于接收并缓存乱序到达的数据包。
-
优势:滑动窗口机制可以提高数据传输的效率,减少等待确认应答的时间。
5. 数据分片与重组
如果数据量较大,需要将数据分片后通过UDP发送,接收方需要将分片的数据重新组合。
-
实现方式:在每个UDP数据报中添加分片标识和总分片数字段。接收方根据分片标识和总分片数将数据重新组合。
-
优势:通过分片与重组机制,可以支持大块数据的可靠传输。
6. 拥塞控制
为了避免网络拥塞,可以引入简单的拥塞控制机制,动态调整发送速率。
-
实现方式:根据网络的拥塞情况动态调整滑动窗口大小。如果检测到丢包,可以减小窗口大小;如果网络状况良好,可以适当增大窗口大小。
-
优势:拥塞控制可以避免发送方过度占用网络资源,提高网络的整体性能。
示例代码(伪代码)
以下是一个简化的伪代码示例,展示如何基于UDP实现可靠传输:
// 发送方
initialize sequence_number = 1
initialize timeout = 1 second
initialize window_size = 4
while (data_to_send) {
if (window_size > 0) {
send_udp_packet(data, sequence_number)
set_timer(sequence_number, timeout)
sequence_number += 1
window_size -= 1
}
if (timer_expired(sequence_number)) {
resend_udp_packet(data, sequence_number)
reset_timer(sequence_number, timeout)
}
if (receive_ack(ack_sequence_number)) {
if (ack_sequence_number >= expected_ack) {
window_size += 1
expected_ack = ack_sequence_number + 1
}
}
}
// 接收方
initialize expected_sequence_number = 1
initialize buffer = []
while (true) {
receive_udp_packet(data, sequence_number)
if (sequence_number == expected_sequence_number) {
deliver_data(data)
expected_sequence_number += 1
send_ack(expected_sequence_number - 1)
while (buffer contains sequence_number == expected_sequence_number) {
data = buffer.pop(sequence_number)
deliver_data(data)
expected_sequence_number += 1
}
} else if (sequence_number > expected_sequence_number) {
buffer.append(data, sequence_number)
send_ack(expected_sequence_number - 1)
}
}
总结
通过引入序列号、确认应答、超时重传、滑动窗口、分片与重组以及拥塞控制等机制,可以在应用层基于UDP实现类似TCP的可靠传输。在面试中,除了描述这些机制外,还可以结合实际场景讨论如何优化和调整这些机制,以应对不同的网络环境和应用需求。
从开始到现在,理论是理论,操作是操作,我们该如何将其有效的关联起来呢?
Linux 内核中 Socket 相关结构与完整流程详解
一、用户态 → 内核态的系统调用流程
当用户态程序调用 socket()、bind()、listen()、accept()、send()、recv() 等接口时,会通过 系统调用门(syscall)陷入内核态,由内核的网络协议栈完成实际操作。
以 socket() 为例,完整调用链路:
用户态: socket(AF_INET, SOCK_STREAM, 0)
↓(系统调用)
内核态: sys_socket()
↓
sock_create() // 创建内核 socket 结构体
↓
sock_alloc_file() // 分配 file 结构体,建立与 socket 的关联
↓
alloc_fd() // 分配进程文件描述符
↓
fd_install() // 将 fd 与 file 结构体绑定
↓
返回 fd 到用户态
二、内核态 Socket 创建与关联细节
1. sock_create():创建内核 Socket 核心结构
分配并初始化 struct socket 对象,代表一个网络套接字。
初始化 socket 的状态、类型(SOCK_STREAM/SOCK_DGRAM)、协议族(AF_INET)。
初始化等待队列 sk_sleep(__wait_queue_head),用于阻塞操作(如 accept、recv 阻塞等待)。
关联到具体协议层(TCP/UDP)的 struct sock 子类(如 struct tcp_sock、struct udp_sock)。
2. sock_alloc_file():建立文件层与 Socket 的双向绑定
分配 struct file 结构体,将其作为用户态与内核态的 “桥梁”。
双向关联:
file->private_data = (void*)socket_ptr:让file结构指向对应的socket。socket->file = file_ptr:让socket结构反向指向所属file。
初始化 file 操作函数集(file_operations),绑定 socket 对应的读写、关闭等回调。
3. alloc_fd() + fd_install():完成进程文件描述符关联
alloc_fd():在进程的 files_struct 中分配一个未使用的文件描述符编号(fd)。
fd_install():将 fd 与 file 结构体写入进程的 fd_array[],建立 fd → file 的映射。
最终用户态拿到的 fd,本质是进程 fd_array[] 的索引,内核通过它找到 file,再找到 socket,最终定位到 TCP 连接。
三、核心数据结构关系详解
1. 进程级结构:task_struct → files_struct → fd_array[]
task_struct:Linux 进程描述符,包含进程所有信息,其中 files 指针指向 files_struct。
files_struct:管理进程打开的所有文件,核心是 fd_array[] 数组。
fd_array[]:数组下标就是文件描述符(fd),数组元素是指向 struct file 的指针。
struct files_struct {
struct file *fd_array[NR_OPEN_DEFAULT]; // fd 到 file 的映射表
};
2. 文件层结构:struct file
代表一个 “打开的文件”,对 socket 而言,它是用户态 fd 与内核 socket 的桥梁。
关键字段:
private_data:指向关联的struct socket,是从文件层到网络层的入口。f_op:文件操作函数集,socket 场景下绑定为socket_file_ops。- 其他:文件状态、偏移量、引用计数等。
3. 网络 Socket 层:struct socket
代表一个网络套接字,是网络操作的核心入口。
关键字段:
file:反向指向所属struct file,完成双向绑定。sk:指向具体协议的struct sock(TCP/UDP 等),是连接状态与数据的核心载体。type:套接字类型(SOCK_STREAM/SOCK_DGRAM)。state:连接状态(TCP_ESTABLISHED/TCP_LISTEN 等)。sk_sleep:等待队列头,用于阻塞 I/O(如accept等待连接、recv等待数据)。
4. 传输控制块层:struct sock 及其子类
struct sock 是所有协议的通用套接字控制块,TCP/UDP 会基于它派生出具体结构(C 语言多态实现):
通用字段:
sk_rcvqueue/sk_write_queue:接收 / 发送缓冲区队列(struct sk_buff_head),存储待收 / 待发的数据包。sk_state:连接状态(对应 TCP 状态机)。sk_family:协议族(AF_INET/AF_INET6)。sk_reuse:端口复用选项(SO_REUSEADDR)标记。
TCP 子类 struct tcp_sock:
- 扩展 TCP 特有字段:滑动窗口、序列号、确认号、重传队列、拥塞控制状态、TIME_WAIT 计时器等。
UDP 子类 struct udp_sock:
- 扩展 UDP 特有字段:无连接状态、端口信息等。
INET 子类 struct inet_sock:
- 承载 IP 层信息:源 / 目的 IP、源 / 目的端口、TTL 等,是
struct sock到 IP 层的桥梁。
四、结构间完整映射链路(最核心)
用户态: fd = 3
↓
进程: task_struct → files_struct → fd_array[3] → struct file*
↓
文件层: struct file → private_data → struct socket*
↓
网络层: struct socket → sk → struct sock*(具体为 struct tcp_sock/struct udp_sock)
↓
连接层: struct sock → 包含收发缓冲区、滑动窗口、序列号、连接状态、对端IP/端口等
以 send(cfd, buf, len, 0) 为例,内核执行流程:
- 用户态调用
send,陷入内核sys_send()。 - 用
cfd查files_struct → fd_array[cfd],找到struct file。 - 从
file->private_data拿到struct socket。 - 从
socket->sk拿到struct sock(TCP/UDP 控制块)。 - 将用户态
buf数据拷贝到sk->sk_write_queue(发送缓冲区队列)。 - 触发 TCP 滑动窗口机制,封装成报文段,交给 IP 层发送。
五、关键特性的结构支撑
1. 全双工通信
struct sock同时维护sk_rcvqueue(接收队列)和sk_write_queue(发送队列),双方可独立收发数据。accept()返回的cfd绑定专属struct socket → struct sock,每个连接有独立的缓冲区与状态。
2. 阻塞 / 非阻塞 I/O
struct socket → sk_sleep是等待队列头,阻塞操作(如recv无数据)会将进程挂入此队列,直到数据到达或超时。- 非阻塞模式下,直接返回
-EAGAIN,不挂起进程。
3. 端口复用(SO_REUSEADDR)
struct sock → sk_reuse标记端口复用选项,允许新 socket 绑定到 TIME_WAIT 状态的端口。- 内核通过
sk_reuse判断是否允许地址 / 端口复用,配合 TCP 序列号机制避免旧报文干扰。
4. TCP 连接状态管理
struct sock → sk_state维护 TCP 状态机(LISTEN → SYN_RCVD → ESTABLISHED → FIN_WAIT1 → TIME_WAIT 等)。struct tcp_sock扩展序列号、窗口、重传计时器等,实现可靠传输与流量控制。
Linux 内核通过「进程文件描述符表 → file 结构体 → socket 结构体 → sock 传输控制块」的四层映射,将用户态的一个数字 fd,精准关联到一条包含收发缓冲区、滑动窗口、连接状态的完整 TCP/UDP 连接,实现了高效可靠的网络通信。

┌───────────────────────────────────────────────────────────────────────────┐
│ 用户态 ↔ 内核态 映射链路 │
├───────────────────────────────────────────────────────────────────────────┤
│ │
│ 【进程控制块】 ┌───────────────────────────────┐ │
│ task_struct │ struct file (文件对象) │ │
│ │ │ ┌─────────────────────────┐ │ │
│ ▼ │ │ *private_data │──┼──┼──┐
│ ┌─────────────────────┐ │ └─────────────────────────┘ │ │ │
│ │ struct files_struct │ └───────────────────────────────┘ │ │
│ │ │ │ │
│ │ fd_array[] 索引 │─────────────────────────────────────────────────┘ │
│ │ (如 sockfd = 3) │ │
│ └─────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ struct socket │ ← BSD Socket 抽象层
│ │
│ struct file *file │ ← 回指 struct file
│ struct sock *sk │───┐
└─────────────────────┘ │
│
▼
┌─────────────────────────────────┐
│ struct sock (通用套接字层) │
│ │
│ sk_receive_queue (接收队列) │
│ sk_write_queue (发送队列) │
│ sk_state (连接状态) │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ struct inet_sock (IP层套接字) │ ← 继承自 struct sock
│ │
│ daddr, saddr (IP地址) │
│ dport, sport (端口号) │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ struct tcp_sock (TCP控制块TCB) │ ← 继承自 struct inet_sock
│ │
│ snd_cwnd (拥塞窗口) │
│ snd_wnd (接收方通告窗口) │
│ ssthresh (慢启动阈值) │
│ ... (序列号、定时器等) │
└─────────────────────────────────┘
C语言结构体嵌套实现的“继承”关系(从底层到上层):
┌─────────────────────────────────────────────────────────┐
│ struct tcp_sock │ ← TCP专属控制块
│ ┌─────────────────────────────────────────────────┐ │
│ │ struct inet_connection_sock │ │ ← 面向连接的套接字状态
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ struct inet_sock │ │ │ ← IP层信息(IP/端口)
│ │ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ │ struct sock │ │ │ │ ← 通用套接字(队列/状态)
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
内存布局上,上层结构体的起始地址就是下层结构体的起始地址,
因此内核可以安全地进行类型转换,实现多态。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)