理解ValueTask和ValueTask<TResult>

  1. 本文译自微软开发博客《Understanding the Whys, Whats, and Whens of ValueTask》一文,仅在知乎平台发布转载请注明原文链接、本文链接和译者(知乎用户 @叶影 )。
  2. 本文不是对原文内容的严格翻译,例如会适当略过一些背景介绍的段落、无关紧要的片段、过渡性的语句;此外还会在尽可能保证理解正确的基础上意译部分内容,如有不当,希望读者不吝赐教。
  3. 原文评论区有一些精彩的读者提问和作者回答(比如文末话题的进一步深入:“IAsycEnumerable异步完成的场景下为什么返回ValueTask<bool>比返回一个已缓存的Task<bool>内存分配更少?”),因译者精力有限,本文不另做翻译,仅在此提醒有兴趣的读者。

前言

本文将介绍比较新的ValueTask/ValueTask<TResult>类型,它们用于提高日常用例中的异步性能,这些日常用例的重点就是要降低内存分配开销。

Task类型

Task可服务于多种目的,但核心是一个“承诺”(promise),一个表示某个操作最终完成的对象。你初始化一个操作并获得了一个相关的Task,当操作完成时Task也会完成,过程可能是:

  • 作为操作的一部分同步发生。例如,访问一些已经被缓存的数据。
  • 异步发生,但是在返回Task的时候已经完成。例如,访问一些未缓存的数据,但该数据获取的速度很快。
  • 异步发生,并且是等待了Task一阵子再完成。例如,通过网络远程访问一些数据。

因为操作可能会异步完成,要么需要阻塞来等待结果(这么做通常恰是违背了异步操作的初衷),要么需要提供一个在操作完成后执行的回调。在.NET Framework 4.0中,提供这样一个回调是通过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作为一个类型,它非常灵活而且带来了许多好处。例如,一个Task可以await多次,被任意数量的消费者并发消费。您可以将其存储到字典中,让任意数量的后续使用者在将来await,这就允许了使用字典来作为异步结果的缓存。如果场景需要,你可以阻塞等待一个Task直至其完成。而且,您可以编写和使用针对Task的多种操作(有时称为“组合器”,combinators),例如“when any”操作,即异步等待最先完成的Task。

然而,在多数常见用法中——简单地执行一个异步操作然后await得到的Task,灵活性并不是必需的:

TResult result = await SomeOperationAsync();
UseResult(result);

在这种用法下,我们不需要:

  • 多次await Task
  • 处理并发地await
  • 处理同步阻塞
  • 写组合器

我们只是单纯地需要等待一个异步操作产生的承诺。毕竟这就是我们写同步代码的方式(好比 TResult result = SomeOperation();),它会自然而然地被编译成async/await的世界。

此外,Task的确有一个潜在的副作用,特别是对于创建大量实例、高吞吐量和性能是核心关注点的场景而言:Task是一个类(class)。作为一个类,意味着任何需要创建类实例的操作都需要分配一个object对象,分配的object对象越多,垃圾收集器(GC)所需要做的工作就越多,我们花费在上面的资源就会越多,而这些资源本可以用来去做其他事情。

在很多情况下,运行时和核心库缓解了这种副作用。例如,当你写如下的方法时:

public async Task WriteAsync(byte value)
{
    if (_bufferedCount == _buffer.Length)
    {
        await FlushAsync();
    }
    _buffer[_bufferedCount++] = value;
}

通常情况下,缓冲区有可用空间,操作会同步完成。此时,需要返回的Task没什么特别的,因为没有返回值:执行的就是个与返回void的同步方法等价的基于Task的方法。因此,运行时会简单地缓存一个单独的非泛型的Task,并反复使用它作为任何同步完成的async Task方法的返回结果(这个缓存的单例以`Task.CompletedTask`公开)。 又例如,你写了这样一个方法:

public async Task<bool> MoveNextAsync()
{
    if (_bufferedCount == 0)
    {
        await FillBuffer();
    }
    return _bufferedCount > 0;
}

通常情况下,我们预想的是有一些数据被缓存,在有缓存的情况下,此方法只是检查 _bufferedCount,看是否大于 0,然后返回 true;仅在当前没有缓冲数据时,才需要执行可能异步完成的操作。并且由于只有两个可能的 Boolean 结果(true/false),只需要两个Task<bool>对象就可以表示所有可能的结果值,因此运行时可以缓存这两个对象,并简单地返回缓存的一个Result为true的 Task<bool> ,这就避免了新的内存分配。仅当操作异步完成时,该方法才需要分配一个新的 Task<bool>,因为它需要在知道操作结果之前将Task对象交还给调用者,并且需要是一个唯一的对象,使得操作完成后可以将结果存储到该Task对象中。

运行时也为其他类型维护了一个类似的小型缓存,但是缓存所有内容是不现实的。例如下面这个方法:

public async Task<int> ReadNextByteAsync()
{
    if (_bufferedCount == 0)
    {
        await FillBuffer();
    }

    if (_bufferedCount == 0)
    {
        return -1;
    }

    _bufferedCount--;
    return _buffer[_position++];
}

该方法也会经常性地同步完成。但不同于Boolean的例子,该方法返回一个Int32值,有大约40亿可能的结果,如果为它们都缓存一个Task<int>对象可能会消耗数百GB的内存。运行时的确为Task<int>维护了一个小型缓存,但只用于一些小的数值。因此,假如该方法同步完成(缓存不为空)并返回4,方法会返回一个缓存的Task对象,但是如果是42,方法返回时将分配一个新的Task<int>对象,相当于调用Task.FromResult(42)。

许多库实现也尝试通过维护自己的缓存来进一步缓解这种情况。例如,.NET Framework 4.5引入的MemoryStream.ReadAsync重载方法总是同步完成,因为它只是从内存中读取数据。ReadAsync方法返回一个Task<int>,Int32结果表示读取的字节数。ReadAsync通常在循环中使用,通常每次调用时请求的字节数相同,并且通常ReadAsync能够完美履行请求。因此,ReadAsync的重复调用通常同步返回与上一次调用相同的Task<int>结果。这样,MemoryStream维护了一个单独的Task的缓存,即缓存最后一次成功返回的Task。然后在后续调用中,如果新结果与其缓存的Task<int>结果相匹配,它只是再次返回缓存的对象;否则,它使用Task.FromResult方法创建一个新Task,将其存为新的缓存Task,然后返回它。

即便如此,仍有许多情况是操作同步完成,且不得不分配一个新的Task<TResult>对象用于返回。

ValueTask<TResult>和同步完成

所有这一切促使了一个新的类型自.NET Core 2.0开始被引入进.NET:ValueTask<TResult>,更早的.NET版本可以通过Nuget包System.Threading.Tasks.Extensions使用它。

ValueTask<TResult>在.NET Core 2.0中作为一个包装TResult或Task<TResult>的结构体(struct)引入进来。这意味着它可以从异步方法中返回,并且如果该方法同步成功完成,则不需要分配任何内容:我们可以简单地使用TResult初始化ValueTask<TResult>结构体并返回它。仅当该方法异步完成时,才要分配Task<TResult>实例,并使用ValueTask<TResult>来包装该实例(为了让ValueTask<TResult>尽可能轻量以及为成功情形做优化,一个因未处理异常而出错的异步方法也将分配一个Task<TResult>,以便在ValueTask<TResult>可以方便地包装Task<TResult>,而不是总得附加一个额外字段用来存放Exception)。

这样的话,类似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>。这么做之后,现在Streams(通常具有一个同步完成的ReadAsync方法,就跟前面MemoryStream示例中所说的那样)使用时的内存分配显著降低了。

但是,当运行吞吐量非常高的服务时,我们仍然关心怎么尽可能多地避免内存分配,这意味着还是要考虑减少和消除异步完成情况下的内存分配。

通过await模型,对于任何异步完成的操作,我们需要能够回调代表该操作最终完成的对象:调用者需要能返回将在操作完成时调用的回调,并且需要在堆上具有一个唯一的、作为服务于此特定操作的管道——的对象。但是,这并不意味着操作完成后是否可以复用该对象的什么信息。如果对象可以复用,则API可以维护一个或多个这类对象的缓存,并将其复用于序列化操作,也意味着它不能将同一对象用于多个正在进行中的异步操作,但可以复用非并发访问下的对象。

.NET Core 2.1中,ValueTask<TResult>增强了对这种池化和复用的支持。它不仅可以包装TResult或Task<TResult>,一个新接口IValueTaskSource<TResult>也引入了进来,ValueTask<TResult>进行了扩展使其能够同样地包装该接口。IValueTaskSource<TResult>提供了返回ValueTask<TResult>的一个异步操作所必需的核心支持,这种方式类似于Task<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方法用于获得操作结果,以便在操作完成后,等待者可以获取TResult的值或抛出任何可能发生的异常。

大多数开发人员应该永远不需要查看这个接口:方法只是简单地返回了一个用于包装该接口实例而构造的ValueTask<TResult>,而调用者并不需要知道细节。该接口主要是让编写性能敏感的API的开发者可以用它尽可能避免内存分配。

.NET Core 2.1中有几个类似的API。最值得注意的是Socket.ReceiveAsync和Socket.SendAsync,.NET Core 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);

如果操作是异步完成的,该方法使用一个池化了的(pooled)、实现上述接口了的对象:

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

当.NET Core 2.0引入ValueTask<TResult>时,它纯粹是为了优化同步完成时的情况,是为了避免必须分配一个Task<TResult>示例用来存储已经就绪的TResult值。这也意味着非泛型的ValueTask是不必要的:对于同步完成的情形,Task.CompletedTask单例可以直接从返回Task类型的方法返回,且隐式地由“async Task”方法的运行时完成。

然而,随着“即使是异步完成都可以免于内存分配”的需求出现,非泛型ValueTask又变得很重要了。因此,在.NET Core 2.1中,我们还引入了非泛型ValueTask和IValueTaskSource。这些提供了泛型版本的直接对应版本,使用方式类似,只是结果返回void。

实现IValueTaskSource / IValueTaskSource<T>

大多数开发人员永远都不需要实现这些接口。它们也不是特别容易实现。如果决定需要,.NET Core 2.1内部有几种实现可以用作参考,例如

为了使想要这样做的开发人员更轻松地进行开发,我们计划在.NET Core 3.0中引入ManualResetValueTaskSourceCore<TResult>类型,该类型是个封装了所有相关逻辑的结构体,可以被封装到另一个实现了IValueTaskSource<TResult>或IValueTaskSource的对象中,包装后的类型单纯地将大部分实现委托给结构体。您可以在https://github.com/dotnet/corefx/issues/32664的dotnet / corefx存储库中的相关问题中了解有关此内容的更多信息 。

ValueTasks的有效消费模式

从表面上看,ValueTask和ValueTask<TResult>比Task和Task<TResult>限制更多。不过这没关系,甚至是符合设计预期的,因为供调用的基础方法就是为了可以简单地await它们即可。

然而,因为ValueTask和ValueTask<TResult>可能包装了复用对象,相较于Task和Task<TResult>,在调用时如果有人偏离了只是await它们的设计目的,则在实践中会受到明显的限制。一般来说,以下操作绝不应发生在ValueTask和ValueTask<TResult>上:

  • 多次Await一个ValueTask和ValueTask<TResult>对象。底层对象可能已经被回收,并已由其他操作使用。与之相反,Task/Task<TResult>永远不会从完成状态过渡到不完成状态,因此你可以根据需要等待多次,并且每次都会得到相同的答案。
  • 并发Await一个ValueTask和ValueTask<TResult>对象。底层对象预期是同一时间只使用来自单个使用者的单个回调,而且企图并发地await可能很容易引入竞争条件和各种敏感的程序错误。这也是上一个错误操作的一个更具体的情况:“多次Await”。与之相反,Task/Task<TResult>支持任意并发数量的await。
  • 当异步操作未完成时使用.GetAwaiter().GetResult() 。IValueTaskSource/IValueTaskSource<TResult>的实现不需要支持阻塞直到操作完成,并且某个实现可能的确不支持,所以这种操作本质上是一种竞争状态,不可能按照调用者所希望的方式执行。与之相反,Task/Task<TResult>允许这么做,可以阻塞调用者直至任务完成。

如果对ValueTask和ValueTask<TResult>一定要做上述的事,你应该使用.AsTask()方法获取一个Task/Task<TResult>,然后基于Task对象接着执行操作。从此之后,你不该与ValueTask和ValueTask<TResult>对象再有任何交互。

简要原则是:对于ValueTask和ValueTask<TResult>,要么直接await它(并可以有选择地加上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>失效的操作(如await或调用AsTask方法)之前优先检查这些属性。例如,在.NET Core 2.1,SocketsHttpHandler类的实现中,代码对连接发出读取请求,该连接返回一个ValueTask<int>对象。如果该操作同步完成,我们不必担心是否可以取消该操作。但是,如果它异步完成,那么在运行时,我们要发起撤销(cancellation),以使撤销指令断开连接。由于这是非常频繁执行的代码路径,并且通过性能分析表明这么处理具有细微的差别,因此代码基本结构如下:

int bytesRead;
{
    ValueTask<int> readTask = _connection.ReadAsync(buffer);
    if (readTask.IsCompletedSuccessfully)
    {
        bytesRead = readTask.Result;
    }
    else
    {
        using (_connection.RegisterCancellation())
        {
            bytesRead = await readTask;
        }
    }
}

这种模式是可以接受的,因为ValueTask<int>对象在Result属性被访问或自身被await之后不会再被用到了。

每个异步API都应该返回ValueTask/ValueTask<TResult>吗?

简而言之:不:默认的选项依然是Task/Task<TResult>。

如上文强调的,Task和Task<TResult>比ValueTask和ValueTask<TResult>更容易被正确地使用,所以除非对性能的影响大于易用性的影响,Task/Task<TResult>仍然是最优选。此外返回ValueTask<TResult>较之Task<TResult>还有一些细微的开销,例如,在微基准测试
(microbenchmarks)中,await一个Task<TResult>对象比await一个ValueTask<TResult>对象稍微快一些,所以如果你可以使用缓存的Task(例如,你的API返回的是Task或Task<bool>),你或许应该出于性能友好的目的坚持使用Task和Task<bool>。ValueTask/ValueTask<TResult>在体积上也有更多的单词(multiple words in size),所以当被await、在调用异步方法的状态机中存储相关字段时,它们将在该状态机对象中占用更多的空间。

不过,ValueTask/ValueTask<TResult>是以下最佳选择:

  1. 您希望API的使用者只能直接await
  2. 避免内存分配的开销对您的API十分重要
  3. 您预期同步完成是非常普遍的情况,或者您可以在异步完成的情形下有效地池化并复用对象。在添加抽象、虚拟或接口方法时,您还需要考虑这些情况是否存在于方法的重写/实现中。

ValueTask/ValueTask<TResult>的下一步?

对于.NET Core库,我们将继续看到添加返回Task/Task<TResult>类型的新API,但我们也会看到在适当的地方,添加了返回ValueTask/ValueTask<TResult>类型的新API。后者的一个重要示例是IAsyncEnumerator<T>,计划在.NET Core 3.0支持。IEnumerator<T>公开了一个返回bool类型的MoveNext方法,而异步版本IAsyncEnumerator<T>公开了一个MoveNextAsync方法。当我们最初开始设计时,我们认为MoveNextAsync应该返回 Task<bool>类型,对于常见的同步完成的情况,该方法通过缓存的Task对象可以非常高效地运行。但是,考虑到我们对异步枚举预期应用的广泛性,考虑到它们可能基于以许多不同方式实现的接口(其中一些可能会深层面地关注性能和内存分配),并且鉴于绝大多数的调用是通过await foreach的语言支持,我们将MoveNextAsync的返回类型切换成了ValueTask<bool>。这样既可以让同步完成情况变得很快,又可以使异步完成情况下复用对象实现低内存分配的优化。实际上,在实现异步迭代器时,C#编译器会利用此优势,来让异步迭代器尽可能不产生多余的内存分配(allocation-free)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值