传输层协议
传输层的主要任务是为运行在不同主机上的应用程序提供端到端的逻辑通信服务。
1.UDP
1.1 再谈端口号
端口号(Port)标识一个主机进行通信的不同的应用程序。端口号通常为2字节。
在TCP/IP协议中,使用(源IP、源端口号、目的IP、目的端口号、协议号)这样一个五元组来标识一个通信。
- 源IP与目的IP体现在网络层协议报头中。
- 源端口号与目的端口号体现在传输层协议报头中。
端口号范围的划分:
- 0 - 1023: 知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的.
- 1024 - 65535: 操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配。
认识知名端口号:
有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号:
- ftp服务器使用21端口。
- ssh服务器使用22端口。
- telnet服务器使用23端口。
- http服务器使用80端口。
- https服务器使用443端口。
通过命令查看知名端口号:
bash
cat /etc/services
- 一个进程可以绑定多个端口号。
- 一个端口号不可以被多个进程绑定。
1.2 UDP协议格式

- 16位UDP长度:整个报文的长度(UDP报头 + UDP有效载荷)的最大长度。
- 16位校验和:用于检测UDP报文中(包括首部和数据)在传输过程中是否发生了错误。
- 如果校验和出错,则直接丢弃。
两大问题:
- 如何分离?
- UDP通过报头字段当中 16位UDP长度 与 定长报头(8字节) 进行报头与有效载荷分离。
- 如何分用?
- UDP通过报头字段当中 16位目的端口号 将有效载荷交付给上层对应的应用程序。
分用是一种自底向上交付的过程,底层协议根据报头中的特定标识符,决定应该将有效载荷(数据)交付给上层哪个协议或应用程序。
1.3 UDP的特点
UDP传输的过程类似于寄信。
- 无连接: 知道对端的IP和端口号就直接进行传输,不需要建立连接;
- 不可靠: 没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息;
- 面向数据报: 不能够灵活的控制读写数据的次数和数量;
1.4 面向数据报
应用层交给UDP多长的报文,UDP会原样发送,既不会拆分,也不和合并。
- 如果发送端调用一次sendto,发送100个字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节;而不能循环调用10次recvfrom,每次接收10个字节(发端发送几次,收端就要接受几次)。
- 面向数据报通过 16位UDP总长度 - 8字节定长报头 来保证 报文与报文之间是有边界的。
1.5 struct udphdr
c
struct udphdr{
__u16 source;
__u16 dest;
__u16 len;
__u16 check;
};
注意: 操作系统内核之间进行网络通信,直接使用约定好的结构体,而不进行复杂的序列化/反序列化。
为什么可以这样做?
- 因为双方约定好了协议,使用相同的语言,约定好的结构体与字段、内存布局的一致性、结构体的对齐方式。通过在有效载荷前直接拼接结构体变量在内存中存储的二进制数据。
为什么要这样做?
- 高性能,省去了序列化和反序列化的计算开销,直接内存到内存,实现简单。
1.6 UDP缓冲区
- UDP没有真正意义上的 发送缓冲区 。调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
- 因为UDP无需保证可靠性,目标是快速发送,不关心是否送达。
- UDP具有接受缓冲区,但是这个接受缓冲区不能保证收到的UDP报文顺序和发送时的顺序一致,并且如果缓冲区满了,再到达UDP报文就会被丢弃。
- 为什么要有接收缓冲区?
- 应用层读取数据是异步的,应用程序可能在处理前一个数据,无法即时响应内核的数据到达通知,而接收缓冲区相当于临时仓库,先把数据存起来,避免数据丢失。
- UDP的socket既能读也能写,并且发和收数据互不干扰,因此UDP是全双工的。
1.7 UDP的注意事项
- UDP报头中存在一个16位最大长度字段,也就是说 UDP能传输的最大数据长度是64KB(包含UDP报头)。
- 如果我们需要传输的数据超过64K,就需要在应用层手动的分包,多次发送,并在接收端手动拼接。
2. struct sk_buff结构体
每当一个网络报文被网卡驱动接收,或者由上层协议准备发送时,操作系统(特指Linux内核)的核心操作就是为其分配并初始化一个 sk_buff 结构体。
什么是 sk_buff ?
sk_buff(socket buffer)是Linux内核中用于表示和管理网络报文数据的核心数据结构。你可以把它想象成一个容器,这个信封不仅装着报文数据本身,还包含了所有处理这个报文所需的控制信息(报头)。
sk_buff结构体
c
struct sk_buff {
/* 这两个成员必须放在最前面 */
struct sk_buff *next;
struct sk_buff *prev;
struct sock *sk;
ktime_t tstamp;
struct net_device *dev;
struct net_device *input_dev;
/* 传输层头部 */
union {
struct tcphdr *th;
struct udphdr *uh;
struct icmphdr *icmph;
struct igmphdr *igmph;
struct iphdr *ipiph;
struct ipv6hdr *ipv6h;
unsigned char *raw;
} h;
/* 网络层头部 */
union {
struct iphdr *iph;
struct ipv6hdr *ipv6h;
struct arphdr *arph;
unsigned char *raw;
} nh;
/* 数据链路层头部 */
union {
struct ethhdr *ethernet;
unsigned char *raw;
} mac;
/* 数据区指针 */
unsigned char *head;
unsigned char *data;
unsigned char *tail;
unsigned char *end;
// ...
};
为什么要有 sk_buff?
服务器需要同时处理大量并发的、不同类型的网络报文,这就需要一个统一的管理机制,使用 sk_buff 结构体描述,再通过相应数据结构把 sk_buff 管理起来。通过统一的抽象,屏蔽不同协议的差异。
sk_buff中四个指针:
head:指向数据缓冲区开始位置(已分配内存的起始地址)。data:指向当前协议层有效数据的开始位置。tail:指向当前协议层有效数据的结束位置。end:指向数据缓冲区结束位置(已分配内存的结束地址)。

所谓的封装和解包,本质就是移动data指针在缓冲区的指向。通过加或减对应层的协议长度,来进行向上层或下层进行交付。
不同协议的大小是不一样的,sk_buff通过预留足够的空间(实际分配的内存 = 数据大小 + 最大可能的协议头空间),数据在中间,两头留空。
3. TCP
TCP 全称为传输控制协议(Transmission Control Protocol)对数据的传输进行一个详细的控制。
3.1 TCP协议格式

3.1.1 16位源端口号与目的端口号
源/目的端口号: 表示数据从客户端哪个进程来,到服务器哪个进程去。
3.1.2 32位序号与确认序号
这里简单理解:在下方策略会重谈。
铺垫概念:TCP最重要可靠性策略是确认应答机制。

正确理解可靠性:
-
具有应答,可以保证历史消息的可靠性。
-
通信中,最新的报文,如果永远没有应答,那就无法保证该报文的可靠性。
-
确认应答机制,不对应答做应答。
-
在思考过程的时候,需要注意,双方传递的全都是TCP报文,至少也是一个报头。
这里的问题:如果发送一个报文就需要一个应答,这种串行化的发送,太机械化和影响效率。
更通用的过程:

-
通过序号来保证乱序问题,因为乱序问题是不可靠的一种体现,通过序号来解决乱序问题。
-
确认序号 = 序号 + 1。
-
确认序号含义:代表报文序号之前的所有信息,已经全部收到了。
- 为什么?因为允许少量的应答丢失。
-
下一次发送,从确认序号开始。
-
为什么存在两个序号呢?(捎带应答)
- 服务端不是只做应答,即需要对对方的报文做确认,自己的报文也有序号!

3.1.3 4位首部长度
- 4位首部长度指的是TCP报头(含选项)的长度。
- TCP报头采用定长长度,不包含选项为 (20字节)。
- 4bit位范围 [0 , 15],单位为 4字节 范围 [0 , 60] 字节,而因为TCP报头定长20字节,因此4位首部长度的范围 [5 , 15],[20 , 60]字节。因此TCP报头一定是要能整除4字节的。
- 为什么没有报文大小,只有报头大小 ?
- 因为TCP是面向字节流的,每次发送的数据是不确定的。
- TCP的有效载荷是否会和下一个TCP报头黏在一起?
- 不会!因为每一个TCP报文和有效载荷是通过独立
sk_buff描述和管理的。
- 不会!因为每一个TCP报文和有效载荷是通过独立
- 两个问题?
- 报头和有效载荷如何分离?
- 通过TCP报头中的4位首部长度字段进行分离。
- 如何分用?
- 通过报头字段当中 16位目的端口号 将有效载荷交付给上层对应的应用程序。
- 报头和有效载荷如何分离?
3.1.4 6位标志位
标志位本质是报头中的一个bit位,是两态的(1或0)。
- URG: 紧急指针是否有效。
- ACK: 确认号是否有效。
- PSH: 提示接收端应用程序立刻从TCP缓冲区中把数据读走。
- RST: 对方要求重新建立连接,携带RST标识称为复位报文段。
- SYN: 请求建立连接,携带SYN标识称为同步报文段。
- FIN: 通知对方,本端要关闭了,携带FIN标识的称为结束报文段。
为什么要有标志位?
因为TCP需要处理多种不同类型的报文(建立连接的报文、普通数据报文、需要立即处理的数据、连接终止的报文),针对不同报文的类型,接收方要有不同的做法。
3.1.5 16位窗口大小
这里简单理解:在下方策略会重谈。
16位窗口大小:指的是自己当前接收缓冲区剩余空间的大小,是TCP实现流量控制的关键。目的是为了告诉对方自己当前的接收能力,合理的发送数据。
为什么要有流量控制呢?若缓冲区满了数据包被丢弃,直接触发超时重传不可以吗?
这是TCP出于对 资源 和 效率 的考量,虽然可以这样做,但是这么做不合理,所以通过流量控制主动避免这种情况,而不是被动的等待缓冲区满 -> 被丢弃 -> 超时重传。
-
在接收方缓冲区已满,到发送方感知到超时的这段时间里,发送方会持续不断地发送对方根本无法接收的数据包。
-
这些数据包全部被接收方无情丢弃,这是对网络带宽和CPU计算能力的巨大浪费。
TCP不仅仅保证了可靠性,其实也有效率的考量。
3.1.6 16位校验和
TCP的16位校验和用于检测TCP报文段在传输过程中是否发生错误。
3.1.7 16位紧急指针
紧急指针 是一个16位的偏移量,它与URG标志位 配合使用,用于标识TCP报文的有效载荷中,特定偏移量处,有紧急数据 。紧急数据,只有一个字节。
为什么有紧急指针?
-
想象一个远程终端会话(如SSH、Telnet):
-
用户正在运行一个耗时的计算任务。
-
需要立即中断这个任务(比如Ctrl+C)。
-
但TCP缓冲区里已经排队了很多待发送的普通数据。
-
-
没有紧急机制:Ctrl+C命令必须排队,等所有之前的数据发送完毕才能处理,用户需要等待很长时间。
-
有紧急机制:Ctrl+C作为紧急数据,可以插队立即被处理。
紧急指针提供了有限的 带外数据 能力:
-
带内数据:普通数据,按顺序处理。
-
带外数据:紧急数据,可以跳过排队立即处理。
实际场景中使用的很少。现代替代方案:使用多个TCP连接(控制连接、数据连接)架构。
3.1.8 40字节选项
与下方具体的控制策略相关联。
3.1.9 struct tcphdr
c
#include <linux/tcp.h>
struct tcphdr {
__be16 source; /* 源端口号 */
__be16 dest; /* 目的端口号 */
__be32 seq; /* 序列号 */
__be32 ack_seq; /* 确认号 */
#if defined(__LITTLE_ENDIAN_BITFIELD) /* 小端 */
__u16 res1:4, /* 保留位 */
doff:4, /* 数据偏移(首部长度) */
fin:1, /* 结束标志 */
syn:1, /* 同步标志 */
rst:1, /* 重置连接标志 */
psh:1, /* 推送标志 */
ack:1, /* 确认标志 */
urg:1, /* 紧急指针标志 */
ece:1, /* ECN-Echo */
cwr:1; /* 拥塞窗口减少 */
#elif defined(__BI G_ENDIAN_BITFIELD) /* 大端 */
__u16 doff:4, /* 数据偏移(首部长度) */
res1:4, /* 保留位 */
cwr:1, /* 拥塞窗口减少 */
ece:1, /* ECN-Echo */
urg:1, /* 紧急指针标志 */
ack:1, /* 确认标志 */
psh:1, /* 推送标志 */
rst:1, /* 重置连接标志 */
syn:1, /* 同步标志 */
fin:1; /* 结束标志 */
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif
__be16 window; /* 窗口大小 */
__sum16 check; /* 校验和 */
__be16 urg_ptr; /* 紧急指针 */
};
3.2 确认应答(ACK)机制
TCP保证可靠性处于核心地位的机制是确认应答机制。
确认应答验证历史报文是100%可靠的。

注意:在应答时,需要将ACK标志位置1。ACK大部分情况下都是1,因为有捎带应答的存在。
TCP将每个字节数据都进行了编号,即为序列号。
什么是序列号?
TCP的发送缓冲区可以理解为 char 类型的数组,所以每个字节天然就有数组下标也就是序号。在套接字部分的 read、write、send、recv 本质都是拷贝函数,将用户区的数据拷贝至内核的缓冲区中。
3.3 超时重传机制

如何理解丢包?发送放没有收到应答ACK,意味着:
-
数据丢失。
-
数据被接收方收到,但是接收方的应答丢失。
丢包指的是收不到应答并且超时,判断报文丢失。
隐藏的问题:若是第2种情况,那么接收方会收到许多重复的报文,而TCP协议是能识别出哪些是重复的报文,通过前面提到的序号,因此序号不仅仅是排序,还有去重的作用!
超时的时间如何确定?
-
理想的情况下,找到一个最小的时间,保证确认应答一定能在这个时间内返回。
-
但是这个时间的长短,是随网络环境的不同,是有差异的。
-
如果超时时间设的太长,会影响整体的重传的效率。
-
如果超时时间设的太短,有可能会频发发送重复的包。
因此,TCP为了保证无论在任何环境下都能有较高性能的通信,会动态计算这个最大超时时间。
-
Linux中(BSD Unix和Windows也是如此),超时以500ms为⼀个单位进⾏控制,每次判定超时重发的超时时间都是500ms的整数倍。
-
如果重发⼀次之后,仍然得不到应答,等待 2*500ms 后再进⾏重传。
-
如果仍然得不到应答,等待 4*500ms 进⾏重传。依次类推,以指数形式递增。
-
累计到⼀定的重传次数,TCP认为⽹络或者对端主机出现异常,强制关闭连接。
为什么会以指数的形式递增?
- 根本原因是为了根据网络情况动态调整。使用持续测量和动态计算,一旦网络通信恢复,通过不断调整重传时间,使其始终适应当前的网络延迟状况。
3.4 连接管理机制
3.4.1 示意图

-
SYN: 请求建立连接,握手过程中使用的同步标志位。(这里不仅仅是只发送一个标志位,而是一个完整的TCP报头)
- 握手时不能携带数据,只有TCP报头。
-
RST: 对方要求重新建立连接。
-
建立连接时,不一定会成功,可能会出现连接建立不一致 问题,因为双方
ESTABLISHED的时间不一致。 -
在TCP连接建立过程中,可能会出现这种情况:客户端发送的确认(ACK)报文在传输中丢失,导致服务端未能确认连接已建立。此时,客户端认为连接已成功建立,并开始发送数据;而服务端由于未收到ACK,仍处于等待状态,因此会认为客户端在未完成握手的情况下发送数据。这种情况下,服务端通常会忽略这些数据,并要求客户端重新发起连接建立过程。
-
-
FIN: 通知对方,本端要关闭了,挥手时使用的标志位。
-
connect函数只是发起连接请求,accept是直接从内核中获取建立成功的连接。三次握手是操作系统自动完成的。
3.4.2 服务端状态变化
-
CLOSED -\> LISTEN\] 服务器端调⽤listen后进⼊LISTEN状态,等待客⼾端连接。
-
SYN_RCVD -\> ESTABLISHED\] 服务端⼀旦收到客⼾端的ACK确认报⽂,就进⼊ESTABLISHED状态,可以进⾏读写数据了。
-
CLOSE_WAIT -\> LAST_ACK\] 进⼊CLOSE_WAIT后说明服务器准备关闭连接(**需要处理完之前的数据**),当服务器真正调⽤close关闭连接时,会向客⼾端发送FIN,此时服务器进⼊LAST_ACK状态,等待最后⼀个ACK到来(这个ACK是客⼾端确认收到了FIN)。
3.4.3 客户端状态变化
-
CLOSED -\> SYN_SENT\] 客⼾端调⽤connect,发送同步报⽂段(ACK)。
-
ESTABLISHED -\> FIN_WAIT_1\] 客⼾端主动调⽤close时,向服务器发送结束报⽂段,同时进⼊FIN_WAIT_1。
-
FIN_WAIT_2 -\> TIME_WAIT\] 客⼾端收到服务器发来的结束报⽂段,进⼊TIME_WAIT,并发出LAST_ACK。
3.4.4 为什么要进行三次握手?
-
以最小的成本,100%确认双方通信意愿。
-
以最短的方式,验证全双工。本质是验证双方所处的网络是通畅的,能够支持全双工。
-
客户端发SYN(这里并不代表客户端验证了可以发送数据,需要服务器应答回来才能确认)
-
服务器收到客户端SYN,向客户端发送SYN + ACK(验证服务端收,同理需要客户端ACK才能确认是否发送成功)
-
客户端收到服务器SYN + ACK(验证客户端SYN发送成功,并且验证了客户端收,因此这里客户端ESTABLISHED)
-
服务器收到客户端ACK(验证服务端向客户端发送SYN成功,因此这里服务器ESTABLISHED)
-
三次握手本质是四次握手
-
client -> SYN -> server
-
server -> ACK -> client
-
server -> SYN -> client
-
client -> ACK -> server
这里服务端发送的 SYN 与 ACK 进行了捎带应答,为什么可以这样?因为服务器天生的职责/作用,就是面对不同的客户端连接请求都要无脑接受!
三次握手核心目的:
交换彼此的初始序号。现代操作系统采用随机初始序号的方式。目的是解决安全问题。
交换双方的窗口大小。
3.4.5 为什么是四次握手?
四次握手时,没有进行捎带应答。很大程度上就是因为被动关闭的一方可能还有数据要发送,无法立即确认立即确认FIN。。
断开连接的本质:建立双方断开连接的共识。
-
Client -> FIN -> Server: 我要发送的数据已经发完了,我要和你断开。
-
Serve -> ACK -> Client: 确认收到客户端FIN。
-
Server -> FIN -> Client: 我给你发送的数据也已经发完了,我也要断开连接。
-
Client -> ACK -> Server: 确认收到服务端FIN。
3.4.6 理解 TIME_WAIT 状态
TIME_WAIT 状态,也称为 2MSL 等待状态 ,是TCP连接中主动关闭连接的一方在发送完最后一个ACK包后进入的状态。
换句话说,在四次挥手中,先发起关闭流程(即先发送第一个FIN包)的那一端 ,在最终回复了第四个ACK包之后,会进入 TIME_WAIT 状态。
一个核心记忆点:谁先调用 close(),谁就会最终进入 TIME_WAIT。
在TCP四次挥手中:
-
主动关闭方(如客户端)发送FIN。
-
被动关闭方(如服务器)收到FIN后,内核会立即回复ACK,并将连接状态置为
CLOSE_WAIT。 -
此时,连接处于半关闭状态。 主动关闭方不能再发送数据,但被动关闭方还可以发送剩余的数据。
-
被动关闭方需要等待应用程序 调用
close()系统调用来发送自己的FIN包,从而结束整个关闭流程。
3.4.7 为什么需要 TIME_WAIT?
TIME_WAIT 状态主要有两个至关重要的目的:
1.可靠地关闭连接(防止旧地重复数据包被误认)
这是 TIME_WAIT 最核心的作用。想象以下场景:
-
一个连接已经关闭,双方都认为连接结束了。
-
但网络上可能还有这个连接的迷失的数据包(比如在网络中绕了很久,即迷途报文),正在缓慢地奔向目的地。
-
此时,相同的IP和端口之间立即建立了一个全新的连接。
-
那个迷失的旧数据包终于到达了,接收方可能会错误地认为它是新连接的数据,从而造成数据混乱。
TIME_WAIT 状态通过强制等待一段时间(2MSL),来确保所有属于旧连接 的数据包都在网络中消散,从而不会干扰到新连接。
2.保证远程对端被正常关闭(让被动关闭方能够完成关闭)
考虑第四次握手的ACK包丢失的情况:
-
客户端发送了最后一个ACK(第四次握手)后,就直接CLOSED。
-
服务器在
LAST-ACK状态,它一直没有收到这个ACK。 -
根据TCP的重传机制,服务器会重新发送它的FIN包。
-
如果客户端还保持着状态(即处于
TIME_WAIT),它收到这个重传的FIN包后,可以再次发送ACK包,从而确保服务器能正常关闭。 -
如果客户端在发送ACK后就直接进入
CLOSED状态,那么服务器将永远收不到ACK,最终会超时并带着错误退出,而不是一个优雅的关闭。
为什么 TIME_WAIT 的时间是 2MSL?
MSL 是 TCP 报⽂的最⼤⽣存时间。
TIME_WAIT 状态需要确保旧连接的所有数据包都在网络中消失。一个连接的数据流是双向 的,因此我们保证在两个传输⽅向上的尚未被接收或迟到的报⽂段都已经消失。
-
对端发送的最后一个包(即第三次握手的FIN包,它可能会重传)。
-
我方发送的最后一个包(即第四次握手的ACK包)。
-
在第一个MSL时间内:如果客户端发送的ACK丢了,一定会收到服务端的重传FIN,由于客户端处于TIME_WAIT,因此客户端会重新发送最后一个ACK包,并重置 2MSL 的计时器。
-
如果第一个MSL过后,我们就确信服务端的FIN包不会再来了。也就是说,来自对端的最后一个包已经消逝。现在,我们需要确保我方发送的最后一个包(那个ACK)也在网络中消逝。第二个MSL就是等待这个ACK包消散的。
超时重传的时间远小于MSL。
3.4.8 TIME_WAIT 造成绑定的绑定失败问题及解决方案
当服务器进程崩溃,我们试图立即重启它。此时会发现当前端口仍然处于 TIME_WAIT 状态,出现 绑定失败 现象。
解决方案:允许一个新的监听套接字绑定到一个仍被 TIME_WAIT 状态套接字占用的端口。
c
// bind之前设置此选项
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
3.4.9 理解 CLOSE_WAIT 状态
如果客户端已经退出,服务端没有调用 close,此时服务端进入 CLOSE_WAIT 状态,依旧占用文件描述符,导致连接没有释放,造成文件描述符泄露问题。结合四次挥手流程,可以认为四次挥手没有正确完成。只需加上对应close即可解决问题。
3.5 滑动窗口
在确认应答策略时,对每⼀个发送的数据段,都要给⼀个ACK确认应答。收到ACK后再发送下⼀个数据段,这样做有⼀个⽐较⼤的缺点,就是性能较差,尤其是数据往返的时间较⻓的时候。既然这样⼀发⼀收的⽅式性能较低,那么我们⼀次发送多条数据,就可以⼤⼤的提⾼性能。
滑动窗口可以从逻辑上理解为在发送缓冲区用指针(下标)来标记一个可操作的范围,而发送方一次向对方发送多少数据,由滑动窗口决定。

-
滑动窗⼝⼤⼩指的是⽆需等待确认应答⽽可以继续发送数据的最⼤值。
-
可以把缓冲区想象为一个 环形结构 ,不需要刻意的清空已发送已确认区域,序号在发送的过程,是依次增大的,也就意味着滑动窗口是向右滑动的。
-
滑动窗口的大小 = 对方接收能力也就是收到报文中的窗口大小(暂时理解)。
-
根据 确认序号的定义 (表示该报文之前的数据已经都收到了),这里可以简单理解为:
- start=报文确认序号start = 报文确认序号start=报文确认序号,end=start+窗口大小end = start + 窗口大小end=start+窗口大小。
-
滑动窗口的本质是流量控制的具体实现方案。
如果丢包了,滑动窗口,会不会跳过报文进行应答?以下三种情况的任意组合。
-
最左侧丢失。
-
最左侧报文丢失
-
当发送方滑动窗口中最左侧的数据包(即序列号最小的那个已发送未确认包)丢失时,接收方因为无法按序接收,其每次收到后续数据包时,都会重复返回对这个丢失包序号 的确认(即确认序号为丢失包序号 + 1)。这意味着,发送方会连续收到多个确认序号完全相同的ACK响应 。一旦在收到足够数量(通常为3个 )的重复ACK后,发送方就可以确信该最左侧的数据包已经丢失(中间报文和右边报文丢失情况确定不了) 。此时,发送方会立即重传该数据包,而不必等待其重传计时器超时。这一机制被称为快重传。
-
超时重传和快重传的底层支持是滑动窗口。快重传是有条件的、提高效率的;而超时重传是兜底的。快重传与超时重传相互配合。

-
-
最左侧报文对应的应答丢失
- 滑动窗口正常工作,因为部分ACK丢失不要紧,可以根据后续的ACK进行确认 (确认序号的定义)。
-
-
中间报文丢失。
- 窗口移动到中间报文丢失位置,转换为最左侧报文丢失情况。
-
最右侧报文丢失。
- 窗口移动到最右侧报文丢失位置,转换为最左侧报文丢失情况。
理解保存:TCP发出报文,暂时没有应答时,必须让对应的报文保存起来,保存到滑动窗口内部。
3.6 流量控制
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继⽽引起丢包重传等等⼀系列连锁反应。
因此TCP⽀持根据接收端的处理能⼒,来决定发送端的发送速度。这个机制就叫做 流量控制。
-
接收端将⾃⼰可以接收的缓冲区剩余空间⼤⼩放⼊ TCP ⾸部中的 窗⼝⼤⼩ 字段,通过ACK端通知发送端。
-
窗⼝⼤⼩字段越⼤,说明⽹络的吞吐量越⾼。
-
接收端⼀旦发现⾃⼰的缓冲区快满了,就会将窗⼝⼤⼩设置成⼀个更⼩的值通知给发送端。发送端接受到这个窗⼝之后,就会减慢⾃⼰的发送速度。
-
如果接收端缓冲区满了,就会将窗⼝置为0。这时发送⽅不再发送数据,但是需要定期发送⼀个 窗⼝探测数据段 ,使接收端把窗⼝⼤⼩告诉发送端 或 接收方发送窗口更新通知段。二者相互配合。
窗口大小是16位,最大表示65535,TCP窗口大小最大就是65535字节吗?
实际上,TCP⾸部40字节选项中还包含了⼀个窗⼝扩⼤因⼦M,实际窗⼝⼤⼩是 窗⼝字段的值左移 M 位。
3.7 拥塞控制
3.7.1 什么是拥塞控制?
TCP不仅仅考虑了双方主机的问题,也考虑了网络本身的问题。网络发生拥塞时,通过调整发送方的数据发送速率来避免网络崩溃,并尽可能高效地利用网络带宽。
根据丢包的多少来判断当前网络的拥塞状况,少量的丢包触发超时重传,大量的丢包认为是网络拥塞 ,如果我们出现大量的丢包,就立即重发,可能会增加网络的负载,使其更加拥堵。通过慢启动解决。
3.7.2 慢启动
虽然TCP可以根据滑动窗口,能够高效的发送大量可靠的数据,但是如果刚开始就发送大量的数据,仍然可能引发问题。
因为网络上有很多计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络的状态下,贸然发送大量的数据,是可能引起雪上加霜的。
TCP引入 慢启动 机制,先发送少量的数据包,探测网络的环境,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
拥塞控制是会让周边多个发送端主机,都采用这种策略。前期发送方都建立共识,尽快恢复网络过程。
3.7.3 核心思想
发送方维护一个 拥塞窗口(cwnd),本质就是一个数值 。值以下,网络大概率不拥塞;值以上,网络可能拥塞。网络是变化的,就决定这个拥塞窗口值一定是需要更新变化的 。
滑动窗口的大小 = min(拥塞值 , 对方通告的窗口大小)。 即不仅要考虑对方的接收能力,也要考虑网络的情况。
- 拥塞窗口刚开始以指数的形式进行增长 2x2^x2x,x依次取值{0 , 1 , 2, ...}。因此,指数级别的增长正是 慢启动,初始时慢,但是增长速度快。
- 但是拥塞窗口不能是单纯的指数增长,此处引入一个慢启动的阈值(ssthresh) ,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。

-
指数增长期间的目的:解决拥塞,恢复网络和通信。
-
线性增长期间的目的:不断探测当前网络新的拥塞值。
-
ssthresh(慢启动阈值)的初始值 在 TCP 实现中通常被设为一个很大的值,具体取决于不同的实现和标准。
-
将慢启动阈值初始值设得极大,是为了强制TCP连接从一开始就通过慢启动来主动探测网络情况
-
即使没达到阈值就发生网络拥塞,ssthresh都会立即乘法减小
-
-
ssthresh新值 = 发生拥塞时的窗口值/2发生拥塞时的窗口值 / 2发生拥塞时的窗口值/2
注意:
-
随着拥塞窗口的值不断增加,发送的数据量不一定在增加,因为还要根据接收方的窗口大小所决定。TCP连接在理想环境下最终会稳定在一个平衡点 滑动窗口 = 对方接收能力(窗口大小)。
-
拥塞窗口值也会受带宽的影响,这是最根本的物理限制。
拥塞控制,归根结底是TCP协议想尽可能快的把数据发送给对方,但是又要避免对网络造成太大的压力,所采取的折中方案。
3.8 延迟应答
如果接收数据的主机⽴刻返回ACK应答,这时候返回的窗⼝可能⽐较⼩。
-
假设接收端缓冲区为1M,⼀次收到了500K的数据;如果⽴刻应答,返回的窗⼝就是500K。
-
但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了。
-
如果接收端稍微等⼀会再应答,⽐如等待200ms再应答,那么这个时候返回的窗⼝⼤⼩就是1M。
也不是所有数据包都会延迟应答。
-
数量限制:每隔N个包就应答⼀次。
-
时间限制:超过最大延迟时间就应答一次。
⼀般N取2,超时时间取200ms。为什么可以每隔N个包应答?因为确认序号的定义(表示该报文之前的数据已经都收到了),通过累计确认应答机制,一次应答确认多个数据报文。
3.9 TCP的特点
3.9.1 面向字节流
创建⼀个TCP的socket,同时在内核中创建⼀个 发送缓冲区 和⼀个 接收缓冲区 ,一个连接,既可以读数据,也可以写数据,这个概念叫做 全双工。
-
调⽤write时,数据会先写⼊发送缓冲区中(本质是拷贝)。
-
什么时候发、发多少、出错了怎么办?由操作系统自主决定。
-
写100个字节数据时,可以调⽤⼀次write写100个字节,也可以调⽤100次write,每次写⼀个字节。
-
读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以⼀次read 100个字节,也可以
⼀次read⼀个字节,重复100次。
3.9.2 粘包问题
根本原因是 TCP是面向字节流的协议 ,它并不关心上层应用消息的边界。接收方应用程序调用对应的读取函数时,它 可能一次读取到多个报文,也可能只读取到一个完整报文的一部分。
解决方案的核心思想:在应用层协议上,明确两个包之间的边界。让接收方能够根据边界从字节流中正确切分并读取一个完整的应用消息。
- 对于定长的包,保证每次按固定大小读取即可。
- 对于变长的包,使用分隔符,在应用消息末尾添加一个特殊的分隔符,例如
\n。 - 对于变长的包,也可以在包前位置,定义一个固定长度的字段,用来表示包的总长度。
UDP不存在粘包问题,UDP通过报头中 16位UDP总长度字段 - 8字节定长报头 来保证报文与报文之间是有边界的。
3.9.3 异常情况
- 进程终止:当进程终止时会正常释放文件描述符,仍然会进行四次挥手,和正常关闭没什么区别。
- 关机:关机首先会关闭所有的进程,和正常进程终止的情况相同。
- 机器掉电/⽹线断开:接收端认为连接还在,⼀旦接收端有写⼊操作,接收端发现连接已经不在了,就会进⾏reset。即使没有写⼊操作,TCP⾃⼰也内置了⼀个保活定时器,会定期询问对⽅是否还在。如果对⽅不在,也会把连接释放。
TCP对于异常情况,有很强的容错。
3.9.4 小结
TCP既保证可靠性,同时又尽可能提高效率。
可靠性:
-
校验和
-
序列号
-
确认应答
-
超时重传
-
连接管理
-
流量控制
-
拥塞控制
提高性能:
-
滑动窗口
-
快重传
-
延迟应答
-
捎带应答
其他:
- 定时器(超时重传定时器、保活定时器、TIME_WAIT定时器等)
TCP与UDP要辩证的看待,根据具体的需求判定使用场景。
UDP实现可靠性传输,可以参考TCP的可靠性机制,在应用层实现类似的逻辑。