商业科技观察

科技商业深度分析,创投动态、商业模式创新与企业战略

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 没有这样的约定。它只是一个结构体,内部可能表示下面三种情况: 已经得到的同步结果。例如 ValueTask.FromResult(42) 直接把 42 放在 ValueTask 中。 一个正在执行或已经完成的 Task / Task。这时 ValueTask 只是对原 Task 的包装,例如 new ValueTask(someTask)。 一个 IValueTaskSource,以及标识本次操作的版本令牌。这个对象可以被复用,用来避免每次操作都创建 Task。 注意:这里说的是 ValueTask 的内部表示,不是方法表面上是否写了 async。一个 async ValueTask 方法也完全可能同步完成,例如缓存命中后直接返回结果;但编译器生成的 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 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 模拟可复用操作。第一次等待完成后,代码立刻复用同一个 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 { private ManualResetValueTaskSourceCore _core; public ValueTask CreateCompleted(int result) { _core.Reset(); _core.SetResult(result); return new ValueTask(this, _core.Version); } int IValueTaskSource.GetResult(short token) => _core.GetResult(token); ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => _core.GetStatus(token); void IValueTaskSource.OnCompleted( Action 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 在发现旧版本令牌时抛出。本例的异常类型和消息只是这个实现的具体行为,不能把它当成 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: public static Task GetAsTask(this in ValueTask valueTask) { if (valueTask.IsCompletedSuccessfully) { valueTask.GetAwaiter().GetResult(); return Task.CompletedTask; } return valueTask.AsTask(); } 这两个分支分别处理不同的情况: 快速路径:同步成功时,调用 GetResult() 消费 ValueTask,再返回共享的 Task.CompletedTask。这样可以避免调用 AsTask();当 ValueTask 由 IValueTaskSource 支撑时,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 时,采用的是“同步保存结果,异步时再等待”的写法: 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 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 交给前面的 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: Exe net10.0 enable enable 完整基准代码如下: using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; using System.Threading.Tasks.Sources; BenchmarkRunner.Run(); [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 _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 continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => _core.OnCompleted(continuation, state, token, flags); } 测试使用 ManualResetValueTaskSourceCore 模拟由 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。普通业务代码无需模仿这种写法;只有性能分析已经证明同步成功路径是热点时,才值得用额外分支换取那一点收益。
热门文章

© 2026 商业科技观察 版权所有

Sitemap