目录
一、背景
二、交互流程
2.1 数据流动
2.2 对象之间的关系
三、TCP
3.1 为什么需要三次握手
3.2 三次握手流程
3.3 三次握手后的产物
3.4 TCB
四、Socket
4.1 Java Socket和C++ Socket
4.2 Socket的本质
4.3 Socket和TCB的关系
4.4 通过文件描述符调用Socket的流程
五、Epoll
5.1 Epoll 结构
5.2 Epoll简要工作流程
5.3 Epoll代码
5.4 epoll_ctl过程
5.5 epoll_wait过程
5.6 水平触发(LT)与边缘触发(ET)
5.7 与Java NIO关系
一、背景
网络传输无处不在,正确理解网络传输的步骤有助于我们写出高性能的程序,也有助于我们解决程序中出现的问题。
二、交互流程
下面的不理解可以跳过 二、交互流程
2.1 数据流动
网卡 → DMA缓冲区 → 协议栈处理(IP/TCP) → TCB接收缓冲区 → recv() → 用户空间缓冲区
应用程序 → send() → 用户空间缓冲区 → 内核发送缓冲区 → 协议栈处理 → 网卡队列 → 网络
2.2 对象之间的关系
epoll
→fd
→ struct file
→ struct socket
→ struct sock
(TCB 的核心数据结构)
三、TCP
老生常谈的东西了,基本就是三次握手,但这次我会从操作系统角度,谈谈还干了什么,还包括三次握手的生成对象TCB
3.1 为什么需要三次握手
-
第一次握手(
SYN
):服务端确认客户端的发送能力正常。 -
第二次握手(
SYN-ACK
):客户端确认服务端的接收和发送能力正常。 -
第三次握手(
ACK
):服务端确认客户端的接收能力正常。 -
只有三次握手后,双方才能确保彼此能正常收发数据。
TCP三次握手发生在网络协议的传输层
3.2 三次握手流程
-
客户端发起连接
-
用户调用
connect()
,内核发送SYN报文(设置初始序列号ISN
)。 -
创建TCB(传输控制块):内核为连接分配资源(如
struct tcp_sock
),初始化序列号(ISN)、窗口大小等参数,TCB状态变为SYN_SENT
-
-
服务端响应
-
DMA写入内存:网卡通过DMA直接将报文数据(包括TCP头、IP头、以太网帧等)写入内核预分配的接收缓冲区(如
sk_buff
结构)。 -
触发软中断:随后网卡触发软中断(如Linux中的
NET_RX_SOFTIRQ
),通知内核有新的数据包需要处理。 -
创建半连接:
内核协议栈解析SYN包,创建传输控制块(TCB),初始化连接状态(如序列号、窗口大小),并将连接状态设为SYN_RCVD
(半开连接)。 -
加入半连接队列(SYN Queue):
该连接(TCB)被存入半连接队列,等待客户端确认。 -
构造SYN-ACK包:
内核生成SYN-ACK响应(设置SYN和ACK标志,分配服务器初始序列号,确认号为客户端的序列号+1)。 -
DMA发送数据:
报文通过内核协议栈封装(TCP头→IP头→MAC头),存入网卡发送缓冲区,网卡通过DMA读取并发送。、 -
启动重传定时器:
为防止丢包,内核启动定时器(默认约1秒),若未收到客户端的ACK,将重传SYN-ACK
-
-
最终确认
-
客户端收到SYN-ACK后,状态变为
ESTABLISHED
,发送ACK。 -
服务端收到ACK后,做3-6步骤的操作
- DMA与软中断:客户端的ACK包由网卡通过DMA写入内存,再次触发
NET_RX_SOFTIRQ
软中断。 - 验证ACK:内核检查ACK的合法性(确认号是否为服务器序列号+1)。
- 连接状态迁移:若验证通过,连接状态转为
ESTABLISHED
,并从半连接队列移至全连接队列(Accept Queue)。 - 通知应用层:应用通过
accept()
系统调用从全连接队列中获取新连接,开始数据传输。
-
3.3 三次握手后的产物
主要是TCB和全连接队列
全连接队列:存放已建立连接但未被accept()
取出的TCB
3.4 TCB
1. 存储连接状态信息
-
连接状态:记录 TCP 状态机的当前阶段(如
ESTABLISHED
、TIME_WAIT
、SYN_RECEIVED
等)。 -
端点信息:保存本地和远端的 IP 地址及端口号,唯一标识一个 TCP 连接。
-
序列号和确认号:维护发送和接收数据的序列号(
SEQ
)和确认号(ACK
),保证数据有序性和可靠性。
2. 完成数据传输
-
每个 TCP 连接由 四元组 唯一标识:
<本地 IP, 本地端口, 远端 IP, 远端端口>
只要四元组中任意一个元素不同,内核就会视为 不同的 TCP 连接,并为其分配独立的 TCB(传输控制块)。当应用程序通过socket fd执行
read()/write()
时,内核会通过关联的TCB完成实际的数据传输
3. 缓冲区和数据管理
-
发送缓冲区:暂存应用层待发送的数据,直到收到对方的确认。
-
接收缓冲区:存储已接收但尚未被应用层读取的数据。
4. 流量控制与窗口管理
-
滑动窗口:记录接收方的可用缓冲区大小(窗口大小),控制发送速率以避免接收方溢出。
-
发送和接收窗口:跟踪当前允许发送的数据范围和已确认的数据范围。
5. 连接生命周期管理
-
三次握手:跟踪
SYN
、SYN-ACK
、ACK
的交换过程,完成连接建立。 -
四次挥手:管理
FIN
包的交换,确保连接正常关闭或终止。
四、Socket
4.1 Java Socket和C++ Socket
Java Socket 和 C++ Socket 在 Linux 上的本质是相同的,它们的底层实现均基于 Linux 内核提供的同一套 Socket 接口。无论是 Java 的 java.net.Socket
还是 C++ 的 sys/socket.h
,最终都会通过系统调用(如 socket()
, bind()
, connect()
等)与内核交互。区别仅在于语言层面的封装和 API 设计。
既然是一样的,我们后面主要是在操作系统层级进行分析。
4.2 Socket的本质
Socket是文件描述符的一种类型。文件描述符可以表示多种资源(文件、管道、Socket等),Socket是其中用于网络通信的一种。
通过文件描述符操作Socket:Socket的读写、关闭等操作均可通过其关联的文件描述符完成:
使用通用I/O函数:如 read()
、write()
、close()
。
使用Socket专用函数:如 send()
、recv()
、bind()
、connect()
等,这些函数需要文件描述符作为参数。
4.3 Socket和TCB的关系
一一对应关系
-
每个 TCP Socket 对应一个 TCB:
-
当应用调用
socket()
创建一个 TCP Socket 后,内核会为这个 Socket 分配一个 TCB。 -
TCB 的生命周期与 Socket 绑定:Socket 被创建时 TCB 初始化,Socket 关闭时 TCB 释放。
-
-
Socket 是 TCB 的“用户态句柄”:
-
应用通过 Socket 文件描述符操作连接(如发送数据、接收数据、关闭连接),内核根据 Socket 找到对应的 TCB,修改其状态或触发协议行为(如重传、流量控制)。
-
-
发送数据:
-
应用通过
send()
写入 Socket 的数据会暂存到 TCB 的发送缓冲区,TCP 协议根据 TCB 中的窗口和拥塞控制参数决定何时发送。
-
-
接收数据:
-
内核将收到的数据存入 TCB 的接收缓冲区,应用通过
recv()
从 Socket 读取时,数据从接收缓冲区复制到用户空间。
-
每个 Socket 文件描述符(fd
)在内核中关联到一个 struct socket
结构,该结构指向对应的 struct sock
(即 TCB 的核心数据结构)。
关系链:
fd
→ struct file
→ struct socket
→ struct sock
(含接收缓冲区)。
4.4 通过文件描述符调用Socket的流程
对象级调用流程:
用户调用 read(sockfd, buf, len)↓ 通过 sockfd 找到进程文件描述符表中的 struct file↓ struct file 的 f_op->read() 调用 Socket 的具体实现(如 sock_read())↓ 内核通过 struct file 的 private_data 找到 struct socket↓ 最终操作 struct sock 的接收缓冲区(sk_receive_queue)读取数据
接收流程:
-
数据到达内核:
数据包经过网卡接收、协议栈解析(IP/TCP 层处理)后,最终存入对应 TCP 连接的 TCB 接收缓冲区。该缓冲区位于内核空间,由内核管理。 -
应用程序调用
recv()
:
当应用程序调用recv(fd, buf, len, flags)
时:-
fd
(文件描述符):关联到特定的 Socket,而该 Socket 绑定到唯一的 TCB。 -
内核操作:
内核从该 TCB 的接收缓冲区中,复制数据到用户提供的缓冲区buf
(位于用户空间)。 -
数据移除:
被成功复制的数据会从 TCB 的接收缓冲区中移除,释放空间,接收窗口(rwnd
)随之扩大。
-
-
返回结果:
-
若接收缓冲区中有数据,
recv()
立即返回实际读取的字节数。 -
若接收缓冲区为空:
-
阻塞模式:进程休眠,直到新数据到达或连接关闭。
-
非阻塞模式:立即返回错误码(如
EAGAIN
或EWOULDBLOCK
)。
-
-
Q:TCB接收缓冲区的作用是什么?
(1)数据暂存与排序
-
TCB 的接收缓冲区存储已通过 TCP 协议验证的、按序排列的数据。例如:
-
若数据包乱序到达,内核会在缓冲区中等待缺失的序列号填补后,再通知应用层读取。
-
若数据重复(如重传包),内核直接丢弃冗余数据。
-
(2)流量控制的基础
-
接收缓冲区的剩余空间决定了 TCP 的 接收窗口(
rwnd
)。此窗口通过 ACK 报文通告给发送方,控制其发送速率。 -
若缓冲区满,
rwnd=0
,发送方暂停发送,避免数据被丢弃。
(3)内核与用户空间的桥梁
-
数据从内核的 TCB 接收缓冲区到用户空间的
buf
,必须通过 拷贝(如copy_to_user()
)。
(注:零拷贝技术如splice()
或sendfile()
可绕过此步骤,但常规recv()
需要拷贝。)
发送流程:
1. 应用程序提交数据
-
send()
/write()
系统调用:
应用程序将数据写入用户空间缓冲区,调用send()
触发系统调用。 -
数据拷贝到内核:
数据从用户空间拷贝到 TCB 的发送缓冲区(内核空间)。
2. 发送缓冲区管理
-
分片与封装:
内核根据 MSS 将数据分片,添加 TCP/IP 头部,生成 TCP 段。 -
发送窗口约束:
仅允许发送窗口内的数据(已发送未确认数据 + 可发送未发送数据 ≤ 发送窗口大小)。
3. 协议栈处理
-
滑动窗口与序列号:
每个 TCP 段携带序列号(seq
),接收方据此确认数据顺序。 -
重传机制:
已发送但未确认的 TCP 段保留在发送缓冲区的重传队列中,超时(RTO)或收到重复 ACK 时触发重传。
4. 网络层与网卡发送
-
IP 层处理:
添加 IP 头部,路由选择,分片(若超过 MTU)。 -
网卡队列:
TCP 段交给网卡驱动,存入发送队列(如tx_ring
),通过 DMA 发送到网络。
五、Epoll
经过上面的介绍,我们对文件描述符、Socket、TCP有相应的了解,也明白了文件描述符是如何操作网络连接的。
那假如有大量的网络连接时,我们该如何管理呢?
目前主流的方法是I/O 多路复用,即单线程/少量线程监听多个socket事件
Epoll是Linux下的一种I/O多路复用机制,用于高效地处理大量文件描述符
5.1 Epoll 结构
epoll
的实现依赖于两个核心数据结构:红黑树(Red-Black Tree) 和 就绪队列(Ready List)。它们共同协作,使得 epoll
能够高效地管理海量文件描述符(FD)的 I/O 事件。
红黑树是 epoll
实例中用于 存储所有被监控的文件描述符(FD) 的数据结构。每个通过 epoll_ctl
添加的 FD(如套接字、管道等)都会在红黑树中注册为一个节点。
设计原因
-
高效动态操作:红黑树是一种自平衡二叉搜索树,插入、删除、查找的时间复杂度均为
O(log N)
,适合频繁增删 FD 的场景(例如 Web 服务器处理大量短连接)。 -
快速定位 FD:当某个 FD 发生事件(如可读、可写)时,内核需要快速找到该 FD 的监控信息(例如用户关注的事件类型),红黑树的特性确保了这一过程的效率。
-
避免重复注册:红黑树的唯一性保证同一个 FD 不会被重复添加,避免资源浪费。
就绪队列是 epoll
实例中用于 临时存储已触发事件的 FD 的链表结构。当某个被监控的 FD 发生事件(例如套接字收到数据),内核会将该 FD 添加到就绪队列中。
设计原因
-
快速事件通知:用户调用
epoll_wait
时,内核无需遍历所有被监控的 FD,而是直接检查就绪队列,时间复杂度接近O(1)
。 -
事件去重与合并:如果同一 FD 的多个事件连续触发(如多次可读),就绪队列会合并这些事件,避免重复通知。
-
支持边缘触发(Edge-Triggered, ET)模式:在 ET 模式下,事件仅在状态变化时触发一次,就绪队列确保事件不会被遗漏。
工作流程
-
事件触发:当某个 FD 发生事件(如数据到达),内核调用与该 FD 关联的回调函数。
-
加入就绪队列:回调函数将 FD 插入就绪队列,并标记触发的事件类型。
-
用户获取事件:用户调用
epoll_wait
时,内核将就绪队列中的事件拷贝到用户空间,并清空队列(取决于触发模式)。
5.2 Epoll简要工作流程
-
创建epoll实例
-
使用
epoll_create1()
创建epoll文件描述符。
-
-
创建并配置监听Socket
-
创建TCP Socket,设置为非阻塞模式,绑定地址并开始监听。
-
-
注册监听Socket到epoll
-
通过
epoll_ctl()
将监听Socket加入epoll监控,关注EPOLLIN
事件(新连接事件)。
-
-
事件循环
-
使用
epoll_wait()
阻塞等待事件发生。 -
遍历就绪事件列表,处理不同类型的事件:
-
新连接:接受连接,将新Socket加入epoll监控。
-
数据可读:读取数据并处理,必要时注册
EPOLLOUT
事件准备写入。 -
数据可写:发送数据,完成后取消
EPOLLOUT
监控。 -
错误/关闭:移除并关闭Socket。
-
-
-
清理资源
-
关闭所有Socket和epoll实例。
-
5.3 Epoll代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <fcntl.h>#define MAX_EVENTS 10
#define PORT 8080
#define BUFFER_SIZE 1024// 设置文件描述符为非阻塞模式
void set_nonblocking(int fd) {int flags = fcntl(fd, F_GETFL, 0);fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}int main() {int listen_fd, epoll_fd, nfds;struct epoll_event ev, events[MAX_EVENTS];struct sockaddr_in addr;// 1. 创建监听Socketlisten_fd = socket(AF_INET, SOCK_STREAM, 0);if (listen_fd == -1) {perror("socket");exit(EXIT_FAILURE);}// 绑定并监听memset(&addr, 0, sizeof(addr));addr.sin_family = AF_INET;addr.sin_addr.s_addr = htonl(INADDR_ANY);addr.sin_port = htons(PORT);if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) {perror("bind");close(listen_fd);exit(EXIT_FAILURE);}if (listen(listen_fd, SOMAXCONN)) {perror("listen");close(listen_fd);exit(EXIT_FAILURE);}set_nonblocking(listen_fd); // 非阻塞模式// 2. 创建epoll实例epoll_fd = epoll_create1(0);if (epoll_fd == -1) {perror("epoll_create1");close(listen_fd);exit(EXIT_FAILURE);}// 3. 注册监听Socket到epollev.events = EPOLLIN; // 关注可读事件ev.data.fd = listen_fd;if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev)) {perror("epoll_ctl: listen_fd");close(listen_fd);exit(EXIT_FAILURE);}printf("Server listening on port %d...\n", PORT);// 4. 事件循环while (1) {nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);if (nfds == -1) {perror("epoll_wait");break;}for (int i = 0; i < nfds; i++) {int fd = events[i].data.fd;// 处理新连接if (fd == listen_fd) {struct sockaddr_in client_addr;socklen_t addrlen = sizeof(client_addr);int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addrlen);if (client_fd == -1) {perror("accept");continue;}set_nonblocking(client_fd); // 新Socket设为非阻塞ev.events = EPOLLIN | EPOLLET; // 边缘触发模式(可选)ev.data.fd = client_fd;if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) == -1) {perror("epoll_ctl: client_fd");close(client_fd);}printf("New connection: fd %d\n", client_fd);// 处理数据可读} else if (events[i].events & EPOLLIN) {char buffer[BUFFER_SIZE];ssize_t nread = recv(fd, buffer, BUFFER_SIZE, 0);if (nread > 0) {printf("Received from fd %d: %.*s\n", fd, (int)nread, buffer);// 回显数据(示例)send(fd, buffer, nread, 0);} else if (nread == 0 || (nread == -1 && errno != EAGAIN)) {// 关闭连接printf("Closing fd %d\n", fd);epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);close(fd);}}// 处理其他事件(如EPOLLOUT、EPOLLERR等)}}// 清理资源close(listen_fd);close(epoll_fd);return 0;
}
5.4 epoll_ctl过程
当通过 epoll_ctl(EPOLL_CTL_ADD)
将一个fd(如Socket)添加到epoll实例时,内核会执行以下操作:
(1) 注册回调函数到fd的等待队列
-
为fd创建epoll条目(
epitem
):包含fd的信息和关注的事件(如EPOLLIN
)。 -
将回调函数
ep_poll_callback
注册到fd的等待队列:-
调用fd的
poll
方法(如Socket的sock_poll
)。 -
poll
方法将ep_poll_callback
添加到fd的等待队列中。
-
(2) 事件触发时的回调流程
-
数据到达触发硬件中断:网卡接收数据后,内核协议栈处理数据并标记Socket为可读。
-
唤醒等待队列:
-
内核调用Socket等待队列中的回调函数
ep_poll_callback
。 -
ep_poll_callback
将对应的fd(封装为epitem
)加入epoll的就绪队列(rdllist
)。
-
-
通知用户程序:
-
如果用户程序阻塞在
epoll_wait
,内核唤醒该线程,使其从epoll_wait
返回并处理就绪事件。
-
5.5 epoll_wait过程
epoll_wait
是 epoll
机制的核心函数,它负责 等待并获取已就绪的事件。
步骤 1:进入内核态
-
用户程序调用
epoll_wait
时,会从用户态切换到内核态。 -
内核访问
epoll
实例的数据结构(包括 红黑树 和 就绪队列)。
步骤 2:检查就绪队列
-
epoll
实例维护一个 就绪队列(ready list),其中保存所有已触发事件的fd。 -
如果就绪队列非空,内核直接从中取出事件,填充到用户空间的
events
数组。 -
如果队列为空,且
timeout=-1
,线程阻塞在此处,直到有新事件到来或信号中断。
步骤 3:监控fd状态(若就绪队列为空)
-
若就绪队列为空,内核通过 回调机制 监控所有注册的fd:
-
当某个fd发生事件(如socket接收数据),内核会将该fd添加到就绪队列。
-
这一过程由内核的 事件驱动机制 实现,无需轮询所有fd,效率极高。
-
步骤 4:返回就绪事件
-
将就绪队列中的事件复制到用户空间的
events
数组。 -
返回就绪事件的数量
nfds
,用户程序通过遍历events[0..nfds-1]
处理事件。 -
如果此时有线程阻塞在
epoll_wait
,内核会唤醒该线程
5.6 水平触发(LT)与边缘触发(ET)
行为 | 水平触发(LT) | 边缘触发(ET) |
---|---|---|
触发时机 | 只要缓冲区有数据/可写,持续触发 | 仅在缓冲区状态变化时触发一次(如新数据到达) |
数据未读尽的后果 | 下次 epoll_wait 继续报告事件 | 不再触发事件,可能导致数据滞留 |
编程复杂度 | 较低(无需一次性处理所有数据) | 较高(需循环读写至 EAGAIN ) |
适用场景 | 简单场景、小数据量 | 高性能场景、需精细化控制 |
5.7 与Java NIO关系
Java NIO 在不同的操作系统和 JDK 版本中确实会使用不同的底层实现,其中在 Linux 系统上,Java NIO 的 Selector
(多路复用机制)默认是基于 epoll
实现的