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...of 里 return() 解决了什么 ​

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 await 当 Promise.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——就抓住了整条机制的核心。

参考资料 ​