绝大多数高频异步方法该用 ValueTask 却用了 Task,本质是堆内存浪费;ValueTask 通过 struct + union 模式实现同步路径零分配、异步路径复用 Task,但需避免多次 await、跨 async 传递或存字段等误用。

绝大多数高频异步方法,该用 ValueTask 却用了 Task,本质是堆内存浪费 —— 不是“能不能用”,而是“不用就亏了”。
为什么 Task 在热路径里是性能黑洞
每次 async Task 方法返回,编译器必须在堆上分配两个对象:状态机实例(约 400 字节)和 Task 对象本身(约 48 字节)。10ms 一次的硬件轮询,1 秒就是 100 个 Task;60 秒后 GC 压力已不可忽视。
- 同步完成路径(如缓存命中)也逃不掉堆分配 ——
Task.FromResult仍要 new 一个Task - 高频调用下,GC 频次上升,STW 时间变长,响应毛刺明显
- 不是“慢”,而是“没必要的开销”:90% 的
GetUserAsync、TryReadAsync类方法,本可零堆分配
ValueTask 怎么做到零分配
ValueTask 是 struct,它内部用 union 模式区分两种状态:同步完成时直接存值(栈上),异步未完成时才包装一个真正的 Task 或 IValueTaskSource(此时才有堆分配)。
- 同步路径:
return new ValueTask<string>(cachedValue)—— 纯栈传递,无堆操作 - 异步路径:
return new ValueTask<string>(FetchFromDbAsync())—— 只复用已有Task,不额外 new - 注意:不能对同一个
ValueTask实例多次await,否则抛InvalidOperationException
哪些地方必须改,哪些地方不能乱改
改对地方才能见效,改错地方反而引入 bug。
- ✅ 必须改:缓存读取、配置解析、本地 I/O(如
FileStream.ReadAsync已返回ValueTask)、硬件轮询等高频、常同步完成的接口 - ❌ 不能改:需要组合 await 的场景,如
Task.WhenAll、ContinueWith—— 必须先调.AsTask()转换,此时又回到堆分配 - ⚠️ 谨慎改:公开 API 接口。一旦返回
ValueTask,调用方就不能再用.Result或.Wait(),且无法安全多次 await;内部方法优先,库作者需权衡兼容性
容易被忽略的坑:生命周期与所有权
ValueTask 是值类型,但内部可能持有对堆对象(如 Task)的引用。如果把它存到字段、集合或跨 async 方法传递,会引发悬垂引用或重复 await 异常。
- 不要把
ValueTask存进private ValueTask<T> _cache字段 —— struct 字段复制后,原实例仍可能被 await 过一次 - 不要在
using语句中 await 后还试图访问其结果变量(尤其涉及 ref 返回时) - 调试时看到
ValueTask.Status == TaskStatus.RanToCompletion并不意味着它“安全可重用”——struct 的拷贝行为会让判断失效



















