深入理解 ValueTask
.NET Framework 4 引入了 System.Threading.Tasks 命名空间,以及其中的 Task 类。这个类型及其派生类 Task<TResult> 已经成为 .NET 编程的基本组成部分,是 C# 5 及其 async / await 关键字引入的异步编程模型的核心部分。在这篇文章中,我将介绍较新的 ValueTask 和 ValueTask<TResult> 类型,这些类型的引入旨在改善常见使用场景中的异步性能,特别是在减少分配开销方面。
Task
Task 具有多种用途,但其核心是一个“承诺”,即表示某个操作最终完成的对象。你启动一个操作并得到一个 Task 对象,该 Task 会在操作完成时完成,这种完成可能会发生在以下几种情况:
同步完成:在启动操作的一部分过程中完成(例如,访问已经缓冲的数据)。
异步但在获取 Task 时完成:在你获取 Task 时已经完成(例如,访问尚未缓冲但访问非常快速的数据)。
异步且在持有 Task 后完成:在你已经持有 Task 之后完成(例如,访问来自网络的数据)。
由于操作可能会异步完成,你需要么阻塞等待结果(这往往会违背操作最初设计为异步的目的),要么提供一个回调,在操作完成时被调用。在 .NET 4 中,提供这种回调是通过 Task 上的 ContinueWith 方法实现的,该方法通过接受一个委托来明确地暴露回调模型,当 Task 完成时调用这个委托。
SomeOperationAsync().ContinueWith(task =>
{
try
{
TResult result = task.Result;
UseResult(result);
}
catch (Exception e)
{
HandleException(e);
}
});
但是,随着 .NET Framework 4.5 和 C# 5 的推出,Task 可以直接使用 await 关键字来等待,这使得消费异步操作的结果变得非常简单。生成的代码能够优化之前提到的所有情况,正确处理操作的不同完成方式,无论是同步完成、异步快速完成,还是异步完成但在隐式提供回调之后。
TResult result = await SomeOperationAsync();
UseResult(result);
Task 作为一个类非常灵活,并带来了许多好处。例如,你可以多次** await** 一个 Task,允许任意数量的消费者同时等待。你可以将一个 Task 存储到字典中,以便将来任何数量的消费者可以等待它,这使得它可以用作异步结果的缓存。你还可以在需要的情况下阻塞等待一个 Task 完成。此外,你可以对 Task 进行各种操作(有时称为“组合子”),例如“when any”操作,它会异步等待第一个完成的 Task。
然而,这种灵活性对于最常见的情况是不必要的:即简单地调用一个异步操作并等待其结果任务。
TResult result = await SomeOperationAsync();
UseResult(result);
在这种使用场景中,我们不需要能够多次 await 同一个 Task。我们不需要处理并发 await。我们不需要处理同步阻塞。我们也不需要编写组合子。我们只是需要能够等待异步操作的结果承诺。这毕竟是我们编写同步代码的方式(例如 TResult result = SomeOperation();),并且这种方式自然地转化为异步编程的 async / await 模型。
此外,Task 确实有一个潜在的缺点,特别是在实例创建频繁且高吞吐量和性能是关键关注点的场景中:Task 是一个类。作为一个类,这意味着任何需要创建一个 Task 的操作都需要分配一个对象,而分配的对象越多,垃圾回收器(GC)需要做的工作就越多,我们在垃圾回收上花费的资源也就越多,这些资源本可以用于其他操作。
运行时和核心库在许多情况下缓解了这个问题。例如,如果你编写如下方法:
public async Task WriteAsync(byte value)
{
if (_bufferedCount == _buffer.Length)
{
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
一般情况下,缓冲区中会有可用的空间,操作将同步完成。当操作完成时,返回的 Task 没有特别之处,因为没有返回值:这相当于一个返回 void 的同步方法。因此,运行时可以简单地缓存一个非泛型的 Task,并在任何异步 Task 方法同步完成时重复使用这个结果任务(这个缓存的单例通过 Task.CompletedTask 公开)。例如,如果你编写如下代码:
public async Task<bool> MoveNextAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
return _bufferedCount > 0;
}
一般情况下,我们期望有一些数据已经被缓冲,此时该方法只是检查 _bufferedCount,发现其大于 0,就返回 true;只有当当前没有缓冲数据时,才需要执行一个可能会异步完成的操作。由于结果只有两种布尔值(true 和 false),所以只需要两个 Task<bool> 对象来表示所有可能的结果值。因此,运行时能够缓存这两个对象,并简单地返回一个缓存的 Task<bool>,其结果为 true,从而避免了额外的分配。只有当操作异步完成时,方法才需要分配一个新的 Task<bool>,因为在知道操作结果之前,需要将对象返回给调用者,并且需要一个唯一的对象来存储操作完成后的结果。
运行时还会为其他类型维护一个小的缓存,但缓存所有东西并不现实。例如,像这样的方法:
public async Task<int> ReadNextByteAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
if (_bufferedCount == 0)
{
return -1;
}
_bufferedCount--;
return _buffer[_position++];
}
这个方法也会经常同步完成。但与布尔值的情况不同的是,这个方法返回一个 Int32 值,Int32 有大约 40 亿个可能的结果,缓存所有这些情况的 Task<int> 会消耗可能数百 GB 的内存。运行时确实为 Task<int> 维护了一个小的缓存,但仅限于少数几个小的结果值。例如,如果这个方法同步完成(缓冲区中有数据),并且返回一个值如 4,它将使用缓存的任务;但如果它同步完成并返回一个值如 42,它将分配一个新的 Task<int>,类似于调用 Task.FromResult(42)。
许多库实现尝试通过维护自己的缓存来进一步缓解这一问题。例如,.NET Framework 4.5 引入的MemoryStream.ReadAsync 重载总是同步完成,因为它只是从内存中读取数据。ReadAsync 返回一个 Task<int>,其中 Int32 结果表示读取的字节数。ReadAsync 经常在循环中使用,通常每次调用请求的字节数相同,并且 ReadAsync 能够完全满足这个请求。因此,重复调用 ReadAsync 时,返回的 Task<int> 同步结果与上一次调用的结果相同是很常见的。因此,MemoryStream 维护了一个单一任务的缓存,即它最后一次成功返回的任务。在随后的调用中,如果新结果与缓存的 Task<int> 的结果匹配,它将再次返回缓存的任务;否则,它使用 Task.FromResult 创建一个新的任务,将其存储为新的缓存任务,并返回它。
即便如此,仍然有许多情况下,操作同步完成时必须分配一个 Task<TResult> 以返回结果。
ValueTask 和同步完成
所有这些因素促使了在 .NET Core 2.0 中引入一种新类型,并通过 System.Threading.Tasks.Extensions NuGet 包使其在之前的 .NET 版本中可用:ValueTask<TResult>。
ValueTask<TResult> 在 .NET Core 2.0 中被引入为一个结构体,能够封装一个 TResult 或一个 Task<TResult>。这意味着它可以从异步方法中返回,如果该方法同步且成功地完成,则无需进行分配:我们可以简单地用 TResult 初始化 ValueTask<TResult> 结构体并返回它。只有当方法异步完成时,才需要分配一个 Task<TResult>,而 ValueTask<TResult> 会创建来封装该实例(为了最小化 ValueTask<TResult> 的大小并优化成功路径,若异步方法因未处理的异常而失败,也会分配一个 Task<TResult>,使得 **ValueTask<TResult> **可以简单地封装那个 Task<TResult>,而不需要始终携带一个额外的字段来存储异常)。
因此,一个像 MemoryStream.ReadAsync 这样的方法,如果返回 ValueTask<int>,就不需要担心缓存问题,可以使用如下代码编写:
public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count)
{
try
{
int bytesRead = Read(buffer, offset, count);
return new ValueTask<int>(bytesRead);
}
catch (Exception e)
{
return new ValueTask<int>(Task.FromException<int>(e));
}
}
ValueTask<TResult> 和异步完成
能够编写一个可以同步完成而无需为结果类型进行额外分配的异步方法是一个重大胜利。这就是为什么 ValueTask<TResult> 被添加到 .NET Core 2.0 中,并且为什么现在预期用于热点路径的新方法都定义为返回 ValueTask<TResult> 而不是 Task<TResult>。例如,当我们在 .NET Core 2.1 中向 Stream 添加了一个新的 ReadAsync 重载,以便能够传递 Memory<byte> 而不是 byte[] 时,我们将该方法的返回类型设置为 ValueTask<int>。这样,Stream(它们的 ReadAsync 方法很经常同步完成,例如之前的 MemoryStream 示例)现在可以在显著减少分配的情况下使用。
然而,在处理非常高吞吐量的服务时,我们仍然需要尽可能避免分配,这意味着也要考虑减少和移除与异步完成路径相关的分配。
使用 await 模型,对于任何异步完成的操作,我们需要能够返回一个代表操作最终完成的对象:调用者需要能够传递一个回调,当操作完成时该回调会被调用,这要求在堆上有一个唯一的对象可以作为这个特定操作的媒介。然而,这并不意味着该对象在操作完成后是否可以被重用。如果对象可以重用,那么 API 可以维护一个或多个这样的对象的缓存,并在序列化操作中重用它们,这意味着它不能在多个进行中的异步操作中使用相同的对象,但可以在非并发访问中重用一个对象。
在 .NET Core 2.1 中,ValueTask<TResult> 被增强以支持这种池化和重用。除了能够封装** TResult** 或 Task<TResult> 外,还引入了一个新的接口 IValueTaskSource<TResult>,并且 ValueTask<TResult> 被增强以能够封装这个接口。IValueTaskSource<TResult> 提供了必要的核心支持,以类似于 Task<TResult> 的方式表示异步操作给 ValueTask<TResult>:
public interface IValueTaskSource<out TResult>
{
ValueTaskSourceStatus GetStatus(short token);
void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags);
TResult GetResult(short token);
}
GetStatus 用于满足像 ValueTask<TResult>.IsCompleted 这样的属性,返回异步操作是否仍在等待中的指示,以及它是否完成以及完成的状态(成功或失败)。OnCompleted 被 ValueTask<TResult> 的 awaiter 用来设置必要的回调,以在操作完成时继续从 await 执行。GetResult 用于检索操作的结果,使得在操作完成后,awaiter 可以获取 TResult 或传播可能发生的任何异常。
大多数开发者不需要直接接触这个接口:方法仅仅返回一个 ValueTask<TResult>,这个 ValueTask<TResult> 可能已经被构造为封装这个接口的实例,而消费者并不需要知道内部细节。这个接口主要是为了使性能导向的 API 开发者能够避免额外的分配。
在 .NET Core 2.1 中有几个这样的 API。最著名的是 Socket.ReceiveAsync 和 Socket.SendAsync,在 2.1 中添加了新的重载,例如:
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
这个重载返回一个 ValueTask<int>。如果操作同步完成,它可以简单地构造一个包含适当结果的 ValueTask<int>,例如:
int result = …;
return new ValueTask<int>(result);
如果操作异步完成,它可以使用一个实现了这个接口的池化对象:
IValueTaskSource<int> vts = …;
return new ValueTask<int>(vts);
Socket 实现维护了一个用于接收的池化对象和一个用于发送的池化对象,只要在任何时候每种对象的未完成数量不超过一个,这些重载将最终实现无分配,即使它们异步完成操作。这种实现进一步体现在 **NetworkStream **中。例如,在 .NET Core 2.1 中,Stream 公开了:
public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
NetworkStream 对这些方法进行了重写。NetworkStream.ReadAsync 只是委托给 Socket.ReceiveAsync,因此 **Socket **的优化也转化到了 **NetworkStream **上,使得 NetworkStream.ReadAsync 实际上也变得无分配。
非泛型的 ValueTask
当 ValueTask<TResult> 在 .NET Core 2.0 中引入时,它的主要目的是优化同步完成的情况,以避免分配一个** Task<TResult>** 来存储已经可用的 TResult。这也意味着对于同步完成的情况,不需要非泛型的 ValueTask:对于同步完成的情况,Task.CompletedTask 单例可以直接从返回 Task 的方法中返回,并且运行时会隐式地为异步 Task 方法处理。
然而,随着使异步完成也能实现无分配的能力的出现,非泛型 ValueTask 再次变得相关。因此,在 .NET Core 2.1 中,我们还引入了非泛型的 ValueTask 和 IValueTaskSource。这些提供了与泛型版本直接对应的实现,使用方式类似,只是结果类型为 void。
IValueTaskSource / IValueTaskSource 实现
大多数开发者不需要实现这些接口,而且这些接口实现起来也不容易。如果你决定要这样做,.NET Core 2.1 中有几个实现可以作为参考,例如:
AwaitableSocketAsyncEventArgs
AsyncOperation<TResult>
DefaultPipeReader
为了简化那些确实需要实现这些接口的开发者,在 .NET Core 3.0 中,我们计划引入一个封装所有这些逻辑的 ManualResetValueTaskSourceCore<TResult> 类型,它是一个结构体,可以封装到另一个实现了 **IValueTaskSource<TResult> **和/或 IValueTaskSource 的对象中,这个包装类型只需将大部分实现委托给该结构体。你可以在 dotnet/corefx 仓库的关联问题 https://github.com/dotnet/corefx/issues/32664 中了解更多信息。
ValueTasks 有效的消费模式
从表面上看,ValueTask 和 ValueTask<TResult> 的功能比 Task 和 Task<TResult> 要有限得多。这是可以接受的,甚至是期望的,因为主要的使用方式是直接等待它们。
然而,由于** ValueTask** 和** ValueTask<TResult>** 可能会包装可重用的对象,因此在消费它们时实际上存在显著的限制,特别是当开发者偏离了仅仅等待它们这一期望的路径时。通常来说,以下操作不应该在 ValueTask / ValueTask<TResult> 上进行:
多次等待 ValueTask / ValueTask<TResult>。底层对象可能已经被回收,并被其他操作使用。相比之下,Task / Task<TResult> 永远不会从完成状态转变为未完成状态,因此你可以根据需要多次等待它,并且每次都会得到相同的结果。
并发等待 ValueTask / ValueTask<TResult>。底层对象期望一次只处理一个消费者的一个回调,尝试并发等待可能会引入竞争条件和微妙的程序错误。这也是上面提到的“多次等待 ValueTask / ValueTask<TResult>”这一不良操作的更具体情况。相比之下,Task / Task<TResult> 支持任意数量的并发等待。
在操作尚未完成时使用 .GetAwaiter().GetResult().IValueTaskSource / IValueTaskSource<TResult> 实现可能不支持阻塞直到操作完成,通常也不会,因此这种操作本质上是一个竞争条件,可能不会按调用者的预期行为工作。相比之下,Task / Task<TResult> 支持这种行为,会阻塞调用者直到任务完成。
如果你有一个 ValueTask 或 ValueTask<TResult>,并且需要执行这些操作之一,你应该使用 .AsTask() 来获取一个 Task / Task<TResult>,然后对结果任务对象进行操作。从那时起,你不应再与原始的** ValueTask / ValueTask<TResult>** 交互。
简短的规则是:对于 ValueTask 或 ValueTask<TResult>,你应该直接等待它(可以选择使用 .ConfigureAwait(false)),或者直接调用 AsTask(),然后不要再使用它,例如:
// Given this ValueTask<int>-returning method…
public ValueTask<int> SomeValueTaskReturningMethodAsync();
…
// GOOD
int result = await SomeValueTaskReturningMethodAsync();
// GOOD
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);
// GOOD
Task<int> t = SomeValueTaskReturningMethodAsync().AsTask();
// WARNING
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
... // storing the instance into a local makes it much more likely it'll be misused,
// but it could still be ok
// BAD: awaits multiple times
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = await vt;
int result2 = await vt;
// BAD: awaits concurrently (and, by definition then, multiple times)
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt);
// BAD: uses GetAwaiter().GetResult() when it's not known to be done
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = vt.GetAwaiter().GetResult();
还有一种额外的高级模式,某些开发者可能会选择使用,当然,希望只有在经过仔细测量并发现它提供了有意义的好处之后才使用。具体来说,ValueTask / ValueTask<TResult> 确实暴露了一些属性,能够反映操作的当前状态。例如,IsCompleted 属性在操作尚未完成时返回 false,在操作完成时返回 true(这意味着它不再运行,可能已经成功完成或以其他方式完成),而 IsCompletedSuccessfully 属性只有在操作完成且成功时才返回 true(这意味着尝试等待它或访问其结果不会导致异常被抛出)。对于那些需要避免在异步路径上只存在的一些额外开销的非常热点路径,开发者可以在执行那些会基本使 ValueTask / ValueTask<TResult> 失效的操作(例如等待或调用 .AsTask())之前检查这些属性。例如,在 .NET Core 2.1 的 SocketsHttpHandler 实现中,代码对连接发出读取操作,这会返回一个 ValueTask<int>。如果该操作同步完成,那么我们不需要担心是否能够取消操作。但如果它异步完成,那么在操作运行期间,我们希望连接能够挂钩取消请求,以便取消请求会关闭连接。由于这是一个非常热点的代码路径,并且性能分析显示这样做可以带来一些微小的改进,因此代码基本上是这样结构化的:
int bytesRead;
{
ValueTask
if (readTask.IsCompletedSuccessfully)
{
bytesRead = readTask.Result;
}
else
{
using (_connection.RegisterCancellation())
{
bytesRead = await readTask;
}
}
}
这种模式是可以接受的,因为在访问 .Result 或等待完成之后,**ValueTask<int> **不再被使用。
每个新的异步 API 都应该返回 ValueTask / ValueTask<TResult> 吗?
简而言之,答案是否定的:默认选择仍然是 Task / Task<TResult>。
正如前面提到的,Task 和 Task<TResult> 比 ValueTask 和 ValueTask<TResult> 更容易正确使用,因此除非性能上的影响超过了可用性上的影响,Task / Task<TResult> 仍然是首选。此外,返回** ValueTask<TResult>** 而不是 Task<TResult> 也存在一些小的成本。例如,在微基准测试中,等待** Task<TResult>** 比等待 ValueTask<TResult> 略微快一些,因此如果你可以使用缓存任务(例如你的 API 返回 Task 或 Task<bool>),从性能角度看,继续使用 Task 和 Task<bool> 可能更好。ValueTask / ValueTask<TResult> 也占用多个字节的空间,因此当这些对象被等待并且它们的字段被存储在调用异步方法的状态机中时,它们会在状态机对象中占用更多空间。
然而,当以下情况适用时,ValueTask / **ValueTask<TResult> **是很好的选择:a) 你期望 API 的使用者仅直接等待它们,b) 避免与分配相关的开销对你的 API 很重要,c) 要么你期望同步完成是非常常见的情况,要么你能够有效地为异步完成对象进行池化。当添加抽象、虚拟或接口方法时,你还需要考虑这些情况是否会存在于这些方法的重写或实现中。
ValueTask 和 ValueTask<TResult> 接下来会有什么发展?
对于核心 .NET 库,我们将继续添加新的返回 Task / Task<TResult> 的 API,同时也会在适当的情况下添加新的返回 ValueTask / ValueTask<TResult> 的 API。其中一个关键例子是 .NET Core 3.0 计划中的新 IAsyncEnumerator<T> 支持。IEnumerator<T> 暴露了一个返回 bool 的 MoveNext 方法,而异步的 IAsyncEnumerator<T> 对应的 MoveNextAsync 方法。在最初设计这个功能时,我们考虑将 MoveNextAsync 设为返回 Task<bool>,这可以通过缓存任务来提高效率,特别是在 MoveNextAsync 同步完成的常见情况下。然而,考虑到我们预计异步枚举会有广泛的应用,并且基于可能有多种不同实现的接口(其中一些可能对性能和分配非常关注),以及绝大多数的使用情况将通过 await foreach 语言支持来完成,我们决定让 MoveNextAsync 返回 ValueTask<bool>。这样可以使同步完成的情况保持快速,同时优化的实现可以使用可重用对象来减少异步完成的内存分配。事实上,C# 编译器在实现异步迭代器时会利用这一点,使异步迭代器尽可能减少内存分配。
翻译自: https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/