返回学习

学习 2026

JavaScript 异步到底在异步什么?从调用栈到事件循环

用一段输出顺序问题串起调用栈、任务与微任务,并比较浏览器和 Node.js 的调度差异,最后给出一套可复用的异步代码分析方法。

JavaScriptNode.js异步编程
本文目录14
  1. 1. 异步改变的是等待方式
  2. 2. 浏览器如何安排一次事件循环
  3. 3. Node.js 为什么又不一样
  4. 3.1 process.nextTick() 与 Promise microtask
  5. 3.2 setTimeout(0) 与 setImmediate()
  6. 4. 从 Callback 到 async/await
  7. 5. 真正常出错的是依赖关系
  8. 5.1 该串行的任务被并发了
  9. 5.2 用定时器猜完成时间
  10. 5.3 错误跨过了异步边界
  11. 5.4 主线程仍然被占满
  12. 6. 一段异步代码应该怎么分析
  13. 7. 异步最后解决的是时间解耦
  14. References

先看一段浏览器里的 JavaScript:

js
console.log("start");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => console.log("promise"));

console.log("end");

输出是:

text
start
end
promise
timeout

setTimeout(..., 0) 明明更早出现,为什么反而最后执行?

答案不在“0 毫秒”里,而在三件事上:当前同步代码要先运行完,Promise 回调进入微任务队列,定时器回调则要等到后续任务。理解这三层关系,Callback、Promise、async/await 以及 Node.js 里的各种顺序题就有了共同的分析基础。

1. 异步改变的是等待方式

先把几组经常混用的概念拆开。

  • 同步 / 异步描述调用方何时拿到结果。同步调用在返回前给出结果;异步调用先返回,结果以后通过回调、Promise 或事件继续传递。
  • 阻塞 / 非阻塞描述线程在等待期间能否继续做别的事。阻塞会让执行线程停在那里,非阻塞则把等待交出去。
  • 串行 / 并行描述任务是否同时推进。异步代码可以串行执行,也可以发起并发;异步本身不等于多线程并行。

例如冲咖啡:按下咖啡机后站在原地等,是同步且阻塞;按下后先去烤面包,咖啡完成再回来取,是异步且非阻塞。咖啡机和烤面包机是否真的同时工作,则是另一个维度的问题。

JavaScript 常被称为单线程语言,更准确地说,是同一个 JavaScript agent 在任一时刻只执行一个 Job,并且当前 Job 会运行到完成。网络、定时器、文件系统等能力通常由浏览器或 Node.js 这样的宿主提供;等待结束后,宿主再把后续工作安排回来。

JavaScript 异步中的四组概念:同步、异步、阻塞、非阻塞

同步 / 异步关注结果何时回来,阻塞 / 非阻塞关注等待时线程能否继续工作。

可以先记住一个最小模型:JavaScript 负责执行当前代码,宿主负责等待,队列负责保存后续工作,事件循环负责选择下一次执行机会。

2. 浏览器如何安排一次事件循环

浏览器里的事件循环可以先压缩成三个动作:

  1. 选择一个可运行的 task,例如脚本、定时器回调或部分事件回调。
  2. 这个 task 执行结束后,清空 microtask queue。
  3. 浏览器获得渲染机会,然后进入下一轮。

常见的微任务来源包括 Promise reaction、queueMicrotask() 和 MutationObserver。async/await 也建立在 Promise 之上:执行到 await 时,函数暂停;被等待的 Promise settle 后,函数剩余部分作为后续 Job 恢复。

现在回看开头的输出顺序:

  • startend 属于当前脚本 task,先连续执行。
  • .then(...) 注册的回调进入微任务队列。
  • 定时器到期只代表回调具备了排队资格,它仍要等待后续 task。
  • 当前脚本结束后,浏览器先清空微任务,于是 promise 先于 timeout

这也解释了一个常见现象:如果微任务不断追加新的微任务,浏览器就迟迟拿不到渲染机会,页面依然会卡。异步 API 不会自动让主线程变空闲,真正耗时的 JavaScript 仍会占住调用栈。

浏览器事件循环:Task、Microtask 与 Render 的调度关系

当前 task 结束后会先进行微任务检查点;微任务清空,浏览器才可能渲染并进入下一轮。

3. Node.js 为什么又不一样

Node.js 也运行 JavaScript,但宿主机制与浏览器不同。它通过 libuv 提供事件循环和异步 I/O 能力:

  • 不同类型的回调在不同 phase 中执行。
  • 网络 I/O 会尽量使用操作系统提供的高效机制。
  • 一些无法直接异步化的工作会交给线程池,例如部分文件系统操作和 DNS lookup。
  • 活跃的 timer、socket、server 等资源还会影响进程是否继续存活。

理解 Node.js 的关键,是把“JavaScript 只有一个主执行线程”和“宿主可以并发处理等待工作”分开。

浏览器与 Node.js 事件循环对比

浏览器更关注任务、微任务与渲染;Node.js 更关注 I/O phase、process.nextTick、Promise microtask 以及进程存活。

3.1 process.nextTick() 与 Promise microtask

在 Node.js CommonJS 中,下面这段代码通常输出:

js
console.log("start");

process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));

console.log("end");
text
start
end
nextTick
promise

process.nextTick() 严格来说不是事件循环的一个 phase。它的队列会在当前操作完成后、事件循环继续推进前被处理,优先级高于 Promise microtask。递归地安排太多 nextTick,可能让 I/O 长时间得不到机会。

运行环境也要写清楚。同样的顶层代码放进 ES Module 时,模块本身可能已经运行在 Promise microtask 的上下文里,顺序会与 CommonJS 不同。

3.2 setTimeout(0)setImmediate()

下面这段顶层代码的先后顺序不能脱离环境硬背:

js
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

两者受事件循环进入时机、计时精度和运行上下文影响。若在 I/O 回调中安排,setImmediate() 通常会在下一轮 timers 之前的 check phase 执行。Node.js 20 起,timers 的执行时机也随 libuv 1.45.0 调整,因此版本信息是顺序分析的一部分。

4. 从 Callback 到 async/await

事件循环解释“什么时候继续执行”,Callback、Promise 和 async/await 则是在表达“结果出来以后做什么”。它们处理的是同一条依赖链,只是组织方式逐渐变得清晰。

假设业务要先读取用户,再根据用户读取订单。回调写法会把控制流嵌进每一层:

js
loadUser(1, (userError, user) => {
  if (userError) return handleError(userError);

  loadOrders(user.id, (orderError, orders) => {
    if (orderError) return handleError(orderError);
    renderOrders(orders);
  });
});

Promise 把“将来得到结果”变成一个可以组合的值。链式调用让正常路径保持线性,错误也能沿链传播:

js
function assertOk(response) {
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response;
}

fetch("/api/users/1")
  .then(assertOk)
  .then((response) => response.json())
  .then((user) => fetch(`/api/users/${user.id}/orders`))
  .then(assertOk)
  .then((response) => response.json())
  .then(renderOrders)
  .catch(handleError);

这里显式检查了 response.ok,因为 fetch() 收到 404 或 500 时通常不会自动 reject;只有网络错误等情况才会直接进入 rejection。

async/await 继续沿用 Promise 的语义,只把代码改写成更接近普通顺序流程的样子:

js
async function loadUserAndOrders(userId) {
  const userResponse = assertOk(await fetch(`/api/users/${userId}`));
  const user = await userResponse.json();

  const orderResponse = assertOk(
    await fetch(`/api/users/${user.id}/orders`),
  );
  return orderResponse.json();
}

async function main() {
  try {
    const orders = await loadUserAndOrders(1);
    renderOrders(orders);
  } catch (error) {
    handleError(error);
  }
}

main();

从历史上看,jQuery Deferred 很早就把状态和回调组织进统一对象;ES2015 标准化了 Promise 与 Generator;ES2017 引入 async/await。Generator 展示了函数可以暂停并恢复,是重要的过渡机制,但日常业务代码通常直接使用 Promise 与 async/await

JavaScript 异步编程从 Callback 到 Promise 再到 async/await 的演进

语法不断变化,核心目标始终是更清楚地表达依赖关系、结果传递和错误路径。

5. 真正常出错的是依赖关系

5.1 该串行的任务被并发了

如果任务存在前后依赖,就应该显式串行:

js
for (const item of items) {
  await save(item);
}

如果任务彼此独立,可以一起发起,再统一等待:

js
const results = await Promise.all(items.map((item) => save(item)));

下面这种写法经常制造误解,因为 forEach 不会等待回调返回的 Promise:

js
items.forEach(async (item) => {
  await save(item);
});

console.log("done"); // 此时保存操作可能尚未完成

5.2 用定时器猜完成时间

setTimeout 当成“等一会儿应该就好了”,本质上是在猜。定时器只能表达“不早于某个时间再尝试执行”,无法证明依赖已经完成。可靠的代码应该等待真正的完成信号,例如 Promise settle、事件触发或明确的回调。

5.3 错误跨过了异步边界

外层 try/catch 捕获不到未来 task 里抛出的异常:

js
try {
  setTimeout(() => {
    throw new Error("boom");
  }, 0);
} catch (error) {
  // 不会执行
}

错误需要在回调内部处理,或转换为 Promise rejection 后沿链传播。每个 Promise 也都应该被 awaitreturn、组合或显式 .catch();悬空的 Promise 会让错误和生命周期一起失控。

5.4 主线程仍然被占满

把代码放进 async function 不会自动把计算移到后台。大循环、重计算和同步 I/O 仍会阻塞当前 JavaScript 线程。需要时应拆分工作、让出执行权,或者使用 Worker / Worker Threads 等真正的并行能力。

6. 一段异步代码应该怎么分析

先不要急着猜最终输出。更稳妥的办法是分三遍看:

第一遍只画控制边界:当前同步段在哪里结束,哪些操作交给宿主,后续代码会进入哪类队列。第二遍沿数据依赖往下走,确认哪些步骤必须等待、哪些可以并发,以及错误由谁接住。第三遍才加入调度细节,标注浏览器或 Node.js、CommonJS 或 ES Module,以及可能影响顺序的运行时版本。

JavaScript 异步代码的七步分析法

从环境和同步段开始,沿着异步边界、队列、依赖和错误路径推进,最后再判断进程与调度结果。

最后再模拟顺序:当前调用栈先运行到空,随后处理约定的微任务检查点,再让事件循环选择下一个 task 或 phase。这样分析得到的结论,既能解释日志顺序,也能指出代码里真正不可靠的依赖。

7. 异步最后解决的是时间解耦

把视野从一段代码拉到整个系统,消息队列、事件驱动服务和任务调度器都在处理相似的问题:工作何时完成、结果如何通知、失败由谁接住、背压如何控制。事件循环是理解这些系统的一个很小但很典型的入口。

具体的输出顺序可能随宿主和版本变化,分析方法则相对稳定:先找同步执行段,再找宿主与队列,最后沿依赖链和错误链确认行为。掌握这套方法之后,异步代码就不再是一组需要背诵的规则,而是一张可以逐层展开的时间关系图。

References

返回首页
上一篇啊鸡入坑音视频之《视频帧与时间戳篇》下一篇AI 对新进程序员的冲击:写得更快,为什么入门反而更难

Discussion

留言与讨论

想法、补充和不同意见都欢迎。