马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
16. IAsyncEnumerable 异步流:边加载边处置惩罚的优雅之道
本章 GitHub 堆栈:csharp-concurrency-cookbook ⭐
接待 Star 和 Fork!全部代码示例都可以在堆栈中找到并运行。
🎯 本章导读
📌 本文目标:把握 IAsyncEnumerable 的使用场景、焦点语法和性能优化本领,实现真正的流式异步数据处置惩罚。
你是否遇到过如许的场景:
- 从数据库查询 10 万条记载,要等全部加载完才气开始处置惩罚?
- 调用分页 API 获取数据,必须等候全部页都下载完?
- 实时日记流、传感器数据流,怎样边吸取边处置惩罚?
- 大文件读取时,怎样克制一次性加载到内存导致 OOM?
本日,我们就来彻底搞懂 IAsyncEnumerable——.NET 异步流的标准办理方案(C# 8.0 引入,.NET Core 3.0+ 支持)。
⚠️ 前置知识:本文涉及 async/await、yield return、Task 等概念,发起先把握前面章节的异步编程根本(第 03-06 章)。
0️⃣ 一个真实的故事:GitHub API 的分页加载逆境
0.1 场景重现:获取某个堆栈的全部 Issue
假设你要写一个工具,获取 GitHub 某个热门堆栈的全部 Issue(大概有上万个),然后统计标签分布、关键词频率等。
GitHub API 是分页的:每次哀求最多返回 100 条,必要通过 page 参数逐页获取。
你写出了第一版代码:- // ❌ 方案1:全部加载完再处理 —— 慢,还占内存
- public async Task<List<GitHubIssue>> GetAllIssuesAsync(string repo)
- {
- var allIssues = new List<GitHubIssue>();
- int page = 1;
- while (true)
- {
- var issues = await FetchPageAsync(repo, page); // 等待每一页
- if (issues.Count == 0) break;
- allIssues.AddRange(issues); // 堆积在内存里
- page++;
- }
- return allIssues; // 全部返回后才能开始处理
- }
- // 调用方必须等待所有数据加载完
- var allIssues = await GetAllIssuesAsync("dotnet/runtime"); // 等了 30 秒...
- foreach (var issue in allIssues)
- {
- ProcessIssue(issue); // 终于可以开始处理了
- }
复制代码 0.2 这个实现的标题
运行后你发现了几个标题:
- ❌ 相应慢:用户要等候全部数据加载完(大概几十秒)才气看到第一条结果
- ❌ 内存占用高:1 万条 Issue 全部加载到内存,大概占用几十 MB
- ❌ 不敷流式:数据是"批量"的,不是"流式"的,无法边加载边处置惩罚
- ❌ 无法提前取消:如果用户只必要前 100 条,也要等全部加载完
你开始思索:能不能边加载边返回?就像水龙头一样,一边出水一边用,而不是先接满一桶再倒出来?
0.3 IAsyncEnumerable 的登场
.NET 提供了一套完备的异步流机制:IAsyncEnumerable。
使用 IAsyncEnumerable 重写后:- // ✅ 方案2:流式返回 —— 快,省内存
- public async IAsyncEnumerable<GitHubIssue> GetAllIssuesStreamAsync(string repo)
- {
- int page = 1;
- while (true)
- {
- var issues = await FetchPageAsync(repo, page);
- if (issues.Count == 0) break;
- foreach (var issue in issues)
- {
- yield return issue; // 逐个返回,不等全部加载完
- }
- page++;
- }
- }
- // 调用方立刻就能开始处理第一条数据
- await foreach (var issue in GetAllIssuesStreamAsync("dotnet/runtime"))
- {
- ProcessIssue(issue); // 边加载边处理!
- }
复制代码 神奇的结果:
- ✅ 相应快:第一页加载完(1 秒)就开始处置惩罚,无需等候全部
- ✅ 内存低:同时在内存中的只有当前处置惩罚的那一页(100 条)
- ✅ 真正的流式:数据像水流一样源源不绝,边来边处置惩罚
- ✅ 可以提前竣事:用 break 或 return 立即制止,后续页不会再加载
看到这里,你是不是开始心动了?别急,我们从原理讲起。
1️⃣ IAsyncEnumerable 是什么?它与 IEnumerable 有什么区别?
1.1 回顾:IEnumerable 的同步天下
我们先回顾一下 IEnumerable,它是 .NET 中最常见的"可罗列"接口:- // 同步版本:生成 1 到 10 的数字
- public IEnumerable<int> GetNumbers()
- {
- for (int i = 1; i <= 10; i++)
- {
- yield return i; // 每次返回一个数字
- }
- }
- // 消费方式
- foreach (var num in GetNumbers())
- {
- Console.WriteLine(num); // 同步逐个处理
- }
复制代码 6️⃣ 性能优化:ValueTask vs Task、缓冲战略
6.1 为什么 MoveNextAsync 返回 ValueTask?
你大概留意到了,IAsyncEnumerator.MoveNextAsync() 返回的是 ValueTask,而不是 Task。
缘故原由:对于高频调用的场景(如数据库游标、文件流),如果每次 MoveNextAsync 都分配一个 Task 对象,会产生大量 GC 压力。
ValueTask 的上风:
- 如果操纵同步完成(如数据已在缓冲区),直接返回结果,不分配堆内存
- 如果操纵异步完成(必要等候 I/O),才分配 Task
这就是为什么 IAsyncEnumerable 比自己手写 Task 更高效的缘故原由之一。
6.2 缓冲战略:镌汰网络来回
对于网络 API,可以实现一个"预加载"战略:当前页在处置惩罚时,配景已经在加载下一页。- // ❌ 错误示范:IEnumerable 里不能用 await
- public IEnumerable<GitHubIssue> GetIssues()
- {
- var issues = await FetchPageAsync(1); // ❌ 编译错误:yield return 方法不能是 async
- foreach (var issue in issues)
- yield return issue;
- }
复制代码 结果:当前页在处置惩罚时,下一页已经在配景加载,镌汰用户等候时间。
6.3 ⚠️ 克制 CA2024 告诫:不要在异步方法中使用 EndOfStream
在读取文件流时,你大概会遇到这个告诫:- // ❌ 不够优雅:失去了"流式"的优势
- public async Task<IEnumerable<GitHubIssue>> GetIssuesAsync()
- {
- var allIssues = new List<GitHubIssue>();
- var issues = await FetchPageAsync(1);
- allIssues.AddRange(issues);
- return allIssues; // 一次性返回
- }
复制代码 标题:EndOfStream 是一个同步属性,在异步方法中调用大概导致壅闭,违背了异步编程的原则。
精确做法:通过 ReadLineAsync 的返回值(null)来判断流竣事:- public interface IAsyncEnumerable<out T>
- {
- IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
- }
- public interface IAsyncEnumerator<out T> : IAsyncDisposable
- {
- T Current { get; }
- ValueTask<bool> MoveNextAsync(); // 注意:这里是 ValueTask,不是 Task
- }
复制代码 6.4 高性能方案:使用 System.IO.Pipelines(.NET Core 3.0+)
对于大文件、高吞吐量场景,System.IO.Pipelines 提供了更高效的流处置惩罚方式:
上风:
- 使用内存池(Memory Pool),镌汰 GC 压力
- 更高效的缓冲管理
- 支持背压控制
- 典范性能提拔:1.5-3x,内存分配镌汰 50-80%
实用场景:
- 大文件(> 100 MB)
- 高吞吐量场景(如日记处置惩罚、数据导入)
- 网络流处置惩罚
实现示例:- // ✅ 正确示范:异步流式返回
- public async IAsyncEnumerable<int> GetNumbersAsync()
- {
- for (int i = 1; i <= 10; i++)
- {
- await Task.Delay(100); // 模拟异步操作(如 API 调用)
- yield return i; // 每次返回一个数字
- }
- }
- // 消费方式:await foreach
- await foreach (var num in GetNumbersAsync())
- {
- Console.WriteLine(num); // 边加载边处理
- }
复制代码 性能对比(10 万行文件):
方案耗时内存分配吞吐量StreamReader150 ms~20 MB666K 行/秒Pipeline100 ms~5 MB1M 行/秒提拔1.5x镌汰 75%1.5x选择发起:
场景保举方案缘故原由小文件(< 10 MB)StreamReader代码简单,性可以大概用大文件(> 100 MB)Pipeline显着的性能提拔和内存节省实时监控 (tail -f)StreamReader实时性更紧张,性能差异不显着高吞吐量场景Pipeline镌汰 GC 压力,进步稳固性完备示例代码:AsyncEnumerable/LogStreamProcessor.cs 和 StreamReaderVsPipelineComparison.cs
7️⃣ 常见陷阱与最佳实践
7.1 ❌ 陷阱1:多次遍历会重新实行
- public async Task<List<int>> GetDataTraditionalAsync()
- {
- var result = new List<int>();
- for (int page = 1; page <= 10; page++)
- {
- await Task.Delay(200); // 模拟 API 延迟
- for (int i = 1; i <= 100; i++)
- result.Add(page * 100 + i);
- }
- return result;
- }
- // 使用方式
- var sw = Stopwatch.StartNew();
- var data = await GetDataTraditionalAsync(); // 等待 2 秒...
- Console.WriteLine($"开始处理第一条数据,已过 {sw.ElapsedMilliseconds}ms");
- foreach (var item in data)
- ProcessItem(item); // 终于可以开始了
复制代码 办理方案:如果必要多次遍历,先转换为 List:- 开始处理第一条数据,已过 2000ms
- 处理完成,总耗时 2500ms
复制代码 7.2 ❌ 陷阱2:忘记 await foreach
- public async IAsyncEnumerable<int> GetDataStreamAsync()
- {
- for (int page = 1; page <= 10; page++)
- {
- await Task.Delay(200); // 模拟 API 延迟
- for (int i = 1; i <= 100; i++)
- yield return page * 100 + i;
- }
- }
- // 使用方式
- var sw = Stopwatch.StartNew();
- await foreach (var item in GetDataStreamAsync())
- {
- if (sw.ElapsedMilliseconds < 500) // 只记录前 500ms 的日志
 - Console.WriteLine($"处理第 {item} 条数据,已过 {sw.ElapsedMilliseconds}ms");
- ProcessItem(item);
- }
复制代码 7.3 ❌ 陷阱3:在 yield return 之前做太多工作
- 处理第 101 条数据,已过 201ms
- 处理第 102 条数据,已过 201ms
- ...(第一页的数据立刻就开始处理了)
- 处理第 201 条数据,已过 401ms
- 处理完成,总耗时 2500ms
复制代码 7.4 ✅ 最佳实践总结
场景保举做法必要取消使用 [EnumeratorCancellation] + WithCancellation()库代码使用 ConfigureAwait(false)多次遍历先 ToListAsync()必要总数返回 (IAsyncEnumerable data, int totalCount) 元组错误处置惩罚在 yield 之前 try-catch,决定是跳过还是制止进度反馈在 yield 时更新进度(如日记、UI)8️⃣ EF Core 中的 IAsyncEnumerable 支持
Entity Framework Core 原生支持异步流:- // 基础语法
- public async IAsyncEnumerable<int> GetNumbersAsync(int count)
- {
- for (int i = 1; i <= count; i++)
- {
- await Task.Delay(100); // 每个数字都需要异步操作
- yield return i;
- }
- }
复制代码 对比传统方式:- // 使用 [EnumeratorCancellation] 特性接收取消令牌
- public async IAsyncEnumerable<int> GetNumbersAsync(
- int count,
- [EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- for (int i = 1; i <= count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested(); // 检查取消
- await Task.Delay(100, cancellationToken);
- yield return i;
- }
- }
复制代码 9️⃣ 实战案例2:实时日记流处置惩罚
完备代码位置:AsyncEnumerable/LogStreamProcessor.cs
9.1 需求
监控 一个日记文件,实时读取新增的行(类似 tail -f)。
9.2 实现
- public async IAsyncEnumerable<string> FetchUrlsAsync(string[] urls)
- {
- foreach (var url in urls)
- {
- string? content = null;
- try
- {
- content = await FetchAsync(url);
- }
- catch (HttpRequestException ex)
- {
- Console.WriteLine($"获取 {url} 失败: {ex.Message}");
- continue; // 跳过失败的 URL
- }
- if (content != null)
- yield return content;
- }
- }
复制代码 🔟 IAsyncEnumerable 与 SSE(Server-Sent Events)的关系
10.1 什么是 SSE?
SSE(Server-Sent Events) 是一种 HTTP 长毗连技能,允许服务器自动向客户端推送数据流,常用于:
- 💬 实时谈天消息推送
- 📊 股票代价、角逐分数实时更新
- 📝 AI 流式输出(如 ChatGPT 的打字机结果)
- 🔔 体系关照推送
技能特点:
- 基于 HTTP,客户端发起毗连后,服务器连续推送 data: 格式的文本流
- 单向通讯(服务器 → 客户端)
- 自动重连机制
- 兼容性好(支持 EventSource API)
10.2 IAsyncEnumerable 与 SSE 的天然契合
在 ASP.NET Core 中,IAsyncEnumerable 是实现 SSE 的美满载体:- await foreach (var item in GetNumbersAsync(10))
- {
- Console.WriteLine(item);
- }
复制代码 10.3 实战案例:AI 流式输出(ChatGPT 结果)
场景:调用 AI API,逐字返回天生的内容,而不是等全部天生完。- var cts = new CancellationTokenSource();
- cts.CancelAfter(TimeSpan.FromSeconds(2)); // 2 秒后取消
- try
- {
- await foreach (var item in GetNumbersAsync(100).WithCancellation(cts.Token))
- {
- Console.WriteLine(item);
- }
- }
- catch (OperationCanceledException)
- {
- Console.WriteLine("操作已取消");
- }
复制代码 前端结果(打字机动画):- await foreach (var item in GetNumbersAsync(10).ConfigureAwait(false))
- {
- // 不捕获 SynchronizationContext,适合库代码
- Console.WriteLine(item);
- }
复制代码 用户体验:
- ❌ 传统方式:等候 5 秒,突然表现完备答案
- ✅ 流式方式:立即开始表现,逐字出现,体验流通
10.4 IAsyncEnumerable 与 SSE 的区别与接洽
维度IAsyncEnumerableSSE本质C# 语言特性(异步迭代器)HTTP 传输协议作用范围服务器端数据流处置惩罚服务器 → 客户端推送关系SSE 的服务端实现方式SSE 的传输格式典范用法await foreach 处置惩罚流式数据EventSource 吸取服务器推送ASP.NET Core 集成返回 IAsyncEnumerable 自动转换为 SSE框架自动处置惩罚 SSE 格式焦点明白:
- IAsyncEnumerable 是 生产者端的抽象(怎样天生流式数据)
- SSE 是 传输层的协议(怎样通过 HTTP 推送数据)
- 在 ASP.NET Core 中,IAsyncEnumerable 自动映射为 SSE
10.5 其他流式技能对比
技能通讯方式协议实用场景IAsyncEnumerable 支持SSE单向(服务器 → 客户端)HTTP实时推送、股票行情、日记流✅ 原生支持WebSocket双向(全双工)WebSocket谈天室、协作编辑、游戏✅ 可共同使用gRPC Streaming双向(基于 HTTP/2)gRPC微服务间流式通讯✅ 原生支持SignalR双向(抽象层)多种(WebSocket/SSE/长轮询)实时应用框架✅ 内部使用选择发起:
- 简单的服务器推送 → SSE + IAsyncEnumerable
- 必要客户端回传数据 → WebSocket
- 微服务流式调用 → gRPC Streaming
- 高级实时功能(如房间、群组) → SignalR
10.6 完备示例:实时日记推送
服务端(ASP.NET Core):- await foreach (var item in GetNumbersAsync(100))
- {
- Console.WriteLine(item);
- if (item == 10)
- break; // 停止迭代,后续数据不会再生成
- }
复制代码 客户端(欣赏器):
[code][/code]结果:欣赏器实时表现服务器日记,无需革新页面。
10.7 要点总结
✅ 关系:IAsyncEnumerable 是 SSE 在 ASP.NET Core 中的服务端实现方式
✅ 上风:代码轻便(无需手动拼接 SSE 格式),框架自动处置惩罚
✅ 实用场景:
- 实时数据推送(股票、天气、比分)
- AI 流式输出(ChatGPT 结果)
- 日记/监控
流
- 进度更新
✅ 限定:
- 只支持单向推送(客户端 → 服务器必要额外哀求)
- 文本格式(二进制数据必要 Base64 编码)
❌ 不恰当:必要双向通讯的场景(发起用 WebSocket 或 SignalR)
🎓 本章总结
焦点要点回顾
- IAsyncEnumerable 是什么:异步版本的 IEnumerable,支持流式返回异步数据
- 焦点语法:
- 生产者:async IAsyncEnumerable + yield return + await
- 斲丧者:await foreach
- 上风:
- 相应快:边加载边处置惩罚,无需等候全部完成
- 内存低:同时在内存中的只有当前批次
- 可取消:支持 CancellationToken
- 实用场景:分页 API、大数据集查询、实时数据流、日记流
- 性能优化:ValueTask、预加载战略、批量处置惩罚
- 常见陷阱:多次遍历会重新实行、忘记 await foreach、在 yield 之前加载全部数据
与其他技能的对比
技能实用场景上风劣势Task小数据量、必要多次遍历简单、可多次遍历内存占用高、相应慢IAsyncEnumerable大数据量、流式处置惩罚内存低、相应快不能多次遍历、不知道总数Channel生产者-斲丧者模式解耦、背压控制代码复杂IObservable (Rx)复杂事故流强大的组合操纵符学习曲线陡峭SSE (Server-Sent Events)HTTP 实时推送基于 HTTP、自动重连单向推送、文本格式最佳实践清单
- ✅ 对于 API 分页、数据库大数据集,优先思量 IAsyncEnumerable
- ✅ 始终支持 CancellationToken(使用 [EnumeratorCancellation])
- ✅ 库代码中使用 ConfigureAwait(false)
- ✅ 必要多次遍历时,先 ToListAsync()
- ✅ 在 yield 之前处置惩罚非常,克制流制止
- ✅ 使用 System.Linq.Async 举行 LINQ 操纵
- ❌ 不要在 yield 之前加载全部数据(失去流式意义)
- ❌ 不要忘记 await foreach
📚 保举阅读
- 官方文档
- IAsyncEnumerable Interface
- System.Linq.Async on NuGet
|