专栏文章
专栏文章
Linux 专栏
1. Linux 专栏 #01:进程与线程 2. Linux 专栏 #02:内存管理 3. Linux 专栏 #03:文件系统 4. Linux 专栏 #04:网络与 IO 模型 5. Linux 专栏 #05:中断机制 6. Linux 专栏 #05:性能分析工具 7. Linux 专栏 #06:eBPF 技术实战

Linux 专栏 #04:网络与 IO 模型

发布于 2026-06-08 07:30 👁 10 次阅读
#操作系统#linux#network

网络与 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 检查是否就绪

缺点

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 的优势

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


零拷贝实现

zero copy arch

传统文件发送(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 内部完整数据路径(含隐藏的临时堆外缓冲区)

jvm copy paths

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

关键结论:

一个反直觉的对比——文件→文件 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 万并发连接。

传统方案的瓶颈

解决方案演进

阶段 方案 代表
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)。队列满了会导致新连接被丢弃。

tcp 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 -lntsomaxconn

参考资料

  • 《趣谈 Linux 操作系统》— 43-48 网络系列(刘超,极客时间)
  • 《Linux 内核技术实战课》— 11-12 TCP 配置调优(邵亚方,极客时间)
  • 《Linux 性能优化实战》— 33-45 网络模块(倪朋飞,极客时间)
  • 《UNIX 网络编程》卷 1 — W. Richard Stevens
  • 从 C10K 到协程(epoll/reactor 是异步回调时代的底层,协程是其上层的抽象)
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论