事件循环
约 3193 字大约 11 分钟
2025-11-10
为了学习调度器,就得先学习 Js 的单线程模型 和 任务队列机制,这是所有任务调度逻辑的底层基础。
JS 是单线程语言,同一时间只能执行一段代码。为了避免 “长时间任务阻塞页面”(比如渲染卡住),JS 设计了 “任务队列” 机制,把代码拆成不同优先级的任务,按规则排队执行 —— 这就是 “调度” 的本质:决定 “哪个任务什么时候执行”。
微任务和宏任务
这是任务调度的核心分类,决定了任务的 “执行优先级” 和 “执行时机”,记住一句话:微任务先于宏任务执行,且同一轮事件循环中,微任务会被 “清空” 后再执行宏任务。
| 类型 | 核心特点 | 常见例子 |
|---|---|---|
| 宏任务 | 优先级低,执行完一个宏任务后会触发页面渲染 | script 整体代码、setTimeout、setInterval、DOM 事件 |
| 微任务 | 优先级高,在当前宏任务执行完后立即执行 | Promise.then/catch/finally、async/await、queueMicrotask |
事件循环
JS 按固定顺序循环执行任务,这就是 “事件循环(Event Loop)”,调度器的逻辑完全遵循这个流程:
- 执行第一个宏任务(通常是整个 script 脚本)。
- 执行过程中遇到微任务,就加入微任务队列;遇到宏任务,加入宏任务队列。
- 当第一个宏任务执行完,清空所有微任务队列(按加入顺序执行)。
- 微任务清空后,触发页面渲染(这一步是浏览器做的)。
- 从宏任务队列中取下一个宏任务执行,重复步骤 2-4,形成循环。
案例理解
console.log('1'); // 宏任务(script)内的同步代码,先执行
setTimeout(() => console.log('2'), 0); // 宏任务,加入宏任务队列
Promise.resolve().then(() => console.log('3')); // 微任务,加入微任务队列
console.log('4'); // 同步代码,继续执行
// 执行结果:1 → 4 → 3 → 2
// 原因:script(宏任务)执行完 → 清空微任务(3) → 渲染 → 执行下一个宏任务(2)常见宏任务有:
script(整体代码)setTimeoutsetIntervalsetImmediate(Node.js)I/O 操作UI rendering
微任务(Micro Task):
Promise.then / catch / finallyqueueMicrotaskMutationObserver
渲染时机不是每次循环都一定触发:
浏览器通常在每一轮 微任务清空之后、下一轮宏任务前 进行 一次渲染检查,但如果当前帧还没到(比如 16ms 内的多次循环),可能会合并渲染。
所以“清空微任务后触发渲染”是常见的现象,但不是绝对的。
浏览器的事件循环机制中,有两个关键点:
- 宏任务队列(MacroTask Queue)
- 微任务队列(MicroTask Queue)
每个宏任务执行时,可能注册(安排)未来的宏任务,但 不会立即执行它们。它们会被放入「等待执行的宏任务队列」。
小🌰子
console.log('A')
setTimeout(() => {
console.log('B')
}, 0)
setTimeout(() => {
console.log('C')
}, 0)
console.log('D')执行过程:
- 整个
script是第一个 宏任务,开始执行。 - 执行
console.log('A')→ 输出A - 遇到第一个
setTimeout→ 注册一个宏任务(定时器事件),放入宏任务队列。 - 遇到第二个
setTimeout→ 又注册一个宏任务,排在第一个后面。 - 执行
console.log('D')→ 输出D - 当前宏任务(
script)执行完毕。 - 清空微任务队列(这里没有微任务)。
- 事件循环取出下一个宏任务(第一个
setTimeout回调)执行 → 输出B - 清空微任务。
- 再取下一个宏任务(第二个
setTimeout回调)执行 → 输出C
输出结果:ADBC
重要
“在执行当前宏任务时,如果遇到新的宏任务(如 setTimeout), 这些任务不会立刻执行,而是注册到宏任务队列中,等待下一轮循环时再依次执行。”
重要
浏览器中的 Event Loop 会先执行一个宏任务(通常是整个 script)。 在执行过程中,异步任务会被分别加入宏任务队列和微任务队列。 当前宏任务执行完后,会立即清空所有微任务。 微任务清空后,浏览器会进行一次可能的页面渲染,然后开始下一轮宏任务。 如此循环往复,构成浏览器的事件循环。
相关信息
并发请求 ≠ 无限
浏览器是有限制的,它会主动限制同一域名(host)下的并发连接数。
这个限制不是由电脑性能决定的,而是由 浏览器自身的实现和协议规范 决定的。
HTTP/1.1 时代
同一域名(Host)默认最大连接数:一般是 6 个连接(Chrome / Firefox / Edge / Safari 等)
所以,如果你同时发起 100 个请求到同一个域名:
- 浏览器会先发出 6 个
- 其他的会进入等待队列
- 当某个连接完成后,再从队列中取下一个继续发
同域名 api.example.com 最多 6 个并行请求,但跨域的不同域名(例如 img.example.com、cdn.example.com)可以分别再有各自的 6 个连接。
HTTP/2 时代(目前主流)
HTTP/2 支持 多路复用(Multiplexing),即:
一个 TCP 连接上可以同时承载多个请求(理论上成百上千个)。
但,浏览器依然有上限,通常是:
- 每个域名一个 TCP 连接;
- 每个连接内最多 100~1000 个并发请求流(stream);
- 实际上限取决于服务器的
SETTINGS_MAX_CONCURRENT_STREAMS设置。
举例:Chrome 通常支持每个连接约 100 个并发 stream;如果服务器设置为 50,那么最多只能并发 50 个请求。
HTTP/3(QUIC)
HTTP/3 基于 UDP,同样具备多路复用特性,但底层协议不同(QUIC),并发能力更强、连接建立更快。
但依然受限于浏览器与服务器的流控(flow control)和 congestion window(拥塞窗口)。
电脑性能有影响,但不是决定性。
浏览器会根据以下因素调整调度策略:
- CPU 性能、线程池大小;
- 系统的文件描述符数量(同时打开的 socket 数量);
- 内存;
- 网络带宽和延迟;
- 操作系统 TCP 连接上限(如 Linux 的
ulimit -n)。
但这些影响的是“整体吞吐量”或“响应速度”,而不是浏览器的“并发上限策略”。
后者是浏览器写死的逻辑。
简易调度器
调度器的核心需求是 管理任务、控制执行方式(串行 / 并发)
代码
class Scheduler {
// option: 'serial' | 'concurrent' | number
// 'serial' => 串行(并发数 = 1)
// 'concurrent' => 无限制并发
// number => 指定并发数(至少 1)
constructor(option = 'serial') {
this.taskQueue = [];
this.runningCount = 0;
if (option === 'serial') {
this.concurrency = 1;
} else if (option === 'concurrent') {
this.concurrency = Infinity;
} else if (typeof option === 'number') {
this.concurrency = Math.max(1, Math.floor(option));
} else {
this.concurrency = 1;
}
}
addTask(task) {
this.taskQueue.push(task);
this.runTasks();
}
runTasks() {
// 启动尽可能多的任务,直到达到并发上限或者队列空
while (this.runningCount < this.concurrency && this.taskQueue.length > 0) {
const task = this.taskQueue.shift();
this.runningCount++;
// 使用微任务把执行从当前栈抽离,行为更可预测
queueMicrotask(() => {
try {
const result = task();
// 支持异步任务(返回 Promise)
if (result && typeof result.then === 'function') {
result.finally(() => {
this.runningCount--;
this.runTasks();
});
} else {
// 同步任务
this.runningCount--;
this.runTasks();
}
} catch (err) {
// 任务抛错也要释放并发槽位
this.runningCount--;
this.runTasks();
}
});
}
}
}
// 用法示例:
// 串行(等同于原来行为)
const serialScheduler = new Scheduler('serial');
serialScheduler.addTask(() => console.log('s1'));
serialScheduler.addTask(() => console.log('s2'));
serialScheduler.addTask(() => console.log('s3'));
// 指定并发数(例如并发 2)
const concurrent2 = new Scheduler(2);
concurrent2.addTask(() => new Promise(res => setTimeout(() => { console.log('c1'); res(); }, 100)));
concurrent2.addTask(() => new Promise(res => setTimeout(() => { console.log('c2'); res(); }, 50)));
concurrent2.addTask(() => new Promise(res => setTimeout(() => { console.log('c3'); res(); }, 10)));
// 完全并发(不限制)
const fullConcurrent = new Scheduler('concurrent');
fullConcurrent.addTask(() => console.log('fc1'));
fullConcurrent.addTask(() => console.log('fc2'));任务调度器相关的库:
p-limit(前端 / Node 通用,轻量并发控制)
- 核心作用:限制异步任务的并发数量(比如控制同时发起的请求数不超过 5 个),避免浏览器 / 服务器资源过载。
- 典型场景:批量请求接口(如批量上传文件、批量获取数据)、循环执行异步函数时控制并发量。
- 特点:体积极小(仅 1KB)、API 极简,支持 Promise,是前端并发控制的 “首选工具”。
BullMQ(Node 端,重量级队列调度)
- 核心作用:基于 Redis 实现的 “分布式任务队列”,支持任务的持久化、重试、优先级、延迟执行,适合后端复杂业务调度。
- 典型场景:后端定时任务(如每天凌晨同步数据)、耗时任务异步处理(如生成大文件、发送批量邮件)、分布式系统间的任务通信。
- 特点:功能全面(支持任务暂停 / 取消 / 优先级)、支持集群部署,是 Node 后端 “工业级调度库”。
node-schedule(Node 端,轻量定时调度)
- 核心作用:Node 环境下的 “定时任务库”,支持按 CRON 表达式 或 “具体时间” 调度任务,类似 Linux 的
crontab。 - 典型场景:后端定时任务(如每小时清理日志、每天 8 点推送消息)、周期性执行代码逻辑。
- 特点:无需依赖 Redis,轻量易上手,CRON 表达式支持灵活(如
'0 8 * * *'表示每天 8 点执行)。
node Event loop(事件循环)
Node.js 的 Event Loop 浏览器不同,因为它不仅处理 JS 的任务队列,还要管理 I/O、定时器、文件系统、网络、libuv 线程池等底层事件源。
Node.js 的 Event Loop 是基于 libuv 实现的,它是一个跨平台的异步 I/O 库。
libuv 管理了:
- 网络 I/O(TCP/UDP)
- 文件系统 I/O
- 定时器
- 线程池任务(如
fs.readFile) - 以及内部回调的调度逻辑
所以 Node 的 Event Loop 比浏览器多几个阶段(phases),是分阶段调度的多队列循环系统。
六个阶段(核心)
每一轮循环都有以下几个阶段:
| 阶段 | 名称 | 说明 |
|---|---|---|
| 1️⃣ | timers | 执行到期的 setTimeout 和 setInterval 回调 |
| 2️⃣ | pending callbacks | 执行一些系统级的 I/O 回调(比如 TCP 错误、DNS 失败等) |
| 3️⃣ | idle, prepare | Node 内部使用(你基本不会接触) |
| 4️⃣ | poll | 等待新的 I/O 事件(文件、socket 等),执行几乎所有的回调 |
| 5️⃣ | check | 执行 setImmediate 的回调 |
| 6️⃣ | close callbacks | 执行如 socket.on('close') 这种关闭回调 |
每一轮执行顺序如下:
timers → pending callbacks → idle/prepare → poll → check → close callbacks在 每个阶段之间,Node 都会:
清空微任务队列(process.nextTick + Promise 等)。
微任务和宏任务
浏览器
- 宏任务:
setTimeout、setInterval、fetch回调、script - 微任务:
Promise.then、queueMicrotask
Node.js
- 宏任务:上面六个阶段的任务
- 微任务:
process.nextTick(优先级最高)Promise.then/queueMicrotask
执行优先级是:
每个阶段的末尾:
- 先清空 process.nextTick 队列
- 再清空 Promise 微任务队列
例子
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))结果:
nextTick
promise
timeout | immediate (顺序不确定)解释:
- 整个 script 是第一个宏任务;
nextTick最先执行(每个阶段前都会清空);Promise微任务其次;- 然后进入下一轮:
- 如果当前没有其他 I/O 阻塞,
setTimeout和setImmediate执行顺序可能不同:- 在主模块中:
setTimeout通常先于setImmediate - 在 I/O 回调中:
setImmediate先于setTimeout
- 在主模块中:
- 如果当前没有其他 I/O 阻塞,
poll 阶段的关键意义
poll 是 Node 的“心脏”:
- 如果有 I/O 事件待处理 → 执行对应回调;
- 如果没有待处理事件:
- 若有
setImmediate→ 直接跳转到 check 阶段; - 若没有 → 阻塞等待新事件或定时器到期。
- 若有
所以:Node 的“空转行为”主要发生在 poll 阶段,这决定了你的异步事件何时被触发。
总结图(简化版)
┌──────────────────────────────┐
│ timers (setTimeout, setInterval) │
└─────────────┬────────────────┘
↓
┌──────────────────────────────┐
│ pending callbacks │
└─────────────┬────────────────┘
↓
┌──────────────────────────────┐
│ poll (I/O callbacks) │
│ ├─ 处理I/O事件 │
│ └─ 如果无事件等待新任务 │
└─────────────┬────────────────┘
↓
┌──────────────────────────────┐
│ check (setImmediate) │
└─────────────┬────────────────┘
↓
┌──────────────────────────────┐
│ close callbacks (socket close)│
└──────────────────────────────┘
每个阶段之间都会清空:
process.nextTick → Promise.then 微任务总结
避免异步陷阱:
- 比如
nextTick写太多会饿死 Event Loop; setImmediate和setTimeout的顺序不同会导致意外行为。
理解高性能服务器的 I/O 调度:
- Node 的高并发来自于非阻塞 I/O 和事件循环机制;
- 理解 poll 阶段有助于优化请求处理模型。
调试异步问题时心中有数:
- 比如为什么某个回调“晚了一拍”,你就能立刻想到哪个阶段还没执行到。
Node.js 的底层 API 分类梳理
其实 Node 的“复杂”只是因为它负责太多事,但我们可以把它分层看:
| 层级 | 作用 | 常见 API |
|---|---|---|
| 应用层 | 写业务逻辑、HTTP、Nest 框架等 | http, express, nestjs, fs/promises |
| 异步调度层 | 控制任务何时执行 | setTimeout, setImmediate, process.nextTick, Promise |
| 系统 I/O 层(libuv) | 操作系统层面异步 I/O | 文件 I/O、网络、DNS、线程池任务 |
| 事件驱动层 | 回调触发机制 | EventEmitter, on, emit |
实际开发中你主要用应用层和异步层;
只有当你写底层库、调试框架、做性能优化时,才会深入到 libuv 那层。