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'))
└───────────────────────────┘
对于后端开发者,不需要记住所有阶段的细节。更重要的是理解简化模型:
- 执行一个宏任务(macrotask)
- 执行所有微任务(microtask)
- 渲染(Node.js 不涉及)
- 回到步骤 1
对后端开发者最重要的部分
实际开发中,最常接触的是:
- 宏任务:
setTimeout、setInterval、I/O 回调、setImmediate - 微任务:Promise callbacks、
queueMicrotask、MutationObserver
执行顺序的关键规则:每个宏任务执行完后,会清空所有微任务队列。
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和6 - 同步代码执行完毕,检查微任务队列
- 微任务按入队顺序执行,输出
3、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 内层
推演过程:
- 同步代码执行完毕
- 微任务队列:
4: 外层 Promise,执行并输出 - 微任务队列清空,执行宏任务
1: setTimeout 外层 - 外层 setTimeout 中又注册了 setTimeout 和 Promise
- Promise 是微任务,立即执行,输出
3 - 微任务队列清空,执行下一个宏任务
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 变为 fulfilled 或 rejected,就不会再改变。这个特性让 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.race 和 Promise.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.js | Cloudflare 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 中用于发起请求、处理流式响应和做请求取消。