Skip to content

for await...of 深度解析:从协议、return() 到 finally 的完整链路

一句话定义

for await...of 是 ECMAScript 2018 引入的异步迭代协议消费语法。它只做三件事:调用 [Symbol.asyncIterator]() 拿到 AsyncIterator;循环中 await 每次 next() 返回的 Promise<{value, done}>;根据 done 决定继续或退出,并在退出时调用 it.return() 完成清理。

它是 for...of 的完全对称版本,唯一的差异是把"同步取值"换成"等待 Promise 解析"。

核心结论

  1. for await...of 等的是 Promise<{value, done}>,不是值本身。
  2. 每次循环之间必须 await,引擎在那一刻让出当前协程。
  3. 退出循环(done / break / throw)会触发 it.return(),由它负责把底层 socket、reader、缓冲释放。
  4. finally 的触发条件是"控制流要离开 try 块",机制一直没变;generator 多了一种"外部调用 return()/throw()"也能触发它的入口。

协议层:它在遍历什么

for...of 的对比

维度for...offor await...of
协议符号Symbol.iteratorSymbol.asyncIterator
next() 返回{value, done}Promise<{value, done}>
调用之间是否等待
是否需要 async 上下文

所谓"异步迭代"不是数据结构异步,而是"取值时机异步"。next() 返回 Promise,引擎在每次循环间让出微任务。

执行过程:逐步推演

假设当前已经处于 async function 中:

text
1. 调用 query(messages)
   → 返回 AsyncIterable(通常这一步不立即发起 I/O,
     第一次 next() 后才真正开始)

2. 执行 obj[Symbol.asyncIterator]()
   → 得到 AsyncIterator 实例 it

3. 进入循环体:
   a. const p = it.next()              // 返回 Promise,不是值
   b. await p                          // 挂起当前 async function
   c. 微任务队列注册回调,Promise resolve 后唤醒
   d. 取 {value, done}
      - done: true  → 退出并隐式调 it.return()
      - done: false → 绑定 event,执行循环体

4. 回到 3.a,开始下一轮

时序示意

典型实现:async function* 与 SSE

query(messages) 通常返回由 async function* 实现的 AsyncIterable:

javascript
async function* query(messages) {
  const res = await fetch('/api/llm', {
    method: 'POST',
    body: JSON.stringify({ messages }),
    headers: { Accept: 'text/event-stream' }
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) return;
      const text = decoder.decode(value, { stream: true });
      for (const ev of parseSSE(text)) {
        yield ev;            // 暂停 producer,让出控制权
      }
    }
  } finally {
    reader.cancel().catch(() => {});   // 防御性清理
  }
}

关键点:

  • yield 会暂停当前 async generator,让消费者先处理上一个值。
  • Producer "按需生产"——消费快,生成也快;消费慢,producer 在 yield 处挂起。
  • return / break / throw 都会进入 finally,关闭底层 socket。

Vercel AI SDK 的 streamText({ messages })、LangChain 的 model.stream(prompt)、浏览器原生 fetch(...).body(作为 ReadableStream)都能直接被 for await...of 消费。

空循环体的真实含义

javascript
for await (const event of query(messages)) {
  // 空
}
维度实际行为
事件是否被拉取是,会一直拉到 done: true
事件是否被处理否,event 仅作为绑定名,从未被使用
网络是否完成是,整个 SSE 流读完才会退出
底层 reader 是否被关闭退出循环时由 iterator 的 return() 关闭(实现正确时)

这种写法的现实意义只是"驱动流跑完"。工程中应把处理写进循环体,或用 await consume(it) 包一层。

关键概念拆解

"丢弃中间值"是什么

query(messages) 返回的流会按时间推出一系列事件对象。这些就是"中间值"——流在执行过程中产生的中间状态数据。

javascript
for await (const event of query(messages)) {
  // event 被绑定了,但代码中既没有使用,也没有保存
}

循环体为空时,event 每次都被绑定到一个新值,但程序对每个值都"视而不见"。这就是"丢弃"——值确实被取出来了,但没被保存或使用。

对比:

javascript
// 丢弃版
for await (const event of query(messages)) { }

// 不丢弃版
const collected = [];
for await (const event of query(messages)) {
  collected.push(event);
  console.log(event.text);
}
维度丢弃不丢弃
yield 产生值
next() 调用 N 次
数据存入变量
响应给前端 / 写入 UI

唯一副作用是循环跑到 done,函数走完。

"触发清理"是什么

异步迭代器底层通常持有系统资源:HTTP 连接、TCP socket、ReadableStream reader、ReadableStreamDefaultController、缓冲区。这些资源不会自己消失。

for await...of 在以下任意一种情况发生时,会自动调用 it.return()

  1. 流正常结束(done: true
  2. break 提前退出
  3. 循环内抛异常
  4. 外层作用域被销毁

it.return() 的合约是:"告诉 producer 别再产出了,把你手里的资源还回来。" 通常的清理模式:

javascript
async function* query(messages) {
  const reader = res.body.getReader();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) return;
      yield parseSSE(value);
    }
  } finally {
    // return / break / throw 都会进入这里
    await reader.cancel().catch(() => {});
  }
}

清理流程:

text
循环退出(done / break / throw)
  → 引擎调用 it.return()
    → generator 走 finally
      → reader.cancel()
        → 服务器收 RST 或停止监听 socket
          → 操作系统释放 TCP 连接、fd、缓冲

两个概念合在一起看

阶段行为
循环中每次 await next,event 被取出但没存 → 中间值被丢弃
循环结束done / break → 引擎调 it.return()
generator finally关 reader、释放 socket → 资源被清理

它们是 for await 最容易忽略的两个隐式契约:不存也能跑完退出必清理。如果不写 for await,而是自己手写 while (await it.next()),很容易漏掉对 it.return() 的调用,结果是 fetch 连接挂在后台迟迟不释放。

it.return() 触发与实现的分工

两者都要,但触发归引擎,实现归 producer。

return() 的实现归 producer

typescript
interface AsyncIterator {
  next(): Promise<IteratorResult<T>>;
  return?(value?: any): Promise<IteratorResult<T>>;   // 可选
  throw?(error?: any): Promise<IteratorResult<T>>;   // 可选
}
  • next() 是必需的。
  • return() / throw() 定义上可选。如果 async function* 体内没写 return() / finally,引擎会自动生成一个空的。

所以"你不必手写 return()"——但它是 no-op。底层 socket、reader 不会被关。

真正要让资源释放,必须写 try { ... } finally { ... }

javascript
async function* query(messages) {
  const reader = res.body.getReader();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) return;
      yield parseSSE(value);
    }
  } finally {
    // 只有写这里才会被清理
    await reader.cancel().catch(() => {});
  }
}

触发时机归引擎管

触发场景引擎行为
流自己走到 done: true不调 return(),已走完
for await 体内 break引擎调 it.return()
for await 体内 throw / 未捕获异常引擎先调 it.throw(err),再调 it.return()
外层作用域销毁(generator 失去引用)引擎调 it.return()
for await 自身结束同 done 路径

必须自己实现 return() 的场景

  1. 异步生成的对象(非 async function*),手动实现 next / return / throw。
  2. 包装别人的 generator,想加自定义清理逻辑。
  3. 极少见:阻止提前结束,在 return() 里不返回 done: true

核对清单

问题答案
return() 必须自己写吗?不必须。async function* 自动产出,但内容是空的。
清理逻辑必须自己写吗?是。资源释放要放进 try { ... } finally { ... }
引擎会在哪些时机调 return()break / throw / 循环退出 / 外层销毁。
流自己结束时引擎调 return() 吗?不会。已经走完 finally 了。
return() 抛错会怎样?异常继续冒到调用方,所以 finally 里通常 .catch(() => {}) 兜底。

为什么 finally 不够,还需要 it.return()

finally 是"作用域结束"语义,return() 是"取消"语义,两者解决的不是同一个问题。

finally 在什么时候才会跑

触发情况finally 是否执行
generator 函数体内 return✅ 执行
generator 函数体内执行到末尾✅ 执行
generator 函数体内抛异常✅ 执行
外部调用 it.return()✅ 执行
外部销毁 generator 引用但从未调 return()不保证
消费方扔掉 async iterable,for await 直接被中断不保证

JS 引擎对"被丢弃的引用"既不可见也不可预测。在 GC 把它收走前,finally 不一定会跑。TCP 连接挂着、SSE 还在 server 推数据、reader 还没 cancel——这才是 for await...of 引擎需要主动调 return() 的原因:它让"终止"变成显式契约,而不是依赖 GC 触发 finally。

for await...ofreturn() 解决了什么

javascript
for await (const event of query(messages)) {
  if (event.type === 'finish') break;   // 引擎立刻调 it.return()
}

for await (const event of query(messages)) {
  if (event.bad) throw new Error('boom');   // 引擎先 throw、再 return
}

消费方代码主动决定了"现在结束"。引擎把"提前结束"的语义映射成 return() 调用,让 producer 立刻知道"不用再产了"。这和 GC 时机是两码事。

关系对照

text
事件                                    finally            return()
─────────────────────────────────────────────────────────────────
generator 自己 return / throw             跑                 不需要调(已在 finally)
done === true                             跑                 不调
break / throw inside for-await          不一定立刻跑         主动调,让 finally 立刻跑
外层作用域销毁、引用丢弃                 不可预期 / 不一定跑   必须主动调才能保证立刻跑
for await 自身结束                        跑                 不需要调

它们是互相配合而不是互相替代

  • 引擎调用 return() → generator 走 return 路径 → finally 触发。
  • 引擎不调时 → 一般是正常结束或已经在走 finally。

"原本" finally 由内部语句触发;多了 generator 之后,外部调用 it.return() / it.throw() 也能把它唤醒。finally 语义一直没变,generator 只是多了一种"别人从外部叫醒"的可能性

对照实验

javascript
function leak() {
  (async () => {
    for await (const e of query(messages)) {
      if (e.type === 'finish') break;
    }
  })();
}
leak();
// 这里看不到 reader.cancel 立刻执行,要等 GC,时间不可控。

async function good() {
  for await (const e of query(messages)) {
    if (e.type === 'finish') break;   // 引擎调 it.return() → finally → cancel reader
  }
}
// 函数结束时 socket 已关

return() 调用之后,引擎做了什么

调用 it.return() 只是第一步。完整链路:

text
for await 触发 break(或 throw)

引擎调用 it.return()(可能带 value 参数)

generator 从 yield 处被唤醒,控制流往下走

控制流经过剩余代码 / return / throw,进入 finally

finally 块执行你写的清理逻辑

return() 返回 { value: undefined, done: true } 的 Promise

引擎 resolve 这个 Promise,循环结束,进程继续

return() 本身不清理资源——它只是"去把 generator 跑到 finally"。真正关 socket、cancel reader 的是你 finally 里的代码。

每个环节拆解

  1. 引擎调 it.return() ——大致等价于 await iterator.return(undefined)。通知 producer 现在结束。
  2. generator 走到 finally ——return()yield 处暂停的 generator 唤醒,控制流跳到 finally。
  3. finally 跑你的清理逻辑 ——reader.cancel()、关闭 AbortController、释放缓冲区等。
  4. return() 返回结果 ——finally 跑完,generator 退出,返回 { value: undefined, done: true }
  5. 引擎看到 done: true ——循环正常结束。

与 throw 路径对比

text
break  →  it.return()    (温和终止)
throw  →  it.throw(err)  (传信号),再 maybe it.return()(保底)

it.return() 在 throw 路径上常被二次触发,保证 finally 一定执行。

最小实验

javascript
async function* query() {
  try {
    console.log('1. 进 try');
    yield 1;
    console.log('2. yield 1 之后');
  } finally {
    console.log('3. finally 跑了');
  }
}

(async () => {
  for await (const ev of query()) {
    console.log('consumer 拿到:', ev);
    break;            // 触发 return()
  }
  await new Promise(r => setTimeout(r, 0));
})();

输出:

text
1. 进 try
consumer 拿到: 1
3. finally 跑了

yield 1 之后 没出现——控制流没继续往下走,直接跳进 finally。return() 本身不做任何清理,清理全靠你自己写在 finally 里的代码

一句话回答

"return 了,然后呢?"

调用 it.return() 把 generator 从 yield 处唤醒,控制流往下走,穿过 return / throw,最后进入你在 finally 里写的清理逻辑;finally 跑完后,return() 返回 { value: undefined, done: true },引擎看到 done: true,for await 循环结束,程序继续。

return() 不自己清理,finally 才是清理执行处。return() 的作用是"主动唤起 finally"。

finally 触发条件:内部 vs 外部

finally 本质就是"块要结束就跑"——触发它的不是"过程出错",而是"控制流要离开 try 块"。至于离开原因是内部还是外部,finally 自己并不区分。

text
控制流离开 try 块
  ├── ① 块内代码正常执行完(隐式结束)
  ├── ② 块内出现 return 语句
  ├── ③ 块内有 break / continue 跳出循环
  ├── ④ 块内抛异常,未被块内 catch 捕获

  ├── ⑤ 块内的生成器遇到 yield,外界调 return() 结束它
  └── ⑥ 块内的生成器遇到 yield,外界调 throw() 传入错误

前 4 条是"内部原因",对应普通函数;后 2 条是"外部原因",只对 generator / async generator 适用,但对 finally 而言触发机制完全一样

对应到 query

javascript
async function* query(messages) {
  const reader = res.body.getReader();

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) return;          // ② 内部 return → finally
      yield parseSSE(value);     // ⑤/⑥ 外部 return/throw → finally
    }
  } finally {
    await reader.cancel().catch(() => {});
  }
}
场景finally 是否触发谁触发的
流正常结束,函数 return内部 return
读到 done内部 return
break 退出 for await外部 it.return()
消费方 throw外部 it.throw()
generator 引用丢失,等 GC❌ 不保证没人主动调
进程崩溃 / OS kill进程没了

为什么 generator 多了一种触发路径

普通函数:

javascript
function f() {
  try {
    return 1;
  } finally {
    console.log('finally');
  }
}

finally 跑不跑,只取决于函数体内发生什么。外部力量无法干预。

generator 是暂停-唤醒的协程:

javascript
async function* g() {
  try {
    yield 1;       // 暂停
    yield 2;       // 控制权在外部
  } finally {
    console.log('finally');   // 外部能叫醒它,跳到这里
  }
}

外部能调 return() / throw(),相当于越过 yield 直接"叫醒 + 走 cleanup"。从语法角度看,这就是 try/finally 原本的语义,只是多了一个"从外面传信号"的入口

对照实验

javascript
async function* gen(label) {
  try {
    console.log(`${label} 进 try`);
    yield 1;
    console.log(`${label} yield 1 之后`);
    yield 2;
  } finally {
    console.log(`${label} finally`);
  }
}

(async () => {
  // 正常跑完:内部触发 finally
  for await (const v of gen('A')) {
    console.log('A 拿到', v);
  }

  // break:外部 return 触发 finally
  for await (const v of gen('B')) {
    console.log('B 拿到', v);
    break;
  }

  // 抛错:外部 throw 触发 finally
  try {
    for await (const v of gen('C')) {
      console.log('C 拿到', v);
      throw new Error('boom');
    }
  } catch (e) {
    console.log('C 外层 catch:', e.message);
  }

  await new Promise(r => setTimeout(r, 0));
})();

输出:

text
A 进 try
A 拿到 1
A yield 1 之后         ← 内部继续
A 拿到 2
A finally              ← 内部 return 触发

B 进 try
B 拿到 1
B finally              ← 外部 return() 触发,yield 1 之后不再执行

C 进 try
C 拿到 1
C finally              ← 外部 throw() 触发
C 外层 catch: boom

注意 B 和 C:yield 1 之后 那行根本没打出来——finally 直接接在外部信号之后,控制流跳过了剩余 yield。

核心结论

  • finally 的触发条件从来没变:控制流要离开 try 块。
  • 变的是离开原因的来源:原本只有内部原因,generator 多了一条"外部 it.return() / it.throw() 强制离开"。
  • finally 不区分内部还是外部:只关心"是不是要走了"。
  • 唯一不会触发的:外部力量完全没碰它(不调 return 也不调 throw,单纯引用丢失)——finally 等不到 GC 唤醒,只能靠主动调一次让它进入。

异常与取消

异常路径

javascript
try {
  for await (const event of query(messages)) {
    if (event.bad) throw new Error('boom');
  }
} catch (err) {
  // 引擎会先调 it.throw(err)(如果实现了),
  // 然后调 it.return() 清理 producer
}

it.next() 返回的 Promise 直接 reject(如 producer 内部 fetch 失败),异常同样冒到 try/catch。

早退

javascript
for await (const event of query(messages)) {
  if (event.type === 'finish') break;
}
// 引擎调 it.return()

外层超时控制

javascript
await Promise.race([
  (async () => {
    for await (const event of query(messages)) { /* 处理 */ }
  })(),
  new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 30_000))
]);

超时触发后内层 async function reject,循环跳到外层 catch,引擎调 it.return()

与手写等价代码对比

for await...of 在语义上等价于:

javascript
const it = query(messages)[Symbol.asyncIterator]();
try {
  while (true) {
    const { value, done } = await it.next();
    if (done) break;
    // 循环体
  }
} finally {
  if (it.return) await it.return();
}

差别在于:手写容易漏 finally;for await 由引擎转译,保证清理调用必触发。

背压如何自然形成

背压指"消费者跟不上生产者时,生产者被减速"。

  • 空循环体:消费者几乎瞬间 await 完,每次 next() 紧接下一次。producer 会被反复 yield - 唤醒 - yield。
  • 重循环体:await processHeavyTask(event) 时,下一次 next() 要等它 resolve 才发起。yield 的值因此堆积。

for await + async generator 是同步背压,不需要手动写流控。

常见工程陷阱

  1. 空循环体被当成"启动副作用":循环跑完什么都没做,调用方拿不到任何中间结果。
  2. 在普通函数里写 for await:报 SyntaxError: await is only valid in async functions。要么包 async IIFE,要么给整个函数加 async
  3. 不知道 producer 何时开始工作:很多 SSE 流直到第一个 next() 才连接服务器;只构造不消费,请求可能根本没发。
  4. 漏写 cleanup:iterator 的 return() 漏实现会导致底层 fetch / reader 残留。
  5. for awaitPromise.all:它是顺序消费,不是并行;并发需要 Promise.all([...])
  6. 误以为只写 finally 就够了:GC 路径不会主动唤醒 finally,必须配合 it.return() 或显式 break / throw。

验证方法

Node 18+ 可直接跑:

javascript
async function* gen() {
  for (let i = 0; i < 3; i++) {
    await new Promise(r => setTimeout(r, 100));
    yield { id: i };
  }
}

(async () => {
  console.log('start', performance.now());
  for await (const ev of gen()) {
    console.log('event', ev, performance.now());
  }
  console.log('end', performance.now());
})();

预期:每条 event 间隔约 100ms,证明 await next() 在每次循环之间真的"等"了一次。

收束

for await...of 引入的不是新的运行时机制,只是异步迭代协议的语法糖 + 资源清理的协议约定。理解它只需记住三件事:

  1. query(messages) 提供 Symbol.asyncIterator
  2. for await 等的是 Promise<{value, done}>,不是值本身。
  3. 退出(done / break / 异常)触发 iterator.return(),由它唤醒 generator 走到 finally,关流。

把"内部原因触发 finally"和"外部 return/throw 触发 finally"统一理解成同一条规则——控制流离开 try 块就要走 finally——就抓住了整条机制的核心。

参考资料