Skip to content

IO 多路复用深度解析

面试官:你知道 epoll 吗?

:知道,epoll 是 Linux 提供的 IO 多路复用机制,相比 select/poll 有更好的性能,是 Nginx、Redis、Netty 等高性能服务器的底层基础。

面试官:epoll 为什么比 select 快?时间复杂度是多少?

能说清 O(1) vs O(n) 和「事件就绪通知机制」区别的候选人,才算真正理解了 IO 多路复用的精髓。


Q1:BIO、NIO、AIO 的区别是什么?必考

Section titled “Q1:BIO、NIO、AIO 的区别是什么?”

核心对比

模型全称特点阻塞点Java API
BIOBlocking IO同步阻塞,等待 IO 完成才返回等待数据 + 复制数据InputStream.read()ServerSocket.accept()
NIONon-blocking IO同步非阻塞,轮询或事件通知只阻塞在复制数据java.nio.channels.SelectorSocketChannel
AIOAsynchronous IO异步非阻塞,IO 完成后回调无阻塞AsynchronousSocketChannel

BIO 模型

传统 BIO(一连接一线程):
客户端 1 → 线程 1 → Socket 1.read() [阻塞]
客户端 2 → 线程 2 → Socket 2.read() [阻塞]
客户端 3 → 线程 3 → Socket 3.read() [阻塞]
...
问题:
1000 个并发连接 → 1000 个线程
├── 大量内存占用(每线程 ~1MB 栈)
├── 大量上下文切换开销
└── 线程创建/销毁开销

NIO 模型

NIO(IO 多路复用):
客户端 1 ─┐
客户端 2 ─┼→ Selector(多路复用器)→ 线程池 → 处理事件
客户端 3 ─┤ ↑
... ┘ │
只在 Channel 就绪时处理
优势:
1 个 Selector 监控 N 个 Channel
线程数可远小于连接数
只处理活跃连接(事件驱动)

代码对比

// BIO:一连接一线程
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept(); // 阻塞等待连接
new Thread(() -> {
InputStream in = client.getInputStream();
byte[] buf = new byte[1024];
int len = in.read(buf); // 阻塞等待数据
// 处理数据...
}).start();
}
// NIO:一个线程处理多个连接
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false); // 非阻塞模式
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞,直到有 Channel 就绪
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) {
// 接受连接
} else if (key.isReadable()) {
// 读取数据
}
}
}

Q2:POSIX 定义的五种 IO 模型是什么?高频

Section titled “Q2:POSIX 定义的五种 IO 模型是什么?”

系统调用层面的 IO 模型

1. 阻塞 IO(Blocking IO)
用户进程 → read() → [阻塞等待数据到达] → [阻塞复制数据] → 返回
数据准备阶段:阻塞
数据复制阶段:阻塞
2. 非阻塞 IO(Non-blocking IO)
用户进程 → read() → 数据未到达,立即返回 EAGAIN
轮询 → read() → ... → 数据到达
→ [阻塞复制数据] → 返回
数据准备阶段:非阻塞(轮询浪费 CPU)
数据复制阶段:阻塞
3. IO 多路复用(IO Multiplexing)
用户进程 → select/poll/epoll(监控多个 fd)
→ 有 fd 就绪 → read() → [阻塞复制数据] → 返回
数据准备阶段:select 阻塞(但监控多个 fd)
数据复制阶段:阻塞
两阶段都是同步的!
4. 信号驱动 IO(Signal-Driven IO)
用户进程 → 注册 SIGIO 信号处理函数 → 立即返回
数据到达 → 内核发送 SIGIO 信号
→ 回调中 read() → [阻塞复制数据] → 返回
数据准备阶段:非阻塞(信号通知)
数据复制阶段:阻塞
5. 异步 IO(Asynchronous IO,AIO)
用户进程 → aio_read() → 立即返回
内核:[等待数据] → [复制数据到用户缓冲区] → 发送信号/回调
数据准备阶段:内核处理(不阻塞进程)
数据复制阶段:内核处理(不阻塞进程)
真正的异步!

关键区分

IO 模型数据准备阶段数据复制阶段是否真正异步
阻塞 IO阻塞阻塞
非阻塞 IO非阻塞(轮询)阻塞
IO 多路复用阻塞(select)阻塞
信号驱动 IO非阻塞阻塞
异步 IO非阻塞非阻塞

链式追问二:select / poll / epoll 原理

Section titled “链式追问二:select / poll / epoll 原理”

Q3:select、poll、epoll 的区别?必考

Section titled “Q3:select、poll、epoll 的区别?”

核心对比

特性selectpollepoll
fd 数量限制1024(FD_SETSIZE,可修改但需重新编译)无限制无限制(受系统内存限制)
时间复杂度O(n),遍历所有 fdO(n),遍历所有 fdO(1),直接返回就绪列表
内存拷贝每次调用拷贝 fd_set 到内核每次调用拷贝 pollfd 数组只在 epoll_ctl 时拷贝,后续共享内存
触发方式水平触发(LT)水平触发(LT)支持水平触发(LT)和边缘触发(ET)
工作模式轮询所有 fd轮询所有 fd事件驱动,回调通知
移植性好(POSIX 标准)好(POSIX 标准)仅 Linux(BSD 有 kqueue)
适用场景小量 fd(< 100)中量 fd大量 fd(高并发)

select 的限制

// select API
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
// fd_set 是位图,默认大小 1024
#define FD_SETSIZE 1024
// 问题 1:每次调用都要把 fd_set 从用户态拷贝到内核
// 问题 2:内核需要遍历所有 fd(O(n))
// 问题 3:返回后,用户态也要遍历所有 fd 找出就绪的

poll 的改进

// poll API
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 监控的事件
short revents; // 返回的事件
};
// 改进:用数组代替位图,无 1024 限制
// 但仍然是 O(n) 遍历,每次拷贝数组

epoll 的革命性设计

// epoll API(三个系统调用)
int epoll_create(int size); // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除 fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件
// 关键优化:
// 1. 只在 epoll_ctl 时拷贝 fd(注册时)
// 2. 内核通过回调机制,fd 就绪时自动加入就绪链表
// 3. epoll_wait 直接返回就绪链表(O(1))

Q4:epoll 为什么比 select 快?内部原理是什么?必考

Section titled “Q4:epoll 为什么比 select 快?内部原理是什么?”

epoll 内部数据结构

epoll 实例(内核数据结构):
┌─────────────────────────────────┐
│ 红黑树(存储所有被监控的 fd) │
│ ├── fd 100 → event struct │
│ ├── fd 200 → event struct │ O(log n) 增删改
│ └── fd 300 → event struct │
├─────────────────────────────────┤
│ 就绪链表(ready list) │
│ ├── fd 100(可读) │ epoll_wait() 直接返回
│ └── fd 200(可写) │
└─────────────────────────────────┘

select/poll 的低效原因

每次 select() 调用:
1. 用户态 → 内核态(系统调用)
2. 拷贝所有 fd_set(如 1000 个 fd)到内核
3. 内核遍历所有 fd,检查是否就绪 → O(n)
4. 把结果拷贝回用户态
5. 用户态遍历所有 fd,找出就绪的 → O(n)
总开销:2 次拷贝 + 2 次遍历
问题:
- fd 越多,开销越大
- 大部分 fd 未就绪,做了无用功

epoll 的高效原理

epoll 工作流程:
1. 注册阶段(epoll_ctl):
用户态 → 内核态(只一次)
把 fd 加入红黑树
注册回调函数:fd 就绪时自动触发
2. 就绪阶段(事件驱动):
fd 变成就绪状态(如 socket 有数据到达)
→ 内核调用注册的回调函数
→ 把 fd 加入就绪链表
(无需遍历!)
3. 等待阶段(epoll_wait):
直接返回就绪链表 → O(1)
(只返回活跃 fd,不遍历所有 fd)
优势:
- 只在注册时拷贝 fd(一次)
- 就绪通知无需遍历(回调机制)
- 返回时只拷贝就绪 fd(通常少量)

性能对比(监控 10000 个 fd,100 个活跃)

操作select/pollepoll
用户态 → 内核态拷贝10000 fd × 每次10000 fd × 仅注册时 1 次
内核遍历10000 次0 次(回调)
内核 → 用户态拷贝10000 fd100 fd(就绪的)
用户态遍历10000 次0 次(直接返回就绪列表)
总时间复杂度O(n)O(1)

epoll 的底层实现(内核源码简化)

fs/eventpoll.c
// epoll 实例结构
struct eventpoll {
struct rb_root rbr; // 红黑树根节点
struct list_head rdllist; // 就绪链表
wait_queue_head_t wq; // 等待队列
};
// 注册 fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) {
// 1. 在红黑树中查找/插入 fd
struct epitem *epi = ep_find(ep, fd);
if (op == EPOLL_CTL_ADD) {
ep_insert(ep, event, fd); // 插入红黑树
}
// 2. 注册回调函数
ep_ptable_queue_proc(..., ep_poll_callback);
}
// 回调函数:fd 就绪时内核自动调用
int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key) {
// 把 fd 加入就绪链表
list_add_tail(&epi->rdllink, &ep->rdllist);
// 唤醒 epoll_wait
wake_up(&ep->wq);
}
// 等待事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout) {
// 直接返回就绪链表
list_for_each_entry_safe(epi, tmp, &ep->rdllist, rdllink) {
events[i++] = epi->event; // 拷贝到用户空间
}
}

Q5:epoll 的水平触发(LT)和边缘触发(ET)的区别?高频

Section titled “Q5:epoll 的水平触发(LT)和边缘触发(ET)的区别?”

触发方式对比

触发方式触发时机特点适用场景
LT(Level Trigger,水平触发)只要 fd 处于就绪状态,每次 epoll_wait 都通知安全,不会漏事件;可分批处理通用,简单易用
ET(Edge Trigger,边缘触发)只在 fd 状态变化时通知一次(不活跃 → 活跃)高效,减少通知次数;必须一次读完所有数据高性能服务器(Nginx)

类比理解

LT(水平触发):
开关一直开着 → 灯一直亮(持续通知)
像邮箱里有信 → 每次查看都提醒你
ET(边缘触发):
开关按下瞬间 → 灯闪一下(只通知一次)
像邮箱收到新信 → 只在信到达时提醒一次

ET 模式的正确使用

// ET 模式必须非阻塞读,循环读到 EAGAIN
int fd = accept(listenfd, ...);
setnonblocking(fd); // 设置非阻塞
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // ET 模式
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
// 事件处理
while (true) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN) {
break; // 数据读完了,正常退出
} else {
perror("read");
break;
}
} else if (n == 0) {
// 对端关闭
close(fd);
break;
}
// 处理数据...
}

ET 模式的陷阱

错误做法:只读一次
read(fd, buf, 1024);
// 如果缓冲区还有数据,不会再收到通知!
→ 数据丢失
正确做法:循环读到 EAGAIN
while ((n = read(fd, buf, sizeof(buf))) > 0) {
process(buf, n);
}
if (n == -1 && errno == EAGAIN) {
// 数据读完
}
为什么要非阻塞?
如果阻塞读:
while (read(fd, buf, sizeof(buf)) > 0) { ... }
最后一次 read 会阻塞 → 无法处理其他 fd
非阻塞读:
read() 返回 EAGAIN → 立即返回

性能对比

场景:1000 个 fd,每个 fd 缓冲区有 10 次数据到达
LT 模式:
数据到达 → epoll_wait 返回 → read 一部分 → 下次 wait 再返回
通知次数:可能 10000 次(每次有数据都通知)
ET 模式:
数据到达 → epoll_wait 返回一次 → 循环 read 读完
通知次数:1000 次(只在状态变化时通知)
性能优势:
ET 减少了系统调用次数(epoll_wait 和 read)
Nginx 使用 ET 模式,性能更高

Q6:什么是零拷贝?有哪些实现方式?必考

Section titled “Q6:什么是零拷贝?有哪些实现方式?”

传统文件发送(4 次拷贝,2 次系统调用)

场景:从磁盘读取文件,通过网络发送
传统方式(read + write):
用户程序调用 read(file_fd, buf, size)
1. 磁盘 → [DMA 拷贝] → 内核缓冲区(Page Cache)
2. 内核缓冲区 → [CPU 拷贝] → 用户缓冲区
用户程序调用 write(socket_fd, buf, size)
3. 用户缓冲区 → [CPU 拷贝] → Socket 缓冲区
4. Socket 缓冲区 → [DMA 拷贝] → 网卡
总开销:4 次拷贝(2 次 DMA + 2 次 CPU)
2 次系统调用(read + write)
2 次上下文切换(用户态 ↔ 内核态)

mmap + write(3 次拷贝,2 次系统调用)

mmap 方式:
用户程序调用 mmap(file_fd)
将内核缓冲区映射到用户地址空间
(用户可直接访问内核缓冲区,无需拷贝)
用户程序调用 write(socket_fd, buf, size)
1. 磁盘 → [DMA 拷贝] → 内核缓冲区(用户可见)
2. 内核缓冲区 → [CPU 拷贝] → Socket 缓冲区
3. Socket 缓冲区 → [DMA 拷贝] → 网卡
总开销:3 次拷贝(2 次 DMA + 1 次 CPU)
2 次系统调用(mmap + write)
优点:减少 1 次 CPU 拷贝
缺点:mmap 需要管理虚拟内存映射,可能有开销

sendfile(2 次拷贝,1 次系统调用)

// sendfile 系统调用
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
// 数据流:
// 1. 磁盘 → [DMA 拷贝] → 内核缓冲区
// 2. 内核缓冲区 → [CPU 拷贝] → Socket 缓冲区
// 3. Socket 缓冲区 → [DMA 拷贝] → 网卡
总开销:2 次拷贝(2 次 DMA + 1 次 CPU,但 CPU 拷贝很少)
1 次系统调用

sendfile + DMA Gather(真正的零拷贝,Linux 2.4+)

DMA Gather/Scatter(分散/聚集 DMA):
网卡支持从多个内存区域读取数据
sendfile 流程:
1. 磁盘 → [DMA 拷贝] → 内核缓冲区
2. 网卡 DMA 引擎读取:
Socket 缓冲区(描述符信息)
+ 内核缓冲区(实际数据)
→ [DMA Gather] → 网卡
总开销:2 次 DMA 拷贝,0 次 CPU 拷贝!
1 次系统调用
真正的零拷贝:CPU 不参与数据拷贝

性能对比

方法CPU 拷贝次数DMA 拷贝次数系统调用次数上下文切换次数
read + write2224
mmap + write1224
sendfile1212
sendfile + DMA Gather0212

Java 中的零拷贝

// FileChannel.transferTo(底层调用 sendfile)
FileChannel source = FileChannel.open(Paths.get("input.txt"), StandardOpenOption.READ);
SocketChannel dest = SocketChannel.open(new InetSocketAddress("host", 8080));
// 零拷贝传输
source.transferTo(0, source.size(), dest);
// 场景:
// - Kafka:使用 transferTo 传输消息,吞吐量提升 50%+
// - Nginx:使用 sendfile 发送静态文件
// - Tomcat:sendfile 支持(AprLifecycleListener)

Kafka 高吞吐量的秘密

Kafka 使用零拷贝传输消息:
生产者 → Broker(写入磁盘)
└── 传统方式:需要 CPU 拷贝
Broker → 消费者(读取磁盘,发送网络)
└── sendfile 零拷贝:
磁盘 → 内核缓冲区 → 网卡
不经过用户空间,不经过 JVM 堆
优势:
1. 减少 CPU 拷贝 → CPU 可处理更多请求
2. 减少系统调用 → 减少上下文切换
3. 不经过 JVM 堆 → 避免 GC 压力
性能提升:
传统方式:~500 MB/s
零拷贝: ~1000 MB/s(提升 100%)

Q7:Reactor 模式是什么?Netty 如何应用?高频

Section titled “Q7:Reactor 模式是什么?Netty 如何应用?”

Reactor 模式:基于 IO 多路复用的事件驱动架构,核心是「事件循环」。

三种 Reactor 模型

1. 单 Reactor 单线程(Redis 6.0 之前):
┌─────────────┐
│ Reactor │ ← epoll_wait
│ (1线程) │
└──────┬──────┘
分派事件
┌────↓────┐
│ Handler │ ← 业务处理(同一线程)
└─────────┘
优点:简单,无线程切换开销
缺点:Handler 阻塞会影响所有连接
适用:Redis(操作内存,快速)
2. 单 Reactor 多线程:
┌─────────────┐
│ Reactor │ ← epoll_wait(主线程)
│ (1线程) │
└──────┬──────┘
分派事件
┌────↓────┐
│ Handler │ ← 快速处理,耗时的交给线程池
└────┬────┘
┌────↓────────────────┐
│ Worker 线程池 │ ← 慢任务处理
│ ┌─────┐ ┌─────┐ │
│ │ T1 │ │ T2 │ │
│ └─────┘ └─────┘ │
└─────────────────────┘
优点:Handler 不阻塞 Reactor
缺点:Reactor 仍是单点
3. 主从 Reactor 多线程(Netty、Nginx):
┌──────────────┐ ┌──────────────┐
│ MainReactor │ │ SubReactor 1 │
│ (BossGroup) │ │(WorkerGroup) │
│ │ │ │
│ epoll_wait │ │ epoll_wait │
│ accept 连接 │──────→│ 处理读写 │
└──────────────┘ └──────┬───────┘
┌──────↓───────┐
│ SubReactor 2 │
│ │
│ epoll_wait │
│ 处理读写 │
└──────┬───────┘
┌────↓────┐
│ Handler │ ← 线程池处理业务
└─────────┘
优点:充分利用多核,Reactor 分工明确
适用:高并发服务器

Netty 的实现

// Netty 主从 Reactor 模型
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // MainReceptor(1 个线程)
EventLoopGroup workerGroup = new NioEventLoopGroup(); // SubReactor(默认 CPU 核心数 × 2)
ServerBootstrap bootstrap = new ServerBootstrap()
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 编解码器
pipeline.addLast(new LengthFieldBasedFrameDecoder(...));
pipeline.addLast(new StringDecoder());
// 业务处理器
pipeline.addLast(new BusinessHandler());
}
});
// 绑定端口
ChannelFuture future = bootstrap.bind(8080).sync();
// BusinessHandler
public class BusinessHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
// 业务处理(在 EventLoop 线程中执行)
// 如果耗时,应提交到业务线程池
String response = process(msg);
ctx.writeAndFlush(response);
}
}

Netty 的关键优化

1. 无锁化设计:
每个 Channel 绑定到一个 EventLoop(单线程)
所有事件在同一线程中串行处理 → 无锁
2. 零拷贝:
- ByteBuf(堆外内存,DirectBuffer)
- CompositeByteBuf(逻辑组合,不拷贝)
- FileRegion(sendfile)
3. 内存池:
- PooledByteBufAllocator(池化 ByteBuf)
- 减少 GC 压力
4. Reactor 线程数:
- BossGroup:1 个线程(只接受连接)
- WorkerGroup:CPU 核心数 × 2(处理 IO)