async/await 到底在等什么:从状态机到 ConfigureAwait

第一次写 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) 只做一件事——不捕获上下文,续体走线程池。
  • 死锁来自同步等待 + 上下文捕获,正确解法是让异步一路传染到调用链顶端。
返回 全部技术文章