1236 字
3 分钟
事件循环(Event Loop)
一、为什么需要事件循环
假设你要写一个聊天服务器,同时服务 10000 个连接。传统做法是每个连接分配一个线程,但每个线程需要 1-8 MB 栈空间,10000 个连接就是 10-80 GB 内存——还没开始处理业务,内存就先爆了。更糟糕的是,大部分连接大部分时间都在等 I/O(等用户发消息),线程白白占着内存和 CPU 时间片。
线程模型的另一个问题是上下文切换。操作系统在成千上万个线程间来回切换,每次切换都要保存和恢复寄存器、栈指针、缓存失效——这些开销在连接数上去后变得不可忽视。
事件循环换了一个思路:与其让 10000 个线程各等各的,不如让一个线程统一等。用操作系统的 I/O 多路复用(epoll、kqueue、IOCP)同时监听所有连接,哪个有数据就处理哪个。这就是 Node.js 在单线程上处理万级并发连接的原理。
二、现实类比
一个人处理整个办公室电话的前台。她不能同时和两个人说话,但她把每个电话放在等待中,处理快速事务,然后轮流回拨。如果某件事耗时,她记下来继续处理下一件——绝不阻塞在任何一个来电上。
三、核心思想
事件循环的核心是一个三步循环:注册兴趣 → 轮询就绪 → 分发处理。
flowchart TD
A[注册 I/O 兴趣] --> B[轮询就绪事件<br/>epoll/kqueue 阻塞]
B --> C[分发给回调处理]
C --> A
以 libuv(Node.js 底层)为例,每一轮循环包含多个阶段:
flowchart LR
A[Timers] --> B[Pending Callbacks]
B --> C[Poll I/O]
C --> D[Check / setImmediate]
D --> E[Close Callbacks]
E --> A
| 属性 | 值 |
|---|---|
| 并发模型 | 单线程,非阻塞 I/O |
| 连接数 | 每线程数千(受文件描述符限制,非线程限制) |
| 延迟 | I/O 密集型低延迟;一个慢回调会阻塞所有 |
| 内存 | O(连接数) 用于状态,非 O(连接数 × 栈大小) |
四、变体与对比
| 模式 | 关系 | 区别 |
|---|---|---|
| 协作调度 | 事件循环依赖协作调度 | 协作调度是「任务主动让出」的调度策略;事件循环是「单线程轮询分发」的架构模式 |
| Actor 模型 | 每个 Actor 本质上是信箱上的事件循环 | Actor 强调消息传递和状态隔离;事件循环强调 I/O 多路复用 |
| 观察者模式 | 事件循环将事件分发给注册的回调 | 观察者是设计模式层面的概念;事件循环是运行时架构 |
| Reactor 模式 | 事件循环的另一种叫法 | Reactor 更强调「事件到来时回调」的反应式语义 |
五、多语言实现
Go:基于 channel 的简易事件循环
Go 有 goroutine,通常不需要手写事件循环。但理解其原理很有价值:
type EventLoop struct { handlers map[int]func()}
func NewEventLoop() *EventLoop { return &EventLoop{handlers: make(map[int]func())}}
func (el *EventLoop) AddHandler(fd int, handler func()) { el.handlers[fd] = handler}
func (el *EventLoop) Tick() int { count := len(el.handlers) for _, handler := range el.handlers { handler() } return count}
func (el *EventLoop) Run(maxTicks int) int { ticksRun := 0 for i := 0; i < maxTicks; i++ { if len(el.handlers) == 0 { break } el.Tick() ticksRun++ } return ticksRun}TypeScript:带阶段的事件循环
type Handler = () => void;
class EventLoop { private handlers = new Map<number, Handler>(); private timerQueue: { deadline: number; handler: Handler }[] = [];
addHandler(fd: number, callback: Handler): void { this.handlers.set(fd, callback); }
// 注册定时器回调 addTimer(delayMs: number, handler: Handler): void { this.timerQueue.push({ deadline: Date.now() + delayMs, handler, }); // 按截止时间排序 this.timerQueue.sort((a, b) => a.deadline - b.deadline); }
tick(): number { const now = Date.now(); // 阶段 1:执行到期的定时器 while (this.timerQueue.length > 0 && this.timerQueue[0].deadline <= now) { this.timerQueue.shift()!.handler(); } // 阶段 2:执行 I/O 回调 for (const [, handler] of this.handlers) { handler(); } return this.handlers.size; }}六、生产验证
- libuv — core.c#L427-L492:
uv_run是 Node.js 使用的主事件循环函数,在单个while循环中处理定时器、I/O 轮询、check 句柄和关闭句柄,支持 DEFAULT/ONCE/NOWAIT 三种运行模式 - Redis — ae.c#L360-L468:
aeProcessEvents是 Redis 事件循环的核心,计算最近定时器超时后调用aeApiPoll,然后分发文件事件和定时器事件,单线程实现 10 万+ ops/sec - Nginx — 每个 worker 进程运行独立的
epoll/kqueue事件循环,多进程架构实现多核利用和进程隔离
七、小结
何时使用:
- 高连接服务器——Web 服务器、聊天服务器、API 网关,数千连接大多空闲
- I/O 密集型工作——网络代理、负载均衡器、数据库连接池
- 实时通信——WebSocket 服务器、游戏服务器、通知系统
- 嵌入式/资源受限——无法承受每连接一线程的内存开销
何时不用:
- CPU 密集型工作——单线程事件循环会在计算上阻塞,应使用线程池或工作进程
- 简单请求-响应——并发连接少于 100 且请求简单时,每请求一线程更简单
- 严格排序要求——事件必须按精确到达顺序处理时,顺序队列更清晰
- 需要多核——单线程只能用一个核,需要多进程(如 Nginx)或线程池补充
八、参考资料
- libuv 官方文档 - 事件循环各阶段的详细说明
- Redis ae.c 源码 - Redis 事件循环实现
- Node.js 事件循环指南 - 官方对事件循环各阶段的解释
- Reactor 模式论文 - Schmidt et al., 1995, Reactor 模式奠基论文
- Nginx 事件模块 - Nginx 多进程事件循环架构
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐






