How JavaScript works: Event loop and the rise of async programming
Category(分类): JavaScript Status: 已更新
原文作者:Alexander Zlatkov,SessionStack Blog 原文发表于:2017-10-11 历史文章:How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await(原文发布于 Medium;当前访问可能受限)
系列主页:How JavaScript works
本文保留原文从调用栈、回调、事件循环、Jobs、Promise 到
async/await的学习路线和历史配图。原文发布于 2017 年,使用“Callback Queue”“Web APIs”和“ES6 event loop”等简化说法;本文补充 HTML Standard 的 task/microtask/rendering 模型、ECMAScript Job、Node.js libuv 差异、Promise 并发与取消,并删除或改写容易造成误解的绝对表述。
一、为什么长时间同步任务会让页面卡住?
在一个 JavaScript 执行环境中,一段 job 会运行到完成,当前执行上下文不会被另一个 JavaScript job 抢占。如果同步代码执行很久,当前环境就不能及时处理用户输入、执行其他脚本或获得渲染机会:
const start = performance.now()
while (performance.now() - start < 3000) {
// 模拟 3 秒 CPU 密集型同步计算
}
console.log('计算完成')
这段代码会阻塞当前页面的 JavaScript 执行和与之关联的渲染机会。异步 API 并不会把任意同步计算自动移到后台;CPU 密集型任务应考虑拆分、requestIdleCallback(适合非关键工作)或 Web Worker:
const worker = new Worker('/compute-worker.js')
worker.postMessage({ input: largeData })
worker.onmessage = event => {
renderResult(event.data)
}
Worker 拥有独立的执行环境,不与页面脚本共享普通的 window、调用栈和 DOM;数据通常通过结构化克隆或 Transferable 在环境之间传递。
原文把这类情况概括为“单线程限制”。这个说法对页面主线程的教学模型有帮助,但不能理解为整个浏览器或整个 Node.js 进程只有一个线程:浏览器可以使用 Worker、网络线程、合成线程等,Node.js 也会通过操作系统和 libuv 处理 I/O。

二、JavaScript 程序中的“现在”和“以后”
JavaScript 源码可能写在一个文件中,但执行通常由许多同步片段和之后才执行的回调组成。下面的输出顺序是确定的:
console.log('first')
setTimeout(() => {
console.log('second')
}, 0)
console.log('third')
// first
// third
// second
setTimeout 只是向宿主请求设置一个计时器。计时器达到时间阈值后,宿主才会把回调安排为未来的 task;回调仍要等待当前脚本执行完,并等待前面排队的任务。
回调不等于同步等待
传统异步 API 经常接收回调:
loadData((error, data) => {
if (error) {
handleError(error)
return
}
render(data)
})
调用 loadData() 时,数据通常还没有返回;回调是“数据准备好后由 API 调用”的函数。不要把下面的写法当作可行的同步等待:
let response
loadData((error, data) => {
if (!error) response = data
})
console.log(response) // 通常仍是 undefined
原文提到同步 Ajax。同步 XMLHttpRequest 会阻塞页面主线程,现代 Web 应用应避免使用;异步 fetch、XMLHttpRequest 或其他非阻塞 API 才是正常选择。
三、事件循环是什么?
JavaScript 引擎负责解析和执行 JavaScript,但它通常运行在宿主环境中。浏览器、Node.js、嵌入式设备可以为同一个语言引擎提供不同的计时器、网络、文件系统、事件和调度实现。
因此不能把“事件循环”理解成一套对所有宿主完全相同的队列:
- 浏览器遵循 HTML Standard 的 event loop 模型,包含 task queue、microtask queue 和渲染机会;
- ECMAScript定义执行上下文、job、Promise reaction 等语言层抽象,但不定义浏览器的 DOM、渲染或网络队列;
- Node.js使用 libuv 的事件循环阶段,例如 timers、poll、check 和 close callbacks,并有
process.nextTick()等 Node 特有机制。

原文把浏览器中的 Web APIs 说成“无法访问的线程”。更准确的说法是:Web API 是宿主提供的接口,底层可能使用线程、进程、内核异步 I/O 或其他实现;JavaScript 只能调用公开 API,不能据此推断其内部一定是一个线程。

四、浏览器事件循环中的一次循环
为了便于理解,可以把浏览器的一轮简化为:
- 从某个 task source 取出一个 task,例如初始脚本、用户事件、定时器回调或网络事件;
- 运行该 task,直到当前 JavaScript job 完成;
- 执行 microtask checkpoint,持续处理 microtask 队列,直到队列为空;
- 浏览器在合适的时机进行渲染、更新界面或等待下一轮 task。
真实规范包含多个 task source 和更细的调度条件,不存在一个所有回调都严格进入同一“Callback Queue”的简单模型。不同来源的任务顺序不能随意互推;同一个 task 内的同步代码则遵循 run-to-completion。
Task、microtask 和渲染
console.log('script start')
setTimeout(() => {
console.log('timer task')
}, 0)
queueMicrotask(() => {
console.log('microtask')
})
Promise.resolve().then(() => {
console.log('promise reaction')
})
console.log('script end')
// script start
// script end
// microtask
// promise reaction
// timer task
Promise 的 then 回调属于 microtask;queueMicrotask 也会把回调加入 microtask 队列。当前 task 结束后,通常会先清空 microtask,再进入后续 task。microtask 如果不断产生新的 microtask,可能长时间推迟渲染和后续任务,因此不要递归无界地使用它。

五、一个 setTimeout 示例的执行过程
考虑下面的代码:
console.log('Hi')
setTimeout(function cb1() {
console.log('cb1')
}, 5000)
console.log('Bye')
初始脚本是一个 task。它先打印 Hi,注册计时器,再打印 Bye。计时器达到至少约 5000 毫秒后,宿主才有机会把 cb1 放进计时器任务队列;事件循环会等当前执行栈和必要的 microtask 完成后再运行它。
下面这些历史配图按照原文顺序保留,用来观察调用栈和计时器从注册到回调执行的过程:

六、setTimeout 的真实语义
setTimeout(callback, delay) 中的 delay 是一个不早于该时间阈值的调度提示,不是精确执行时间:
const start = performance.now()
setTimeout(() => {
console.log(performance.now() - start)
}, 1000)
// 输出可能大于或略高于 1000,而不会保证恰好是 1000
回调可能被以下因素延迟:
- 当前同步代码仍未结束;
- 前面已有任务或 microtask;
- 浏览器正在进行渲染、后台页面节流或资源调度;
- 操作系统调度和设备负载;
- Node.js 事件循环阶段和 I/O 回调。
因此 setTimeout(callback, 0) 的实际含义是“尽快安排一个后续任务”,不是“立即执行”,也不是“等待 0 毫秒后准确执行”:
console.log('Hi')
setTimeout(() => {
console.log('callback')
}, 0)
console.log('Bye')
// Hi
// Bye
// callback
浏览器还可能对嵌套计时器、后台页面和隐藏标签页应用最小延迟或节流规则。需要动画帧时使用 requestAnimationFrame,需要微任务时使用 Promise reaction 或 queueMicrotask,不要用 setTimeout(..., 0) 模拟所有调度需求。
七、ES2015 的 Jobs 与 Promise microtask
原文把 Job Queue 描述为每个事件循环 tick 末尾的一层队列。这个模型有助于理解 Promise,但更准确的表述是:ECMAScript 定义了 job 抽象和 Promise reaction job,浏览器宿主把它们接入 HTML event loop 的 microtask checkpoint。
const promise = Promise.resolve()
promise.then(() => console.log('job 1'))
promise.then(() => console.log('job 2'))
console.log('synchronous')
// synchronous
// job 1
// job 2
已兑现的 Promise 也不会同步调用 then 回调:
Promise.resolve().then(() => console.log(2))
console.log(1)
// 1
// 2
microtask 适合在当前同步代码完成后、后续 task 之前执行少量工作:
let state = 'pending'
queueMicrotask(() => {
state = 'ready'
})
console.log(state) // pending
但下面这种代码可能让事件循环和渲染“饿死”:
function starve() {
queueMicrotask(starve)
}
// 不要执行:它会持续占用 microtask checkpoint
// starve()
八、回调与嵌套回调
回调是 JavaScript 中最基础的异步表达方式,很多事件监听、Node.js 老式 API 和定时器都使用回调:
button.addEventListener('click', () => {
setTimeout(() => {
fetch('/api/data')
.then(response => response.json())
.then(data => render(data))
.catch(handleError)
}, 100)
})
多个操作嵌套时,代码的困难不只是缩进,还包括:
- 错误处理需要在每一层重复;
- 外层很难知道内层操作何时完成;
- 共享变量容易产生竞态;
- 取消、重试和并发控制变得复杂。
“Callback Hell”不是说所有回调都不好。事件监听器本身就是自然的回调抽象;问题在于缺少清晰的组合、错误和生命周期管理。
九、Promise:表示未来的结果
Promise 可以把“值现在是否已经准备好”与“如何使用这个值”分离。下面的 fetchX、fetchY 可以现在返回,也可以稍后兑现:
function sum(fetchX, fetchY) {
return Promise.all([fetchX(), fetchY()])
.then(([x, y]) => x + y)
}
sum(
() => Promise.resolve(10),
() => Promise.resolve(20)
).then(result => console.log(result)) // 30
Promise.all 返回一个新的 Promise;其 then 又会返回另一个新的 Promise:
const first = Promise.resolve(1)
const second = first.then(value => value + 1)
console.log(first === second) // false
second.then(console.log) // 2
Promise 的状态和“解决”
Promise 通常被描述为 pending、fulfilled、rejected 三种状态。更精确地说,调用 resolve(thenable) 时可能先进入“已解决但仍采用另一个 Promise 状态”的过程;对使用者而言,Promise 最终会稳定地 fulfilled 或 rejected。
Promise 一旦结算,结果不能被另一个调用覆盖:
const promise = new Promise((resolve, reject) => {
resolve('first')
reject(new Error('ignored'))
})
promise.then(console.log) // first
这并不意味着 Promise 取消了底层网络请求或定时器;它只保证结果观察的一致性。
链式调用和错误传播
function delay(value, ms) {
return new Promise(resolve => {
setTimeout(() => resolve(value), ms)
})
}
delay(1, 100)
.then(value => delay(value + 1, 100))
.then(value => console.log(value))
.catch(error => console.error(error))
如果 then 回调抛错,then 返回的新 Promise 会被拒绝:
const original = Promise.resolve('value')
const derived = original.then(() => {
throw new TypeError('broken')
})
derived.catch(error => console.log(error.message)) // broken
原文提到的 done() 不是原生 Promise 标准方法。不要依赖某些旧库提供的 done;现代代码应返回 Promise、在边界使用 catch,并根据宿主配置处理 unhandledrejection 或 Node.js 的 unhandledRejection。
不要用 instanceof Promise 识别所有 Promise
来自其他 iframe/realm 的 Promise 可能不是当前全局的同一个构造器,第三方库也可能返回 Promise-like thenable。多数时候不需要判断类型,直接使用 Promise.resolve(value) 进行标准化:
Promise.resolve(maybePromise).then(value => {
console.log(value)
})
十、async/await
ES2017 的异步函数建立在 Promise 之上:
- 调用
async function会立即返回 Promise; return value会让返回 Promise 兑现为value;- 抛出异常会让返回 Promise 拒绝;
await会暂停当前异步函数,等待普通值、Promise 或 thenable 的结果;await不会阻塞整个宿主,也不是同步地把线程停住。
async function add(a, b) {
return a + b
}
add(1, 2).then(console.log) // 3
Promise 链:
function loadUser() {
return fetch('/api/user')
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
})
}
等价的 async/await 表达:
async function loadUserWithAwait() {
const response = await fetch('/api/user')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
await 只能在异步函数中使用,或在支持它的 ES module 顶层使用:
async function main() {
try {
const user = await loadUserWithAwait()
console.log(user)
} catch (error) {
console.error(error)
}
}
main()
异步函数表达式也可以写成 IIFE:
;(async () => {
const user = await loadUserWithAwait()
console.log(user)
})().catch(console.error)
现代主流浏览器和 Node.js 已原生支持 async/await;如果目标环境较旧,Babel/TypeScript 可以转译,但转译不等于底层异步 API自动获得取消、并发或线程能力。
十一、编写可维护异步代码的五个建议
1. 让控制流与业务顺序一致
async/await 能减少 .then 和匿名回调,但不要为了“像同步代码”而把所有操作串行化:
async function loadPage() {
const user = await loadUser()
const settings = await loadSettings(user.id)
return { user, settings }
}
这里第二步确实依赖第一步。如果两个请求无依赖,应并发启动:
async function loadPageInParallel() {
const userPromise = loadUser()
const settingsPromise = loadSettings()
const [user, settings] = await Promise.all([
userPromise,
settingsPromise
])
return { user, settings }
}
2. 统一处理同步和异步错误
async function parseRemoteConfig() {
try {
const response = await fetch('/config.json')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.json()
} catch (error) {
reportError(error)
throw error
}
}
fetch 在 HTTP 404/500 时通常仍会兑现 Response,只有网络失败、请求被取消等情况才会拒绝,所以需要显式检查 response.ok。不要空 catch 后返回一个看似成功的默认值,除非这是明确的降级策略。
3. 条件分支不要无意中串行
async function getContent(user) {
if (user.isAdmin) {
return loadAdminContent()
}
return loadPublicContent()
}
如果条件中的多个请求互不依赖,仍然可以用 Promise.all;如果分支会重复获取同一资源,可以先保存 Promise,避免重复请求。
4. 错误堆栈和异步边界
早期 Promise 链的调试体验确实可能不如同步代码;现代浏览器和 Node.js DevTools 通常提供 async stack traces,但显示范围、性能开销和生产环境信息都依赖运行时,不能保证所有异步边界都有完整堆栈。
async function loadAndTransform() {
const response = await fetch('/data.json')
const data = await response.json()
return transform(data)
}
生产代码应保留错误原因、请求上下文和可关联的 trace id,而不要只依赖开发者工具里的堆栈展示。
5. 调试时关注 Promise 是否“浮动”
下面的 fetch 没有被返回或等待,外层 Promise 无法知道它是否完成:
function save() {
return getUrl()
.then(url => {
fetch(url) // 浮动 Promise
})
.then(() => {
console.log('这里不代表 fetch 已完成')
})
}
正确写法是返回它:
function save() {
return getUrl()
.then(url => fetch(url))
.then(response => response.json())
}
使用 async/await 时也要等待真正需要的 Promise:
async function save() {
const url = await getUrl()
const response = await fetch(url)
return response.json()
}
十二、Promise 并发、顺序与取消
四个常用组合 API 的语义不同:
async function inspectTasks() {
const tasks = [getUser(), getSettings(), getMessages()]
const all = await Promise.all(tasks) // 全部兑现,否则拒绝
const settled = await Promise.allSettled(tasks) // 等全部结束,保留每项状态
const first = await Promise.race(tasks) // 第一个结算(兑现或拒绝)
const firstSuccess = await Promise.any(tasks) // 第一个兑现,全部拒绝才拒绝
return { all, settled, first, firstSuccess }
}
这些 API 不会凭空停止已启动的任务。对于 fetch,可以使用 AbortController:
const controller = new AbortController()
const request = fetch('/api/data', {
signal: controller.signal
})
controller.abort()
request.catch(error => {
if (error.name === 'AbortError') {
console.log('request cancelled')
} else {
console.error(error)
}
})
Promise 本身没有通用取消协议;取消通常由底层 API 的 signal、关闭流、清除定时器或业务状态标志实现。
十三、浏览器与 Node.js 的事件循环差异
浏览器
浏览器事件循环要同时协调脚本、用户事件、网络回调、计时器、microtask 和渲染。页面代码不应依赖某个浏览器内部的“队列名称”来推断所有事件顺序;应使用标准保证,例如:
- 当前脚本运行到完成;
- Promise reaction 在当前 job 之后异步执行;
setTimeout回调不会早于其时间阈值;- 具体 task source 和渲染时机由宿主调度。
Node.js
Node.js 使用 libuv 事件循环阶段,简化后常见阶段包括:
- timers;
- pending callbacks;
- poll;
- check(
setImmediate); - close callbacks。
setTimeout 和 setImmediate 的先后顺序依赖调用上下文;在 I/O 回调中通常 setImmediate 先于计时器,但在主模块中不能把顺序写死。Node.js 20 之后 libuv 的 timers/poll 调度细节也有变化,因此应以目标 Node.js 版本文档为准。
process.nextTick() 不是浏览器 API,也不是普通的 HTML task。它会在当前操作结束后优先处理,递归使用可能饿死 I/O。跨平台文章不要把它与 queueMicrotask、浏览器 Promise microtask 或 setImmediate 简单画成同一个队列。
十四、原文背景与历史资料
原文是 SessionStack “How JavaScript works” 系列的一篇,后半部分以 SessionStack 的产品记录和回放场景说明异步代码对监控库的意义。这部分属于 2017 年的产品背景;技术上仍然可以抽象为:库应避免长时间同步工作,正确返回 Promise,处理异常,控制并发,并在需要时把 CPU 密集工作放入 Worker,而不是单纯增加计时器数量。

原文还引用了早期的《You Don’t Know JS》异步章节和 2017 年的 async/await 文章。它们适合了解历史语境,但 Promise、事件循环和 async/await 的细节应优先对照当前规范和宿主文档。
参考资料
- HTML Standard:Event loops
- HTML Standard:Queue a microtask
- MDN:JavaScript execution model
- MDN:Microtask guide
- MDN:
setTimeout - MDN:Using promises
- MDN:
async function - MDN:
await - MDN:
Promise.all - MDN:AbortController
- MDN:Web Workers
- MDN:
requestAnimationFrame - Node.js:Don't Block the Event Loop
- Node.js:The Node.js Event Loop
- ECMAScript Language Specification
- SessionStack 原文系列
- You Don’t Know JS:Async & Performance














