当你在 C# 中写下 async/awaityield return 时,你其实是在指挥编译器去构建一个状态机。这个状态机是完全自动生成的,平时你看不到它,但它却是异步方法和迭代器能够“暂停-恢复”的核心秘密。

本文就来把这个隐藏的机制拉到台前,彻底搞清楚它是什么、怎么运作、以及为什么重要。

1. 一句话理解:什么是状态机?

先抛开代码,想象一个现实场景:你按微波炉的按钮加热午餐

  • 开始:放进食物,关上门,按“启动”。
  • 等待(暂停):微波炉嗡嗡转,你走开去刷手机。微波炉没有“卡死”,它在计时。
  • 恢复:计时结束,“叮”一声,程序切换到“保温/结束”状态,你回来取餐。

软件里的状态机就是干这个的:

一个方法执行到一半,需要等待(比如等网络数据、等定时器、等迭代器下一个值),它可以先“冻结”现场并返回,等条件满足后,再从刚才的断点“复活”继续执行

在 C# 中,编译器通过生成一个内部类(就是状态机),把这个“暂停-恢复”能力赋予普通的方法。

2. 两大状态机场景:迭代器 与 异步方法

2.1 yield return 迭代器状态机

看一个最简单的迭代器:

IEnumerable<int> GetNumbers()
{
    Console.WriteLine("开始");
    yield return 1;
    Console.WriteLine("继续");
    yield return 2;
    Console.WriteLine("结束");
}

当你调用 GetNumbers() 时,方法体内的代码一行都不会立刻执行。它只是创建了一个状态机对象。只有当 foreach 开始遍历(调用 MoveNext())时,代码才按状态逐步执行:

  1. 状态 -2 (初始):刚创建,还没跑。
  2. 第一次 MoveNext():切换到状态 0,执行 Console.WriteLine("开始"),然后 yield return 1在此处暂停,状态记录为 1,返回 true
  3. 第二次 MoveNext():从状态 1 恢复,执行 Console.WriteLine("继续"),然后 yield return 2,再次暂停,状态记录为 2。
  4. 第三次 MoveNext():从状态 2 恢复,执行 Console.WriteLine("结束"),方法结束,状态变为 -1(完成),返回 false

方法执行被“切”成了三段,每段对应一个状态编号。那个记录编号并在下次执行时跳转到对应位置的,就是状态机。

2.2 async/await 异步状态机

再看异步版本:

async Task<string> FetchDataAsync()
{
    Console.WriteLine("开始请求");
    var data = await HttpHelper.GetAsync("https://example.com");
    Console.WriteLine($"获取到数据长度: {data.Length}");
    return data;
}

这个方法的执行也是断开的:

  1. 调用 FetchDataAsync(),同步执行 Console.WriteLine("开始请求"),发出 HTTP 请求。
  2. 到达 await。如果任务没完成(大概率),方法立刻返回一个 Task<string> 给调用者。此时线程不阻塞,可以去干别的。
  3. 等 HTTP 请求在网络后台完成,运行时会把 await 后面的代码(Console.WriteLine(...)return)包装成一个回调,丢回合适的线程去执行。
  4. 那个“回复执行”的过程,就是从状态机保存的断点处继续。

3. 解剖状态机:反编译看看编译器干的好事

我们写一个极简的异步方法,然后用 ILSpy 或 SharpLab 等工具反编译,窥探一下生成的代码结构。

源代码:

public async Task<int> DemoAsync()
{
    int x = 1;
    await Task.Delay(100);
    x = 2;
    return x;
}

编译器会把这个方法拆解成一个状态机结构体(或类)。大致会长下面这样(简化并伪代码化以便理解):

[CompilerGenerated]
private struct <DemoAsync>d__0 : IAsyncStateMachine
{
    // 字段:保存方法的局部变量和参数
    public int <x>5__1;
    public TaskAwaiter <>u__1;     // 保存 await 的等待器
    public int <>1__state;         // 核心:当前状态号
    public AsyncTaskMethodBuilder<int> <>t__builder; // 构建 Task 的辅助器

    // 状态机入口:MoveNext
    void IAsyncStateMachine.MoveNext()
    {
        int result;
        try
        {
            TaskAwaiter awaiter;
            switch (<>1__state)
            {
                case 0:
                    // --- 初始状态:执行 await 之前的代码 ---
                    <x>5__1 = 1;                      // int x = 1;
                    awaiter = Task.Delay(100).GetAwaiter();
                    
                    if (awaiter.IsCompleted)
                    {
                        goto case 1;  // 如果已完成,直接跳转
                    }
                    
                    // 【暂停点1】设置状态,保存 awaiter,注册回调
                    <>1__state = 1;
                    <>u__1 = awaiter;
                    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;   // <-- 线程在此返回!

                case 1:
                    // --- 从暂停点1恢复 ---
                    awaiter = <>u__1;
                    <>u__1 = default;  // 清理 awaiter 字段
                    <>1__state = -1;   // 重置为完成状态
                    
                    awaiter.GetResult(); // 获取 await 结果(此处为无返回值的 Task)
                    <x>5__1 = 2;        // x = 2; (待延迟完成后才执行)
                    result = <x>5__1;   // return x;
                    break;
            }
            // 标记 Task 为成功完成
            <>t__builder.SetResult(result);
        }
        catch (Exception ex)
        {
            <>1__state = -2;
            <>t__builder.SetException(ex);
        }
    }

    // ... 其他接口方法
}

机制拆解:

  • <>1__state 字段:这是状态机的心脏。-2 表示初始未启动,0 是第一个断点之前,1 是第一个 await 处暂停,-1 是执行完毕。如果有多个 await,状态数会递增。
  • switch/case 跳转:方法调用 MoveNext() 时,根据当前状态号,用 switch 直接跳转到上次暂停的 case 块,完美实现“从断点继续”。
  • 局部变量“提级”为字段:方法里的 int x 不能放在线程栈上了(因为方法会中途返回,栈会销毁)。编译器把它变成状态机的字段 <x>5__1,存活期贯穿整个异步操作。
  • await 是如何释放线程的await 时,状态机检查任务是否完成。若没完成,则记录状态号、保存 awaiter,然后向 awaiter 注册回调,接着直接 return!调用该异步方法的线程在此处被彻底释放。
  • 任务完成后的回调:当 Task.Delay(100) 完成时,其内部会触发那个注册的回调,回调的核心就是再次调用状态机的 MoveNext()。这一次,switch 会跳到 case 1,延续执行 x = 2

4. 为什么需要状态机?—— 解决线程阻塞的终极方案

假设没有状态机,我们要让一个耗时操作“不阻塞界面”,纯手写是这样的痛苦流程:

  1. 开启一个后台线程。
  2. 在后台线程启动网络请求。
  3. 定义一个回调函数,处理请求结果。
  4. 在那个回调函数里,用 BeginInvoke 把结果封送回 UI 线程更新界面。
  5. 处理异常、超时、嵌套异步调用……(很快代码变成一团乱麻)。

状态机的革命性在于:它让你用“直线思维”写同步代码,同时获得异步的高性能。

你写 string data = await httpClient.GetStringAsync(url); 这一行,编译器就帮你生成:

  • 线程在此释放的记录。
  • 网络操作完成后的回调注册。
  • UI 线程恢复的上下文捕获。
  • 异常捕获和向返回 Task 的传递。

状态机让开发者从回调地狱中彻底解脱。

5. 两类状态机的关键区别

特性 yield return 迭代器状态机 async/await 异步状态机
实现接口 IEnumerator<T> / IEnumerable<T> 的生成类 IAsyncStateMachine
暂停触发 遇到 yield return 遇到不完整的 await
恢复触发 外部调用 MoveNext() (如 foreach) await 的任务完成后,回调调用 MoveNext()
生成结构 通常是一个类 通常是一个结构体(以减少堆分配)
返回值 IEnumerable<T> 等(惰性序列) Task / Task<T> / ValueTask (异步操作句柄)

6. 实际影响与最佳实践

避免不必要的状态机

非必要不添加 async。如果一个方法只是传递 Task,就不必标记 async

// 坏:多生成一个无意义的状态机
async Task<int> FooAsync() => await BarAsync();

// 好:直接返回 Task,零分配
Task<int> FooAsync() => BarAsync();

理解 ConfigureAwait(false)

状态机在恢复执行时,默认会捕获并还原原始“上下文”(如 UI 线程)。这确保 await 之后能安全访问 UI 控件。
在库代码中,不关心上下文时,用 await task.ConfigureAwait(false) 可以避免上下文切换开销和死锁风险。

状态机与性能

每个 async 方法首次 await 未完成的任务时,它的状态机结构体会被装箱到堆上(作为 IAsyncStateMachine)。这是异步有少量开销的根源。C# 的高版本和 ValueTask 正在通过池化和结构体传递来减小这种开销。


总结

C# 的状态机不是需要你手动编写的一种代码模式,而是编译器为你自动注入的一种运行时转换机制。它悄无声息地栖身于每一个 asyncyield 方法的背后,把复杂的挂起、回调与恢复逻辑编译成一个个朴素的状态跳转和字段存储。

理解它的存在,能让你在写 LINQ 迭代器时更清楚其延迟执行特性,在诊断异步死锁和性能调优时有更明确的方向,在阅读反编译代码时面对那些 <> 怪名也能会心一笑——原来,它是一个默默守护着现代 C# 并发优雅的“状态守护神”。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐