从 IO 多路复用到 Redis 事件循环,顺手理清 Java、Go、JavaScript 和 Windows的事件循环

7 阅读11分钟

做服务端开发时,IO 多路复用、epoll、事件循环这些词几乎绕不开。它们经常一起出现,但很多人第一次接触时会有一种感觉:每个词单独看都认识,连起来就有点乱。

这篇文章想做的事很简单:把这条线顺一遍。先说什么是 IO 多路复用,再说 epoll 到底解决了什么问题,然后落到 Redis 的事件循环上,最后再看看 Java、Go、JavaScript、Windows 分别站在这条线的什么位置。

一、IO 多路复用到底是在解决什么问题

先从最普通的网络服务器说起。

如果一个服务器要同时服务很多客户端,最直接的写法往往是“一条连接一个线程”。这个思路很直观,但问题也明显:连接一多,线程就会爆炸,内存占用、线程切换、调度开销都会迅速上来。

于是很多人会想到第二种路子:把 socket 设成非阻塞,然后自己不断去问,“这个连接有数据吗?那个连接有数据吗?”
这比“一连接一线程”省线程,但会带来另一个问题:CPU 大量时间都浪费在反复检查上。

IO 多路复用就是为了解决这两个问题。

它的核心思路可以概括成一句话:

一个线程同时盯住很多连接,哪个连接真的有事了,再去处理哪个。

也就是说,应用程序不需要自己不停遍历所有连接,而是把“等待事件”这件事交给操作系统。操作系统负责盯着这些连接,一旦有连接可读、可写或者发生异常,就把应用唤醒。

可以把它理解成这样:

传统阻塞 IO:
一个服务员盯一张桌子,客人不点餐也得一直站着等

非阻塞轮询:
一个服务员来回跑所有桌子,反复问“好了吗”

IO 多路复用:
一个服务员管很多桌子,谁按铃了就去谁那边

真正高性能的服务器,基本都离不开这个思路。

二、epoll 是什么,它为什么比 select/poll 更适合高并发

IO 多路复用是一种思想,epoll 是 Linux 下这套思想的一种高效实现。

在 epoll 出来之前,Linux 上已经有 selectpoll。它们也能同时监听多个连接,但有两个典型问题:

第一,每次调用都要把要监听的 fd 集合传给内核。
第二,内核在判断哪些 fd 就绪时,通常要把整个集合遍历一遍。

连接数少的时候,这不算大问题。可一旦连接数达到几万、几十万,而其中真正活跃的只有一小部分,这种“每次都把全名单扫一遍”的成本就很明显了。

epoll 的改进点,核心就两个:

  1. 把关注的 fd 长期保存在内核里
  2. 把已经就绪的 fd 单独放到就绪队列里

这样一来,应用程序平时只需要做两件事:

  • epoll_ctl 告诉内核:我关心哪些 fd
  • epoll_wait 等待:哪些 fd 已经准备好了

这和早期模型最大的差别就是,应用程序不再需要每次都把整份名单交上去,也不再需要反复扫描全部连接。它只处理“已经就绪”的那一小部分。

简单画一下就是:

所有被监听的连接
        │
        ▼
   内核中的关注集合
        │
        ├─ 没事件:线程睡眠
        │
        └─ 有事件:放进就绪队列
                    │
                    ▼
              epoll_wait 返回

所以,epoll 快不快,关键不在于“它用了什么高深算法”,而在于它把“关注谁”和“谁已经就绪”拆开了。

三、Redis 的事件循环,本质上就是“文件事件 + 时间事件”

Redis 常被拿来举例,因为它的网络模型很典型。

Redis 的主线程会进入一个循环,不断处理两类事情:

  • 文件事件:比如客户端连进来了、客户端发命令了、回复可以写回去了
  • 时间事件:比如一些周期性任务、超时检查、过期清理等

很多人一听“事件循环”就会觉得很玄,其实把它拆开之后非常朴素:

  1. Redis 启动时,把监听 socket 注册到 epoll
  2. 主线程进入循环
  3. 没事可做时,调用 epoll_wait 休眠
  4. 有连接进来或有数据到来时,内核唤醒它
  5. 它读取命令、执行命令、写回结果
  6. 顺手在循环里处理时间事件

大致像这样:

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 这类操作系统提供的事件机制。

大致过程是:

  1. goroutine 调用 conn.Read
  2. 如果数据没到,不会一直占着线程死等
  3. runtime 把这个 goroutine 挂起
  4. 底层继续等 epoll 之类的事件通知
  5. 数据到了,再把 goroutine 恢复执行

所以 Go 给人的体验是:你像写同步代码一样写高并发程序,但运行时在背后把异步和调度都做掉了。

六、JavaScript 里的事件循环,和 Redis 的事件循环是一个东西吗

名字一样,但解决的问题不是一个层级。

Redis、epoll 这一套,主要在处理高并发网络 IO
JavaScript 的事件循环,主要在解决单线程执行环境里怎么安排异步任务、页面渲染和回调执行

先看浏览器里的 JavaScript。

JavaScript 主线程只有一条,如果它被长时间占住,页面就会卡死。所以浏览器会把很多任务拆分成不同队列,由事件循环来调度。

通常可以把它粗略分成三部分:

  • 调用栈:当前正在执行的同步代码
  • 宏任务队列:比如 setTimeout、用户点击、网络回调
  • 微任务队列:比如 Promise.thenasync/await 的后续逻辑

执行顺序通常可以这样理解:

  1. 先把当前调用栈里的同步代码执行完
  2. 再把微任务队列清空
  3. 必要时进行页面渲染
  4. 再取下一个宏任务执行
  5. 然后重复

看一段经典例子就很直观:

console.log('1')

setTimeout(() => {
  console.log('2')
}, 0)

Promise.resolve().then(() => {
  console.log('3')
})

console.log('4')

输出结果是:

1
4
3
2

原因很简单:

  • 14 是同步代码,先执行
  • 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 靠定时器检查”。

所以,把这件事记成一句话最合适:

现代高性能网络框架在等待网络数据这件事上,依赖的是内核事件唤醒,而不是应用层定时去问。