技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

How JavaScript works: Event loop and the rise of async programming

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/20-How JavaScript works Event loop and the rise of As
本文目录15 个章节
  1. 一、为什么长时间同步任务会让页面卡住?
  2. 二、JavaScript 程序中的“现在”和“以后”
  3. 三、事件循环是什么?
  4. 四、浏览器事件循环中的一次循环
  5. 五、一个 setTimeout 示例的执行过程
  6. 六、setTimeout 的真实语义
  7. 七、ES2015 的 Jobs 与 Promise microtask
  8. 八、回调与嵌套回调
  9. 九、Promise:表示未来的结果
  10. 十、async/await
  11. 十一、编写可维护异步代码的五个建议
  12. 十二、Promise 并发、顺序与取消
  13. 十三、浏览器与 Node.js 的事件循环差异
  14. 十四、原文背景与历史资料
  15. 参考资料

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 应用应避免使用;异步 fetchXMLHttpRequest 或其他非阻塞 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,不能据此推断其内部一定是一个线程。

原文配图:Web API 与回调队列的历史示意

四、浏览器事件循环中的一次循环

为了便于理解,可以把浏览器的一轮简化为:

  1. 从某个 task source 取出一个 task,例如初始脚本、用户事件、定时器回调或网络事件;
  2. 运行该 task,直到当前 JavaScript job 完成;
  3. 执行 microtask checkpoint,持续处理 microtask 队列,直到队列为空;
  4. 浏览器在合适的时机进行渲染、更新界面或等待下一轮 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 完成后再运行它。

下面这些历史配图按照原文顺序保留,用来观察调用栈和计时器从注册到回调执行的过程:

  1. 原文配图:初始调用栈
  2. 原文配图:console.log Hi 入栈
  3. 原文配图:执行 console.log Hi
  4. 原文配图:console.log Hi 出栈
  5. 原文配图:setTimeout 入栈
  6. 原文配图:浏览器创建计时器
  7. 原文配图:setTimeout 调用完成
  8. 原文配图:console.log Bye 入栈
  9. 原文配图:执行 console.log Bye
  10. 原文配图:console.log Bye 出栈
  11. 原文配图:计时器达到时间阈值
  12. 原文配图:回调进入任务队列
  13. 原文配图:事件循环取出 cb1
  14. 原文配图:cb1 执行 console.log
  15. 原文配图:cb1 内层调用完成
  16. 原文配图:cb1 从调用栈移除

原文配图:事件循环过程回顾动画

六、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 可以把“值现在是否已经准备好”与“如何使用这个值”分离。下面的 fetchXfetchY 可以现在返回,也可以稍后兑现:

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 事件循环阶段,简化后常见阶段包括:

  1. timers;
  2. pending callbacks;
  3. poll;
  4. check(setImmediate);
  5. close callbacks。

setTimeoutsetImmediate 的先后顺序依赖调用上下文;在 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 的细节应优先对照当前规范和宿主文档。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS