文章目录
- [彻底解剖 TCP 数据流动:从 `write()` 到网卡,再从网卡到 `read()` 的全链路解析](#彻底解剖 TCP 数据流动:从
write()到网卡,再从网卡到read()的全链路解析) - [二、发送方向:从 `write()` 到客户端网卡](#二、发送方向:从
write()到客户端网卡) -
- [第 1 步:根据文件描述符找到 TCP 套接字](#第 1 步:根据文件描述符找到 TCP 套接字)
- [第 2 步:数据从用户空间复制到内核](#第 2 步:数据从用户空间复制到内核)
- [第 3 步:`write()` 返回究竟代表什么](#第 3 步:
write()返回究竟代表什么) - [第 4 步:`write()` 不一定一次写完](#第 4 步:
write()不一定一次写完) - [第 5 步:TCP 为数据建立可靠传输状态](#第 5 步:TCP 为数据建立可靠传输状态)
- [第 6 步:TCP 分段](#第 6 步:TCP 分段)
- [第 7 步:进入 IP 层](#第 7 步:进入 IP 层)
- [第 8 步:进入链路层和邻居子系统](#第 8 步:进入链路层和邻居子系统)
- [第 9 步:进入 qdisc 和网卡驱动](#第 9 步:进入 qdisc 和网卡驱动)
- [第 10 步:DMA 与网卡发送](#第 10 步:DMA 与网卡发送)
- 三、网络传输:数据如何到达服务端
- [四、接收方向:从服务端网卡到 `read()`](#四、接收方向:从服务端网卡到
read()) -
- [第 11 步:服务端网卡收到数据](#第 11 步:服务端网卡收到数据)
- [第 12 步:RX Ring 与 DMA](#第 12 步:RX Ring 与 DMA)
- [第 13 步:硬中断与 NAPI](#第 13 步:硬中断与 NAPI)
- [第 14 步:链路层处理](#第 14 步:链路层处理)
- [第 15 步:IP 层处理](#第 15 步:IP 层处理)
- [第 16 步:TCP 根据连接信息查找对应套接字](#第 16 步:TCP 根据连接信息查找对应套接字)
- [第 17 步:TCP 校验和序列号处理](#第 17 步:TCP 校验和序列号处理)
- [第 18 步:数据进入套接字接收队列](#第 18 步:数据进入套接字接收队列)
- [第 19 步:接收端发送 ACK](#第 19 步:接收端发送 ACK)
- [五、`read()` 如何从 TCP 接收队列取数据](#五、
read()如何从 TCP 接收队列取数据) -
- [第 20 步:通过 `cfd` 找到连接对象](#第 20 步:通过
cfd找到连接对象) - [第 21 步:检查是否存在可读取数据](#第 21 步:检查是否存在可读取数据)
- [第 22 步:读取后数据是否立即消失](#第 22 步:读取后数据是否立即消失)
- [第 20 步:通过 `cfd` 找到连接对象](#第 20 步:通过
- [六、为什么 `read()` 一次不一定读到完整消息](#六、为什么
read()一次不一定读到完整消息) - 七、所谓"粘包"和"拆包"到底是什么
- 八、服务端回显时,数据如何反向流动
- [九、监听套接字 `lfd` 到底保存什么](#九、监听套接字
lfd到底保存什么) - [十、TCP 建立连接时涉及哪些队列](#十、TCP 建立连接时涉及哪些队列)
-
- [第一次握手:客户端发送 SYN](#第一次握手:客户端发送 SYN)
- [第二次握手:服务端发送 SYN+ACK](#第二次握手:服务端发送 SYN+ACK)
- [第三次握手:客户端返回 ACK](#第三次握手:客户端返回 ACK)
- [十一、`accept()` 到底做了什么](#十一、
accept()到底做了什么) - 十二、监听套接字是否完全不能接收业务数据
- 十三、三个阻塞函数的真正阻塞条件
-
- [1. `read()`](#1.
read()) - [2. `write()`](#2.
write()) - [3. `accept()`](#3.
accept())
- [1. `read()`](#1.
- [十四、TCP 流量控制为什么会让发送端停下来](#十四、TCP 流量控制为什么会让发送端停下来)
- [十五、`read()` 返回 0 到底表示什么](#十五、
read()返回 0 到底表示什么) - 十六、完整数据流全景
- 十七、不同对象在整个过程中分别负责什么
- 十八、几个必须彻底纠正的错误认识
-
- [错误一:`write()` 返回说明对方已经收到](#错误一:
write()返回说明对方已经收到) - [错误二:数据交给网卡后,TCP 就可以立即删除发送数据](#错误二:数据交给网卡后,TCP 就可以立即删除发送数据)
- [错误三:网卡通过 DMA 直接把数据写进 `cfd` 的读缓冲区](#错误三:网卡通过 DMA 直接把数据写进
cfd的读缓冲区) - [错误四:内核根据四元组找到用户空间的 `cfd`](#错误四:内核根据四元组找到用户空间的
cfd) - [错误五:一次 `write()` 对应一次 `read()`](#错误五:一次
write()对应一次read()) - [错误六:`lfd` 的读缓冲区里保存 SYN 包](#错误六:
lfd的读缓冲区里保存 SYN 包) - 错误七:每收到一个包都会产生一次完整硬中断处理
- [错误八:网卡 Ring Buffer 中直接保存完整数据包](#错误八:网卡 Ring Buffer 中直接保存完整数据包)
- [错误一:`write()` 返回说明对方已经收到](#错误一:
- 十九、最终结论
彻底解剖 TCP 数据流动:从 write() 到网卡,再从网卡到 read() 的全链路解析
在 TCP 网络编程中,我们通常只看到几个简单的系统调用:
cpp
write(fd, data, size);
read(fd, buffer, size);
但在这两个函数之间,数据实际上经历了:
- 用户态与内核态之间的数据拷贝
- TCP 发送队列与接收队列
- TCP 分段、重传和流量控制
- IP 路由与链路层封装
- 网卡驱动和 DMA
- 硬中断、NAPI 与软中断
- TCP 四元组查找
- 套接字唤醒与进程调度
下面以"客户端向服务端发送 "Hello""为例,从应用程序一直追踪到网卡,再从服务端网卡追踪到 read()。
一、首先建立正确的整体认识
对于一个已经建立 TCP 连接的通信套接字,内核通常会维护两组相互独立的状态:
- 发送方向的状态和队列
- 接收方向的状态和队列
因此,一个 TCP 连接可以同时发送和接收数据,这也是 TCP 能够实现全双工通信的基础。
不过,"每个套接字都有一个固定的读缓冲区和一个固定的写缓冲区"只是一种便于入门的抽象。
在 Linux 内核中,真正的实现远比两个连续数组复杂。数据通常通过若干 struct sk_buff、队列、链表以及内存计数机制进行管理,例如:
- 发送队列
- 接收队列
- 乱序接收队列
- 重传队列
- backlog 队列
- 预队列或其他协议栈内部队列
因此,后文中的"发送缓冲区"和"接收缓冲区",应该理解为:
内核为该 TCP 套接字维护的发送数据、接收数据及相关控制状态的统称,而不一定是一整块连续内存。
还需要区分两类套接字:
-
监听套接字
lfd用于接收新的连接,不承担普通业务数据通信。
-
已连接套接字
cfd或客户端fd表示一条具体 TCP 连接,用于收发业务数据。
二、发送方向:从 write() 到客户端网卡
假设客户端已经与服务端建立连接,并执行:
cpp
ssize_t n = write(fd, "Hello", 5);
第 1 步:根据文件描述符找到 TCP 套接字
用户程序中的 fd 只是一个整数,例如:
text
fd = 3
它本身不包含 TCP 连接信息。
进入内核后,内核会沿着类似下面的关系找到真正的 TCP 对象:
text
文件描述符 fd
↓
进程文件描述符表
↓
struct file
↓
struct socket
↓
struct sock
↓
struct tcp_sock
其中:
struct file表示打开的内核文件对象struct socket是 BSD Socket 层对象struct sock是网络协议栈中的通用套接字对象struct tcp_sock保存 TCP 特有状态,例如序列号、拥塞窗口和接收窗口
第 2 步:数据从用户空间复制到内核
write() 最终会进入 TCP 的发送逻辑。
在普通阻塞式 Socket 中,内核通常会:
- 检查当前连接状态
- 检查发送缓冲区是否有可用空间
- 从用户空间读取
"Hello" - 将数据复制或组织到内核管理的发送数据结构中
- 为数据分配 TCP 序列号
- 尝试将数据发送到更低层协议栈
可以将这一过程概括为:
text
用户空间 buf
↓ copy_from_user 或等价机制
TCP 套接字发送队列
但需要注意,发送队列通常不是一块简单的字符数组,而是由一个或多个 sk_buff 等数据结构组成。
第 3 步:write() 返回究竟代表什么
假设:
cpp
ssize_t n = write(fd, "Hello", 5);
返回:
text
n = 5
这只代表:
内核已经接受了这 5 个字节,并将其纳入该 TCP 套接字的发送管理范围。
它并不代表:
- 数据已经到达网卡
- 数据已经离开本机
- 数据已经到达服务端
- 服务端程序已经调用
read() - 服务端已经处理了数据
甚至在 write() 返回时,数据可能还没有真正形成一个独立的网络包。
TCP 是否立即发送,还会受到以下机制影响:
- Nagle 算法
- 拥塞控制
- 接收窗口
- TCP Small Queues
- 分段与聚合
- 网卡发送队列状态
- 调度策略
TCP_NODELAYMSG_MORE- 网卡硬件卸载能力
第 4 步:write() 不一定一次写完
以下代码是不严谨的:
cpp
write(fd, data, length);
因为即使在阻塞模式下,write() 也可能只接受部分数据。
正确做法应该检查返回值:
cpp
ssize_t send_all(int fd, const char* data, size_t length) {
size_t sent = 0;
while (sent < length) {
ssize_t n = write(fd, data + sent, length - sent);
if (n > 0) {
sent += static_cast<size_t>(n);
continue;
}
if (n < 0 && errno == EINTR) {
continue;
}
return -1;
}
return static_cast<ssize_t>(sent);
}
对于非阻塞套接字,如果当前发送缓冲区无法继续接收数据,write() 通常返回:
text
-1
errno = EAGAIN 或 EWOULDBLOCK
如果连接已经被对端关闭,继续写入还可能出现:
EPIPESIGPIPE
第 5 步:TCP 为数据建立可靠传输状态
TCP 不是简单地把 "Hello" 加上一个头部就立即丢给网卡。
TCP 还要维护:
- 发送序列号
- 已发送但尚未确认的数据
- 重传定时器
- 拥塞窗口
- 对端通告的接收窗口
- RTT 估计
- 重复 ACK 状态
- SACK 信息
假设 "Hello" 对应的 TCP 序列号范围是:
text
SEQ = 1000
数据长度 = 5
覆盖字节序号 1000~1004
即使数据已经交给网卡发送,内核通常也不能立刻释放所有相关数据。
因为在对端返回确认之前,TCP 必须保留足够的信息,以便在丢包时进行重传。
因此:
"数据已交给网卡"与"数据已从 TCP 发送缓冲区中彻底释放"不是同一件事。
只有收到相应 ACK 后,TCP 才能确认这些字节已经被对端 TCP 协议栈接收,并逐步释放相关发送资源。
第 6 步:TCP 分段
如果应用程序写入的数据较多,TCP 需要将字节流划分为适合传输的 TCP 段。
例如应用层一次写入:
text
10 KB
网络路径允许的单个 TCP 载荷可能只有约:
text
1460 字节
那么逻辑上可能需要拆分为多个 TCP 段。
不过在现代 Linux 和现代网卡中,这个过程可能受到以下硬件卸载技术影响:
- TSO:TCP Segmentation Offload
- GSO:Generic Segmentation Offload
- CHECKSUM offload
启用 TSO/GSO 时,内核可能把一个较大的数据块交给网卡,由网卡完成最终分段。
因此,"TCP 协议栈先把数据完整切成一个个小包,再交给网卡"的描述并不总是准确。
更准确的说法是:
TCP/IP 协议栈建立逻辑上的 TCP 分段与协议头信息,实际物理分段可能由内核完成,也可能延迟到网卡硬件完成。
第 7 步:进入 IP 层
TCP 层准备好待发送数据后,将其交给 IP 层。
IP 层主要负责:
- 确定目标 IP 地址
- 查询路由表
- 选择输出网络设备
- 填写 IP 头部
- 处理分片或路径 MTU 相关逻辑
- 经过 Netfilter 等处理路径
抽象路径如下:
text
TCP 数据
↓
TCP 头
↓
IP 头
↓
IP 数据包
第 8 步:进入链路层和邻居子系统
如果使用以太网,内核还需要知道下一跳设备的 MAC 地址。
这通常涉及:
- ARP:IPv4
- NDP:IPv6
- 邻居缓存
- 网关地址
- 路由结果
链路层封装后,逻辑上形成:
text
以太网头
IP 头
TCP 头
应用数据
但在网卡卸载开启时,部分头部、校验和或分段工作可能仍由网卡稍后完成。
第 9 步:进入 qdisc 和网卡驱动
数据不会简单地从 TCP 套接字直接放入网卡 Ring Buffer。
在 Linux 中,发送路径通常还会经过:
text
TCP/IP 协议栈
↓
网络设备层
↓
qdisc 排队规则
↓
网卡驱动
↓
网卡 TX 描述符环
qdisc 是 Linux 的排队规则系统,例如:
fqfq_codelpfifo_fastmq
它负责一定程度上的:
- 排队
- 调度
- 整形
- 公平性控制
网卡驱动随后把待发送数据映射给 DMA,并在网卡的发送描述符环中填写描述信息。
第 10 步:DMA 与网卡发送
网卡的 TX Ring 通常不是直接存放全部数据内容,而是保存一组发送描述符。
描述符会告诉网卡:
- 数据位于哪块内存
- 数据长度是多少
- 是否需要硬件计算校验和
- 是否需要 TSO
- 一个数据包由哪些内存片段组成
可以简化理解为:
text
内核中的数据页或 skb
↑
│ DMA 映射
│
网卡 TX 描述符环
↓
网卡读取数据
↓
发送到物理介质
网卡通过 DMA 读取内存中的数据,并将其编码为:
- 电信号
- 光信号
- 无线电信号
随后发送到网络中。
三、网络传输:数据如何到达服务端
数据离开客户端后,可能经过:
text
客户端网卡
↓
交换机
↓
路由器
↓
防火墙或 NAT
↓
服务端所在网络
↓
服务端网卡
中间设备主要根据:
- MAC 地址
- IP 地址
- 路由表
- NAT 状态
- 防火墙规则
决定数据包下一步去向。
TCP 的可靠性并不是由路由器提供的。
路由器只负责尽力转发 IP 数据包。发生丢包、乱序或重复时,由通信两端的 TCP 协议栈负责恢复。
四、接收方向:从服务端网卡到 read()
第 11 步:服务端网卡收到数据
服务端网卡接收到以太网帧后,通常不会直接把 "Hello" 写入某个 cfd 的接收缓冲区。
网卡此时根本不知道:
- 哪个进程拥有这个连接
- 对应的文件描述符是多少
- 应该交给哪个
struct sock - TCP 数据是否乱序
- 校验和是否合法
网卡只负责接收链路层数据。
第 12 步:RX Ring 与 DMA
网卡驱动通常会提前准备一批可用于接收数据的内存区域,并把对应地址登记在 RX 描述符环中。
网卡收到帧后,通过 DMA 将数据写入这些预先准备的内存页或缓冲区。
可以简化为:
text
网络信号
↓
网卡
↓ DMA
内核预先准备的接收内存
这里需要特别纠正一个常见说法:
网卡不是"把整个数据包直接写进 Ring Buffer"。
更准确地说:
- RX Ring 主要保存接收描述符
- 描述符指向真正的数据缓冲区或内存页
- 网卡通过 DMA 把包数据写到描述符所指向的内存中
- 网卡再更新描述符状态,表示数据已经就绪
第 13 步:硬中断与 NAPI
早期或简化模型通常描述为:
text
每收到一个数据包
↓
网卡触发一次硬件中断
↓
CPU 立即处理这个包
但现代 Linux 高速网络通常使用 NAPI。
典型过程是:
- 网卡收到一批数据
- 网卡触发硬中断
- 硬中断处理程序暂时关闭或抑制继续中断
- 调度 NAPI poll
- 内核在软中断上下文中批量处理多个数据包
- 数据处理完毕后重新启用中断
因此,更准确的路径是:
text
网卡 DMA 接收
↓
硬中断进行快速通知
↓
NAPI poll
↓
NET_RX_SOFTIRQ
↓
网络协议栈
这样能够降低高流量场景下的中断开销。
第 14 步:链路层处理
数据进入协议栈后,首先处理链路层头部。
以以太网为例,内核会检查:
- 目标 MAC 地址
- 以太网协议类型
- VLAN 信息
- 数据包长度
- 网卡校验和状态
然后根据 EtherType 判断上层协议:
text
0x0800 → IPv4
0x86DD → IPv6
0x0806 → ARP
如果是 IPv4,就交给 IPv4 协议栈。
第 15 步:IP 层处理
IP 层会检查:
- IP 版本
- 头部长度
- 目标 IP 地址
- 数据包长度
- 分片状态
- 路由结果
- Netfilter 规则
- 上层协议号
如果 IP 头中的协议字段表示 TCP:
text
Protocol = 6
就将数据继续交给 TCP 层。
第 16 步:TCP 根据连接信息查找对应套接字
TCP 需要找到这个数据包属于哪条连接。
一条 TCP 连接通常由以下信息标识:
text
本地 IP
本地端口
远端 IP
远端端口
也就是常说的四元组:
text
<源 IP, 源端口, 目标 IP, 目标端口>
但在 Linux 内核中,查找过程还可能受到以下因素影响:
- 网络命名空间
- IPv4 或 IPv6
- 绑定设备
- 地址复用规则
SO_REUSEPORT- 连接状态
因此,"仅凭四元组找到 cfd"在教学上可以接受,但严格地说,内核找到的不是用户空间的整数文件描述符 cfd。
内核真正找到的是:
text
对应的 struct sock / struct tcp_sock
cfd 只是服务端进程文件描述符表中,指向该连接对象的一个整数索引。
准确表达应该是:
TCP 协议栈根据连接标识查找到对应的内核 TCP 套接字对象,而不是直接查找到用户程序中的
cfd。
第 17 步:TCP 校验和序列号处理
找到连接对象后,TCP 还要检查:
- TCP 校验和
- 序列号是否合法
- 数据是否重复
- 数据是否乱序
- 是否存在缺口
- ACK 是否有效
- 窗口是否合法
- 是否包含 FIN、RST 等控制标志
- 是否涉及 SACK
假设当前接收端期望的下一个序列号是:
text
RCV.NXT = 1000
收到的数据正好是:
text
SEQ = 1000
长度 = 5
那么它覆盖:
text
1000~1004
接收完成后,期望序列号可以推进为:
text
RCV.NXT = 1005
如果收到的却是更靠后的数据,例如:
text
SEQ = 1200
而 1000~1199 尚未到达,那么这段数据不能立即作为连续字节流交给应用程序。
它通常会先进入乱序队列,等待缺失的数据到达。
第 18 步:数据进入套接字接收队列
当 TCP 确认数据有效,并且已经能够组成从当前接收序列号开始的连续字节流后,数据会进入该 TCP 套接字的接收管理结构。
入门时可以称它为:
text
cfd 的读缓冲区
更精确的理解是:
text
与该已连接 struct sock 关联的接收队列和内存状态
此时,应用数据 "Hello" 已经被服务端 TCP 协议栈接收。
但是服务端进程是否已经运行、是否已经调用 read(),是另一件事。
TCP 协议栈可以先接收数据,再等待用户进程稍后读取。
第 19 步:接收端发送 ACK
服务端 TCP 收到 "Hello" 后,通常还会向客户端返回 ACK。
例如:
text
ACK = 1005
它表示:
序列号 1005 之前的所有字节都已经按序收到,下一步期望收到序列号 1005。
但 ACK 不等于:
- 服务端应用程序已经调用
read() - 服务端程序已经处理
"Hello" - 服务端业务逻辑执行成功
ACK 只说明:
数据已经到达服务端 TCP 协议栈,并被纳入可靠接收状态。
这是一个非常重要的边界。
五、read() 如何从 TCP 接收队列取数据
服务端执行:
cpp
char buf[1024];
ssize_t len = read(cfd, buf, sizeof(buf));
第 20 步:通过 cfd 找到连接对象
和发送路径类似,cfd 只是文件描述符。
内核通过服务端进程的文件描述符表,找到对应的:
text
struct file
↓
struct socket
↓
struct sock
↓
struct tcp_sock
第 21 步:检查是否存在可读取数据
情况一:接收队列中有数据
假设当前已经有:
text
Hello
内核就可以将数据从套接字接收队列复制到用户空间的 buf。
text
TCP 接收队列
↓ copy_to_user 或等价机制
用户空间 buf
然后:
cpp
len == 5
情况二:接收队列为空
如果这是一个阻塞套接字,并且:
- 当前没有可读数据
- 对端没有关闭发送方向
- 没有发生错误
- 没有信号中断
那么当前线程会进入睡眠状态,等待以下事件之一:
- 新数据到达
- 收到 FIN
- 连接发生错误
- 超时
- 信号到达
线程并不是在 CPU 上不停循环检查,而是会被加入相应等待队列,然后由调度器切换出去。
当 TCP 协议栈把新数据放入接收队列后,会唤醒等待该套接字的线程。
情况三:非阻塞套接字没有数据
如果套接字设置为非阻塞模式,并且当前没有数据可读,read() 通常返回:
text
-1
errno = EAGAIN 或 EWOULDBLOCK
第 22 步:读取后数据是否立即消失
普通 read() 成功后,被读取的字节会从应用可见的接收字节流中消费掉。
可以理解为接收队列的读取位置向前移动,相关内存最终被释放或复用。
例如接收队列中有:
text
HelloWorld
执行:
cpp
read(cfd, buf, 5);
得到:
text
Hello
剩余可读数据为:
text
World
如果使用:
cpp
recv(cfd, buf, 5, MSG_PEEK);
则可以查看数据,但不消费它。
六、为什么 read() 一次不一定读到完整消息
TCP 提供的是:
可靠、有序、双向的字节流。
TCP 不提供应用层消息边界。
假设发送端依次执行:
cpp
write(fd, "Hello", 5);
write(fd, "World", 5);
接收端并不保证得到两次对应的 read():
text
第一次:"Hello"
第二次:"World"
接收端可能得到:
情况一
text
第一次 read:"HelloWorld"
情况二
text
第一次 read:"Hel"
第二次 read:"loWorld"
情况三
text
第一次 read:"H"
第二次 read:"ello"
第三次 read:"World"
这些结果都符合 TCP 规范。
原因是:
text
发送端 write 调用边界
≠
TCP 分段边界
≠
IP 数据包边界
≠
接收端 read 调用边界
TCP 只保证接收端最终看到的字节顺序是:
text
HelloWorld
至于每次 read() 返回几个字节,由以下因素共同决定:
- 当前接收队列中有多少数据
- 用户传入的缓冲区大小
- 套接字是否阻塞
- 调度时机
- 网络到达时机
SO_RCVLOWAT- 信号和超时
- 接收 API 的参数
七、所谓"粘包"和"拆包"到底是什么
"粘包"和"拆包"不是 TCP 出错。
它们只是应用程序错误地把:
text
一次 write()
理解成:
text
一条具有边界的 TCP 消息
例如发送端发送:
text
消息 A:Hello
消息 B:World
TCP 实际只看到:
text
HelloWorld
它不知道:
text
Hello | World
中间存在一个应用层边界。
因此,应用层必须自行定义协议。
方案一:固定长度
每条消息固定为 64 字节:
text
[固定 64 字节消息]
[固定 64 字节消息]
接收端每次累计读取 64 字节,再解析一条消息。
方案二:分隔符
例如:
text
Hello\n
World\n
接收端不断读取,直到找到 \n。
HTTP/1.x 的部分结构就是这种思路。
方案三:长度字段
常见格式:
text
[4 字节长度][消息正文]
例如:
text
00 00 00 05 Hello
00 00 00 05 World
接收端首先读取固定长度的消息头,解析正文长度,再继续累计读取对应字节数。
示例:
cpp
struct MessageHeader {
uint32_t body_length;
};
需要注意:
- 使用网络字节序
- 校验长度上限
- 防止整数溢出
- 处理部分读取
- 防止恶意超大数据包
八、服务端回显时,数据如何反向流动
服务端读取到:
text
Hello
然后执行:
cpp
write(cfd, buf, len);
数据将沿相反方向流动:
text
服务端用户空间 buf
↓
服务端 cfd 对应 TCP 发送队列
↓
服务端 TCP/IP 协议栈
↓
服务端 qdisc
↓
服务端网卡 TX Ring
↓
网络
↓
客户端网卡 RX Ring
↓
客户端 TCP/IP 协议栈
↓
客户端 fd 对应接收队列
↓
客户端 read()
↓
客户端用户空间 buf
TCP 是全双工协议,因此同一条连接上存在两个逻辑上独立的字节流:
text
客户端 → 服务端
服务端 → 客户端
每个方向都有独立的:
- 序列号空间
- 发送状态
- 接收状态
- 流量控制
- ACK 处理
九、监听套接字 lfd 到底保存什么
监听套接字与已连接套接字承担不同职责。
服务端通常执行:
cpp
int lfd = socket(AF_INET, SOCK_STREAM, 0);
bind(lfd, ...);
listen(lfd, 128);
调用 listen() 后,lfd 进入监听状态。
它的主要职责是:
接收新的 TCP 连接建立请求,并管理尚未被应用程序
accept()的连接。
它不是用来接收普通业务数据的。
因此,不应该简单地说:
lfd的读缓冲区里存放连接。
更精确的表述是:
监听套接字维护连接请求相关的数据结构,包括未完成握手的连接状态和已经完成握手、等待
accept()的子连接。
十、TCP 建立连接时涉及哪些队列
教学中通常把监听端的连接管理划分为两个队列:
| 队列 | 常用名称 | 主要内容 |
|---|---|---|
| 未完成连接队列 | SYN Queue、半连接队列 | 已收到 SYN,握手尚未完成的连接请求 |
| 已完成连接队列 | Accept Queue、全连接队列 | 已完成握手,等待应用程序调用 accept() 的连接 |
需要说明的是,这两个名称是概念模型。
Linux 内核中的具体实现由 request_sock、监听队列以及子套接字等数据结构共同完成,并不一定表现为两个简单数组。
第一次握手:客户端发送 SYN
客户端发送:
text
SYN
服务端监听套接字收到后,内核会建立连接请求状态,记录:
- 客户端 IP
- 客户端端口
- 服务端 IP
- 服务端端口
- 初始序列号
- TCP 选项
- MSS
- 窗口扩大因子
- SACK 能力
- 时间戳能力
概念上,这个请求处于半连接状态。
第二次握手:服务端发送 SYN+ACK
服务端返回:
text
SYN + ACK
此时连接还没有完全建立。
服务端仍在等待客户端最后的 ACK。
第三次握手:客户端返回 ACK
客户端返回:
text
ACK
服务端验证成功后,会创建或完成对应的已连接子套接字,使其进入:
text
ESTABLISHED
随后,该连接进入等待 accept() 的队列。
十一、accept() 到底做了什么
服务端执行:
cpp
int cfd = accept(lfd, nullptr, nullptr);
accept() 不是读取 TCP 业务数据,也不是重新执行三次握手。
三次握手通常已经由内核协议栈完成。
accept() 的核心工作可以概括为:
- 检查监听套接字的已完成连接队列
- 如果队列为空,阻塞套接字可能使线程睡眠
- 从队列中取出一个已建立连接
- 创建新的文件对象和文件描述符
- 让新的
cfd指向该已连接 Socket - 将
cfd返回给用户程序
所以:
text
lfd
代表监听入口,而:
text
cfd
代表某一条具体的 TCP 连接。
可以形象地理解为:
lfd是接待台- Accept Queue 是等候区
accept()是领取下一位已经完成登记的客户cfd是为该客户建立的专属服务通道
但从内核实现角度看,并不存在"由 cfd 接管连接"的过程。
连接对象在 accept() 前就已经由内核建立。accept() 只是为用户进程创建一个新的文件描述符,使用户程序能够引用这条连接。
十二、监听套接字是否完全不能接收业务数据
通常情况下,应用程序不会通过监听描述符读取业务数据。
客户端业务数据应该进入对应的已连接子套接字,而不是进入监听套接字。
还存在一个容易忽略的情况:
客户端可能在第三次握手 ACK 中携带数据,或者使用 TCP Fast Open 在建立连接阶段发送数据。
即使如此,这些业务数据最终也属于对应的子连接,而不是作为普通字节流放进监听套接字供 read(lfd, ...) 使用。
十三、三个阻塞函数的真正阻塞条件
1. read()
cpp
read(cfd, buf, size);
阻塞套接字通常在以下情况下阻塞:
- 当前没有可读数据
- 对端未关闭发送方向
- 当前没有待处理错误
- 没有超时或信号中断
它可能因以下事件返回:
- 至少读取到部分数据
- 收到 FIN,且所有已接收数据已经读完,此时返回
0 - 被信号中断,返回
-1,errno == EINTR - 发生连接错误
- 设置了接收超时并超时
- 非阻塞模式下返回
EAGAIN
需要特别注意:
普通阻塞式
read()通常不要求填满用户缓冲区才返回。
例如:
cpp
char buf[4096];
ssize_t n = read(cfd, buf, sizeof(buf));
即使 buf 有 4096 字节,只要当前有 5 个字节可读,read() 就可能返回:
text
5
2. write()
cpp
write(cfd, data, size);
阻塞式 write() 可能在发送缓冲区没有足够可用空间时等待。
但"发送缓冲区满"并不只是因为网卡还没把数据发走。
发送空间还受到以下因素制约:
- 数据尚未收到 ACK
- 对端通告窗口过小或为零
- 网络拥塞
- 拥塞窗口限制
- 本地发送队列压力
- 内核套接字内存上限
- TCP Small Queues
- 网卡和 qdisc 排队压力
因此,更准确地说:
write()是否能够继续接受数据,取决于 TCP 发送内存是否存在足够可用额度,而不仅仅取决于数据是否已经交给网卡。
3. accept()
cpp
accept(lfd, ...);
阻塞式 accept() 在以下情况下等待:
text
已完成连接队列为空
当某个连接完成握手并进入等待接受状态后,内核会唤醒阻塞在 accept() 上的线程。
非阻塞模式下,如果当前没有可接受连接,通常返回:
text
-1
errno = EAGAIN 或 EWOULDBLOCK
十四、TCP 流量控制为什么会让发送端停下来
TCP 使用接收窗口告诉发送端:
我当前还能接收多少尚未确认的数据。
这个窗口通常称为:
text
rwnd
假设服务端应用程序一直不调用 read():
text
服务端接收队列不断增长
↓
可用接收空间逐渐减少
↓
服务端通告的接收窗口变小
↓
客户端可发送数据量受到限制
当接收窗口缩小为零时,发送端通常会停止发送新的普通数据,并通过零窗口探测机制定期确认窗口是否重新打开。
但是发送端实际可发送的数据量不只由接收窗口决定,还受拥塞窗口影响。
可以简化为:
text
实际允许在途的数据量
≈ min(rwnd, cwnd)
其中:
rwnd:接收端流量控制窗口cwnd:发送端拥塞控制窗口
所以:
rwnd防止压垮接收端cwnd防止压垮网络
十五、read() 返回 0 到底表示什么
当:
cpp
ssize_t n = read(cfd, buf, sizeof(buf));
返回:
text
n == 0
对于阻塞式 TCP Socket,通常表示:
对端已经正常关闭其发送方向,并且本端已经读完此前到达的所有数据。
这通常是因为接收到了 TCP FIN。
返回 0 并不表示:
- 暂时没有数据
- 读缓冲区当前为空但连接仍正常
- 应该稍后继续等待同一条数据
暂时没有数据时:
- 阻塞套接字会等待
- 非阻塞套接字返回
EAGAIN - 收到 FIN 并读完数据后才返回
0
十六、完整数据流全景
客户端发送 "Hello" 的完整逻辑路径可以概括为:
text
客户端用户空间
│
│ write(fd, "Hello", 5)
▼
客户端内核 TCP 发送内存
│
│ TCP 序列号、拥塞控制、流量控制
▼
TCP 层
│
▼
IP 层
│
▼
链路层与邻居子系统
│
▼
qdisc
│
▼
客户端网卡驱动
│
▼
客户端网卡 TX 描述符环
│
│ DMA 读取内存
▼
客户端网卡
│
▼
交换机、路由器及网络链路
│
▼
服务端网卡
│
│ DMA 写入接收内存
▼
服务端 RX 描述符环关联的数据缓冲区
│
▼
硬中断
│
▼
NAPI / NET_RX_SOFTIRQ
│
▼
以太网层
│
▼
IP 层
│
▼
TCP 层
│
│ 连接查找、校验和、序列号、乱序重组
▼
服务端已连接 Socket 接收队列
│
│ 唤醒等待进程
▼
read(cfd, buf, size)
│
▼
服务端用户空间 buf
十七、不同对象在整个过程中分别负责什么
| 对象 | 作用 |
|---|---|
客户端 fd |
引用客户端已连接 TCP Socket |
服务端 lfd |
引用监听 Socket,负责接收新连接 |
服务端 cfd |
引用某一条已连接 TCP Socket |
| TCP 发送队列 | 保存待发送或等待确认的数据及状态 |
| TCP 接收队列 | 保存已经按序接收、可供应用读取的数据 |
| 乱序队列 | 暂存尚不能组成连续字节流的数据 |
| Accept Queue | 保存已建立但尚未被应用 accept() 的连接 |
| TX Ring | 保存网卡发送描述符 |
| RX Ring | 保存网卡接收描述符 |
| qdisc | 对待发送数据进行排队和调度 |
| NAPI | 批量处理网卡接收数据,降低中断开销 |
十八、几个必须彻底纠正的错误认识
错误一:write() 返回说明对方已经收到
错误。
write() 返回通常只说明内核已经接受了这些数据。
错误二:数据交给网卡后,TCP 就可以立即删除发送数据
错误。
在收到 ACK 之前,TCP 通常仍需要保留重传所需的数据或相关状态。
错误三:网卡通过 DMA 直接把数据写进 cfd 的读缓冲区
错误。
网卡只会 DMA 到驱动准备的接收内存。
随后必须经过:
text
驱动
→ NAPI
→ 链路层
→ IP 层
→ TCP 层
→ 连接查找
→ 序列号处理
→ 套接字接收队列
错误四:内核根据四元组找到用户空间的 cfd
错误。
内核根据连接信息找到的是内核中的 TCP Socket 对象。
cfd 只是某个进程引用该对象的文件描述符编号。
错误五:一次 write() 对应一次 read()
错误。
TCP 没有应用层消息边界。
错误六:lfd 的读缓冲区里保存 SYN 包
不准确。
监听套接字维护的是连接请求和已完成连接相关的数据结构,而不是供应用程序通过普通 read() 读取的 TCP 业务字节流。
错误七:每收到一个包都会产生一次完整硬中断处理
不准确。
现代 Linux 通常使用硬中断配合 NAPI 和软中断进行批量处理。
错误八:网卡 Ring Buffer 中直接保存完整数据包
不准确。
Ring 中主要是描述符。实际数据通常位于描述符指向的内存页或缓冲区中。
十九、最终结论
整个 TCP 数据流动可以归纳为以下几句话。
第一,write() 的本质是:
把用户空间的字节交给本机内核 TCP 协议栈管理,而不是直接把数据交给对端程序。
第二,网卡发送的本质是:
驱动通过描述符和 DMA 让网卡读取内存中的数据,再发送到物理网络。
第三,网卡接收的本质是:
网卡先把数据 DMA 到驱动准备的通用接收内存,再由内核协议栈逐层解析和分发。
第四,TCP 分发的本质是:
协议栈根据连接标识找到对应的内核 TCP Socket,而不是直接找到用户空间文件描述符。
第五,read() 的本质是:
把已经由 TCP 按序接收的数据,从内核套接字接收队列复制到用户空间。
第六,accept() 的本质是:
从监听套接字的已完成连接队列中取得一条已经建立的连接,并为当前进程创建一个新的文件描述符。
第七,TCP 字节流的本质是:
TCP 只保证字节可靠、有序地到达,不保留应用程序每次
write()的边界。
最终完整路径是:
text
write()
→ TCP 发送管理
→ IP
→ 链路层
→ qdisc
→ 网卡驱动
→ TX Ring
→ DMA
→ 网络
→ 服务端网卡
→ RX Ring
→ DMA
→ NAPI
→ IP
→ TCP
→ Socket 接收队列
→ read()
理解了这条路径之后,就能真正解释:
- 为什么
write()成功不代表对方已经处理 - 为什么发送数据还需要等待 ACK
- 为什么
read()可能只返回部分数据 - 为什么会出现所谓的"粘包"和"拆包"
- 为什么
accept()与read()完全不是同一类操作 - 为什么网卡不能直接找到用户程序中的
cfd - 为什么高性能网络程序需要
epoll、非阻塞 I/O 和状态机