先搞懂TCP在哪里:网络分层模型
要理解TCP,先得知道它在网络体系中的位置。
| OSI参考模型 | TCP/IP分层模型 | 说明 |
|---|---|---|
| 应用层 | 应用层 | DNS、HTTP、SMTP、SSH、FTP等 |
| 表示层 | 应用层 | TLS/SSL、MIME等 |
| 会话层 | 应用层 | RTP、SIP等 |
| 传输层 | 传输层 | TCP、UDP、SCTP、DCCP |
| 网络层 | 互联网层 | IP、ICMP、ARP |
| 数据链路层 | 网卡层 | 设备驱动与网络接口 |
| 物理层 | 硬件 | 物理传输介质 |
关键点 :TCP属于传输层,是操作系统内核的一部分,向下依赖网络层(IP),向上为应用层提供服务。应用程序通过系统调用(socket API)与TCP交互。
TCP协议段格式:每个字段都是干什么的?
TCP报文段(Segment)的头部结构是理解一切机制的基础。
0 15 16 31
+-------------------+-------------------+
| 16位源端口号 | 16位目的端口号 |
+-------------------+-------------------+
| 32位序号 |
+---------------------------------------+
| 32位确认序号 |
+-------+-------+-----------------------+
|4位首部|保留6位 | U A P R S F |
|长度 | | R C S S Y I |
| | | G K H T N N |
+-------+-------+-----------------------+
| 16位窗口大小 | 16位校验和 |
+-------------------+-------------------+
| 16位紧急指针 | 选项 |
+-------------------+-------------------+
| 数据 |
+---------------------------------------+
核心字段详解
-
源/目的端口号:标识数据从哪个进程来、到哪个进程去。IP地址标识主机,端口号标识进程。
-
32位序号:TCP对每个字节都编号。序号是本报文段第一个数据字节的编号。
-
32位确认序号:期望收到的下一个字节的编号。比如收到1~1000,确认序号就是1001。
-
4位首部长度:表示TCP头部有多少个32位字(4字节)。最大15,所以头部最大60字节。通常没有选项时是20字节。
-
6位标志位:
-
URG:紧急指针有效
-
ACK:确认序号有效(连接建立后通常都置1)
-
PSH:提示接收端立刻把数据交给应用层
-
RST:复位连接(对方要求重新建立连接)
-
SYN:同步序号,用于建立连接
-
FIN:发送方准备关闭连接
-
-
16位窗口大小:接收端告诉发送端自己还能接收多少字节(流量控制的核心)。
-
16位校验和:CRC校验,覆盖TCP首部+数据部分。
-
16位紧急指针:标识紧急数据的位置。
扩展:选项字段中的"窗口扩大因子"
16位窗口最大只能表示65535字节。为了支持更大的窗口,TCP选项中有窗口扩大因子M,实际窗口大小 = 窗口字段值 × 2^M。这在高速网络中非常重要。
TCP的核心机制(上):可靠传输
确认应答(ACK)机制
TCP把每个字节都编号,接收端收到数据后返回ACK,告诉发送端:"我已经收到了X之前的所有数据,下次从X开始发。"
生活类比:唐僧讲经,每讲一段就问"听懂了吗?";徒弟回答"听懂了,继续下一段"。
关键规则:
-
每个ACK都携带确认序号,表示"这个序号之前的数据我都收到了"。
-
发送端收到ACK后,才知道数据被正确接收。
超时重传机制
数据或ACK可能丢失。发送端在特定时间间隔内没收到ACK,就重发。
核心问题:超时时间怎么定?
-
太长:重传慢,效率低。
-
太短:频繁重传,浪费带宽。
TCP的动态策略(Linux实现):
-
以500ms为基本单位。
-
每次重传后,等待时间翻倍:500ms → 1000ms → 2000ms → 4000ms...
-
累计到一定次数后,认为网络或对端异常,强制关闭连接。
扩展:自适应重传(Karn算法)
实际TCP实现中,RTT(往返时间)是动态测量的。TCP维护一个加权平均RTT(SRTT)和偏差(RTTVAR),超时时间 = SRTT + 4×RTTVAR。这比固定500ms更智能。
去重与排序
由于重传,接收端可能收到重复数据。TCP利用序号可以轻松去重:如果收到的数据序号小于已确认的序号,直接丢弃。
TCP的核心机制(中):连接管理(三次握手、四次挥手)
三次握手建立连接
客户端 服务器
| |
| ① SYN (seq=x) |
| ------------------------------------------->|
| SYN_SENT SYN_RCVD |
| |
| ② SYN + ACK (seq=y, ack=x+1) |
| <-------------------------------------------|
| |
| ③ ACK (seq=x+1, ack=y+1) |
| ------------------------------------------->|
| ESTABLISHED ESTABLISHED |
| |
| (连接建立,可以传数据了) |
每一步在做什么?
第一次握手:客户端 → 服务器
-
动作 :客户端调用
connect(),发送一个 SYN报文段。 -
关键字段:
-
SYN = 1(标志位,表示请求建立连接) -
seq = x(客户端的初始序列号,随机生成)
-
-
客户端状态 :
CLOSED → SYN_SENT -
含义:客户端说"我想和你建立连接,我的初始序列号是x"。
第二次握手:服务器 → 客户端
-
动作 :服务器收到SYN后,如果同意连接,回复一个 SYN+ACK报文段。
-
关键字段:
-
SYN = 1(我也请求建立连接) -
ACK = 1(确认号有效) -
seq = y(服务器的初始序列号,随机生成) -
ack = x + 1(确认收到客户端的SYN,期望下次收到x+1)
-
-
服务器状态 :
LISTEN → SYN_RCVD -
含义:服务器说"我同意,我的初始序列号是y,我确认收到你的x了"。
第三次握手:客户端 → 服务器
-
动作 :客户端收到SYN+ACK后,发送一个 ACK报文段。
-
关键字段:
-
ACK = 1 -
seq = x + 1 -
ack = y + 1(确认收到服务器的SYN)
-
-
客户端状态 :
SYN_SENT → ESTABLISHED -
服务器状态 :收到ACK后,
SYN_RCVD → ESTABLISHED -
含义:客户端说"我确认收到你的y了,连接建立!"
三次握手的本质:确认双方收发能力
| 握手 | 能确认什么 |
|---|---|
| 第一次 | 服务器能确认:客户端能发,自己能收 |
| 第二次 | 客户端能确认:服务器能发,自己能收;服务器能收,客户端能发 |
| 第三次 | 服务器能确认:客户端能收,自己能发 |
经过三次握手,双方都确认了自己和对方的收发能力都正常。
为什么是三次,而不是两次或四次?
防止已失效的连接请求突然到达服务器,导致服务器资源浪费。
假设只有两次握手:
客户端 服务器
| |
| ① SYN (seq=x) ------ 网络延迟,滞留 ------ |
| |
| (客户端超时,重发SYN) |
| ② SYN (seq=x') ---------------------->|
| <---------------- SYN+ACK (ack=x'+1) |
| ③ ACK ------------------------------> | 连接建立
| |
| (此时①的SYN姗姗来迟) |
| <---------------- SYN+ACK (ack=x+1) |
| | 服务器认为连接建立!
| (客户端不理它,服务器却一直等) |
问题:服务器收到一个早已失效的SYN,就认为连接建立了,分配资源等待客户端。但客户端根本不认这个连接,导致服务器资源白白浪费。
三次握手解决这个问题:
-
服务器收到失效SYN后,回复SYN+ACK,进入SYN_RCVD。
-
客户端不会对这个失效连接回复ACK。
-
服务器超时后释放资源,不会真正建立连接。
为什么不是四次?
第二次握手时,服务器可以同时发送SYN和ACK(即SYN+ACK),把两个动作合并成一个报文,所以不需要四次。
总结:
-
三次是最少次数:既能确认双方收发能力,又能防止失效连接。
-
两次不安全:无法防止历史连接。
-
四次没必要:SYN和ACK可以合并。
四次挥手断开连接
客户端 服务器
| |
| ① FIN (seq=u) |
| ------------------------------------------->|
| FIN_WAIT_1 CLOSE_WAIT |
| |
| ② ACK (ack=u+1) |
| <-------------------------------------------|
| FIN_WAIT_2 |
| |
| (服务器处理剩余数据,可能还要发数据) |
| |
| ③ FIN (seq=v) |
| <-------------------------------------------|
| LAST_ACK |
| |
| ④ ACK (ack=v+1) |
| ------------------------------------------->|
| TIME_WAIT (等待2MSL) CLOSED |
| |
| (2MSL后)CLOSED |
每一步在做什么?
第一次挥手:客户端 → 服务器
-
动作 :客户端调用
close(),发送 FIN报文段。 -
关键字段:
-
FIN = 1 -
seq = u
-
-
客户端状态 :
ESTABLISHED → FIN_WAIT_1 -
含义:客户端说"我没有数据要发了,准备关闭"。
第二次挥手:服务器 → 客户端
-
动作 :服务器收到FIN后,回复 ACK。
-
关键字段:
-
ACK = 1 -
ack = u + 1
-
-
服务器状态 :
ESTABLISHED → CLOSE_WAIT -
客户端状态 :收到ACK后,
FIN_WAIT_1 → FIN_WAIT_2 -
含义:服务器说"我知道你要关了,但我可能还有数据要发,你等等"。
注意 :此时连接处于**半关闭(Half-Close)**状态------客户端不能再发数据,但服务器还能发。
第三次挥手:服务器 → 客户端
-
动作 :服务器处理完所有数据后,调用
close(),发送 FIN。 -
关键字段:
-
FIN = 1 -
seq = v
-
-
服务器状态 :
CLOSE_WAIT → LAST_ACK -
含义:服务器说"我也没数据要发了,准备关闭"。
第四次挥手:客户端 → 服务器
-
动作 :客户端收到FIN后,回复 ACK。
-
关键字段:
-
ACK = 1 -
ack = v + 1
-
-
客户端状态 :
FIN_WAIT_2 → TIME_WAIT -
服务器状态 :收到ACK后,
LAST_ACK → CLOSED -
含义:客户端说"我确认收到你的FIN了,你可以关了"。
-
客户端 :等待 2MSL 后,才进入
CLOSED。
为什么是四次,而不是三次?
核心原因:TCP是全双工的,每个方向需要单独关闭。
-
客户端发FIN:关闭"客户端→服务器"方向。
-
服务器回ACK:确认收到FIN。
-
服务器发FIN:关闭"服务器→客户端"方向。
-
客户端回ACK:确认收到FIN。
为什么不能合并ACK和FIN?
因为服务器收到FIN时,可能还有数据没发完。它必须先回ACK(告诉客户端"我知道你要关了"),等数据发完后再发FIN。
特殊情况 :如果服务器收到FIN时也没有数据要发了,可以把ACK和FIN合并,变成三次挥手。但TCP协议默认按四次处理。
为什么客户端要等待2MSL?
MSL = Maximum Segment Lifetime,报文最大生存时间。RFC 1122规定为2分钟,Linux默认60秒。
两个原因:
原因一:确保最后一个ACK能到达服务器
客户端 服务器
| |
|<-------- FIN (seq=v) ----|
| |
|------ ACK (ack=v+1) ---->| ← 这个ACK如果丢失
| |
| | 服务器超时重发FIN
|<-------- FIN (seq=v) ----|
| |
|------ ACK (ack=v+1) ---->|
| |
如果客户端发完ACK立刻关闭,而ACK丢失,服务器会重发FIN。但客户端已经关闭,无法响应,服务器就无法正常关闭。
等待2MSL,客户端还能响应服务器的重发FIN。
原因二:让本次连接的所有报文在网络中消失
如果客户端立刻用相同端口建立新连接,旧连接的延迟报文可能被新连接误收,导致数据错乱。
等待2MSL,确保本次连接的所有报文都已消亡。
状态转换详解
服务器端:
-
CLOSED → LISTEN:调用listen()
-
LISTEN → SYN_RCVD:收到SYN
-
SYN_RCVD → ESTABLISHED:收到ACK
-
ESTABLISHED → CLOSE_WAIT:收到FIN
-
CLOSE_WAIT → LAST_ACK:调用close(),发送FIN
-
LAST_ACK → CLOSED:收到ACK
客户端:
-
CLOSED → SYN_SENT:调用connect()
-
SYN_SENT → ESTABLISHED:收到SYN+ACK,发送ACK
-
ESTABLISHED → FIN_WAIT_1:调用close(),发送FIN
-
FIN_WAIT_1 → FIN_WAIT_2:收到ACK
-
FIN_WAIT_2 → TIME_WAIT:收到FIN,发送ACK
-
TIME_WAIT → CLOSED:等待2MSL
TIME_WAIT 与 CLOSE_WAIT
核心结论:
-
TIME_WAIT :主动关闭方在发送最后一个ACK后进入的状态。
-
CLOSE_WAIT :被动关闭方在收到FIN并回复ACK后进入的状态。
TIME_WAIT 详解
什么是 TIME_WAIT?
TIME_WAIT 是主动关闭连接的一方 在发送最后一个ACK后进入的状态。它需要等待 2MSL(报文最大生存时间) 后才能进入CLOSED状态。
MSL 的值:
-
RFC 1122 规定为 2分钟。
-
Linux 默认
tcp_fin_timeout = 60秒,即 MSL = 60秒,2MSL = 120秒?不,实际上 Linux 的 TIME_WAIT 持续时间是 60秒 (tcp_fin_timeout直接控制 TIME_WAIT 时长,不是2MSL的2倍)。
为什么需要 TIME_WAIT?(核心!)
TIME_WAIT 的存在有两个根本原因:
原因一:确保最后一个ACK能可靠到达服务器
客户端 服务器
| |
|<-------- FIN (seq=v) ----|
| |
|------ ACK (ack=v+1) ---->| ← 这个ACK如果丢失
| |
| (客户端立刻关闭) | 服务器超时重发FIN
| |
|<-------- FIN (seq=v) ----|
| |
| ✗ 客户端已关闭,无法响应 |
| |
| 服务器无法正常关闭!
问题:如果客户端发送最后一个ACK后立刻关闭,而这个ACK在网络中丢失,服务器会超时重发FIN。但客户端已经关闭,无法响应,导致服务器一直处于LAST_ACK,无法正常关闭。
解决:客户端等待2MSL,期间如果收到服务器重发的FIN,还能再次发送ACK。
为什么是2MSL?
-
1MSL:ACK到达服务器的最长时间。
-
1MSL:服务器重发FIN到达客户端的最长时间。
-
2MSL = 一来一回,确保能处理所有重发情况。
原因二:让本次连接的所有报文在网络中消失
客户端 服务器
| |
| 连接1:数据包延迟在网络中 |
| |
| 连接1关闭 |
| |
| 连接2:使用相同四元组 |
| |
| ✗ 旧数据包到达,被误认为 |
| 是新连接的数据! |
问题:TCP连接由四元组(源IP、源端口、目的IP、目的端口)唯一标识。如果客户端立刻用相同端口建立新连接,旧连接的延迟报文可能被新连接误收,导致数据错乱。
解决:等待2MSL,确保本次连接的所有报文都已消亡(超过MSL的报文会被网络丢弃)。
TIME_WAIT 的危害
危害一:端口占用
服务器主动关闭连接后,端口在TIME_WAIT期间不能被重用。如果服务器需要频繁主动关闭连接,会出现:
bash
$ ./server
bind error: Address already in use
原因:虽然应用程序终止了,但TCP层的连接还没完全断开。
验证:
bash
$ netstat -apn | grep 8000
tcp 0 0 127.0.0.1:33498 127.0.0.1:8000 CLOSE_WAIT 10830/client
tcp 0 0 127.0.0.1:8000 127.0.0.1:33498 FIN_WAIT2 -
危害二:资源浪费
每个TIME_WAIT连接占用一个通信五元组(源IP、源端口、目的IP、目的端口、协议),以及内核中的一些内存资源。
高并发场景:
-
服务器每秒处理大量短连接。
-
如果服务器主动关闭,会产生大量TIME_WAIT。
-
每个TIME_WAIT持续60秒,累积起来可能达到数万甚至数十万。
-
可能导致端口耗尽、内存耗尽。
如何解决 TIME_WAIT 问题?
方法一:SO_REUSEADDR(最常用)
bash
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
作用:允许创建端口号相同但IP地址不同的多个socket描述符,或者绑定处于TIME_WAIT的端口。
原理:通知内核,如果端口处于TIME_WAIT,可以重用。
注意:这不能缩短TIME_WAIT时间,只是允许新连接绑定。
方法二:让客户端主动关闭
核心思想:客户端端口多(65535个),不怕TIME_WAIT;服务器端口少(如80、443),TIME_WAIT影响大。
实现:
-
HTTP协议中,客户端发完请求后主动关闭。
-
服务器设置
Connection: close,让客户端关闭。
方法三:使用长连接
核心思想:减少连接建立/关闭频率。
CLOSE_WAIT 详解
什么是 CLOSE_WAIT?
CLOSE_WAIT 是被动关闭连接的一方在收到FIN并回复ACK后进入的状态。
关键 :此时连接处于半关闭状态------对方不能再发数据,但自己还能发数据。
正常流程:
-
收到FIN。
-
回复ACK。
-
进入CLOSE_WAIT。
-
应用程序处理剩余数据。
-
应用程序调用
close()。 -
发送FIN,进入LAST_ACK。
-
收到ACK,进入CLOSED。
问题 :如果应用程序一直不调用 close(),连接就永远停留在CLOSE_WAIT,导致文件描述符泄漏。
CLOSE_WAIT 的危害
危害一:文件描述符泄漏
每个CLOSE_WAIT连接占用一个文件描述符。Linux默认单进程文件描述符上限是1024(可调整)。
后果:
-
文件描述符耗尽。
-
无法接受新连接。
-
报错
Too many open files。
危害二:内存泄漏
每个连接占用内核内存(socket结构、缓冲区等)。
后果:
-
内存持续增长。
-
最终OOM(Out Of Memory)。
危害三:连接堆积
大量CLOSE_WAIT连接堆积,导致:
-
端口占用。
-
系统负载升高。
-
服务不可用。
Q:服务器出现大量CLOSE_WAIT是什么原因?
答:代码BUG------服务器收到FIN后没有调用close()。需要检查代码,确保所有路径都能正确关闭socket。
Q:CLOSE_WAIT会持续多久?
答:取决于应用程序何时调用close()。如果不调用,永远持续。
Q:CLOSE_WAIT和TIME_WAIT哪个更严重?
答:CLOSE_WAIT更严重。因为:
-
TIME_WAIT是正常状态,会自行消失。
-
CLOSE_WAIT是异常状态,不修复代码永远不会消失。
-
TIME_WAIT占用端口,CLOSE_WAIT占用文件描述符和内存。
TCP的核心机制(下):高效传输
滑动窗口
问题 :一发一收性能太差。
解决:一次发送多个段,无需等待每个ACK。
窗口大小:无需等待确认应答而可以继续发送数据的最大值。
-
发送前4个段(窗口=4000字节),不用等ACK。
-
收到第一个ACK后,窗口向后滑动,发送第5个段。
-
发送缓冲区记录未应答的数据,收到ACK后删除。
窗口与吞吐量
吞吐量 ≈ 窗口大小 / RTT。窗口越大,吞吐量越高。
快重传
情况一:ACK丢了 ------不要紧,后续ACK可以确认。
情况二:数据包丢了 ------接收端连续收到相同ACK(如"下一个是1001"),发送端连续3次收到相同ACK,立即重传丢失的数据(不等超时)。这就是快重传。
快恢复
快重传后,不回到慢启动,而是将拥塞窗口减半,然后线性增长(快恢复)。
拥塞控制
问题 :网络本身拥堵,贸然发大量数据会雪上加霜。
解决:慢启动 + 拥塞避免。
慢启动:
-
初始拥塞窗口=1。
-
每收到一个ACK,拥塞窗口+1(指数增长)。
-
达到慢启动阈值后,改为线性增长(拥塞避免)。
超时重传时:
-
慢启动阈值减半。
-
拥塞窗口置1,重新慢启动。
延迟应答
问题 :立刻ACK,窗口可能较小。
解决:等一会再ACK,窗口可能变大。
-
数量限制:每N个包应答一次(N通常=2)。
-
时间限制:超过200ms必须应答。
捎带应答
问题 :ACK单独发太浪费。
解决:ACK搭应用层响应的顺风车一起发。
- 例如:客户端问"How are you",服务器回"Fine, thank you"时顺便带上ACK。
TCP的"性格":面向字节流与粘包问题
面向字节流
TCP不保留应用层的消息边界。发送端write 100字节,可能被拆成多个TCP段;接收端read时,可能一次读到多个消息。
全双工:一个连接既有发送缓冲区,也有接收缓冲区,可以同时读写。
粘包问题
本质 :应用层不知道从哪到哪是一个完整消息。
解决:
-
定长包:固定大小读取。
-
变长包:包头加长度字段。
-
分隔符:如HTTP的\r\n\r\n。
UDP会不会粘包?
不会。UDP保留消息边界,要么收到完整报文,要么不收。
TCP异常情况处理
| 异常 | 行为 |
|---|---|
| 进程终止 | 释放文件描述符,发送FIN,正常关闭 |
| 机器重启 | 同进程终止 |
| 机器掉电/网线断开 | 接收端认为连接还在,写入时发现异常,发送RST;TCP有保活定时器定期探测 |
| 应用层保活 | HTTP长连接、QQ断线重连等 |
扩展
┌─────────────────────────────────────────────────────────────┐
│ 进程层 │
│ ┌──────────────┐ │
│ │ task_struct │ │
│ └──────┬───────┘ │
│ │ files │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ struct files_struct │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ struct file* fd_array[]│ │ │
│ │ └───────────┬────────────┘ │ │
│ └──────────────┼───────────────┘ │
│ │ fd_array[n] │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ struct file │ │
│ │ private_data ──────────┼──────┐ │
│ └──────────────────────────────┘ │ │
│ │ │
│ ┌──────────────────────────────┐ │ │
│ │ struct socket │◄─────┘ │
│ │ file ───────────────────┼──────┐ │
│ │ sk ─────────────────────┼───┐ │ │
│ └──────────────────────────────┘ │ │ │
│ │ │ │
│ ┌──────────────────────────────┐ │ │ │
│ │ struct sock │◄──┘ │ │
│ │ ┌────────────────────────┐ │ │ │
│ │ │ struct inet_sock │ │ │ │
│ │ │ ┌──────────────────┐ │ │ │ │
│ │ │ │struct inet_conn │ │ │ │ │
│ │ │ │ ┌──────────────┐ │ │ │ │ │
│ │ │ │ │structtcp_sock│ │ │ │ │ │
│ │ │ │ └──────────────┘ │ │ │ │ │
│ │ │ └──────────────────┘ │ │ │ │
│ │ └────────────────────────┘ │ │ │
│ └──────────────────────────────┘ │ │
│ │ │
│ 注:struct file 与 struct socket │ │
│ 通过 private_data / file 互相指向 └────────────────────┘
└─────────────────────────────────────────────────────────────┘
这张图非常经典,它展示了 Linux 内核中从用户态文件描述符到内核 TCP 协议栈的完整映射关系。
核心结构体全景图(从上到下)
这张图分为四个层级:
-
进程层 :
task_struct(进程控制块) -
文件系统层 :
struct files_struct→struct file -
Socket 抽象层 :
struct socket -
传输层协议层 :
struct sock→struct inet_sock→struct inet_connection_sock→struct tcp_sock(以 TCP 为例)
1. 进程与文件系统层
-
task_struct:代表一个进程。每个进程都有自己打开的文件表。 -
struct files_struct:进程打开文件的表。其中fd_array[]是一个指针数组,下标就是文件描述符(fd)。 -
struct file:代表一个打开的文件。对于 socket 来说,它代表一个网络连接。图中的private_data指向struct socket。
2. Socket 抽象层
-
struct socket:这是 BSD Socket 层的抽象。它不关心底层是 TCP 还是 UDP,只提供通用的 socket 接口(如sock->ops->sendmsg)。-
它有两个关键指针:
-
struct file *file:指回上层的struct file(用于文件系统交互)。 -
struct sock *sk:指向底层的传输层控制块。
-
-
3. 传输层协议层(重点!)
图中的底部是一个多层嵌套的结构,这是 Linux 内核网络栈的精髓------继承与多态。
-
struct sock:最基础的传输层控制块。包含了所有协议通用的字段(如状态、接收队列、发送队列、定时器等)。 -
struct inet_sock:继承自struct sock,增加了 IPv4 相关的字段(如源 IP、目的 IP、TTL 等)。 -
struct inet_connection_sock:继承自struct inet_sock,增加了面向连接协议特有的字段(如三次握手队列、重传定时器、MSS 等)。UDP 不需要这个结构,TCP 需要。 -
struct tcp_sock:继承自struct inet_connection_sock,增加了 TCP 特有的字段(如滑动窗口、拥塞窗口、序列号、确认号等)。
内存布局 :这些结构体在内存中是连续嵌套 的。一个 struct tcp_sock 变量的首地址,同时也是 struct inet_connection_sock、struct inet_sock 和 struct sock 的首地址。因此,内核可以通过强制类型转换,实现类似 C++ 继承的"向上转型"。
用户调用 socket() 系统调用时发生了什么?
当用户调用 int fd = socket(AF_INET, SOCK_STREAM, 0); 时,内核会经历以下步骤:
第一步:创建 struct socket
内核调用 sock_create(),分配一个 struct socket 对象。此时它只是一个空壳,还没有绑定具体协议。
第二步:创建 struct file
内核在文件系统中创建一个 struct file 对象,并将它的 private_data 指向刚刚创建的 struct socket。同时,struct socket 的 file 指针也指回这个 struct file。这就是图中 private_data 和 file 互相指向的原因。
第三步:创建底层协议控制块(struct sock)
根据 SOCK_STREAM 参数,内核知道要使用 TCP 协议。
-
内核调用
inet_create()。 -
根据协议类型,分配对应的底层结构。对于 TCP,它会分配一个
struct tcp_sock对象(这个对象很大,包含了完整的struct sock、inet_sock等)。 -
初始化该结构,并将
struct socket的sk指针指向这个struct tcp_sock对象。 -
关键点 :
struct socket的ops指针会被设置为inet_stream_ops(TCP 的操作函数集)。如果是 UDP,则会设置为inet_dgram_ops。
第四步:分配文件描述符(fd)
内核在当前进程的 files_struct 的 fd_array[] 中找一个空闲的位置,将 struct file 的指针填入,并返回数组下标作为 fd。
至此,用户拿到的 fd 就与内核的 TCP 协议栈建立了完整的映射关系。
TCP 与 UDP 在内核实现上的差异
这张图展示的是 TCP(因为有 inet_connection_sock 和 tcp_sock)。如果是 UDP,结构会简化很多:
| 特性 | TCP | UDP |
|---|---|---|
| 底层结构 | tcp_sock → inet_connection_sock → inet_sock → sock |
udp_sock → inet_sock → sock |
| 连接管理 | 有 inet_connection_sock,包含连接队列、重传定时器 |
无连接,不需要 inet_connection_sock |
| Socket 操作集 | inet_stream_ops |
inet_dgram_ops |
| 发送数据 | tcp_sendmsg(可能阻塞、拆分、重传) |
udp_sendmsg(直接封装成包发出) |
| 接收数据 | tcp_recvmsg(从接收队列读取,处理乱序、去重) |
udp_recvmsg(从接收队列读取,保留消息边界) |
| 状态机 | 复杂的 11 种状态(ESTABLISHED, TIME_WAIT 等) | 基本只有 CLOSE 和 ESTABLISHED(无连接) |
UDP 为什么没有 inet_connection_sock?
因为 UDP 是无连接的,不需要维护三次握手队列、重传定时器、滑动窗口等面向连接的特性。所以它的结构体层级更浅,内存占用更小,处理速度更快。
调用逻辑:从用户态到内核态的数据流
以 TCP 发送数据为例,说明这些结构体是如何协同工作的:
用户态:write(fd, buf, len)
↓
系统调用:sys_write(fd, buf, len)
↓
VFS 层:根据 fd 找到 struct file
↓
Socket 层:调用 file->private_data->ops->sendmsg (即 struct socket 的 ops)
↓
INET 层:sock_sendmsg -> inet_sendmsg
↓
TCP 层:tcp_sendmsg(struct sock *sk, ...)
↓
数据被放入 sk->sk_write_queue (发送队列)
↓
TCP 输出:tcp_write_xmit() 构建 TCP 报文段
↓
IP 层:ip_queue_xmit() 添加 IP 头
↓
数据链路层:dev_queue_xmit() 发送到网卡
关键理解:
-
struct socket是门面,提供统一的文件系统接口(read/write/ioctl)。 -
struct sock(及其子类)是大脑,维护协议状态、队列、定时器。 -
函数指针(
ops)实现了多态 。同一个write系统调用,对于 TCP 会走到tcp_sendmsg,对于 UDP 会走到udp_sendmsg。
TCP 协议栈是如何形成的?
这里的"协议栈"有两层含义:
1. 静态的代码栈(协议族注册)
Linux 内核启动时,会调用 inet_init(),向内核注册 TCP 和 UDP 协议:
-
注册
tcp_prot(TCP 协议操作集)和udp_prot(UDP 协议操作集)。 -
注册
inet_stream_ops和inet_dgram_ops。 -
注册 TCP 和 UDP 的收包处理函数(
tcp_v4_rcv和udp_rcv)到 IP 层。
这样,当数据包到达时,IP 层知道该交给谁来处理。
2. 动态的连接栈(Socket 实例)
每次用户调用 socket(),内核就动态生成一个 struct tcp_sock 实例。这个实例包含了该连接的所有状态(序列号、窗口、定时器等)。多个连接对应多个 tcp_sock 实例,它们共享同一套代码逻辑(tcp_prot)。
三次握手的过程,就是内核在这个动态实例上不断更新状态、收发队列、启动定时器的过程。
这张图揭示了 Linux 内核网络实现的三大设计哲学:
-
分层抽象 :
file→socket→sock,每层各司其职。 -
继承与多态:通过结构体嵌套和函数指针,实现 C 语言下的面向对象。
-
统一接口,差异化实现 :用户只用
read/write,内核根据SOCK_STREAM或SOCK_DGRAM走完全不同的协议路径。