做服务端开发时,IO 多路复用、epoll、事件循环这些词几乎绕不开。它们经常一起出现,但很多人第一次接触时会有一种感觉:每个词单独看都认识,连起来就有点乱。
这篇文章想做的事很简单:把这条线顺一遍。先说什么是 IO 多路复用,再说 epoll 到底解决了什么问题,然后落到 Redis 的事件循环上,最后再看看 Java、Go、JavaScript、Windows 分别站在这条线的什么位置。
一、IO 多路复用到底是在解决什么问题
先从最普通的网络服务器说起。
如果一个服务器要同时服务很多客户端,最直接的写法往往是“一条连接一个线程”。这个思路很直观,但问题也明显:连接一多,线程就会爆炸,内存占用、线程切换、调度开销都会迅速上来。
于是很多人会想到第二种路子:把 socket 设成非阻塞,然后自己不断去问,“这个连接有数据吗?那个连接有数据吗?”
这比“一连接一线程”省线程,但会带来另一个问题:CPU 大量时间都浪费在反复检查上。
IO 多路复用就是为了解决这两个问题。
它的核心思路可以概括成一句话:
一个线程同时盯住很多连接,哪个连接真的有事了,再去处理哪个。
也就是说,应用程序不需要自己不停遍历所有连接,而是把“等待事件”这件事交给操作系统。操作系统负责盯着这些连接,一旦有连接可读、可写或者发生异常,就把应用唤醒。
可以把它理解成这样:
传统阻塞 IO:
一个服务员盯一张桌子,客人不点餐也得一直站着等
非阻塞轮询:
一个服务员来回跑所有桌子,反复问“好了吗”
IO 多路复用:
一个服务员管很多桌子,谁按铃了就去谁那边
真正高性能的服务器,基本都离不开这个思路。
二、epoll 是什么,它为什么比 select/poll 更适合高并发
IO 多路复用是一种思想,epoll 是 Linux 下这套思想的一种高效实现。
在 epoll 出来之前,Linux 上已经有 select 和 poll。它们也能同时监听多个连接,但有两个典型问题:
第一,每次调用都要把要监听的 fd 集合传给内核。
第二,内核在判断哪些 fd 就绪时,通常要把整个集合遍历一遍。
连接数少的时候,这不算大问题。可一旦连接数达到几万、几十万,而其中真正活跃的只有一小部分,这种“每次都把全名单扫一遍”的成本就很明显了。
epoll 的改进点,核心就两个:
- 把关注的 fd 长期保存在内核里
- 把已经就绪的 fd 单独放到就绪队列里
这样一来,应用程序平时只需要做两件事:
- 用
epoll_ctl告诉内核:我关心哪些 fd - 用
epoll_wait等待:哪些 fd 已经准备好了
这和早期模型最大的差别就是,应用程序不再需要每次都把整份名单交上去,也不再需要反复扫描全部连接。它只处理“已经就绪”的那一小部分。
简单画一下就是:
所有被监听的连接
│
▼
内核中的关注集合
│
├─ 没事件:线程睡眠
│
└─ 有事件:放进就绪队列
│
▼
epoll_wait 返回
所以,epoll 快不快,关键不在于“它用了什么高深算法”,而在于它把“关注谁”和“谁已经就绪”拆开了。
三、Redis 的事件循环,本质上就是“文件事件 + 时间事件”
Redis 常被拿来举例,因为它的网络模型很典型。
Redis 的主线程会进入一个循环,不断处理两类事情:
- 文件事件:比如客户端连进来了、客户端发命令了、回复可以写回去了
- 时间事件:比如一些周期性任务、超时检查、过期清理等
很多人一听“事件循环”就会觉得很玄,其实把它拆开之后非常朴素:
- Redis 启动时,把监听 socket 注册到 epoll
- 主线程进入循环
- 没事可做时,调用
epoll_wait休眠 - 有连接进来或有数据到来时,内核唤醒它
- 它读取命令、执行命令、写回结果
- 顺手在循环里处理时间事件
大致像这样:
Redis 主线程
│
├─ 注册监听 socket
├─ 进入事件循环
├─ epoll_wait 等待网络事件
├─ 处理命令请求
└─ 穿插处理时间事件
这里有一个很容易混淆的点:
Redis 确实有时间事件,但网络 IO 这部分并不是靠定时轮询做的。
网络事件的核心仍然是 epoll 这种内核事件通知机制。时间事件只是事件循环里的另一类任务,不是网络模型的主体。
四、Redis 为什么单线程还能很快
很多人第一次看 Redis 会有点困惑:都说高并发要靠多线程,为什么 Redis 长期以单线程模型著称,性能却还很好?
原因其实并不神秘,主要就这几件事叠在一起:
第一,命令执行路径很短,而且主要是内存操作。
第二,单线程避免了锁竞争。
第三,网络等待交给了 epoll,线程不会傻等。
第四,少了大量线程切换的开销。
所以 Redis 的“单线程快”,不是因为单线程天然更快,而是因为:
它把最容易浪费时间的等待阶段交给了内核,把真正要做的工作压缩在一个简单、连续、少竞争的执行路径上。
顺便提一句,Redis 6 之后也引入了 IO 线程,用来分担读写压力。但核心命令执行依然保持单线程,这是它一直坚持的设计取向。
五、Java、Go、Linux 本质上是不是一类机制
答案是:底层思路是同一类,封装层次不一样。
1. Linux:原生地基
Linux 提供的是最底层的 epoll 接口。
Nginx、Redis、很多高性能 C/C++ 网络程序,最终都直接站在这层接口上工作。
这一层最接近操作系统,灵活,控制力强,但也最容易写复杂。
2. Java:Selector、NIO、Netty
Java 早期是 BIO,也就是一连接一线程。后来引入 NIO,把底层的多路复用能力封装成了 Selector。
你在 Java 里不会直接写 epoll 系统调用,但你调用 selector.select() 时,本质上就是在走这类机制。
再往上一层,Netty 又把这件事包装得更完整。
在 Linux 下,它可以直接使用 native epoll;在别的平台上,则会自动切到对应的实现。
所以 Java 不是没有这套机制,而是它把这套机制放在了更高的抽象层。
3. Go:写起来像阻塞,底层其实不是
Go 这块很有意思。
你写 Go 网络代码时,经常就是:
n, err := conn.Read(buf)
看起来像阻塞调用,写法也很像同步代码。但底层并不是“一个连接卡死一个线程”。
Go 运行时内部有一个 netpoller。名字里虽然带了 poll,但这不是“定时轮询”的意思。它底层依然会使用 epoll、kqueue、IOCP 这类操作系统提供的事件机制。
大致过程是:
- goroutine 调用
conn.Read - 如果数据没到,不会一直占着线程死等
- runtime 把这个 goroutine 挂起
- 底层继续等 epoll 之类的事件通知
- 数据到了,再把 goroutine 恢复执行
所以 Go 给人的体验是:你像写同步代码一样写高并发程序,但运行时在背后把异步和调度都做掉了。
六、JavaScript 里的事件循环,和 Redis 的事件循环是一个东西吗
名字一样,但解决的问题不是一个层级。
Redis、epoll 这一套,主要在处理高并发网络 IO。
JavaScript 的事件循环,主要在解决单线程执行环境里怎么安排异步任务、页面渲染和回调执行。
先看浏览器里的 JavaScript。
JavaScript 主线程只有一条,如果它被长时间占住,页面就会卡死。所以浏览器会把很多任务拆分成不同队列,由事件循环来调度。
通常可以把它粗略分成三部分:
- 调用栈:当前正在执行的同步代码
- 宏任务队列:比如
setTimeout、用户点击、网络回调 - 微任务队列:比如
Promise.then、async/await的后续逻辑
执行顺序通常可以这样理解:
- 先把当前调用栈里的同步代码执行完
- 再把微任务队列清空
- 必要时进行页面渲染
- 再取下一个宏任务执行
- 然后重复
看一段经典例子就很直观:
console.log('1')
setTimeout(() => {
console.log('2')
}, 0)
Promise.resolve().then(() => {
console.log('3')
})
console.log('4')
输出结果是:
1
4
3
2
原因很简单:
1、4是同步代码,先执行Promise.then是微任务,同步代码跑完后立即执行setTimeout是宏任务,要再下一轮
所以,JavaScript 的事件循环更像是一个任务调度器。
它和 Redis/epoll 的关系是什么
两者名字相同,但位置不同。
- Redis/epoll 关注的是:很多连接来了,哪个连接现在可以读写
- JavaScript 事件循环 关注的是:当前主线程先执行谁,后执行谁,什么时候处理回调,什么时候刷新页面
如果放到 Node.js 里看,这两者会发生一点“接轨”。
Node.js 的 JavaScript 层也有事件循环,但它底下的异步 IO 能力通常会继续交给更底层的库和操作系统去做,比如 libuv,再继续调用系统的 epoll、kqueue 或 IOCP。
所以可以这么说:
JavaScript 事件循环不是 epoll,但在 Node.js 这样的环境里,它会建立在这类底层 IO 机制之上。
七、Windows 有没有类似 epoll 的东西
有,但设计思路不完全一样。
Windows 下承担类似角色的,是 IOCP,也就是 I/O Completion Port。
epoll 更像是“就绪通知”:
这个 socket 现在可读了,你可以来读了。
IOCP 更像是“完成通知”:
这个异步 IO 我已经帮你做完了,结果在这里,你来取吧。
所以两者虽然都服务于高性能网络编程,但风格不同:
- epoll 偏“谁就绪了”
- IOCP 偏“谁完成了”
1. 事件通知时机
- epoll(Reactor 模式)
内核仅通知 “数据已就绪” (例如 Socket 可读),但 实际 I/O 操作(如数据从内核缓冲区复制到用户空间)仍需用户线程主动完成。
典型流程:
epoll_wait() 返回可读事件 → 调用 read()/recv() 执行数据拷贝。
关键限制:若未一次性处理完数据,需依赖水平触发(LT)模式反复通知,或边缘触发(ET)模式配合非阻塞 I/O 手动循环读取。 - IOCP(Proactor 模式)
内核 完成整个 I/O 操作后才通知用户,数据 已直接复制到用户提供的缓冲区。
典型流程:
提交 WSARecv() 异步请求 → 内核完成数据接收和拷贝 → 通过完成包通知“操作已完成”。
核心优势:用户线程无需参与数据拷贝,彻底避免因 I/O 阻塞导致的线程闲置。
2. I/O 操作责任归属
- epoll
仅负责 事件检测,I/O 读写操作由用户线程同步完成,属于 半异步模型。 - IOCP
内核全程管理 I/O 操作(包括等待数据、拷贝数据),用户只需处理结果,属于 全异步模型。
如果是高级语言或者成熟框架,底层差异通常会被封装起来。
比如 Java、Go、Node.js 这类运行时,会根据平台自动选择底层实现。
如果是跨平台库,有时也会在 Windows 上提供一层“epoll 风格兼容”,例如用 wepoll 之类的方案去模拟接口行为。
八、这些机制到底靠什么工作:事件通知,还是定时轮询
这个问题非常重要,因为它关系到对整套模型的理解是否到位。
答案很明确:
Linux、Redis、Java NIO、Go 的网络 IO 核心,靠的都是事件通知,不是定时任务轮询。
也就是说,它们不是每隔 10 毫秒检查一次“有没有数据”;而是把线程挂起,等内核在数据真正到来时再唤醒。
这个区别很大:
如果是定时轮询:
- 没数据也得反复醒来检查
- CPU 会空转
- 还会引入额外延迟
如果是事件通知:
- 没数据时线程休眠
- 有数据时内核直接唤醒
- 延迟和资源消耗都更合理
这里唯一容易让人误会的是两件事:
第一,Go 里有个名字叫 netpoller,看起来像轮询,其实底层仍然是事件驱动。
第二,Redis 有时间事件,但那是另一类任务,不等于“网络 IO 靠定时器检查”。
所以,把这件事记成一句话最合适:
现代高性能网络框架在等待网络数据这件事上,依赖的是内核事件唤醒,而不是应用层定时去问。