从进程线程到 async/await:JS Event-loop事件循环机制底层解析

前言

你是否也曾有过这样的困惑:明明写的是 "顺序执行" 的代码,结果却先拿到了后面请求的结果;明明只是多开了一个浏览器标签,内存占用却直线飙升;明明 setTimeout 设置了 100ms 延迟,实际执行却慢了半拍?

那些被我们挂在嘴边的 "进程""线程",并非只是教科书上的抽象名词;V8 引擎的 "单线程" 背后,藏着不为人知的协作逻辑;而 event-loop 的循环往复,悄悄决定了每一行异步代码的执行时机。我们每天和这些机制打交道,却常常在 "好像懂了" 和 "又被绕晕" 之间反复横跳 ------ 异步的本质不是 "等待",而是 "调度",单线程的表象下,藏着多线程的默契配合。

本文将拨开这些概念的迷雾,从底层逻辑出发,拆解从进程线程到 async/await 的完整链路,带你看清那些 "似懂非懂" 的 JavaScript 运行真相。

进程、线程

  • 进程:cpu运行指令加载和保存上下文所需的时间

  • 线程:cpu 执行指令需要的时间

  • 比如:浏览器多开一个 tab 页,就是增加一个进程

    1. 渲染线程

    2. js 引擎线程

    3. HTTP 请求线程 等等

    • 因为 js 可以修改DOM,所以js 引擎线程和 渲染线程是互斥的

V8

  • v8 在执行js 的过程中默认只开一个线程

  • 只开一个线程就会带来:异步

  • 单线程处理代码的过程:遇到同步任务就会立即执行,遇到异步任务就会存放到任务队列中,等待js 引擎线程空闲时,再执行任务队列中的异步任务

event-loop

在一开始的时候只分同步代码和异步代码,但是到后面发现有些异步代码需要加急处理,于是在异步代码加入任务队列后的基础上有分为微任务和宏任务。简单来说就是微任务是有特权的,可以插在宏任务前头。

  • 微任务:promise.then(), process.nextTick(), MutationObserver

  • 宏任务:script, setTimeout(), setInterval(), ajax, I/O, UI-rendering

执行顺序

  1. 先执行同步代码 (这属于宏任务),这个过程如果遇到异步任务,就存入对应的队列
  2. 同步执行完之后,执行微任务队列中的代码
  3. 微任务全部结束后,有需要的话就渲染页面
  4. 执行宏任务 (下一次循环的开始)

async await

  1. 函数前面加一个 async 等同于函数内部返回了一个 promise实例对象
  2. await 必须跟 async 配合使用,并且 await 后面如果不接一个 promise 对象的话,await 便无法约束它
  3. await fn() 把 fn() 当成同步看待, 是因为 await 会把它后续的代码挤到微任务队列中去
js 复制代码
console.log('script start');
async function async1() {
  await async2()
  console.log('async1 end');
}
async function async2() {
  console.log('async2 end');
}
async1()
setTimeout(() => {
  console.log('setTimeout');
}, 0)
new Promise((resolve, reject) => {
  console.log('promise');
  resolve()
})
  .then(() => {
    console.log('then1');
  })
  .then(() => {
    console.log('then2');
  });
console.log('script end');

如果能将上面这段弯弯绕绕的代码吃透,那基本就跨过了 event-loop 的第一道门槛。来逐行推演一遍。

代码执行推演

第一步,主线程从上往下执行同步代码:

  • console.log('script start') → 立即输出 script start
  • async1() 被调用,进入 async1 函数体
  • await async2() → 先执行 async2,输出 async2 end ;await 会把 async1 函数 await 之后的代码(即 console.log('async1 end'))挂起,塞入微任务队列
  • async1 函数返回一个 Promise(因为 async 修饰),但此时没人 await async1() 的结果,继续往下走
  • setTimeout → 宏任务,塞入宏任务队列
  • new Promise(...) → 构造函数同步执行,输出 promiseresolve() 调用后,第一个 .then 被塞入微任务队列
  • console.log('script end') → 立即输出 script end

此时同步代码跑完了,主线程开始清空微任务队列:

  • 第一个微任务:console.log('async1 end') → 输出 async1 end
  • 第二个微任务:.then(() => console.log('then1')) → 输出 then1 ;then1 执行完后,第二个 .then 被塞入微任务队列
  • 第三个微任务:console.log('then2') → 输出 then2

微任务清空后,执行宏任务:

  • setTimeout 回调 → 输出 setTimeout

最终输出顺序:

arduino 复制代码
script start
async2 end
promise
script end
async1 end
then1
then2
setTimeout

有人可能会问:为什么 async1 endthen1 前面?因为 await async2() 挂起 async1 后续代码的时机,早于 new Promise.then 被塞入微任务队列的时机。微任务队列是先进先出的,所以 async1 end 先执行。

不过这里有个细节在不同浏览器和不同 V8 版本下结果可能不同。V8 73 之前(Chrome 73 以下),async1 end 的输出位置会有差异,因为早期 V8 对 await 的实现是用了两个微任务,后来优化成了一个。这也是为什么有的面试题答案在不同资料里对不上的原因。

await 的底层转换

async/await 说到底是 Promise 的语法糖,但 V8 引擎在编译阶段会对它做一次转换。大致规则如下:

js 复制代码
async function async1() {
  await async2()
  console.log('async1 end')
}

会被转换成类似这样的形式:

js 复制代码
function async1() {
  return async2().then(() => {
    console.log('async1 end')
  })
}

也就是说,await xxx 本质上等价于把 await 后面的代码包进一个 .then 回调里。这就是为什么 await 后面的代码不会立即执行,而是被推到微任务队列。

但 V8 实际的实现比这复杂一点:它会用 PromiseResolveThenableJob 来处理 await 的操作数,确保即使 await 后面跟的不是 Promise(比如 await 42),也会被包成一个 resolved Promise。这意味着 await 后面的代码至少要经过一次微任务轮转才会执行,不会"跳队"。

async 函数的返回值

js 复制代码
async function foo() {
  return 1
}
// 等价于
function foo() {
  return Promise.resolve(1)
}

async 修饰符保证函数一定返回 Promise。如果函数体里 return 了一个 Promise,那就直接返回它;如果 return 了一个普通值,就包成 Promise.resolve(value);如果没 return,返回 Promise.resolve(undefined)

这个设计的好处是调用方永远可以 .then() 链式调用,不用担心"有时候是 Promise,有时候不是"的问题。

浏览器和 Node.js 的 event-loop 差异

很多人以为 event-loop 只有一套规则,实际上浏览器和 Node.js 的实现是有区别的。

浏览器的 event-loop

浏览器的规范来自 HTML Living Standard,一个完整的循环:

  1. 执行一个宏任务(从宏任务队列取一个)
  2. 执行所有微任务(清空微任务队列)
  3. 判断是否需要渲染(requestAnimationFrame、UI rendering)
  4. 如果需要渲染,执行 rAF 回调
  5. 渲染页面
  6. 回到第 1 步

关键点:每一轮循环只执行一个宏任务,然后清空所有微任务。不会出现宏任务还没执行完就去执行下一个宏任务的情况。

Node.js 的 event-loop

Node.js 用的是 libuv,循环分为 6 个阶段(phase):

  1. timers → 执行 setTimeout / setInterval 到期的回调
  2. pending callbacks → 执行系统级回调(如 TCP 错误回调)
  3. idle, prepare → 内部使用
  4. poll → 获取新的 I/O 事件,执行 I/O 回调
  5. check → 执行 setImmediate 回调
  6. close callbacks → 执行 close 事件回调(如 socket.on('close'))

每个阶段切换之间会清空微任务队列(process.nextTick 和 Promise.then)。注意 process.nextTick 的优先级高于普通微任务,它会在每个阶段结束后、微任务之前执行。

一个经典差异案例

js 复制代码
setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

在浏览器里没有 setImmediate,不用考虑。在 Node.js 里,这两行代码的输出顺序不确定 ------ 取决于进入 timers 阶段时 1ms 的定时器是否已经到期。

但如果把它们放在 I/O 回调里:

js 复制代码
const fs = require('fs')
fs.readFile('test.txt', () => {
  setTimeout(() => console.log('timeout'), 0)
  setImmediate(() => console.log('immediate'))
})

这种情况下 immediate 一定先于 timeout 输出。因为 I/O 回调在 poll 阶段执行完后,下一个阶段就是 check(setImmediate),再下一轮循环才会到 timers。

这个知识点看起来像面试八股,但它背后体现的是 libuv 阶段切换的设计逻辑:阶段之间是有固定顺序的,不是随机调度

实际开发中的 event-loop 陷阱

陷阱一:阻塞主线程

js 复制代码
console.log('start')
setTimeout(() => console.log('timeout'), 0)

// 模拟耗时同步操作
const start = Date.now()
while (Date.now() - start < 2000) {}

console.log('end')

输出:start → (等 2 秒)→ endtimeout

setTimeout 的回调再怎么延迟设为 0,也得等主线程把同步代码跑完才会执行。这就是为什么前端性能优化一直强调"不要在主线程做耗时操作"------ 一旦主线程被阻塞,UI 渲染、用户交互、定时器全部会卡住。

如果真要做耗时计算,用 Web Worker 开一个独立线程,或者用 requestIdleCallback 把任务拆成小块在空闲时间执行。

陷阱二:微任务递归导致页面卡死

js 复制代码
function recursive() {
  Promise.resolve().then(recursive)
}
recursive()

这段代码不会栈溢出,因为每次都是通过微任务调度的,调用栈不会累积。但它会无限递归地塞微任务,导致宏任务永远得不到执行机会------页面完全冻结,setTimeout、UI 渲染、用户点击全部失效。

同理:

js 复制代码
function loop() {
  console.log('loop')
  queueMicrotask(loop)
}
loop()

queueMicrotask 是标准 API,直接把回调塞入微任务队列。效果一样------页面卡死。

陷阱三:Promise 链和 async/await 混用的时序问题

js 复制代码
async function foo() {
  console.log('foo start')
  await bar()
  console.log('foo end')
}

function bar() {
  return new Promise(resolve => {
    console.log('bar inside promise')
    resolve()
  })
}

foo()
console.log('main end')

执行顺序:foo startbar inside promisemain endfoo end

为什么 main endfoo end 前面?因为 await bar()foo end 推到了微任务队列,主线程的同步代码 console.log('main end') 先执行。

这种时序问题在面试里经常被问到,实际开发中如果不注意,可能会导致依赖执行顺序的逻辑出 bug。比如你以为 await 之后的代码会在某个同步操作之前执行,结果恰恰相反。

从宏观看微观:一张图理清全链路

把前面所有概念串起来,JavaScript 异步执行的完整链路是:

arduino 复制代码
代码执行
  │
  ├─ 同步代码 → 主线程直接执行
  │
  └─ 异步代码 → 交给 Web API / Node API 处理
       │
       ├─ 计时器(setTimeout)→ 计时到期后,回调进入宏任务队列
       ├─ I/O 操作(fetch, fs)→ 操作完成后,回调进入宏任务队列
       ├─ Promise.then / await 后续 → 进入微任务队列
       └─ process.nextTick(Node.js)→ 进入 nextTick 队列(优先级最高)
            │
            ▼
      event-loop 循环调度
       ├─ 浏览器:宏任务 → 清空微任务 → 渲染 → 下一轮
       └─ Node.js:阶段切换 → 清空 nextTick → 清空微任务 → 下一阶段

理解了这张图,再去看任何异步代码,都能画出它的执行时序。不是背答案,而是从原理推导。

总结

回头看开头那些困惑:

  • "顺序执行的代码,结果却先拿到后面的请求" → 异步请求不阻塞主线程,回调在任务队列里等机会插队
  • "多开一个标签页,内存飙升" → 每个标签页是一个独立进程,有自己的 V8 实例和内存堆
  • "setTimeout 100ms 实际更慢" → setTimeout 的延迟只是"最早可执行时间",如果主线程在忙,回调得排队等

从进程线程到 event-loop 到 async/await,整条链路的核心思想其实就一个:JavaScript 是单线程的,但它背后的运行环境不是。浏览器和 Node.js 提供了多线程的 Web API / libuv 来处理 I/O,通过 event-loop 把异步结果回调到主线程执行。async/await 是这套机制的语法糖,让异步代码写起来像同步,但底层还是 Promise + 微任务调度。

把这些底层逻辑想透了,写代码时自然就知道什么时候该用 Promise、什么时候该用 async/await、什么时候要注意微任务插队的问题。不是为了面试背八股,是为了写出真正靠谱的异步代码。

相关推荐
岁月宁静22 分钟前
一、《从零手撸 Agent》 我用 10 行代码跑通了第一次大模型调用(顺便踩了 4 个坑)
前端·python·agent
guanguan0_025 分钟前
用 AI 做技术方案评审:输入 3 个方案,输出对比矩阵 + 推荐理由
javascript·人工智能·矩阵·ai编程
计算机魔术师41 分钟前
Fable 5.1 来了,智能体任务成本降 45%——Anthropic 这次为何降价这么狠
前端
Csvn42 分钟前
现代 CSS 新特性深度:container query、:has()、@scope 与 subgrid
前端
计算机魔术师1 小时前
Anthropic 自己承认:模型越来越难管住了
前端
两只羊ovo1 小时前
Agent 干活的底层地基:path 路径 + fs 文件系统,一次讲透
前端
xiaominlaopodaren1 小时前
three.js地图视口瓦片(二):如何形成完整视口多边形
javascript·gis·three.js
一用书生1 小时前
我给 ChatGPT、DeepSeek、Kimi 都加了一个「保存为笔记」按钮
前端·ai编程·vibecoding
咖啡无伴侣1 小时前
1. 从零搭建企业级 Monorepo 工程化模板:初始化、应用创建与共享 TypeScript 配置
前端·前端框架