返回首页

Node.js 异步编程演进:从回调地狱到 async/await

回调、Promise、async/await 一路怎么走来,以及并发控制、错误处理这些进阶话题里最容易踩的坑。

41 约 4 分钟 · 4515 字 后端

Node.js 的一切都围绕异步展开。但"写异步"这件事本身,在过去十年经历了好几次大的进化。理解这条演进路线,不只是怀旧,而是能帮你看懂为什么现在的写法是这样、以及那些进阶坑为什么会出现。

第一阶段:回调与回调地狱

最早,Node 的异步全靠回调函数。单个回调没问题,但一旦多个异步操作有先后依赖,代码就会向右"缩进成金字塔":

fs.readFile('a.txt', 'utf8', (err, a) => {
  if (err) return handle(err)
  fs.readFile('b.txt', 'utf8', (err, b) => {
    if (err) return handle(err)
    db.query('SELECT ...', (err, rows) => {
      if (err) return handle(err)
      sendMail(rows, (err) => {
        if (err) return handle(err)
        console.log('终于完成了') // 已经缩进了五层
      })
    })
  })
})

这就是臭名昭著的回调地狱(Callback Hell)。它的问题不只是难看:

  • 每一层都要重复写 if (err),错误处理散落各处
  • 逻辑顺序和代码缩进强绑定,改顺序就是噩梦
  • 没法用 try/catch,没法用 return

第二阶段:Promise 把嵌套拍平

Promise 用"链式调用"取代了嵌套。它代表一个"将来才有结果的值",有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败),且状态一旦确定就不可变。

上面的金字塔被拍平成一条链:

readFilePromise('a.txt')
  .then((a) => readFilePromise('b.txt'))
  .then((b) => db.query('SELECT ...'))
  .then((rows) => sendMail(rows))
  .then(() => console.log('完成'))
  .catch((err) => handle(err)) // 一个 catch 兜住整条链的错误

关键进步:错误处理统一了。链上任何一环 reject,都会直接跳到 .catch,不用每层都写 if (err)

第三阶段:async/await 让异步读起来像同步

Promise 虽好,但 .then 链长了依然不够直观。async/await 是建立在 Promise 之上的语法糖,让异步代码读起来跟同步一样:

async function run() {
  try {
    const a = await readFilePromise('a.txt')
    const b = await readFilePromise('b.txt')
    const rows = await db.query('SELECT ...')
    await sendMail(rows)
    console.log('完成')
  } catch (err) {
    handle(err) // 熟悉的 try/catch 回来了
  }
}

注意两点:

  • async 函数总是返回一个 Promise
  • await 后面跟一个 Promise,会"暂停"函数执行直到它 resolve,但不会阻塞主线程(事件循环照常处理别的任务)
  • 错误处理回归到大家最熟悉的 try/catch

到这里,异步代码终于写得"像人话"了。但 async/await 用顺手之后,新的坑也来了。

进阶坑一:该并发的别写成串行

await 最容易被滥用的地方,就是把本可以并发的操作写成了串行:

// ❌ 串行:三个请求互不依赖,却排队等,总耗时 = 三者之和
const user = await getUser()
const posts = await getPosts()
const comments = await getComments()

这三个请求之间没有依赖关系,完全可以同时发出。用 Promise.all 并发,总耗时降到"最慢的那一个":

// ✅ 并发:同时发出,总耗时 = max(三者)
const [user, posts, comments] = await Promise.all([
  getUser(),
  getPosts(),
  getComments(),
])

判断标准:后一个操作是否依赖前一个的结果。不依赖,就该并发。

进阶坑二:Promise.all 一败俱败

Promise.all 有个特性:只要有一个 reject,整体立刻 reject,其余结果你就拿不到了。如果你想"无论成败都拿到每个结果",用 Promise.allSettled:

const results = await Promise.allSettled([
  getUser(),
  getPosts(),    // 假设这个失败了
  getComments(),
])

// allSettled 不会因为一个失败就丢掉全部
results.forEach((r) => {
  if (r.status === 'fulfilled') console.log('成功', r.value)
  else console.log('失败', r.reason)
})

几个 Promise 组合方法的区别值得记牢:

方法 行为
Promise.all 全部成功才成功,一个失败就整体失败
Promise.allSettled 等全部完成,返回每个的成功/失败结果,不会整体失败
Promise.race 返回最先完成的(无论成功失败),常用于超时控制
Promise.any 返回最先成功的,全部失败才失败

进阶坑三:forEach 里的 await 是假的

这是 async/await 最隐蔽的坑之一。forEach 不会等待里面的 async 回调:

// ❌ 看起来在等,其实没等。"全部处理完"会在任何一个真正完成前就打印
items.forEach(async (item) => {
  await process(item)
})
console.log('全部处理完') // 骗人的

原因是 forEach 根本不理会回调的返回值(Promise)。要顺序处理,用 for...of:

// ✅ 顺序执行,真正等每个完成
for (const item of items) {
  await process(item)
}
console.log('全部处理完')

要并发处理,用 map 生成 Promise 数组再 Promise.all:

// ✅ 并发执行
await Promise.all(items.map((item) => process(item)))

进阶坑四:无限并发会打爆下游

Promise.all(items.map(...)) 很爽,但如果 items 有一万条,你会同时发出一万个请求,瞬间打爆数据库或被对方限流。生产中需要限制并发数:

// 简易的并发池:同时最多 limit 个在跑
async function asyncPool(limit, items, fn) {
  const results = []
  const executing = new Set()
  for (const item of items) {
    const p = Promise.resolve().then(() => fn(item))
    results.push(p)
    executing.add(p)
    p.finally(() => executing.delete(p))
    if (executing.size >= limit) {
      await Promise.race(executing) // 等任意一个腾出位置
    }
  }
  return Promise.all(results)
}

// 同时最多 5 个并发
await asyncPool(5, urls, (url) => fetch(url))

生产里也可以直接用成熟的库(如 p-limit)来做并发控制,原理与上面一致。关键是别让并发数失控

小结

  • 异步写法一路演进:回调 → Promise → async/await,本质都是在管理"将来的值"
  • 回调地狱的痛点是嵌套深、错误处理散;Promise 用链式 + 统一 catch 解决;async/await 让代码读起来像同步
  • 进阶四大坑:
    • 不依赖的操作别串行,用 Promise.all 并发
    • 想"一败不俱败"用 Promise.allSettled,分清 all/allSettled/race/any
    • forEach 不等 await,顺序用 for...of,并发用 map + Promise.all
    • 大批量任务要限制并发数,别打爆下游

写好异步不是会用 await 就行,而是清楚每个操作之间的依赖关系,在"串行、并发、限并发"之间做对选择。