三个都叫「事件循环」,本质上属于同一类技术:Reactor 模式 + I/O 多路复用 + 非阻塞 I/O 构成的事件驱动并发模型。但它们的事件抽象不同、调度单位不同、宿主不同、实现完全不同——Python 调度的是协程,JS 调度的是任务回调,Redis 调度的是客户端命令。本文从本质讲到三份具体实现。
三者都实现了 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 为了无锁地保证命令原子性 + 极致降低延迟。
事件循环 = 非阻塞 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)"切换,而不是靠操作系统抢占。 | 那就变回多线程抢占式调度了。 |
run_in_executor 把阻塞调用扔进线程池。准确说法是:调度线程少且集中,阻塞操作外包。
# 一切事件循环的伪代码骨架 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() # 串行执行,遇阻塞点就交还控制权
| 对比维度 | 多线程 / 多进程模型 | 事件循环模型 |
|---|---|---|
| 并发单位 | 一个连接一个线程(或线程池) | 一个线程处理全部连接 |
| 调度方 | 操作系统内核抢占式调度 | 用户态协作式调度(主动让出) |
| 上下文切换 | 频繁,需要陷入内核,保存寄存器/栈 | 几乎没有,只是函数跳转或协程切换 |
| 内存开销 | 每线程默认栈 1~8 MB,1 万连接 = 数十 GB | 每连接只多一个对象/结构体,KB 级 |
| 数据竞争 | 必须加锁,可能出现死锁 | 单线程内无竞争,无需锁 |
| CPU 密集任务 | 擅长可用多核 | 不擅长一个耗时任务卡死全局 |
| I/O 密集任务 | 可以但线程多了开销陡增 | 极擅长这是它的主场 |
KEYS *、Node 怕 JSON.parse 大对象、Python asyncio 里不能写 time.sleep() 而必须写 await asyncio.sleep() 的根本原因。
Python 的事件循环是 asyncio 标准库提供的(_asyncio 有 C 加速实现)。它不是语言内置的——你不主动拿就没有:asyncio.run(coro) 内部会创建/获取一个事件循环,然后 run_forever()。
它的核心只有三样数据结构:
_ready(collections.deque):就绪回调队列,本轮要执行的回调都在这里。_scheduled(heapq 最小堆):定时任务,按到期时间排序,用于计算 selector 的 timeout。_selector(selectors 模块):I/O 多路复用封装,Linux 用 epoll、macOS 用 kqueue。# 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()
await,全部协程一起卡死——这是协作式调度的代价。写 time.sleep(3) 会让整个循环停 3 秒,必须写 await asyncio.sleep(3)。loop.run_in_executor() 扔进线程池/进程池。JS 的事件循环不在 ECMAScript 语言规范里,而是由宿主环境定义:浏览器按 HTML 规范实现一套,Node.js 用 C 库 libuv 实现另一套。两者共有的核心语义是宏任务 + 微任务。
setTimeout、setInterval、DOM 事件、I/O、postMessage。Promise.then/catch/finally、queueMicrotask、MutationObserver、await 后续代码。setTimeout / setInterval。setImmediate。socket.on('close') 等。// 经典执行顺序题:为什么是 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(后两者顺序在主模块中不保证)
function spin(){ queueMicrotask(spin) } 会让页面彻底卡死,而 setTimeout(spin, 0) 不会。
Redis 没有用 libevent / libuv,而是自己写了一个极简事件库 ae.c(约 500 行),用策略模式在编译期选择后端:ae_evport.c(Solaris)→ ae_epoll.c(Linux)→ ae_kqueue.c(macOS/BSD)→ ae_select.c(兜底)。上层只调统一的 aeApiPoll()。
它只有两类事件:
acceptTcpHandler(新连接)、readQueryFromClient(读命令)、sendReplyToClient(写响应)。serverCron,默认每秒运行 hz 次(默认 10,高连接时自适应提到 500),负责过期键清理、统计更新、增量 rehash、持久化触发、复制心跳等。// 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; }
io-threads 4 + io-threads-do-reads yes 后,I/O 线程只负责 socket 的读取/解析与写回,命令执行依然严格单线程。官方建议线程数小于 CPU 核数(4 核设 2~3,8 核设 6)。这么做的原因很实在:网络读写才是瓶颈,而命令执行保持单线程就不用给 Lua 脚本、事务、数据结构加任何锁。
KEYS *、大 key 删除、FLUSHALL、复杂度 O(N) 的聚合)会让所有客户端一起等。所以 Redis 提供了 UNLINK(异步删除)、SCAN(渐进式遍历)、lazyfree 机制——本质都是把阻塞操作挪出主线程。
| 维度 | Python asyncio | JavaScript | Redis 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 与渲染 | 无锁保证命令原子性 + 极低延迟 |
select/poll/epoll/kqueue 家族。| 模型 | 典型坑 | 正确做法 |
|---|---|---|
| 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 |
| Redis | KEYS *、删大 key、FLUSHALL 阻塞全局 | 用 SCAN / UNLINK;开启 lazyfree |
goroutine + 多 P 是另一种折中)。