IO 模型与高性能网络
五种 IO 模型
Section titled “五种 IO 模型”Linux 下的 IO 操作涉及两个阶段:
- 等待数据就绪(数据从网卡到内核缓冲区)
- 数据从内核复制到用户空间
五种模型在这两个阶段的处理方式不同:
阻塞 IO(Blocking IO,BIO)
Section titled “阻塞 IO(Blocking IO,BIO)”用户进程 内核 │ │ │── read() ────────────────►│ │ 阻塞等待 │ 1.等待数据到达网络 │ 阻塞等待 │ 2.数据从内核复制到用户空间 │◄─────────────── 返回数据 ──│ │ 继续执行 │
特点:两个阶段都阻塞代价:一个线程只能处理一个连接,高并发需大量线程(C10K 问题的根源)非阻塞 IO(Non-Blocking IO)
Section titled “非阻塞 IO(Non-Blocking IO)”用户进程 内核 │── read() ────────────────►│ 数据未就绪 │◄─────────── EAGAIN ───────│ 立即返回 │ (忙等待轮询) │ │── read() ────────────────►│ 数据未就绪 │◄─────────── EAGAIN ───────│ │── read() ────────────────►│ 数据就绪 │ 阻塞等待 │ 数据从内核复制到用户空间 │◄─────────── 返回数据 ──────│
特点:第一阶段不阻塞(立即返回),第二阶段阻塞代价:需要不断轮询,CPU 空转严重;实际很少单独使用IO 多路复用(IO Multiplexing)★★★
Section titled “IO 多路复用(IO Multiplexing)★★★”用户进程 内核 │── select/poll/epoll ──────►│ │ 阻塞等待(监控多个fd) │ 等待任意一个fd就绪 │◄─────────── 有fd就绪 ──────│ │── read(就绪的fd) ──────────►│ │ 阻塞等待 │ 数据从内核复制到用户空间 │◄─────────── 返回数据 ──────│
特点:一个线程监控多个连接;两个阶段都可能阻塞,但第一阶段等待多个 fd优势:用少量线程处理大量连接(C10K 的解决方案)信号驱动 IO(Signal-driven IO)
Section titled “信号驱动 IO(Signal-driven IO)”内核在数据就绪时发送 SIGIO 信号通知进程,进程注册信号处理函数后继续执行其他任务。实际使用较少。
异步 IO(Asynchronous IO,AIO)
Section titled “异步 IO(Asynchronous IO,AIO)”用户进程 内核 │── aio_read() ─────────────►│ │ 立即返回,继续执行 │ 1.等待数据到达 │ │ 2.数据从内核复制到用户空间 │◄─────────── 信号通知 ───────│ │ 在回调中处理数据 │
特点:两个阶段都不阻塞(真正的异步)Linux AIO 的局限:只支持 O_DIRECT(绕过 page cache),实际使用受限select / poll / epoll 对比
Section titled “select / poll / epoll 对比”这三个系统调用都实现了 IO 多路复用,但效率差异显著。
select
Section titled “select”// 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. 返回后用户态还要再遍历一次才能找到就绪的 fdint poll(struct pollfd *fds, nfds_t nfds, int timeout);
改进: • 用链表(pollfd 数组)替代位图,无 1024 的 fd 数量限制
遗留问题: • 仍然是 O(n) 遍历 • 仍然需要用户/内核空间的数据拷贝epoll(Linux 2.6+)★★★
Section titled “epoll(Linux 2.6+)★★★”int epoll_create(int size); // 创建 epoll 实例,返回 epfdint 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 加入就绪链表 → 唤醒等待进程epoll 的核心优势
Section titled “epoll 的核心优势”对比项 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 模式,需要在接收到事件后循环处理直到 EAGAINReactor 模式
Section titled “Reactor 模式”Reactor 是基于 IO 多路复用的高性能网络编程模式,是 Netty、Nginx、Redis 等框架的设计基础。
单 Reactor 单线程
Section titled “单 Reactor 单线程” ┌─────────────────┐连接请求 ──────────► │ Reactor │数据读写 │ (Selector/epoll)│ │ ↓ │ │ Dispatch │ │ ↓ ↓ │ │ Acceptor Handler│ │ (建立连接)(处理请求)│ └─────────────────┘
特点:单线程处理所有事情(Redis 6.0 前的模型)优点:无锁,无线程切换缺点:CPU 无法充分利用;Handler 阻塞会影响 Acceptor单 Reactor 多线程
Section titled “单 Reactor 多线程” ┌─────────────────────────────┐ │ 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 事件零拷贝(Zero-Copy)
Section titled “零拷贝(Zero-Copy)”传统文件传输(如 HTTP 文件服务器发送文件)的 4 次数据拷贝:
传统方式: 磁盘 →(DMA拷贝)→ 内核缓冲区 →(CPU拷贝)→ 用户缓冲区 →(CPU拷贝)→ Socket 发送缓冲区 →(DMA拷贝)→ 网卡
涉及 4 次拷贝,2 次 CPU 参与的拷贝,4 次上下文切换sendfile(Linux 2.1+)
Section titled “sendfile(Linux 2.1+)”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 拷贝,极大提升了吞吐量。