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