📋 SynchronizationContext & await 续延调度
速查表 · 基于 Lesson 05
核心概念
| 概念 | 一句话 | 关键细节 |
| SynchronizationContext |
决定 await 续延在哪个线程/上下文执行 |
抽象类;核心方法 Post(异步投递)和 Send(同步投递) |
| ExecutionContext |
保证上下文数据(AsyncLocal 等)跟着异步流走 |
与 SynchronizationContext 独立;ConfigureAwait(false) 不影响它 |
| ConfigureAwait(false) |
告诉 await:不要捕获 SynchronizationContext |
续延不 Post 回原上下文,直接在线程池跑;防止 .Result 死锁 |
| AsyncLocal<T> |
异步调用链中自动流动的数据槽 |
依赖 ExecutionContext,不依赖 SynchronizationContext |
关键概念速记
线程亲和 (Thread Affinity)
对象/资源绑定在特定线程上,只能在该线程访问。Windows GUI 控件是典型——从线程池线程改控件会抛 InvalidOperationException。SynchronizationContext 就是为解决线程亲和问题设计的——Post 续延回 UI 线程,控件操作才安全。
因果链:控件有线程亲和 → 必须在 UI 线程操作 → await 捕获 SC 回到 UI 线程 → 可安全更新控件。库代码无线程亲和 → ConfigureAwait(false) 避免回 UI 线程。
三大概念关系
ExecutionContext = 背包(容器)—— await 前 Capture,await 后 Run,强制的
AsyncLocal<T> = 背包里的口袋(数据槽)—— 值跟随异步调用链,不依赖线程
SynchronizationContext = 目的地(调度器)—— Post 把续延投递到目标线程
ConfigureAwait(false) = 只改目的地,不改背包里的东西
各环境的 SynchronizationContext
| 环境 | SynchronizationContext.Current | 行为 | .Result 死锁? |
| WinForms / WPF UI 线程 |
WindowsFormsSynchronizationContext
DispatcherSynchronizationContext |
Post → Win32 消息投递回 UI 线程 |
是 |
| ASP.NET (.NET Framework) |
AspNetSynchronizationContext |
Post → 线程池排队,但同一请求串行 |
是 |
| ASP.NET Core |
null |
续延在线程池直接执行 |
否 |
| 控制台 / 线程池线程 |
null |
续延在线程池直接执行 |
否 |
| Blazor Server |
有(Blazor 专用) |
续延回到 Blazor 渲染同步上下文 |
是 |
经典 .Result 死锁因果链
死锁三条件(缺一不可):
① 有 SynchronizationContext 限制串行执行
② .Result / .Wait() 阻塞当前线程
③ await 续延需要回到被阻塞的 SynchronizationContext
逐帧回放
// .NET Framework ASP.NET 中这段代码死锁:
public ActionResult Index()
{
var data = GetDataAsync().Result; // 死锁
return View(data);
}
// 帧1: 线程 A 进入 Index() → SC = AspNetSynchronizationContext
// 帧2: 线程 A 调用 GetDataAsync()
// 帧3: GetDataAsync 中 await 捕获了 AspNetSynchronizationContext
// 帧4: GetDataAsync 返回未完成的 Task → 线程 A 回到 Index()
// 帧5: .Result 阻塞线程 A,等待 Task 完成
//
// 帧6: HTTP 完成 → Task 完成 → 触发续延
// 帧7: 续延尝试 Post 回 AspNetSynchronizationContext
// 帧8: 但 AspNetSynchronizationContext 被线程 A 占着
// 帧9: 线程 A 在等 Task,Task 在等线程 A → 死锁
三种打破方式
| 方式 | 打破条件 | 做法 |
| 不用 .Result | ② | var data = await GetDataAsync(); → async all the way |
| ConfigureAwait(false) | ③ | 库代码每个 await 加 .ConfigureAwait(false) |
| ASP.NET Core | ① | 迁移到 Core——天然没有 SynchronizationContext |
await 内部流程(简化)
// 编译器把 await 变成约等于:
var awaiter = task.GetAwaiter();
if (awaiter.IsCompleted)
{
result = awaiter.GetResult(); // 同步完成——直接拿结果
}
else
{
var capturedContext = SynchronizationContext.Current; // ← 捕获
awaiter.OnCompleted(() =>
{
if (capturedContext != null)
capturedContext.Post(_ => /* 续延代码 */, null);
else
/* 直接在线程池执行续延代码 */;
});
}
最佳实践速查
| 场景 | 做法 | 理由 |
| 库代码(DAL / Service / Utils) |
每个 await 加 .ConfigureAwait(false) |
不碰 UI,不需要回原上下文;防止调用方死锁 |
| ASP.NET Core 控制器 |
直接 await,不加强制(加了也行) |
Core 没有 SC,加了无害但多余 |
| WinForms / WPF UI 事件 |
不能加 ConfigureAwait(false) |
需要回 UI 线程改控件 |
| 永远 |
不要用 .Result / .Wait() |
async all the way——死锁 + 浪费线程 |
| 构造函数/静态构造中必须同步 |
Task.Run(() => AsyncMethod()).GetAwaiter().GetResult() |
从线程池跑,避开当前 SC |
SynchronizationContext vs ExecutionContext
| SynchronizationContext | ExecutionContext |
| 控制什么 | 续延在哪个线程 | 续延能看到什么数据 |
| 类比 | 引导续延去哪里 | 确保持带着护照和行李 |
| ConfigureAwait(false) 影响? | ✅ 是 | ❌ 否——总是流动 |
| 存储内容 | 调度策略 | SecurityContext + AsyncLocal 数据 |
| 何时需要 | 需要回到特定线程时 | 需要追踪调用链数据时(日志、权限) |
AsyncLocal<T> vs ThreadLocal<T>
| ThreadLocal<T> | AsyncLocal<T> |
| 绑定到 | 操作系统线程 | 异步调用链(不依赖线程) |
| await 换线程后 | ❌ 值丢失——新线程没设过 | ✅ 值自动流动——ExecutionContext 传递 |
| 适用场景 | 同步代码 | async/await 异步代码 |
| 典型使用 | 旧式线程本地存储 | HttpContext 追踪、OpenTelemetry、多租户隔离 |
真实世界模式
| 场景 | 依赖 | 机制 |
ASP.NET Core IHttpContextAccessor |
AsyncLocal<HttpContext> |
中间件在请求开始设值 → 整个调用链可访问 |
OpenTelemetry Activity.Current |
AsyncLocal<Activity> |
await 跨线程不变,追踪链完整 |
多租户 TenantContext |
AsyncLocal<string> |
中间件设租户 ID → 全局可访问,不需透传参数 |
Framework → Core 迁移关注点
| Framework 4.8 模式 | Core 对应 |
HttpContext.Current |
注入 IHttpContextAccessor |
到处 .ConfigureAwait(false)(防死锁) |
不再需要——但保留无害 |
| 每个请求自动串行化 |
无自动串行——如果多个续延并发需自己用锁 |
HostingEnvironment.QueueBackgroundWorkItem |
IHostedService / BackgroundService |