TCP协议可靠性设计的核心机制与底层逻辑
本文系统介绍了TCP协议的核心机制,包括报头结构、可靠性保障和性能优化策略。主要内容涵盖:TCP报头字段解析;可靠性机制,包括超时重传、快重传和连接管理(三次握手/四次挥手);流量控制与滑动窗口的动态调整原理;拥塞控制算法及其慢启动策略;应答优化策略如延迟应答和捎带应答。文章通过具体示例和状态转换分析,揭示了TCP如何在保证可靠传输的同时实现高效数据传输,并解释了粘包/半包等问题的成因。这些机制共同构成了TCP协议作为面向连接的可靠字节流传输协议的技术基础。
目录
一、TCP报头字段理解
1.1 首部长度
1.2 序号与确认序号
1.3 窗口大小
1.4 标志位
1.5 紧急指针
二、重传机制
2.1 超时重传
2.2 快重传
三、连接管理机制
3.1 三次握手
3.2 四次挥手
四、滑动窗口
五、流量控制
六、拥塞控制
七、应答策略
八、面向字节流
一、TCP报头字段理解
TCP报头格式:
任何一层协议都必须解决两个问题:如何交付有效载荷?如何分离报头和有效载荷分离?
TCP协议中通过端口来指定要交付的进程。解析基础报头,再根据偏移量(首部长度)移动指针,从而可以将报头与有效载荷分离。
报头:定长报头(20字节)+不定长选项
1.1 首部长度
因为报头的选项长度是不确定的,所以报头长度就不是固定的,需要首部长度来标识报头的长度。
首部长度:4bite位(0~15),基本单位是4字节,也就是说首部长度需要乘以4字节才是所表示的报头长度,所以它能表示的长度范围是[0*4,15*4]字节。如上图TCP报头至少是20字节,x*4 = 20,x=5;即首部长度范围[5,15]。tcp报头长度一定能整除4字节。
每个 UDP 报文都是一个独立的数据报,就像快递包裹一样,发送方一次发送一个完整的报文,接收方也一次接收一个完整的报文。UDP 首部包含长度字段(16位),明确告诉操作系统这个报文有多大。
TCP 是字节流协议,数据像水管里的水一样连续流动,没有天然的“报文”概念。只有首部长度,标识报头有多大,无法知道有效载荷大小。
1.2 序号与确认序号
TCP可靠性体现的其中一方面是确认应答机制。
当报文从A端发送到B端时报头会带一个序号(记为c)。当B端接收到数据后,会给A端回应,将确认序号字段设为c+1(即确认序号 = 序号值 + 1),表示已收到c序号值以前的数据。如果A端在一定时间内接收不到应答,会判定为数据丢失,进行重发。
- 应答可以保证历史消息的可靠性。
- 最新的报文永远没有应答,最新报文可靠性无法保证。
- 应答是OS自动维护的。
- 应答不是数据,不对应答做应答。对端可以不用回复但一定要应答。
并不是简单的发一条数据,得到应答后才发第二条。背后有一套复杂的应答机制和应答策略,在下文会细讲。
为什么TCP协议中既要设置32位序号,又要设置32位确认序号呢?
这是因为一条数据传输既能实现向对方发送数据的功能,又能做应答,这被称为捎带应答。简单来说,就是在向对端发送数据的同时,顺便携带上对已接收信息的应答。
如何做应答交互?
在TCP通信里,数据传输和应答并非遵循简单的一对一、逐条发送与响应模式。实际情况往往是,一方会连续向对端发送大量数据,之后对端再针对这些已接收的数据逐一给出回复。
而且,我们完全不用担心部分应答在传输过程中丢失,这是为什么呢?下面通过一个例子来详细说明。假如对端发来的应答是1001,这就意味着它已经成功接收了序号为1000及之前的数据,接下来发送方可以从序号1001开始继续发送新的数据。在这个过程当中,即便序号为500、600等对应的应答丢失了,也不会影响整体的通信。因为TCP协议具有累积确认机制,后续收到的应答所表示的已接收数据范围会涵盖之前丢失应答所对应的数据,发送方根据最新的有效应答就能准确判断哪些数据已被成功接收,从而确保数据传输的可靠性和连续性。
- 注意1:数据是按序号发,但不一定按序号收到。乱序问题,是不可靠的问题,内部会进行排序解决。
- 注意2:这些操作的都是系统内核控制的。
- 注意3:UDP不一定就比TCP快,主要是应用场景。
确认序号在原序号值上加1有什么作用?
- 方便去重:在重传时,B端检查序号如果小于上一次ACK的值,说明该数据已经接收过了,直接丢弃。(为什么有重复报文?比如网络太慢,而被判定为报文丢失并进行重传,所以网络里就有了大量重复报文)
- 明确边界:比如序号值为100,如果确认序号和序号值一样,容易分歧:是序号100的数据收到了,还是序号100以前的数据收到了。
1.3 窗口大小
数据发多少合适?
如果数据无止境地向对端发,那么对端接收缓冲区满之后,只能把这些数据给丢弃(也就不会做应答)。虽然有TCP可靠性保障,数据被丢弃后会重新发送。但是这么干显然太蠢了,如果接收缓冲区一直满,那么数据就一直丢弃,那就一直做无用功,还白白浪费电力。
如果发的数据太少,效率又太低。很让人头疼。
所以我们在给发数据时,在报头中把自己接收缓冲区剩余大小带上,这样对端就知道该给你发多少数据合适了。这就是16位窗口大小的作用。
- 注意1:以上机制被称为流量控制(具体细节后文讲解),除此之外数据发送的量还要结合当前网络状况来决定,即拥塞控制,在下文会详细讲解。
- 注意2:不是发数据的快慢问题,发得快并不是好事,而是要合理,主要解决效率问题。
1.4 标志位
TCP报头内核源码:
struct tcphdr {__u16 source;__u16 dest;__u32 seq;__u32 ack_seq;
#if defined(__LITTLE_ENDIAN_BITFIELD)__u16 res1:4,doff:4,fin:1,syn:1,rst:1,psh:1,ack:1,urg:1,res2:2;
#elif defined(__BIG_ENDIAN_BITFIELD)__u16 doff:4,res1:4,res2:2,urg:1,ack:1,psh:1,rst:1,syn:1,fin:1;
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif __u16 window;__u16 check;__u16 urg_ptr;
};
字段 | 类型 | 说明 |
---|---|---|
source | __u16 | 源端口号(16位,范围 0~65535)。 |
dest | __u16 | 目的端口号(16位)。 |
seq | __u32 | 序列号(Sequence Number):当前数据包第一个字节的序号(用于数据排序和可靠性)。 |
ack_seq | __u32 | 确认号(Acknowledgment Number):期望收到的下一个字节序号(需 |
标志位 | 位段 | ...... |
window | __u16 | 窗口大小:接收方的可用缓冲区大小(用于流量控制)。 |
check | __u16 | 校验和:覆盖头部和数据,用于错误检测。 |
urg_ptr | __u16 | 紧急指针:当 URG=1 时有效,指向紧急数据的末尾偏移量。 |
这里标志位有两个布局,分别是小端和大端,通过条件编译适配不同的环境。
为什么要有标记位?TCP报文会存在不同类型,需要有不同处理方法。
标志位有6个:
标志位 | 名称 | 作用描述 | 常见场景 |
---|---|---|---|
FIN | 终止(Finish) | 表示发送方希望关闭连接。 | 四次挥手的第一次和第三次。 |
SYN | 同步(Synchronize) | 用于建立连接,同步序列号(ISN)。 | 三次握手的前两次。 |
RST | 重置(Reset) | 强制终止连接(异常情况)。 | 连接错误、端口未开放时。 |
PSH | 推送(Push) | 要求接收方立即将数据交给应用层,而非缓冲。 | 实时交互(如 SSH 输入)。 |
ACK | 确认(Acknowledgment) | 表示确认号(ack_seq )有效,用于确认数据接收。 | 除初始 SYN 包外,几乎所有报文。 |
URG | 紧急(Urgent) | 表示紧急指针(urg_ptr )有效,优先处理紧急数据。 | 极少使用(如 Telnet 中断命令)。 |
示例:建立连接(三次握手,后文讲解)
数据不是上来就简单粗暴的直接发(这是UDP的做法),而是需要双方确认是否具有收发数据的能力,和是否同意进行数据收发。
建立连接的本质:建立共识。在此期间是不携带任何数据的,只是简单的一个报头。然后在对应标志位上置1表明是什么操作。
注:三次握手成功后已经知道双方接收能力。
SYN标志位
连接建立的请求,在三次握手的前两次用到时用到。
ACK标志位
ACK置1表明报文为应答报文,该标志位指明确认信号是否有效。99%情况ACK设为1(通常都是 数据+应答)。其他情况,如第一次握手时ACK置为0。
RST 标志位
建立连接不一定成功,比如网络原因,所以需要重置连接。而且两端对连接建立是否成功的认识不一样,如A端发出最后一个ACK后就认为连接建立了,ACK到达B端需要一段时间,此时B端是不认为连接建立成功的,有一段时间差。而如果ACK丢失或其他原因导致B端接收不到ACK,那么B端会发起RST进行重新握手。
- 注意:通信过程中双方连接出现任何问题都可以重置。双方地位对等。
1.5 紧急指针
正常情况下TCP会对缓冲区中的数据按序处理,但某些场景需要优先处理紧急数据(类似医院开通急诊通道)。此时可通过16位紧急指针(Urgent Pointer)标识紧急数据位置——特定偏移量处的数据。
URG标志位
URG是用来标记紧急指针是否有效。0:无效,1:有效。
注意:URG并不是处理紧急数据的最优方案,现代网络应用中极少使用它。比如,建立新连接进行紧急数据传输也可以解决。
二、重传机制
2.1 超时重传
没有收到ACK意味着什么?
- 收到应答:能说明对端100%收到。
- 没收到应答:丢包?不是。也可能是应答丢了。只能意味着数据可能丢失,对方可能已经收到。
无法100%确认对方收到数据,要么数据丢,要么应答丢,无法判断。所以在特定的时间间隔后未收到应答,就判定报文丢失(进行重传),不是客观上的真丢,而是主观上的认为。
超时的时间应该设多长?
取决于网络因数,网络变化,太长不行?太短不行?所以时间间隔必须是动态变化的。重传周期通常以500毫秒为倍数。
2.2 快重传
快重传:如果连续接收3个以及以上的相同ACK应答,那么判定为丢包,并进行重传。
如下图解:
- 注意:快重传效率比较高,通常情况都是在快重传,超时重传只是用来兜底的。
三、连接管理机制
3.1 三次握手
在TCP连接建立过程中会出现各种状态,这些是 附属在套接字(Socket)上,而套接字由内核管理,与进程通过文件描述符(File Descriptor, FD)关联。状态本质就是一个整数(宏值)。
使用以下指令可以查看套接字状态:
netstat -antp
或通过管道过滤得到指定端口的套接字信息,比如查看8080端口:
netstat -antp | grep 8080
握手阶段 | 客户端状态 | 服务端状态 | 内核关键动作 |
---|---|---|---|
第一次(SYN) | SYN_SENT | - | 初始化序列号,启动重传定时器。 |
第二次(SYN+ACK) | - | SYN_RECV | 半连接队列存储,启动 SYN+ACK 定时器。 |
第三次(ACK) | ESTABLISHED | ESTABLISHED | 移入全连接队列,等待 accept() 。 |
服务器上一定有非常多的链接。连接需要管理,一定是某种结构体进行描述,包含连接相关的属性。所以建立连接有成本,TCP要付出太多代价了,要花时间,要花空间。
服务器listen监听,客户端connect发起三次握手(注意措辞)。三次握手具体过程是由客户端和服务器操作系统自动完成。
- 注意:
accept()
并不参与三次握手,而是负责从已完成连接的队列中取出一个已建立的连接,并为它创建一个新的套接字(socket),供后续数据通信使用。它的核心作用是管理服务端已建立的连接。
为什么要进行三次握手?
- 理由1:以最短的方式进行全双工验证。以A端向B端发起连接请求为例,第一次握手:能确定A是否能发;第二次握手:能确定B是否能收和是否能发;第三次握手:能确定A是否能收。本质:验证网络是否流畅,能否支持全双工。
- 理由2:可以获取对端的接收能力(通过16位窗口大小)。
三次握手为什么会成为三次握手,而不是四次握手?
因为在第二次握手时做了捎带应答,所以成了三次握手。客户端发连接,服务器都要无脑接收,所以可以把ACK捎带。但断开连接(四次挥手)不一定,可能服务器正在向客户端传数据,不能立马断开,只能单独做一个ACK应答。
- 注意1:三次握手交换的是报头,未携带数据。
- 注意2:connect不参与3次握手,只是发起连接,而3次握手是由系统内核自动完成的。
- 注意3:长连接通过复用已建立的 TCP 连接 来减少重复握手和断开的开销,从而显著提升性能。
3.2 四次挥手
在TCP协议中当一端想要结束通信时,会采用四次挥手(FIN-ACK 机制)来终止连接,而非直接强制断开,这是为了保证数据的可靠性和网络的健壮性。如下:
挥手阶段 | 主动关闭方状态 | 被动关闭方状态 | 内核行为与关键动作 |
---|---|---|---|
第一次挥手 | FIN_WAIT_1 | ESTABLISHED | 主动方发送 FIN ,进入等待确认状态。 |
第二次挥手 | FIN_WAIT_2 | CLOSE_WAIT | 被动方收到 FIN ,回复 ACK ,进入等待应用层调用 close() 的状态。 |
第三次挥手 | TIME_WAIT | LAST_ACK | 被动方发送 FIN ,进入等待最终确认状态;主动方收到 FIN 后回复 ACK ,进入 TIME_WAIT 。 |
第四次挥手 | -(连接关闭) | -(连接关闭) | 被动方收到 ACK ,连接彻底关闭。 |
断开连接的本质:建立双方断开连接的共识。
如下,C为主动关闭方,C与S的挥手对话:
- C->S:FIN——我(c)要发的数据已经发完了,我(c)要和你(s)断开连接。
- S->C:ACK——好的收到。我(s)正在给你发着数据呢(假设有),等一会。
- S->C:FIN——我(s)好了,可以断开连接了。(如果我等待MSL后收不到C的ACK回应,那就当作丢包了,重发)
- C->S:ACK——OK,我(c)收到了。(如果等待 2MSL后S没有给我重发,那么它就是收到ACK回应了,可以断开连接了)
注:MSL是一个时间信息,默认为60秒。
客户端退出或关闭,服务器不关(不就行close)那么就会处于close_wait状态依旧占用文件fd,连接没有释放,即文件描述符泄漏问题!一直这样会导致文件描述符可用越来越少,服务器会变得越来越卡。
为什么主动断开的一方发完最后一个ACK后不能立即关,而是进入TIME_WAIT状态等一段时间?
要等待多长时间呢?为什么还要有TIME_WAIT状态?
两个主要的原因:1.确保对端收到ACK。如果等待的这段时间对端重传FIN说明对端没有收到ACK,需要给对端重传ACK。2.确保下次通信不受本次陈旧报文影响。等待的这段时间让网络里两个朝向的陈旧报文消散。(即把传输过来的陈旧报文丢弃)。防止陈旧报文对下次连接建立产生干扰。
为什么等待的时间是2MSL?
因为报文在网络中的最大存活时间是MSL,ACK发出去和等待对端重传FIN两个朝向就需要等待2MSL的时间。
- 注意:客户端再次连接服务器时会更换端口,就不会受到陈旧报文影响。
- 注意:通过序号可以甄别陈旧报文。
- 注意:因为TIME_WAIT状态的存在,服务器退出后不能立即重启(更换端口可以立即重启,不推荐。或做一些选项设置可以立即重启)。
四、滑动窗口
TCP协议有接收缓冲区和发送缓冲区,构成一个生产者消费者模型,实现解耦,支持忙闲不均。
而滑动窗口就是发送缓冲区的一部分。
可以把区看作char outbuffer[N],而滑动窗口大小为end-start。
序号是怎么来的?可以先简单理解为数组下标。
- 窗⼝⼤⼩指的是⽆需等待确认应答⽽可以继续发送数据的最⼤值,上图的窗⼝⼤⼩就是4000个字节(四个段)。
- 发送前四个段的时候,不需要等待任何ACK,直接发送;
- 收到第⼀个ACK后,滑动窗⼝向后移动,继续发送第五个段的数据;依次类推;
- 操作系统内核为了维护这个滑动窗⼝,需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉;
- 窗⼝越⼤,则⽹络的吞吐率就越⾼;
在整体结构中,处于中间位置的动态处理区域对应的是滑动窗口,该窗口的边界由起始点 start 和终止点 end 界定,可表示为 [start, end] 区间。滑动窗口把缓冲区分为3部分:已发送已确认;动态处理区(发送未确认或可立即发送);待发送;
- start=报文确认序号。
- end=start+win(16位窗口大小)。
滑动窗口移动的本质?start和end下标增加。
滑动窗口大小是依据什么确定的?由对端接收能力决定。
滑动窗口本质?流量控制的具体方案。
- 注意1:滑动窗口大小不是单由对端接收能力决定,以上的描述与计算并不严谨,但方便理解。在后文讲拥塞控制会提到。
- 注意2:已发送已确认相当于把缓冲区清空了。不要管了,不用刻意的清空,后续直接覆盖即可。
- 注意3:序号是依次增大的,也意味着滑动窗口未来向右移动。
问题集萃与解析
滑动窗口可以向左滑吗?不能。
窗口可以变大吗?可以变小吗?窗口大小是一直在动态变化的,由网络因素(拥塞控制)和对端接收能力(流量控制)共同影响。
滑动窗口可以为0吗?可以,比如缓冲区没有数据或对端接收能力为0等。
滑动窗口会溢出吗?不会的,事实上滑动串口是一个环形结构。
丢包怎么办?
在没有收到相应的ACK应答,滑动窗口的start指针是不会向右移动的,通常会因收到3个相同ACK触发快重传。如下图:
A主机收到应答1001唯一能确定的是1000及以前报文对端已经收到了,而连续收到3个1001应答,说明1001~2000丢了,进行快重传。中间部分丢没丢不清楚,先不管,在后续会有条件触发。
TCP把报文发出去时,必须让对应报文暂时保存起来,方便重传。即在滑动窗口保存,本质就是start不进行移动。
为什么数据要一块一块的发送(如上图以1000字节为单位进行发送),而不是单个的字节进行发送?·一个字节为单位发太浪费TCP报头了,TCP报头就要20个字节及以上,本身也要消耗空间。
五、流量控制
流量控制在上文已经讲解过,这里在做一点补充。
流量控制总结:通过对端发来的报文的报头中的16位窗口大小得知对端的数据接收能力,然后动态控制自己的发送缓冲区中的滑动窗口。
第一次握手有没有可能发超过对端的接收能力?不会。刚开始双方缓冲区都是空的,而且都没有带数据。不会放在接收缓冲区。
假设A端向B端发数据:
B端上层处理太慢时A端滑动窗口设为0,A端会周期性窗口探测。探测的本质:只发报头,不带数据(没报文就不会放在接受缓冲区),对端会给应答,从而得到窗口大小。B端也会自动给A端通知。通常两种策略同时使用。
但如果B端接收缓冲区一直是满的,A端问急眼了,会发PSH,要求接收方立即将数据交给应用层。如果还是一直满的,A端会自动断开连接。
六、拥塞控制
发1000个报文,丢了两三个正常,重发就行。但丢了900多个?
丢包多和丢包少原因和处理方法不同。大量丢包往往都是中间的网络问题?网络出了问题,再怎么重发都没有用。在通信过程中不仅考虑了双方主机可靠性,还考虑了网络的可靠性问题。
- 丢太多->判定网络出现拥塞问题!要不要重发?不敢!不敢!
- 我就重发能咋地?增加网络负担,更加拥堵。
- 啊!你也太看得起我了,我重发个包就造成拥堵了吗?
- 又不是你一个人在用网络,那么多人虽然访问不同的服务器,但你们都访问网络呀!别人也会这样认为,他们也都重发!加速网络拥堵。
- 啊!那要怎么办?拥塞控制!
截至目前,我们在讲解过程中均以两端主机之间的通信作为示例来阐述相关概念。接下来要介绍的拥塞控制,则是针对网络中众多主机所实施的一种全局性管理机制。
拥塞控制,控制的是在不同网络状况下,报文的发送量。当网络出现拥堵,报文发送量从0开始进行慢增长。即慢增长机制,使用指数级慢启动算法。
指数增长不是很快吗?怎么说是慢增长?
在前期阶段,要求入口流量维持在一个较低水平,采用小流量、多频次的发送方式,逐步进行试探性传输。当监测到网络拥塞状况得到缓解、网络畅通时,需迅速采取措施恢复网络的正常通信进程。
而指数增长正好满足了这种需求,前期增长缓慢,后期增长超级快。
流量不是由滑动窗口决定吗?不是等于对端接收能力吗?怎么做到?
是的。确实是由滑动窗口决定,事实上它既要结合对端接收能力,也要结合网络状况,在它们之间做一个平衡。
为了支持慢启动增长算法,提出拥塞窗口,拥塞窗口本质:一个int类型的变量。
拥塞窗口是一个临界值,在这个值内,网络大概不拥塞。网络状况是变化的,拥塞窗口大小会更新。(指数增长)
滑动窗口 = min(对端接收缓冲区剩余大小,拥塞窗口大小)
可以这样理解:对端接收能力巨大,但网络不行得听网络的;如果网络巨好,但对端接收能力差,得听对端接收能力的。发多一点都是浪费。谁小谁是主要矛盾。
发数据时会一直进行指数增长吗?肯定是不行的,到一定限度后进行线性增长。
不是一直指数增长,但刚开始为什么要指数增长?尽快探测和恢复网络通信。
什么时候转变成线性增长呢?以ssthresh参数为参照,当拥塞窗口大小到达ssthresh时转变为线性增长,如下:
- 注意:拥塞窗口增加数据传输量不一定增加,因为还受对端接收能力影响。
为什么要一直增加?网络一直都不丢包,是不是应该增大探测。虽然此时数据传输量可能是由接收能力控制的,但这只是单纯的对网络探测,因为还有其他用户在影响啊。
到后面突然网络拥塞(即大量丢包),即探测出拥塞窗口。ssthresh参数变成此时拥塞窗口的一半,所有主机此时数据传输量从头(从0)慢增长开始。
- 注意:拥塞窗口是衡量网络是否会拥堵的指标。
七、应答策略
延迟应答:我接收到你的报文后不立马回复ACK而是稍等一会。目的等缓冲区的一些数据被取走,给对端更多的发送空间。提高通信效率。
捎带应答:应答基本上都不是单独发,的而是在给对端发数据时捎带的。也是用来提高效率。
八、面向字节流
在上文讲首部长度时提到过,这里再回顾一下。
面向字节流:报文没有边界,像水流。没有结构,报文之间会粘在一起(粘包问题),会不完整(半包问题)这些问题都是要由应用层解决(即程序自己解决,协议+序列+反序列)
非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!