原文:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/
作者:Stephen
翻译:xiaoxiaotank
备注:本文要求读者对Task
有一定的了解,文章文字描述较多,但内容十分充实,相信你认真阅读后,一定让你受益匪浅。
前言
Task
类是在.NET Framework 4引入的,位于System.Threading.Tasks
命名空间下,它与派生的泛型类Task
已然成为.NET编程的主力,也是以async/await
(C# 5引入的)语法糖为代表的异步编程模型的核心。
随后,我会向大家介绍.NET Core 2.0中的新成员ValueTask/ValueTask
,来帮助你在日常开发用例中降低内存分配开销,提升异步性能。
Task
虽然Task
的用法有很多,但其最核心的是“承诺(promise)”,用来表示某个操作最终完成。
当你初始化一个操作后,会获取一个与该操作相关的Task
,当这个操作完成时,Task
也同样会完成。这个操作的完成情况可能有以下几种:
- 作为初始化操作的一部分同步完成,例如:访问一些已被缓存的数据
- 恰好在你获取到
Task
实例的时候异步完成,例如:访问虽然没被缓存但是访问速度非常快的数据 - 你已经获取到了
Task
实例,并等待了一段时间后,才异步完成,例如:访问一些网络数据
由于操作可能会异步完成,所以当你想要使用最终结果时,你可以通过阻塞来等待结果返回(不过这违背了异步操作的初衷);或者,使用回调方法,它会在操作完成时被调用,.NET 4通过Task.ContinueWith
方法显式实现了这个回调方法,如:
SomeOperationAsync().ContinueWith(task =>
{
try
{
TResult result = task.Result;
UseResult(result);
}
catch(Exception ex)
{
HandleException(ex);
}
})
而在.NET 4.5中,Task
通过结合await
,大大简化了对异步操作结果的使用,它能够优化上面说的所有情况,无论操作是同步完成、快速异步完成还是已经(隐式地)提供回调之后异步完成,都不在话下,写法如下:
TResult result = await SomeOperationAsync();
UseResult(result);
Task
作为一个类(class),非常灵活,并因此带来了很多好处。例如:
- 它可以被任意数量的调用者并发
await
多次 - 你可以把它存储到字典中,以便任意数量的后续使用者对其进行
await
,进而把这个字典当成异步结果的缓存 - 如果需要的话,你可以通过阻塞等待操作完成
- 另外,你还可以对
Task
使用各种各样的操作(称为“组合器”,combinators),例如使用Task.WhenAny
异步等待任意一个操作率先完成。
不过,在大多数情况下其实用不到这种灵活性,只需要简单地调用异步操作并await
获取结果就好了:
TResult result = await SomeOperationAsync();
UseResult(result);
在这种用法中,我们不需要多次await task
,不需要处理并发await
,不需要处理同步阻塞,也不需要编写组合器,我们只是异步等待操作的结果。这就是我们编写同步代码(例如TResult result = SomeOperation()
)的方式,它很自然地转换为了async/await
的方式。
此外,Task
也确实存在潜在缺陷,特别是在需要创建大量Task
实例且要求高吞吐量和高性能的场景下。Task
是一个类(class),作为一个类,这意味着每创建一个操作,都需要分配一个对象,而且分配的对象越多,垃圾回收器(GC)的工作量也会越大,我们花在这个上面的资源也就越多,本来这些资源可以用于做其他事情。庆幸的是,运行时(Runtime)和核心库在许多情况下都可以缓解这种情况。
例如,你写了如下方法:
public async Task WriteAsync(byte value)
{
if (_bufferedCount == _buffer.Length)
{
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
一般来说,缓冲区中会有可用空间,也就无需Flush
,这样操作就会同步完成。这时,不需要Task
返回任何特殊信息,因为没有返回值,返回Task
与同步方法返回void
没什么区别。因此,运行时可以简单地缓存单个非泛型Task,并将其反复用作任何同步完成的方法的结果(该单例是通过Task.CompletedTask
公开的)。
或者,你的方法是这样的:
public async Task MoveNextAsync()
{
if (_bufferedCount == 0)
{
// 缓存数据
await FillBuffer();
}
return _bufferedCount > 0;
}
一般来说,我们想的是会有一些缓存数据,这样_bufferedCount
就不会等于0,直接返回true
就可以了;只有当没有缓存数据(即_bufferedCount == 0)时,才需要执行可能异步完成的操作。而且,由于只有true
和false
这两种可能的结果,所以只需要两个Task
对象来分别表示true
和false
,因此运行时可以将这两个对象缓存下来,避免内存分配。只有当操作异步完成时,该方法才需要分配新的Task
,因为调用方在知道操作结果之前,就要得到Task
对象,并且要求该对象是唯一的,这样在操作完成后,就可以将结果存储到该对象中。
运行时也为其他类型型维护了一个类似的小型缓存,但是想要缓存所有内容是不切实际的。例如下面这个方法:
public async Task ReadNextByteAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
if (_bufferedCount == 0)
{
return -1;
}
_bufferedCount--;
return _buffer[_position++];
}
通常情况下,上面的案例也会同步完成。但是与上一个返回Task
的案例不同,该方法返回的Int32
的可能值约有40亿个结果,如果将它们都缓存下来,大概会消耗数百GB的内存。虽然运行时保留了一个小型缓存,但也只保留了一小部分结果值,因此,如果该方法同步完成(缓冲区中有数据)的返回值是4,它会返回缓存的Task
,但是如果它同步完成的返回值是42,那就会分配一个新的Task
,相当于调用了Task.FromResult(42)
。
许多框架库的实现也尝试通过维护自己的缓存来进一步缓解这种情况。例如,.NET Framework 4.5中引入的MemoryStream.ReadAsync
重载方法总是会同步完成,因为它只从内存中读取数据。它返回一个Task
对象,其中Int32
结果表示读取的字节数。ReadAsync
常常用在循环中,并且每次调用时请求的字节数是相同的(仅读取到数据末尾时才有可能不同)。因此,重复调用通常会返回同步结果,其结果与上一次调用相同。这样,可以维护单个Task
实例的缓存,即缓存最后一次成功返回的Task
实例。然后在后续调用中,如果新结果与其缓存的结果相匹配,它还是返回缓存的Task
实例;否则,它会创建一个新的Task
实例,并把它作为新的缓存Task
,然后将其返回。
即使这样,在许多操作同步完成的情况下,仍需强制分配Task
实例并返回。
同步完成时的ValueTask
正因如此,在.NET Core 2.0 中引入了一个新类型——ValueTask
,用来优化性能。之前的.NET版本可以通过引用NuGet包使用:System.Threading.Tasks.Extensions
ValueTask
是一个结构体(struct),用来包装TResult
或Task
,因此它可以从异步方法中返回。并且,如果方法是同步成功完成的,则不需要分配任何东西:我们可以简单地使用TResult
来初始化ValueTask
并返回它。只有当方法异步完成时,才需要分配一个Task
实例,并使用ValueTask
来包装该实例。另外,为了使ValueTask
更加轻量化,并为成功情形进行优化,所以抛出未处理异常的异步方法也会分配一个Task
实例,以方便ValueTask
包装Task
,而不是增加一个附加字段来存储异常(Exception)。
这样,像MemoryStream.ReadAsync
这类方法将返回ValueTask
而不需要关注缓存,现在可以使用以下代码:
public override ValueTask ReadAsync(byte[] buffer, int offset, int count)
{
try
{
int bytesRead = Read(buffer, offset, count);
return new ValueTask(bytesRead);
}
catch (Exception e)
{
return new ValueTask(Task.FromException(e));
}
}
异步完成时的ValueTask
能够编写出在同步完成时无需为结果类型产生额外内存分配的异步方法是一项很大的突破,.NET Core 2.0引入ValueTask
的目的,就是将频繁使用的新方法定义为返回ValueTask
而不是Task
。
例如,我们在.NET Core 2.1中的Stream
类中添加了新的ReadAsync
重载方法,以传递Memory
来替代byte[]
,该方法的返回类型就是ValueTask
。这样,Streams(一般都有一种同步完成的ReadAsync
方法,如前面的MemoryStream
示例中所示)现在可以在使用过程中更少的分配内存。
但是,在处理高吞吐量服务时,我们依旧需要考虑如何尽可能地避免额外内存分配,这就要想办法减少或消除异步完成时的内存分配。
使用await
异步编程模型时,对于任何异步完成的操作,我们都需要返回代表该操作最终完成的对象:调用者需要能够传递在操作完成时调用的回调方法,这就要求在堆上有一个唯一的对象,用作这种特定操作的管道,但是,这并不意味着有关操作完成后能否重用该对象的任何信息。如果对象可以重复使用,则API可以维护一个或多个此类对象的缓存,并将其复用于序列化操作,也就是说,它不能将同一对象用于多个同时进行中的异步操作,但可以复用于非并行访问下的对象。
在.NET Core 2.1中,为了支持这种池化和复用,ValueTask
进行了增强,不仅可以包装TResult
和Task
,还可以包装新引入的接口IValueTaskSource
。类似于Task
,IValueTaskSource
提供表示异步操作所需的核心支持;
public interface IValueTaskSource
{
ValueTaskSourceStatus GetStatus(short token);
void OnCompleted(Action
GetStatus
用于实现诸如ValueTask
之类的属性,返回指示异步操作是否仍在挂起或是否已完成以及完成情况(成功或失败)的指示。.IsCompleted OnCompleted
用于ValueTask
的等待者(awaiter),它与调用者提供的回调方法挂钩,当异步操作完成时,等待者继续执行回调方法。GetResult
用于检索操作的结果,以便在操作完成后,等待者可以获取TResult
或传播可能发生的任何异常。
大多数开发人员永远都不需要用到此接口(指IValueTaskSource
):方法只是简单地将包装该接口实例的ValueTask
实例返回给调用者,而调用者并不需要知道内部细节。该接口的主要作用是为了让开发人员在编写性能敏感的API时可以尽可能地避免额外内存分配。
.NET Core 2.1中有几个类似的API。最值得关注的是Socket.ReceiveAsync
和Socket.SendAsync
,添加了新的重载,例如:
public ValueTask ReceiveAsync(Memory buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
此重载返回ValueTask
。
如果操作同步完成,则可以简单地构造具有正确结果的ValueTask
,例如:
int result = …;
return new ValueTask(result);
如果它异步完成,则可以使用实现此接口的池对象:
IValueTaskSource vts = …;
return new ValueTask(vts);
该Socket
实现维护了两个这样的池对象,一个用于Receive,一个用于Send,这样,每次未完成的对象只要不超过一个,即使这些重载是异步完成的,它们最终也不会额外分配内存。NetworkStream
也因此受益。
例如,在.NET Core 2.1中,Stream
公开了一个方法:
public virtual ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default);
NetworkStream
的重载方法NetworkStream.ReadAsync
,内部实际逻辑只是交给了Socket.ReceiveAsync
去处理,所以将优势从Socket
带到了NetworkStream
中,使得NetworkStream.ReadAsync
也有效地不进行额外内存分配了。
非泛型的ValueTask
当在.NET Core 2.0中引入ValueTask
时,它纯粹是为了优化异步方法同步完成的情况——避免必须分配一个Task
实例用于存储TResult
。这也意味着非泛型的ValueTask
是不必要的(因为没有TResult
):对于同步完成的情况,返回值为Task
的方法可以返回Task.CompletedTask
单例,此单例由async Task
方法的运行时隐式返回。
然而,随着即使异步完成也要避免额外内存分配需求的出现,非泛型的ValueTask
又变得必不可少。因此,在.NET Core 2.1中,我们还引入了非泛型的ValueTask
和IValueTaskSource
。它们提供泛型版本对应的非泛型版本,使用方式类似,只是GetResult
返回void
。
实现IValueTaskSource / IValueTaskSource
大多数开发人员都不需要实现这两个接口,它们也不是特别容易实现。如果您需要的话,.NET Core 2.1的内部有几种实现可以用作参考,例如
- AwaitableSocketAsyncEventArgs
- AsyncOperation
- DefaultPipeReader
为了使想要这样做的开发人员更轻松地进行开发,将在.NET Core 3.0中计划引入ManualResetValueTaskSourceCore
结构体(译注:目前已引入),用于实现接口的所有逻辑,并可以被包装到其他实现了IValueTaskSource
和IValueTaskSource
的包装器对象中,这个包装器对象只需要单纯地将大部分实现交给该结构体就可以了。
ValueTask的有效消费模式
从表面上看,ValueTask
和ValueTask
的使用限制要比Task
和Task
大得多 。不过没关系,这甚至就是我们想要的,因为主要的消费方式就是简单地await
它们。
但是,由于ValueTask
和ValueTask
可能包装可复用的对象,因此,与Task
和Task
相比,如果调用者偏离了仅await
它们的设计目的,则它们在使用上实际回受到很大的限制。通常,以下操作绝对不能用在ValueTask/ValueTask
上:
-
await ValueTask/ValueTask
多次。因为底层对象可能已经被回收了,并已由其他操作使用。而
Task/Task
永远不会从完成状态转换为未完成状态,因此您可以根据需要等待多次,并且每次都会得到相同的结果。 -
并发
await ValueTask/ValueTask
。底层对象期望一次只有单个调用者的单个回调来使用,并且尝试同时等待它可能很容易引入竞争条件和细微的程序错误。这也是第一个错误操作的一个更具体的情况——
await ValueTask/ValueTask
多次。相反,Task/Task
支持任意数量的并发等待 -
使用
.GetAwaiter().GetResult()
时操作尚未完成。IValueTaskSource / IValueTaskSource
接口的实现中,在操作完成前是没有强制要求支持阻塞的,并且很可能不会支持,所以这种操作本质上是一种竞争状态,也不可能按照调用方的意愿去执行。相反,Task/Task
支持此功能,可以阻塞调用者,直到任务完成。
如果您使用ValueTask/ValueTask
,并且您确实需要执行上述任一操作,则应使用.AsTask()
获取Task/Task
实例,然后对该实例进行操作。并且,在之后的代码中您再也不应该与该ValueTask/ValueTask
进行交互。
简单说就是使用ValueTask/ValueTask
时,您应该直接await
它(可以有选择地加上.ConfigureAwait(false)
),或直接调用AsTask()
且再也不要使用它,例如:
// 以这个方法为例
public ValueTask SomeValueTaskReturningMethodAsync();
…
// GOOD
int result = await SomeValueTaskReturningMethodAsync();
// GOOD
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);
// GOOD
Task t = SomeValueTaskReturningMethodAsync().AsTask();
// WARNING
ValueTask vt = SomeValueTaskReturningMethodAsync();
... // 将实例存储到本地会使它被滥用的可能性更大,
// 不过这还好,适当使用没啥问题
// BAD: await 多次
ValueTask vt = SomeValueTaskReturningMethodAsync();
int result = await vt;
int result2 = await vt;
// BAD: 并发 await (and, by definition then, multiple times)
ValueTask vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt);
// BAD: 在不清楚操作是否完成的情况下使用 GetAwaiter().GetResult()
ValueTask vt = SomeValueTaskReturningMethodAsync();
int result = vt.GetAwaiter().GetResult();
另外,开发人员可以选择使用另一种高级模式,最好你在衡量后确定它可以带来好处之后再使用。具体来说,ValueTask/ValueTask
确实公开了一些与操作的当前状态有关的属性,例如:
IsCompleted
,如果操作尚未完成,则返回false
;如果操作已完成,则返回true
(这意味着该操作不再运行,并且可能已经成功完成或以其他方式完成)IsCompletedSuccessfully
,当且仅当它已完成并成功完成才返回true
(意味着尝试等待它或访问其结果不会导致引发异常)
举个例子,对于一些执行非常频繁的代码,想要避免在异步执行时进行额外的性能损耗,并在某个本质上会使ValueTask/ValueTask
不再使用的操作(如await
、.AsTask()
)时,可以先检查这些属性。例如,在 .NET Core 2.1的SocketsHttpHandler
实现中,代码在连接上发出读操作,并返回一个ValueTask
实例。如果该操作同步完成,那么我们不用关注能否取消该操作。但是,如果它异步完成,在运行时就要发出取消请求,这样取消请求会将连接断开。由于这是一个非常常用的代码,并且通过分析表明这样做的确有细微差别,因此代码的结构基本上如下:
int bytesRead;
{
ValueTask readTask = _connection.ReadAsync(buffer);
if (readTask.IsCompletedSuccessfully)
{
bytesRead = readTask.Result;
}
else
{
using (_connection.RegisterCancellation())
{
bytesRead = await readTask;
}
}
}
这种模式是可以接受的,因为在ValueTask
的Result
被访问或自身被await
之后,不会再被使用了。
新异步API都应返回ValueTask / ValueTask 吗?
当然不是,Task/Task
仍然是默认选择
正如上文所强调的那样,Task/Task
比ValueTask/ValueTask
更加容易正确使用,所以除非对性能的影响大于可用性的影响,否则Task/Task
仍然是最优的。
此外,返回ValueTask
会比返回Task
多一些小开销,例如,await Task
比await ValueTask
会更快一些,所以如果你可以使用缓存的Task实例(例如,你的API返回Task
或Task
),你或许应该为了更好地性能而仍使用Task
和Task
。而且,ValueTask/ValueTask
相比Task/Task
有更多的字段,所以当它们被await
、并将它们的字段存储在调用异步方法的状态机中时,它们会在该状态机对象中占用更多的空间。
但是,如果是以下情况,那你应该使用ValueTask/ValueTask
:
- 你希望API的调用者只能直接
await
它 - 避免额外的内存分配的开销对API很重要
- 你预期该API常常是同步完成的,或者在异步完成时你可以有效地池化对象。
在添加抽象、虚拟或接口方法时,您还需要考虑这些方法的重载/实现是否存在这些情况。
ValueTask和ValueTask 的下一步是什么?
对于.NET Core库,我们将依然会看到新的API被添加进来,其返回值是Task/Task
,但在适当的地方,我们也将看到添加了新的以ValueTask/ValueTask
为返回值的API。
ValueTask/ValueTask
的一个关键例子就是在.NET Core 3.0添加新的IAsyncEnumerator
支持。IEnumerator
公开了一个返回bool
的MoveNext
方法,异步IAsyncEnumerator
则会公开一个MoveNextAsync
方法。刚开始设计此功能时,我们认为MoveNextAsync
应返回Task
,一般情况下,通过缓存的Task
在同步完成时可以非常高效地执行此操作。但是,考虑到我们期望的异步枚举的广泛性,并且考虑到它们基于是基于接口的,其可能有许多不同的实现方式(其中一些可能会非常关注性能和内存分配),并且鉴于绝大多数的消费者将通过await foreach
来使用,我们决定MoveNextAsync
返回ValueTask
。这样既可以使同步完成案例变得很快,又可以使用可重用的对象来使异步完成案例的内存分配也减少。实际上,在实现异步迭代器时,C#编译器会利用此优势,以使异步迭代器尽可能免于额外内存分配。