网络与 IO 模型
Linux 网络子系统基于 TCP/IP 四层模型构建,应用程序通过 Socket API 与内核协议栈交互。理解 5 种 I/O 模型(尤其是 epoll 的 ET/LT 模式)和零拷贝技术,是构建高并发 Java/Go 服务的理论基础。
目录
| 章节 | 说明 |
|---|---|
| Linux 网络协议栈 | TCP/IP 四层模型与内核实现 |
| Socket 编程模型 | 服务端/客户端基本流程 |
| 5 种 I/O 模型 | 阻塞/非阻塞/多路复用/信号驱动/异步 |
| select/poll/epoll 对比 | 演进历史与核心差异 |
| 零拷贝实现 | sendfile/mmap+write/JMH 实测数据 |
| C10K 问题 | 高并发连接的解决方案 |
Linux 网络协议栈
TCP/IP vs OSI 模型
| TCP/IP 层 | OSI 层 | 协议 | Linux 实现 |
|---|---|---|---|
| 应用层 | 应用/表示/会话层 | HTTP、DNS、gRPC | 用户空间 |
| 传输层 | 传输层 | TCP、UDP | 内核 net/ipv4/tcp.c |
| 网络层 | 网络层 | IP、ICMP、ARP | 内核 net/ipv4/ip_input.c |
| 网络接口层 | 数据链路/物理层 | Ethernet、Wi-Fi | 网卡驱动 |
网络包接收流程
物理网卡收到数据帧
↓
DMA 写入内核 Ring Buffer
↓
网卡触发硬中断
↓
中断处理程序分配 sk_buff,触发软中断(NET_RX_SOFTIRQ)
↓
ksoftirqd 内核线程处理软中断
↓
网络协议栈逐层处理:
链路层 → 网络层(IP)→ 传输层(TCP/UDP)
↓
数据写入 Socket 接收缓冲区
↓
应用程序 read() / epoll 通知
网络包发送流程
应用程序 write() / sendmsg()
↓
写入 Socket 发送缓冲区
↓
TCP 层:添加 TCP 头、分段
↓
IP 层:添加 IP 头、路由查找
↓
网络接口层:ARP 解析 MAC、添加帧头
↓
放入发包队列,软中断通知驱动
↓
驱动通过 DMA 从队列读取,发送到网卡
# 查看网络协议栈统计
netstat -s
ss -s
# 查看网卡收发包统计
ip -s link show eth0
ethtool -S eth0
Socket 编程模型
服务端流程
socket() 创建套接字(fd)
↓
bind() 绑定 IP:Port
↓
listen() 开始监听,建立 backlog 队列
↓
accept() 从完成三次握手的队列取出连接(阻塞等待)
↓
read()/write() 数据收发
↓
close() 关闭连接(四次挥手)
TCP 三次握手与队列
客户端 服务端
│ │
│──── SYN ───────────────→│ SYN_SENT → 放入半连接队列(SYN Queue)
│ │
│←─── SYN+ACK ────────────│ SYN_RCVD
│ │
│──── ACK ───────────────→│ ESTABLISHED → 移入全连接队列(Accept Queue)
│ │
│ │ accept() 从全连接队列取出
# 查看半连接/全连接队列溢出
netstat -s | grep -i "listen\|overflow\|drop"
ss -lnt # 查看 Recv-Q(全连接队列积压数)和 Send-Q(全连接队列上限)
Java 中设置 backlog:
// ServerSocket 的 backlog 参数对应全连接队列大小
ServerSocket ss = new ServerSocket(8080, 1024);
// Netty
ServerBootstrap b = new ServerBootstrap();
b.option(ChannelOption.SO_BACKLOG, 1024);
5 种 I/O 模型
以"从 Socket 读取数据"为例,数据需经历:等待数据到达内核缓冲区 + 将数据从内核复制到用户空间。
1. 阻塞 I/O(Blocking I/O)
应用程序 内核
│──── read() ──→│
│ 阻塞等待 │ 等待数据到达
│ │ 数据到达,复制到用户空间
│←─── 返回 ─────│
特点:简单,但一个线程只能处理一个连接。
Java 对应:java.io.InputStream.read()(BIO)
2. 非阻塞 I/O(Non-blocking I/O)
应用程序 内核
│──── read() ──→│ 无数据 → EAGAIN
│←─── 返回 ─────│
│ 轮询... │
│──── read() ──→│ 数据到达 → 复制 → 返回
│←─── 数据 ─────│
特点:不阻塞,但需要忙等(CPU 空转),通常配合 I/O 多路复用使用。
3. I/O 多路复用(I/O Multiplexing)
一个线程监听多个 fd,有 fd 就绪时再调用 read/write。
应用程序 内核
│──select/epoll→│ 监听多个 fd
│ 阻塞等待 │
│ │ 某 fd 数据到达
│←─── 返回 ─────│ 告知哪些 fd 就绪
│──── read() ──→│ 读取就绪 fd
│←─── 数据 ─────│
特点:单线程处理大量连接,是高并发服务器的核心技术。
Java 对应:java.nio.Selector(NIO),Netty 的 EventLoop
4. 信号驱动 I/O(Signal-driven I/O)
注册 SIGIO 信号处理函数,数据就绪时内核发送信号。实际使用较少。
5. 异步 I/O(Asynchronous I/O / AIO)
应用程序 内核
│── aio_read() →│ 立即返回
│ 继续执行其他 │ 异步处理
│ │ 数据准备好,复制完成
│←─── 信号/回调─│
特点:真正的异步,应用程序不参与等待和复制。Linux AIO 实现有限,io_uring(Linux 5.1+)是更好的选择。
Java 对应:java.nio.channels.AsynchronousSocketChannel(AIO)
5 种模型对比
| 模型 | 等待数据 | 数据复制 | 适用场景 |
|---|---|---|---|
| 阻塞 I/O | 阻塞 | 阻塞 | 简单服务,连接数少 |
| 非阻塞 I/O | 轮询 | 阻塞 | 配合多路复用使用 |
| I/O 多路复用 | 阻塞(select/epoll) | 阻塞 | 高并发服务器主流方案 |
| 信号驱动 | 不阻塞 | 阻塞 | 较少使用 |
| 异步 I/O | 不阻塞 | 不阻塞 | io_uring、Windows IOCP |
select/poll/epoll 对比
select
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);
select(maxfd+1, &readfds, NULL, NULL, &timeout);
// 遍历所有 fd 检查是否就绪
缺点:
- fd 数量限制(默认 1024,
FD_SETSIZE) - 每次调用需要将 fd_set 从用户空间复制到内核
- 返回后需要 O(n) 遍历所有 fd 找就绪的
poll
改用链表,解除了 fd 数量限制,但仍有 O(n) 遍历问题。
epoll(Linux 2.6+)
int epfd = epoll_create1(0);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 注册
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epfd, events, MAX_EVENTS, -1); // 等待
// events 数组直接包含就绪的 fd,O(1) 处理
epoll 的优势:
- 无 fd 数量限制(受系统文件描述符上限约束)
- 内核维护就绪列表,
epoll_wait只返回就绪的 fd(O(1)) - 通过
mmap在内核和用户空间共享内存,减少数据复制
ET vs LT 模式
| 模式 | 全称 | 触发条件 | 特点 |
|---|---|---|---|
| LT(默认) | Level Triggered(水平触发) | 只要 fd 可读/可写就通知 | 简单,可多次读取,不会丢数据 |
| ET | Edge Triggered(边缘触发) | fd 状态变化时才通知(从不可读变可读) | 高效,必须一次性读完(否则不再通知),需配合非阻塞 I/O |
// ET 模式下必须循环读直到 EAGAIN
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
// 读取时
while (true) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) break; // 读完了
if (n <= 0) break; // 错误或连接关闭
// 处理数据
}
Netty 使用 epoll ET 模式:
// Netty 在 Linux 上默认使用 NioEventLoop(Java NIO Selector)
// 可切换为 EpollEventLoop 获得更好性能
EventLoopGroup group = new EpollEventLoopGroup();
ServerBootstrap b = new ServerBootstrap()
.channel(EpollServerSocketChannel.class);
select/poll/epoll 对比总结
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 1024 | 无限制 | 无限制 |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 内核/用户拷贝 | 每次全量 | 每次全量 | 仅注册时 |
| 就绪通知 | 遍历 | 遍历 | 就绪列表直接返回 |
| 适用场景 | 跨平台(含 macOS/Windows) | 跨平台 | Linux 高并发 |
macOS/BSD 的对应是 kqueue,Windows 是 IOCP。
零拷贝实现
传统文件发送(4 次拷贝、4 次上下文切换):
磁盘 → DMA → 内核 Page Cache
内核 Page Cache → CPU 复制 → 用户空间缓冲区 (read 系统调用)
用户空间缓冲区 → CPU 复制 → Socket 发送缓冲区 (write 系统调用)
Socket 发送缓冲区 → DMA → 网卡
sendfile(2 次拷贝)
sendfile(out_fd, in_fd, &offset, count);
// 内核直接将 Page Cache 中的数据发送到 Socket,无需经过用户空间
磁盘 → DMA → 内核 Page Cache
内核 Page Cache → DMA → 网卡(通过 scatter-gather,仅复制文件描述符)
Java 对应:FileChannel.transferTo() 底层调用 sendfile
FileChannel fileChannel = FileChannel.open(path);
SocketChannel socketChannel = ...;
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
Go 对应:io.Copy() 在 Linux 上会自动使用 sendfile
mmap + write(3 次拷贝)
void* buf = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);
write(socket_fd, buf, size);
将文件映射到用户虚拟地址空间,write 时内核直接从 Page Cache 复制到 Socket 缓冲区。
适用场景:需要在用户空间修改数据后再发送时使用 mmap+write;纯转发使用 sendfile。
JVM 内部完整数据路径(含隐藏的临时堆外缓冲区)
JMH 实测数据(Apple M3 Pro,OpenJDK 22,文件→Socket)
以下数据来自 JMH 基准测试,测试场景:本机 TCP loopback,预分配 buffer,复用连接,排除分配和连接建立开销,只测数据传输本身。
| 方案 | 用户空间拷贝次数 | 64KB (ops/ms) | 1MB (ops/ms) |
|---|---|---|---|
| HeapBuffer + FileChannel | 2次(Heap→临时堆外→Socket) | 30.841 | 5.619 |
| DirectBuffer + FileChannel | 1次(堆外→Socket) | 33.857 | 7.552 |
transferTo(socketChannel) |
0次(sendfile,内核内直传) | 37.157 | 9.961 |
关键结论:
- 1MB 时:
transferTo比 HeapBuffer 快 77%,DirectBuffer 比 HeapBuffer 快 34% - 64KB 时:三者差距缩小,
transferTo仍领先约 20% - 数据量越大,减少的拷贝次数越值钱
一个反直觉的对比——文件→文件 vs 文件→Socket:
| 场景 | 1MB (ops/ms) | 说明 |
|---|---|---|
transferTo(fileChannel) 文件→文件 |
2.844 | 需先读后写,比 HeapBuffer 还慢 8 倍 |
transferTo(socketChannel) 文件→Socket |
9.961 | sendfile 语义,绕过用户空间 |
transferTo的零拷贝语义只在目标是 SocketChannel 时才成立。 文件→文件的transferTo在内核里仍需读+写两步,比直接 write 更慢。Kafka 消息发送之所以快,正是因为用的是文件→Socket 的sendfile,而非文件→文件复制。
C10K 问题
C10K(Concurrent 10K connections):单机处理 1 万并发连接。
传统方案的瓶颈:
- 每连接一线程:1 万线程的内存(每线程默认 8MB 栈)= 80GB,不可行
- select/poll:O(n) 遍历,1 万连接时性能急剧下降
解决方案演进:
| 阶段 | 方案 | 代表 |
|---|---|---|
| C10K | epoll + 非阻塞 I/O + 事件循环 | Nginx、Node.js |
| C100K | 多进程/多线程 + epoll | Nginx worker |
| C1000K | DPDK 绕过内核网络栈 | 高性能网络设备 |
Java 高并发方案:
BIO(一连接一线程)→ NIO(Selector + 非阻塞)→ Netty(epoll + 事件驱动)
↓
Virtual Thread(JDK 21)
(用协程模拟同步写法,底层仍是 NIO)
Go 高并发方案:
goroutine + netpoller(基于 epoll/kqueue)
每个 goroutine 用同步写法,运行时自动调度到 epoll 事件驱动
内核网络优化
TCP 连接建立调优
三次握手过程中,内核维护两个队列:半连接队列(SYN Queue) 和 全连接队列(Accept Queue)。队列满了会导致新连接被丢弃。
关键参数:
| 参数 | 默认值 | 建议值 | 说明 |
|---|---|---|---|
net.ipv4.tcp_max_syn_backlog |
512 | 16384 | 半连接队列长度,防止 SYN 丢包 |
net.core.somaxconn |
128(内核<5.4)/ 4096(>=5.4) | 16384 | 全连接队列上限 |
net.ipv4.tcp_syncookies |
1 | 1 | 开启 SYN Cookie,防 SYN Flood 攻击 |
net.ipv4.tcp_syn_retries |
6 | 2 | 数据中心内网建议调小,减少超时等待 |
net.ipv4.tcp_synack_retries |
5 | 2 | SYNACK 重传次数 |
# 查看全连接队列积压和溢出
ss -lnt # Recv-Q 为积压数,Send-Q 为队列上限
netstat -s | grep -i "listen\|overflow\|drop"
# 应用参数(立即生效)
sysctl -w net.core.somaxconn=16384
sysctl -w net.ipv4.tcp_max_syn_backlog=16384
# 持久化写入 /etc/sysctl.conf
TCP 连接断开调优
| 参数 | 默认值 | 建议值 | 说明 |
|---|---|---|---|
net.ipv4.tcp_fin_timeout |
60s | 2s | FIN_WAIT_2 超时时间,数据中心可调小 |
net.ipv4.tcp_max_tw_buckets |
32768 | 10000 | TIME_WAIT 最大数量,超出直接销毁 |
net.ipv4.tcp_tw_reuse |
0 | 1 | 复用 TIME_WAIT 连接,防端口耗尽 |
tcp_tw_recycle已在新内核中删除,不要使用,NAT 环境下会导致丢包。
# 查看 TIME_WAIT 连接数
ss -s | grep TIME-WAIT
netstat -an | awk '/^tcp/{print $6}' | sort | uniq -c | sort -rn
TCP 收发缓冲区调优
缓冲区太小会导致发包阻塞或接收丢包;太大则浪费内存。
# 发送缓冲区(min / default / max,单位 Byte)
net.ipv4.tcp_wmem = 8192 65536 16777216
net.core.wmem_max = 16777216 # 单连接发送缓冲区上限
# 接收缓冲区(min / default / max,单位 Byte)
net.ipv4.tcp_rmem = 8192 87380 16777216
net.core.rmem_max = 16777216 # 单连接接收缓冲区上限
net.ipv4.tcp_moderate_rcvbuf = 1 # 开启接收缓冲区自动调节
# 所有 TCP 连接总内存(单位:页,4KB/页)
net.ipv4.tcp_mem = 8388608 12582912 16777216
诊断缓冲区不足:
# 检查发送缓冲区溢出事件(需要 systemtap)
# probe kernel.function("sk_stream_wait_memory") { printf("%d %s overflow\n", pid(), execname()) }
# 检查接收缓冲区溢出(内核 5.9+ 支持)
netstat -s | grep -i "pruned\|collapsed\|failed"
# 观察 TCP 内存使用
cat /proc/net/sockstat | grep TCP
网络吞吐优化
# 端口范围(客户端发起连接时的本地端口)
net.ipv4.ip_local_port_range = 1024 65535
# NAPI 批量收包数量(默认 300,高吞吐场景可调大)
net.core.netdev_budget = 600
# 网卡发送队列长度(txqueuelen 满时会丢包)
ip link set eth0 txqueuelen 2000
# 或
ifconfig eth0 txqueuelen 2000
# 检查发送队列丢包
ip -s -s link show eth0 | grep -A2 "TX:"
# 若 dropped 不为 0,说明 txqueuelen 不够
# 开启 TCP BBR 拥塞控制(需 Linux 4.9+)
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
常见网络问题速查
| 现象 | 排查方向 | 命令 |
|---|---|---|
| 建连失败/超时 | 半连接/全连接队列溢出 | netstat -s | grep overflow |
| 大量 TIME_WAIT | 端口复用未开启 | sysctl net.ipv4.tcp_tw_reuse |
| 大量 CLOSE_WAIT | 应用程序未调用 close() | 检查应用代码 |
| 发包阻塞 | 发送缓冲区太小 | netstat -s | grep pruned |
| 高并发下连接被 reset | 全连接队列满 | ss -lnt,somaxconn |
参考资料
- 《趣谈 Linux 操作系统》— 43-48 网络系列(刘超,极客时间)
- 《Linux 内核技术实战课》— 11-12 TCP 配置调优(邵亚方,极客时间)
- 《Linux 性能优化实战》— 33-45 网络模块(倪朋飞,极客时间)
- 《UNIX 网络编程》卷 1 — W. Richard Stevens
- 从 C10K 到协程(epoll/reactor 是异步回调时代的底层,协程是其上层的抽象)
评论 (0)