【Linux网络】传输层协议TCP——一文彻底搞定TCP协议
1. TCP协议TCP 全称为 "传输控制协议( Transmission Control Protocol )",人如其名,要对数据的传输进行一个详细的控制;
在这里插入图片描述1.1 TCP协议格式在这里插入图片描述TCP报头的长度也是定长的——20字节(不包含选项的长度)
TCP头部各字段详细说明
源端口号 (Source Port) 和 目的端口号 (Destination Port) 长度: 各占16位(2字节)。
作用: 标识发送和接收数据的应用程序进程。
详解:
源端口号: 标识发送方主机上的特定应用程序(如浏览器、邮件客户端)。
目的端口号: 标识接收方主机上等待连接的目标服务(如Web服务默认80端口,SSH服务默认22端口)。
端口号与IP地址共同构成了网络通信中唯一的“套接字”(Socket),确保了数据能够准确交付给正确的应用程序。
序列号 (Sequence Number) 长度: 32位(4字节)。
作用: 标识本报文段所发送数据的第一个字节在整个数据流中的字节编号。
详解:
在建立连接时,双方会随机初始化一个初始序列号(ISN),以防止网络中存在延迟的旧报文造成混淆。
后续的序列号表示该报文段第一个数据字节的编号。例如,如果初始序列号为1000,且第一个报文段携带了500字节的数据,那么下一个报文段的序列号就是1501。
核心功能:用于数据排序和去重,是TCP可靠传输的基石。
确认号 (Acknowledgment Number) 长度: 32位(4字节)。
作用: 期望收到的下一个报文段的序列号。表示确认号减一之前的所有数据都已被正确接收。
详解:
只有在控制标志中的ACK位为1时,此字段才有效。
它采用“累积确认”机制。例如,如果接收方发送的确认号为1501,意味着序列号1500及之前的所有字节都已成功接收,现在期望发送方从序列号1501开始发送。
核心功能:实现确认重传机制,保证数据的可靠交付。
数据偏移 (Data Offset) / 首部长度 (Header Length) 长度: 4位。
作用: 指示TCP头部的总长度,以4字节(32位字)为单位。
详解:
因为TCP头部有可变长的“选项”字段,所以需要这个字段来明确头部结束和数据开始的位置。
该字段的最小值是5(二进制0101),表示标准的20字节头部。最大值是15(二进制1111),表示头部最大长度为60字节(15 * 4 = 60)。
保留 (Reserved) 长度: 6位。
作用: 为未来协议扩展而保留,必须设置为0。
控制标志 (Control Flags) 长度: 6位,每位代表一个特定的控制功能。
作用: 用于管理TCP的连接状态和控制数据传输。
各标志位详解:
URG (Urgent): 为1时,表示报文段中有紧急数据,应优先处理。此时紧急指针字段有效。
ACK (Acknowledgment): 为1时,表示确认号字段有效。在连接建立后,绝大多数报文此位都被置1。
PSH (Push): 为1时,要求接收方立即将数据推送给上层应用程序,而不必等待缓冲区填满。常用于交互式应用(如Telnet、SSH),以降低延迟。
RST (Reset): 为1时,表示需要重置连接。通常用于异常终止连接,或拒绝一个非法的连接请求。
SYN (Synchronize): 为1时,表示这是一个连接请求或连接接受报文。用于三次握手建立连接。
FIN (Finish): 为1时,表示发送方数据已发送完毕,要求释放连接。用于四次挥手关闭连接。
窗口大小 (Window Size) 长度: 16位。
作用: 指示从确认号开始,接收方愿意接收的字节数量。用于流量控制。
详解:
该字段告诉对方:“从你发送的确认号开始,最多还能再发送这么多字节给我,否则我的缓冲区可能会溢出。”
窗口大小是动态变化的,接收方根据自身的接收缓存能力来调整,从而控制发送方的发送速率,避免接收方被过快的数据淹没。
校验和 (Checksum) 长度: 16位。
作用: 用于检验TCP头部、数据以及一个伪首部在传输过程中是否出现差错。
详解:
发送方计算校验和,接收方进行验证。如果校验失败,接收方会直接丢弃该报文,发送方超时后重传。
这是保证数据无差错传输的重要环节。
紧急指针 (Urgent Pointer) 长度: 16位。
作用: 当URG标志为1时,此指针指示本报文段中紧急数据的末尾在数据部分中的位置(相对于当前序列号的偏移量)。
详解:
它标记了紧急数据的结束,而紧急数据从数据部分的开始。这个机制允许接收方优先处理紧急数据(如终端命令Ctrl+C)。选项 (Options) 和 填充 (Padding) 长度: 可变,但必须是4字节的整数倍。不足时用0填充(Padding)。
作用: 提供一些可选功能。
常见选项:
最大报文段长度 (MSS): 在三次握手时通告对方本端能接受的最大报文段长度。
窗口缩放因子 (Window Scale): 用于在高速网络中扩大窗口大小(原始16位窗口最大只有64KB)。
选择性确认 (SACK): 允许接收方告知发送方哪些不连续的数据块已经收到,提高重传效率。
数据 (Data) 长度: 可变,可以为0。
作用: 承载上层(如HTTP、FTP等)的应用数据。
详解:
在连接建立和终止的报文中(如仅含SYN、FIN标志的报文),数据部分长度通常为0。 那么TCP报文的报头和有效载荷如何分离呢
TCP 报文头部与有效载荷的分离依赖于头部中的 “数据偏移”(Data Offset) 字段。具体步骤如下:
读取 TCP 报文的前 20 字节
这是 TCP 头部的基本部分(固定长度),其中包含 数据偏移 字段(位于第 13 字节的高 4 位)。
解析“数据偏移”字段
该字段以 4 字节 为单位表示整个头部的长度。例如:
若数据偏移值为 5,表示头部长度为 5×4 = 20 字节(无选项)。若数据偏移值为 8,表示头部长度为 8×4 = 32 字节(含 12 字节选项)。注意:长度4位的数据偏移以4字节为单位,所以头部长度的范围是 5 (0101) ~15(1111),即20字节~60字节 根据数据偏移计算头部结束位置
从报文起始位置向后偏移 (数据偏移值 × 4) 字节,即得到头部结束处。之后的所有内容即为 有效载荷(应用层数据)。
处理选项字段(如果有)
如果数据偏移大于 5,说明头部包含选项。在读取完基本头部(20 字节)后,继续读取 (数据偏移值×4 – 20) 字节的选项,然后剩余部分才是有效载荷。
示例
假设收到一个 TCP 报文,其数据偏移字段为 6,则:
头部总长度 = 6 × 4 = 24 字节。前 20 字节为固定头部,接着 4 字节为选项,之后全部为有效载荷。 为什么在TCP中,没有包含整个报文大小,只有报头大小?而UDP中就有16位UDP长度
TCP 是面向字节流的协议TCP 把数据视为连续的字节流,不保留单个“消息”边界。接收方依靠序列号和确认号来重组数据流,不需要在 TCP 层知道每个报文段的确切数据长度。整个 TCP 段的长度可以通过 IP 总长度减去 IP 头部长度和 TCP 头部长度(由“数据偏移”字段给出)间接得到。因此,TCP 头部只需提供头部长度(数据偏移),而不需要再额外携带整个报文段的总长度。UDP 是面向数据报的协议 UDP 每个数据报都是独立的,接收端必须能够明确区分每个报文的起止。16 位 UDP 长度字段直接指明头部与数据的总长度,让接收方知道当前数据报在哪里结束
由于 UDP 没有连接状态,也不使用序列号,只能依靠长度字段判断报文边界,否则无法从 IP 层交付的数据中正确分离出 UDP 报文。
协议分工的不同IP 层已经提供了总长度字段,TCP 利用这一信息(结合自己的头部偏移)即可算出数据长度,避免了在传输层重复记录。UDP 同样可以借助 IP 长度推算,但为了自包含(self‑contained)以及支持伪首部校验和计算,仍保留了独立长度字段。1.2 TCP可靠性的本质在通信中,如何实现可靠性?是否可能达到100%的可靠?
在这里插入图片描述
如果对端没有给我们应答,那我们就不能确定对端是否收到我发送的消息,这其实就说明世界上就没有100%可靠的协议,但是我们能保证历史消息的可靠性
正确理解可靠性
对历史消息的保证: 关键机制:具有应答。
一旦发送出去的报文收到了接收方的确认应答,发送方就能100%确定该历史消息已经被对方成功接收。这是可靠性得以实现的基础。
对最新消息的挑战: 核心困境:最新的报文永远没有应答。
在通信过程中,刚刚发送出去的、最新的那个报文,在它被确认之前,其状态是未知的。发送方无法确定它是否已经到达、是否在途中丢失或损坏。因此,单凭应答机制,无法保证“最新”报文的可靠性。
为了确保某个消息的可靠性,我们可以“牺牲”其“最新性”。即:不要求它是最新的报文,只要求它有应答即可。
例如,在一个长连接中,我们可以通过编号或时间戳区分消息的新旧。只要某个消息获得了ACK,我们就知道它“可靠送达”,哪怕它不是最近发出的。
1.3 TCP的一般通信过程(暂时)发送方发送一个报文,接收方收到后必须回复一个确认应答。只有收到这个应答,发送方才能确信数据已经成功送达。这其实就是确认应答机制,即确认应答机制:发送方发送数据后,接收方需返回“应答”,以确认收到数据。
在这里插入图片描述
对于应答,我们不再需要回应,即不对应答做应答,因为ACK不携带数据,且TCP协议设计中ACK可被捎带,避免无限循环
下面我们以客户端给服务端发送报文为例
实际上,客户端可以一次发送多个消息,服务端则对每个消息做应答
在这里插入图片描述
每个TCP报文段都有一个序号(Seq),用于标识数据字节流中的位置。
接收方返回的确认序号(Ack) = 收到的最后一个正确报文的序号 + 1,表示“期待下一个字节的序号”。
序号(Sequence Number, Seq)与确认序号(Acknowledgment Number, Ack) Seq:每个TCP报文段包含一个32位的序列号,表示该报文段中第一个字节在整个字节流中的位置。例如,第一个报文段的Seq=0,若其携带100字节数据,则下一个报文段的Seq=100。
Ack:接收方返回的确认号表示“我已成功接收所有序号小于Ack的数据,下一个期望接收的字节序号是Ack”。
举例:
发送方发送一个报文段:Seq = 100, Data = 100 bytes接收方成功接收后,返回:Ack = 200这表示:已收到序号从100到199的所有数据,下一个期望收到的是序号200。可靠性保障机制(1)确认应答(ACK)
发送方每发送一个报文段,就启动一个计时器。若在超时时间内未收到ACK,则重传该报文段。“不对应答做应答” —— 即ACK本身不需要被再次确认(因为ACK不携带数据,且TCP协议设计中ACK可被捎带,避免无限循环)。(2)解决乱序问题
报文段可能因网络路径不同而乱序到达。接收方根据Seq号将报文段按序排列,重新组装成原始数据流。如果某个报文段缺失,则接收方持续返回相同的Ack,直到收到缺失报文。(3)解决丢包问题
超时重传机制确保丢包后能重发。接收方只确认连续到达的数据,未收到的报文段会一直被请求。捎带应答(Piggybacking)当接收方要向发送方发送数据时,可以在自己的数据报文中捎带对之前收到数据的ACK。提高网络效率,减少单独ACK包的数量。例如:
A发送数据给B。B准备回送数据给A → 在B的数据报文中带上“A的数据已收到”的ACK信息。 为什么需要两个序号?
这是一个常见疑问:“为什么不能只用一个序号?”
答案:因为通信是双向的!
发送方需要自己的Seq来标识自己发出的数据。接收方需要Ack来告诉对方“我已经收到了哪些数据”,同时还会捎带自己的数据,自己的数据就需要序号来标识 类比:你寄快递给朋友,你的包裹上贴有“序号1”,朋友收货后回复“我已收到序号1,期待序号2”。这里的“序号1”是你发的,“序号2”是他希望你下一次发的。这就是两个独立的序列号系统。
在通信的时候,双方传递的报文就是tcp报文,最少也得是一个报头
1.4 流量控制和滑动窗口发送与接收的速度不匹配
发送方应用产生数据的速度可能远快于接收方应用处理数据的速度,或快于网络的处理能力。
如果发送方不顾一切地发送数据,最终会导致接收方的接收缓冲区被填满,后续的数据包会被丢弃,引发大量重传,造成网络资源和计算资源的浪费
在TCP协议中,流量控制(Flow Control)是为了防止发送方发送数据过快,导致接收方来不及处理或缓冲区溢出。其核心机制是滑动窗口协议。
在TCP报文段的首部中,有一个 16位窗口大小(Window Size)字段,它的作用是:
告知发送方,接收方当前可用的接收缓冲区大小(即接收方还能接收多少字节的数据)。
换句话说,这个“窗口大小”是接收方告诉发送方的,表示的是接收方的接收缓冲区中剩余空间的大小。
具体机制如下:
接收方维护一个接收缓冲区,用于暂存从网络接收到但尚未被应用层读取的数据。当接收方收到数据后,会将数据放入缓冲区,并向发送方返回一个ACK确认报文。在这个ACK报文中,包含了一个“窗口大小”字段,其值等于:
窗口大小 = 接收缓冲区总大小-已占用空间
也就是当前可用的缓冲区空间大小。发送方根据收到的这个窗口大小值,调整自己的发送速率:如果窗口大小为0,发送方必须停止发送(或发送零窗口探测);如果窗口大小大于0,发送方可以发送最多等于该窗口大小的数据;随着接收方处理数据并释放缓冲区空间,窗口大小会动态增加,发送方可以继续发送。 所以“发送端如何尽早得知对方的接收能力?”
答案是:通过接收方在ACK报文中携带的“窗口大小”字段。
按量按需发送,必须知道对方的接收缓冲区中剩余空间的大小
1.5 标志位我们来看一下内核中的tcp报头
代码语言:javascript复制// linux kernel 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,
cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u16 doff:4,
res1:4,
cwr:1,
ece:1,
urg:1,
ack:1,
psh:1,
rst:1,
syn:1,
fin:1;
#else
#error "Adjust your
#endif
__be16 window;
__sum16 check;
__be16 urg_ptr;
};doff (Data Offset):这实际上是TCP头部长度(以32位字为单位),而不是一个标志。它指示了TCP头部有多少32位字(4字节)。由于TCP头部可能包含可选项,因此这个字段用于告诉接收方TCP头部有多长。通常,没有可选项的TCP头部长度是20字节(即doff为5,因为5*4=20字节)。res1:这是保留位,通常被设置为0。它们用于未来的协议扩展。cwr (Congestion Window Reduced):当发送方收到一个带有ECE(Explicit Congestion Notification,显式拥塞通知)标志的ACK时,它可能会设置CWR标志来响应。这通常用于ECN(Explicit CongestionNotification,显式拥塞通知)机制,一种改进TCP拥塞控制的机制。ece (ECN-Echo):当TCP的接收方检测到网络拥塞时,它可能会发送一个带有ECE标志的ACK给发送方。ECE标志告诉发送方,接收方已经检测到拥塞,并且可能希望发送方减少其发送速率。标志位:本质就是报头中的比特位!
为什么需要标志位?
因为网络中传输的TCP报文有不同的类型(如建立连接、传输数据、断开连接),接收方需要根据报文类型做出不同的处理。标志位就是用来明确标识一个TCP报文的类型和目的的。例如:
SYN:用于建立连接。
ACK:确认字段有效,表示这是一个确认报文。
FIN:用于断开连接。
每个标志位具体有什么作用呢?下面我们展开来讲讲
SYN:用于建立连接
ACK:确认字段有效,表示这是一个确认报文。
其中涉及到三次握手,我们来介绍一下
第一次握手(客户端 -> 服务器):客户端发送一个报文,其中 SYN 标志位被置为1。这是一个连接请求。
第二次握手(服务器 -> 客户端):服务器同意连接,回复一个报文。这个报文同时设置了 SYN 和 ACK 标志位(即 SYN-ACK报文)。
第三次握手(客户端 -> 服务器):客户端再次发送一个确认报文,其中 ACK 标志位被置为1。此时连接建立成功,双方可以开始正常通信。
类比:
client客户端:我想要和你建立连接(SYN)server服务端:好的,什么时候?(ACK+ SYN)client客户端:就现在(ACK)✅ 连接建立成功,进入ESTABLISHED状态。
在这里插入图片描述
关键点:前两次握手不携带应用数据,是为了防止恶意攻击(如SYN洪泛攻击)。只有在确认连接合法后,第三次握手及之后的报文才可以开始传输数据。
ACK几乎是常被设为1的
FIN:用于断开连接。涉及四次挥手
连接终止需要四个步骤,因为它是一个全双工连接的独立关闭。
第一次挥手(主动方 -> 被动方):主动关闭方发送一个 FIN 标志位为1的报文。
第二次挥手(被动方 -> 主动方):被动方收到FIN后,回复一个 ACK 报文进行确认。
第三次挥手(被动方 -> 主动方):被动方在准备好后,也发送一个 FIN 报文。
第四次挥手(主动方 -> 被动方):主动方最后发送一个 ACK 报文进行确认,随后连接完全关闭。
在这里插入图片描述 PSH (Push): 为1时,要求接收方立即将数据推送给上层应用程序,而不必等待缓冲区填满
没有PSH的情况(默认行为):
发送方:应用程序调用send()写入的数据,TCP不会立即发送。它可能会在发送缓冲区中积累更多数据,以便组合成一个更大的报文段发送,提高网络利用率(Nagle算法也可能起作用)。
接收方:TCP接收到数据后,不会立即交给应用程序。它可能会在接收缓冲区中等待,直到积累到足够的数据,或者一个定时器超时后,才将数据一次性提交给应用。
这种缓冲机制虽然提高了效率,但对于交互式应用来说,会导致不可接受的延迟。
使用PSH的情况:
PSH标志就像一个“刷新”按钮。当它被设置时:
对发送方:TCP应立即发送缓冲区中的所有数据(包括当前这个设置了PSH的报文段)。
对接收方:TCP应立即将缓冲区中已有的所有数据(包括当前这个报文段的数据)推送给上层应用程序。
注意:
PSH不是“紧急数据”: 这是最常见的混淆。PSH 和 URG 是两个完全不同的标志。
PSH:关乎数据的交付时机(什么时候交给应用)。
URG:关乎数据的处理优先级(标识数据中有一部分是“紧急”的)。
PSH不保证数据立即到达:PSH只影响发送和接收两端TCP栈的行为,它不改变报文在网络中的传输路径、优先级或速度。它不提供任何QoS保证。应用程序通常不直接控制PSH: 在现代操作系统中,TCP协议栈的实现非常智能,它通常会自动判断何时应该设置PSH位。例如,当发送缓冲区被清空,或者一个write()操作恰好填满一个MSS(最大报文段长度)时,栈可能会自动设置PSH。
大多数Socket API(如Berkeley sockets)并没有给应用程序提供直接设置PSH位的标准接口。应用程序通过write()或send()写入数据,而由TCP栈决定是否设置PSH。
PSH不是双向的:一个方向的PSH标志只影响该方向的数据流。ACK包可以携带PSH标志,但这表示的是反方向数据的推送。 RST (Reset): 为1时,表示需要重置连接。通常用于异常终止连接,或拒绝一个非法的连接请求。
与正常的FIN挥手告别不同,RST是一种突然的、强制性的、非优雅的连接终止方式。
核心效果:一旦收到RST,接收方会立即释放连接所有相关资源,连接状态直接回到CLOSED,不经过任何中间状态。
如果说FIN是礼貌的"再见",那么RST就是紧急的"断开!现在!"。它存在的根本目的是处理那些无法通过正常TCP流程解决的错误和异常情况。
RST提供了一种最后的保障,让一方能够单方面宣布连接失效,并强制对方释放资源,防止连接 hanging(挂起)。
RST 的常见触发场景
以下是导致发送RST报文的典型情况:
连接到不存在的端口 场景:客户端尝试连接到一个服务器端口,但该端口上没有进程在监听。
行为:服务器方的TCP协议栈会直接回复一个RST报文。
例子:telnet 192.168.1.1 9999,如果9999端口没有开放,你会看到"Connection refused",这背后就是收到了RST。
处理半打开连接 场景:一方(通常是客户端)已经崩溃或异常重启,而另一方(服务器)不知情。当服务器向这个"僵尸连接"发送数据时。
行为:客户端的新TCP实例收到不认识的连接数据,会回复RST。
例子:你的电脑突然蓝屏重启,然后服务器还在向之前的连接发数据,你的新系统会回复RST。
应用程序异常终止 场景:应用程序设置了SO_LINGER套接字选项,且超时设为0。
行为:当应用调用close()时,TCP会发送RST而不是正常的FIN,目的是快速释放资源,避免TIME_WAIT状态。这在服务器开发中有时用于处理高并发时的端口耗尽问题。
响应非期望的报文 场景:收到了一个完全不属于当前任何连接的报文(比如序列号完全对不上,或者连接已经关闭)。
行为:由于无法识别该报文属于哪个有效连接,TCP栈会回复RST。
收到SYN但内存不足 场景:服务器收到SYN握手请求,但系统内存不足,无法创建新的连接控制块。
行为:服务器可能会直接回复RST,拒绝连接建立。
安全或管理性中断 场景:防火墙、入侵检测系统或管理员主动中断可疑连接。
行为:模拟一方发送RST报文,强行拆解连接。这被称为 “TCP Reset攻击”。
URG (Urgent): 为1时,表示报文段中有紧急数据,应优先处理。此时紧急指针字段有效。
核心概念:URG机制允许发送方告诉接收方:“这个报文中有一些数据需要被’紧急’处理,请优先关注这部分数据。”
URG的设计初衷是为了实现一种带外数据的传输机制,即在正常数据流之外传递一些控制信令或紧急通知。
典型应用场景:
Telnet/SSH会话中的中断命令:当用户按下Ctrl+C想要中断当前执行的任务时,这个中断信号需要通过URG机制优先传递给服务器。
紧急关闭请求:应用程序需要立即通知对端停止当前操作。
实时应用中的高优先级消息
URG 的工作机制
URG机制涉及两个关键组件:
URG 标志位 位置:TCP报头标志字段中的1位
功能:当设为1时,表示"本报文包含紧急数据"
紧急指针字段 位置:TCP报头的16位字段
功能:指示紧急数据在报文段中的结束位置
计算方式:紧急指针值 + 当前序列号 = 紧急数据最后一个字节的下一个字节的序列号
关键理解:
紧急指针指向的是紧急数据的结束位置,而不是开始位置
紧急数据总是从TCP数据部分的第一个字节开始,到紧急指针指示的位置结束
紧急数据的长度 = 紧急指针值
问题与局限性:
实现不一致: 不同操作系统的URG实现存在差异
有些系统只支持1字节的紧急数据
紧急指针的解释在不同系统中可能不同
安全性问题: URG机制可能被用于网络攻击(如端口扫描、OS指纹识别)
许多防火墙会过滤或忽略URG标志
应用层替代方案: 现代应用更倾向于在应用层实现优先级机制
使用单独的控制通道或消息队列处理紧急信令
HTTP/2、WebSocket等协议在应用层提供了更好的流控制
有限的实用性: 真正的"紧急"通信场景很少
大多数应用的数据都有相似的优先级
2. TCP的主要机制2.1 确认应答(ACK)机制在这里插入图片描述
TCP将每个字节的数据都进行了编号,即为序列号.
对于TCP来说,它传输的不是一个个独立的“消息包”,而是一个连续的、无结构的字节流。
应用层视角: 你可能会发送两条消息:“Hello” 和 “World”。
TCP视角: 它看到的是一个连续的字节序列:‘H’, ‘e’, ‘l’, ‘l’, ‘o’, ‘W’, ‘o’, ‘r’, ‘l’, ‘d’。
序列号就是为这个连续的字节流中的每一个字节都分配一个唯一的编号。
序列号在报文段中的具体体现
虽然每个字节都有编号,但TCP是以报文段为单位来发送数据的。每个TCP报文段的首部中都包含一个序列号字段。
这个字段的值,指明了该报文段所携带的【第一个数据字节】的序列号。
在这里插入图片描述 第一个报文段:序列号 = 1。这意味着它包含了从第1个字节开始的1000个字节(即字节1 到 1000)。
第二个报文段:序列号 = 1001。这意味着它包含了从第1001个字节开始的1000个字节(即字节1001 到 2000)。
第三个报文段:序列号 = 2001。这意味着它包含了从第2001个字节开始的1000个字节(即字节2001 到 3000)。
确认应答如何与字节编号对应
接收方的确认同样是基于字节序列号的。
当接收方成功收到第一个报文段(字节1-1000)后,它会回复:ACK = 1001。
这个 1001 的含义是:“截至到第1000号字节的所有数据我都已收到,我下一个期望收到的字节的序列号是1001”。 当发送方收到 ACK=1001 后,它就知道前1000个字节已经安全送达,可以继续发送从1001开始的数据。
初始序列号是如何确定的?
在TCP建立连接(三次握手)时,通信双方会各自随机生成一个初始序列号。使用随机值是为了安全,防止被恶意预测和攻击。
客户端 在 SYN 报文中携带自己的初始序列号(例如 seq = x)。
服务端 在 SYN-ACK 报文中携带自己的初始序列号(例如 seq = y),并确认客户端的序列号(ACK = x+1)。
客户端 在最后的 ACK 报文中确认服务端的序列号(ACK = y+1)。
至此,连接建立,双方后续的数据传输都基于这个初始序列号递增。
2.2 超时重传机制如何理解丢包,重新理解应答报文
丢包分为两种情况
情况一:主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;
在这里插入图片描述
情况二:主机A未收到B发来的确认应答。也可能是因为ACK应答报文丢失了
在这里插入图片描述 发送方没有收到应答ACK,意味着什么?意味着丢包吗?
丢包,要么数据丢,要么应答丢,你能确认是数据丢还是应答丢吗?你无法确切地判断究竟是原始数据包在传输途中丢失,还是接收方返回的确认应答(ACK)包丢失了
这只能意味着数据可能丢失,对方可能没有收到。
但是在这两种情况下,发送方会陷入永远的等待,那怎么办呢?
尽管无法区分原因,但TCP协议通过一套统一的机制来保证可靠性,你无需关心具体是哪种丢包:
一视同仁:超时重传
对于发送方来说,区分这两种情况既无必要,也无意义。其核心目标是确保数据可靠送达。因此,TCP采用了统一的策略:只要在预定时间内没有收到ACK,就触发重传。
处理重复数据
在ACK丢失的情况下,你的重传会导致接收方收到两份完全相同的数据。为此,TCP在报头中为每个数据字节设置了序列号。接收方可以利用这个序列号识别出重复的数据包并将其丢弃,确保向上层应用提交的数据是完整且不重复的
那么,如果超时的时间如何确定?
最理想的情况下,找到一个最小的时间,保证 “确认应答一定能在这个时间内返回”.但是这个时间的长短,随着网络环境的不同,是有差异的.如果超时时间设的太长,会影响整体的重传效率;如果超时时间设的太短,有可能会频繁发送重复的包;TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间.
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍.如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传.如果仍然得不到应答,等待 4*500ms 进行重传,依次类推,以指数形式递增.累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接在Linux中,默认的最大重传次数由/proc/sys/net/ipv4/tcp_retries2决定,通常是15次。
在这里插入图片描述 所以,序号的作用是什么?
标识数据顺序: 接收方可以根据序列号将乱序到达的数据包重新排序,组装成正确的数据流。
消除重复数据: 如果接收到相同序列号的数据,接收方可以识别并丢弃重复的数据包。
作为确认的依据: 确认应答ACK正是基于序列号来告知发送方“我已经收到哪个数据了”。
即:按序到达、确认应答、去重
2.3 连接管理机制在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接
但是在建立连接和断开连接时,客户端和服务端的状态会产生变化,这些状态的本质其实就是一个整数。
那服务器一定会存在与多个客户端建立连接的情况,那连接需不需要被管理呢?先描述再组织
代码语言:javascript复制struct tcp_sock {
struct inet_connection_sock inet_conn;
// TCP专用字段:序列号、窗口、拥塞控制等
};
struct inet_connection_sock {
/* inet_sock has to be the first member! */
struct inet_sock icsk_inet; // 基础的INET套接字信息
struct request_sock_queue icsk_accept_queue; // 用于管理建立连接前的请求队列(如TCP的SYN队列和accept队列)
unsigned long icsk_timeout; // 当前连接的超时时间
struct timer_list icsk_retransmit_timer; // 数据包重传定时器
struct timer_list icsk_delack_timer; // 延迟ACK定时器
__u32 icsk_rto; // 重传超时时间(根据RTT动态计算)
__u32 icsk_pmtu_cookie; // 记录路径MTU(Path MTU)发现的信息
const struct tcp_congestion_ops *icsk_ca_ops; // 指向拥塞控制算法的操作函数集
const struct inet_connection_sock_af_ops *icsk_af_ops; // 地址族相关的操作(IPv4/IPv6)
__u8 icsk_ca_state:6, // 拥塞控制状态机(如Open, Disorder, CWR, Recovery, Loss)
__u8 icsk_retransmits; // 记录超时重传的次数
__u8 icsk_pending; // 记录挂起的定时器事件(如ICSK_TIME_RETRANS)
__u8 icsk_backoff; // 重传退避指数,用于计算重传超时
__u8 icsk_syn_retries; // SYN重试次数
// ... 可能还有其他字段
};所以建立连接是需要成本的——时间和空间
在这里插入图片描述
服务端状态转化:
[CLOSED -> LISTEN] 服务器端调用listen后进入 LISTEN 状态,等待客户端连接;[LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文.[SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文,就进入 ESTABLISHED 状态,可以进行读写数据了.[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入 CLOSE_WAIT;[CLOSE_WAIT -> LAST_ACK] 进入 CLOSE_WAIT 后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入 LAST_ACK 状态,等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)[LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK,彻底关闭连接.客户端状态转化:
[CLOSED -> SYN_SENT] 客户端调用connect,发送同步报文段;[SYN_SENT -> ESTABLISHED] connect调用成功,则进入 ESTABLISHED 状态,开始读写数据;[ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时,向服务器发送结束报文段,同时进入 FIN_WAIT_1;[FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认,则进入 FIN_WAIT_2,开始等待服务器的结束报文段;[FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段,进入 TIME_WAIT,并发出 LAST_ACK;[TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入 CLOSED 状态客户端connect主动发起连接进行三次握手,这个过程是由操作系统自动完成的
那服务端如果不调用accept,三次握手建立连接能够成功吗?
在TCP协议中,三次握手是由操作系统内核的TCP协议栈完成的,而不是由应用程序控制的。因此,当客户端发送SYN报文段到达服务器时,服务器内核会自动回复SYN-ACK,并在收到客户端的ACK后完成三次握手。此时,连接已经建立,并被放入服务器的监听套接字的连接队列(accept queue)中。
所以,即使服务端应用程序没有调用accept,三次握手仍然可以成功完成。但是,如果服务端一直不调用accept,那么已经建立的连接会一直停留在连接队列中。当连接队列满了之后,服务器内核可能会开始拒绝新的连接请求。
目前我们已经了解了什么是三次握手,三次握手是怎么做的,那么为什么要进行三次握手呢?
理由一:以最小成本,100%确认双方通信意愿
在通信开始前,必须确保客户端和服务器双方都明确同意建立连接。如果只用两次握手,可能会出现以下问题:
服务器发送SYN-ACK后,客户端未收到ACK,但服务器已认为连接建立。此时若客户端因网络问题未能收到ACK,但客户端认为这是无效连接,它不会主动建立连接,但服务器会持续等待或占用资源,造成资源浪费。三次握手通过以下过程解决此问题:
客户端 → 服务器:SYN(请求建立连接)服务器 → 客户端:SYN-ACK(确认请求,并发送自己的同步)客户端 → 服务器:ACK(确认服务器的SYN)只有在第3步完成后,服务器才真正确认客户端收到了它的SYN-ACK,从而双向确认了通信意愿。
✅ 这确保了“双方都愿意通信”,避免了一方单方面建立连接的资源浪费。
理由二:以最短的方式,验证全双工通信能力
TCP是面向连接、全双工(Full-duplex)的协议,即双方可以同时发送和接收数据。
三次握手的本质是验证:“我们两个所处的网络是通畅的,能够支持全双工!”
第一次握手:客户端发送SYN,服务器收到。这样服务器就能确认:客户端的发送能力是正常的,验证了客户端→服务器路径通畅。第二次握手:服务器发送SYN+ACK,客户端收到。这样客户端就能确认:服务器的接收和发送能力都是正常的,验证了服务器→客户端路径通畅(同时确认服务器愿连接)。第三次握手:客户端发送ACK,服务器收到。这样服务器就能确认:客户端的接收能力是正常的,再次验证了客户端→服务器路径通畅(并确认客户端收到服务器响应)。通过三次握手,双方都确认了彼此的发送和接收能力正常,从而确保了全双工通信。
类比
这等价于“男女朋友 → 夫妻”的两个条件:
双方意愿(对应通信意愿);父母等外部条件满足(对应网络环境支持全双工)。 为什么不是四次?
实际上,如果分开,那么就是:
客户端 -> 服务器:SYN服务器 -> 客户端:ACK(对客户端SYN的确认)服务器 -> 客户端:SYN(服务器自己的序列号)客户端 -> 服务器:ACK(对服务器SYN的确认)但是,中间两次(服务器对客户端SYN的确认ACK和服务器发送自己的SYN)可以合并为一次(可以理解为捎带应答),所以就成了三次。
所以三次握手本质上就是四次握手,不过第二次将中间两次合并为一次是以最小成本来验证全双工通信
那断开连接为什么要四次挥手?不能合并为三次挥手吗?
建立连接时,服务器在收到客户端的SYN后,可以将自己的SYN和ACK放在同一个报文中发送,因此可以三次握手。而断开连接时,TCP连接是全双工的,每个方向都必须单独关闭。当一方发送FIN,表示它不再发送数据,但还能接收数据。
四次挥手的过程(假设客户端先发起关闭):
客户端发送FIN,进入FIN_WAIT_1状态,表示客户端没有数据要发送了。
服务器收到FIN,发送ACK,进入CLOSE_WAIT状态。客户端收到ACK后进入FIN_WAIT_2状态。此时,客户端到服务器的连接关闭,但服务器到客户端的连接还可以传输数据。
服务器发送完剩余数据后,发送FIN,进入LAST_ACK状态。
客户端收到FIN,发送ACK,进入TIME_WAIT状态,服务器收到ACK后关闭连接。
为什么不能合并为三次?
因为服务器在收到客户端的FIN时,可能还有数据要发送,不能立即发送FIN。所以先发送ACK,等数据发送完毕后再发送FIN。
因此,ACK和FIN不能像握手那样合并发送。
但是,如果服务器在收到FIN时,没有数据要发送,那么服务器是否可以将ACK和FIN合并呢?
理论上是可以的,但TCP协议设计时为了可靠,并没有这样做。因为TCP规范要求每个FIN必须单独确认,所以即使没有数据,也要分开两个步骤。
不过,在实际中,有时确实可以看到三次挥手,即服务器的ACK和FIN合并,但这不是标准行为,依赖于实现。
然而,为什么TCP协议不设计成在服务器没有数据要发送时合并ACK和FIN呢?
因为TCP协议设计需要处理一般情况,即服务器可能还有数据要发送。如果为了少数情况而设计合并,会增加复杂性。
而且,分开确认可以保证可靠性。如果合并,那么ACK和FIN的确认机制就会变得复杂。
另外,从状态机角度,断开连接需要四个步骤,确保双方都知道对方已经关闭了连接。
所以,四次挥手是TCP协议设计的标准,以确保可靠地关闭连接。
下图是TCP状态转换的一个汇总:
在这里插入图片描述较粗的虚线表示服务端的状态变化情况;较粗的实线表示客户端的状态变化情况;CLOSED是一个假想的起始点,不是真实状态 为什么需要这些状态?
LISTEN:服务器端等待连接请求的状态。这个状态的存在使得服务器能够区分是否正在等待客户端的连接请求。如果没有这个状态,服务器可能无法正确处理 incoming 的连接。
SYN_RCVD:服务器收到SYN报文后,发送SYN-ACK,进入此状态。这个状态的存在是为了处理可能出现的重传和超时。如果服务器发送SYN-ACK后没有收到ACK,它可以重传SYN-ACK。
ESTABLISHED:表示连接已经建立,可以传输数据。这个状态的存在使得两端知道连接已经准备好进行数据传输。
FIN_WAIT_1:当一端(假设为A)发送FIN报文后,进入此状态,表示A没有数据要发送了,等待对方确认。这个状态的存在是为了处理对方可能没有及时确认FIN的情况,如果超时,A可以重传FIN。
FIN_WAIT_2:当A收到对FIN的确认(ACK)后,进入此状态,表示A到B的方向已经关闭,但B到A的方向可能还有数据在传输。这个状态的存在是为了让A能够继续接收B可能还在发送的数据,直到B也发送FIN。
CLOSE_WAIT:当B收到A的FIN后,进入此状态,表示B已经知道A没有数据要发送了,但B可能还有数据要发送给A。这个状态的存在是为了让B能够完成剩余数据的发送,然后再关闭连接。
LAST_ACK:当B发送FIN报文后,进入此状态,等待A对B的FIN的确认。这个状态的存在是为了确保B的FIN能被A确认,如果超时,B可以重传FIN。
TIME_WAIT:当A收到B的FIN并发送ACK后,进入此状态。这个状态的存在有两个主要原因:
确保ACK能够到达B,如果B没有收到ACK,会重传FIN,A在 TIME_WAIT 状态可以再次发送ACK。
让旧的重复报文段在网络中消失,避免被后续新的连接接收(因为同一个四元组可能会被重用)。
CLOSED:表示连接完全关闭,不再占用资源。
这些状态的存在使得TCP能够以一种可靠的方式管理连接,处理各种网络异常(如丢包、重传、乱序等)。每个状态都有其特定的超时和重传机制,确保连接的正确性。
如果没有这些状态,我们将很难处理连接建立和断开过程中可能出现的各种情况,比如:
如果沒有 SYN_RCVD 状态,服务器在发送SYN-ACK后无法知道连接是否建立成功,也无法处理重传。
如果沒有 FIN_WAIT_1 和 FIN_WAIT_2 状态,主动关闭的一端可能无法知道对方是否已经收到FIN,也无法知道对方是否还有数据要发送。
如果沒有 CLOSE_WAIT 状态,被动关闭的一端可能被迫立即关闭连接,导致数据丢失。
因此,TCP状态机设计这些状态是为了确保连接的可靠性和数据的完整性。
CLOSEING状态是什么情况?
CLOSING状态的触发条件
当TCP连接的两端几乎同时发送FIN报文时,就会进入CLOSING状态。具体来说:
客户端发送FIN,进入FIN_WAIT_1状态
几乎同时,服务器也发送了FIN,进入FIN_WAIT_1状态
客户端在FIN_WAIT_1状态收到服务器的FIN(而不是预期的ACK)
服务器在FIN_WAIT_1状态收到客户端的FIN(而不是预期的ACK)
状态转换过程
假设客户端和服务器同时发送FIN:
客户端:
最初状态:ESTABLISHED
发送FIN,进入FIN_WAIT_1
在FIN_WAIT_1状态,收到了服务器发来的FIN(注意,此时客户端还没有收到对自身FIN的ACK),则客户端会发送ACK回应服务器的FIN,并进入CLOSING状态。
服务器:
同样,服务器在发送FIN后进入FIN_WAIT_1(如果服务器是主动关闭的一方,但通常服务器是被动关闭,但也可以主动关闭),然后收到客户端的FIN,发送ACK,并进入CLOSING状态。但是,注意:在典型的TCP状态机中,CLOSING状态是主动关闭的一方在等待对方确认自己的FIN,同时自己也收到了对方的FIN(即双方都发送了FIN,但都没有收到对应的ACK)时进入的状态。
CLOSING状态的行为
在CLOSING状态下,TCP等待对方对自己发送的FIN的确认(ACK)。实际上,当双方都发送了FIN,那么双方都会收到对方对FIN的ACK(因为收到FIN后必须发送ACK),然后双方都会进入TIME_WAIT状态。
具体过程:
客户端发送FIN,进入FIN_WAIT_1
服务器发送FIN,进入FIN_WAIT_1(假设服务器也主动关闭)
客户端收到服务器的FIN,会发送一个ACK,然后进入CLOSING状态
服务器收到客户端的FIN,会发送一个ACK,然后进入CLOSING状态
当客户端在CLOSING状态收到服务器对自己FIN的ACK(即步骤4中服务器发送的ACK),则客户端进入TIME_WAIT状态
同样,当服务器在CLOSING状态收到客户端对自己FIN的ACK(即步骤3中客户端发送的ACK),则服务器进入TIME_WAIT状态
注意:在CLOSING状态,双方都在等待对方对自己FIN的ACK。一旦收到,就会进入TIME_WAIT状态。
状态转换图
在TCP状态转换图中,CLOSING状态的转换路径如下:
从FIN_WAIT_1状态,当收到FIN(对方也发送了FIN)并发送ACK后,进入CLOSING
在CLOSING状态,当收到ACK(对方对自己FIN的确认)后,进入TIME_WAIT
为什么需要CLOSING状态?
CLOSING状态是为了处理双方同时关闭连接的情况。如果没有这个状态,协议可能无法正确处理这种情况。同时关闭是相对少见的,但TCP协议必须处理所有可能的情况。
在同时关闭的情况下,双方都发送了FIN,并且都收到了对方的FIN,然后双方都发送ACK回应对方的FIN。这样,双方都需要等待对方对自己FIN的确认,这就是CLOSING状态的作用。
与其它状态的区别
FIN_WAIT_1:发送了FIN,等待ACK或FIN(如果收到FIN则进入CLOSING)
FIN_WAIT_2:收到了对自己FIN的ACK,等待对方的FIN
CLOSING:发送了FIN,收到了对方的FIN(但还没有收到对自己FIN的ACK),等待对自己FIN的ACK
实际中的CLOSING状态
在实际网络中,CLOSING状态很少见,因为通常连接关闭是由一方发起的,另一方响应。但是,如果应用程序设计为双方都可以主动关闭,则可能发生同时关闭。
理解TIME_WAIT状态
TCP协议规定,主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态.我们使用Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口;MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Centos7/Ubuntu上默认配置的值是60s;可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值;规定TIME_WAIT的时间请读者参考UNP 2.7节;在这里插入图片描述想一想,为什么 TIME_WAIT 的时间是 2MSL ?
MSL 是 TCP 报文的最大生存时间,因此 TIME_WAIT 持续存在 2MSL 的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重发一个FIN,这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发 LAST_ACK)解决TIME_WAIT状态引起的bind失败的方法
在 server 的 TCP 连接没有完全断开之前不允许重新监听,某些情况下可能是不合理的
服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求).这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量 TIME_WAIT 连接.由于我们的请求量很大,就可能导致 TIME_WAIT 的连接数很多,每个连接都会占用一个通信五元组(源ip、源端口、目的ip、目的端口、协议). 其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号和 TIME_WAIT 占用的连接重复了,就会出现问题.使用 setsockopt ()设置 socket 描述符的 选项 SO_REUSEADDR 为 1 ,表示允许创建端口号相同但IP地址不同的多个 socket 描述符
在这里插入图片描述2.4 滑动窗口刚才我们讨论了确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答,收到ACK后再发送下一个数据段,这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候.
在这里插入图片描述
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了).
那么,发送方一次发多少数据,由什么决定?
在TCP协议中,发送方一次可以向对方发送的数据量,由当前滑动窗口的大小决定。
滑动窗口大小指的是无需等待确认应答而可以继续发送数据的最大值
以主机A和主机B收发数据为例:
在这里插入图片描述
滑动窗口是发送缓冲区的一部分。
你可以将发送缓冲区当作一个char类型的数组(char buffer[N]),发送方维护一个“发送缓冲区”,其中包含已发送但未确认、可发送和待发送的数据。在该缓冲区内,有一个“滑动窗口”,它表示当前允许发送但尚未发送的数据范围。那么滑动窗口就是这个数组中的两个指针:start和end,窗口大小 = end - start,即窗口右边界减去左边界。
在这里插入图片描述
已发送已确认:这部分的空间并不是无效了,而是可以重复利用了,并不需要刻意去清空这部分空间
序号在发送的轮次中,数字是依次增大的,也就意味着滑动窗口是向右滑动的(宏观上)
所以,窗口滑动的本质就是start和end下标增加
那么滑动窗口的大小,由什么决定呢?
窗口大小的决定因素:
接收方的接收能力(即接收窗口大小)是决定发送方滑动窗口大小的根本原因。接收方在每次发送ACK确认报文时,会携带一个window size(窗口大小)字段,告知发送方自己当前还能接收多少数据。发送方根据这个值动态调整自己的滑动窗口大小。窗口移动机制:
当接收方成功接收并确认了部分数据后,会发送ACK,其中包含下一个期望接收的序列号。发送方收到ACK后,将滑动窗口的左边界(start)向右移动(即“滑动”),释放已确认的数据空间。同时,右边界(end)也相应增加,以保持窗口大小不变(除非接收方调整了窗口大小)。start = 收到的确认序号,end = start + win(窗口大小)滑动窗口的本质:流量控制的具体实现方案
在这里插入图片描述 滑动窗口可以向左滑动吗?滑动窗口,可以变大吗?可以变小吗?可以不变吗?可以为0吗?
❌ 不可以向左滑动!
滑动窗口只能向右滑动(即“向前滑动”),不能向左滑动。
原因:
滑动窗口的左边界(start)代表“已发送且已确认”的数据的下一个序列号。一旦数据被确认,这部分数据就不再需要重传,窗口左边界向右移动,释放缓冲区空间。TCP是面向字节流的协议,序列号是严格递增的,因此窗口只能向前滑动,不能后退。⚠️ 注意: 虽然窗口不能“物理”向左滑动,但在某些情况下(如收到重复ACK或超时重传),发送方可能会回退窗口位置进行重传,但这属于重传机制,不是窗口本身的“向左滑动”。
✅ 可以变大!
条件:
当接收方的接收缓冲区有更多空闲空间时,会通过ACK报文通告一个更大的win值。发送方收到该ACK后,将滑动窗口的右边界(end)向右扩展,从而增大窗口大小。示例:
初始窗口大小 = 1000接收方处理完部分数据,空闲空间增加 → 通告 win=2000发送方更新窗口 → 新窗口大小 = 2000✅ 可以变小!
条件:
接收方缓冲区快满了 → 通告更小的win值。发送方收到后,将滑动窗口的右边界(end)向左收缩,减小窗口大小。示例:
当前窗口大小 = 1000接收方通告 win=500发送方调整窗口 → 新窗口大小 = 500⚠️ 这种行为称为“窗口缩放”,用于防止接收方溢出。
✅ 可以不变!
当接收方通告的窗口大小没有变化时,滑动窗口大小保持不变。
场景:
接收方处理速度稳定,缓冲区占用率稳定。发送方持续发送数据,但每次收到的ACK中win值相同。窗口只是“滑动”,但大小不变。✅ 可以为0!
意义:
当接收方缓冲区完全满时,会通告 win=0。此时发送方必须停止发送数据,直到接收方处理完部分数据并发送新的ACK(包含非零窗口值)。行为:
发送方进入“零窗口等待状态”。通常会定期发送“窗口探测报文”(Window Probe)以询问接收方窗口是否打开。 如果丢包了怎么办?滑动窗口会不会跳过报文进行应答?
滑动窗口不会“跳过”丢失报文的应答
滑动窗口是基于序列号和确认号工作的,它不会跳过任何应答。接收方必须按顺序确认数据。
关键点:
TCP是面向字节流的协议,每个字节都有唯一的序列号。接收方只能确认连续到达的、从起始序号开始的数据。如果中间某个报文丢失,接收方会持续发送对“最后一个正确收到的数据”的ACK(即重复ACK),不会跳过。我们可以把丢包分为以下几种情况:
a.最左侧丢失 b.中间报文丢失 c.最右侧丢失。而现实情况是这三种情况的组合。
a.最左侧丢失
这里又分两种情况讨论:
情况1:数据包已经抵达,应答报文ACK被丢了.
在这里插入图片描述
这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认。
就比如图中所示:应答报文1001、3001、4001丢失,但是我收到了2001、5001、6001,通过应答报文ACK的确认序号6001,我们就可以知道1~6000字节的数据服务端都收到了,所以部分ACK丢了没有关系
情况2:数据包就直接丢了
在这里插入图片描述当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 “我想要的是 1001” 一样;如果发送端主机连续三次收到了同样一个 “1001” 这样的应答,就会将对应的数据 1001 - 2000 重新发送;这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中;这种机制被称为 “高速重发控制”(也叫 “快重传”)
选择性确认(SACK)的优化
标准的快重传有一个小问题:当有多个报文丢失时,发送方收到3个重复ACK后,只知道第一个丢失的报文,不清楚后面是否还有丢失。
SACK(选择性确认) 对此进行了优化。启用SACK后,接收方在ACK报文中可以额外告诉发送方:“虽然我没收到1001-2000,但我已经收到了2001-3000和3001-4000”。这样,发送方就能精确地只重传1001-2000这个丢失的报文段,避免了不必要的重传,进一步提高了效率。
超时重传VS快重传
超时重传(Timeout Retransmission):发送方为每个未确认的数据包设置一个定时器。如果在超时时间内未收到ACK,认为该数据包丢失,触发重传。快速重传(Fast Retransmit):如果发送方连续收到3个重复ACK(即同一个ACK序号),就认为某个中间报文丢失。立即重传丢失的数据包,无需等待超时。TCP就是通过超时重传机制和快速重传机制来检测丢包的
滑动窗口行为:
窗口左边界(start)不能前进,因为没有收到ACK。窗口大小不变,但无法滑动 → 发送方暂停发送新数据。b.中间报文丢失
最左侧报文收到应答,滑动窗口更新,此时就转为了最左侧报文丢失
c.最右侧丢失
最右侧丢失也会转为最左侧丢失的情况
滑动窗口一直向右滑动会不会溢出?
序列号是循环的(Modulo)空间
TCP 为每个方向分配 32 位无符号序列号(0 - 4 294 967 295),在发送方每发送一个字节就递增一次。当序列号达到上限后会 回绕到 0,整个空间采用 模 2³² 运算。
只要双方在 窗口大小 < 2³¹(即不超过序列号空间的一半),回绕后仍能唯一区分新旧报文,接收方可以通过比较序列号的相对大小判断报文是否属于当前会话。实际上,窗口大小受接收方通告的窗口(16 位)以及可选的窗口缩放(最大 2³⁰)限制,远小于 2³¹,因而不可能出现窗口跨越回绕点而产生歧义的情况。2.5 流量控制接收端处理数据的速度是有限的,如果发送端发的太快。导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应.
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control);
接收端将自己可以接收的缓冲区剩余空间大小放入 TCP 首部中的 “窗口大小” 字段,通过ACK通知发送端;窗口大小字段越大,说明网络的吞吐量越高;接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后,就会减慢自己的发送速度;如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端.
在这里插入图片描述接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中吗,有一个16位窗口字段,就是存放了窗口大小信息;
那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?
实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移 M 位;
2.6 拥塞控制虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题.
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的.
TCP引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据;
在这里插入图片描述此处引入一个概念称为拥塞窗口(cwnd)发送开始的时候,定义拥塞窗口大小为1个MSS(最大报文段大小)每次收到一个ACK应答,拥塞窗口加1个MSS(所以每轮次cwnd翻倍,指数增长)每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口;所以滑动窗口的大小 = min(win,拥塞窗口),谁小谁决定(win指接收方窗口大小)
像上面这样的拥塞窗口增长速度,是指数级别的,“慢启动” 只是指初使时慢,但是增长速度非常快
为了不增长的那么快,因此不能使拥塞窗口单纯的加倍.此处引入一个叫做慢启动的阈值(ssthresh)当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长在这里插入图片描述当TCP开始启动的时候,慢启动阈值等于窗口最大值;在每次发生超时重传时,将ssthresh设置为当前cwnd的一半(但不小于2),然后cwnd重置为1,重新进入慢启动少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞;
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降;
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案.
2.7 延迟应答和捎带应答什么是延迟应答?
延迟应答是一种优化技术,它的核心思想是:当接收端收到数据后,不要立即发送ACK,而是等待一段时间(比如200毫秒),看看这段时间内是否会有数据要发送给对方。如果有,那么ACK就可以随着数据一起发送出去,这样就节省了一个单独ACK包的开销。
延迟应答与滑动窗口
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小.
假设接收端缓冲区为1M,一次收到了500K的数据;如果立刻应答,返回的窗口就是500K;但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了;在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M;一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
那么所有的包都可以延迟应答么? 肯定也不是;
数量限制:每隔N个包就应答一次;时间限制:超过最大延迟时间就应答一次;具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms;
在这里插入图片描述捎带应答
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 “一发一收” 的,意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you”;
那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine, thank you” 一起回给客户端
在这里插入图片描述2.8 面向字节流和粘包问题创建一个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报文,要么不收,不会出现"半个"的情况.2.9 TCP异常情况和TCP小结TCP异常情况
进程终止:当进程终止时,操作系统会关闭该进程打开的所有文件描述符。对于TCP连接,这会触发正常的连接关闭过程,即发送FIN包,然后进行四次挥手。因此,这种情况下,连接可以正常关闭。
机器重启:机器重启时,操作系统会终止所有进程,并关闭所有打开的文件描述符。因此,这种情况与进程终止类似,会触发正常的连接关闭过程。但是,如果机器突然重启,可能来不及发送FIN包,那么就会像掉电一样。
机器掉电/网线断开:这种情况分为两种:
a. 接收端掉电或网线断开
b. 发送端掉电或网线断开
但是,无论哪一端,如果对方没有发送数据,那么可能不会立即发现连接异常。TCP提供了保活机制(Keep-Alive)来检测对方是否还存在。
保活机制:TCP保活定时器会定期(通常很长,默认2小时)发送探测报文。如果对方没有响应,则会重试几次(通常为9次,每次间隔75秒),如果仍然没有响应,则关闭连接。
另外,如果接收端掉电后恢复,它可能已经丢失了之前的连接信息,此时如果收到发送端的数据,它会回复RST复位报文,从而重置连接。
此外,应用层协议也可能有检测机制,例如HTTP长连接可能会定期发送心跳包,QQ等即时通讯软件也会定期检测连接并尝试重连。
其他异常:比如网络中间设备(如路由器)出现故障,可能导致连接中断,TCP会通过重传机制尝试重传,如果重传次数超过限制,则关闭连接。
保活机制(Keep-Alive):
默认情况下,保活机制是关闭的,可以通过设置socket选项来开启。开启后,如果在保活时间内(默认7200秒)连接上没有数据交换,则开始发送保活探测包。探测包没有响应,则每隔75秒重试一次,共重试9次,如果都没有响应,则关闭连接。RST复位:
当TCP连接的一端收到一个它认为不存在的连接的报文时,会发送RST复位报文。例如,一端已经关闭了连接,而另一端还试图发送数据,那么就会收到RST。另外,如果一方崩溃后重启,它会丢失所有连接信息,当收到对端的数据时,它会发送RST。总结:TCP通过正常的关闭过程(FIN)、保活机制和RST复位来应对各种异常情况。但是,保活机制默认时间较长,因此在实际应用中,往往依赖应用层的心跳机制来更快地检测连接状态。
TCP小结
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能.
可靠性:
校验和序列号(按序到达)确认应答超时重发连接管理流量控制拥塞控制提高性能:
滑动窗口快速重传延迟应答捎带应答其他:
定时器(超时重传定时器,保活定时器,TIME_WAIT定时器等)基于TCP应用层协议
HTTPHTTPSSSHTelnetFTPSMTP当然,也包括你自己写TCP程序时自定义的应用层协议;
3. TCP/UDP对比我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢?TCP和UDP之间的优点和缺点,不能简单绝对的进行比较
TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景;UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ、视频传输等,另外UDP可以用于广播;归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定.
3.1 用UDP实现可靠传输(经典面试题)参考TCP的可靠性机制,在应用层实现类似的逻辑;
例如:
引入序列号,保证数据顺序;引入确认应答,确保对端收到了数据;引入超时重传,如果隔一段时间没有应答, 就重发数据;…4. 补充4.1 sk_buff在这里插入图片描述
在操作系统内部,一定可能同时存在大量的报文,甚至不同层次的报文。操作系统就必须管理这些报文,如何进行管理?先描述,再组织。
struct sk_buff结构体就是用来统一描述报文的结构,类似于struct file的作用。
struct sk_buff(通常简称为SKB)是Linux内核网络子系统中最核心的数据结构,它代表了内核中流通的网络数据包。可以说,整个Linux网络协议栈就是围绕SKB的创建、传递、处理和销毁来构建的。
代码语言:javascript复制struct sk_buff {
/* 这两个指针必须放在最前面 */
struct sk_buff *next;
struct sk_buff *prev;
/* 数据缓冲区指针 - 这是SKB的灵魂 */
unsigned char *head; // 分配的数据缓冲区起始位置
unsigned char *data; // 当前协议层有效数据的起始位置
unsigned char *tail; // 当前协议层有效数据的结束位置
unsigned char *end; // 分配的数据缓冲区结束位置
/* 长度信息 */
unsigned int len; // 当前数据长度(data到tail)
unsigned int data_len; // 分片数据的长度
unsigned int truesize; // 整个SKB结构+数据区的总大小
/* 协议头指针 */
union {
struct tcphdr *th; // TCP头指针
struct udphdr *uh; // UDP头指针
struct icmphdr *icmph; // ICMP头指针
struct iphdr *iph; // IPv4头指针
struct ipv6hdr *ipv6h; // IPv6头指针
unsigned char *raw;
} h;
union {
struct iphdr *iph; // IPv4头指针
struct ipv6hdr *ipv6h; // IPv6头指针
struct arphdr *arph;
unsigned char *raw;
} nh;
union {
unsigned char *raw;
} mac;
/* 网络设备信息 */
struct net_device *dev; // 接收或发送该包的网络设备
/* 路由和套接字信息 */
struct sock *sk; // 所属的套接字
struct dst_entry *dst; // 路由缓存结果
/* 控制缓冲区 - 存储各层私有数据 */
char cb[48]; // Control Buffer
/* 其他重要字段 */
__u32 priority; // QoS优先级
__be16 protocol; // 包协议类型
__u16 transport_header; // 传输层头偏移
__u16 network_header; // 网络层头偏移
__u16 mac_header; // MAC层头偏移
/* 引用计数 */
atomic_t users; // 引用计数
};一、接收过程:数据包上山(从网卡到应用层)
这个过程是剥离头部、不断“轻量化”的过程
网卡(链路层入口): 动作: 网卡驱动收到原始数据帧,分配一个 sk_buff结构体,并将数据帧完整地(包括以太网头、IP头、传输层头、应用数据)DMA到内核的一块内存缓冲区中。
指针设置: sk_buff的 head和 end指针指向这片缓冲区的起止点。初始时,data和 tail指针分别指向以太网帧头的开始和整个数据的结束。
链路层处理(以太网): 动作: 检查以太网帧头中的类型字段(如 0x0800代表 IPv4),以确定应将数据包传递给哪个网络层协议处理程序(如 ip_rcv)。
指针移动: 将 data指针向后(向高地址)移动以太网头部的长度。这样,data指针现在指向了 IP 头。这个过程称为“剥离”链路层头,如图中接收方向的箭头所示。len字段相应减小。
网络层处理(IP): 动作: IP模块检查数据包的完整性、校验和,并根据目标IP地址决定是转发、丢弃还是交付给本机的传输层。
指针移动: 将 data指针再次向后移动 IP 头部的长度。现在,data指针指向了 TCP 或 UDP 头。
传输层处理(TCP/UDP): 动作: TCP或UDP模块检查端口号,找到对应的Socket。TCP会进行序列号验证、重组乱序报文等。最终,将数据包放入对应Socket的接收队列。
指针移动: 将 data指针向后移动 TCP/UDP 头部的长度。此时,data指针终于指向了纯粹的应用层数据。
应用层读取:应用程序调用 read()或 recv()时,内核将 sk_buff中 data指针指向的应用数据拷贝到用户空间。二、发送过程:数据包下山(从应用到网卡)
这个过程是添加头部、不断“封装”的过程,与接收过程相反
应用层写入:应用程序调用 send()或 write(),数据从用户空间拷贝到内核的Socket发送缓冲区。传输层处理(TCP/UDP): 动作: 传输层从Socket缓冲区取出一块数据,为其添加TCP或UDP头部(包括源/目标端口、序列号、校验和等)。
指针移动: 在创建 sk_buff时,会在数据缓冲区的前面预留足够的头空间。此时,将 data指针向前(向低地址)移动,腾出空间,然后填入传输层头。
网络层处理(IP): 动作: IP层为数据段添加IP头(包括源/目标IP地址、TTL等)。
指针移动: 再次将 data指针向前移动,填入IP头。
链路层处理(以太网): 动作: 根据路由查询结果,获取下一跳的MAC地址,并添加以太网帧头。
指针移动: 最后将 data指针向前移动,填入以太网头。此时,data指针指向了完整帧的开始。
网卡发送:网卡驱动将 sk_buff中 data指针指向的完整数据帧通过DMA发送到网络上。三、sk_buff在协议栈传递中的关键操作
skb_push:在数据区域的前面添加数据(即向低地址方向扩展),用于在发送过程中添加协议头。
skb_pull:从数据区域的前面移除数据(即向高地址方向移动data指针),用于在接收过程中剥离协议头。
skb_put:在数据区域的尾部添加数据(即向高地址方向扩展),用于在应用层添加数据。
skb_reserve:在数据区域的前面预留空间,用于后续添加协议头。
这些操作通过移动sk_buff中的data和tail指针来实现,避免了频繁的内存拷贝,提高了性能。
四、sk_buff的分配和释放
sk_buff结构体和数据缓冲区是分开分配的,但通常通过同一个函数(如alloc_skb)来分配。sk_buff结构体本身位于sk_buff缓存池(slab缓存),而数据缓冲区则通过kmalloc分配。
当sk_buff的引用计数为0时,它会被释放,包括数据缓冲区的释放。
4.2 串联文件和网络那应该怎么将网络和之前的文件串联起来呢?
在这里插入图片描述
下面我们来详细解读图中的每一步:
第1步:进程与文件的关联(task_struct-> files_struct)
内核为每个进程(如您的网络服务器程序)维护一个 task_struct(进程控制块),它是进程的“身份证”。
task_struct中包含一个指针,指向一个 files_struct结构。这个结构管理了该进程所有打开的文件。
第2步:文件描述符的本质(files_struct-> fd_array[])
files_struct中有一个核心数组成员:fd_array[](文件描述符数组)。
当您的程序调用 socket(AF_INET, SOCK_STREAM, 0) 创建一个套接字时,系统调用返回的是一个整数,比如 3。这个 3就是文件描述符,它实际上是 fd_array的索引。
第3步:文件描述符指向文件对象(fd_array[3]-> struct file)
fd_array[3]中存储的是一个指向 struct file的指针。struct file代表一个打开的文件对象,它记录了文件的打开模式(只读、读写等)、当前读写位置(pos)等操作状态。
对于普通磁盘文件,这个 struct file的 private_data 会指向一个表示磁盘文件的结构(如 inode)。但在这里,关键点来了:对于套接字,struct file的 private_data 指针并没有指向磁盘文件,而是指向了一个 struct socket!
第4步:文件对象连接到网络套接字(struct file-> struct socket)
这正是串联的关键!通过 private_data指针,内核成功地将一个“文件”操作引导至了“网络”操作。
struct socket是一个套接字抽象层,它包含了套接字的类型(如流式TCP、数据报UDP)和状态(如监听LISTEN、已连接ESTABLISHED)。
第5步:套接字关联到具体协议(struct socket-> struct sock)
struct socket结构中含有一个至关重要的指针 sk,它指向了整个网络通信的真正核心:struct sock。
struct sock是一个极其复杂的结构体,它维护了网络连接所需的一切信息,例如:
TCP协议信息:发送/接收序列号、窗口大小、拥塞控制状态、重传定时器等。
对端地址:源/目的IP地址和端口号。
数据缓冲区:发送和接收队列。
第6步:协议族的继承关系(struct sock-> 具体协议结构)
图中下方不同颜色的方框(inet_connection_sock, tcp_sock等)展示了Linux内核面向对象的设计思想。struct sock是一个基类,而针对TCP等具体协议,有更详细的子类结构(通过C语言的结构体嵌入实现继承)。
例如,struct tcp_sock结构体内必然包含一个 struct sock作为其第一个成员,从而扩展了TCP特有的功能。这使得内核可以用统一的 struct sock *指针处理所有协议,同时在需要时又能获取到具体的协议信息。这就是C语言的多态实现
总结:一次读写操作的完整路径
当您的应用程序执行 write(fd, data, len)试图通过网络发送数据时,内核的处理流程如下:
查找文件对象:根据进程的 task_struct找到 files_struct,再用文件描述符 fd作为索引,在 fd_array[]中找到对应的 struct file指针。
转向网络操作:检查 struct file,发现其 private_data指向一个 struct socket,于是识别出这不是一个磁盘文件操作,而是一个网络操作。
进入协议栈:通过 struct socket的 sk指针,找到对应的 struct sock(及其具体的 tcp_sock)。
执行发送:内核将您的数据放入 struct sock的发送缓冲区,然后启动TCP/IP协议栈的处理流程,最终数据被网卡发送出去。
读操作 read的路径与此完全对称,只是方向相反。