目录
正在加载目录…
专栏文章
专栏文章
Linux 专栏
1. 进程与线程 2. 内存管理 3. 文件系统 4. 网络与 IO 模型 5. 中断机制 6. 性能分析工具 7. eBPF 技术实战

网络与 IO 模型

发布于 2026-06-08 07:30 · 最后编辑于 2026-07-31 15:52 · 字数 3,878 👁 92 次阅读

网络与 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 可读/可写就通知简单,可多次读取,不会丢数据
ETEdge 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 对比总结

维度selectpollepoll
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 + FileChannel2次(Heap→临时堆外→Socket)30.8415.619
DirectBuffer + FileChannel1次(堆外→Socket)33.8577.552
transferTo(socketChannel)0次(sendfile,内核内直传)37.1579.961

关键结论:

  • 1MB 时transferTo 比 HeapBuffer 快 77%,DirectBuffer 比 HeapBuffer 快 34%
  • 64KB 时:三者差距缩小,transferTo 仍领先约 20%
  • 数据量越大,减少的拷贝次数越值钱

一个反直觉的对比——文件→文件 vs 文件→Socket:

场景1MB (ops/ms)说明
transferTo(fileChannel) 文件→文件2.844需先读后写,比 HeapBuffer 还慢 8 倍
transferTo(socketChannel) 文件→Socket9.961sendfile 语义,绕过用户空间

transferTo 的零拷贝语义只在目标是 SocketChannel 时才成立。 文件→文件的 transferTo 在内核里仍需读+写两步,比直接 write 更慢。Kafka 消息发送之所以快,正是因为用的是文件→Socket 的 sendfile,而非文件→文件复制。

C10K 问题

C10K(Concurrent 10K connections):单机处理 1 万并发连接。

传统方案的瓶颈

  • 每连接一线程:1 万线程的内存(每线程默认 8MB 栈)= 80GB,不可行
  • select/poll:O(n) 遍历,1 万连接时性能急剧下降

解决方案演进

阶段方案代表
C10Kepoll + 非阻塞 I/O + 事件循环Nginx、Node.js
C100K多进程/多线程 + epollNginx worker
C1000KDPDK 绕过内核网络栈高性能网络设备

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_backlog51216384半连接队列长度,防止 SYN 丢包
net.core.somaxconn128(内核<5.4)/ 4096(>=5.4)16384全连接队列上限
net.ipv4.tcp_syncookies11开启 SYN Cookie,防 SYN Flood 攻击
net.ipv4.tcp_syn_retries62数据中心内网建议调小,减少超时等待
net.ipv4.tcp_synack_retries52SYNACK 重传次数
# 查看全连接队列积压和溢出
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_timeout60s2sFIN_WAIT_2 超时时间,数据中心可调小
net.ipv4.tcp_max_tw_buckets3276810000TIME_WAIT 最大数量,超出直接销毁
net.ipv4.tcp_tw_reuse01复用 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)

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