深入理解 JavaScript 异步函数:从 Event Loop 到 async/await 的本质

164 阅读4分钟

前言

在现代 Web 开发中,异步编程是绕不开的核心话题。无论是读取文件、发起网络请求,还是处理用户交互,JavaScript 都需要在单线程的限制下高效完成任务。为此,语言提供了 Promiseasync/await 等工具,让“异步代码”看起来像“同步代码”一样清晰。

然而,许多初学者甚至中级开发者仍对以下问题感到困惑:

  • 为什么 new Promise 中的 console.log() 是同步执行的?
  • 被 async 声明的函数为什么返回一个 Promise
  • 异步代码的执行顺序到底如何?

本文将从底层机制出发,结合真实代码示例,彻底讲清 JavaScript 异步函数的执行逻辑,并揭示其背后的运行时模型。


一、JavaScript 是单线程,但支持异步

1. 单线程 ≠ 不能并发

  • JavaScript 只有一个主线程,负责执行代码。
  • 但浏览器或 Node.js 提供了多线程能力(如网络线程、定时器线程、文件 I/O 线程)。
  • 当遇到耗时操作(如 fetchfs.readFilesetTimeout),JS 主线程会将其委托给宿主环境,自己继续执行后续同步代码。

2. Event Loop:异步任务的调度中心

  • 同步代码 → 直接执行。
  • 异步任务 → 放入 Event Loop(事件循环)  的任务队列。
  • 所有同步代码执行完毕,Event Loop 才会将队列中的回调函数推入主线程执行。

✅ 这就是为什么 console.log(1)console.log(2) 总是在异步回调之前输出。


二、Promise:异步流程的控制者

1. new Promise 的执行时机

看这段代码:

js
编辑
console.log(1);

const p = new Promise((resolve, reject) => {
    console.log(3); // ← 为什么这是同步的?
    fs.readFile('./a.txt', (err, data) => {
        if (err) reject(err);
        else resolve(data);
    });
});

p.then(data => console.log('then:', data));
console.log(2);

输出顺序1 → 3 → 2 → then: ...

🔍 关键点解析:

  • new Promise(executor) 会立即调用 executor 函数(即传入的那个箭头函数)。
  • 因此,console.log(3) 是在同步阶段执行的
  • 但 fs.readFile 是异步 I/O 操作,它的回调被放入 Event Loop。
  • resolve() 被调用时,才会将 .then() 的回调放入微任务队列(Microtask Queue)。

💡 结论Promise 构造函数内部的同步代码是立即执行的,只有异步回调才是延迟执行的。


2. 微任务 vs 宏任务

  • .then().catch() 属于 微任务(Microtask)

  • setTimeoutsetInterval、I/O 回调属于 宏任务(Macrotask)

  • Event Loop 执行顺序:

    1. 执行当前宏任务(如同步代码)
    2. 清空所有微任务队列
    3. 执行下一个宏任务

这解释了为什么 Promise.then 总是比 setTimeout 更早执行。


三、async/await:语法糖下的 Promise

1. async 函数的本质

js
编辑
async function fetchData() {
    console.log('start');
    const res = await fetch('/api/data');
    console.log('end');
    return res;
}

等价于:

js
编辑
function fetchData() {
    console.log('start');
    return fetch('/api/data').then(res => {
        console.log('end');
        return res;
    });
}

✅ 核心特性:

  • 任何 async 函数都会返回一个 Promise 对象
  • await 只能在 async 函数内部使用
  • await 后面可以跟 Promise 或普通值(普通值会被包装成 resolved Promise)

2. 为什么 async 函数是“异步”的?

虽然 async 函数内部的同步代码会立即执行,但函数本身不会阻塞主线程

js
编辑
console.log(1);

async function foo() {
    console.log(2); // 同步执行
    await Promise.resolve();
    console.log(3); // 微任务中执行
}

foo();
console.log(4);

输出1 → 2 → 4 → 3

⚠️ 注意:console.log(2) 是同步的,但 console.log(3) 被推迟到微任务阶段。


四、常见误区澄清

误区正确理解
“Promise 是异步的”Promise 构造函数内的同步代码是立即执行
“async 函数会延迟执行”async 函数体内的同步部分立刻执行,只有 await 后的内容被推迟
“await 会让整个程序暂停”不会!它只是让当前函数暂停,主线程继续执行其他同步代码

五、大厂高频面试题

🔹 面试题 1:以下代码输出什么?为什么?

js
编辑
console.log('A');

new Promise(resolve => {
    console.log('B');
    resolve();
}).then(() => console.log('C'));

setTimeout(() => console.log('D'), 0);

console.log('E');

答案A → B → E → C → D
考点:同步代码 → 微任务(Promise.then)→ 宏任务(setTimeout)


🔹 面试题 2:async 函数返回的是什么?如何获取它的结果?

答案

  • 返回一个 Promise 对象。
  • 可通过 .then() 或外层 await 获取结果。
js
编辑
async function fn() { return 42; }
fn().then(val => console.log(val)); // 42

🔹 面试题 3:为什么 new Promise 中的 console.log 是同步的?

答案

  • new Promise(executor) 会立即执行 executor 函数
  • 只有 executor 内部的异步操作(如 setTimeoutfetch)才会被推迟。
  • 因此,executor 中的同步语句(如 console.log)属于当前同步执行栈。

🔹 面试题 4:实现一个 sleep 函数,支持 await sleep(1000)

参考答案

js
编辑
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用
async function demo() {
    console.log('开始');
    await sleep(1000);
    console.log('1秒后');
}

六、结语

JavaScript 的异步机制看似复杂,实则逻辑严密。理解 Event Loop、微任务/宏任务、Promise 执行时机、async/await 本质,是写出高性能、无 bug 异步代码的基础。

记住:

  • Promise 构造函数是同步执行的
  • async 函数返回 Promise,内部同步代码立即运行
  • 真正的“异步”发生在宿主环境(如网络、文件系统)与 Event Loop 的协作中

掌握这些,你就能在异步编程的海洋中乘风破浪!