C# 从 7.0 版本引入了 ValueTask,从那时起异步方法的返回类型就有了 Task 和 ValueTask 两种选择。很多开发者看到 ValueTask 号称性能更好,就一拥而上把所有方法都改成 ValueTask,结果反而引入了难以排查的 Bug。事实上,这两种类型各有明确的定位:Task 通用、安全、适合绝大多数场景;ValueTask 则是为特定的性能敏感场景而生。理解它们的底层差异,才能在正确的场合做出正确的选择。

一、底层实现差异:引用类型与值类型的本质区别
首先需要明确的是,Task 是一个 class,也就是引用类型;而 ValueTask<TResult> 是一个 struct,值类型。这个根本差异决定了两者行为的所有不同。
每次异步方法返回一个未完成的 Task,运行时通常需要在堆上分配一个 Task 对象(虽然 .NET 内部对一些常见结果做了缓存优化,比如 Task.FromResult 会返回缓存的已完成实例,非泛型的 Task.CompletedTask 也可以直接复用)。但如果方法的逻辑是动态的,比如读取缓冲区中的数据,大部分时候能直接命中缓存同步返回,只有偶尔才需要真正异步等待,那么在同步完成的路径上使用 Task 就会产生不必要的堆分配。
ValueTask 的设计目标正是消除这类分配。看一下它的内部结构就能明白:
public readonly struct ValueTask<TResult>
{
private readonly object _obj; // 可能是 Task<TResult>、IValueTaskSource<TResult> 等
private readonly TResult _result; // 同步完成时直接存放结果
private readonly short _token;
private readonly bool _continueOnCapturedContext;
}当方法同步完成时,ValueTask 直接把结果放在栈上的结构体字段里,返回给调用方,全程零堆分配。只有真正需要异步等待时,它才会退化成包装一个 Task 或者一个实现了 IValueTaskSource 接口的可复用对象。System.IO.Pipelines 中的 PipeReader.ReadToEndAsync 就是典型代表,它通过 ManualResetValueTaskSourceCore 复用同一个源对象,避免了每次调用都新建 Task。
二、性能对比:什么场景下 ValueTask 才有优势
ValueTask 的收益主要体现在"高频调用、大多数同步完成"的路径上。典型的例子包括:缓存查询、缓冲区读取、连接池判断、限流器检查等。这类方法往往被每秒调用成千上万次,如果每次都分配一个 Task 对象,GC 压力会明显上升。
来看一个直观的例子。假设我们要实现一个带缓存的查询方法:
// 使用 Task:即使命中缓存也要分配(GetAwaiter 之前可能产生包装)
public async Task<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
{
return user; // async 方法仍可能涉及状态机装箱等开销
}
user = await _db.QueryUserAsync(id);
_cache[id] = user;
return user;
}
// 使用 ValueTask:命中缓存时零分配
public ValueTask<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
{
return new ValueTask<User>(user); // 直接返回结果,无任何堆分配
}
return new ValueTask<User>(LoadUserFromDbAsync(id));
}
private async Task<User> LoadUserFromDbAsync(int id)
{
var user = await _db.QueryUserAsync(id);
_cache[id] = user;
return user;
}注意第二个版本里,真正的异步逻辑放在了单独的私有方法中,公共方法本身不是 async 方法,这样才能在同步路径上完全避免状态机相关的开销。这是写高性能 ValueTask 方法的一个通用技巧。
反过来说,如果方法几乎总是异步完成,比如一次数据库查询、一次 HTTP 请求,那么 ValueTask 的优势几乎为零,甚至因为结构体本身更大(16 字节以上,传递时需要拷贝)而略有额外开销。对于普通业务代码,Task 依然是首选,它的开销在这些场景下完全可以忽略。
三、使用限制:ValueTask 的几条铁律
ValueTask 的性能优势是有代价的,它对使用方式有严格限制,违反这些限制会产生不确定的行为,而且往往很难复现和排查。
第一,默认情况下 ValueTask 只能被 Await 一次。多次 Await 同一个 ValueTask 实例可能抛出异常,也可能返回错误结果,取决于底层的实现方式。如果确实需要多次等待,应先调用 AsTask() 转换成 Task,之后就可以任意次等待该 Task。
ValueTask<int> vt = reader.ReadAsync(); // 正确:只等待一次 int result = await vt; // 错误:同一个实例等待第二次,行为未定义 // int result2 = await vt; // 如果需要多次等待,先转换 Task<int> t = vt.AsTask(); int a = await t; int b = await t; // Task 可以安全地等待多次,结果一致
第二,在 Await 完成之前不能调用 .Result 或 .GetAwaiter().GetResult() 进行阻塞等待,这在同步阻塞 ValueTask 时风险比 Task 更高,可能导致池化对象被错误回收。第三,不要同时进行多个并发操作,比如同时调用 Await 和 AsTask。第四,不要在生产代码中丢弃对 ValueTask 的观察,即使不关心结果,也应该 Consume 它,否则池化场景下可能破坏对象复用状态。
概括来说,Task 就像一个普通的对象,怎么用都行;ValueTask 更像一个"借来的资源",用完必须按规矩归还。这也是为什么官方建议:只有在性能分析证明 Task 分配是瓶颈时,才考虑使用 ValueTask。
四、如何选择:一份实用的决策清单
综合上面的分析,可以在日常开发中遵循这样的原则。以下情况使用 Task:方法调用频率不高、几乎总是异步完成、调用方可能多次等待结果、需要与其他 Task 组合(如 Task.WhenAll)、代码的可维护性和安全性优先。
以下情况考虑 ValueTask:方法位于性能热路径上、大部分调用可以同步完成(缓存命中、缓冲区有数据等)、代码库有明确的性能规范、团队理解它的使用限制。此外,如果实现了 IValueTaskSource 做对象池化,收益会更大,这也是 Kestrel、Pipelines 等基础库的 做法。
还有一点容易被忽略:接口设计。如果不确定调用方的场景,接口和公共 API 优先返回 Task,因为 Task 更宽容;内部的热路径实现再单独使用 ValueTask。随着 .NET 的演进,ValueTask 的生态支持也在改善,例如 await using、ConfigureAwait 都能配合它使用,但它"只能等待一次"的核心约束始终存在。
最后总结一句:Task 是默认选择,ValueTask 是测量之后的优化选择。先写出正确的代码,再用性能工具定位分配热点,最后针对性地替换,这才是稳妥的做法。