事件循环:Python / JS / Redis 都在用,是一回事吗?

三个都叫「事件循环」,本质上属于同一类技术:Reactor 模式 + I/O 多路复用 + 非阻塞 I/O 构成的事件驱动并发模型。但它们的事件抽象不同、调度单位不同、宿主不同、实现完全不同——Python 调度的是协程,JS 调度的是任务回调,Redis 调度的是客户端命令。本文从本质讲到三份具体实现。

一句话:思想同源(一个线程盯住成千上万个 fd,谁就绪处理谁),实现各写各的。
本质:Reactor 模式 底座:epoll / kqueue / IOCP Python 调度协程 JS 调度任务 Redis 调度命令
1
结论先行:同源、不同形
先把"是不是一回事"钉死,再逐层展开。

是同一类技术吗?是

三者都实现了 Reactor(反应器)模式:一个循环体,调用操作系统提供的 I/O 多路复用器(Linux 上就是 epoll),问一句"哪些连接就绪了",拿到就绪列表后逐个分发给对应的处理器。

实现方案一样吗?不一样

没有一行共享代码。Python 用标准库 asyncio 纯 Python 实现;JS 由宿主(浏览器 / libuv)用 C++ 实现,语言层面只规定任务与微任务语义;Redis 用自研的 ae.c(约 500 行 C)自己写了一个极简事件库。

解决的问题一样吗?部分相同

共同点:用单线程扛高并发 I/O,避开多线程的锁与上下文切换。不同点:Python 为了绕开 GIL 下的 I/O 并发;JS 为了不阻塞 UI 与渲染;Redis 为了无锁地保证命令原子性 + 极致降低延迟。

最关键的区分点:看「事件」到底是什么。 Python 的事件 = 协程的一次可继续执行(I/O 就绪 / 定时器到期);JS 的事件 = 一个任务回调(宏任务/微任务/渲染);Redis 的事件 = 某个客户端 socket 可读可写 + 定时任务。事件语义不同,决定了三者的循环长相完全不同。
2
事件循环本质属于什么技术
它不是一个"某个语言的特性",而是四种成熟技术的组合体。

事件循环 = 非阻塞 I/O + I/O 多路复用 + Reactor 设计模式 + 用户态协作式调度。四块拼起来才完整,缺任何一块都不成立:

组成技术它负责什么没有它会怎样
非阻塞 I/O把 socket 设成非阻塞,read() 没数据就立刻返回 EAGAIN,而不是把线程卡住。一次 read 卡死整个线程,后面所有连接一起等。
I/O 多路复用由一个系统调用(select/poll/epoll/kqueue/IOCP)同时监视成千上万个 fd,返回"哪些就绪"。只能一个线程盯一个连接(thread-per-connection),C10K 直接崩。
Reactor 模式设计模式层面:把"事件识别"与"事件处理"解耦——注册回调 → 事件到来 → 分发器(Demultiplexer)找到对应 handler → 执行。逻辑全糊在一个大 while 里,无法扩展。
用户态协作式调度调度发生在用户态,靠"主动让出(await / yield / return)"切换,而不是靠操作系统抢占。那就变回多线程抢占式调度了。
图 1 · 事件循环在并发模型谱系中的位置:它是「单线程 + 用户态协作调度」这条路线的代表
并发编程模型 ① 多进程 / 多线程(抢占式) OS 调度 · 上下文切换重 · 需要锁 ② 事件循环(协作式) 用户态调度 · 无锁 · 单线程扛并发 回调 / Promise JS 早期形态 协程 / async-await Python · Go · JS 现代 支撑底座:I/O 多路复用 epoll(Linux) · kqueue(macOS/BSD) · IOCP(Windows) · evport(Solaris) 阻塞式同步 thread per conn
顺带澄清一个常见误解:事件循环不等于单线程。事件循环要求"业务处理逻辑跑在少数几个线程上",但底层可以有帮手:Node 用 libuv 线程池做文件 I/O 与 DNS;Redis 6.0+ 用 I/O 线程做 socket 读写;Python 可以用 run_in_executor 把阻塞调用扔进线程池。准确说法是:调度线程少且集中,阻塞操作外包。
3
通用模型:所有事件循环都长这个样
先抽象出一个最小骨架,后面三个都是它的变体。
# 一切事件循环的伪代码骨架
while not stopped:
    1. timeout = 计算下一个定时器还有多久到期   # 不能因为等 I/O 而误了定时任务
    2. events = multiplexer.wait(timeout)        # epoll_wait / kevent:阻塞在此,直到有 fd 就绪或超时
    3. for ev in events:                          # 把就绪事件转成「待办」
            ready_queue.push(handlers[ev.fd])
    4. for h in due_timers: ready_queue.push(h)  # 到期的定时器也进待办
    5. while ready_queue: ready_queue.pop().run() # 串行执行,遇阻塞点就交还控制权
图 2 · Reactor 通用模型:事件源 → 多路复用器 → 就绪队列 → 分发器 → 处理器
事件源 客户端 socket 文件 / 管道 定时器 信号 / 自定义 I/O 多路复用器 epoll / kqueue 内核帮你盯着所有 fd 返回「就绪集合」 而不是一个个轮询 就绪队列 ready queue 事件循环(单线程) Event Loop 取出 → 执行 执行 → 让出 让出 → 取下一个 串行 · 无锁 处理器 读请求 执行逻辑 写响应 定时任务 处理完继续监听下一轮(循环)
记住这三句话,就能看懂任何事件循环:
  • 谁在等?——循环体阻塞在多路复用器上,而不是阻塞在某个连接上。
  • 等什么?——等「就绪通知」和「定时器到期」。
  • 执行多久?——只执行到下一个让出点(await / 回调返回 / 命令执行完),绝不在里面做阻塞式等待。
4
它到底解决什么问题
不是为了"更快",而是为了"更省、更稳、更简单"。
C10K
经典问题:单机 1 万并发连接。thread-per-connection 模型下 1 万线程的内存与调度开销直接压垮系统。
无锁
单线程内串行执行,数据结构不需要加锁,天然避免死锁、竞态、缓存行伪共享。
低延迟
没有线程上下文切换(一次约 1~10 微秒),命令/任务可以做到微秒级响应。
对比维度多线程 / 多进程模型事件循环模型
并发单位一个连接一个线程(或线程池)一个线程处理全部连接
调度方操作系统内核抢占式调度用户态协作式调度(主动让出)
上下文切换频繁,需要陷入内核,保存寄存器/栈几乎没有,只是函数跳转或协程切换
内存开销每线程默认栈 1~8 MB,1 万连接 = 数十 GB每连接只多一个对象/结构体,KB 级
数据竞争必须加锁,可能出现死锁单线程内无竞争,无需锁
CPU 密集任务擅长可用多核不擅长一个耗时任务卡死全局
I/O 密集任务可以但线程多了开销陡增极擅长这是它的主场
事件循环最大的软肋:怕阻塞。 循环体只有一条,任何一段同步阻塞代码都会让后面所有事件全部排队。这就是 Redis 怕 KEYS *、Node 怕 JSON.parse 大对象、Python asyncio 里不能写 time.sleep() 而必须写 await asyncio.sleep() 的根本原因。
5
Python 的事件循环:asyncio
调度单位是「协程 Task」,由标准库纯 Python 实现,你必须显式写出来。

Python 的事件循环是 asyncio 标准库提供的(_asyncio 有 C 加速实现)。它不是语言内置的——你不主动拿就没有:asyncio.run(coro) 内部会创建/获取一个事件循环,然后 run_forever()。

它的核心只有三样数据结构:

# asyncio/base_events.py —— _run_once() 骨架(高度简化,保留真实结构)
def _run_once(self):
    # 1. 到期的定时器从堆里弹出,塞进就绪队列
    end_time = self.time() + self._clock_resolution
    while self._scheduled and self._scheduled[0]._when < end_time:
        handle = heapq.heappop(self._scheduled)
        self._ready.append(handle)

    # 2. 算 selector 的阻塞超时:不能因为等 I/O 而耽误定时器
    timeout = 0 if self._ready else None
    if timeout is None and self._scheduled:
        timeout = min(max(0, self._scheduled[0]._when - self.time()),
                      MAXIMUM_SELECT_TIMEOUT)

    # 3. 阻塞在 I/O 多路复用上(唯一的"等")
    event_list = self._selector.select(timeout)

    # 4. I/O 就绪 → 对应回调进就绪队列
    self._process_events(event_list)

    # 5. 执行本轮就绪回调(先快照长度,防止队列被无限追加)
    ntodo = len(self._ready)
    for _ in range(ntodo):
        self._ready.popleft()._run()
图 3 · Python asyncio 事件循环:就绪队列 + selector + 定时器堆,三者协同
_ready(deque) 就绪回调队列 _scheduled(heapq) 定时器最小堆 sleep(2) · sleep(5) · call_later _selector(epoll/kqueue) I/O 多路复用 监听 socket 可读 / 可写 selector.select(timeout) timeout = 下一个定时器到期时间 逐个执行 _ready 中的回调 遇到 await → 挂起协程 → 交还控制权 await 时注册 fd 就绪回调
Python 独有陷阱:
  • 一个协程不 await,全部协程一起卡死——这是协作式调度的代价。写 time.sleep(3) 会让整个循环停 3 秒,必须写 await asyncio.sleep(3)。
  • CPU 密集任务会卡死循环:用 loop.run_in_executor() 扔进线程池/进程池。
  • 阻塞库(requests、pymysql)不能在协程里直接用:要用 aiohttp、aiomysql 这类异步库,否则等价于同步阻塞。
6
JS 的事件循环:语言只定语义,宿主负责实现
调度单位是「任务(Task)」,分浏览器与 Node 两套实现。

JS 的事件循环不在 ECMAScript 语言规范里,而是由宿主环境定义:浏览器按 HTML 规范实现一套,Node.js 用 C 库 libuv 实现另一套。两者共有的核心语义是宏任务 + 微任务。

浏览器:task → microtask → render

  • 宏任务(macrotask):setTimeout、setInterval、DOM 事件、I/O、postMessage。
  • 微任务(microtask):Promise.then/catch/finally、queueMicrotask、MutationObserver、await 后续代码。
  • 规则:执行一个宏任务 → 清空全部微任务 → 可能渲染(rAF → 布局 → 绘制)→ 下一个宏任务。

Node.js:libuv 六阶段

  • timers:到期的 setTimeout / setInterval。
  • pending callbacks:上一轮遗留的系统级回调。
  • idle / prepare:内部使用。
  • poll:核心阶段,取 I/O 事件并执行回调;队列空时阻塞等待。
  • check:setImmediate。
  • close callbacks:socket.on('close') 等。
图 4 · 浏览器 JS:调用栈空了才从队列取任务;微任务永远优先且必须清空
Call Stack 调用栈(同步) 当前执行函数 栈空了才取任务 Microtask Queue 微任务(高优先级,全部清空) Macrotask Queue 宏任务(每轮只取一个) Event Loop ① 取 1 个宏任务 ② 清空所有微任务 ③ 是否渲染? ④ 回到 ① 微任务递归 = 饿死渲染 Render rAF → 样式/布局 → 绘制 Web APIs setTimeout · fetch DOM 事件 单线程:同步代码 → 微任务 → 渲染 → 下一宏任务
// 经典执行顺序题:为什么是 1 2 3 5?
console.log('1 sync');
setTimeout(() => console.log('5 macrotask'), 0);   // 宏任务,最后
Promise.resolve().then(() => console.log('3 microtask'));  // 微任务,先
console.log('2 sync');
// 输出:1 sync → 2 sync → 3 microtask → 5 macrotask
// 原因:同步代码跑完 → 清空微任务 → 才取下一个宏任务

// Node 特有:nextTick 优先级高于 Promise 微任务
process.nextTick(() => console.log('A nextTick'));    // 最先
Promise.resolve().then(() => console.log('B promise'));  // 次之
setImmediate(() => console.log('C immediate'));        // check 阶段
setTimeout(() => console.log('D timeout'), 0);         // timers 阶段
// 输出:A → B → D / C(后两者顺序在主模块中不保证)
JS 独有的「饿死」问题:微任务队列必须一次性清空,如果微任务里又产生微任务,会无限循环下去,浏览器永远拿不到渲染的机会。所以 function spin(){ queueMicrotask(spin) } 会让页面彻底卡死,而 setTimeout(spin, 0) 不会。
7
Redis 的事件循环:自研的 ae.c
调度单位是「客户端命令」,500 行 C 代码撑起十万级连接。

Redis 没有用 libevent / libuv,而是自己写了一个极简事件库 ae.c(约 500 行),用策略模式在编译期选择后端:ae_evport.c(Solaris)→ ae_epoll.c(Linux)→ ae_kqueue.c(macOS/BSD)→ ae_select.c(兜底)。上层只调统一的 aeApiPoll()。

它只有两类事件:

// ae.c —— 主循环与事件处理(简化,保留真实结构)
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop,
            AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP);
    }
}

int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
    // 1. 计算最近一个时间事件还有多久 → 作为 poll 的超时
    shortest = 最近时间事件剩余毫秒数;

    // 2. beforeSleep:回复缓冲区刷盘、AOF flush、lazyfree、整理待写客户端
    if (eventLoop->beforesleep && flags & AE_CALL_BEFORE_SLEEP)
        eventLoop->beforesleep(eventLoop);

    // 3. 阻塞等 I/O:epoll_wait / kevent / select
    numevents = aeApiPoll(eventLoop, tvp);

    // 4. afterSleep(Redis 6.0+:唤醒 I/O 线程并行读写 socket)
    if (eventLoop->aftersleep && flags & AE_CALL_AFTER_SLEEP)
        eventLoop->aftersleep(eventLoop);

    // 5. 分发就绪的文件事件
    for (j = 0; j < numevents; j++) {
        fe = &eventLoop->events[eventLoop->fired[j].fd];
        mask = eventLoop->fired[j].mask;
        if (fe->mask & mask & AE_READABLE)  fe->rfileProc(eventLoop, fd, fe->clientData, mask);
        if (fe->mask & mask & AE_WRITABLE)  fe->wfileProc(eventLoop, fd, fe->clientData, mask);
    }

    // 6. 处理到期的时间事件(serverCron 等)
    if (flags & AE_TIME_EVENTS) processed += processTimeEvents(eventLoop);
    return processed;
}
图 5 · Redis ae.c 主循环:文件事件与时间事件融合在同一个循环里
文件事件 File Event socket 可读 / 可写 时间事件 Time Event serverCron(链表) 事件源汇总 aeCreateFileEvent 注册 beforeSleep() 刷回复缓冲 · AOF · lazyfree aeApiPoll(timeout) epoll_wait · kevent · select afterSleep() 6.0+ 唤醒 IO 线程读写 主线程串行执行命令 读 → 解析 → 执行 → 写回 processTimeEvents() serverCron:过期 · 统计 · rehash aeMain:无限循环,永不退出
关于 Redis 6.0+ 的「多线程」:开启 io-threads 4 + io-threads-do-reads yes 后,I/O 线程只负责 socket 的读取/解析与写回,命令执行依然严格单线程。官方建议线程数小于 CPU 核数(4 核设 2~3,8 核设 6)。这么做的原因很实在:网络读写才是瓶颈,而命令执行保持单线程就不用给 Lua 脚本、事务、数据结构加任何锁。
Redis 的软肋同样是阻塞:一条慢命令(KEYS *、大 key 删除、FLUSHALL、复杂度 O(N) 的聚合)会让所有客户端一起等。所以 Redis 提供了 UNLINK(异步删除)、SCAN(渐进式遍历)、lazyfree 机制——本质都是把阻塞操作挪出主线程。
8
三者横向对比:一张表看懂
从事件抽象、调度单位到底层实现,逐维度拆开。
维度Python asyncioJavaScriptRedis ae.c
事件是什么 协程可以继续执行了(I/O 就绪 / 定时到期) 一个任务回调(宏任务 / 微任务 / 渲染) fd 可读可写 + 时间事件到期
调度单位 协程 / Task(显式 async-await) 回调函数(Promise 是回调的语法糖) 客户端连接上的一条命令
谁实现 标准库 asyncio(纯 Python,C 加速) 宿主:浏览器引擎 / Node 的 libuv(C++) Redis 自研 ae.c(约 500 行 C)
多路复用后端 selectors → epoll / kqueue / devpoll 浏览器:内核 IO 线程;Node:libuv(epoll/kqueue/IOCP) 编译期选择:evport / epoll / kqueue / select
定时器结构 最小堆 heapq,O(log n) 浏览器:最小堆;Node:libuv 时间轮/最小堆 单链表遍历(数量少,够用)
就绪队列 deque(FIFO) 宏任务队列 + 微任务队列(双队列) aeFiredEvent 数组(epoll 直接返回)
优先级机制 无(FIFO,先到先服务) 有(微任务 > 宏任务;Node 还有 nextTick 更高) 有(AE_BARRIER 保证写先于读,为 AOF 顺序)
要写特殊语法吗 要async / await 必须显式写 半要async/await 或回调,同步代码自动进循环 不要它是服务端,使用者是客户端
额外线程 可用 run_in_executor 线程池 Node:libuv 线程池(fs/dns/crypto);浏览器:Web Worker 6.0+ I/O 线程(只做读写)+ BIO 后台线程(关 fd、fsync)
阻塞后果 整个循环所有协程全停 页面卡死 / Node 停止响应 所有客户端一起等
解决的独特问题 GIL 下的 I/O 并发(单线程也能高并发) 单线程不阻塞 UI 与渲染 无锁保证命令原子性 + 极低延迟
一句话记忆三者差异:Python 是「我显式让出,你才能跑」;JS 是「同步跑完清微任务,再排下一个」;Redis 是「谁的 socket 有数据,我就先处理谁,命令一条一条来」。
9
实现方案一样吗:思想同源,代码各写各的
从"哪些一样、哪些不一样"两个方向回答。

✅ 一样的(思想层)

  • 都是 Reactor 模式:注册 → 等待 → 分发 → 执行。
  • 都依赖 I/O 多路复用:select/poll/epoll/kqueue 家族。
  • 都要求 非阻塞 I/O,绝不在循环体里同步等待。
  • 都用 定时器计算 poll 超时,避免"等 I/O 误了定时任务"。
  • 都是 用户态协作式调度,调度点由程序主动让出。
  • 都怕阻塞:一段同步耗时代码拖垮全局。

❌ 不一样的(实现层)

  • 代码完全独立:asyncio(Python)、libuv/V8(C++)、ae.c(C),零共享。
  • 事件抽象不同:协程续体 / 任务回调 / socket 命令。
  • 队列结构不同:deque / 双队列+微任务 / fired 数组。
  • 定时器结构不同:最小堆 / 最小堆或时间轮 / 单链表。
  • 优先级策略不同:Python 无优先级;JS 微任务插队;Redis AE_BARRIER 保证 AOF 顺序。
  • 线程模型不同:Python 单线程可选线程池;Node libuv 线程池;Redis IO 线程只做读写。
  • 暴露给用户的形态不同:库(要你写代码)/ 语言运行时(自动)/ 服务端内部(你感知不到)。
图 6 · 三者堆叠在同一套「I/O 多路复用」底座上,但上层是各自独立的实现
Python asyncio 协程 Task · deque · heapq JS Event Loop 宏/微任务 · libuv 六阶段 Redis ae.c 文件事件 · 时间事件 共同的抽象层:Reactor 模式(注册 → 等待 → 分发 → 执行) 思想同源:非阻塞 I/O + 事件驱动 + 用户态协作式调度 操作系统底座:I/O 多路复用 epoll(Linux) · kqueue(macOS / BSD) · IOCP(Windows) · evport(Solaris) · select(兜底)
10
交互演示:三种循环一轮都做了什么
切换模型,点「下一步」逐步看一轮循环里发生的事。
共 6 步 · 第 0 步

各模型的典型坑

模型典型坑正确做法
Python协程里写 time.sleep() / 用 requests改 await asyncio.sleep() / 用 aiohttp;CPU 活儿走 run_in_executor
JS 浏览器微任务递归导致页面不渲染;长同步任务卡 UI用 setTimeout 让出;大计算放 Web Worker
Node大 JSON 解析、同步加密、fs.readFileSync用流 / 异步 API;CPU 密集用 worker_threads
RedisKEYS *、删大 key、FLUSHALL 阻塞全局用 SCAN / UNLINK;开启 lazyfree
选型建议(一句话版):
  • I/O 密集、连接数巨大、逻辑简单 → 事件循环(Node / asyncio / Go netpoll 都行)。
  • CPU 密集、要吃满多核 → 多线程 / 多进程,或把计算外包出去(Go 的 goroutine + 多 P 是另一种折中)。
  • 混合型 → 事件循环管 I/O,线程池管阻塞,这是 Node、Redis、现代 Python 服务共同的做法。
11
总结

一句话收尾

  • 本质:事件循环 = 非阻塞 I/O + I/O 多路复用 + Reactor 模式 + 用户态协作式调度。它是一种并发模型,不是某个语言特性。
  • 解决什么:用一个(或极少数)线程扛住海量并发连接,避开多线程的锁、死锁与上下文切换开销;代价是绝不能阻塞。
  • Python:asyncio 标准库实现,deque 就绪队列 + heapq 定时器 + selectors 多路复用,调度协程,必须显式 async/await。
  • JS:语言只定义宏任务/微任务语义,实现交给宿主——浏览器是「任务 → 微任务 → 渲染」,Node 是 libuv 六阶段(timers→pending→idle→poll→check→close)。
  • Redis:自研 ae.c,只有文件事件(socket 读写)与时间事件(serverCron),命令执行严格单线程;6.0+ 的 I/O 线程只帮忙读写 socket。
  • 实现一样吗:思想同源(都跑在 epoll/kqueue 上),代码、事件抽象、队列结构、优先级、线程模型全都不一样——不共享一行代码。