☰
C#异步流IAsyncEnumerable实战:从底层原理到高并发优化
2026/10/14 7:02:34 网站建设 项目流程

很多朋友对 C# 异步编程的印象可能停留在async/await和Task上,但对异步流(IAsyncEnumerable<T>)这个更进阶的概念一直没摸透。这篇文章不是入门教程,默认你已经能熟练使用Task和async/await,我会把重点放在异步流的底层机制、高并发场景的优化,以及 WPF 和 ASP.NET Core 两个具体场景的实战代码上。

先讲一个我真实遇到过的场景。当时某个报表导出功能要从数据库里拉取上百万条订单,最初代码用的是await db.Orders.ToListAsync(),然后一次性返回给客户端。数据量小的时候一切正常,数据量一上来,服务端内存直接飙升到 2GB,第一个字节要等十几秒才到浏览器。后来我把代码改成IAsyncEnumerable<T>配合await foreach,接口变成“边查边返回”,内存峰值降了一个数量级,首包时间也大幅提前。

这个经历让我明白了一件事:很多异步问题并不是出在“异步”这两个字上,而是出在返回值类型上。Task<List<T>>本质上表达的是“你必须等所有数据都出来了才能拿到结果”,而IAsyncEnumerable<T>表达的是“你可以一边等一边拿,拿到一个算一个”。下面我从原理到实战逐步拆开讲。

1. 从yield return到await foreach:异步流的出现到底补齐了什么短板

1.1 传统异步返回集合的三个痛点

绝大部分 C# 开发者写异步集合方法,用的都是Task<List<T>>或者Task<IEnumerable<T>>。这两者在语义上没有本质区别:你都得等全部数据组装完毕,才能真正“拿到”这个集合。

第一个痛点是内存。假设你有 100 万条订单数据,ToListAsync()会把 100 万个实体完整物化到内存里。实体本身有引用类型开销,字段越多内存越大,再加上 EF Core 内部跟踪机制,内存翻倍是很正常的事。做导出、报表、全文检索这类场景时,这几乎是致命的。

第二个痛点是端到端延迟。用户点击导出后,明明数据库第一条记录 100 毫秒就查出来了,但是因为接口必须等ToListAsync()把全部数据查完才能 return,所以用户的第一批数据要等所有查询结束才看得到。如果整体 SQL 要跑 5 秒,那用户就白等 5 秒。

第三个痛点是下游消费不灵活。Task<List<T>>返回的是完整快照,调用方必须持有整个列表。如果调用方只是想一条条处理,例如写到文件、发消息队列、实时展示,这种“全量”模式反而碍事。你被迫先ToList()再foreach,白白多占用一份内存。

1.2 用一段代码体会两者的语义差异

对比下面两段代码,你就能明显感知到区别。

// 传统写法:一次性全量返回 public async Task<List<Order>> GetAllOrdersAsync() { return await db.Orders.AsNoTracking().ToListAsync(); } // 异步流写法:边查边抛出来 public async IAsyncEnumerable<Order> GetOrdersAsync() { await foreach (var order in db.Orders.AsNoTracking().AsAsyncEnumerable()) { yield return order; } }

传统写法的调用方必须等所有数据凑齐,拿到的是一整个List;异步流写法的调用方则可以这样消费:

await foreach (var order in GetOrdersAsync()) { Console.WriteLine(order.Id); }

注意await foreach这个语法。它是 C# 8.0 引入的,专门用来消费IAsyncEnumerable<T>。它背后的语义和IEnumerable<T>的foreach很像,区别在于:IEnumerable<T>的GetEnumerator()/MoveNext()都是同步的,而IAsyncEnumerable<T>的MoveNextAsync()返回ValueTask<bool>,可以真正地等待一次异步 I/O 完成。

你可以把Task<List<T>>想象成“所有菜都做好了一次性端上桌”,把IAsyncEnumerable<T>想象成“后厨一盘一盘现炒,炒好一盘上一盘”。如果你的菜本来就需要 5 分钟才能全部做完,那前一种方式只能干等;后一种方式至少能让你先吃到第一口。

2. 拆解迭代器的黑盒:IAsyncEnumerable背后的状态机与ValueTask

2.1 异步流接口的基本结构

要真正理解IAsyncEnumerable<T>,不能只停留在“会用await foreach”的层面。它的核心是三个接口成员:

public interface IAsyncEnumerable<out T> { IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default); } public interface IAsyncEnumerator<out T> : IAsyncDisposable { T Current { get; } ValueTask<bool> MoveNextAsync(); }

这段定义里有几个地方非常关键:

第一,GetAsyncEnumerator可以接收一个CancellationToken。这意味着流在创建的时候就可以接收取消信号,而且这个信号会沿着枚举器的生命周期往下传。

第二,Current是同步属性。也就是说,当你await MoveNextAsync()成功后,直接访问Current就能拿到当前元素,不需要再await一次异步取值。这一点和Task<T>完全不同,Task<T>的值只能异步获取。

第三,MoveNextAsync()返回的是ValueTask<bool>而不是Task<bool>。这是一个很重要的性能优化。ValueTask对“异步操作可能同步完成”的场景特别友好。如果某一次枚举根本没有等待任何真正的异步 I/O,例如数据已经在内存里,那么MoveNextAsync()可以直接返回一个同步的值,避免额外的堆分配。

举例来说,如果你写这样一个异步迭代器:

public async IAsyncEnumerable<int> Numbers() { for (int i = 0; i < 10; i++) { await Task.Delay(10); yield return i; } }

编译器会把整个方法编织成一个异步状态机。你看到的yield return只是表象,背后其实是一个实现了IAsyncEnumerator<int>的状态机对象。每次调用MoveNextAsync()时,状态机就会运行到下一个yield return或return语句,然后暂停等待。

2.2 await foreach 的展开逻辑

await foreach看起来只是一个语法糖,但它展开后的代码能解释很多奇怪的行为。比如下面这段代码:

await foreach (var item in source) { Console.WriteLine(item); }

编译器大致等价于:

await using (var enumerator = source.GetAsyncEnumerator()) { while (await enumerator.MoveNextAsync()) { var item = enumerator.Current; Console.WriteLine(item); } }

注意这里有个await using,意味着枚举器本身实现了IAsyncDisposable,在循环结束后会自动执行DisposeAsync()。如果异步流内部持有数据库连接、文件句柄、HTTP 响应流等资源,DisposeAsync()就是释放这些资源的地方。

这个展开逻辑解释了为什么在异步迭代器中,某些同步IEnumerable<T>里可以做的操作会被禁止。最典型的就是yield return不能写在try-catch的catch块里,因为状态机的暂停与恢复逻辑难以处理异常分支。这也是很多第一次写异步迭代器的人会踩的坑。

3. 高并发实战:用Channel做背压管道,用SemaphoreSlim做精确限流

3.1 为什么高并发异步场景需要 Channel

很多开发者一提到高并发异步,第一反应是Task.Run加ConcurrentQueue,再用BlockingCollection做生产者消费者。但BlockingCollection是同步阻塞的,Take()在数据没到时会让线程处于阻塞状态,这在真正的异步场景中会浪费线程池线程。

更推荐的做法是使用System.Threading.Channels的Channel<T>。它是一个异步友好的管道,生产者和消费者都通过异步方法进行读写。当管道满了,生产者WriteAsync会等待,而不是让线程去繁忙轮询;当管道空了,消费者ReadAllAsync()会待到下一个元素出现。

我建议在做高并发异步流水线时,优先把Channel当作默认选择,它能非常自然地解决背压问题。

3.2 一个完整的异步流水线示例

假设有一个数据采集服务,从消息队列中持续读取事件,经过简单处理后写入下游存储。下游存储的并发能力有限,比如数据库连接池最大 20 个连接,那么我们就需要既保证生产速度往上推,又不能让下游被打爆。

public sealed class EventRecord { public string Id { get; init; } public string Payload { get; init; } } public class PipelineService { private readonly SemaphoreSlim _gate = new(20, 20); public async Task RunAsync(CancellationToken ct) { var channel = Channel.CreateBounded<EventRecord>(new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait, SingleReader = false, SingleWriter = true }); // 生产者 var producer = Task.Run(() => ProduceAsync(channel.Writer, ct), ct); // 10 个消费者并行消费 var consumers = Enumerable.Range(0, 10) .Select(_ => ConsumeAsync(channel.Reader, ct)) .ToArray(); await Task.WhenAll(consumers); } private async Task ProduceAsync(ChannelWriter<EventRecord> writer, CancellationToken ct) { while (!ct.IsCancellationRequested) { // 假设从某个消息队列拉取一批数据 var batch = await FetchBatchAsync(ct); foreach (var item in batch) { await writer.WriteAsync(item, ct); } } writer.Complete(); } private async Task ConsumeAsync(ChannelReader<EventRecord> reader, CancellationToken ct) { await foreach (var record in reader.ReadAllAsync(ct)) { await _gate.WaitAsync(ct); try { await SaveToStorageAsync(record, ct); } finally { _gate.Release(); } } } // 以下两个方法为模拟实现 private Task<List<EventRecord>> FetchBatchAsync(CancellationToken ct) => Task.FromResult(new List<EventRecord>()); private Task SaveToStorageAsync(EventRecord record, CancellationToken ct) => Task.CompletedTask; }

这段代码有几点值得细看。

Channel.CreateBounded创建了一个有界管道,容量是 100 条。FullMode = BoundedChannelFullMode.Wait表示当管道已满时,生产者的WriteAsync会等待,直到消费者腾出空间。这是最核心的背压机制:生产速度再快,也不会无限积压内存。

消费者这边,ReadAllAsync(ct)返回的正是IAsyncEnumerable<EventRecord>,所以我们能很方便地用await foreach循环读取。这里我创建了 10 个消费者并行执行,每个消费者内部再用SemaphoreSlim限制同时进行的数据库写入数量,最终并发数不会超过 20 个。

为什么不直接用lock来做并发限制?因为SemaphoreSlim.WaitAsync是异步的,它不会阻塞线程。lock一旦争用失败,线程就得阻塞等待,这在 IOCP 线程池场景下很容易引发线程池饥饿。SemaphoreSlim可以 await,所以更符合异步编程的风格。

3.3 管道容量和并发数怎么定

很多初学者不知道BoundedChannelOptions的容量到底应该设多大。我的经验是:

管道容量 ≈ 目标峰值(条/秒) × 允许积压时间(秒)

举个例子,如果生产端峰值是每秒 1000 条,你希望消费者在峰值时可以积压最多 500ms 的数据来缓冲一下,那容量可以设 500。这样既不会无限积压内存,又能在瞬时尖峰时保证数据不丢失。

SemaphoreSlim的并发数应该参考下游系统的实际承载能力。上面例子里的数据库连接池如果是 20 个连接,那并发数就设 20。不要盲目设置一个很大的数字,否则下游被压垮,整体吞吐反而会骤降。

高并发场景还有一个常见的优化是在计数和状态更新上避免全局锁。比如统计处理条数、失败次数,可以考虑用Interlocked.Increment,它比lock轻量得多。这也是我实测下来一个很小的性能优化点,但在热路径上收益非常明显。

4. WPF中的安全加载:异步流与UI线程的协作方式

WPF 这类 UI 框架有自己的线程模型,UI 界面只能在主线程(Dispatcher 线程)上操作。很多人写异步流时,第一反应是直接写:

await foreach (var item in source) { Items.Add(item); }

这样写如果数据量小,写着倒是方便,但数据量一大,UI 就会卡死。

原因是:await foreach在 WPF 主线程执行时,每次MoveNextAsync()返回后,代码会回到主线程的同步上下文。于是“添加集合项、触发集合变更通知、刷新界面布局”这些操作,全部压在主线程上执行。100 万条数据就是 100 万次 UI 操作,再强的电脑也扛不住。

更合理的做法是:把消费异步流的循环放到后台线程,数据按批次收集,每攒够一批再回到 UI 线程批量更新。

private async Task LoadOrdersAsync(CancellationToken ct) { // 必须在 UI 线程创建,以便捕获 SynchronizationContext var progress = new Progress<List<OrderRow>>(batch => { foreach (var row in batch) Orders.Add(row); }); await Task.Run(async () => { var buffer = new List<OrderRow>(50); await foreach (var row in GetOrdersAsync().WithCancellation(ct)) { buffer.Add(row); if (buffer.Count >= 50) { progress.Report(buffer.ToList()); buffer.Clear(); } } if (buffer.Count > 0) progress.Report(buffer); }, ct); }

这段代码有几个关键点。

第一,Progress<List<OrderRow>>是在 UI 线程上创建的,因此它捕获了 UI 的SynchronizationContext。当后台线程调用progress.Report(batch)时,实际上会通过SynchronizationContext.Post把操作投递回 UI 线程执行。

第二,我在后台线程里用Task.Run包裹了整个消费循环。这样await foreach里的记录读取、过滤、组装OrderRow等操作都发生在后台线程,UI 线程只需要在progress.Report回调里处理界面更新。

第三,我按 50 条一个批次合并更新,而不是每读一条就更新一次界面。这能显著减少 UI 控件的布局计算次数,对 DataGrid、ListView 这类控件尤其明显。

如果你需要在界面显示加载进度,可以额外再维护一个计数器,每处理 100 条调用一次progress.Report更新进度条,逻辑完全一致。

5. ASP.NET Core中的流式响应:让接口边查询边返回

5.1 Controller 直接返回 IAsyncEnumerable

ASP.NET Core 对IAsyncEnumerable<T>有原生支持。如果 Action 的返回值是IAsyncEnumerable<T>,框架会流式写出响应。也就是说,第一项序列化完就会发往客户端,整个查询还没结束客户端就已经能看到数据。

一个典型的导出接口可以这样写:

[HttpGet("orders/export")] public async IAsyncEnumerable<OrderDto> ExportOrders( [FromQuery] DateTime from, CancellationToken cancellationToken) { await foreach (var order in db.Orders .AsNoTracking() .Where(o => o.CreateTime >= from) .AsAsyncEnumerable() .WithCancellation(cancellationToken)) { yield return new OrderDto { Id = order.Id, Total = order.Total, CustomerName = order.CustomerName }; } }

这里有两个非常重要的小细节。

第一,AsAsyncEnumerable()是 EF Core 专门提供的方法,它和ToListAsync()最大的区别就是:AsAsyncEnumerable()执行的是真·流式查询,每枚举一条,就从数据库游标里取一条出来。如果你用ToListAsync(),EF Core 仍然会在内部把全部数据拉回来再物化,那IAsyncEnumerable就白装了。

第二,[EnumeratorCancellation]。如果你在 Controller Action 里接收了CancellationToken,又想把它传给GetAsyncEnumerator,你可能会遇到一个很隐蔽的问题:直接写source.WithCancellation(cancellationToken)没问题,但如果你把方法拆成两个方法,比如在私有方法里用[EnumeratorCancellation]标记一个参数,才能把 Action 上的 token 正确绑定到枚举器上。

private async IAsyncEnumerable<OrderDto> GetOrdersFromDbAsync( [EnumeratorCancellation] CancellationToken token) { // 异步流内部的主要取消逻辑 }

5.2 什么时候应该用流式响应

不是所有接口都适合流式。我的判断标准很简单:如果客户端是一个报表页面,需要一条一条展示数据,或者数据量达到几万条以上,流式响应很合适。因为首包时间明显变短,用户体验更好。

但如果你需要做数据校验、签名、汇总统计,必须拿到所有数据之后才能计算最终结果,那流式响应反而会让代码变复杂,还不一定有性能收益。这种情况下老老实实用Task<List<T>>会更清晰。

另外要注意,流式响应并不等于响应一定不会被缓冲。如果项目启用了压缩中间件,或者前方有代理转发,它们可能会把整个响应缓存完之后才发给客户端。这时候你写的流式逻辑仍然会执行,但网络层面看到的仍然是一次性到达,失去流式意义。所以部署到有网关的环境时,最好先在预发环境做一次端到端测试。

5.3 和 Channel 配合做实时推送

如果你想把数据库变化、后台任务结果实时推给客户端,可以把Channel和IAsyncEnumerable结合起来:后台任务把数据写入Channel,Controller Action 里的await foreach不断读取Channel,再通过流式响应发出去。

Controller 的代码可以保持非常简洁:

private readonly Channel<OrderDto> _realtimeChannel; [HttpGet("orders/realtime")] public async IAsyncEnumerable<OrderDto> RealtimeOrders(CancellationToken ct) { await foreach (var dto in _realtimeChannel.Reader.ReadAllAsync(ct)) { yield return dto; } }

这个模式本质上是一个异步的生产者消费者管道。生产者可能来自某个后台服务,消费者就是 HTTP 请求本身。当请求断开时,CancellationToken会被触发,ReadAllAsync会停止,管道和资源会顺利释放。

6. 六个容易踩的坑:取消、释放与并发冲突

6.1 并发和异常处理的限制

第一个坑:同一个异步枚举器不能并发推进。MoveNextAsync()不是线程安全的。如果你把同一个IAsyncEnumerator<T>丢给多个Task并行调用,会得到一些看起来毫无规律的异常。正确的做法是像我在第 3 节写的那样,每个消费者拿到的是独立的ChannelReader,底层是多个枚举器分别读取。

第二个坑:异步迭代器的yield return不能出现在catch块中。C# 编译器会直接报错。这意味着你不能直接在异步迭代器方法里用try-catch捕获异常后再yield return一个错误结果。如果要处理异常,建议把故障信息包装成结果对象的一部分,或者把迭代方法拆成两层:内层负责迭代,外层负责异常处理。

6.2 取消与资源释放

第三个坑:WithCancellation并不保证立即中断。WithCancellation只是把取消令牌传入枚举器底层,如果异步流内部有一段耗时的同步计算没有检查令牌,取消信号就要等这段计算完成之后才能生效。所以在长时间运行的循环体里,自己也要定期调用ct.ThrowIfCancellationRequested()。

第四个坑:忘记释放枚举器。await foreach会自动await using,但如果你手动获取枚举器:

var enumerator = source.GetAsyncEnumerator(); while (await enumerator.MoveNextAsync()) { // ... }

你必须手动await enumerator.DisposeAsync()。否则异步流持有的数据库连接、文件句柄就不会关闭。在 WPF 或者 ASP.NET Core 这种长生命周期应用里,泄漏几次就会出大问题。

6.3 上下文与缓冲陷阱

第五个坑:在 UI 上下文里直接消费大型异步流,一定会卡。这个坑我在 WPF 那节详细解释过,原则就是把消费逻辑挪到后台循环,用Progress<T>批量回传 UI。

第六个坑:流式响应不等于端到端保证流式。ASP.NET Core 的中间件、输出压缩、代理服务器都可能缓冲响应。如果你依赖“首包快速到达”这个特性,就必须在包含中间件的真实请求链路上测试,而不仅是本地跑一个 controller。

7. 压测观察与一点个人取舍

7.1 一次不严谨但很有代表性的对比

我在某个报表系统改造时做过一次对比测试。数据源是某张包含约 100 万行订单的表,每条订单实体大约 200 字节。用Task<List<Order>>方式查询时,进程内存峰值维持在 300~400MB 左右,而且从请求发出到第一个响应字节需要等待整体查询完成;改成IAsyncEnumerable<Order>流式返回后,内存峰值经常只有 30~50MB,第一个响应字节在第一批数据查询完成时就开始传输。

吞吐量方面其实没有出现质的提升,因为瓶颈始终在数据库查询和网络带宽上。异步流的真正价值不完全在于“跑得更快”,而是在于它大幅降低了内存占用和端到端延迟。理解这一点很重要,不要神化它。

7.2 我现在的选型原则

经过这些实践之后,我给自己定了一条简单的选型原则:如果消费者必须等全部数据才能做第一件事,那就别强行用异步流;如果消费者拿到第一个元素就能开始干实事,那异步流就是更优解。

例如报表展示、断点续传式导出、实时日志推送、数据清洗管道,这些场景非常合适。但像“查询所有订单然后统计总金额”这种必须在最后才能得出结论的场景,直接ToListAsync()更省心。

另外分享一个小技巧:在调试异步流时,可以在await foreach循环里临时加一个计数器,每 N 次打印一下当前进度,这样能非常直观地看到流是否在正常推进,以及瓶颈究竟是在生产端还是消费端。这个习惯帮我省了不少排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询