系列介绍
简介
【五分钟的dotnet】是一个利用您的碎片化时间来学习和丰富.net知识的博文系列。它所包含了.net体系中可能会涉及到的方方面面,比如C#的小细节,AspnetCore,微服务中的.net知识等等。
场景
您可以在下班坐地铁的时候,拿出手机逛一逛博客园,利用短短的五分钟完成阅读。
诞生缘由
曾经学过的内容可能过不了多久就忘了,我们需要一些文章来帮我们查漏补缺。
太长篇幅的文章看着滚动条就害怕了,我们可能更期望文字少的文章。
.net体系的内容太多了,平时也不知道该学哪些,我们可能需要一点点知识线索。
文章质量
当然,并不意味着它篇幅短就质量差。所谓麻雀虽小五脏俱全,我们会尽可能保证利用最少的文字去详细的阐述内容。
正文
伴随着dotnet core的不断迭代,我们在享受.net性能上的提升之外,还收获了许许多多新出现的API。不知您有没有发现,有这样一个类型在开始逐渐出现在我们的视野中 ———— ValueTask。
比如在最新的EF Core中:
public virtual async ValueTask<EntityEntry> AddAsync( object entity, CancellationToken cancellationToken = default)
以上代码是EF Core中DBContext的AddAsync签名,我们可以发现它的返回类型为ValueTask,可能就如同您想的一样,既然AddAsync是这样,那异步查找方法返回的类型也是这样。是的,曾经这些由Task来包裹的结果,现在全部交由VauleTask来处理了。
在最新的C# 8的特性中,引入了 异步流 的概念。它在原有的同步迭代器的基础上,扩充了异步的迭代器版本:
IAsyncEnumerable 和 IAsyncEnumerator
而这个异步迭代器接口的方法签名是这样的:
public interface IAsyncEnumerator<out T> : IAsyncDisposable { T Current { get; } ValueTask<bool> MoveNextAsync(); }
OMG,又是ValueTask!!!
那么,ValueTask到底是什么东西呢?它和传统的Task又有什么区别呢?该在什么时候使用它。
不要慌,接下来的五分钟您将Get到它。
开胃菜
在开始之前,我们先来了解一下咱们.NET中对内存中对象的存储格式:堆与栈。
先来看栈和堆的区别:
栈,或多或少负责跟踪正在程序中运行的代码。栈空间比较小,但是读取速度快
堆,或多或少负责跟踪程序对象或数据。堆空间比较大,但是读取速度慢
而在C#里面(其它.NET语言同理哈),咱们都知道有Class 和 Struct这两个类别,这两个类别对应的就是引用类型和值类型。
我们先拿实例化一个类来说,比如我们在执行 var newInstance = new ClassA()的时候,我们就会建立一个A的对象,而这个对象的数据一般来说就是分配在堆上的,而同时会建立一个引用ID,该ID就一般就置放在栈上面。
那么值类型的数据呢?一般来说它是存放在栈上的。当然这句话不全对:
"值类型存储在栈中, 引用类型存储在堆中” 这句话的前半句是有争议的,“变量的值是在它声明的位置存储的,假如一个类中有一个int类型的实例变量,那么在这个类的任何对象中,该变量的值总是和对象中的其他数据在一起,也就是在堆上,只有局部变量(方法内部声明的变量)和方法参数在栈上。而对于C#2以及更高版本,很多局部变量并不完全存放在栈中”引用-《C# in depth》及译本《深入理解C#》.
这也是为什么我们会将结构化的小数据创建为Struct的原因,比如具有(R,G,B)三个属性的结构Color。
栈里面的数据一般来说因为空间小,读取数据库的原因,它的生命周期就比较小,比如一个返回值为int的方法,当方法完成之后,该栈中的数据就销毁了。而堆呢?堆保存了几乎所有类中的数据,它怎么销毁数据来保存内存不溢出呢? 是的,您会想到GC,在.NET中就是一个专门的垃圾回收器来完成该操作。
开始飙车
回到本篇文章的主题,ValueTask。 Task可能大家都用的比较多了,毕竟从DotNET Framework的年代就流传至今,而ValueTask却从DotNET Core2.0才引入。
我们先来看看 MSDN 中对ValueTask的阐述:
提供异步操作的可等待结果。提供包装 Task 和 TResult(仅使用其中之一)的值类型。
往下滑MSDN,就能看到里面有一个很重要的一点:
There are tradeoffs to using a ValueTask
不要问为什么这个是英文,因为我尝试MSDN的机翻。唉…………能读懂个鬼,强烈建议给MSDN负责翻译的人员扣鸡腿。
上面大致的意思就是说,ValueTask会避免同步情况下一些不必要的内存分配,从而提升应用整体的性能。
所以说,现在就能明白ValueTask出现的目的是为了提升性能,而被提升的对象就是Task。二位秋名山车神的竞速之路:
如果您足够仔细,您会发现我上面说的是同步的情况。 “???纳尼,我用Task不是异步吗?怎么成同步了?”
别急,回想下您是否写过这样的代码:
return Task.FromResult(42);
您肯定写过(就算没写过也看过😝),那么为什么会有这种代码呢? 因为我们需要在方法中部分执行异步,然后使用awit关键字等待它返回一个确定的结果,然后进行一波同步运算后返回结果。
所以现在问题就来了,以前的版本我们都是这样写,这没有一点问题,但是我们需要明白一点:Task是一个类,开胃菜中我们得知了,类在实例化的时候数据量会被存放在堆中,等待没有引用之后就被GC回收掉。所以来看刚才那句代码,我们的返回类型是什么,一个Task<int>。
如果我们以同步方式来实现直接返回一个int是什么样呢? 数据被置放在栈中,方法完成后,内存中的数据就消失了。这个周期非常的短,而且内存分配极小。
那么使用Task之后呢,实例化了一个Task对象,放在堆中,堆里面置放了大量的Task缓存对象。直至最后来等待GC回收。
来看看下面这个代码:
public async Task<int> ReadNextByteAsync() { if (_bufferedCount == 0) { await FillBuffer(); } if (_bufferedCount == 0) { return -1; } _bufferedCount--; return _buffer[_position++]; }
假如它是一个读取文本的方法,则ReadNextByteAsync可能会被调用无数次,比如10w+,那么按照我们的猜想结果是什么样子呢? 内存堆中存了10w+被包裹的Task对象数据。哪怕一个Task的所需要的内存量极小,那么10w+之后会是什么样呢?那么1000w+呢?
OMG,不敢想象。(隔壁:128G海盗船请求出战)
所以,救星来了:ValueTask。它从遥远的M78星云…………哦,不对,它从.NET Core2.0中出现了。
public readonly struct ValueTask : IEquatable<ValueTask> { }
是的,它就是这个样子。它是一个结构体,也就是值类型。如果按照我们之前对值类型和引用类型的说法来猜想,使用ValueTask完成上面的ReadNextByteAsync是什么样子呢? 它将数据存放在栈中,每次方法结束后它将被释放,避免不必要的内存开销。
所以这也是之前MSDN上说的,在同步中它会提高性能的原因。
所以以后我们可以尝试将以下代码替换:
//brefore return Task.FromResult(42); //after return new ValueTask(42);
不是所有情况它都是车神
ValueTask 被讲的这么好,是不是所有用Task的地方都可以用ValueTask了呢?
如果真的要回答这个问题的话,答案是:不是的。
回到MSDN对它的定义,您会发现,它是对Task的包装。因为是包装的原因,所以您可将所有用Task的地方转换为ValueTask,编译器并不会报错,而且ValueTask还可以转换为Task,毕竟是个包装器嘛。
来看看ValueTask的源码:
也就是说如果我们不是通过同步的方式直接得到结果,而是对Task的包装的话,获取ValueTask结果的内部其实还是通过Task在进行操作。
所以如果异步操作需要返回Task的情况下,我们将返回值更改为ValueTask反而增大了内存存储量(有一个Task对象,又有一个ValueTask对象)。
那么?异步的情况我也想避免不必要的开销怎么办呢? 可能您发现了在ValueTask里面还出现了另外的一个东西:IValueTaskSource 。
本文由于篇幅有限,所以只能在后期为大家带来该接口的介绍(虽然有点吊胃口哈,不过这确实超纲了😭)
总结
所以,到这里我们大致了解了ValueTask出现的目的,以及它和Task的区别。您可以想一下,您现在所做的项目中是否可以引入ValueTask,虽然它在core 2.0之后引入,但是由于.NET standard的特性,core和farmework都能够使用它。
以下是ValueTask使用的一些误区,是摘自MSDN,如果您看懂了本篇的内容,您就可以很容易知道它为什么不能这么使用。对了,由于MSDN翻译问题,所以这里还是英文。(再次说一句,翻译扣鸡腿)。
对了,小声说一句:“嘘,点波关注哈”
The following operations should never be performed on a ValueTask<TResult> instance:
Awaiting the instance multiple times.
Calling AsTask multiple times.
Using .Result or .GetAwaiter().GetResult() when the operation hasn't yet completed, or using them multiple times.
Using more than one of these techniques to consume the instance.