一、 宏观视角:生产者-消费者模型
核心架构:内核缓冲区的双向队列
在生产者-消费者模型中,核心组件是一个阻塞队列,在TCP中,这个队列被拆分成两个部分,分别位于发送端和接收端的OS内核中
-
生产者:发送端的应用程序
- 当调用write或者send时,并不是把数据发送到了网络上,而是将数据总用户空间拷贝到了内核中的发送缓冲区
- 一旦拷贝完成,write系统调用就会返回,生产者任务暂时结束,至于数据什么时候发、发多少,完全由TCP协议栈决定
-
消费者:接收端的应用程序
- 数据从网卡驱动到达内核后,拷贝到接收缓冲区
- 接收端的read或recv调用,本质是从内核的接收缓冲区里拷贝数据到用户空间
-
全双工通道
TCP是全双工的,意味着每一段既是生产者,也是消费者。每个TCP连接在内核中都有两个缓冲区,一个发送,一个接收
具体过程如下图所示:

面向字节流的解耦特性
生产者-消费者模型的巨大优势是解耦:生产者的生产速率和消费者的消费速率不需要完全一致:
-
生产和消费的不对等:
- 生产者:可以一次性write100字节的数据
- 消费者:可以分100次,每次read1个字节,也可以一次性read100个字节
-
底层原理:
因为由内核缓冲区的存在,消费者不需要知道生产者是怎么发的,只管从缓冲区里拿数据就好,只要缓冲区里有数据,就能拿,这种流式处理就消除了数据包边界的限制
同步机制:流量控制和阻塞
生产者-消费者模型必须解决两个问题:
- 队列满了怎么办?(生产太快,消费太慢)
- 队列空了怎么办?(生产太慢,消费太快)
下文详细讲解
传送带角色
在这个模型中,连接两个内核缓冲区的,是不可靠的网络层,就像一条不稳定的传送带:
- 可能会丢包
- 可能会乱序到达
- 可能会网络拥塞
那么TCP就需要一套机制来保证数据不丢失(可靠性)、不乱序(有序性)且不爆仓(流量控制)
二、 微观视角:解剖TCP报头结构

报文的身份证
在应用层来看,数据是一段完整的消息(如"hello"),在传输层,这段数据会被打上一个TCP报头,封装成一个TCP报文段
- 标准尺寸:TCP报头有一个固定长度:20字节
- 扩展能力:TCP可以通过选项字段扩展,报头最长可以达到60字节
- 结构逻辑:按照32位对齐,像砌砖一样一层一层
核心字段的详细作用
- 第一层:身份识别(寻址)
- 16位源端口号&16位目的端口号
- 作用:解决分用问题
- 解析:IP地址解决了给哪台机器的问题,端口号解决发给这个机器上的哪个进程的问题
- 16位源端口号&16位目的端口号
- 第二层:可靠性的基石(顺序与确认)
- 32位序列号
- 作用:解决乱序和去重问题
- 解析:TCP给发送的每一个字节都编了号,对方收到一堆乱序的包,可以根据序列号排队,还原出正确的数据
- 32位确认应答号
- 作用:解决丢包问题
- 解析:表达含义是我收到了该序号之前的所有数据,下次请从这里开始发,比如给发送端发送1001,表示1000及以前的数据都收到了,请从1001开始发
- 32位序列号
- 第三层:连接控制和管理
- 4位首部长度
- 为什么存在?-> 因为有选项字段存在,报头长度不固定
- 计算方法:这个字段是4字节
- 如果该值为5,表示报头长度为5×4=205 \\times 4 = 205×4=20字节(标准长度)
- 最大值为15,表示报头最长为15×4=6015 \\times 4 = 6015×4=60字节
- 所以:报头长度=4位首都长度×4+额外选项报头长度 = 4位首都长度 \\times 4 + 额外选项报头长度=4位首都长度×4+额外选项 -> [20,60]
- 6位标志位
- 这是TCP的控制面板,决定了当前报文的性质,服务器收到不同类型的报文,做出不同的动作
- SYN:建立连接的请求
- FIN:断开连接的请求
- ACK:表明自己是一个确认报文,告诉对端“你要关心确认序号!”因为有捎带应答,所以大部分TCP报文的ACK都是1
- RST:连接异常,强制断开连接
- PSH:催促接收端尽快把数据读走,别在缓冲区墨迹
- URG:说明包里有紧急数据,需要插队
- 这是TCP的控制面板,决定了当前报文的性质,服务器收到不同类型的报文,做出不同的动作
- 16位窗口大小
- 作用:实现流量控制注意:流量控制!=减少发送
- 解析:标识对方接收缓冲区的剩余空间大小,接收方通过这个字段告诉发送方:我还能吃得下多少,你悠着点来,别把我撑着或者饿着了
- 4位首部长度
- 第四层:数据完整性
- 16位校验和
- 作用:保证数据没坏,发送端填充,接收端校验,如果校验不通过,直接丢弃,当作没收到
- 16位紧急指针
- 作用:标识哪部分数据是紧急数据
- 16位校验和
内核代码视角:
struct tcphdr {
__be16 source; // 16位源端口
__be16 dest; // 16位目的端口
__be32 seq; // 32位序列号
__be32 ack_seq; // 32位确认序号
// … (标志位和首部长度,因大小端序不同而略有差异)
__be16 window; // 16位窗口大小
__sum16 check; // 16位校验和
__be16 urg_ptr; // 16位紧急指针
};
三、 可靠性的保证
铺垫思考
先理解一下确认应答:
- 主机A给主机B发送“你好”,报文在网络上跑,需要花时间
- 主机B要给主机A发的消息做出应答
- 主机A要给主机B的应答继续应答!
- 这样不就死循环下去了?
如下图:

所以:长距离通信,是没有100%可靠性的,总会有最新的一条消息没有得到应答,老消息有应答就是100%可靠的
可靠性不是对最新消息的应答
我们站在不同角度理解可靠性:
- 我是主机A:我要保证我发给主机B的消息可靠性!
- 我是主机B:我要保证我发给主机A的消息可靠性!
而TCP是全双工的,所以,TCP是可靠的!如下图,保证两个方向的可靠性,就保证了整体的可靠性:

上面的图演示的都是发一个报文,等一个应答,串行处理,效率难免太低了!
所以,我就可以发一批报文,同时应答,同时给报文带上编号,来完成对报文的确认!

现在详细展开讲
序列号:解决乱序和去重问题
如果网络卡顿,对方重发了数据,结果之前没收到的数据包又到了,接收方岂不是收到了两份同样的数据包?
- 痛点:网络层会导致数据包乱序到达,或者因为重传产生重复数据
- 原理
- 排序:接收缓冲区就是一个char类型的数组,数据包到达之后,内核会根据TCP报头的32位序列号,把它放到对应索引的位置上,即使后发的包先到了,也只能等着,直到前面的空缺被填上
- 去重:如果内核发现收到的数据包序号在缓冲区里已经有了,就会直接丢弃这个重复的报文段,通常会回复一个ACK,告诉对方“不用发了,我已经有了”
确认应答(ACK):不仅仅是收到
- 核心痛点:我发了一堆数据,怎么知道对方收到了哪部分?丢了哪部分?
- 原理:累计确认
- TCP不会为每一个字节单独发送一个ACK,串行效率太低了
- 确认序号含义:我已经成功收到了该序号之前的所有字节,下一次发送请从该序号开始发送
- 举例:
- 发送方发了 1-1000, 1001-2000, 2001-3000 三个包
- 接收方收到了 1-1000 和 2001-3000,中间的 1001-2000 丢了
- 此时接收方不能回复 ACK 3001,只能回复 ACK 1001。
- 含义: 1001 之前我都收到了,但我想要 1001,请你重传
兜底:超时重传机制

因为网络拥堵问题,A给B发送失败或B给A的应答A没收到
这个超时的时间如何确定?
- 绝对不能写死
- 写长了业务层阻塞时间过长
- 写短了稍微有点网络波动就会引起一大批主机重发
- 正常情况:动态计算内核会动态采样,每次数据包一来一回,内核会测量真实的耗时,动态更新下一次的超时时间,做到网快就短,网慢就长
- 极端情况:指数退避,真的发生超时,说明网络极度拥塞。TCP会把下次超时时间翻倍,如果连续重试15次依然失败,底层Socket就会抛出ETIMEDOUT错误,通知业务层连接断开
连接管理(超级重要)
首先建立一个认知:物理层面上,根本不存在所谓的连接,网络是一根根网线和路由先,它们只负责转发电信号。而所谓的“TCP”连接,本质上是通信双方的操作系统内核里,各自开辟了一块内存,维护了一个struct tcp_sock的状态机。建立连接,就是双方同步初始化这个状态机的过程;断开连接,就是双方销毁这块内存的过程

三次握手
详细过程
-
第一次握手(客户端发)
- 报文:客户端发送一个SYN标志位为1的报文,说:我要建立连接,我的初始序列号是x
- 内核:客户端调用connect(),触发底层发包,此时客户端TCP状态机处于SYN_SENT状态(发出就转换状态)

-
第二次握手(服务器响应)
- 报文:服务端收到后,恢复一个标志位SYN和ACK为1的报文,说:收到你的x了(ACK=x+1),我也要和你建立连接,我的初始序列号是y(SYN=y)
- 内核:服务端收到SYN,TCP状态机处于SYN_RCVD状态

-
第三次握手(客户端拍板)
- 报文:客户端收到了服务端的SYN+ACK,最后回复一个ACK报文,说:收到你的y了(ACK=y+1)信道正式建成!
- 内核:客户端状态机进入ESTABLISHED(发出就转换状态),同时,服务器进入ESTABLISHED状态(发出就转换状态),此时服务器阻塞在accept()的代码唤醒,返回一个全新的Socket文件描述符

注意:
- 三次握手的过程是操作系统完成的,connect和accept不参与!
- 三次握手本质就是赌,赌最后一个ACK对方收到了
再来一张图总结:

为什么要三次握手?
-
防范历史失效连接的内存泄漏:
假设网络极度堵塞,客户端发了SYN1卡在半路,超时之后又发了SYN2,如果只有两次握手,服务器收到迟到的SYN1直接分配内存建立连接,但是客户端不认这个旧连接了,导致服务器的Socket资源浪费
而三次握手的精髓就是客户端最后一次校验,服务器回复SYN1+ACK给客户端,客户端发现上下文不对,我应该收到SYN2+ACK啊,立刻发送一个RST标志位为1的报文,强制把服务器建立好的半成品连接销毁
-
保证了全双工信道的通畅性:
客户端发SYN,服务端收到发来SYN+ACK,这里验证了客户端O的能力正常,服务器的IO能力正常,客户端收到SYN+ACK,发一次ACK,验证了客户端的I能力正常,你来我往,同时也验证了网络的通畅性!
四次挥手
详细过程
- 第一次挥手(客户端发起关闭)
- 报文:客户端发送FIN报文,说:我的发送缓冲区空了,不再发数据了
- 内核:调用close,进入FIN_WAIT_1状态
- 第二次挥手(服务器确认,进入半关闭)
- 报文:服务器回复ACK,说:知道了,你不会再发了
- 内核:服务器收到FIN后,进入CLOSE_WAIT状态,客户端收到ACK,进入FIN_WAIT_2状态。此时是**半关闭状态:**客户端不能发,但是还能收,服务器应用层如果调用read回读到0,但是它底层发送缓冲区里的历史数据还会继续往外发
- 第三次挥手(服务器彻底发完)
- 报文:服务器把剩下的数据发完,也向客户端发送FIN报文,说:我的数据也发完了,我也关了
- 内核:服务器应用层调用close,进入LAST_ACK状态
- 第四次挥手(客户端最后确认)
- 报文:客户端收到FIN,回复ACK,说:收到,就此别过
- 内核:服务器收到ACK后,立刻进入CLOSED,销毁,而此时客户端绝对不能立刻销毁,必须进入TIME_WAIT状态,在内核中等待2MSL(60s)后,等游离的报文消散,再进入CLOSED
完整图解:

双方FIN和ACK有来有回,连接断开成功
注意:主动关闭连接的一段,才有TIME_WAIT状态
为什么是四次挥手?
服务器收到客户端的FIN时,仅仅代表了客户端不发了,但服务器可能还有没发完的数据,所以服务器必须回一个ACK稳住客户端(防止客户端超时重传FIN),等服务器自己的应用层把数据全部发完之后,才能发出属于自己的FIN。ACK和FIN中间隔着业务处理的时间,通常无法合并,必须四步走,确保双方的读写缓冲区都关闭了
理解TIME_WAIT状态
未完待续~



