09-同步、异步与事件循环

前面几篇文章讲了 HTTP 协议、REST 架构、Web Standards、运行时和部署模型。读者已经知道 HTTP 请求是异步处理的,Fetch API 返回 Promise,流式响应需要异步读取。这些「异步」行为背后有一个共同的机制——JavaScript 的事件循环。

本篇要解释这个机制是如何工作的,以及它为什么对 AI 后端开发者很重要。

要点

  • JavaScript 是单线程语言,通过事件循环实现非阻塞 I/O
  • 同步代码会阻塞线程,在 AI 后端中会导致请求堆积和超时
  • 事件循环按阶段执行任务,微任务优先于宏任务
  • Promise 的三种状态(pending、fulfilled、rejected)和链式调用是异步编程的基础
  • async/await 是 Promise 的语法糖,让异步代码看起来像同步
  • 理解微任务和宏任务的执行顺序,是排查后端异步问题的关键
  • Edge Runtime 的 isolate 模型对 CPU 时间有严格限制,与 Node.js 事件循环行为有差异

1. 同步与阻塞

同步代码按顺序执行,每一行代码等待前一行完成后才开始。这种执行方式在计算密集的场景中可以接受,但在 I/O 密集的后端服务中会导致严重问题。

看一个反面示例:

import { readFileSync } from 'node:fs'
import { Hono } from 'hono'
 
const app = new Hono()
 
// 反面示例:同步读取文件
app.get('/config', (c) => {
  // readFileSync 会阻塞整个线程,直到文件读取完成
  const config = readFileSync('./config.json', 'utf-8')
  return c.json(JSON.parse(config))
})
 
app.listen({ port: 3000 })

这段代码的问题在于:readFileSync 执行期间,整个 Node.js 进程无法处理任何其他请求。如果有 100 个并发请求,它们会排队等待,一个接一个处理。文件读取可能需要几毫秒到几百毫秒,取决于文件大小和磁盘速度。在这段时间里,服务器完全停滞。

对于 AI 后端,这个问题更严重。调用 LLM API 可能需要几秒到几十秒,如果同步等待,服务器吞吐量会降到不可接受的水平。

阻塞操作在 AI 后端中不可接受的原因:

  • LLM API 调用延迟高(通常 1-30 秒)
  • 并发请求会被阻塞,导致超时和用户体验下降
  • 单线程模型下,一个阻塞操作会影响所有其他请求

2. 异步与非阻塞 I/O

Node.js 的设计选择是用事件循环处理 I/O,而不是为每个请求创建新线程。当代码发起 I/O 操作(网络请求、文件读取、数据库查询)时,Node.js 将操作交给系统内核或线程池,然后继续执行后续代码。I/O 完成后,回调函数会被放入任务队列,等待事件循环处理。

这种模型的演进路径是:回调 → Promise → async/await。

回调时代

import { readFile } from 'node:fs/promises'
 
// 早期 Node.js 风格
readFile('./config.json', 'utf-8', (err, data) => {
  if (err) {
    console.error(err)
    return
  }
  console.log(data)
})
 
// 回调地狱
readFile('./a.json', 'utf-8', (err1, a) => {
  if (err1) return
  readFile('./b.json', 'utf-8', (err2, b) => {
    if (err2) return
    readFile('./c.json', 'utf-8', (err3, c) => {
      if (err3) return
      console.log(a, b, c)
    })
  })
})

回调的问题在于错误处理分散、嵌套层级深、控制流难以追踪。

Promise 时代

import { readFile } from 'node:fs/promises'
 
// Promise 链式调用
readFile('./a.json', 'utf-8')
  .then((a) => {
    return readFile('./b.json', 'utf-8').then((b) => [a, b])
  })
  .then(([a, b]) => {
    return readFile('./c.json', 'utf-8').then((c) => [a, b, c])
  })
  .then(([a, b, c]) => {
    console.log(a, b, c)
  })
  .catch((err) => {
    console.error(err)
  })

Promise 让错误处理集中化,但链式调用仍然不够直观。

async/await 时代

import { readFile } from 'node:fs/promises'
 
async function readAll() {
  try {
    const a = await readFile('./a.json', 'utf-8')
    const b = await readFile('./b.json', 'utf-8')
    const c = await readFile('./c.json', 'utf-8')
    console.log(a, b, c)
  } catch (err) {
    console.error(err)
  }
}

async/await 让异步代码的写法接近同步,但底层仍然是非阻塞的。await 暂停的是函数执行,不是整个线程。

AI 后端的 I/O 特征

AI 后端大部分时间都在等待 I/O:

操作典型延迟类型
LLM API 调用1-30 秒网络 I/O
向量数据库查询10-100 毫秒网络 I/O
用户会话读取1-10 毫秒网络/内存
日志写入1-5 毫秒磁盘 I/O
流式响应读取持续数秒网络 I/O

这些操作的共同点是 CPU 参与度低,大部分时间在等待外部系统响应。事件循环模型在这种场景下效率很高——单线程可以处理大量并发 I/O,只要 CPU 计算量不大。

3. 事件循环(Event Loop)

事件循环是 Node.js 处理异步操作的核心机制。它的基本模型是:不断检查任务队列,如果有任务就执行,没有就等待。

六个阶段

Node.js 的事件循环分为六个阶段,每个阶段负责处理特定类型的任务:

   ┌───────────────────────────┐
┌─>│           timers          │  setTimeout、setInterval 的回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  系统操作的回调(如 TCP 连接错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  I/O 回调、阻塞等待新 I/O
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  setImmediate 的回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──│      close callbacks      │  close 事件的回调(如 socket.on('close'))
   └───────────────────────────┘

对于后端开发者,不需要记住所有阶段的细节。更重要的是理解简化模型:

  1. 执行一个宏任务(macrotask)
  2. 执行所有微任务(microtask)
  3. 渲染(Node.js 不涉及)
  4. 回到步骤 1

对后端开发者最重要的部分

实际开发中,最常接触的是:

  • 宏任务setTimeoutsetInterval、I/O 回调、setImmediate
  • 微任务:Promise callbacks、queueMicrotaskMutationObserver

执行顺序的关键规则:每个宏任务执行完后,会清空所有微任务队列

4. 微任务(Microtasks)与宏任务(Macrotasks)

理解微任务和宏任务的执行顺序,是排查异步问题的关键。

代码示例:执行顺序推演

console.log('1: 同步代码开始')
 
setTimeout(() => {
  console.log('2: setTimeout 回调(宏任务)')
}, 0)
 
Promise.resolve().then(() => {
  console.log('3: Promise then(微任务)')
})
 
Promise.resolve().then(() => {
  console.log('4: Promise then(微任务)')
})
 
queueMicrotask(() => {
  console.log('5: queueMicrotask(微任务)')
})
 
console.log('6: 同步代码结束')

执行顺序:

1: 同步代码开始
6: 同步代码结束
3: Promise then(微任务)
4: Promise then(微任务)
5: queueMicrotask(微任务)
2: setTimeout 回调(宏任务)

推演过程:

  1. 执行同步代码,输出 16
  2. 同步代码执行完毕,检查微任务队列
  3. 微任务按入队顺序执行,输出 345
  4. 微任务队列清空,开始下一轮事件循环
  5. 执行 setTimeout 回调,输出 2

嵌套 Promise 的执行顺序

setTimeout(() => {
  console.log('1: setTimeout 外层')
 
  setTimeout(() => {
    console.log('2: setTimeout 内层')
  }, 0)
 
  Promise.resolve().then(() => {
    console.log('3: Promise(微任务)')
  })
}, 0)
 
Promise.resolve().then(() => {
  console.log('4: 外层 Promise')
})

执行顺序:

4: 外层 Promise
1: setTimeout 外层
3: Promise(微任务)
2: setTimeout 内层

推演过程:

  1. 同步代码执行完毕
  2. 微任务队列:4: 外层 Promise,执行并输出
  3. 微任务队列清空,执行宏任务 1: setTimeout 外层
  4. 外层 setTimeout 中又注册了 setTimeout 和 Promise
  5. Promise 是微任务,立即执行,输出 3
  6. 微任务队列清空,执行下一个宏任务 2: setTimeout 内层

对 AI 后端的意义

在 AI 后端中,这种执行顺序影响:

  • 日志顺序console.log 和异步操作的输出顺序可能与代码顺序不同
  • 状态更新:微任务中的状态更新会在下一个宏任务之前生效
  • 错误处理:未捕获的 Promise rejection 会在微任务阶段触发

5. Promise 深入

Promise 是 JavaScript 异步编程的基础。理解它的状态机和组合方法,对编写健壮的 AI 后端代码很重要。

Promise 的三种状态

const p1 = new Promise((resolve) => {
  setTimeout(() => resolve('done'), 1000)
})
// 初始状态:pending
// 1 秒后:fulfilled
 
const p2 = new Promise((_, reject) => {
  setTimeout(() => reject(new Error('failed')), 1000)
})
// 初始状态:pending
// 1 秒后:rejected
 
const p3 = Promise.resolve('immediate')
// 初始状态:fulfilled(立即完成)

Promise 的状态一旦从 pending 变为 fulfilledrejected,就不会再改变。这个特性让 Promise 可以作为异步操作的结果容器。

Promise 链

async function fetchLLMResponse(prompt: string) {
  return fetch('https://api.llm.com/v1/chat', {
    method: 'POST',
    body: JSON.stringify({ prompt })
  })
    .then((res) => {
      // 检查 HTTP 状态
      if (!res.ok) {
        throw new Error(`HTTP ${res.status}`)
      }
      return res.json()
    })
    .then((data) => {
      // 提取响应内容
      return data.choices[0].message.content
    })
    .catch((err) => {
      // 统一错误处理
      console.error('LLM 调用失败:', err)
      throw err // 继续抛出,让调用者处理
    })
}

Promise 链的每个 .then() 返回新的 Promise,可以连续处理异步结果。错误会沿着链向下传播,直到被 .catch() 捕获。

Promise 组合方法

AI 后端经常需要同时处理多个异步操作。Promise 提供了四种组合方法:

// 1. Promise.all:全部成功才成功,一个失败就失败
const [response1, response2] = await Promise.all([
  callLLM('prompt1'),
  callLLM('prompt2')
])
 
// 2. Promise.allSettled:等待全部完成,无论成功失败
const results = await Promise.allSettled([
  callLLM('prompt1'),
  callLLM('prompt2'),
  callLLM('prompt3')
])
// results: [{ status: 'fulfilled', value: ... }, { status: 'rejected', reason: ... }, ...]
 
// 3. Promise.race:取最快完成的(无论成功失败)
const fastest = await Promise.race([
  callOpenAI('prompt'),
  callAnthropic('prompt'),
  callLocalModel('prompt')
])
 
// 4. Promise.any:取最快成功的(全部失败才失败)
const firstSuccess = await Promise.any([
  callPrimaryLLM('prompt'),
  callSecondaryLLM('prompt'),
  callTertiaryLLM('prompt')
])

AI 场景:多模型竞争调用

在 AI 后端中,Promise.racePromise.any 常用于多模型竞争调用:

async function callWithFallback(prompt: string): Promise<string> {
  // 同时发起多个模型调用
  const models = [
    callClaude(prompt).then((r) => ({ provider: 'claude', content: r })),
    callGPT(prompt).then((r) => ({ provider: 'gpt', content: r })),
    callLocalModel(prompt).then((r) => ({ provider: 'local', content: r }))
  ]
 
  // 取第一个成功的结果
  try {
    const result = await Promise.any(models)
    console.log(`使用 ${result.provider} 的响应`)
    return result.content
  } catch (errors) {
    // 所有模型都失败
    console.error('所有模型调用失败:', errors)
    throw new Error('无可用模型')
  }
}

这段代码同时向多个 LLM 发起请求,取最快返回的成功结果。如果所有模型都失败,抛出错误。

错误处理

// 方式 1:.catch()
fetchLLMResponse('hello')
  .then((response) => console.log(response))
  .catch((err) => console.error(err))
 
// 方式 2:async/await + try/catch
try {
  const response = await fetchLLMResponse('hello')
  console.log(response)
} catch (err) {
  console.error(err)
}

两种方式等价,但 async/await 的 try/catch 可以捕获同步和异步错误,更统一。

6. async/await

async/await 是 Promise 的语法糖,让异步代码的写法接近同步。

基本规则

// async 函数的返回值始终是 Promise
async function getValue(): Promise<string> {
  return 'hello'
}
 
// 等价于
function getValue(): Promise<string> {
  return Promise.resolve('hello')
}
 
// await 暂停执行直到 Promise 完成
async function main() {
  const value = await getValue() // 等待 Promise resolve
  console.log(value) // 'hello'
}

错误处理

async function fetchAndProcess() {
  try {
    const response = await fetch('https://api.llm.com/v1/chat')
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }
    const data = await response.json()
    return data.choices[0].message.content
  } catch (err) {
    // 捕获 fetch 错误、HTTP 错误、JSON 解析错误
    console.error('请求失败:', err)
    throw err
  }
}

常见陷阱

陷阱 1:忘记 await

async function bad() {
  const response = fetch('https://api.llm.com/v1/chat')
  // response 是 Promise,不是 Response
  const data = response.json() // 报错:response.json is not a function
}
 
async function good() {
  const response = await fetch('https://api.llm.com/v1/chat')
  const data = await response.json()
  return data
}

陷阱 2:串行执行可以并行的操作

// 慢:串行执行,总耗时 = A + B + C
async function slow() {
  const a = await callModelA() // 等待 3 秒
  const b = await callModelB() // 等待 3 秒
  const c = await callModelC() // 等待 3 秒
  return [a, b, c] // 总耗时 9 秒
}
 
// 快:并行执行,总耗时 = max(A, B, C)
async function fast() {
  const [a, b, c] = await Promise.all([
    callModelA(), // 同时发起
    callModelB(), // 同时发起
    callModelC()  // 同时发起
  ])
  return [a, b, c] // 总耗时约 3 秒
}

这个陷阱在 AI 后端中很常见。多个独立的模型调用应该并行执行,而不是串行等待。

AI 场景:流式读取中的 async iteration

流式响应是 AI 后端的核心需求。ReadableStream 的异步读取使用 async iteration:

async function streamLLMResponse(prompt: string) {
  const response = await fetch('https://api.llm.com/v1/chat', {
    method: 'POST',
    body: JSON.stringify({ prompt, stream: true })
  })
 
  if (!response.body) {
    throw new Error('No response body')
  }
 
  // response.body 是 ReadableStream
  // 使用 async iteration 逐块读取
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
 
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
 
      const chunk = decoder.decode(value, { stream: true })
      // 处理每个 chunk
      process.stdout.write(chunk)
    }
  } finally {
    reader.releaseLock()
  }
}

await reader.read() 每次等待下一个数据块到达。在等待期间,事件循环可以处理其他请求。这就是非阻塞 I/O 的体现。

7. 对 AI 后端的实际影响

事件循环模型对 AI 后端的影响体现在四个具体场景。

7.1 并发请求处理

Node.js 的单线程事件循环模型意味着:

  • 一个 Node.js 进程只有一个主线程执行 JavaScript
  • I/O 操作由系统内核或线程池处理,不阻塞主线程
  • 大量并发请求可以共享同一个线程,只要 CPU 计算量不大
// 这个服务器可以同时处理数千个并发请求
// 因为每个请求大部分时间在等待 I/O
app.post('/api/chat', async (c) => {
  const prompt = await c.req.json()
 
  // 这里的 await 不会阻塞其他请求
  // 在等待 LLM API 响应期间,事件循环可以处理其他请求
  const response = await fetch('https://api.llm.com/v1/chat', {
    method: 'POST',
    body: JSON.stringify(prompt)
  })
 
  return c.json(await response.json())
})

这意味着 Node.js 可以用少量线程(通常等于 CPU 核心数)处理大量并发 I/O 密集型请求。这是它适合 AI 后端的原因之一。

7.2 流式响应

流式响应是 AI 后端的核心需求。ReadableStream 的异步读取与事件循环配合:

app.get('/api/stream', async (c) => {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder()
 
      // 模拟流式输出
      for (let i = 0; i < 10; i++) {
        // await 让出控制权,事件循环可以处理其他请求
        await new Promise((resolve) => setTimeout(resolve, 100))
 
        controller.enqueue(encoder.encode(`data: chunk ${i}\n\n`))
      }
 
      controller.enqueue(encoder.encode('data: [DONE]\n\n'))
      controller.close()
    }
  })
 
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache'
    }
  })
})

await new Promise(...) 期间,事件循环可以处理其他请求。流式输出的每个 chunk 之间不会阻塞其他用户。

7.3 超时控制

AI API 调用可能很慢,需要设置超时:

async function callLLMWithTimeout(prompt: string, timeoutMs = 30000) {
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), timeoutMs)
 
  try {
    const response = await fetch('https://api.llm.com/v1/chat', {
      method: 'POST',
      body: JSON.stringify({ prompt }),
      signal: controller.signal // 传入 abort signal
    })
    return await response.json()
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error(`LLM 调用超时(${timeoutMs}ms)`)
    }
    throw err
  } finally {
    clearTimeout(timeout)
  }
}

AbortController 是 Web Standard API,可以在超时后取消正在进行的 fetch 请求。这避免了请求永远挂起。

7.4 内存管理

长时间流式传输时,需要注意内存:

// 反面示例:累积所有 chunk 到内存
async function badStream() {
  const chunks: string[] = []
  const reader = response.body!.getReader()
 
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    chunks.push(decoder.decode(value)) // 累积到数组
  }
 
  return chunks.join('') // 一次性返回
}
 
// 正确做法:逐块处理,不累积
async function goodStream() {
  const reader = response.body!.getReader()
 
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
 
    // 逐块处理,处理完即释放
    processChunk(value)
  }
}

对于流式响应,应该逐块处理和转发,而不是累积到内存。这在处理长文本生成时尤为重要。

8. Edge Runtime 中的并发模型

Cloudflare Workers 等 Edge Runtime 使用 isolate 模型,与 Node.js 的事件循环有差异。

Isolate 模型

每个 Cloudflare Worker 运行在独立的 V8 isolate 中:

  • isolate 是 V8 引擎的轻量级实例,比线程更轻
  • 每个 isolate 有自己的堆内存,不与其他 isolate 共享
  • Worker 启动时创建 isolate,冷启动时间通常在几毫秒
// Cloudflare Workers 入口
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // 每个请求在 isolate 中执行
    // isolate 可以处理多个请求,但内存有限制
    return handleRequest(request)
  }
}

与 Node.js 事件循环的差异

特性Node.jsCloudflare Workers
并发模型单线程事件循环isolate + 事件循环
CPU 时间限制无(直到进程退出)免费版 10ms,付费版 30s
内存限制约 1.5GB(V8 默认)128MB
文件系统无(使用 KV/R2)
长连接支持有限制

最重要的差异是 CPU 时间限制。Cloudflare Workers 免费版只允许 10ms CPU 时间,付费版 30 秒。这意味着:

  • 不能做大量 CPU 密集型计算
  • 流式响应时,每个 chunk 的处理时间不能超限
  • 长时间运行任务需要使用 Durable Objects 或 Queue
// 在 Cloudflare Workers 中,这个代码可能超时
async function heavyComputation() {
  // 大量计算会消耗 CPU 时间
  for (let i = 0; i < 1000000; i++) {
    // ...
  }
}

对 AI 后端的影响

Edge Runtime 适合:

  • 轻量级 API 代理
  • 请求路由和转发
  • 简单的 prompt 处理

不适合:

  • 大量文本处理
  • 本地模型推理
  • 长时间流式传输

选择运行时需要根据具体需求权衡。

延伸阅读

总结

JavaScript 的事件循环是非阻塞 I/O 的核心机制。单线程通过异步处理大量并发 I/O,这是 Node.js 适合 AI 后端的原因。

Promise 和 async/await 让异步代码更易读,但底层仍然是事件循环驱动。理解微任务和宏任务的执行顺序,是排查异步问题的关键。

Edge Runtime 的 isolate 模型对 CPU 时间和内存有限制,与 Node.js 行为有差异。选择运行时需要根据具体需求权衡。

下一篇会深入 Fetch API,具体看 fetch() 如何在 Hono 中用于发起请求、处理流式响应和做请求取消。