基于 .NET 11 深入理解 AsyncLocal 和 ExecutionContext

目录

前言

一次请求的处理过程可能跨越多个线程,而同一个线程也可能先后处理不同的请求。如何让请求标识等数据跟随当前的逻辑流程,同时避免在每一层方法之间手动传递?

AsyncLocal<T> 可以在当前逻辑流程中保存数据,让后续代码读取。只要执行上下文正常流动,即使后续代码换了线程,通常也能读取到之前设置的值。

常见场景包括:

  • 使用 Thread.Start() 启动新线程;
  • 使用 ThreadPool.QueueUserWorkItem() 在线程池中执行回调;
  • 使用 Task.Run() 执行任务;
  • 异步方法在 await 后继续执行。

这些数据由 ExecutionContext 维护。在上述场景中,运行时和相关 API 会在适当的时机捕获当前执行环境,并在后续代码执行时恢复它 。本文会结合源码梳理上下文的流转过程,以及 AsyncLocal<T> 如何读写其中的数据。

了解流转过程后,还需要留意赋值的作用范围:子异步方法中重新设置 AsyncLocal<T>.Value,调用方恢复执行后看到的值可能保持不变;当值是引用类型时,多个上下文也可能指向同一个对象。文章会分别用示例和图解释这些行为。

本文的源码分析基于 .NET 11.0.0 ,重点介绍上下文流动及其与 AsyncLocal<T> 的关系。.NET 11 还优化了异步执行和部分上下文处理路径,文末会结合 .NET 10 与 .NET 11 的实测结果讨论这些变化。文中的私有字段和内部优化用于帮助理解实现,不属于公共 API 契约。

ExecutionContext 的作用

应用经常需要在一段调用链中访问一些环境数据,例如当前语言和地区设置、请求追踪信息,或者由 AsyncLocal<T> 保存的请求标识。这些数据可能被日志、诊断组件和业务代码共同读取,但我们未必希望把它们作为参数层层传递。

同步代码一直运行在同一个线程时,线程本地存储可以为每个线程维护一份数据。异步代码则会遇到两个问题:

  1. 一个逻辑操作可以在某次 await 后由另一个线程继续执行。
  2. 同一个线程可以先后执行多个互不相关的逻辑操作。

假设一次请求进入应用后生成了请求标识,后续方法需要用它关联日志。即使处理过程切换了线程,我们也希望这条请求的后续代码仍能读取到原来的标识,同时不影响其他请求。

AsyncLocal<T> 为这种数据提供了读写方式:调用方设置值,后续代码可以读取当前逻辑流程中的值。那么,这个值保存在哪里,又是怎样传递给后续代码的?

这就需要了解 ExecutionContext:它保存需要随逻辑执行流程 传播的环境数据,其中包括 AsyncLocal<T> 的值。运行时和相关 API 会在需要传播的执行边界保存当前上下文,并在后续代码执行时恢复它。例如,读取 AsyncLocal<T>.Value 时,运行时会从当前线程的 ExecutionContext 中查找对应的值。

下面先以 Task 的执行为例,看看执行上下文如何从调用方传递到后续执行的代码:

图中的任务或延续对象用于说明 Task 相关执行过程;线程启动辅助对象和线程池回调的包装对象也可能保存捕获的上下文。同步完成、普通 Task、编译器生成的状态机和 .NET 11 的 Runtime Async 使用的内部路径并不完全相同。一个等待中的异步操作也不一定会创建一个新的 Task;不过,等待 I/O 或等待调度期间,通常不需要为该操作保留一个专属的托管线程。

机制 数据或职责 关注点
[ThreadStatic]、ThreadLocal<T> 每个物理线程各自保存数据 当前是哪个线程
AsyncLocal<T> 通过当前 ExecutionContext 存取环境值 当前逻辑流程看到什么值
ExecutionContext 保存并传播环境数据 代码执行时使用哪份逻辑环境

ExecutionContext 里的数据可以包含通过 AsyncLocal<T> 维护的状态,当前语言和地区设置以及一些框架诊断信息也会依赖这类环境数据。具体有哪些数据取决于应用和框架,不要把它理解为整个线程、调用栈或所有局部变量的快照。

上下文流动示例

先看一个请求标识如何随 Task.Run 和 await 流动的例子:

csharp 复制代码
using System;
using System.Threading;
using System.Threading.Tasks;

internal static class Program
{
    private static readonly AsyncLocal<string?> RequestId = new();

    private static async Task Main()
    {
        RequestId.Value = "request-42";

        await Task.Run(() =>
            Console.WriteLine($"Task.Run: {RequestId.Value}"));

        await PrintAfterAwaitAsync();
        Console.WriteLine($"Main: {RequestId.Value}");
    }

    private static async Task PrintAfterAwaitAsync()
    {
        await Task.Delay(10);
        Console.WriteLine($"await 后: {RequestId.Value}");
    }
}

输出:

text 复制代码
Task.Run: request-42
await 后: request-42
Main: request-42

Task.Run 中的委托和 await 之后的代码都能读取请求标识。它们可能运行在不同线程上,也可能恰好复用相同线程;例子的结论不依赖线程 ID。

ExecutionContext 中的数据

从应用的角度看,随执行上下文流动的不只有自己定义的请求标识。语言和地区设置、当前追踪操作、当前主体等信息,也可能由运行时或框架通过这套机制传递。

从 .NET 11 的 ExecutionContext 源码看,它的主要实例字段包括:

字段 作用
m_localValues 以 AsyncLocal 实例为键,保存当前上下文中的值映射。
m_localChangeNotifications 保存需要在上下文切换时检查并通知的 AsyncLocal 实例。
m_isFlowSuppressed 标记当前上下文是否阻止向后续工作传播。
m_isDefault 标记默认上下文。

下表中的数据通常由应用或框架使用各自的 AsyncLocal 保存,再随 ExecutionContext 传播 。它们不是 ExecutionContext 类中分别命名的独立业务字段。

环境数据 .NET 11 中的例子
应用自定义数据 应用定义的 AsyncLocal<T>,例如请求标识或租户标识。
当前语言和地区设置 设置 CultureInfo.CurrentCulture、CurrentUICulture 时,运行时通过内部 AsyncLocal<CultureInfo> 保存,并用通知更新当前线程的相应缓存。
当前追踪操作 Activity.Current 通过内部 AsyncLocal<Activity?> 维护当前操作,可用于关联调用链和诊断数据。
当前主体 Thread.CurrentPrincipal 的当前值通过内部 AsyncLocal<IPrincipal?> 维护;身份和授权的具体行为仍取决于应用及所用框架。
HTTP 请求上下文 ASP.NET Core 的 HttpContextAccessor 通过 AsyncLocal 和 holder 间接保存当前 HttpContext。

即使应用没有显式使用 AsyncLocal<T>,框架组件也可能已经通过它写入上下文。因此,考虑阻止上下文流动或分析其成本时,需要同时考虑应用和依赖库设置的数据。

Activity.Current 与 Tracing 上下文

CultureInfo 表示语言和地区相关的设置:CurrentCulture 影响日期、数字等数据的格式化和解析,CurrentUICulture 用于查找本地化资源。Activity 可以表示一次请求、数据库查询或其他需要追踪的操作。调用 Start() 时,新的 Activity 会参考当前的 Activity.Current 建立父子关系,并成为当前操作。下面的例子展示 CurrentCulture 和当前追踪操作如何流入任务:

csharp 复制代码
using System;
using System.Diagnostics;
using System.Globalization;
using System.Threading.Tasks;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("zh-CN");
using var request = new Activity("http.request").Start();

await Task.Run(() =>
{
    Console.WriteLine($"当前格式化设置: {CultureInfo.CurrentCulture.Name}");
    Console.WriteLine($"任务开始: {Activity.Current?.OperationName}");

    using (var query = new Activity("db.query").Start())
    {
        Console.WriteLine($"当前操作: {Activity.Current?.OperationName}");
        Console.WriteLine($"父操作: {query.Parent?.OperationName}");
    }

    Console.WriteLine($"子操作结束: {Activity.Current?.OperationName}");
});

Console.WriteLine($"任务结束: {Activity.Current?.OperationName}");

输出:

text 复制代码
当前格式化设置: zh-CN
任务开始: http.request
当前操作: db.query
父操作: http.request
子操作结束: http.request
任务结束: http.request

Task.Run 捕获调用方的 ExecutionContext,其中的 Activity.Current 指向 http.request。任务开始时,db.query 因此可以找到这个父操作;子操作结束后,任务中的当前操作恢复为 http.request。调用方在等待结束后也仍然处于自己的 http.request 上下文中。

这里流动的是 Activity 对象的引用,并不会复制整个追踪对象。阻止上下文流动后,后台工作可能读不到当前 Activity,新建操作也就无法自动关联到原来的父操作。在本进程内,Activity.Current 可以随 ExecutionContext 流动;跨进程或服务传递追踪信息时,还需要通过协议携带 Tracing 上下文。

示例直接创建 Activity,以便独立运行。实际埋点中也常通过 ActivitySource.StartActivity 创建操作;没有监听器或采样决定不创建时,该方法可能返回 null,使用时应处理这种情况。

具体实现可参考 CultureInfo.cs、Activity.cs 和 Thread.cs。不同 .NET 版本和平台支持的环境状态可能不同,本文以 .NET 11 的实现为准。

上下文如何流动

在需要传播上下文的场景中,运行时和相关 API 会保存当前环境,并在后续代码执行时恢复。先看上下文如何捕获,以及常见入口各自在什么时候捕获。

捕获当前上下文

在 .NET 11 的实现中,ExecutionContext.Capture() 会读取 Thread.CurrentThread._executionContext:

  • 当前线程的字段为 null 时,公共 Capture() 返回代表默认上下文的实例。
  • 当前上下文已经通过 SuppressFlow() 阻止流动时,公共 Capture() 返回 null。
  • 其他情况下,返回当前上下文的引用。

捕获通常无需深拷贝上下文及其所有值 。运行时可以共享当前上下文引用,在之后更新 AsyncLocal 映射时创建新的映射版本。

常见的传播入口

ExecutionContext 的传播由各个执行入口配合完成,并不限于 Task 。下表列出 .NET 11 中几种常见入口;默认流动的情况都以调用方没有通过 SuppressFlow() 阻止流动为前提。

入口 捕获或使用上下文的时机
new Thread(...).Start() 调用 Start() 时捕获调用方的上下文,在线程入口执行委托时使用。
ThreadPool.QueueUserWorkItem 将回调加入线程池队列时捕获调用方的上下文,执行回调时使用。
Task.Run 创建并安排任务时捕获调用方的上下文,执行委托时使用。
await 需要挂起并恢复时,异步方法保存用于后续执行的上下文;已经完成的操作通常直接继续执行。
Thread.UnsafeStart、ThreadPool.UnsafeQueueUserWorkItem 不自动捕获调用方的 ExecutionContext。
ExecutionContext.Capture 和 Run 调用方可以显式捕获上下文,并在该上下文中运行同步回调。

具体 API 的契约很重要:自定义 awaiter 和其他回调机制可能有自己的捕获与恢复方式;同时,传播也不要求物理线程一定发生切换。

接下来通过两组例子,看捕获后的上下文如何保存和使用。第一组分析新建线程和线程池回调 ,第二组分析 async Task 方法等待任务时的挂起与恢复。

线程与线程池中的上下文流动

新建线程和线程池回调都会在执行入口处处理上下文,但捕获时机和保存上下文的对象不同。本节先用示例对比结果,再分别查看两个入口的处理过程。

新建线程与线程池回调

下面的例子分别使用 Thread 和线程池执行回调,并对比会捕获上下文的入口与不自动捕获的 Unsafe 入口:

csharp 复制代码
using System;
using System.Threading;

internal static class Program
{
    private static readonly AsyncLocal<string?> RequestId = new();

    private static void Main()
    {
        RequestId.Value = "at-construction";
        var thread = new Thread(() => Print("Thread.Start"));

        RequestId.Value = "at-start";
        thread.Start();
        thread.Join();

        var unsafeThread = new Thread(() => Print("Thread.UnsafeStart"));
        unsafeThread.UnsafeStart();
        unsafeThread.Join();

        RunOnThreadPool(flowContext: true);
        RunOnThreadPool(flowContext: false);
    }

    private static void RunOnThreadPool(bool flowContext)
    {
        using var completed = new ManualResetEventSlim();
        WaitCallback callback = _ =>
        {
            Print(flowContext ? "QueueUserWorkItem" : "UnsafeQueueUserWorkItem");
            completed.Set();
        };

        if (flowContext)
            ThreadPool.QueueUserWorkItem(callback);
        else
            ThreadPool.UnsafeQueueUserWorkItem(callback, null);

        completed.Wait();
    }

    private static void Print(string source) =>
        Console.WriteLine($"{source}: {RequestId.Value ?? "<empty>"}");
}

输出:

text 复制代码
Thread.Start: at-start
Thread.UnsafeStart: <empty>
QueueUserWorkItem: at-start
UnsafeQueueUserWorkItem: <empty>

Thread 对象创建时保存了线程入口委托,调用 Start() 时才捕获当前 ExecutionContext,所以新线程看到的是 at-start。QueueUserWorkItem 将回调加入线程池队列时捕获当前上下文,回调随后在线程池线程上执行。

UnsafeStart 和 UnsafeQueueUserWorkItem 不自动捕获提交方的上下文,因此它们的回调没有继承上面设置的值。这里的 Unsafe 指上下文流动的行为,与 C# 的 unsafe 指针语法无关;选择这些 API 前仍需确认语言和地区设置、追踪和日志作用域等环境数据是否应该传播。例子中的 Join 和 ManualResetEventSlim 只用于等待每个演示完成并固定输出顺序。

Thread.Start 的捕获与执行

.NET 11 的 Thread.cs 中,普通 Start() 的执行过程可以概括为:

  1. 调用方在线程启动前执行 ExecutionContext.Capture(),并将结果保存到线程启动辅助对象中。
  2. 新线程启动后,辅助对象取出捕获的上下文,在该环境中调用线程入口委托。
  3. 线程入口委托读取 AsyncLocal.Value 时,通过新线程当前的 ExecutionContext 查找对应的值。

捕获的是调用 Start() 时的环境:在构造 Thread 后、启动前重新赋值,会影响新线程看到的值。启动后调用方再重新赋值,则不会改写已经捕获的映射;如果映射中的值是可变引用对象,对象本身仍可能被共享。

线程启动辅助对象持有捕获的上下文,直到新线程运行入口委托时使用。入口委托结束后,新建线程也随之结束;线程池线程则会复用,因此还需要在执行后清理环境,供后续回调使用。

UnsafeStart() 使用同一类启动流程,但跳过调用方上下文的捕获。这里保存上下文的载体是线程启动辅助对象;在线程池场景中,承担类似职责的是回调包装对象。

线程池回调的上下文捕获

ThreadPool.QueueUserWorkItem 会将回调加入线程池队列,并根据需要保存当前上下文。.NET 11 源码中,WaitCallback 重载的核心流程可以简化为:

csharp 复制代码
ExecutionContext? context = ExecutionContext.Capture();

object workItem = (context == null || context.IsDefault)
    ? new QueueUserWorkItemCallbackDefaultContext(callback, state)
    : new QueueUserWorkItemCallback(callback, state, context);

s_workQueue.Enqueue(workItem, forceGlobal: true);

这里省略了参数验证,名称也做了简化。包装对象会保存回调和状态;非默认上下文也会由相应对象保存。线程池随后从队列中取出该对象,在需要时恢复捕获的上下文并运行回调。默认上下文和阻止流动的情况使用专门的处理路径。

回调读取 AsyncLocal.Value 时,会从执行该回调的线程当前使用的上下文中查找对应的值。回调结束后,线程池会处理执行期间留下的上下文,避免影响该线程随后执行的其他回调。捕获时共享的是上下文及其映射的引用;回调里重新给 AsyncLocal.Value 赋值,不会改写调用方原先的映射。映射中保存的引用类型对象是否共享,则需要另行考虑。

UnsafeQueueUserWorkItem 不调用这一步 Capture(),创建的回调包装对象不会携带调用方的上下文。两种方法都在线程池中执行回调,区别在于加入队列时是否捕获调用方的执行环境。

await Task 时的上下文流转

下面以 .NET 11 中采用传统状态机的 async Task 方法为例,分析它等待一个尚未完成的 Task 时,上下文如何被捕获和保存,以及方法继续执行时如何恢复。这里假设调用方没有阻止上下文流动;延续执行时可能使用原线程,也可能使用其他线程。

异步方法开始执行

调用异步方法后,方法先在当前线程上同步执行,直到完成或遇到需要挂起的 await。传统状态机由 AsyncMethodBuilderCore.Start 启动:它保存调用方的执行上下文,调用状态机的 MoveNext,并在返回时恢复调用方的环境。

因此,即使子异步方法在首次挂起前重新设置了 AsyncLocal<T>.Value,调用方继续执行时仍会使用自己的上下文。如果方法遇到未完成的任务,挂起点的上下文还需要另行保存,供之后恢复执行。

捕获上下文

异步方法执行到 await 时,会先检查被等待的 Task 是否已经完成。如果已经完成,代码通常直接读取结果并继续执行,无需经过一次实际挂起。

如果操作尚未完成,方法需要保存状态并登记延续。对于本文讨论的传统 async Task 状态机,运行时会取得当前执行上下文,供该方法恢复时使用;.NET 11 使用的内部捕获路径会在后文介绍。这里捕获的是等待方执行到挂起点时的环境,与 Task.Run 创建任务时为其委托捕获的环境需要分别理解。

保存上下文并等待

当 await 遇到尚未完成的操作时,等待方会登记延续;操作完成后,延续负责恢复异步方法的执行。等待期间,捕获的上下文需要由某个仍然存活的对象持有。

这里有两个容易混淆的对象:被等待的操作 负责表示什么时候完成;等待它的异步方法的延续 负责在完成后恢复执行。上下文保存在哪里取决于具体实现,不能简单地认为所有异步操作都把上下文放在同一个 Task 字段里。

以 .NET 11 的两个实现为例:

  • Task.Run 创建的普通委托 Task 会在创建时捕获供委托执行的上下文;非默认上下文可保存在 Task.ContingentProperties.m_capturedContext 中,默认上下文和被阻止流动的情况还有单独处理。
  • 等待方在挂起时保存的是自己的上下文。传统 async Task 的 AsyncStateMachineBox<TStateMachine> 继承自 Task<TResult>,它的 Context 属性复用基类 Task.m_stateObject 保存上下文。这个 box 同时承载状态机,并可作为延续的一部分。

相关实现可以分别查看 Task.cs 和 AsyncTaskMethodBuilderT.cs。

操作也可能恰好在完成检查之后、注册延续之前完成,异步基础设施会处理这个竞争。完成后的延续可能内联执行,也可能被安排到合适的调度环境。执行它的线程可以与之前相同,也可以不同。

传统状态机在内部常调用名字带 Unsafe 的 awaiter 路径,以避免由 awaiter 重复捕获上下文;builder 和状态机仍负责所需的 ExecutionContext 流动。不能只凭内部方法名推断 AsyncLocal 会丢失。

恢复上下文并执行

假设延续最终由线程 B 执行。对于需要切换上下文的路径,运行时会先记录线程 B 当前的环境,再让线程 B 使用捕获到的上下文,然后执行委托或状态机的下一段代码。

以 AsyncLocal<T>.Value 的读取为例,其内部会调用 ExecutionContext.GetLocalValue,从当前线程的 _executionContext 查找以该 AsyncLocal 实例为键保存的值。因此,在线程 B 上运行的代码也能看到之前从线程 A 捕获的值。

这里说的"恢复"是将逻辑环境应用到正在执行代码的线程,不是把线程 A 本身移动到线程 B,也不是把线程 A 上所有线程本地数据都复制过来。

执行结束后还原

对于进入捕获上下文的执行路径,回调正常返回或抛出异常后,运行时会恢复执行线程原来的上下文,避免影响之后复用该线程的其他工作。线程池也有针对默认上下文的专门执行和清理路径;流动被抑制时,内联延续还有下文介绍的特殊行为。

概念上可以把它理解为"进入前保存,退出时还原"。.NET 11 的 RunInternal 实现对异常使用 catch 和 ExceptionDispatchInfo,在恢复环境后重新抛出,以便在调用方的异常筛选器运行前完成恢复;源码并非简单地在 finally 中赋值。

任务完成后,运行时也会清理部分不再需要的引用。例如,普通 Task 会清空保存的捕获上下文;传统状态机 box 完成后会清空状态机和上下文引用。这有助于避免已完成但仍被持有的任务继续保留这些数据。其他仍然存活的引用,以及任务结果、闭包或业务对象本身,仍可能让对象保持可达。

AsyncLocal 与写时复制

AsyncLocal<T> 的值如何修改,决定了不同逻辑流程分别能看到什么。下面先观察子异步方法重新赋值后的结果,再分析映射的写时复制和内部结构。

修改子流程中的值

下面通过父子异步方法的例子,观察子方法重新赋值后调用方读到的值:

csharp 复制代码
using System;
using System.Threading;
using System.Threading.Tasks;

internal static class Program
{
    private static readonly AsyncLocal<string?> Name = new();

    private static async Task Main()
    {
        Name.Value = "A";
        var gate = new TaskCompletionSource(
            TaskCreationOptions.RunContinuationsAsynchronously);

        Task child = ChangeAsync(gate.Task);
        Console.WriteLine($"父流程,等待前: {Name.Value}");

        gate.SetResult();
        await child;
        Console.WriteLine($"父流程,等待后: {Name.Value}");
    }

    private static async Task ChangeAsync(Task gate)
    {
        Name.Value = "B";
        Console.WriteLine($"子流程,等待前: {Name.Value}");
        await gate;
        Console.WriteLine($"子流程,等待后: {Name.Value}");
    }
}

输出:

text 复制代码
子流程,等待前: B
父流程,等待前: A
子流程,等待后: B
父流程,等待后: A

ChangeAsync 在第一个未完成的 await 之前就已经把值设成了 B,但调用返回到 Main 时,Main 看到的仍然是 A。传统 async builder 启动状态机时会保存调用方环境,MoveNext 返回后恢复调用方环境;方法挂起后继续执行时,再使用该方法保存的环境。

子流程最初可以和父流程共享同一份上下文。给 AsyncLocal.Value 赋新值时,运行时创建更新后的映射,并将新的上下文关联到当前执行流程。父流程原先持有的映射保持原样,等待子流程完成也不会自动把子流程的赋值合并回来。

这个例子依赖异步方法的上下文保存与还原。普通同步方法调用仍在调用方的当前执行环境中运行 :如果同步方法直接把 Name.Value 从 A 改为 B,方法返回后调用方也会看到 B。

如果子方法需要向调用方返回结果,异步方法可以使用返回值;同步方法也可以按需要使用 out 参数等明确的数据传递方式。

AsyncLocal 赋值与 ExecutionContext 写时复制

通常说的"AsyncLocal 写时复制",指的是给 AsyncLocal.Value 赋值时,ExecutionContext 中的值映射如何更新。AsyncLocal<T> 提供读写入口;实际保存值的是当前上下文中的映射。对于需要更新的值,.NET 11 的处理可以概括为:

  1. 捕获时共享 :调用方捕获当前 ExecutionContext 时,通常保存对当前上下文的引用,无须复制整个映射。
  2. 赋值时更新映射 :AsyncLocal.Value 的 setter 调用 SetLocalValue,运行时基于原映射生成包含新值的映射;原映射保持不变。
  3. 切换当前上下文:运行时创建包含新映射的上下文,并把它关联到当前执行线程。特殊情况下会复用已有表示,或使用默认空上下文。
  4. 后续执行与还原:当前流程继续使用新上下文;保存旧上下文的其他流程仍可使用旧版本。对于进入捕获上下文的执行路径,退出时相关基础设施会恢复之前保存的上下文。

假设父流程设置了两个 AsyncLocal,然后启动子流程;子流程只修改 Name:

时机 父流程使用的上下文 子流程使用的上下文
子流程开始 Context A:Name = A,TraceId = T 共享 Context A
子流程将 Name 设为 B Context A:Name = A,TraceId = T Context B:Name = B,TraceId = T
子流程结束后 仍为 Context A 子流程结束

TraceId 仍然保留在新映射中;更新 Name 不会改写旧映射中的条目。父流程和子流程可以因此看到不同的 Name 值。捕获、进入或退出上下文时主要是在保存或切换上下文引用;当上下文带有变化通知时,切换还可能执行通知回调。

"写时复制"描述的是隔离效果,并不要求每次赋值都完整复制一个普通字典。运行时会根据条目数量使用不同的映射实现;相同引用的赋值可能直接返回,空映射和被阻止流动的上下文也有特殊表示。映射中的引用类型值仍指向原对象,修改对象属性的行为见下文。

AsyncLocal 映射的内部结构

AsyncLocal<T> 实例本身充当映射的键,其 Value getter 和 setter 分别委托给 ExecutionContext.GetLocalValue 和 SetLocalValue。在 .NET 11 中,当前上下文的 m_localValues 通过 IAsyncLocalValueMap 保存这些键值;运行时根据条目数量选择不同的内部映射实现,小映射使用专门的存储形状。

IAsyncLocalValueMap 暴露的核心操作是查询 TryGetValue 和更新 Set。Set 返回更新后的映射,由调用方决定如何使用;已有映射要能继续供其他上下文使用。.NET 11.0.0 的常见映射形状如下:

条目数 实现类型 存储方式
0 EmptyAsyncLocalValueMap 空映射,使用共享实例。
1 至 4 OneElementAsyncLocalValueMap 至 FourElementAsyncLocalValueMap 分别用专用字段保存键值对。
5 至 16 MultiElementAsyncLocalValueMap 使用键值对数组。
17 及以上 ManyElementAsyncLocalValueMap 使用基于字典的映射。

例如,四条有效数据的映射增加第五条时,会创建数组形式的 MultiElementAsyncLocalValueMap;十六条增加到十七条时,会创建 ManyElementAsyncLocalValueMap。移除条目并跨过相应阈值时,运行时可以切换回更小的表示。这里的条目指内部映射实际保留的键值对,是否保留 null 还取决于是否需要变化通知。

更新已有键不会增加条目数。对于需要修改的值,映射返回包含新值的版本;被修改的 AsyncLocal 以外的键值对仍然保留。某些不需要改变内容的操作可以复用原映射。

当值发生变化时,SetLocalValue 根据原映射生成更新后的映射,再为当前执行流程关联相应的 ExecutionContext。映射里保存的引用仍指向原来的对象,运行时不会深拷贝这些对象。没有启用流动抑制且映射为空时,当前线程上的 null 上下文可以表示默认环境。

null 值还有一个与通知有关的实现细节:对于无需通知的 AsyncLocal,存入 null 通常可以视为从映射移除;对于已经注册变化回调的实例,运行时可能保留值为 null 的条目,以记录通知注册状态。相同引用赋值会提前返回,所以"每次赋值都创建新上下文或触发通知"并不成立。这些都是内部实现细节,不应依赖映射的具体类型或条目数设计应用逻辑。

AsyncLocal 的常见使用陷阱

了解写时复制之后,还需要区分重新赋值和修改共享对象:两者会影响调用方能否看到变化。共享可变对象时,还需要考虑并发访问和对象生命周期。本节通过示例梳理这些差异,并介绍 HttpContextAccessor 的特殊处理。

子异步方法的赋值不会自动传回调用方

前面的 ChangeAsync 示例中,子方法将 Name.Value 从 A 改为 B,调用方等待结束后仍读到 A。AsyncLocal<T> 适合向后续流程传递环境数据,不能依靠子异步方法中的重新赋值向调用方返回结果。

如果结果需要交给调用方,使用方法返回值可以明确表达这一关系:

csharp 复制代码
static async Task<string> GetNameAsync()
{
    await Task.Delay(10);
    return "B";
}

string name = await GetNameAsync(); // B

如果多个流程确实需要观察同一个对象的变化,可以考虑下面的引用共享方式,同时处理好对象的并发访问和生命周期。

共享引用不受赋值隔离保护

写时复制保护的是上下文中 AsyncLocal 到值的映射。如果值是引用类型,对象本身不会随上下文捕获被深拷贝:

csharp 复制代码
private sealed class Holder
{
    public string? Value { get; set; }
}

private static readonly AsyncLocal<Holder?> Current = new();

private static async Task ExampleAsync()
{
    Current.Value = new Holder { Value = "A" };

    await Task.Run(() => Current.Value!.Value = "B");

    Console.WriteLine(Current.Value!.Value); // B
}

父子流程中的映射都指向同一个 Holder。子流程修改的是共享对象的属性,因此父流程也能观察到修改。

通过 holder 间接保存值,可以让多个执行流程观察到同一个对象的属性修改。它同时引入了共享可变状态:不同执行流程并发访问同一个可变对象,且至少有一方修改时,应按该对象的线程安全要求同步。创建 holder 的时机也很重要,只有已经持有同一个实例的流程才会共享后续修改。

ASP.NET Core 的 HttpContextAccessor 也使用了类似的间接引用。设置 HttpContext 时,accessor 先清空旧 holder 中的 Context,使持有该旧 holder 的执行上下文也能观察到清除。如果新值非空,accessor 再创建新 holder 并赋给当前 AsyncLocal。

因此,新 HttpContext 不会自动成为其他旧上下文中的值。HttpContext 本身也不应被当作可供任意后台任务长期持有、并发访问的对象。

以父子异步流程为例,假设父流程先把 HttpContext 设为 A,子流程再把它设为 B:

  1. 父子流程开始时都引用保存 A 的旧 holder。
  2. 子流程设置 B 时,先把旧 holder 的 Context 清空,再为 B 创建新 holder,并将新 holder 赋给子流程的 AsyncLocal。
  3. 子流程可以读到 B;父流程恢复后仍引用旧 holder,因此通过 accessor 读到的是 null。

这说明旧值的清除可以通过共享 holder 被其他上下文观察到 ,而新 holder 的赋值只属于当前逻辑流程。这是 accessor 为管理 HttpContext 生命周期采用的设计,不代表任意共享对象都具有自动清理或线程安全能力。

几个容易混淆的修改

同样是"在子流程里修改数据",写法不同,外层看到的结果也不同:

子流程中的操作 父流程等待完成后 原因
Name.Value = "B" 通常仍读到原来的 "A" 子流程产生新的上下文映射,父流程恢复自己的上下文。
Current.Value!.Value = "B" 如果双方引用同一个 holder,可读到 "B" 修改的是共享对象的属性,没有替换 AsyncLocal 中的 holder 引用。
Current.Value = new Holder { Value = "B" } 仍指向父流程自己的 holder 给 AsyncLocal 重新赋值会更新子流程的映射。
子流程第一次创建 holder 并赋给 Current.Value 父流程不会自动得到新 holder holder 必须先被两个流程持有,后续属性修改才会共享。

普通同步方法直接赋值是另一种情况:调用和返回都发生在同一逻辑执行环境中,调用方会看到这个赋值。需要向调用方返回结果时,优先使用返回值;共享 holder 适合确实需要共享状态的场景,并要考虑并发访问和对象生命周期。

值变化通知

AsyncLocal<T> 的构造函数可以接收变化回调。显式设置 Value 时可能触发回调;进入或离开一个上下文时,如果该 AsyncLocal 在切换前后保存的值引用不同,也可能触发回调。回调参数中的 ThreadContextChanged 用于区分后者。这里比较的是底层保存的对象引用;对于值类型,赋值时的装箱也可能产生新的引用。

下面用同步的 Capture 和 Run 将这两类通知区分开:

csharp 复制代码
using System;
using System.Threading;

var local = new AsyncLocal<string?>(change =>
    Console.WriteLine($"{change.PreviousValue ?? "<null>"} -> " +
        $"{change.CurrentValue ?? "<null>"}; " +
        $"上下文切换: {change.ThreadContextChanged}"));

local.Value = "A";
ExecutionContext captured = ExecutionContext.Capture()!;
local.Value = null;

ExecutionContext.Run(captured, _ => local.Value = "B", null);

在默认允许流动的情况下,输出为:

text 复制代码
<null> -> A; 上下文切换: False
A -> <null>; 上下文切换: False
<null> -> A; 上下文切换: True
A -> B; 上下文切换: False
B -> <null>; 上下文切换: True

两次显式赋值先产生 False 通知;Run 进入捕获的上下文时,当前值从 null 变为 A,产生 True 通知。回调中把值改为 B 后,Run 退出并恢复原上下文,当前值又从 B 变为 null,产生另一次 True 通知。这里的变化描述的是当前执行环境切换时观察到的值,并不意味着已经清空了捕获的上下文或其他流程中的值。

线程池中的回调也可能在进入和退出上下文时产生类似通知,具体次数和顺序取决于调度及上下文变化。通知回调会增加上下文切换的工作量,适合保持简短,并避免依赖特定线程或固定调用次数。尤其要避免抛出异常:.NET 11 在处理上下文切换通知时若回调抛异常,会调用 Environment.FailFast 终止进程 。显式设置 Value 触发的通知有所不同:异常会传播给赋值方,此时新值已经写入,不会自动回滚。

控制上下文流动

大多数时候,运行时会自动处理上下文传播。需要控制传播范围或自行运行回调时,可以使用 SuppressFlow,或者显式调用 Capture 和 Run。下面分别介绍这些 API 的作用和使用限制。

SuppressFlow 的使用范围

少数情况下,调用方明确不需要把环境数据传给新启动的任务,可以使用 ExecutionContext.SuppressFlow():

csharp 复制代码
private static readonly AsyncLocal<string?> RequestId = new();

private static async Task ExampleAsync()
{
    RequestId.Value = "request-42";

    Task background;
    using (ExecutionContext.SuppressFlow())
    {
        background = Task.Run(() =>
            Console.WriteLine(RequestId.Value ?? "<empty>"));
    }

    await background;
    Console.WriteLine(RequestId.Value); // request-42
}

示例中的后台任务输出 <empty>,调用方恢复流动后仍可读取自己的值。阻止流动只改变之后捕获上下文时的行为,不会清除调用方当前已经设置的数据。

在抑制范围内通过 Task.Run 启动的任务不会捕获调用方上下文,但任务之后仍可传播自己的上下文。

例如,A 在阻止流动的范围内启动任务 B,B 不会继承 A 的请求标识。如果 B 随后设置自己的 AsyncLocal 值,并且启动任务 C 时未阻止流动,C 仍然可以继承 B 设置的值。

下面用三个线程展示这个边界:

csharp 复制代码
using System;
using System.Threading;

internal static class Program
{
    private static readonly AsyncLocal<string?> Value = new();

    private static void Main()
    {
        Value.Value = "A";

        var threadB = new Thread(() =>
        {
            Console.WriteLine($"线程 B: {Value.Value ?? "<empty>"}");
            Value.Value = "B";

            var threadC = new Thread(() =>
                Console.WriteLine($"线程 C: {Value.Value}"));
            threadC.Start();
            threadC.Join();
        });

        using (ExecutionContext.SuppressFlow())
        {
            threadB.Start();
        }

        threadB.Join();
        Console.WriteLine($"线程 A: {Value.Value}");
    }
}

输出:

text 复制代码
线程 B: <empty>
线程 C: B
线程 A: A

线程 A 启动 B 时阻止了自己的上下文流动,因此 B 初始时读不到 A 的值。B 设置自己的值后,再正常启动 C;C 捕获的是 B 当前的上下文。A 退出 using 后恢复了流动,自己的值始终保持为 A。

AsyncFlowControl 应在创建它的线程上恢复,恢复时当前上下文也必须仍处于阻止流动的状态。不要在 using (ExecutionContext.SuppressFlow()) 范围内跨越可能挂起的 await。延续可能在另一个线程执行,导致恢复失败。即使回到原线程,抑制状态也可能没有随延续流动,恢复仍可能失败。

先同步启动任务并退出 using,再等待任务。

不流动上下文也意味着后台工作可能失去当前语言和地区设置、追踪信息、日志作用域或其他由框架存放的环境数据。只有在明确理解这些影响时才应使用。传给后台任务的业务数据可以通过参数或不可变对象显式提供。

流动被抑制时的内联延续

抑制流动后,后续延续不会获得发起方捕获的上下文。在 .NET 11 的传统状态机路径中,如果操作完成时延续直接在完成操作的线程上内联执行,延续可能看到该线程当时的上下文;在这段延续中重新设置 AsyncLocal,也可能影响完成操作的代码。

下面使用默认的 TaskCompletionSource,展示一次发生内联执行的情况:

csharp 复制代码
using System;
using System.Threading;
using System.Threading.Tasks;

var value = new AsyncLocal<string?>();
var gate = new TaskCompletionSource();

async Task ChildAsync()
{
    await gate.Task;
    Console.WriteLine($"子方法恢复后: {value.Value}");
    value.Value = "child";
}

Task child;
using (ExecutionContext.SuppressFlow())
{
    child = ChildAsync();
}

value.Value = "caller";
gate.SetResult();
Console.WriteLine($"SetResult 返回后: {value.Value}");
await child;

在本文的 .NET 11 测试环境中,输出为:

text 复制代码
子方法恢复后: caller
SetResult 返回后: child

这里的结果依赖延续在 SetResult() 中内联执行,不能推广到所有调度路径。设置 TaskCreationOptions.RunContinuationsAsynchronously 会改变该示例的延续安排;因此,不要把 SuppressFlow() 当作保证空上下文或隔离可变状态的手段。需要隔离的数据应通过明确的参数、返回值或其他同步方式处理。

显式捕获和运行

ExecutionContext.Capture() 取得当前逻辑环境的快照,供之后使用;ExecutionContext.Run(context, callback, state) 则在指定上下文中同步执行一次回调 。调用 Run 的线程仍然是原来的线程,运行时会先保存该线程当前的上下文,执行回调,最后恢复进入前的上下文。

例如,先捕获值为 A 的上下文,再把当前值改为 B,最后在捕获的上下文中运行回调:

csharp 复制代码
using System;
using System.Threading;

var value = new AsyncLocal<string?>();
value.Value = "A";
ExecutionContext captured = ExecutionContext.Capture()
    ?? throw new InvalidOperationException("无法捕获执行上下文");

value.Value = "B";
Console.WriteLine($"Run 之前: {value.Value}");

try
{
    ExecutionContext.Run(captured, _ =>
    {
        Console.WriteLine($"进入回调: {value.Value}");
        value.Value = "C";
        Console.WriteLine($"修改之后: {value.Value}");
        throw new InvalidOperationException("回调失败");
    }, null);
}
catch (InvalidOperationException)
{
    Console.WriteLine($"捕获异常: {value.Value}");
}

Console.WriteLine($"Run 之后: {value.Value}");

输出:

text 复制代码
Run 之前: B
进入回调: A
修改之后: C
捕获异常: B
Run 之后: B

回调开始时使用捕获的 A;回调修改出自己的上下文后看到 C。即使回调抛出异常,Run 在异常继续向外传播前也会恢复调用方原来的 B。这里的捕获仍然是浅层逻辑快照:如果上下文里保存的是可变对象引用,对象自身并不会被 Capture() 复制。

Capture() 在流动被阻止时可能返回 null;公共 Run 需要非空上下文。示例选择在无法捕获时抛出异常,实际基础设施也可以根据自身契约选择其他处理方式。Run 不创建线程、不排队工作,也不等待异步操作结束 。它接收的是同步 ContextCallback;不要把 async lambda 当成可以由 Run 等待完成的异步入口。

业务代码通常可以依赖线程、线程池和异步基础设施处理上下文;显式 Capture 和 Run 更适合需要自行管理回调环境的底层组件。

.NET 11 的变化

与本文相关的变化可以分为三点:

  1. 传统状态机的上下文捕获 :即使应用没有启用 Runtime Async,async Task 状态机也会使用新的内部 CaptureForSuspension 路径,以更直接的表示处理默认上下文和流动抑制状态。
  2. 可选的 Runtime Async:应用可以为符合条件的异步方法启用新的编译路径,由运行时和 JIT 承担更多挂起、恢复和延续管理工作。部分 .NET 11 框架库已经采用这一方式。
  3. Runtime Async 延续的上下文处理:对于不需要独立保存上下文的特定内部延续,运行时可以省去相应的保存与恢复工作;需要上下文的其他延续仍负责正确传播。

下面分别查看实现和适用条件,再通过基准观察相关操作的实际表现。

不启用 Runtime Async:传统状态机

应用不启用 Runtime Async 时,编译器仍然生成传统状态机,但它调用的是 .NET 11 的运行时库,因此仍能使用库和运行时本身的改进。以 async Task 的状态机 box 为例,对比 .NET 10.0.7 和 .NET 11.0.0 的 GetStateMachineBox:

csharp 复制代码
// .NET 10:获取公共 Capture() 的表示
ExecutionContext? currentContext = ExecutionContext.Capture();

// .NET 11:为挂起场景获取内部表示
ExecutionContext? currentContext =
    ExecutionContext.CaptureForSuspension(Thread.CurrentThread);

这是分别来自 .NET 10 的状态机 builder 和 .NET 11 的状态机 builder 的对应语句。两段代码属于不同版本,这里放在一起只为对照。

CaptureForSuspension 是内部方法,它直接读取当前线程的上下文,避免默认上下文在内部经过 null → Default → null 的表示转换。其核心逻辑可以简化为:

csharp 复制代码
// .NET 11 内部逻辑,省略断言
ExecutionContext? context = currentThread._executionContext;
if (context?.m_isFlowSuppressed == true)
{
    context = DefaultFlowSuppressed;
}

return context;

公共 Capture() 的契约仍然是:默认上下文返回 Default,阻止流动时返回 null。内部挂起路径则用线程字段中的 null 表示默认上下文,用特殊的 DefaultFlowSuppressed 表示阻止流动;状态机恢复时会识别该特殊表示。

这项变化不需要应用开启 Runtime Async,也不改变应用对 Capture() 返回值的理解。它说明 .NET 11 调整了状态机内部的上下文表示和处理路径;默认上下文并不意味着所有捕获或恢复代码都被完全绕过。具体的耗时影响要结合调用路径测量。

Runtime Async

传统 C# async 方法由编译器生成状态机,运行时通过 builder、状态机 box 和延续恢复执行。.NET 11 引入了 Runtime Async:对符合条件且启用该功能的方法,由运行时和 JIT 承担更多挂起、恢复和延续管理工作。

应用代码可在项目文件中选择启用:

xml 复制代码
<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>
  <Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

.NET 11 的部分框架库已经采用该路径。微软的运行时文档仍将 Runtime Async 列为预览功能,但面向 net11.0 的项目启用时不再需要设置 EnablePreviewFeatures。两种实现应保持本文涉及的 AsyncLocal 和上下文流动语义一致;字段位置、对象形状和诊断堆栈等实现细节则可能不同。

Runtime Async:延续的上下文处理

Runtime Async 会将异步方法的执行组织为延续链。不同延续承担的工作不同:有些延续需要恢复自己的执行上下文,有些内部辅助延续本身不需要单独持有上下文。

一个具体例子是 dotnet/runtime#128323:用于处理 IValueTaskSource 的内部辅助延续不需要自己重复保存和恢复 ExecutionContext,因此移除了这份冗余工作。该 PR 中的类型名是 ValueTaskContinuation;在 .NET 11 正式版源码中,对应类型名为 ValueTaskSourceContinuation。

在 .NET 11 的延续分发代码中,可以看到下面的判断:

csharp 复制代码
if (curContinuation.TryGetExecutionContext(out ExecutionContext? execContext))
{
    RestoreExecutionContext(awaitState.CurrentThread, execContext);
}

这里判断的是该延续是否有用于保存 ExecutionContext 的位置 。如果没有,分发器跳过该延续的恢复步骤;如果有,就根据保存的信息恢复。其他需要上下文的延续仍然承担相应责任,AsyncLocal 的可观察流动语义保持不变。

这与"应用没有声明 AsyncLocal,所有 await 就跳过全部上下文工作"是两回事。框架自身也可能使用 AsyncLocal,而运行时能跳过多少工作取决于具体路径。微软的 .NET 11 运行时更新说明也介绍了相关优化;下面的基准测量几种常见操作,不能单独量化这个内部 IValueTaskSource 延续优化。

Benchmark:.NET 10 与 .NET 11

本机跨版本测试中,.NET 11 的 AsyncLocal 赋值和几个异步操作的平均耗时低于 .NET 10;上下文捕获与 Run 的均值接近。

另一组测试复现了微软官方博客的两层异步调用。在同步完成和 Task.Yield() 两组中,Runtime Async 的平均耗时和分配量均低于传统状态机。

两组测试中的异步结果存在波动,不能将差异直接归因于某项上下文优化。

下面给出测试代码、环境和完整数据,再复现微软文章中传统状态机与 Runtime Async 的对比。

测试内容

为了观察版本差异,下面分别测量当前上下文捕获、AsyncLocal 赋值、ExecutionContext.Run、Task.Run 和 Task.Yield。这些操作覆盖的范围不同:同步的 Capture、Set、Run 主要观察上下文相关代码;异步方法的测量还包含任务创建、线程池调度、延续和基准框架等待结果的开销。

基准项目面向两个运行时,使用下面的项目配置:

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net10.0;net11.0</TargetFrameworks>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.2" />
  </ItemGroup>
</Project>

测试代码如下:

csharp 复制代码
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);

[MemoryDiagnoser]
public class ExecutionContextBenchmarks
{
    private static readonly AsyncLocal<string?> Ambient = new();
    private static readonly Action Noop = static () => { };
    private static readonly Action ReadAmbient = static () => _ = Ambient.Value;
    private static readonly ContextCallback ReadInContext = static _ => _ = Ambient.Value;
    private static readonly string[] Values = ["first", "second"];
    private int _index;
    private ExecutionContext _captured = null!;

    [GlobalSetup]
    public void Setup()
    {
        Ambient.Value = Values[0];
        _captured = ExecutionContext.Capture()!;
        Ambient.Value = null;
    }

    [Benchmark]
    public ExecutionContext? CaptureCurrent() => ExecutionContext.Capture();

    [Benchmark]
    public void SetAsyncLocal()
    {
        _index ^= 1;
        Ambient.Value = Values[_index];
    }

    [Benchmark]
    public void RunCaptured() => ExecutionContext.Run(_captured, ReadInContext, null);

    [Benchmark]
    public Task TaskRunWithoutAsyncLocal() => Task.Run(Noop);

    [Benchmark]
    public async Task TaskRunWithAsyncLocal()
    {
        Ambient.Value = Values[0];
        try
        {
            await Task.Run(ReadAmbient);
        }
        finally
        {
            Ambient.Value = null;
        }
    }

    [Benchmark]
    public async Task AwaitYield()
    {
        await Task.Yield();
    }
}

CaptureCurrent 测量准备阶段清除该业务 AsyncLocal 后的当前上下文捕获成本。框架仍可能设置自己的环境数据,因此该测试并不涵盖所有上下文形态。

SetAsyncLocal 在两个已有字符串之间切换,避免连续写入相同引用。RunCaptured 使用准备阶段捕获的非空上下文,回调读取其中的值。这两个测试使用的是单个未注册变化通知的业务 AsyncLocal。

TaskRunWithAsyncLocal 包含设置、读取和清除 AsyncLocal 的工作。因此,不能把它与无业务 AsyncLocal 的 TaskRunWithoutAsyncLocal 之差简单当作纯粹的捕获成本。

测试环境与运行方式

  • 系统:macOS 27.2.1,Arm64,Workstation GC;Apple M2 Max,12 核(8 个性能核、4 个能效核),64 GB 内存。型号与核心数通过 system_profiler 另行确认;基准进程在沙箱中无法读取 sysctl 硬件信息,因此原始报告显示 Unknown processor。
  • SDK:11.0.100;运行时分别为 .NET 10.0.7 和 .NET 11.0.0。
  • BenchmarkDotNet:0.16.0-preview.2;每项启动 2 次,每次预热 5 轮、测量 8 轮,每轮 300 ms。
  • 测试项目同时面向 net10.0 和 net11.0,未给应用项目启用 runtime-async=on。.NET 11 的框架库内部仍可能使用自身的 Runtime Async 编译路径。
  • 测试时进程无法设置高优先级;结果会受线程池、系统负载、JIT 和基准框架影响。

在 benchmark 项目目录中,一次运行两个运行时的测试:

bash 复制代码
dotnet run -c Release -f net11.0 -- --job short --launchCount 2 --warmupCount 5 --iterationCount 8 --iterationTime 300 --runtimes net10.0 net11.0 --filter '*'

--runtimes 让 BenchmarkDotNet 为两个目标分别构建并执行基准,再输出一份对照报告;SDK 负责构建,实际执行使用报告中列出的运行时。确保命令使用的 dotnet 安装中同时存在两个目标运行时。此次选择预览版是因为测试时使用的稳定版 0.15.8 尚不能识别 .NET 11。

实测结果

这次测试中,.NET 11 的 AsyncLocal 赋值均值低于 .NET 10,CaptureCurrent 和 RunCaptured 的均值接近;几个异步测试的均值也更低,但波动较大,不能把差异单独归因于上下文优化。下面先看完整数据,再分析这些结果的适用范围。

耗时为 BenchmarkDotNet 的平均值及 99.9% 置信区间半宽,单位为 ns;分配量为每次操作报告的托管分配字节数。百分比以同一方法的 .NET 10 平均耗时为基准,数值越低表示本次测量越快。

测试方法 .NET 10 平均耗时 .NET 11 平均耗时 .NET 11 / .NET 10 分配量:10 / 11
CaptureCurrent 1.649 ± 0.007 1.659 ± 0.005 100.6% 0 / 0 B
SetAsyncLocal 18.145 ± 0.145 15.680 ± 0.199 86.4% 72 / 72 B
RunCaptured 7.915 ± 0.059 7.983 ± 0.397 100.9% 0 / 0 B
TaskRunWithoutAsyncLocal 596.426 ± 93.752 253.086 ± 10.438 42.4% 64 / 64 B
TaskRunWithAsyncLocal 730.144 ± 62.890 411.484 ± 45.573 56.4% 305 / 309 B
AwaitYield 514.667 ± 221.770 299.132 ± 30.053 58.1% 96 / 96 B

下图标出了平均耗时;误差范围见上表。两张图的纵轴刻度不同,以便看清各自量级。

表中的百分比是两个报告均值直接相除;BenchmarkDotNet 原始报告的 Ratio 是对样本比例的统计,两者在波动较大时可能不同。CaptureCurrent 和 RunCaptured 的均值相近,SetAsyncLocal 在本次测量中有所下降。异步项目中的均值差异更大,但不能把它们全部归因于 ExecutionContext:这些方法还覆盖调度、延续、不同版本的运行时与框架代码,以及 BenchmarkDotNet 的异步测量路径。

尤其需要注意,.NET 11 的 RunCaptured 以及两个运行时的 TaskRunWithAsyncLocal 被标记为双峰分布;.NET 10 的 AwaitYield 均值为 514.667 ns,99.9% 置信区间半宽达到 221.770 ns。不同系统负载和线程池状态可能显著改变结果。分配数字也受异步执行与诊断方式影响。这里是当前机器上的微基准快照,不能据此推断实际应用一定提升相同比例,更不能用它证明某一个内部延续优化单独带来了这些收益。

.NET 11:Runtime Async 补充测量

在同一台机器上,另外单独运行了一次启用 runtime-async=on 的 .NET 11 测试。以下只列出含异步方法的项目,作为探索性观察:

测试方法 默认路径均值 ± 误差 启用 Runtime Async 均值 ± 误差 分配量:默认 / 启用
TaskRunWithAsyncLocal 411.484 ± 45.573 ns 415.640 ± 14.224 ns 309 / 278 B
AwaitYield 299.132 ± 30.053 ns 271.512 ± 9.898 ns 96 / 48 B

两组来自先后独立运行,基准框架生成的代码也可能受编译选项影响;启用组的两个方法都被标记为双峰分布。它们适合观察待深入调查的差异,不能作为单独验证 Runtime Async 收益的受控实验。

在 macOS 或 Linux 上,可以使用下面的命令让 MSBuild 为测试项目和基准生成项目启用该编译特性:

bash 复制代码
Features=runtime-async=on dotnet run -c Release -f net11.0 -- --job short --launchCount 2 --warmupCount 5 --iterationCount 8 --iterationTime 300 --filter '*'

该命令针对当前进程及其子进程设置 Features,因此基准生成代码也可能改变;做更严格的对比时,应固定测试框架与运行时,并设计只改变被测方法编译方式的实验。

复现微软官方博客的 Runtime Async 基准

本机复现的结论与微软官方博客一致:在相同的 .NET 11 运行时版本下,针对同步完成和 Task.Yield() 挂起的两层异步调用,Runtime Async 的平均耗时和托管分配均低于传统状态机。

其中 RuntimeYielding 的结果出现双峰分布,具体提升幅度需要结合误差和测试环境看待。

微软 .NET 官方博客中,Stephen Toub 的文章 Performance Improvements in .NET 11 对比了同一 .NET 11 运行时中的两种异步实现:一组等待已经完成的任务,另一组通过 Task.Yield() 发生异步挂起。每组都有外层和内层两个异步方法。下面使用相同的实验结构在本机复现;前面的跨版本测试则回答 .NET 10 和 .NET 11 在常见操作上如何表现,是另一个问题。

场景 编译方式 平均耗时 ± 误差 每次托管分配
同步完成 传统状态机 21.573 ± 0.166 ns 144 B
同步完成 Runtime Async 6.960 ± 0.038 ns 0 B
Task.Yield() 挂起 传统状态机 550.152 ± 41.414 ns 245 B
Task.Yield() 挂起 Runtime Async 345.636 ± 20.149 ns 168 B

上表误差是 BenchmarkDotNet 报告的 99.9% 置信区间半宽。微软文章对应的平均耗时约为 21.2 / 6.15 ns 和 254 / 117 ns,分别对应传统状态机 / Runtime Async。本机结果方向相同,数值和比例不同:机器、系统、基准框架版本及调度状态都可能影响结果。RuntimeYielding 在本次运行中被标记为双峰分布,进程也无法设置高优先级。

两组方法位于同一个测试项目,使用相同的 .NET 11 运行时版本;BenchmarkDotNet 会为各测试方法启动独立进程。项目启用 Runtime Async,再通过编译器识别的 RuntimeAsyncMethodGeneration(false) 为传统方法单独关闭该生成方式。这个特性是实验性的编译器识别约定,不是公开的运行时 API。本机检查编译结果时,传统方法带有生成的状态机标记,Runtime Async 方法使用新的 async 方法标记。

新建独立项目,配置如下:

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <Features>$(Features);runtime-async=on</Features>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.2" />
  </ItemGroup>
</Project>

完整测试代码如下:

csharp 复制代码
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);

[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
public class RuntimeAsyncBenchmarks
{
    private static readonly Task<int> Completed = Task.FromResult(42);

    [Benchmark(Baseline = true), BenchmarkCategory("Completed")]
    public Task<int> ClassicCompleted() => ClassicCompletedOuter();

    [Benchmark, BenchmarkCategory("Completed")]
    public Task<int> RuntimeCompleted() => RuntimeCompletedOuter();

    [Benchmark(Baseline = true), BenchmarkCategory("Yielding")]
    public Task<int> ClassicYielding() => ClassicYieldingOuter();

    [Benchmark, BenchmarkCategory("Yielding")]
    public Task<int> RuntimeYielding() => RuntimeYieldingOuter();

    [RuntimeAsyncMethodGeneration(false)]
    private static async Task<int> ClassicCompletedOuter() => await ClassicCompletedInner();

    [RuntimeAsyncMethodGeneration(false)]
    private static async Task<int> ClassicCompletedInner() => await Completed;

    private static async Task<int> RuntimeCompletedOuter() => await RuntimeCompletedInner();

    private static async Task<int> RuntimeCompletedInner() => await Completed;

    [RuntimeAsyncMethodGeneration(false)]
    private static async Task<int> ClassicYieldingOuter() => await ClassicYieldingInner();

    [RuntimeAsyncMethodGeneration(false)]
    private static async Task<int> ClassicYieldingInner()
    {
        await Task.Yield();
        return 42;
    }

    private static async Task<int> RuntimeYieldingOuter() => await RuntimeYieldingInner();

    private static async Task<int> RuntimeYieldingInner()
    {
        await Task.Yield();
        return 42;
    }
}

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Method)]
    internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
    {
        public bool RuntimeAsync => runtimeAsync;
    }
}

本次使用与前一组相同的硬件、SDK、运行时和 BenchmarkDotNet 配置。在该项目目录中运行:

bash 复制代码
dotnet run -c Release -- --job short --launchCount 2 --warmupCount 5 --iterationCount 8 --iterationTime 300 --filter '*'

这组结果体现的是整个两层异步调用路径的差异,不能单独证明某个 ExecutionContext 优化带来的收益。微软文章还展示了部分异常场景下 Runtime Async 较慢的例子,应用升级时仍应使用自己的代表性工作负载测试。

小结

  • 作用 :ExecutionContext 保存随逻辑执行流程传播的环境数据。AsyncLocal<T> 提供读写入口;语言和地区设置、当前主体和 Activity.Current 等框架数据也会依赖执行上下文流动。
  • 与线程的关系:当前执行的代码通过当前线程持有的上下文访问数据。逻辑流程可以跨线程继续,一个线程也可以先后执行不同流程,因此线程身份和逻辑上下文需要分别理解。
  • 传播场景 :Thread.Start、ThreadPool.QueueUserWorkItem、Task.Run 和异步方法恢复执行等场景都可能传播上下文;具体捕获时机和保存对象随 API、执行路径而变化。
  • 捕获、保存和恢复:相关 API 在执行边界捕获调用方环境,由线程启动辅助对象、回调包装对象、任务或延续等对象持有;进入捕获上下文的路径会在执行结束后还原原环境。流动被抑制时的内联延续需要单独考虑。等待期间通常不需要为该操作保留专属线程。
  • 显式控制 :Capture() 获取当前环境,流动被抑制时返回 null;Run() 在指定环境中同步执行回调并在退出时还原。SuppressFlow() 阻止当前执行上下文向后续工作自动流动,作用范围应保持同步,并及时还原。UnsafeStart 和 UnsafeQueueUserWorkItem 不自动传播调用方上下文。
  • 赋值隔离 :设置 AsyncLocal.Value 时,运行时更新当前上下文的值映射;旧映射仍可供其他流程使用。子异步方法对值的重新赋值不会自动合并回调用方,同步方法直接赋值则会影响当前执行环境。
  • 引用共享:写时复制针对映射和上下文,映射中的引用类型值不会因此被深拷贝。多个流程可能访问同一个可变对象,其属性修改和并发访问需要单独设计。
  • 内部映射 :.NET 11 根据 AsyncLocal 条目数量使用不同的 IAsyncLocalValueMap 实现;小映射使用专门的存储形状,条目增多后使用数组或字典。具体类型和阈值属于实现细节。
  • 变化通知 :设置值以及切换上下文都可能触发 AsyncLocal 的变化回调;可以通过回调参数判断是否由上下文切换引起。上下文切换期间的通知回调抛出异常可能导致进程终止,因此回调应尽量简单并妥善处理异常。
  • .NET 11 与基准测试:传统状态机也能使用运行时库的上下文处理改进;Runtime Async 是应用侧可选的另一条编译与执行路径,某些内部延续可以省去自身不需要的上下文保存和恢复。实际收益应以具体工作负载测量,本文结果受调度、系统负载和基准框架影响,不能直接归因于某一项优化。

参考资料