第一次写 async 方法时,很多人会有一个模糊的印象:await 会把当前线程让出去,等任务完成再「回来」。
这个印象不算错,但它解释不了两个最常见的困惑:为什么 .Result 会死锁,以及为什么类库代码里到处都在写 ConfigureAwait(false)。
这篇笔记从编译器产物出发,把这两件事讲清楚。
一、编译器把 async 方法改成了什么
先看一段最简单的代码:
public async Task<int> GetLengthAsync()
{
var text = await ReadTextAsync();
return text.Length;
}
它编译后并不会变成一个「会等待的方法」。C# 编译器做的是状态机改写:
把方法体拆成可以分段执行的状态,方法本身只负责创建状态机并立刻返回一个 Task。
用 ILSpy 反编译,骨架大致是这样:
// 编译器生成的近似结构(字段名来自实际 IL)
[AsyncStateMachine(typeof(GetLengthAsyncStateMachine))]
public Task<int> GetLengthAsync()
{
var sm = default(GetLengthAsyncStateMachine);
sm.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
sm.<>1__state = -1;
sm.<>t__builder.Start(ref sm); // 同步执行到第一个未完成的 await
return sm.<>t__builder.Task; // 立刻把 Task 交还给调用方
}
关键在于 Start:它会在调用方的线程上同步执行,一直跑到第一个真正需要等待的地方才停。
也就是说,如果 ReadTextAsync 完成得快(比如命中缓存、返回 Task.FromResult),
整个方法可能压根没有发生过线程切换。这就是所谓的「快路径」。
真正干活的是状态机里的 MoveNext,它的结构被压平成了下面这样:
private void MoveNext()
{
int result;
try
{
TaskAwaiter<string> awaiter;
if (<>1__state != 0) // 首次进入
{
awaiter = ReadTextAsync().GetAwaiter();
if (!awaiter.IsCompleted) // 快路径:已完成就不挂起
{
<>1__state = 0; // 记住执行位置
<>u__1 = awaiter; // 保存 awaiter
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return; // 关键:从当前线程返回
}
}
else // 回调恢复到断点
{
<>1__state = -1;
awaiter = <>u__1;
<>u__1 = default;
}
string text = awaiter.GetResult(); // 异常在此重新抛出(非包装)
result = text.Length;
}
catch (Exception ex)
{
<>1__state = -2;
<>t__builder.SetException(ex);
return;
}
<>1__state = -2;
<>t__builder.SetResult(result);
}
从这里可以读出三条确定的事实:
await的本质是「检查是否已完成」,已完成就继续往下走,没有任何线程开销。- 未完成时,
return把当前线程交还给调用者——线程没有被阻塞,只是这个方法暂时不占用它。 - 恢复执行是由 awaiter 注册的回调驱动的,而这个回调在哪个线程上执行,取决于调度器,这就是
ConfigureAwait的切入点。
二、ConfigureAwait(false) 到底关掉了什么
默认情况下(ConfigureAwait(true)),AwaitUnsafeOnCompleted 会捕获两个东西之一:
SynchronizationContext.Current,没有的话则用 TaskScheduler.Current。
等任务完成时,续体(也就是下一次 MoveNext)会被 Post 回那个上下文,而不是随便找个线程池线程跑。
ConfigureAwait(false) 做的事非常单纯:不捕获上下文,续体直接在线程池上执行。
它既不会改变并发,也不会让代码「更快地并行」,只是不再要求「回到原来的地方继续跑」。
| 场景 | 续体需要回到原上下文吗 | 建议 |
|---|---|---|
| 类库 / 通用组件 | 不需要,不该假设宿主环境 | 全部 ConfigureAwait(false) |
| WinForms / WPF 事件处理器 | 需要,await 后要碰控件 | 保持默认 |
| ASP.NET Core 请求管道 | 不需要,本来就没有同步上下文 | 写不写都一样 |
| 控制台 / 后台服务 | 不需要 | 可省略,加上更保险 |
ASP.NET Core 这一行值得强调:它没有 SynchronizationContext,
所以「不加 ConfigureAwait(false) 会死锁」在 Core 的 Web 管道里并不成立。
但类库不知道调用方是谁,所以类库仍然应该统统加上——这不是为了性能,而是为了不把宿主拖进死锁。
三、死锁是怎么产生的
死锁需要三个条件同时成立:有同步上下文、在上下文线程上同步等待、被等待的异步方法内部捕获了上下文。 写一个最小复现,比在 WinForms 里瞎猜有效得多:
using System.Collections.Concurrent;
// 一个只有单线程在跑的同步上下文,行为和 UI 线程一致
sealed class SingleThreadContext : SynchronizationContext
{
private readonly BlockingCollection<(SendOrPostCallback Cb, object? State)> _queue = new();
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
public void Run()
{
foreach (var (cb, state) in _queue.GetConsumingEnumerable())
cb(state);
}
}
然后让主线程「扮演」UI 线程去同步阻塞:
var ctx = new SingleThreadContext();
SynchronizationContext.SetSynchronizationContext(ctx);
var task = LoadAsync(); // 内部 await 后会 Post 回 ctx
Console.WriteLine(task.Result); // 主线程在这里阻塞
static async Task<int> LoadAsync()
{
await Task.Delay(50); // 捕获到 ctx,续体排队等主线程空闲
return 42;
}
Task.Delay 完成后,续体被 Post 进队列,等主线程空闲时执行;
而主线程正卡在 task.Result 上等这个续体。互相等,谁也不动。
把 await Task.Delay(50) 改成 await Task.Delay(50).ConfigureAwait(false),
续体改由线程池执行,死锁立刻消失。
结论:死锁的根因不是
await,而是在同步上下文线程上做同步等待(.Result/.Wait()/GetAwaiter().GetResult())。 修复方式的首选永远是「一路 async 到底」,ConfigureAwait(false)是类库侧的防御,不是让你放心用.Result的许可证。
四、几个反复被问到的细节
await 会不会创建新线程
不会。await 本身不创建线程,它只是给「已存在的异步操作」注册回调。
只有当你 Task.Run 或者底层 I/O 完成回调需要线程池线程时才涉及线程。
一个 I/O 密集的 async 方法可以做到全程只占用很少的线程。
async void 为什么危险
async void 没有 Task 可供等待,异常会直接抛到同步上下文(通常等于进程崩溃),
调用方也无从知道它何时结束。它只应该出现在事件处理器里,其他位置一律用 async Task。
ValueTask 值得用吗
当方法大多数情况能同步完成、且调用频率极高时(例如在热路径上、从缓冲区取值),ValueTask 能省掉一次堆分配。
但它只能 await 一次、不能并发等待,误用会引发极难排查的 bug。
默认用 Task,只有被性能数据推着走的时候才换。
小结
async方法返回的是状态机驱动的一个Task,方法体在Start时同步跑到第一个未完成的await。await有快路径:已完成则同线程继续,无切换开销。ConfigureAwait(false)只做一件事——不捕获上下文,续体走线程池。- 死锁来自同步等待 + 上下文捕获,正确解法是让异步一路传染到调用链顶端。