C# 7 中引入了 ValueTask。(这句话不够严谨)目的是为了解决一些情形下 Task 会造成过多内存分配的问题。
众所周知,Task 是个 class,每次返回一个 Task 都会分配一个新的对象,在内存中积累起来,造成 gc 压力。如果它对应的是 IO 任务那还好,IO 导致的延迟和开销可能远超这个小小的内存分配,负面影响不显。但如果只是由于 API 设计的限制,需要使用类似 Task.FromResult 直接返回一个值,那就很浪费了。而且在某些类似缓存的场合,第一次操作是大开销的 IO 操作,之后的所有访问都是直接从内存中返回,这样也很浪费。特别是在循环里面 await 这个 Task。
为了某种程度上弥补这种负面影响,引入了 ValueTask。ValueTask 是一个 struct,所以通常它自己是不会在堆里面分配东西的。它很符合上述的缓存模型的使用方式,有两个构造函数,一个是 ValueTask(TResult result),直接通过传入结果构造;另一个是 ValueTask(Task<TResult> task),指向了另一个 Task,如果调用了这个构造函数,无疑还是会在堆里分配这个 Task 的。
微软文档里面推荐的使用方式如下。感觉也是最适合它的使用方式:
public ValueTask<int> CachedFunc()
{
return (cache) ? new ValueTask<int>(cacheResult) : new ValueTask<int>(LoadCache());
}
private bool cache = false;
private int cacheResult;
private async Task<int> LoadCache()
{
// simulate async work:
await Task.Delay(100);
cacheResult = 100;
cache = true;
return cacheResult;
}
对于能够直接同步返回值的操作,直接返回 new ValueTask(result) 是非常好的,它将 result 直接返回,并不会导致在堆里分配内存。频繁调用也不会有什么副作用。
保持兼容性,如果是执行异步操作,那么就直接返回底下的 Task,操作方式跟原来的一样。当然,分配就无可避免了。
如果操作都是异步操作,那么就相当于给 Task 多加了一些开销,也避免不了分配。
ValueTask 不如 Task 那么直接。
由于 ValueTask 包含了 ResultValue 字段以及 Task 字段,所以 await 生成的状态机会更加复杂,导致性能可能下降。
所以 ValueTask 也不是万能药,需要综合多种因素(同步异步操作的比例,操作耗时等)来进行取舍,通常需要进行测试来验证是否值得将 Task 替换为 ValueTask。
由于 ValueTask 的异步操作委托给它自己对应的单独的一个 Task 只是一个实现细节,很可能在后续的实现中更改。目前 BCL 中正在尝试使用 IValueTaskSource<TResult> 来重新实现 ValueTask 的行为,池化它涉及异步操作的部分,以减少 alloc,可预计很快就会实现。目前的修改和性能基准测试取得了很好的效果。所以后面在 ValueTask 和 Task 的选择中,除非是为了使用并发的 Task Parallel Library 的功能,只是涉及异步的话,一律使用 ValueTask,这样可以乘上 BCL 和 CLR 改进的东风,什么都不用做就能获得大量的性能提升和更少的内存分配。不过需要注意的是,对所有的 ValueTask,只能 await 一次。因为池化后,await 过后,后面的资源就认为可以被其他任务复用了。再次 await 将会造成混乱,从而出异常。
另外,Task 还有一些性能上比 ValueTask 更优胜的地方,譬如同步完成的 async 方法,runtime 会使用 cached 的 Task.CompletedTask,并不会造成分配,对于同步的 Task<bool> 也是如此,会分别 cache Task<true> 和 Task<false>,对于很小的 int,也会做这样的处理。而且在 Task 上 await 会比 ValueTask 上 await 稍微快上那么一点点。所以 BCL 中,新的异步 API 还是有些会采用 Task 版本,这需要周详的考虑。更加大量的是改成了 ValueTask 版本。