Skip to content

IO 模型与高性能网络

Linux 下的 IO 操作涉及两个阶段:

  1. 等待数据就绪(数据从网卡到内核缓冲区)
  2. 数据从内核复制到用户空间

五种模型在这两个阶段的处理方式不同:

用户进程 内核
│ │
│── read() ────────────────►│
│ 阻塞等待 │ 1.等待数据到达网络
│ 阻塞等待 │ 2.数据从内核复制到用户空间
│◄─────────────── 返回数据 ──│
│ 继续执行 │
特点:两个阶段都阻塞
代价:一个线程只能处理一个连接,高并发需大量线程(C10K 问题的根源)
用户进程 内核
│── read() ────────────────►│ 数据未就绪
│◄─────────── EAGAIN ───────│ 立即返回
│ (忙等待轮询) │
│── read() ────────────────►│ 数据未就绪
│◄─────────── EAGAIN ───────│
│── read() ────────────────►│ 数据就绪
│ 阻塞等待 │ 数据从内核复制到用户空间
│◄─────────── 返回数据 ──────│
特点:第一阶段不阻塞(立即返回),第二阶段阻塞
代价:需要不断轮询,CPU 空转严重;实际很少单独使用

IO 多路复用(IO Multiplexing)★★★

Section titled “IO 多路复用(IO Multiplexing)★★★”
用户进程 内核
│── select/poll/epoll ──────►│
│ 阻塞等待(监控多个fd) │ 等待任意一个fd就绪
│◄─────────── 有fd就绪 ──────│
│── read(就绪的fd) ──────────►│
│ 阻塞等待 │ 数据从内核复制到用户空间
│◄─────────── 返回数据 ──────│
特点:一个线程监控多个连接;两个阶段都可能阻塞,但第一阶段等待多个 fd
优势:用少量线程处理大量连接(C10K 的解决方案)

内核在数据就绪时发送 SIGIO 信号通知进程,进程注册信号处理函数后继续执行其他任务。实际使用较少。

用户进程 内核
│── aio_read() ─────────────►│
│ 立即返回,继续执行 │ 1.等待数据到达
│ │ 2.数据从内核复制到用户空间
│◄─────────── 信号通知 ───────│
│ 在回调中处理数据 │
特点:两个阶段都不阻塞(真正的异步)
Linux AIO 的局限:只支持 O_DIRECT(绕过 page cache),实际使用受限

这三个系统调用都实现了 IO 多路复用,但效率差异显著。

// select 监听最多 FD_SETSIZE(默认1024)个文件描述符
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
原理:
• 调用 select 时,将 fd_set(位图)从用户空间拷贝到内核
• 内核遍历所有 fd,检查是否就绪
• 返回后,用户态再次遍历所有 fd,找到就绪的那些
问题:
1. fd 数量上限 1024(FD_SETSIZE 限制)
2. 每次调用都需要将 fd_set 在用户/内核空间来回拷贝
3. 内核遍历是 O(n),fd 数量大时效率低
4. 返回后用户态还要再遍历一次才能找到就绪的 fd
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
改进:
• 用链表(pollfd 数组)替代位图,无 1024 的 fd 数量限制
遗留问题:
• 仍然是 O(n) 遍历
• 仍然需要用户/内核空间的数据拷贝
int epoll_create(int size); // 创建 epoll 实例,返回 epfd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 添加/修改/删除监听
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件
epoll 内部数据结构:
红黑树(rbr):存储所有被监听的 fd(epoll_ctl 增/删是 O(log n))
就绪链表(rdllist):就绪的 fd 挂在这里
epoll_wait 流程:
1. 检查就绪链表是否有 fd
2. 有 → 将就绪 fd 拷贝到用户空间 events 数组,返回就绪数量
3. 无 → 将进程加入等待队列,挂起
4. 网卡中断 → 数据到达 → 将对应 fd 加入就绪链表 → 唤醒等待进程
对比项 select/poll epoll
───────────────────────────────────────────────
fd 数量限制 select:1024/poll无限 无限制
事件注册方式 每次调用重新传入 epoll_ctl 只需注册一次
内核扫描方式 O(n) 遍历所有 fd O(1) 就绪链表直接获取
用户/内核拷贝 每次 O(n) 拷贝fd集合 只拷贝就绪的 fd
适用场景 少量连接 大量连接(C10K)

ET(边缘触发)vs LT(水平触发)

Section titled “ET(边缘触发)vs LT(水平触发)”
LT(Level-Triggered,默认):
• 只要 fd 处于就绪状态,每次 epoll_wait 都通知
• 没读完的数据,下次 epoll_wait 还会触发
• 更安全,不容易漏事件
ET(Edge-Triggered):
• 只在 fd 状态发生变化时(从未就绪变为就绪)通知一次
• 如果没有读完所有数据,不会再次通知 → 可能永远读不到剩余数据
• ET 模式下必须循环读直到 EAGAIN,使用非阻塞 IO
• 减少了 epoll_wait 的调用次数,理论上性能更高
Nginx 使用 ET 模式,需要在接收到事件后循环处理直到 EAGAIN

Reactor 是基于 IO 多路复用的高性能网络编程模式,是 Netty、Nginx、Redis 等框架的设计基础。

┌─────────────────┐
连接请求 ──────────► │ Reactor │
数据读写 │ (Selector/epoll)│
│ ↓ │
│ Dispatch │
│ ↓ ↓ │
│ Acceptor Handler│
│ (建立连接)(处理请求)│
└─────────────────┘
特点:单线程处理所有事情(Redis 6.0 前的模型)
优点:无锁,无线程切换
缺点:CPU 无法充分利用;Handler 阻塞会影响 Acceptor
┌─────────────────────────────┐
│ Main Thread │
连接请求 ──────────► │ Reactor(epoll) │
│ Acceptor + Dispatch │
└──────────┬──────────────────┘
│ 分发 IO 事件
┌────────────────┼────────────────┐
▼ ▼ ▼
Worker-1 Worker-2 Worker-3
(Thread Pool,处理业务逻辑,非阻塞)
特点:IO 和业务分离,业务由线程池处理
缺点:Acceptor 仍然是单线程,高并发下连接建立可能成为瓶颈

主从 Reactor 多线程(Netty 模型)★

Section titled “主从 Reactor 多线程(Netty 模型)★”
Main Reactor(BossGroup,1~几个线程):
• 只负责监听连接(accept)
• 连接建立后,将 Channel 注册到 Sub Reactor
Sub Reactor(WorkerGroup,通常 CPU 核心数 × 2):
• 每个 Sub Reactor 是一个 NIO 线程(EventLoop)
• 负责监听已建立连接的 IO 事件(read/write)
• IO 事件发生后交给 Pipeline 中的 Handler 处理
┌─────────────┐
客户端 ─────► │ BossGroup │
│ Main Reactor│
│ (accept) │
└──────┬───────┘
│ 注册 Channel
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│EventLoop0│ │EventLoop1│ │EventLoop2│
│(Sub │ │(Sub │ │(Sub │
│ Reactor) │ │ Reactor) │ │ Reactor) │
└──────────┘ └──────────┘ └──────────┘
每个 EventLoop 绑定一个线程,处理其 Channel 的所有 IO 事件

传统文件传输(如 HTTP 文件服务器发送文件)的 4 次数据拷贝:

传统方式:
磁盘 →(DMA拷贝)→ 内核缓冲区 →(CPU拷贝)→ 用户缓冲区
→(CPU拷贝)→ Socket 发送缓冲区 →(DMA拷贝)→ 网卡
涉及 4 次拷贝,2 次 CPU 参与的拷贝,4 次上下文切换
sendfile(out_fd, in_fd, offset, count)
数据流:
磁盘 →(DMA拷贝)→ 内核缓冲区 →(CPU拷贝)→ Socket 发送缓冲区 →(DMA拷贝)→ 网卡
3次拷贝(1次CPU拷贝),2次上下文切换
数据不经过用户空间!

sendfile with scatter-gather(Linux 2.4+,真正的零拷贝)

Section titled “sendfile with scatter-gather(Linux 2.4+,真正的零拷贝)”
需要网卡支持 SG-DMA(Scatter-Gather DMA)
磁盘 →(DMA拷贝)→ 内核缓冲区 →(只传fd描述符)→ Socket Buffer →(SG-DMA)→ 网卡
2次DMA拷贝,0次CPU拷贝,2次上下文切换
真正的零拷贝(CPU 不参与数据搬运)
mmap 将内核缓冲区映射到用户空间(共享同一物理内存)
用户可直接操作内核缓冲区中的数据
优势:
• 修改文件不需要 read/write,直接操作映射的内存
• 适合:大文件随机读写(数据库文件)
数据传输路径:
磁盘 →(DMA)→ 内核缓冲区(同时是用户空间)→(CPU)→ Socket发送缓冲区 →(DMA)→ 网卡
= 3次拷贝(1次CPU拷贝)

Kafka 为什么快? 生产者写入使用顺序写(利用磁盘顺序写速度接近内存);消费者读取使用 sendfile 零拷贝,数据直接从 PageCache 发到网卡,不经过应用层。


Q:BIO、NIO、AIO 的区别是什么?

BIO:同步阻塞,read 等数据到来才返回,一个线程处理一个连接;NIO:同步非阻塞 + IO 多路复用,select/poll/epoll 监控多个 fd,一个线程可处理多个连接;AIO:异步非阻塞,两个阶段都不阻塞,内核完成数据搬运后通知用户(Linux AIO 实现不完善)。

Q:epoll 为什么比 select 高效?

select 每次都需要将所有 fd 从用户空间拷贝到内核(O(n) 拷贝),内核用 O(n) 遍历所有 fd 检查就绪状态,返回后用户态还要再遍历一次。epoll 用红黑树维护被监听的 fd(epoll_ctl O(log n)),用就绪链表记录就绪 fd,epoll_wait 直接返回就绪的 fd(O(就绪fd数)),无需全量拷贝和遍历。

Q:epoll 的 ET 和 LT 模式有什么区别?

LT(水平触发,默认):只要 fd 处于就绪状态,每次 epoll_wait 都通知,适合简单场景,不易漏事件。ET(边缘触发):只在 fd 从未就绪变为就绪时通知一次,用户必须循环读到 EAGAIN,减少 epoll_wait 调用次数,性能略高但使用更复杂(必须配合非阻塞 IO)。

Q:什么是零拷贝?Kafka 如何使用零拷贝?

零拷贝是指数据传输过程中 CPU 不参与数据搬运(只有 DMA 拷贝)。Kafka 消费者读取数据时,通过 FileChannel.transferTo() 底层调用 sendfile 系统调用,数据从 PageCache 直接通过 DMA 发送到网卡,不经过用户空间,避免了内核→用户和用户→内核的两次 CPU 拷贝,极大提升了吞吐量。