说实话, 我在很多地方都听过多路复用 ,但是始终没时间去研究他们有啥区别,以及底层原理, 今天咱们就来研究一下
I/O 多路复用(select/poll/epoll)
Redis 单线程 + 多路复用
Java NIO 的 Selector
网络数据包的多路复用/解复用(端口、协议号)
这些“多路复用”技术原理不完全一样,但核心思想完全一致。
核心思想:用“一个东西”同时管理“多个通道/任务”,避免为每个任务都分配独立资源。
下面我帮你把这几个概念彻底分清。
我们直接从 Selector 底层实现原理 出发,以 epoll 为例,讲清楚“多路复用”的本质。
首先咱们了解一下 epoll
epoll 是 Linux 内核提供的一种 I/O 多路复用机制,它的核心工作是帮你同时监控大量的文件描述符(比如网络 socket)。
Epoll 解决什么问题?
你要写一个服务器,同时处理 10000 个客户端连接。
老方法(select/poll):每次都要把 10000 个 fd 拷给内核,内核遍历一遍,再把结果拷回来 → 每次 O(N)
epoll:只在第一次告诉内核“帮我盯着这些 fd”,后面内核只告诉你“哪些 fd 有事件了” → 每次 O(1)
select/poll 做了什么
每次调用 select 或 poll,内核做的动作:
1. 把用户传进来的 fd 集合从用户态拷贝到内核态2. 遍历所有 fd(10000 个),逐个检查是否有事件3. 把有事件的 fd 标记出来4. 把结果拷回用户态
核心:每次调用都要完整遍历一遍全部 fd,开销是 O(N)。
epoll 把“遍历”提前到了事件发生时
epoll 在内核里维护了两个核心结构:
| 数据结构 | 作用 |
|---|---|
| 红黑树 | 存储所有被监控的 fd |
| 就绪链表 | 存储已经发生事件的 fd |
事件发生时,内核主动做了这件事
当某个 socket 上有数据到达(硬件中断 → 网络协议栈 → 唤醒等待的进程):
内核在把数据交给协议栈处理的同时:1. 找到这个 socket 对应的 fd2. 如果这个 fd 在 epoll 的红黑树里3. 就把这个 fd 添加到「就绪链表」中
我们从 OSI 七层模型 和 Linux 内核网络栈 两个角度,把“数据到达 → epoll 就绪”的完整路径画出来。
OSI 七层视角(简化版)
| OSI 层 | 动作 |
|---|---|
| 物理层 | 网卡接收电信号/光信号,转为比特 |
| 数据链路层 | 网卡驱动解析以太网帧,去掉 MAC 头 |
| 网络层 | IP 层处理,去掉 IP 头,确定是 TCP 还是 UDP |
| 传输层 | TCP 层处理,找到对应的 socket |
| 会话层以上 | 数据 ready,等待用户进程读取 |
关键点:在第 4 层(TCP)处理完后,内核就知道这个数据属于哪个 socket fd。
Linux 内核网络栈的详细路径(从硬件到 epoll)

四步核心
硬件 + 驱动:DMA 把数据放到内存,发中断
软中断 + 网络栈:IP → TCP → socket → 接收队列
回调:
sk_data_ready→ep_poll_callback→ fd 入就绪链表 + 唤醒用户进程:
epoll_wait返回 →recv读数据
关键数据结构对应表
| 图上组件 | 内核数据结构 / 函数 | 作用 |
|---|---|---|
| Ring Buffer | rx_ring | 网卡驱动与内核共享的环形队列 |
| 软中断 | NET_RX_SOFTIRQ | 网络收包软中断 |
| IP 层 | netif_receive_skb | IP 层入口 |
| TCP 层 | tcp_v4_rcv | TCP 层入口 |
| socket | struct sock | 代表一个网络连接 |
| 接收队列 | sk->sk_receive_queue | 存放已收到未读的 sk_buff |
| 数据就绪回调 | sk->sk_data_ready | 数据可用时调用的函数指针 |
| epoll 回调 | ep_poll_callback | 被 sk_data_ready 调用 |
| epoll 就绪链表 | ep->rdllist | 存放有事件的 fd |
| epoll 等待队列 | ep->wq | 睡眠在 epoll_wait 上的进程 |

Java NIO 的 Selector 底层在 Linux 上就是 epoll。Selector.open() 对应 epoll_create,在内核中创建红黑树、就绪链表和等待队列。channel.register() 对应 epoll_ctl,把 socket fd 加入红黑树。selector.select() 对应 epoll_wait,检查就绪链表;为空时当前线程加入等待队列,进入睡眠。
当数据到达时,网卡通过中断 → 网络栈 → sk_data_ready 最终触发 ep_poll_callback,把 fd 加入就绪链表,并唤醒等待队列中的线程。
线程从 epoll_wait 返回,通过 selectedKeys 拿到就绪事件。
这就是 Java NIO 能够单线程处理海量连接的核心原理。”
有个问题, EventLoop里边是怎么操作 这个Selector的, 就WebCLient来说 里边的HttpClient里边的连接池 ConnectionProvider是把 TCP连接 创建好了 之后, EventLoop都用Selector完成注册, 之后假如说 Socket没收到数据 这个EventLoop 线程是 挂起吗? 还是去处理别的东西
场景:WebClient 发起请求,等待响应
步骤 1:连接池创建 TCP 连接
ConnectionProvider 创建
SocketChannel,此时还没有进入 EventLoop。连接建立成功。
步骤 2:把连接注册到 EventLoop 的 Selector
SocketChannel channel = ...;channel.configureBlocking(false);channel.register(selector, SelectionKey.OP_READ);
JVM 状态:
RUNNABLE(执行 register 代码)OS 状态:运行
步骤 3:请求已发出,等待响应(Socket 没有数据)
这是你关心的核心时刻。
EventLoop 线程执行到:
int ready = selector.select(); // 没有就绪事件时| 层级 | 状态 | 说明 |
|---|---|---|
| JVM 状态 | RUNNABLE | JVM 不把 select() 阻塞归类为 WAITING 或 BLOCKED |
| OS 状态 | 阻塞 | epoll_wait 系统调用让线程睡眠,不占用 CPU |
关键:此时 EventLoop 在 OS 层是阻塞的,因此 不占用 CPU,但 JVM 看起来还是 RUNNABLE。
步骤 4:EventLoop 会去“处理别的东西”吗?
答案:不会。
因为没有事件(没有数据、没有新连接、没有超时),select() 不会返回。
EventLoop 线程在 OS 层彻底阻塞,CPU 会去执行其他可运行线程(比如另一个 EventLoop,或其他普通线程)。
所以:
线程不会去执行其他 I/O 操作
线程不会去处理其他 Channel
线程不会去执行任务队列里的普通任务
它就是在 OS 层“睡着”,直到被唤醒。
三、那“去处理别的东西”指什么?
你之前可能听到过“EventLoop 会去处理别的东西”,那是有就绪事件的情况,而不是没事件的情况。
| 场景 | JVM 状态 | OS 状态 | 会处理别的东西吗 |
|---|---|---|---|
| 没有就绪事件 | RUNNABLE | 阻塞 | ❌ 不会,线程在睡觉 |
| 有就绪事件 | RUNNABLE | 运行 | ✅ 会,只处理就绪的 Channel |
| 任务队列有任务 | RUNNABLE | 运行 | ✅ 会,执行普通任务 |
误区澄清:EventLoop 不会在没事件时“主动去处理别的 Channel”,因为 select() 还没返回。
四、数据到达时会发生什么?
网卡收到数据 → 内核回调 →
ep_poll_callback→ fd 加入就绪链表内核唤醒阻塞在
epoll_wait上的线程(OS 阻塞 → 就绪)select()返回 → 线程从 OS 阻塞变为运行JVM 状态仍然是
RUNNABLE(没有变化)EventLoop 开始处理就绪事件(读数据、执行回调)
WebClient 的 EventLoop 在等待响应(Socket 没有数据)时:
JVM 状态:
RUNNABLE(JVM 不区分网络 I/O 阻塞)OS 状态:阻塞(由
epoll_wait系统调用引起,线程不占用 CPU)会不会去处理别的东西?:不会。没有就绪事件时,
select()不会返回,线程在 OS 层睡眠。什么时候被唤醒?:数据到达、新连接、或超时时间到。
唤醒后:只处理就绪的 Channel,处理完继续
select()。

那实际上 这里Selector的多路复用机制, 最关键的就是 socket收到数据的时候 . sk.date_ready()触发回调, ep_poll. callbakk()将 fd 加入就绪链表 相当于说是 操作系统 从底层支持了这种多路复用的底层原理, 不然你上层怎么设计都没这么高效
I/O 多路复用的本质。
多路复用之所以能高效工作,是因为操作系统内核从底层提供了事件通知机制,而不是靠上层轮询。
为什么说「不然你上层怎么设计都没用」?
假如内核不支持事件通知,最原始的做法是:
方案 A:完全靠用户态轮询
while (true) {for (Socket socket : allSockets) {if (socket.hasData()) { // 每次都要系统调用socket.read();}}}
每个 socket 都要一次系统调用
10000 个 socket 一次循环就要 10000 次系统调用
CPU 会被打满,但大部分时间在做无用功
方案 B:select/poll(内核遍历)
你告诉内核「帮我看这些 fd」
内核每次全量遍历,再把结果给你
10000 个 fd 每次都要内核遍历一遍
问题:无论哪种,都避免不了「每次都要主动问内核一遍」。
epoll 的底层支持改变了什么?
epoll 不是让你「问内核」,而是让内核 主动通知你。
内核主动通知的前提:内核在网络栈里嵌入了回调点。
当数据到达时,协议栈在把数据交给 socket 的过程中,会调用 sk_data_ready 函数指针。
如果这个指针指向了 ep_poll_callback,epoll 就能得到通知。
// socket 结构里有一个函数指针struct sock {void (*sk_data_ready)(struct sock *sk);};// 当数据到达时sk->sk_data_ready(sk); // 调用这个函数
如果没有 epoll,这个函数指针可能指向默认处理(比如唤醒等待 recv 的进程)。
有了 epoll,就可以把指针指向 ep_poll_callback,让 epoll 先得到通知。
上层设计能否绕过这个底层机制?
| 方案 | 是否依赖内核事件通知 | 能否高效 |
|---|---|---|
| 纯用户态轮询 | ❌ 不需要内核支持 | ❌ 极差 |
| select/poll | ❌ 只需要遍历 fd 集合 | ❌ O(N) 差 |
| epoll | ✅ 依赖回调 + 就绪链表 | ✅ O(1) 好 |
结论:没有内核的事件通知机制,上层无论如何设计都无法做到 O(1) 高效。
这也是为什么 Java NIO 在不同操作系统实现不同
| 操作系统 | Java NIO Selector 底层 | 原因 |
|---|---|---|
| Linux | epoll | Linux 提供 epoll 内核支持 |
| macOS / BSD | kqueue | BSD 提供 kqueue 内核支持 |
| Windows | select(旧) / IOCP(NIO.2) | Windows 没有 epoll,用 IOCP 做异步 I/O |
关键:Java NIO 只是对底层操作系统机制的一层封装。
Linux 上
Selector.open()→EPollSelectorProvider→epoll_createmacOS 上
Selector.open()→KQueueSelectorProvider→kqueue
如果你在 Linux 上运行 Java NIO,底层就是 epoll;在 macOS 上,底层就是 kqueue。
上层 Java 代码完全一样,但内核机制不同。
可以这样总结(面试高分)
「I/O 多路复用的核心是操作系统内核从底层支持事件通知机制,而不是靠上层轮询。
epoll 之所以高效,是因为它在网络栈中嵌入了回调,当数据到达时,内核主动把就绪的 fd 加入就绪链表,并唤醒等待的进程。
上层框架(Netty、Java NIO)只是对这些内核机制的封装。
没有内核的底层支持,无论上层怎么设计都不可能做到 O(1) 的高效事件通知。」
操作系统底层的事件通知能力(epoll/kqueue),才是多路复用高效的根本原因。上层一切框架都是站在这个基础之上的封装。
我说实话这个事件通知机制 跟观察者模式 好像啊
epoll 的事件通知机制,从设计思想上讲,就是典型的“观察者模式”在内核态的实现。
我们把这个对应关系彻底拆开,你就会发现,很多底层技术思想和你日常用的设计模式是相通的。
一、观察者模式 vs epoll 事件通知
| 观察者模式(OOP) | epoll 机制(内核) | 说明 |
|---|---|---|
| 主题(Subject) | 被监控的 socket | 事件来源 |
| 观察者(Observer) | epoll 实例(红黑树 + 就绪链表) | 负责收集和通知事件 |
| 注册(register) | epoll_ctl(ADD, fd) | 把 socket 加入 epoll 的红黑树 |
| 事件触发(notify) | 数据到达 → sk_data_ready → ep_poll_callback | 内核主动通知,而不是轮询 |
| 获取事件(get event) | epoll_wait 返回就绪链表 | 观察者拉取通知 |
现在可以回答一个很难的问题
“epoll 和观察者模式有什么关系?”
答案:
「epoll 是观察者模式在内核态的实现。socket 是被观察者,epoll 实例是观察者。通过
epoll_ctl注册 socket,当 socket 有事件时,内核主动调用回调函数ep_poll_callback,把就绪的 fd 加入就绪链表,然后通知阻塞在epoll_wait上的线程。这和用户态观察者模式的思想完全一致,只是运行在内核态。」
四、为什么这个理解很重要?
| 维度 | 意义 |
|---|---|
| 打通知识体系 | 设计模式和操作系统底层不是割裂的 |
| 面试亮点 | 能说出「epoll 是观察者模式的内核实现」会让面试官刮目相看 |
| 架构设计 | 理解事件驱动系统的本质 |
设计模式层面:观察者模式
操作系统层面:epoll 的回调 + 就绪链表
Java 框架层面:NIO Selector / Netty EventLoop
底层依赖:内核必须支持事件通知,否则上层无法高效
有一个问题, 为啥叫做 多路复用啊
“多路复用”这个词来自通信领域,后来被借用到计算机网络和操作系统里。
结论:
“多路” = 多个通道/连接(很多个 Socket)
“复用” = 用一个东西(一个线程 / 一个 Selector)来处理它们
合起来就是:用单个线程(或单个 Selector)来处理多个网络连接。
从“通信工程”的原意理解
在通信领域(比如电话系统):
多路:很多人同时打电话
复用:把这些通话合并到同一条物理线路上传输
目的:一条物理线路上同时传输多个通话,而不是每个通话拉一条线。
在 I/O 多路复用里是怎么对应的
| 通信工程 | I/O 多路复用(select / epoll) |
|---|---|
| 多个通话 | 多个 Socket 连接 |
| 一条物理线路 | 一个 EventLoop / 一个线程 |
| 复用器(交换机) | Selector / epoll |
一句话:用一条“逻辑管道”(一个线程 + Selector)来处理多个 Socket。
几种“多路复用”的对照
1. I/O 多路复用(select / epoll)
多个 Socket
一个
epoll_wait哪个 Socket 有数据,就处理哪个
2. 网络协议中的时分复用 / 频分复用
多个数据流
一条物理线路
按时间片或频率分给不同数据流
3. Java NIO 的 Selector
多个 Channel
一个
select()调用哪些 Channel 就绪,就处理哪些
4. RDB 多路复用(比如 Redis)
多个客户端连接
一个主线程
用 epoll 把请求分发到不同命令处理
有哪些 IO 操作?
IO 操作就是“输入输出”动作,在计算机里本质是 把数据从内核空间拷贝到用户空间(读)或反过来(写)。
常见的几种 IO 操作:
| 操作类型 | 例子 |
|---|---|
| 网络 IO | socket read() / write() |
| 文件 IO | read() / write() 读写硬盘文件 |
| 标准输入输出 | scanf / printf(最终也是系统调用) |
| 管道 / 消息队列 | pipe / mq_send |
在网络编程里,主要说的是 socket 的读写。
I/O 多路复用复用的是什么?
你直觉上觉得应该是“复用 IO 操作”,这是对的,但不是复用“读写”这个动作,而是复用“等待哪个 IO 可以做了”这一层动作。
可以把整个 IO 过程拆成两个阶段:
等待数据就绪(内核准备好数据)
实际拷贝数据(从内核到用户空间)
I/O 多路复用复用的是第一阶段——“等待多个 IO 各自就绪”的等待过程。
用更直观的伪代码帮你理解
传统阻塞方式(不复用)
// 线程 1read(sock1); // 阻塞,只等 sock1// 线程 2read(sock2); // 阻塞,只等 sock2
每个 socket 需要一个线程去等它 → 没有复用
I/O 多路复用方式(复用一个线程等所有)
// 一个线程select(socks, ...); // 同时等待多个 socket:哪个就绪就处理哪个
一个等待调用,同时等 1000 个 socket → 复用了“等待”这个动作
为什么这个命名容易搞混?
因为你直觉把“I/O 操作”理解为 read() / write() 本身。
但在 I/O 多路复用的语境下,“I/O 操作”指的是“等待 IO 就绪”这个子动作。
英文 I/O multiplexing 中的 I/O 包含“等待就绪 + 实际传输”,但复用发生的层次在“等待”。
中文如果改成“IO 等待多路复用”也许更好懂,但官方名字已经固定为 I/O 多路复用。
“I/O 多路复用复用的不是 read() / write() 本身,而是等待多个 I/O 就绪的过程。
它用一个 select 或 epoll_wait 取代了 N 个阻塞的 read,从而把多个‘等待’合并成一个。
这就是‘复用’的真正含义。”
I/O 多路复用的底层核心思想,就是“事件通知机制”。
所有主流的多路复用实现(epoll、kqueue、IOCP),本质上都是内核在“某个 fd 就绪”时主动通知用户态,而不是让用户态反复询问。
---------------------------------------------------- 补充
fd = file descriptor(文件描述符)。
fd 就是操作系统给每个打开的文件、socket、管道等资源分配的「整数编号」。
你可以把它理解为:
餐厅给你的一张排队小票(号码牌)
你凭这个小票找到你对应的资源(文件、socket、管道)
常见的 fd 有哪些?
| 类型 | fd 值 | 说明 |
|---|---|---|
| 标准输入 | 0 | stdin |
| 标准输出 | 1 | stdout |
| 标准错误 | 2 | stderr |
| 普通文件 | 3, 4, 5… | open() 返回的 fd |
| socket | 3, 4, 5… | socket() 返回的 fd |
| epoll 实例 | 7, 8… | epoll_create() 返回的 fd |
注意:fd 不仅可以是文件,也可以是 socket、管道、epoll 实例本身(因为 Linux 一切皆文件)。
咱们来复习一下线程的状态
Java(JVM)线程状态
| 状态 | 说明 | 进入条件 | 退出条件 |
|---|---|---|---|
| NEW | 线程创建,未启动 | new Thread() | start() |
| RUNNABLE | 可运行 / 正在运行 | start() 之后 | 等待 CPU 或执行完毕 |
| BLOCKED | 等待锁 | 进入 synchronized 没抢到锁 | 获得锁 |
| WAITING | 无限等待 | wait() / join() / park() | notify() / unpark() |
| TIMED_WAITING | 限时等待 | wait(timeout) / sleep(timeout) | 超时 / 通知 |
| TERMINATED | 结束 | run() 执行完 | — |
Java JVM中线程的状态 有 线程在被创建出来的时候, 处于 new 初始状态, 执行了start 方法之后, 进入了 运行状态 runnable . 如果 线程调用了wait方法, 就会进入 waiting 等待状态, 而如果调用wait方法同时传入时间, 在等待的基础上增加了超时机制 线程会处于 timed_waiting状态 ,即超时等待状态 , 超时等待结束 ,或者其他线程通知notify 就会返回运行状态runnable . 如果进入了同步代码块 比方说synchronized修饰 的, 如果没有 获取到锁, 线程就会进入阻塞状态
操作系统线程状态
| 状态 | 说明 |
|---|---|
| 就绪(Runnable) | 线程可运行,等待 CPU 分配时间片 |
| 运行(Running) | 线程正在 CPU 上执行 |
| 阻塞(Blocked / Sleeping) | 线程等待事件(I/O、锁、时间、唤醒信号等),不占用 CPU |
JVM 状态与 OS 状态对应关系(面试必考点)
| JVM 状态 | OS 状态 | 说明 |
|---|---|---|
NEW | 不存在对应 | 还没 start(),OS 不知道这个线程 |
RUNNABLE | 就绪 或 运行 | JVM 把“可运行”和“运行中”合并了 |
BLOCKED | 阻塞 | 等待锁 |
WAITING | 阻塞 | 等待 notify/unpark |
TIMED_WAITING | 阻塞 | sleep/wait(timeout) 等 |
TERMINATED | 不存在对应 | 线程已销毁 |
关键点:JVM 把 OS 的 就绪 + 运行 合并成了 RUNNABLE;把多种等待(锁、I/O、时间)都映射到 OS 的 阻塞。


