C# 异步噩梦

异步噩梦

吵死人的小尾巴

上下文自动流转乃这个设计一大败笔.
导致了无处不在的小尾巴 ConfigureAwait(false) 还分:类库,测试,UI,各不同.
关键是测试和UI上面一写就报错,必须要true的(或者不写)

这导致了什么?
没有人可以引入异步之后,在一套代码上面用一种风格来解决所有问题.

资源泄露

类成员要持有Task

Task最阴的一面,就是射后不理.
Task是异步线程持有资源,线程跑没跑,不知道.线程释放了没有,不知道.

当你有一个函数返回Task.
1,要不要await?
如果场景不允许await呢?
2,无法await的你还必须类字段持有(Task变量或者容器)
否则就是释放不确定,造成资源不确定回收.

这造成了我曾经犯错:实现了射后不理.
射后不理有两层意思:
一是信号处理,例如Actor邮箱模型这种Tell方式.
二是异步函数忘记类持有Task,必须要改为持有在类字段,不然Task在类释放了也没有关闭.
忘记 await 或者 .GetAwaiter().GetResult() 就泄露.

这就导致了大量的关注点分离问题,
1,这个函数要不要await?
2,await之后要不要加不加尾巴.
3,是不是忘记处理函数返回Task,要await或者字段持有Task然后释放清理?

异步析构缺失

同步释放的标准写法通常会加入析构函数,也就是GC兜底.
异步释放则没有这个操作.
这导致了,我必须要去实现await using不然就是内存泄露了.
当我发现这个问题的时候,我突然顿悟了为什么win桌面总是内存泄露.

这会导致什么?
你需要让分析器来设计生命周期计算,然后容器持有和释放.

分析器

微软官方BCL库是同时支持这两个同步释放和异步释放接口的,
那么你作为新做代码肯定不想写那么多冗余代码,
因为这种兼容性代码很烦人,相当于一套代码写两次,同步和异步各自一套能不烦人嘛?

如果你想通过语法分析器去检测释放接口,
然后监控全部都using或者await using,这是可行的.
但是你无法递归检测全局,
只能检测自己项目范围的类,或者做白名单,把BCL的Task之类排除.

虽然语法分析器可以扫描出来了,但是很多发现是级联问题,
也就是一次编译不能通过,要打地鼠一样修一点编译一点,
要用 ast_cli 方式进行扫描预览+修改全部代码.

技术选型的疲劳

在唯一和唯二上面选择那个唯一.
宁可不贪恋异步那点速度,慢一点或者隔离起来做卫星程序,起码很好检测资源泄露.
但是现代都是异步化,只是这个异步的代价是非常昂贵,
昂贵到你必须要打造一套环境来检测AI写的代码,
这种技术选型的阴阳面总是会产生疲劳.

这就是为什么Go语言的异步更受欢迎,
没有函数颜色,它的异步包裹的,传染性低,
但是它也有罪恶的: if err != nil

数据结构
用同步函数,例如BCL的Dictionary,List...

IO和业务容器
用异步函数,用了就有问题了,也就是异步传染性...
也就是世界复杂的,
要全面异步化是不行的,会在数据结构和Lazy停下来.
要全面同步话,这个倒可以.

延迟构造

异步有传染性大家都知道了,然后传染到Lazy延迟构造上面就会不行,
因为延迟构造规定了必须要同步的,所以会大量产生这种相悖的东西.
这真是拍拍脑袋决定的事情.

后来微软做了社区库AsyncLazy<T>,不过BCL一直没有.

异步的返回值

微软给了你四五种写法,还都"能用"。是 C# 生态特有的混乱。

这些类型到底该怎么选

类型 本质 该用场景
void(同步) 直接执行 CPU 计算、内存操作、已就绪结果
Task 可能异步、无返回值 常规异步方法,I/O 为主
Task<T> 可能异步、有返回值 同上,带结果
ValueTask 可能同步完成、可能异步 热路径、经常同步返回的异步方法
ValueTask<T> 同上带结果 同上
async void 火忘式(fire-and-forget) 只应出现在事件处理器,其他全是 bug

混乱的根源

  1. Task 是引用类型,每次分配都要 GC
    微软为了优化高频调用,搞出 ValueTask 来避免堆分配。
    但 ValueTask 有严格使用限制:
    · 只能 await 一次
    · 不能 .Result / .Wait()
    · 不能存起来多次消费

结果就是你为了性能用 ValueTask,但要记住一堆额外规则,一不小心就出诡异 bug。

  1. "同步完成"这个概念被引入
    一个 Task 方法可能:
    · 立刻返回已完成结果(缓存命中)
    · 真正异步等待(缓存未命中)
    于是有了 IsCompletedSuccessfully、ValueTask 这些判断分支,代码复杂度飙升。

  2. 微软自己的库都不统一
    · Stream.ReadAsync 返回 Task
    · MemoryStream.ReadAsync 经常同步完成,但签名还是 Task
    · 一些新 API 用 ValueTask
    · IAsyncEnumerable 又引入 ValueTask 的 MoveNextAsync

  3. 模式太多:TAP / APM / EAP
    老代码里 BeginInvoke/EndInvoke、IAsyncResult、事件回调都还在,现代代码又用 async/await,混在一起就是灾难。

务实的选择建议

除非你在写框架或超高吞吐的库,否则:

  1. 一律用 Task / Task——简单、安全、规则少
  2. 别碰 ValueTask,除非 profiler 明确告诉你这里分配是瓶颈
  3. 别用 async void,除非是事件处理器
  4. 统一 async 全链路,不要中途 .Result / .Wait()
  5. CPU 密集型就老老实实同步,别硬包成 Task

一句话总结

ValueTask 是微软给框架作者的优化工具,不是给普通业务代码用的。
普通项目里"Task + async/await 全链路 + 同步计算保持同步"这套组合,已经能覆盖 95% 的场景,剩下的 5% 才需要考虑 ValueTask、ConfigureAwait(false)、IAsyncEnumerable 这些进阶玩意。

posted @ 2026-09-23 06:10  惊惊  阅读(2)  评论(0)    收藏  举报