本文有对应的视频教程:哔哩哔哩
自从 C# 5.0 引入了 async
和 await
语法以后,异步编程变得非常简单,而 Task 类型也在开发中扮演着相当重要的角色,存在感极高。但是在 .NET Core 2.0 这个版本,微软引入了一个新的类型 ValueTask
,那么这个类型是什么?为什么我们需要它?什么情况下应该使用它?我们今天就来探讨一下。
简单回顾 Task
类型
¶
在异步编程中,我们经常会使用 Task
类型来表示一个异步操作或者说异步任务。相较于其他一些主流编程语言,C# 中的异步任务其实开销很小。比如知乎上的大佬 hez2010 在他的这个回答中提到,C# 的 Task 类型通常只占用 64~136 B 的内存,而 Go 语言的一个 goroutine 至少占用 2 KB 的内存。
不仅如此,Task 还有许多优化技巧,比如:
- 如果想直接返回一个结果,可以使用
Task.FromResult
方法 - 如果想直接返回一个已经完成的任务,可以使用
Task.CompletedTask
- 如果想直接返回一个已经取消的任务,可以使用
Task.FromCanceled
- 如果想直接返回一个已经失败的任务,可以使用
Task.FromException
等等。所以从 C# 5.0(大概是 .NET Framework 4 时代)开始,直到 .NET Core 2.0 之前,一直相安无事。
传统 Task
类型的问题
¶
但是,随着 .NET 开始跨平台,能使用 C# 的场景越来越多,微软的“野心”也越来越大,开始从各种角度优化 C# 的性能,从而使 .NET 能够胜任各种任务场景。除了引入 Span
、Memory
、ref struct
等新特性外,还引入了 ValueTask
。那么,传统的 Task
类型有什么问题呢?
首先我们要知道,Task
包含泛型版本和非泛型版本,分别对应有无返回值的异步任务。而 ValueTask
在诞生之初,只有一个泛型版本。换句话说,设计者认为,ValueTask
应当只适用于有返回值的异步任务。所以这里我们来看一个典型的例子:
|
|
在上面的 GetMessageAsync
方法中,我们首先尝试从缓存中获取消息,如果没有找到,就再尝试从数据库中获取。但这里有一个问题,如果缓存中有数据,那么虽然我们好像会直接返回一个值。但是,由于 GetMessageAsync
方法是一个异步方法,所以实际上会返回一个 Task<string>
类型的对象。这就意味着,即便我们本可以只返回一个值,我们依旧会多创建一个 Task
对象,这就导致了无端的内存开销。
await
关键字,而是直接返回。也就是说,这个异步任务自始至终都是在同一个线程上执行的。ValueTask
简介
¶
所以,ValueTask
的主要作用就是解决这个问题。它在 .NET Core 2.0 被正式引入,并在 .NET Core 2.1 得到了增强(新增了 IValueTaskSource<T>
接口,从而使它可以拥有诸如 IsCompleted
等属性),并且还添加了非泛型的 ValueTask
类型(这个我们稍后再说)。
ValueTask
我们先不要去思考它是否为值类型,而是可以这么理解:它适用于可能返回一个 Value
,也可能返回一个 Task
的情形。也就是说,它非常适合上面的“缓存命中”的典型场景。我们可以把上面的代码修改为:
|
|
此时,如果缓存中有数据,那么我们可以直接返回一个 ValueTask<T>
对象,而不需要再创建一个 Task<T>
对象。这样就避免了无端的堆内存开销;否则,我们才会创建 Task<T>
对象。或者说,在这种情况下,ValueTask
的性能会退化为 Task
(甚至可能还稍微低一丁点,因为涉及到更多的字段,以及值拷贝等)。
ValueTask
,它的使用情形就更少了。它只有在即使异步完成也可以无需分配内存的情况下才会派上用场。ValueTask
的“发明者”Stephen Toub 在他的文章中提到,除非你借助 profiling 工具确认 Task
的这一丁点开销会成为瓶颈,否则不需要考虑使用 ValueTask
。这时候我们再来思考它的性能究竟如何:
顾名思义,ValueTask
是一个值类型,可以在栈上分配,而不需要在堆上分配。不仅如此,它因为实现了一些接口,从而使它可以像 Task
一样被用于异步编程。所以,照理说,ValueTask
的性能要比 Task
更好很多(就如同 ValueTuple
之于 Tuple
、Span
之于 Array
一样)。
但是,ValueTask
真的这么美好吗?它是不是可以完全替代 Task
呢?事情恐怕并没有这么简单。
ValueTask
的注意事项
¶
现在,我们该谈一谈 ValueTask
在使用时需要注意的地方了。
ValueTask
不能被多次等待(await
)
¶
ValueTask
底层会使用一个对象存储异步操作的状态,而它在被 await
后(可以认为此时异步操作已经结束),这个对象可能已经被回收,甚至有可能已经被用在别处(或者说,ValueTask
可能会从已完成状态变成未完成状态)。而 Task
是绝对不可能发生这种情况的,所以可以被多次等待。
不要阻塞 ValueTask
¶
ValueTask
所对应的 IValueTaskSource
并不需要支持在任务未完成时阻塞的功能,并且通常也不会这样做。这意味着,你无法像使用 Task
那样在 ValueTask
上调用 Wait
、Result
、GetAwaiter().GetResult()
等方法。
但换句话说,如果你可以确定一个 ValueTask
已经完成(通过判断 IsCompleted
等属性的值),那么你可以通过 Result
属性来安全地获取 ValueTask
的结果。
不要在多个线程上同时等待一个 ValueTask
¶
ValueTask
在设计之初就只是用来解决 Task
在个别情况下的开销问题,而不是打算全面取代 Task
。因此,Task
的很多优秀且便捷的特性它都不用有。其中一个就是线程安全的等待。
也就是说,ValueTask
底层的对象被设计为只希望被一个消费者(或线程)等待,因此并没有引入线程安全等机制。尝试同时等待它可能很容易引入竞态条件和微妙的程序错误。而 Task
支持任意数量的并发等待。
如何克服 ValueTask
的局限性
¶
在实际使用过程中,难免遇到需要突破它的上述限制的情况。那么我们该怎么办呢?这里给出几种常见情况的对应方式:
- 如果希望用阻塞的方式(
Result
与.GetAwaiter().GetResult()
)获取ValueTask<T>
的结果,可以先判断IsCompleted
或IsCompletedSuccessfully
等属性的值,确认它已经完成,然后再获取结果 - 如果希望等待多次,或在多个线程中等待等,那么可以使用
AsTask()
方法将其转为一个普通的Task
,进而再进行各种Task
的常用操作
基于 ValueTask
的原理及限制,一个普遍认同的推荐用法是:
await
关键字来等待一个返回值为 ValueTask<T>
的异步任务并获取结果,而不是试图将其返回值赋值给一个变量(最多是搭配 ConfigureAwait()
进行使用);否则,建议使用 AsTask()
方法将其转为传统的 Task
,再进行常规操作。总结 ¶
总的来说,ValueTask
确实有很多闪光点,比如在栈上分配来避免堆分配的性能开销,但它也有一些让人头疼的限制,比如不能被多次等待。使用它就像是在走钢丝,一不小心就可能掉进性能优化的陷阱里。但别担心,大多数情况下,我们还是可以安全地使用 await
来等待 ValueTask<T>
的,只要我们不试图把它当作 Task
的替代品来用就好。
希望看了这篇文章之后,大家能够正确使用 ValueTask
。