ChannelT
一、它是什么
System.Threading.Channels 提供的 Channel<T>,是一个异步安全的生产者-消费者管道。数据从 Writer 端写入,从 Reader 端读出,中间是一根"管道",这也是名字的由来。
var channel = Channel.CreateUnbounded<int>();await channel.Writer.WriteAsync(1); // 生产者
await foreach (var x in channel.Reader.ReadAllAsync()) { } // 消费者
二、背后的理念:通过通信共享数据,而不是共享内存加锁
传统并发模式里,多个线程直接读写同一块内存,靠 lock 互相协调;Channel 走的是另一条路——数据被封装成消息,通过管道从一个所有者"交接"给另一个所有者,同一时刻只有一方在碰这份数据,天然没有竞态,不需要在业务代码里到处手写锁。
这个理念源头是 Tony Hoare 1978 年提出的 CSP(Communicating Sequential Processes)理论,经 occam 语言、Go 语言的 chan 一路传承下来,Go 那句著名的 slogan 说得很直接:
Don't communicate by sharing memory; share memory by communicating.
.NET 的 Channel<T> 就是照着 Go 的这套模型设计的,连命名都直接沿用。
三、为什么会出现:填补"异步版生产者消费者"的空白
在 Channel(.NET Core 3.0,2019)出现之前,.NET 做生产者消费者只有 BlockingCollection<T>——它的 Take() 是同步阻塞的,没数据时会死等,占着一个线程池线程不放。这跟 async/await 早已是 .NET 生态标配的现实很不匹配:高并发场景下,为了"等一个队列里的数据"就占用一个宝贵的线程,代价太高。
Channel<T> 补上了这块空白:等待时用 await 挂起,不占线程,数据来了再恢复执行,彻底异步化。
四、解决什么问题
- 线程不再被阻塞等待:
ReadAsync/WriteAsync都是异步的,没数据/满了就挂起,不占用线程池资源 - 背压(Backpressure):
CreateBounded可以限制容量,写满了自动等待,防止生产者压垮消费者、内存爆掉 - 优雅的完成信号:
Writer.Complete()之后,await foreach自然结束,不需要哨兵值或者标志位 - 天然线程安全:支持多生产者、多消费者同时读写,不用自己加锁;多个消费者是竞争消费关系,一条消息只会被其中一个消费者拿到(不是广播)
五、常见使用场景
| 场景 | 说明 |
|---|---|
| BackgroundService 消费队列 | 最常见形态:HTTP 请求把任务丢进 Channel 立刻返回,BackgroundService 在后台持续消费执行,典型用于长任务、领域事件发布 |
回调 API 转 IAsyncEnumerable |
把老式"注册回调"的 SDK 包装成 await foreach 风格的现代异步流 |
| SSE / 流式推送的缓冲层 | 业务逻辑产生数据和 HTTP 响应写出之间用 Channel 解耦,两边速率不一致也没关系 |
| 批量攒批(Batching) | 高频小事件(日志、埋点)攒够 N 条或超时就统一 flush 一次 |
| 单次请求内的并发限流 | 不需要常驻后台服务,一次性批量处理时限制并发度,天然带背压,效果类似 SemaphoreSlim |
| 请求合并/去抖动 | BoundedChannel(capacity:1) + DropOldest,只保留最新一条待处理,老的自动丢弃 |
| 测试里等待异步副作用 | 订阅事件写入 Channel,测试代码 await ReadAsync 等到事件发生再断言 |
六、总结
只要出现"一边产生数据、一边消费数据,两边速度不匹配、需要异步协调"的场景,都是 Channel<T> 的用武之地——BackgroundService 只是消费端的一种常见载体,Channel 本身关心的是生产消费之间的协调,不限定消费者跑在哪。它不是要取代 RabbitMQ 这类独立消息中间件(没有持久化、不能跨进程),而是把这套异步、线程安全的生产消费能力,做成了进程内随手可用的轻量原语。