首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
异步函数 vs 异步生成器:从内存爆掉到流式处理的优雅重构
📅 2026/10/11 22:47:46
✍️ 爱科研究院
👁 阅读 3,247
在写数据迁移工具的时候栽过一个不大不小的跟头。当时要从远程接口一页一页拉取数据再逐条写进数据库。我一开始老老实实写了一个async function把接口返回的所有页全部拉下来拼成一个巨型数组再交给下游处理。数据量一上来内存直接爆了进程被系统杀掉。后来我把获取下一页的逻辑从异步函数改成了异步生成器代码行数几乎没变但内存占用从整个列表的大小降到了只有一页的大小。这篇文章就围绕这次重构展开把异步函数和异步生成器的区别、适用场景、常见坑和选型思路一次说透。这两兄弟名字听起来像但定位完全不同。异步函数处理的是一次调用一个结果异步生成器处理的是一次调用多个结果还是按需产出。如果你正在写 Node.js 服务、爬虫、数据管道或者只是想把异步控制流这个概念彻底搞明白这篇应该对你有用。1. 异步函数它到底是怎么暂停的1.1 一次await等于一次状态保存先看一段最常见的代码async function fetchUser(id) { console.log(开始拉取用户); const res await fetch(/users/${id}); console.log(拿到响应); return res.json(); } fetchUser(1); console.log(主流程继续);执行顺序是什么fetchUser被调用后会先执行到await fetch()那一行打印第一行日志。然后await会把当前函数的执行状态整体打包保存下来函数立刻返回一个 Promise主流程继续往下走打印主流程继续。等到fetch真正返回响应时fetchUser的上下文才被恢复从await之后的那一行继续执行。这里的关键是await不是阻塞等待而是先让出到时恢复。它保存的是整个词法环境、调用栈片段和内部状态在 Promise 完成时通过微任务队列精确恢复。这也是为什么异步函数不会阻塞主线程——它让出的不是操作系统线程而是当前这个协程的执行权。可以类比成餐厅点餐服务员不会站在后厨盯着你的菜出锅他会先去做别的事菜好了再端回来。函数跟事件循环之间就是这种接了单就走做好了再回来的关系。1.2 状态机才是异步函数的真身引擎层面对异步函数的实现本质上是把一个函数编译成状态机。每个await都是一个状态切换点async function demo() { const a await getA(); const b await getB(a); return b 1; }编译成状态机后大致是初始状态调用getA()注册 promise 回调挂起。状态1getA完成拿到a调用getB(a)再次挂起。状态2getB完成拿到b计算出b 1作为 Promise 的完成值返回。这套机制让异步函数包装 Promise 时表现得非常自然函数体内的return会变成 Promise 的 resolve函数体内的throw会变成 Promise 的 reject。理解了这一点后面遇到异步函数里的错误处理就不会懵。1.3await不会替你并发写异步函数最容易犯的错就是把多个独立请求排成串行。const a await fetchA(); const b await fetchB();这两行代码真实执行顺序是先发请求 A等 A 完全返回再发请求 B。如果 A 耗时 2 秒、B 耗时 2 秒总耗时是 4 秒。正确的并发姿势是const [a, b] await Promise.all([fetchA(), fetchB()]);这样两个请求几乎同时发出总耗时约 2 秒。但Promise.all也要悠着点用如果传入几百个请求目标服务很可能直接打挂。我自己习惯写一个带并发上限的调度函数比如同时最多跑 10 个完成一个再补一个。这也是从那次内存爆炸事故里学到的另一条教训异步函数只是给了你非阻塞的能力并没有替你规划并发量和内存占用。2. 异步生成器一个函数也能流式返回2.1 从同步生成器说起生成器在 ES6 就已经有了用function*声明配yield逐个吐值function* counter() { let n 0; while (true) { n; yield n; } } const it counter(); console.log(it.next().value); // 1 console.log(it.next().value); // 2这里有个非常容易被忽略的特质调用counter()时函数体一行代码都不会执行。只有调用it.next()时函数才会执行到下一个yield然后再次挂起。这就是惰性求值。while (true)虽然看着像死循环但因为每次都在yield处暂停它并不会真的无限循环下去。我把同步生成器理解成一根水管阀门在消费端。你不拧开next()函数里再多的数据也不会流出来。每次拧一下只能得到一小段水。2.2 异步生成器 生成器 Promise异步生成器就是把同步生成器的能力扩展到异步场景它可以在yield之前await一个 Promise然后吐出一个值。写法和function*只差一个asyncasync function* timedCounter() { let n 0; while (true) { await sleep(1000); n; yield n; } }调用timedCounter()得到的不是普通迭代器而是异步迭代器。它的next()方法返回的不是{ value, done }而是Promise{ value, done }。每次消费者请求下一个值生成器内部会执行到下一个yield这期间可能有多次await消费者拿到的是一个 Promise可以继续做别的事等数据就绪了再处理。这里就是异步函数和异步生成器最直观的分水岭异步函数每次调用只产出一个最终结果或者抛一次错误。异步生成器每次调用产出一个异步迭代器对象可以持续产出多个值这些值还可以是异步等待来的。2.3 两种消费方式最简单的消费方式是for await...offor await (const num of timedCounter(3)) { console.log(num); if (num 3) break; }另一种是手动拿迭代器循环里调用next()const it timedCounter(); const first await it.next(); console.log(first.value);手动方式多见于需要在循环外处理第一步、或者想精细控制迭代节奏的场景。for await 只是帮你省略了重复的await it.next()和判断done的样板代码底层机制并没有差别。3.for await...of消费异步序列协议层面的理解3.1 异步迭代协议的三件套想真正理解异步生成器得先认识异步迭代协议。一个对象能被for await...of消费需要具备三个东西Symbol.asyncIterator方法返回一个异步迭代器对象。异步迭代器对象有next()方法返回Promise{ value, done }。可选地有return()和throw()方法分别处理提前退出和异常注入。for await...of在内部做的事情就是把循环的每一步都变成一个await表达式。它拿到下一个值之后会先检查done。如果是true循环结束否则把value交给循环体。如果提前break或者return退出循环解释器会主动调用迭代器的return()方法。这一步很关键后面聊资源释放还要回来。3.2 同步可迭代对象也能用在for await上有个很容易忽略的兼容细节for await...of的迭代目标不仅可以是异步可迭代对象也可以是普通同步可迭代对象比如数组、Set、Map。它会自动把它们包装成异步迭代器来处理。反过来就不行了。同步的for...of无法消费异步可迭代对象因为同步循环没有地方安排await。你要是把异步生成器丢给for...of会直接报is not iterable的错误。这个不对称性以前坑过我一次排查半天才发现是循环关键字写错了。3.3 真实世界里的异步可迭代对象Node.js 的Readable流是异步可迭代的可以直接for await读取数据块。readline/promises的接口逐行返回 Promise天然适配异步迭代。Web 标准里的ReadableStream同样实现了Symbol.asyncIterator。各种 SDK 里返回AsyncIterableT的方法比如 MongoDB Node.js 驱动里很多查询结果就是异步可迭代对象。这也说明了异步生成器不是花架子它对接的是现实世界中大量数据要分批、分块、在时间上不断到达的场景。4. 实战场景一分页接口和流式日志4.1 从内存爆炸到优雅分页回到开头那个数据迁移工具。最初的实现是这样async function fetchAllPages(api) { const pages []; let page 1; while (true) { const data await api(page); pages.push(...data.items); if (!data.hasMore) break; page; } return pages; }问题很明显pages数组会把全量数据常驻内存。如果总共有 50 万条记录就算每页 100 条最终也会吃掉一份完整的 50 万条数据。下游再逐条写数据库内存写一遍加读一遍直接叠加。改成异步生成器之后async function* paginate(api) { let page 1; while (true) { const data await api(page); yield data.items; if (!data.hasMore) break; page; } } for await (const items of paginate(fetchPage)) { for (const item of items) { await writeRecord(item); } }关键变化在哪yield data.items之后生成器就暂停了。消费者处理完这一批 items生成器才会继续请求下一页。如果消费者每批要处理 2 秒那么下一页请求也是在 2 秒后才发出。这就是拉模型带来的天然背压消费速度决定生产速度生产端不会自作主张地一路狂拉到资源耗尽。4.2 大文件逐行处理内存恒定处理上百 MB 的日志文件如果一次性读入内存再按行切分内存峰值会非常难看。Node.js 里通常这样处理import { createReadStream } from node:fs; import { createInterface } from node:readline/promises; const rl createInterface({ input: createReadStream(/var/log/app.log), crlfDelay: Infinity, }); for await (const line of rl) { // 单行处理这里每次只在内存里保留一行 }readline内部正是基于异步迭代把文件内容一条一条吐出来。文件流本身不会把整个文件装进内存而是按缓冲区大小读数据for await每轮循环只拿一行出来。对处理超大型日志的场景来说这个搭配几乎是最省心的方案。4.3 实时数据流把事件变成可迭代异步生成器还可以把 WebSocket 或者 Server-Sent Events 包装成标准接口。工厂函数内部维护事件监听外面调用方用for await消费消息async function* sseStream(url, signal) { const response await fetch(url, { signal }); const reader response.body.getReader(); try { while (true) { const { done, value } await reader.read(); if (done) break; yield new TextDecoder().decode(value); } } finally { reader.releaseLock(); } }这么写的好处是消费者不需要关心事件监听器、手动 close、连接状态这类底层细节。一个for await循环就把流的生命周期管理起来了异常和结束都会自然退出循环。5. 背压、取消和资源释放异步生成器的硬骨头5.1 背压是自动的但别开后台任务异步生成器在拉模型下天然具备背压但这个背压只对生成器内部的同步代码和await操作有效。如果你在生成器内部启动了setInterval、打开了文件描述符、或者在别的后台任务里做处理消费端停止时这些资源不会自动消失。看看这个反面教材async function* badPoll() { const timer setInterval(() console.log(心跳), 1000); while (true) { await sleep(1000); yield Date.now(); } }消费者如果只取三个值就break生成器被终止但那个setInterval还会继续跑。正确做法是在finally里清理async function* goodPoll() { const timer setInterval(() console.log(心跳), 1000); try { while (true) { await sleep(1000); yield Date.now(); } } finally { clearInterval(timer); } }break触发return()时生成器会执行挂起的finally块。所有需要清理的资源都放在这里最稳妥。5.2 用return()优雅停止生成器除了循环里break还可以手动结束异步生成器const it goodPoll(); await it.next(); await it.return(); // 立即结束并触发 finallyreturn()调用后生成器会走到finally执行清理然后进入完成状态。如果生成器外部还有代码持有它这个操作等于主动释放资源。写库或者写第三方 SDK 的时候这个能力经常用来做优雅退出。5.3 结合 AbortSignal 做取消浏览器和 Node.js 里现在都有AbortSignal。把它传进异步生成器做一个轮询判断async function* pollUntilAborted(signal) { try { while (!signal.aborted) { await sleep(1000); yield getCurrentMetric(); } } finally { cleanup(); } }消费者不用显式调用return()外部只要abortController.abort()生成器就会在下一轮循环检查到signal.aborted主动退出并运行finally。这种方式尤其适合长任务、后台任务和测试流程代码的退出路径非常清晰。6. 错误处理异步生成器不是异步函数的序列版6.1 错误该从哪里冒出来异步函数里throw会变成返回 Promise 的 reject。调用方用一个try/catch就能接住。异步生成器不一样。它内部抛出的异常会变成消费者调用next()得到的 Promise 的 reject。在for await...of循环里就表现为循环体所在作用域的异常。看个例子async function* pageSource() { await sleep(100); throw new Error(数据源异常); yield 1; } try { for await (const value of pageSource()) { console.log(value); } } catch (err) { console.log(捕获, err.message); }执行结果是循环直接跳到catch没有任何值输出。因为第一个next()返回的 Promise 被 reject 了。异步生成器内部可以写多层try/catch错误发生在哪一层就从哪一层抛出没被接住的最终都会浮到消费者的try/catch。6.2 反向注入错误throw()方法迭代器协议里还有一个平时不太会碰到的throw()方法可以主动往生成器内部扔一个错误const it pageSource(); const p it.next(); await it.throw(new Error(从外部注入的错误));这个错误会被当成生成器内部try/catch可能捕获的异常从而影响生成器的执行流。偶尔在测试或协作场景会用到但说实话正常业务代码里比较少依赖它。知道它存在就行遇到外部如何终止一个卡住的生成器这类问题时它是一种候选方案。6.3catch之后继续循环的利弊异步生成器里的try/catch如果只围住某一步错误被捕获之后生成器还能继续async function* safeFetch(urls) { for (const url of urls) { try { const data await fetch(url).then((r) r.json()); yield data; } catch (err) { yield { error: err.message, url }; // 继续下一个 URL } } }这种写法适合单个失败不应该中断整体处理的场景比如批量抓取多个页面。但也要小心错误被吞掉后消费者完全感知不到曾经发生过异常日志里如果不主动记录排查问题时会很被动。我的习惯是catch之后至少打个结构化日志让错误留下痕迹。6.4 别用同步生成器思维写异步生成器一个常见误解是以为异步生成器的yield和同步生成器一样只是多了个await能力。实际上这两者在错误传播和暂停语义上差别都不小。同步生成器中yield本身不会失败异步生成器中yield出来的值如果来自一个失败的 Promise这个失败会直接影响消费者的下一次迭代。引擎处理这两者的内部状态机完全不同所以理解成同一个东西加上异步会漏掉很多细节。7. 选型什么时候用异步函数什么时候上异步生成器7.1 一张表看清差异维度异步函数异步生成器声明方式async functionasync function*返回值PromiseTAsyncGeneratorT执行时机调用即执行到第一个await调用时不执行每次next()推进产出个数单个值或抛错零到多个值消费方式await或thenfor await...of或手动next()典型场景RPC 调用、单次 IO、事务处理分页、流、轮询、批量处理内存特征一次性结果常驻按批次产出内存可控背压无直接等待结果天然拉模型背压取消机制依赖 AbortSignal 或外部包装break触发return()可配合finally清理7.2 判断准则我的判断顺序大致是三条调用方只需要一个最终结果中间值没有消费方用异步函数。数据量大到不能全量放内存或者下一批次依赖上一批次的处理结果用异步生成器。数据是持续到达的比如日志、指标、消息推送异步生成器能把到一批、处理一批表达得很自然。7.3 不要为用而用异步生成器虽然好用但不是所有异步循环都值得改成它。如果接口一次性就把所有数据返回了你只是想在函数内部循环处理那老老实实写一个普通异步函数就好。强行拆出异步生成器反而多了一层迭代抽象让调用方的for await多绕一道。我在实际项目里的体会是先写一个async function版本的实现如果发现它在攒数组、等全量、再处理这条路上走得越来越别扭——比如内存报警、或者后续处理必须等前一批完成——这时候再切换到异步生成器往往是最自然的重构时机。异步函数和异步生成器不是谁替代谁的关系它们只是不同形态的异步控制流工具一个面向单次结果一个面向流式结果。把这根主线抓住了这两个概念就能各归其位。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/11 22:47:46
YOLOv8球类运动轨迹追踪:从目标检测到跨帧关联的完整工程实践
2026/10/11 22:47:46
基于YALMIP的梯级水光互补系统短期优化调度模型解析
2026/10/11 22:42:46
内核报错paging request:内存故障还是驱动问题?
2026/10/11 23:32:49
VC6.0实现USB HID上位机通信:完整指南与避坑经验
2026/10/11 23:32:49
ggsegExtra:高精度脑图谱工具箱,解决fMRI可视化坐标漂移与分区不匹配
2026/10/11 23:32:49
VB6/VB.NET读取安捷伦DSO-X 3034A测量值实战指南
2026/10/11 23:32:49
LegoFlow:自动化模型训练流水线,从数据到测评全流程
2026/10/11 23:32:49
从Cursor回归命令行:AI时代开发者的工具路线与混合实践
2026/10/11 23:27:49
太阳能电池板无人机检测数据集:从整理到YOLO训练全攻略
2026/10/11 0:00:10
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/11 0:00:10
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/11 0:00:10
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/11 0:00:10
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/11 0:00:10
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/11 0:00:10
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/11 19:13:46
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/11 21:41:11
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/9 11:36:17
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)