一、 初识传输层
为什么网络是不可靠的?
我们要理解TCP,首先要理解为什么需要"可靠性"。 现代计算机大多基于冯诺依曼体系结构。在一台计算机内部,CPU、内存、I/O设备之间通过总线(系统总线、IO总线)连接,线路极短,干扰极小,因此本地传输几乎不出错。

然而,网络通信本质上是将多台计算机看作一个巨大的计算机网络。连接它们的"线"变得非常长(跨越城市、海洋),数据在长距离传输中面临信号衰减、路由拥堵、硬件故障等问题。"线"太长,是不可靠的根源。
而TCP就是在此背景下诞生的,TCP就是一种保证可靠性的协议。
TCP vs UDP
TCP 和 UDP 是传输层两个最主要的协议。
TCP(可靠):
-
特点: 提供详尽的可靠性保证(丢包重传、乱序重排等)。
-
代价: 机制复杂,维护成本高(时间+空间),头部开销大。
-
场景: HTTP, HTTPS, FTP, SSH, MySQL底层等。
UDP(不可靠):
-
特点: 简单,不保证到达,不保证顺序。
-
优势: 头部开销小,传输效率极高,没有复杂的连接管理。
-
场景: 直播、视频会议(允许少量丢包)、DNS等。
不可靠和可靠是中性词。没有最好的协议,只有最合适的场景。如果应用容忍少量丢包(如视频流),首选UDP;如果数据必须准确无误(如转账),必须TCP。
二、 TCP协议格式与封装
TCP基本协议格式如下:

报头核心字段解析如下:
- 源/目的端口号 (16位):标识数据从哪个进程来,去往哪个进程(配合IP地址锁定唯一进程)。
- 序号 ( 32位):解决数据乱序问题。
- 确认序号 (32位):解决数据丢包问题,告知对方"我收到了哪些数据"。
- 首部长度 (4位):单位是4字节。最大值1111(15) * 4 = 60字节。因此TCP头部最长60字节,选项最长40字节。
- 窗口大小 (16位):流量控制的核心,告知对方我的接收缓冲区还能装多少数据。
6个标志位 (Flags):
- SYN:请求建立连接(握手)。
- FIN:通知关闭连接(挥手)。
- ACK:确认序号有效。
- RST:复位报文,连接异常要求重新建立。
- PSH:催促接收端应用层尽快读走数据。
- URG:紧急指针有效(配合16位紧急指针,用于发送带外数据)。
TCP报头在内核当中本质就是一个位段类型,给数据封装TCP报头时,实际上就是用该位段类型定义一个变量,然后填充TCP报头当中的各个属性字段,最后将这个TCP报头拷贝到数据的首部,至此便完成了TCP报头的封装。
解包与分用
**解包:**先读取前20字节,提取"首部长度"字段。如果该值>5(即>20字节),则继续读取选项字段。剩下的即为有效载荷。
**分用:**内核通过哈希表维护了端口号 与 进程ID的映射。TCP提取目的端口号,直接找到对应的应用层进程(如HTTP服务)。
关于首部长度
TCP报头当中的4位首部长度描述的基本单位是4字节。4位首部长度的取值范围是0000 ~ 1111,因此TCP报头最大长度为15 × 4 = 60 字节,因为基本报头的长度是20字节,所以报头中选项字段的长度最多是40字节。
如果TCP报头当中不携带选项字段,那么TCP报头的长度就是20字节,此时报头当中的4位首部长度的值就为20 ÷ 4 = 5 ,也就是0101。
三、 TCP的可靠性机制
TCP的可靠性是通过一套复杂的逻辑来实现的,不仅仅是报头。
确认应答机制 (ACK)
真正的可靠性困境
互联网中不存在"100%的可靠性"。因为无论A给B发多少条确认,总有一条"最新的消息"是得不到确认的。
这就比如A发消息,B回ACK。A怎么知道B回的ACK没丢?B得要求A对ACK再回ACK......这就陷入了"死循环"。

而TCP协议对此的解决方案为不保证所有消息都可靠,只保证核心数据可靠。只要一方收到了另一方的应答(ACK),就认为上一次发送的数据对方确实收到了。
序列号 与 确认序号
为了解决"乱序"和"丢包"问题,TCP给每个字节的数据都编了号。
32位序号:发送方填充。表示本报文段数据部分的第一个字节的编号。

比如现在发送端要发送3000字节的数据,如果发送端每次发送1000字节,那么就需要用三个TCP报文来发送这3000字节的数据。此时这三个TCP报文当中的32位序号填的就是发送数据中首个字节的序列号,因此分别填的是1、1001和2001。
接收端收到了这三个TCP报文后,根据TCP报头当中的32位序列号对这三个报文进行顺序重排(该动作在传输层进行),重排后将其放到TCP的接收缓冲区当中。
接收端在进行报文重排时,可以根据当前报文的32位序号与其有效载荷的字节数,进而确定下一个报文对应的序号。
32位确认序号:接收方填充。核心含义是我已收到序号X之前的所有数据,下次请从序号X开始发。

以刚才的例子为例,当主机B收到主机A发送过来的32位序号为1的报文时,由于该报文当中包含1000字节的数据,因此主机B已经收到序列号为1-1000的字节数据,于是主机B发给主机A的响应数据的报头当中的32位确认序号的值就会填成1001。
- 一方面是告诉主机A,序列号在1001之前的字节数据我已经收到了。
- 另一方面是告诉主机A,下次向我发送数据时应该从序列号为1001的字节数据开始进行发送。
为什么要两套序号?
因为TCP是全双工的。A给B发数据需填充"序号",同时A可能收到B的数据,需在"确认序号"中给B回应。
超时重传机制
如果发送方迟迟收不到ACK,不会无限等待,而是发送方在发送数据后开启了一个定时器会判定数据是否丢失并重传。
丢包的两种不同情况:
-
数据包丢了:B根本没收到,自然不会回ACK。A超时后重传。
-
ACK包丢了:B收到了,回了ACK但半路丢了。A超时后重传。
对于第2种情况,B会收到重复数据,但是有序列号,B根据序列号自动去重,并再次补发ACK。
需要注意的是,当发送缓冲区当中的数据被发送出去后,操作系统不会立即将该数据从发送缓冲区当中删除或覆盖,而会让其保留在发送缓冲区当中,以免需要进行超时重传,直到收到该数据的响应报文后,发送缓冲区中的这部分数据才可以被删除或覆盖。
等待时间怎么定?
太短的话会导致频繁重传,浪费带宽。太长的话会导致补救太慢,效率低。
在Linux中以500ms为基准单位,随着重传次数增加,等待时间呈指数级增长,1倍、2倍、4倍...如果累计多次仍失败,TCP会判定连接异常,强制关闭。
连接管理机制
TCP是面向连接的,可靠性依赖于连接状态。
三次握手 (建立连接)
过程:Client(SYN) -> Server(SYN+ACK) -> Client(ACK)

为什么是三次?
验证全双工信道,三次是验证双方都能发、能收的最小次数。
风险转移,最后一次ACK由客户端发出。如果第三次丢包,连接建立失败的挂起资源在客户端,保护了服务器不被耗尽资源。
三次握手时的状态变化

三次握手时的状态变化如下:
- 最开始时客户端和服务器都处于CLOSED状态。
- 服务器为了能够接收客户端发来的连接请求,需要由CLOSED状态变为LISTEN状态。
- 此时客户端就可以向服务器发起三次握手了,当客户端发起第一次握手后,状态变为SYN_SENT状态。
- 处于LISTEN状态的服务器收到客户端的连接请求后,将该连接放入内核等待队列中,并向客户端发起第二次握手,此时服务器的状态变为SYN_RCVD。
- 当客户端收到服务器发来的第二次握手后,紧接着向服务器发送最后一次握手,此时客户端的连接已经建立,状态变为ESTABLISHED。
- 而服务器收到客户端发来的最后一次握手后,连接也建立成功,此时服务器的状态也变成ESTABLISHED。
至此三次握手结束,通信双方可以开始进行数据交互了。
套接字和三次握手之间的关系
| 阶段 | 客户端 API / 动作 | 网络交互 (TCP 报文) | 服务器 API / 动作 | 服务器内核队列变化 |
|---|---|---|---|---|
| 准备 | socket() |
socket(), bind(), listen() |
初始化 Syn/Accept 队列 | |
| 握手1 | connect() 调用 (发送 SYN) |
SYN -> | (内核自动接收) | 加入半连接队列 |
| 握手2 | (connect 阻塞中) |
<- SYN + ACK | (内核自动发送) | 状态变 SYN_RCVD |
| 握手3 | connect() 返回成功 |
ACK -> | (内核自动接收) | 移入全连接队列 |
| 使用 | write() / read() |
accept() 返回新 fd |
从全连接队列取出 |
四次挥手 (断开连接)
过程:Client(FIN) -> Server(ACK) ... Server(FIN) -> Client(ACK)

为什么是四次?
因为TCP是全双工的。Client想断开连接(FIN),Server确认(ACK)。
但此时Server可能还有数据没发完,等Server发完了,Server想断开连接(FIN),此时Client确认(ACK)。中间两步通常不能合并。
四次挥手时的状态变化

- 在挥手前双方均处于 ESTABLISHED 状态。
- 主动断开方(客户端)发出 FIN 报文后进入 FIN_WAIT_1 ;被动方(服务器)收到 FIN 并回复 ACK,进入 CLOSE_WAIT 状态(此时连接处于半关闭,服务器可能仍有数据要发送)。
- 待服务器数据发送完毕,发出 FIN 报文后进入 LASE_ACK ;客户端收到该 FIN 后回复最后一个 ACK,随即进入 TIME_WAIT 状态。
- 最终,服务器收到 ACK 后立即进入 CLOSED ,而客户端需等待 2MSL 时间以确保连接彻底终止后,才进入 CLOSED 状态。
关键状态
CLOSE_WAIT (被动关闭方):收到FIN后进入。如果服务器出现大量CLOSE_WAIT,说明应用层代码忘了调用 close() 关闭文件描述符。
TIME_WAIT(主动关闭方):发完最后一个ACK后进入,等待2MSL(报文最大生存时间,通常60s)。
- 作用1:保证最后一个ACK被对方收到(如果丢包,对方重发FIN,我还能回ACK)。
- 作用2:等待网络中滞留的旧报文消散,防止影响新连接。
这就是我们bind一个端口号后,客户端主动退出,紧接着再启动另一个客户端时,bind同一个端口时可能bind失败的原因。
解决TIME_WAIT状态引起的bind失败的方法
在服务器的 TCP 连接没有完全断开之前不允许重新监听,这种机制在某些情况下是不合理的。
- 当服务器需要处理极大量的客户端连接时 ------ 这些连接的生存时间可能很短,但每秒都会有大量客户端发起请求 ------ 就会暴露出这个机制的弊端。如果由服务器端主动关闭连接(比如清理长时间不活跃的客户端),会产生大量处于 TIME_WAIT 状态的连接。
- 由于请求量极大,TIME_WAIT 连接的数量可能会非常多。每个 TCP 连接都会占用一个通信五元组 ,即
源IP、源端口、目的IP、目的端口、传输协议。对于服务器来说,自身的 IP、监听端口和传输协议(TCP)是固定不变的。此时如果有新的客户端发起连接,其 IP 和端口恰好与某个 TIME_WAIT 连接占用的五元组参数重复,就会引发连接失败的问题。
调用 setsockopt () 函数将 socket 描述符的 SO_REUSEADDR 选项设置为 1,这一操作表示允许创建端口号相同但 IP 地址不同的多个 socket 描述符。
套接字和四次挥手之间的关系
| 步骤 | 主动关闭方 | 网络交互 | 被动关闭方 | 状态流转 (被动方) |
|---|---|---|---|---|
| 1 | 调用 close() |
FIN -> | (内核收到 FIN,置 EOF 标志) | ESTABLISHED \\to CLOSE_WAIT |
| 2 | (进入 FIN_WAIT_2) | <- ACK | (内核自动回复 ACK) | CLOSE_WAIT (维持中) |
| 3 | read() 返回 0 (感知关闭) |
|||
| 4 | 调用 close() |
CLOSE_WAIT -> LAST_ACK | ||
| 5 | (收到 FIN,回 ACK) | <- FIN | (内核发送 FIN) | |
| 6 | (进入 TIME_WAIT) | ACK -> | (收到 ACK,彻底关闭) | LAST_ACK -> CLOSED |
四、 TCP的效率提升机制
滑动窗口
如果不使用窗口,发一个包等一个ACK,效率极低。
于是允许发送方一次发送多组数据,不用等ACK,其实是将多个段的等待时间重叠在一起了。而窗口大小决定了"无需等待ACK可以发送的最大数据量"。

窗口可以将发送缓冲区当中的数据分为三部分:
- 已经发送并且已经收到ACK的数据。
- 已经发送还但没有收到ACK的数据。
- 还没有发送的数据。

而滑动窗口描述的就是,发送方不用等待ACK一次所能发送的数据最大量。
如何实现滑动窗口
我们可以把 TCP 的接收缓冲区和发送缓冲区都看作一个字符数组,而滑动窗口实际上就可以理解为由两个指针限定的一段区间:

用 start 指针指向滑动窗口的左边界,用 end 指针指向滑动窗口的右边界,那么在 start 和 end 之间的区域,就是滑动窗口的有效范围。
当发送端收到来自接收端的响应报文时,如果该响应报文中的确认序号为 x,窗口大小为 win,此时发送端就可以把 start 指针更新为 x,同时把 end 指针更新为 start + win,以此来调整滑动窗口的位置和大小。
丢包处理(快重传)
如果中间某个包(如1001-2000)丢了,而后续包(2001+)能够到达,则接收方会一直回复"确认序号1001"。
发送方连续收到3次同样的确认序号,不再等待超时,立即重发该包。
流量控制
防止发送太快,把接收方缓冲区打满。
接收端在给发送端回复 ACK 时,会将自己接收缓冲区当前的剩余空间大小填入"窗口大小"字段。
以此进行动态调整:
-
窗口越大:说明接收端吞吐量高,发送端可以加大发送力度。
-
窗口越小:说明接收端缓冲区快满,发送端必须减速。
-
窗口为0:说明接收端缓冲区已满,发送端必须暂停发送数据。

零窗口与探测
当接收端缓冲区满时,会发送一个窗口大小为 0 的 ACK。此时发送端停止发送数据。
当发送端收到零窗口通知时,会启动一个定时器
定时器每隔一段时间超时,发送端就会发送一个窗口探测数据包(仅携带1字节数据)。这个探测包强制接收端响应当前的窗口大小。
-
如果还是 0,重置定时器继续等。
-
如果非 0,恢复数据传输。
拥塞控制
防止发送太快,把整个网络链路堵死(避免网络拥塞)。
TCP 引入了拥塞窗口的概念,发送方最终发送数据的速度取决于接收能力和网络情况的短板:
实际滑动窗口 = min( 接收端通告窗口, 拥塞窗口)
以此进行动态调整:
-
没有发生丢包,说明网络状况好,拥塞窗口不断增大。
-
发生丢包(超时重传),说明网络负载过重,拥塞窗口必须减小。
所有主机共同遵守拥塞控制策略,保证网络发生故障后能尽快恢复。
慢启动与拥塞避免

刚建立连接时,不知道网络深浅,TCP 采用慢启动策略进行探路。
-
指数增长 :初始
cwnd = 1,每收到一个 ACK,窗口值加倍(1→2→4→8→16→⋯→2^n),快速提升利用率。
-
线性增长 :当
cwnd达到 慢启动阈值(ssthresh)后,进入拥塞避免阶段。此时窗口按线性增加,逼近网络极限。
乘法减小与恢复
一旦发生网络拥塞(表现为超时重传),说明触碰到了网络极限,TCP 采取严厉的乘法减小措施。
-
阈值减半 :将
ssthresh设置为当前拥塞窗口的一半。 -
窗口重置 :将
cwnd瞬间重置为 1。 -
重新循环:再次进入慢启动阶段,让网络迅速从拥塞中恢复。
延迟应答
通过短暂等待,留出时间让接收缓冲区中的数据尽可能被上层应用层消费掉,从而向对方通告更大的接收窗口,从而提升网络吞吐量。
如果接收数据后立即回 ACK,此时接收缓冲区还没被应用层消费,窗口可能很小。

它的优化策略为:
-
接收方收到数据后,不立即发 ACK,而是等待一小段时间。
-
在这段时间内,上层应用极有可能已经把接收缓冲区的数据读走了。
-
此时再发 ACK,报告的窗口大小就是更大的,发送方下一次就可以发更多数据。
延迟的限制条件
延迟应答不能无限制地等,否则会导致发送方误判丢包而重传。
通常遵循以下规则:
-
数量限制:每隔 N 个包就应答一次(一般 N 取 2)。
-
时间限制:超过最大延迟时间就应答一次(一般取 200ms,需小于超时重传时间)。
捎带应答
因为在实际通信中,往往是"一问一答"的模式。而捎带应答的目的是减少纯 ACK 包的发送数量,降低网络开销。

它的优化策略为:
-
当主机 B 收到 A 的消息需要回 ACK 时,如果 B 恰好也要给 A 发送数据,ACK 就不单独发送。
-
ACK 标志位与 B 要发送的有效数据合并在一个 TCP 报文中发出。
-
既发送了数据,又确认了收到的包,效率最高。
五、 面向字节流与粘包问题
面向字节流
创建一个 TCP Socket,内核会为其分配两个缓冲区:发送缓冲区和接收缓冲区。
于是我们进行写、读操作时
- 写操作 :应用层调用 write,只是将数据拷贝到内核的发送缓冲区。至于什么时候发、发多少、拆成几个包发,全由 TCP 决定。
- 读操作 :应用层调用 read,是从内核的接收缓冲区拷贝数据。TCP 已经把网络上的数据按序号排好序放在这里了。
于是就有了读写的"不匹配性"
- 写 :你可以调用 100 次
write,每次写 1 个字节。 - 读 :你可以调用 1 次
read,直接读出 100 个字节。
TCP 不关心你发送的是什么结构的数据(是图片、文字还是结构体),在它眼里,所有数据都只是一个个连续的字节,这就叫做面向字节流。
什么是粘包?
接收端应用程序看到的是一连串连续的字节流,就像水流一样,没有边界。接收端不知道从哪里到哪里是一个完整的应用层数据包。
导致粘包的原因是
- TCP 协议头没有"总长度"字段:TCP 无法像 UDP 那样告诉应用层"这一个包有多长"。
- 传输层的合并与拆分:TCP 为了效率,可能会把应用层多次写入的小数据合并成一个大报文发送(粘包),也可能把一个大数据拆分成多个报文发送(半包)。
UDP 会粘包吗?
答案是不会的,因为UDP 是面向报文的,而且UDP 协议头中有 16位 UDP 长度字段。
UDP 交付给应用层的是一个个完整的报文,要么全收,要么全不收,天然有边界。
如何解决粘包?
解决粘包的核心思想是在应用层协议中明确"包"的边界。
常见的有三种方案:
-
定长包:约定每个包固定大小(例如 512 字节)。如果不满就补位,超长就截断。
-
特殊分隔符 :在包与包之间插入特殊符号(如文本协议中的
\r\n)。需保证正文中不包含该分隔符。 -
长度字段:在包头位置约定一个字段记录包的总长度。
推荐使用第3种方案,就比如HTTP 协议的 Content-Length 字段。接收端先读包头,解析出长度,再根据长度读取指定字节数的正文。
六、TCP异常情况
TCP 连接不仅在正常通信时有效,在面对各种突发状况(崩溃、断电)时也有既定的处理逻辑。
进程终止 / 机器重启
当客户端程序抛出异常退出,或被 kill -9 杀掉
或者用户点击重启(reboot)。操作系统在关闭前会先杀掉所有进程。
而TCP 连接和文件描述符是生命周期绑定的。当进程退出(无论正常还是异常),操作系统都会自动关闭该进程打开的所有文件描述符。
这相当于在底层自动调用了 close()。操作系统会构建并发送 FIN 包,触发标准的四次挥手流程,释放连接资源。
于是使得突然终止时和正常关闭没有任何区别。
机器掉电 / 网线断开
比如笔记本电脑电池耗尽突然关机、台式机电源线被拔、网线被物理切断。
导致操作系统来不及(或无法)发送 FIN 包。对端(服务器)完全不知道客户端已经挂了。
如果没有数据交互,服务器会认为连接依然建立(ESTABLISHED),并一直维持着这个"僵尸连接",结果就是连接进入"僵死"状态,白白占用资源。
对于上述问题,系统引入了保活策略:
TCP 内置的保活定时器
如果连接在一段时间内(默认通常是 2 小时)没有任何数据交互,TCP 会自动发送一个"探测报文"。如果连续多次收不到对方的 ACK,TCP 就会判定对方已掉线,强制关闭连接。
它的缺点在于反应太慢(默认 2 小时才检测),不适合对实时性要求高的应用。
应用层心跳
由应用程序自己定义心跳包,假设每 5 秒发一个空包。如果应用层在规定时间内没收到心跳响应,直接在应用层逻辑中断开连接。
它的优点在于灵活、反应快,如 HTTP 的长连接、即时通讯软件(IM)都大量使用此机制。