ValueTask 应该怎么 await

前言

平时拿到 ValueTask,直接 await 看起来没有什么可讨论的。但在 .NET runtime 和 ASP.NET Core 的热点代码里,经常能看到另一种写法:先检查 IsCompletedSuccessfully,同步成功时直接读取结果,只有未完成时才等待。

这很容易让人产生疑问:调用方是不是也应该先判断完成状态?直接 await 会不会错过优化?又为什么有些代码会把 ValueTask 转成 Task?本文就围绕这些问题展开,重点区分普通调用方的写法和框架内部的优化写法。

先说结论:拿到一个 ValueTask 后,绝大多数情况下直接 await 就可以了。

var result = await valueTask;

前面提到的框架写法会先处理同步成功的情况;如果操作尚未完成,或者已经失败、取消,再交给后面的异步分支。

这不是让每个调用方都在 await 前做判断,而是框架在性能热点上使用的一种优化。后文会结合 ASP.NET Core v10.0.0 的源码来看它具体是怎么写的。

直接 await 就会检查完成状态

await 并不是无条件挂起。编译器会先取得 awaiter,再检查它的 IsCompleted

var awaiter = valueTask.GetAwaiter();

if (!awaiter.IsCompleted)
{
    // 保存 async 方法的状态,并注册完成后的后续回调。
    // 恢复后会调用 awaiter.GetResult()。
}
else
{
    awaiter.GetResult();
}

上面的代码只是为了说明流程,实际生成的代码还会处理状态机字段、异常和上下文。关键逻辑并不复杂:

  • 已成功完成:直接取结果,不注册后续回调。
  • 已失败或已取消:同样直接调用 GetResult(),让异常或取消按 await 的语义抛出。
  • 尚未完成:注册后续回调,完成后再调用 GetResult()

也就是说,调用方自己判断 IsCompleted 并不能省掉这次检查,反而需要自己处理成功、失败和取消三种已完成状态。普通代码直接交给 await 即可。

为什么同一个 ValueTask 不能多次 await

Task 是可共享的对象。多个调用方可以等待同一个 Task,任务完成后,结果也会一直保留。

ValueTask 没有这样的约定。它只是一个结构体,内部可能表示下面三种情况:

  1. 已经得到的同步结果。例如 ValueTask.FromResult(42) 直接把 42 放在 ValueTask<int> 中。
  2. 一个正在执行或已经完成的 Task / Task<T>。这时 ValueTask 只是对原 Task 的包装,例如 new ValueTask<int>(someTask)
  3. 一个 IValueTaskSource,以及标识本次操作的版本令牌。这个对象可以被复用,用来避免每次操作都创建 Task

注意:这里说的是 ValueTask 的内部表示,不是方法表面上是否写了 async。一个 async ValueTask<T> 方法也完全可能同步完成,例如缓存命中后直接返回结果;但编译器生成的 builder 如何构造返回值是实现细节,调用方不应据此判断 ValueTask 的来源。

前两种情况在很多实现中确实可以多次等待,但调用方无法从方法签名判断实际拿到的是哪一种。只要 API 返回的是 ValueTask,就必须按第三种情况的约束来使用。

真正的限制来自 ValueTask 包装 IValueTaskSource 的情况。高吞吐 I/O 会把 IValueTaskSource 放进对象池中复用:一次操作完成并被消费后,同一个 IValueTaskSource 就可以开始下一次操作。ValueTask 保存的版本令牌用来标识“这是第几次操作”。

第 1 次操作:IValueTaskSource + token 17 -> ValueTask A
await A:消费结果
IValueTaskSource 被归还并开始第 2 次操作,token 变为 18
再次 await A:A 仍携带 token 17,已不再代表当前操作

如果在 IValueTaskSource 被复用后再次等待 A,通常会因为令牌不匹配而失败。即使它还没被复用,也未必支持登记多个后续回调。具体表现取决于实现,不能依赖“第二次能正常工作”,也不能假设一定会抛出某一种异常。

这也解释了为什么有些 ValueTask 看上去可以多次 await:它们刚好包装了 Task,或者自身保存了同步结果。调用方无法从方法签名判断 ValueTask 的来源,因此应当遵守最严格的约束:一个 ValueTask 实例只能消费一次。

var valueTask = reader.ReadAsync(cancellationToken);

// 正确:只消费一次。
var result = await valueTask;

// 不要再次 await valueTask,也不要再次调用 valueTask.AsTask()。

若确实需要多个等待者、缓存结果或传给 Task.WhenAll,转换一次并保存该 Task

Task<ReadResult> task = reader.ReadAsync(cancellationToken).AsTask();

await Task.WhenAll(task, RecordCompletionAsync(task));
var result = await task;

这样做可能会多分配一个 Task,但换来了 Task 支持多次等待和多个观察者的语义。官方文档将多次 await、重复调用 AsTask(),以及混合两种消费方式都列为不受支持的用法。Stephen Toub 的 Understanding the Whys, Whats, and Whens of ValueTask 也介绍了可复用 IValueTaskSource 用来避免分配的背景。更多约束可以参考 ValueTask API 文档

一个实际会报错的例子

下面的示例使用 ManualResetValueTaskSourceCore<T> 模拟可复用操作。第一次等待完成后,代码立刻复用同一个 IValueTaskSource 创建第二次操作,然后再次等待旧的 ValueTask

using System.Threading.Tasks.Sources;

var source = new ReusableOperation();
var first = source.CreateCompleted(1);

Console.WriteLine($"First result: {await first}");

// source 开始下一次操作,版本令牌发生变化。
_ = source.CreateCompleted(2);

try
{
    Console.WriteLine($"Second result: {await first}");
}
catch (Exception exception)
{
    Console.WriteLine($"{exception.GetType().FullName}: {exception.Message}");
}

public sealed class ReusableOperation : IValueTaskSource<int>
{
    private ManualResetValueTaskSourceCore<int> _core;

    public ValueTask<int> CreateCompleted(int result)
    {
        _core.Reset();
        _core.SetResult(result);
        return new ValueTask<int>(this, _core.Version);
    }

    int IValueTaskSource<int>.GetResult(short token) => _core.GetResult(token);

    ValueTaskSourceStatus IValueTaskSource<int>.GetStatus(short token) =>
        _core.GetStatus(token);

    void IValueTaskSource<int>.OnCompleted(
        Action<object?> continuation,
        object? state,
        short token,
        ValueTaskSourceOnCompletedFlags flags) =>
        _core.OnCompleted(continuation, state, token, flags);
}

在本机的 .NET 10.0.0 上运行,输出为:

First result: 1
System.InvalidOperationException: Operation is not valid due to the current state of the object.

异常由 ManualResetValueTaskSourceCore<T> 在发现旧版本令牌时抛出。本例的异常类型和消息只是这个实现的具体行为,不能把它当成 ValueTask 多次 await 时的通用结果;不同的 IValueTaskSource 可以表现不同。这也是调用方不能依赖第二次 await 的原因。

为什么源码里使用 IsCompletedSuccessfully

IsCompleted 表示操作已经不在 Pending 状态,其中包括成功、失败和取消。IsCompletedSuccessfully 则进一步保证操作已经成功完成,因此这时读取 Result 是安全的。

这类判断适合放在非 async 方法的快速路径上:成功时同步处理结果并立即返回;未完成、失败和取消则交给 await,由它处理后续回调、异常和取消。

调用底层操作
  -> IsCompletedSuccessfully ?
       是:直接读取 Result,继续同步执行
       否:调用局部异步方法,由 await 等待结果

外层方法通常不会标记为 async。否则编译器仍然需要为它生成状态机,快速路径的意义就小很多。下面直接看 ASP.NET Core .NET 10 中几种实际写法。

为什么有些源码会调用 AsTask

这种写法通常出现在方法必须返回 Task 的地方。这里调用 AsTask() 不是为了让 await 更好用,而是要把 ValueTask 转成调用方需要的 Task

.NET 10 的 ASP.NET Core 在 src/Shared/ValueTaskExtensions/ValueTaskExtensions.cs 中用 GetAsTask 处理无返回值的 ValueTask<FlushResult>

public static Task GetAsTask(this in ValueTask<FlushResult> valueTask)
{
    if (valueTask.IsCompletedSuccessfully)
    {
        valueTask.GetAwaiter().GetResult();
        return Task.CompletedTask;
    }

    return valueTask.AsTask();
}

这两个分支分别处理不同的情况:

  • 快速路径:同步成功时,调用 GetResult() 消费 ValueTask,再返回共享的 Task.CompletedTask。这样可以避免调用 AsTask();当 ValueTaskIValueTaskSource 支撑时,AsTask() 可能需要分配一个 Task
  • 其余情况:未完成、失败或取消时,AsTask() 返回一个能够表示后续结果、异常或取消的 Task,满足方法返回 Task 的约定。

即使这里没有业务结果,也必须调用 GetResult()。它不只是“读取返回值”,对于一些由可复用 IValueTaskSource 支撑的操作,它还表示这次 ValueTask 已经被消费。因此不能因为操作已经完成,就直接跳过它。

如果外层方法本身就是 async,也没有必须返回 Task 的要求,通常仍然写:

await valueTask;

而不是 await valueTask.AsTask()。后者可能引入不必要的 Task 转换。只有返回类型、组合 API(例如 Task.WhenAll)或框架接口明确需要 Task 时,才应该转换。

ASP.NET Core MVC 的 Action 返回值

在 .NET 10 的 ASP.NET Core MVC 中,src/Mvc/Mvc.Core/src/Infrastructure/ControllerActionInvoker.cs 处理 Action 方法返回的 ValueTask<IActionResult> 时,采用的是“同步保存结果,异步时再等待”的写法:

private Task InvokeActionMethodAsync()
{
    var objectMethodExecutor = _cacheEntry.ObjectMethodExecutor;
    var actionMethodExecutor = _cacheEntry.ActionMethodExecutor;
    var orderedArguments = PrepareArguments(_arguments, objectMethodExecutor);

    var actionResultValueTask = actionMethodExecutor.Execute(
        ControllerContext, _mapper, objectMethodExecutor, _instance!, orderedArguments);

    if (actionResultValueTask.IsCompletedSuccessfully)
    {
        _result = actionResultValueTask.Result;
    }
    else
    {
        return Awaited(this, actionResultValueTask);
    }

    return Task.CompletedTask;

    static async Task Awaited(
        ControllerActionInvoker invoker,
        ValueTask<IActionResult> actionResultValueTask)
    {
        invoker._result = await actionResultValueTask;
    }
}

如果 Action 方法同步返回了 IActionResult,MVC 就直接保存结果并返回 Task.CompletedTask。只有 Action 方法尚未完成时,才调用局部异步方法等待。失败和取消也会走这条路径,因此异常仍然由 await 按正常的异步语义传递。

ASP.NET Core MVC 的资源释放

同一个 ResourceInvoker 在释放资源时返回 ValueTask。虽然这里没有结果可读取,但它同样会通过 IsCompletedSuccessfully 判断是否需要等待:

internal ValueTask ReleaseResourcesCore(IDisposable? scope)
{
    Exception? releaseException = null;
    ValueTask releaseResult;
    try
    {
        releaseResult = ReleaseResources();
        if (!releaseResult.IsCompletedSuccessfully)
        {
            return HandleAsyncReleaseErrors(releaseResult, scope);
        }
    }
    catch (Exception exception)
    {
        releaseException = exception;
    }

    return HandleReleaseErrors(scope, releaseException);

    static async ValueTask HandleAsyncReleaseErrors(
        ValueTask releaseResult, IDisposable? scope)
    {
        Exception? releaseException = null;
        try
        {
            await releaseResult;
        }
        catch (Exception exception)
        {
            releaseException = exception;
        }

        await HandleReleaseErrors(scope, releaseException);
    }
}

官方代码:ResourceInvoker.cs。释放操作同步成功时,代码继续执行后续的释放逻辑;失败、取消和未完成时,则交给局部异步方法,让 await 统一处理异常。

HttpResponse.WriteAsync 中的连续快速路径

HttpResponseWritingExtensions.WriteAsync 中有连续两层这样的判断。它先检查 StartAsync 是否同步完成;如果完成,就继续写入。写完后,再把 FlushAsync 返回的 ValueTask<FlushResult> 交给前面的 GetAsTask

public static Task WriteAsync(
    this HttpResponse response,
    string text,
    Encoding encoding,
    CancellationToken cancellationToken = default)
{
    if (!response.HasStarted)
    {
        var startAsyncTask = response.StartAsync(cancellationToken);
        if (!startAsyncTask.IsCompletedSuccessfully)
        {
            return StartAndWriteAsyncAwaited(
                response, text, encoding, cancellationToken, startAsyncTask);
        }
    }

    Write(response, text, encoding);
    return response.BodyWriter.FlushAsync(cancellationToken).GetAsTask();
}

private static async Task StartAndWriteAsyncAwaited(
    HttpResponse response,
    string text,
    Encoding encoding,
    CancellationToken cancellationToken,
    Task startAsyncTask)
{
    await startAsyncTask;
    Write(response, text, encoding);
    await response.BodyWriter.FlushAsync(cancellationToken);
}

这个例子说明,快速路径不一定只有一层。每一步都可以先处理同步完成的情况;一旦遇到真正未完成的操作,就调用局部异步方法,后面的代码仍然按普通的 await 写法执行。

基准测试:同步成功的 ValueTask

下面给出完整的基准代码。在 .NET 10 控制台项目中引用 BenchmarkDotNet 后,执行下面的命令即可运行:

dotnet run -c Release

项目只需要引用 BenchmarkDotNet:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
  </ItemGroup>
</Project>

完整基准代码如下:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using System.Threading.Tasks.Sources;

BenchmarkRunner.Run<ValueTaskAwaitBenchmarks>();

[MemoryDiagnoser]
[SimpleJob(launchCount: 1, warmupCount: 5, iterationCount: 12, invocationCount: 10_000_000)]
public class ValueTaskAwaitBenchmarks
{
    private readonly CompletedValueTaskSource _source = new();

    [Benchmark(Baseline = true)]
    public async ValueTask DirectAwait()
    {
        await _source.CreateCompleted();
    }

    [Benchmark]
    public ValueTask FastPath()
    {
        var valueTask = _source.CreateCompleted();
        if (valueTask.IsCompletedSuccessfully)
        {
            valueTask.GetAwaiter().GetResult();
            return ValueTask.CompletedTask;
        }

        return AwaitSlowPath(valueTask);
    }

    [Benchmark]
    public Task ConvertToTask()
    {
        return _source.CreateCompleted().AsTask();
    }

    [Benchmark]
    public Task FastPathToTask()
    {
        var valueTask = _source.CreateCompleted();
        if (valueTask.IsCompletedSuccessfully)
        {
            valueTask.GetAwaiter().GetResult();
            return Task.CompletedTask;
        }

        return valueTask.AsTask();
    }

    private static async ValueTask AwaitSlowPath(ValueTask valueTask)
    {
        await valueTask;
    }
}

public sealed class CompletedValueTaskSource : IValueTaskSource
{
    private ManualResetValueTaskSourceCore<bool> _core;

    public ValueTask CreateCompleted()
    {
        _core.Reset();
        _core.SetResult(true);
        return new ValueTask(this, _core.Version);
    }

    void IValueTaskSource.GetResult(short token) => _core.GetResult(token);

    ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) =>
        _core.GetStatus(token);

    void IValueTaskSource.OnCompleted(
        Action<object?> continuation,
        object? state,
        short token,
        ValueTaskSourceOnCompletedFlags flags) =>
        _core.OnCompleted(continuation, state, token, flags);
}

测试使用 ManualResetValueTaskSourceCore<T> 模拟由 IValueTaskSource 支撑、但调用时已经成功完成的可复用操作。这正是 IsCompletedSuccessfully 可能带来收益的场景。真正还没有完成的 I/O 必然需要注册后续回调,这部分成本不在本次基准的范围内。

比较的四种写法如下:

方法 行为
DirectAwait async ValueTask 方法中直接 await valueTask
FastPath 成功时 GetResult() 并返回 ValueTask.CompletedTask;否则调用局部异步方法。
ConvertToTask 直接调用 valueTask.AsTask()
FastPathToTask 成功时 GetResult() 并返回 Task.CompletedTask;否则调用 AsTask()

运行环境:macOS Sequoia 15.7.7、Apple Silicon arm64、.NET SDK 10.0.100、.NET 10.0.0、BenchmarkDotNet 0.15.8。每轮一千万次调用,预热 5 轮、测量 12 轮。

方法 Mean 相对 DirectAwait 托管分配
DirectAwait 15.71 ns 1.00 0 B
FastPath 13.48 ns 0.86 0 B
ConvertToTask 12.90 ns 0.82 0 B
FastPathToTask 12.85 ns 0.82 0 B

FastPath 比直接 await 少约 2.2 ns,约为 14%。不过这个数字本身很小,而且直接 await 也没有分配。这个结果并不意味着所有 ValueTask 都应该先检查完成状态。只有 MVC、Kestrel 这类调用频率很高、同步成功又很常见的框架代码,才值得用额外分支去换这点成本。

本次测试中的 ConvertToTask 也没有分配,但这不代表所有 AsTask() 调用都如此。ValueTask 的实际来源会影响转换成本,特别是由某些 IValueTaskSource 实现支撑时,AsTask() 仍可能创建一个 Task。因此,只要 API 不要求 Task,还是应该直接 await valueTask

总结

对大多数调用方来说,直接写下面这行代码即可:

var result = await valueTask;

await 自己会判断操作是否完成。手动检查 IsCompleted 通常不会减少工作量,还需要额外处理失败和取消的情况。

一个 ValueTask 实例只应消费一次。不要多次 await,也不要多次调用 AsTask()。如果需要多个等待者,或者需要将结果交给 Task.WhenAll 之类的 API,应当调用一次 AsTask() 并保存返回的 Task

不要为了 await 而调用 AsTask()。只有方法签名、框架接口或组合 API 明确要求 Task 时,才需要转换。

IsCompletedSuccessfully 主要用于 .NET runtime、ASP.NET Core 这类调用频率很高的基础代码。它表示操作已经成功完成,可以安全读取 Result。普通业务代码无需模仿这种写法;只有性能分析已经证明同步成功路径是热点时,才值得用额外分支换取那一点收益。

posted @ 2026-07-27 21:38  黑洞视界  阅读(0)  评论(0)    收藏  举报