IO 模型
IO 模型的分类
Unix/Linux 下有 5 种 IO 模型:
| 模型 | 描述 | 特点 |
|---|---|---|
| 阻塞 IO(BIO) | 进程在 IO 完成前阻塞 | 简单但浪费 CPU |
| 非阻塞 IO(NIO) | 立即返回,轮询检查 | 忙等待浪费 CPU |
| IO 多路复用(select/poll/epoll) | 单线程监视多个 fd | 高并发关键 |
| 信号驱动 IO(SIGIO) | fd 就绪时发信号 | 不常用 |
| 异步 IO(AIO) | 内核完成 IO 后通知进程 | 复杂,应用少 |
阻塞 IO(BIO)
c
fd = open("/data/file", O_RDONLY);
read(fd, buf, 1024); // 进程挂起,直到数据就绪
// 继续执行流程:
- 进程调用
read(),陷入内核 - 内核检查数据是否就绪
- 数据未就绪 → 进程进入睡眠
- 数据就绪(DMA 到内核 buffer)→ 数据拷贝到用户 buffer
- 进程唤醒,
read()返回
缺点:每个连接需要独立线程/进程,C10K 问题。
非阻塞 IO(NIO)
c
fd = open("/data/file", O_RDONLY | O_NONBLOCK);
while (read(fd, buf, 1024) == -1) {
// 忙等待或做其他事
if (errno == EAGAIN) continue;
}特点:read() 立即返回,如果数据未就绪返回 EAGAIN。需要用户程序轮询,浪费 CPU。
IO 多路复用
单线程监视多个文件描述符的就绪状态:
select
c
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (FD_ISSET(fd1, &readfds)) read(fd1, ...);局限:
- 监视的 fd 数量上限
FD_SETSIZE(默认 1024) - 每次调用需将全部 fd 集合从用户态拷贝到内核态
- 返回后需遍历所有 fd 检查哪个就绪 → O(n)
poll
类似 select,但使用链表结构,无上限限制。仍有全量拷贝 + 全量遍历问题。
epoll(Linux 特有)
c
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
struct epoll_event events[128];
int n = epoll_wait(epfd, events, 128, -1);
for (int i = 0; i < n; i++) {
// 直接处理就绪的 fd
read(events[i].data.fd, ...);
}epoll 的优势:
| 对比 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 1024 | 无限制 | 无限制 |
| 数据传递 | 全量拷贝用户↔内核 | 全量拷贝 | 注册时拷贝,事件通知 |
| 就绪查询 | O(n) 遍历 | O(n) 遍历 | O(1) 直接返回就绪列表 |
| 模式 | 水平触发 LT | LT | LT + ET(边缘触发) |
| 平台 | 跨平台 | 跨平台 | Linux only |
边缘触发(Edge-Triggered, ET):文件描述符状态变化时才通知一次,应用需一次性将数据读完。性能更高但编程更复杂。
异步 IO(AIO)
c
// POSIX AIO(略)
// Linux io_uring(现代推荐)真正的异步:应用发起 IO 后立即返回,内核完成全部操作(包括数据拷贝到用户 buffer)后通知应用。
io_uring(Linux 5.1+)
io_uring 是 Linux 最新最强大的异步 IO 框架:
- 使用共享内存环形队列消除系统调用开销
- 支持读写、网络、文件系统等各类操作
- 零拷贝 IO 提交与完成
c
// 使用 liburing
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);
io_uring_submit(&ring);
// 等待完成
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// cqe->res 包含结果
io_uring_cqe_seen(&ring, cqe);零拷贝(Zero-Copy)
传统 IO 路径中,数据在磁盘 → 内核 buffer → 用户 buffer → Socket buffer → 网卡之间多次拷贝。
mmap + write
c
buf = mmap(file, len);
write(socket_fd, buf, len);减少一次内核 buffer → 用户 buffer 的拷贝。
sendfile
c
sendfile(out_fd, in_fd, NULL, len);一次系统调用完成文件到 Socket 的传输,数据不经过用户空间:
磁盘 → 内核 PageCache → 网卡(DMA)splice(管道 splice)
c
splice(fd_in, &off, pipe[1], NULL, len, 0);
splice(pipe[0], NULL, fd_out, NULL, len, 0);通过管道在两个文件描述符间移动数据,无需用户 buffer。
零拷贝应用
- Nginx:
sendfile on;发送静态文件 - Kafka:利用
sendfile高效传输日志数据 - Netty:
FileRegion封装了零拷贝传输
Java 中的 IO 模型
| API | 底层 | 是否阻塞 | 使用场景 |
|---|---|---|---|
java.io.*(BIO) | 同步阻塞 Socket | 阻塞 | 低并发,简单场景 |
java.nio.*(NIO) | 多路复用(select/poll/epoll) | 非阻塞 | 高并发,Netty 底层 |
java.nio.channels.Asynchronous*(AIO) | 异步 Channel | 非阻塞 | Windows IOCP 效果好,Linux 实际仍用 NIO |
实际工程中,Java 高并发场景基本使用 Netty(封装 NIO),较少直接使用 AIO。
常见面试问题
- BIO、NIO、AIO 的区别?
- select、poll、epoll 的区别?epoll 为什么高效?
- epoll 的水平触发(LT)和边缘触发(ET)有什么区别?
- 零拷贝是什么?sendfile 的流程?
- Java NIO 的 Selector 底层是 epoll 吗?
- io_uring 相比 epoll 的优势?