c# 如何避免在 async 方法中产生过多的堆分配

async方法中最典型的堆分配来自编译器生成的状态机类;此外await未完成Task、捕获局部变量形成闭包、误用ValueTask构造、调用非ValueTask异步API等也会触发额外堆分配。

async 方法里哪些操作会悄悄分配堆内存

最典型的堆分配来自 async 方法编译后生成的状态机类。C# 编译器会把每个 async 方法转换成一个实现了 IAsyncStateMachine 的堆对象,哪怕方法体只有一行 await Task.CompletedTask。此外,以下情况也会触发额外堆分配:

  • await 一个未完成的 Task(比如 Task.RunHttpClient.GetAsync)—— 框架需缓存延续(continuation)委托
  • async 方法中捕获局部变量并跨 await 使用(闭包)—— 编译器将变量提升到状态机类字段,该类本身是堆分配的
  • 使用 ValueTask 但误用其构造方式(如反复 new ValueTask 包装新 Task
  • async 方法中调用非 ValueTask 返回的异步 API,又没做适配

用 ValueTask 替代 Task 的真实约束条件

ValueTask 不是万能替代品,它只有在满足「多数路径同步完成」或「底层支持池化」时才真正减少分配。盲目替换反而可能引入 bug 或性能倒退:

  • 仅当对应同步重载存在(如 Stream.ReadAsync 对应 Stream.Read),且实现内部用了 ArrayPool 或类似机制时,ValueTask 才可能复用结构体实例
  • ValueTask 禁止多次 await —— 第二次 await 会抛 InvalidOperationException,而 Task 允许
  • 不要用 new ValueTask(someTask) 包装已有 Task,这等于白造一层包装,还失去 Task 的可 await 多次特性
  • .NET 6+ 中部分 BCL 类型(如 MemoryStreamPipeReader)已默认返回 ValueTask,优先直接消费它们的返回值

避免闭包和状态机膨胀的实操写法

编译器为每个 async 方法生成的状态机类字段越多,堆分配压力越大。关键是要控制「被提升的变量」数量和类型:

  • 把只在 await 前使用的变量声明移出 async 方法,或改为参数传入
  • 避免在 async 方法内定义本地函数并捕获外部变量后再 await
  • struct 封装多个相关参数,减少字段数(状态机字段是按变量个数而非大小计的)
  • 对高频调用的小型 async 方法,考虑改用同步 API + Task.Run 手动调度(前提是业务允许阻塞线程池)
public async ValueTask ProcessAsync(string input, int timeoutMs)
{
    // ❌ input 和 timeoutMs 都会被提升为状态机字段
    var buffer = ArrayPool.Shared.Rent(1024);
    try
    {
        var result = await ParseAsync(input, buffer, timeoutMs); // ✅ buffer 是局部栈变量,不提升
        return result;
    }
    finally
    {
        ArrayPool.Shared.Return(buffer);
    }
}

验证是否真减少了分配:别只信文档

实际效果必须用工具测,尤其在 .NET Core / .NET 5+ 上,不同版本的运行时优化差异很大:

  • dotnet trace 抓取 Microsoft-Windows-DotNETRuntime:GCHeapAlloc 事件,对比前后堆分配量
  • 在 BenchmarkDotNet 中启用 [MemoryDiagnoser],关注 Gen0/Gen1/Gen2 GCAllocated
  • 注意:ValueTask 的结构体本身不分配堆,但若其内部封装了新分配的 Task(如 ValueTask.FromResult(42) 是零分配,但 ValueTask.FromException(...) 可能分配异常对象),仍需细看源码或反编译

真正难的是权衡——有些分配无法避免(比如网络 I/O 必然要缓冲区),重点应放在高频小方法上;而一旦用了 ValueTask,就必须全程约束调用方不能重复 await,这点容易在代码演进中被遗忘。