

前言 平时拿到 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