网页 综合 更新时间 2026-11-13 22:13:41

基于 .NET 11 深入理解 AsyncLocal 和 ExecutionContext - 黑洞视界 - 博客园

自动蜘蛛 www.cnblogs.com 正文约 3968 字
基于 .NET 11 深入理解 AsyncLocal 和 ExecutionContext - 黑洞视界 - 博客园
一次请求的处理过程可能跨越多个线程,而同一个线程也可能先后处理不同的请求。如何让请求标识等数据跟随当前的逻辑流程,同时避免在每一层方法之间手动传递?
AsyncLocal<T> 可以在当前逻辑流程中保存数据,让后续代码读取。只要执行上下文正常流动,即使后续代码换了线程,通常也能读取到之前设置的值。
这些数据由 ExecutionContext 维护。在上述场景中,运行时和相关 API 会在适当的时机捕获当前执行环境,并在后续代码执行时恢复它。本文会结合源码梳理上下文的流转过程,以及 AsyncLocal<T> 如何读写其中的数据。
了解流转过程后,还需要留意赋值的作用范围:子异步方法中重新设置 AsyncLocal<T>.Value,调用方恢复执行后看到的值可能保持不变;当值是引用类型时,多个上下文也可能指向同一个对象。文章会分别用示例和图解释这些行为。
本文的源码分析基于 .NET 11.0.0,重点介绍上下文流动及其与 AsyncLocal<T> 的关系。.NET 11 还优化了异步执行和部分上下文处理路径,文末会结合 .NET 10 与 .NET 11 的实测结果讨论这些变化。文中的私有字段和内部优化用于帮助理解实现,不属于公共 API 契约。
应用经常需要在一段调用链中访问一些环境数据,例如当前语言和地区设置、请求追踪信息,或者由 AsyncLocal<T> 保存的请求标识。这些数据可能被日志、诊断组件和业务代码共同读取,但我们未必希望把它们作为参数层层传递。
同步代码一直运行在同一个线程时,线程本地存储可以为每个线程维护一份数据。异步代码则会遇到两个问题:
假设一次请求进入应用后生成了请求标识,后续方法需要用它关联日志。即使处理过程切换了线程,我们也希望这条请求的后续代码仍能读取到原来的标识,同时不影响其他请求。
AsyncLocal<T> 为这种数据提供了读写方式:调用方设置值,后续代码可以读取当前逻辑流程中的值。那么,这个值保存在哪里,又是怎样传递给后续代码的?
这就需要了解 ExecutionContext:它保存需要随逻辑执行流程传播的环境数据,其中包括 AsyncLocal<T> 的值。运行时和相关 API 会在需要传播的执行边界保存当前上下文,并在后续代码执行时恢复它。例如,读取 AsyncLocal<T>.Value 时,运行时会从当前线程的 ExecutionContext 中查找对应的值。
下面先以 Task 的执行为例,看看执行上下文如何从调用方传递到后续执行的代码:
图中的任务或延续对象用于说明 Task 相关执行过程;线程启动辅助对象和线程池回调的包装对象也可能保存捕获的上下文。同步完成、普通 Task、编译器生成的状态机和 .NET 11 的 Runtime Async 使用的内部路径并不完全相同。一个等待中的异步操作也不一定会创建一个新的 Task;不过,等待 I/O 或等待调度期间,通常不需要为该操作保留一个专属的托管线程。
ExecutionContext 里的数据可以包含通过 AsyncLocal<T> 维护的状态,当前语言和地区设置以及一些框架诊断信息也会依赖这类环境数据。具体有哪些数据取决于应用和框架,不要把它理解为整个线程、调用栈或所有局部变量的快照。
先看一个请求标识如何随 Task.Run 和 await 流动的例子:
Task.Run 中的委托和 await 之后的代码都能读取请求标识。它们可能运行在不同线程上,也可能恰好复用相同线程;例子的结论不依赖线程 ID。
从应用的角度看,随执行上下文流动的不只有自己定义的请求标识。语言和地区设置、当前追踪操作、当前主体等信息,也可能由运行时或框架通过这套机制传递。
从 .NET 11 的 ExecutionContext 源码看,它的主要实例字段包括:
下表中的数据通常由应用或框架使用各自的 AsyncLocal 保存,再随 ExecutionContext 传播。它们不是 ExecutionContext 类中分别命名的独立业务字段。
即使应用没有显式使用 AsyncLocal<T>,框架组件也可能已经通过它写入上下文。因此,考虑阻止上下文流动或分析其成本时,需要同时考虑应用和依赖库设置的数据。
CultureInfo 表示语言和地区相关的设置:CurrentCulture 影响日期、数字等数据的格式化和解析,CurrentUICulture 用于查找本地化资源。Activity 可以表示一次请求、数据库查询或其他需要追踪的操作。调用 Start() 时,新的 Activity 会参考当前的 Activity.Current 建立父子关系,并成为当前操作。下面的例子展示 CurrentCulture 和当前追踪操作如何流入任务:
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:
捕获通常无需深拷贝上下文及其所有值。运行时可以共享当前上下文引用,在之后更新 AsyncLocal 映射时创建新的映射版本。
ExecutionContext 的传播由各个执行入口配合完成,并不限于 Task。下表列出 .NET 11 中几种常见入口;默认流动的情况都以调用方没有通过 SuppressFlow() 阻止流动为前提。
具体 API 的契约很重要:自定义 awaiter 和其他回调机制可能有自己的捕获与恢复方式;同时,传播也不要求物理线程一定发生切换。
接下来通过两组例子,看捕获后的上下文如何保存和使用。第一组分析新建线程和线程池回调,第二组分析 async Task 方法等待任务时的挂起与恢复。
新建线程和线程池回调都会在执行入口处处理上下文,但捕获时机和保存上下文的对象不同。本节先用示例对比结果,再分别查看两个入口的处理过程。
下面的例子分别使用 Thread 和线程池执行回调,并对比会捕获上下文的入口与不自动捕获的 Unsafe 入口:
Thread 对象创建时保存了线程入口委托,调用 Start() 时才捕获当前 ExecutionContext,所以新线程看到的是 at-start。QueueUserWorkItem 将回调加入线程池队列时捕获当前上下文,回调随后在线程池线程上执行。
UnsafeStart 和 UnsafeQueueUserWorkItem 不自动捕获提交方的上下文,因此它们的回调没有继承上面设置的值。这里的 Unsafe 指上下文流动的行为,与 C# 的 unsafe 指针语法无关;选择这些 API 前仍需确认语言和地区设置、追踪和日志作用域等环境数据是否应该传播。例子中的 Join 和 ManualResetEventSlim 只用于等待每个演示完成并固定输出顺序。
.NET 11 的 Thread.cs 中,普通 Start() 的执行过程可以概括为:
捕获的是调用 Start() 时的环境:在构造 Thread 后、启动前重新赋值,会影响新线程看到的值。启动后调用方再重新赋值,则不会改写已经捕获的映射;如果映射中的值是可变引用对象,对象本身仍可能被共享。
线程启动辅助对象持有捕获的上下文,直到新线程运行入口委托时使用。入口委托结束后,新建线程也随之结束;线程池线程则会复用,因此还需要在执行后清理环境,供后续回调使用。
UnsafeStart() 使用同一类启动流程,但跳过调用方上下文的捕获。这里保存上下文的载体是线程启动辅助对象;在线程池场景中,承担类似职责的是回调包装对象。
ThreadPool.QueueUserWorkItem 会将回调加入线程池队列,并根据需要保存当前上下文。.NET 11 源码中,WaitCallback 重载的核心流程可以简化为:
这里省略了参数验证,名称也做了简化。包装对象会保存回调和状态;非默认上下文也会由相应对象保存。线程池随后从队列中取出该对象,在需要时恢复捕获的上下文并运行回调。默认上下文和阻止流动的情况使用专门的处理路径。
访问原文 返回搜索 https://www.cnblogs.com/eventhorizon/p/23645861
本页为82搜索生成的网页快照,内容来自站内索引;演示数据的原文链接仅作示例。