ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入拆解C# async/await:状态机与同步上下文机制

深入拆解C# async/await:状态机与同步上下文机制 这些年不管是技术社区、面试交流还是日常 code review几乎每天都能看到 C# 的 async/await 在刷存在感。但说句实话能把这两个关键字背后那层东西讲清楚的人比例并不高。很多人知道“async 方法会被编译器改成一个状态机”知道“await 会捕获同步上下文”可真到调一个偶发死锁或者线上任务异常丢失的时候对这些概念的理解就有点飘。这篇文章我就想系统拆一次 async/await 的实现原理重点放在状态机到底怎么转、await 恢复点怎么跳、捕获上下文和线程切换之间是什么关系。你会看到一些简化过的伪代码是我根据实际生成的 IL 和 .NET 源码整理出来的不是教科书式画饼而是程序员实际排查问题时需要的那套底层认知。适合已经被 async/await 的语法迷得晕头转向、或者想彻底搞懂回调续体究竟藏在哪里的人。1. 先从“为什么需要 async/await”说起1.1 没有状态机之前异步代码是怎么写的要把状态机讲明白得先回到没有 async/await 的年代。早年间写 C# 异步主要靠三类东西直接 new Thread、用 ThreadPool以及委托的 BeginInvoke/EndInvoke。这些方案不是不能用而是代码一旦复杂起来控制流和资源回收到处是坑。你还记得那种拿回调一层套一层的写法吗前一秒请求结果回来以后还要再次发起另一个请求再回来以后又要更新 UI 线程的控件每个回调里都得手动保存“这一次轮到哪一步”的现场状态。这个“现场状态”通常用一个局部变量不够就得靠类字段、闭包捕获、回调函数参数把这些跨步骤的数据传来传去。到了 .NET Framework 4.0 前后Task 和 TaskFactory 出现把线程封装成了可组合的“未来结果”你用 ContinueWith 可以比较自然地把后续动作接上去。但 ContinueWith 如果写多了动作之间的状态传递依然完全靠人肉管理该在哪个线程上继续也容易混乱。再者ContinueWith 的异常处理很反人类嵌套之后栈信息支离破碎现场定位问题能把人急死。所以问题的本质不是“能不能开线程”而是“一段逻辑中间要等一个 IO 或一个延迟等完之后怎么自动接回原来的逻辑并且状态和线程上下文都不出错”。这才是 async/await 真正的出场理由它把编译器变成了帮你管理“中断在哪、回来从哪继续、现场数据存到哪”的调度器。1.2 async/await 是“语法糖”吗为什么不能只当糖看很多人喜欢把 async/await 叫语法糖。这个说法有一定道理但它不是那种吃完就化的糖。它更像编译器把整段方法切开、重新排布生成一个包含多个状态入口的“可恢复函数”。表面上你写的是一段普通方法体看起来顺序清清楚楚。实际上编译器拿到 async 方法后会把方法转换成状态机类型方法体从“一条直线”变成 switch 的多个 case方法里原来的局部变量会被提升为状态机的字段每个 await 点前后的状态会被分配不同的状态编号。执行到 await 时如果等待的东西还没完成状态机就把当前状态编码到字段里然后直接 return。等到被 await 的对象完成后某个调度机制会重新调用状态机的 MoveNext从一个新状态继续向下执行。如果没有这层切换异步代码就得手动保存“执行到第几步”和“过程中的数据”还要自己解决恢复点与回调线程的关系。async/await 的价值不在于“不用写回调”而在于“编译器把回调的组织方式、状态保存方式和调度方式全部标准化了”。这也是为什么学习 C# 异步编程时绕开状态机去死记 async/await 的规则会很难受——因为你总会在诸如“await 后为什么还是 UI 线程”“为什么这里局部变量还在”“为什么一个 async 方法只执行到这里就返回了”的问题上撞墙。2. 状态机编译器在你背后做的事2.1 一个普通 async 方法编译后是什么样直接看 IL 太消耗耐心我们先用一段简化过的伪代码感受一下。假设你写的是这么个方法public async Taskint CalcAsync(int input) { int tmp input * 2; await Task.Delay(10); return tmp 1; }这段代码逻辑很简单但对编译器来说重点是方法体中存在一个 await。它需要把“await 之前”和“await 之后”切分开来。编译器最终生成的结构大概是这样的伪代码public Taskint CalcAsync(int input) { var machine new CalcAsyncd__0(); machine.input input; machine.builder AsyncTaskMethodBuilderint.Create(); machine.state -1; machine.builder.Start(ref machine); return machine.builder.Task; }冒号里那个 machine 就是状态机对象。每一次 async 方法调用都会创建一个对应的状态机实例。真正的计算过程从builder.Start开始Start 内部会立刻调用一次MoveNext也就是把你的方法体从最初 state 状态跑起来。这里有个很重要的点async 方法并不是“调用时创建一个 Task 然后马上返回空壳”而是先把状态机跑一遍一直跑到第一个 await 点如果发现要等的任务没完成才把尚未完成的 Task 返回给调用方。CalcAsync原本的逻辑会被改写到 MoveNext 里state 从 -1 开始进入初始分支执行输入乘以 2 的逻辑然后拿到 Task.Delay(10) 的 awaiter。如果这个 awaiter 没有同步完成就会把状态记录为 0注册一个 continuation 给 Delay 任务然后 return。整段异步方法的时间线就这样从“一次调用”变成了“首次执行 后续多次恢复执行”。2.2 状态机里最重要的三样东西state、字段、MoveNext状态机为什么能存住现场因为它把所有需要在 await 之后继续用到的值全部放在自己类型的字段里。刚才代码里那个变量 tmp从局部变量变成了机器里的一个字段。参数 input 也被存成字段。状态机的核心字段大体包括state用来表示当前执行到哪个恢复点。原本的局部变量和参数被“提升”到字段。当前正在等待的 awaiter需要暂存等任务完成时通过它拿结果。builder负责和返回给调用方的 Task 打交道的对象比如设置返回值或者异常。可能还有捕获的 SynchronizationContext后面章节再讲。MoveNext 的逻辑从人类视角看是一个大型 switch。state 等于 -1 时执行开头部分遇到 await 并且没完成设置 state 为第一次等待对应的编号然后注册 continuation 并 return等后续恢复再来执行 state 0 的 case拿到等待结果继续往下计算。如果后面还有第二个 await执行到这里时又可能把 state 设置成 1再次注册。整个过程有点像“一段可以暂停、可以恢复的协程”。值得强调的是编译器并不是固定只用 -1、0、1 这些数字做状态编号不同版本文档生成方式会有差异。我们分析的时候只需要确认每个 await 点都会对应一个编号state-2 或者类似标记代表状态机已经结束。2.3 MoveNext 完成一个状态之后返回值怎么塞给 Task这是很多人虽然写了无数 async 方法但没仔细想过的地方编译器状态机执行完 return tmp 1 之后这个 int 结果是怎么变成 Task 的秘密在 builder 上。当 MoveNext 内部成功走到方法逻辑末尾时它会调用builder.SetResult(result)或者builder.SetException(ex)。外部那个 Task 对象早已经在方法开始时被创建并暴露给调用者了。SetResult 会把结果塞进这个 Task 中并且触发所有等待这个 Task 的 continuation。换句话说你在 async 方法里写 return 42并不是真的直接返回 42 给调用方法而是通过 builder 把 42 写到一个已经存在但尚未完成的 Task 里。那你的调用方拿到 Task 后做await task又是怎么恢复它自己的状态机的当它执行到await CalcAsync(input)时先调用GetAwaiter()然后 if awaiter.IsCompleted 为 false它就把自己的 continuation 注册进这个 task 的 awaiter 上。等到 SetResult 被调用后这个 Task 进入完成状态底层就会触发 continuation可能是线程池线程直接跑也可能被调度回捕获的同步上下文再去调用下一个状态机的 MoveNext。整个 async/await 体系就是这么一层状态机接一层状态机地串起来的。3. 执行过程拆解从第一次调用到完整体验状态切换3.1 第一次 MoveNext 到底跑了什么我们把 2.1 里那段方法实际跑一遍从这个过程能清楚看到编译器把方法“切到了哪个点”。第一次调用 CalcAsync(3)创建状态机实例state 初始化为 -1input 存为字段。调用 builder.StartStart 里触发 MoveNext。进入 state-1 分支先执行 tmp 3*2tmp 现在是 6被存在字段里。执行Task.Delay(10).GetAwaiter()拿到 awaiter如果这个 delay 已经完成极少但不是不可能比如任务极短且调度上同步完成那么 IsCompleted 为 true程序不会暂停而是直接跳转去处理后一个 state 的代码GetResult 拿到结果继续算。这是编译器一个重要的优化分支。大多数情况下 Delay 没有完成IsCompleted 是 false于是状态机先把 state 置为 0再把当前所在步骤注册为 Delay 的 continuation然后 return。如果这时候调用方法之外有一个断点或调试器在观察你会看到 CalcAsync 返回了一个状态为 WaitingForActivation 的 Task调用线程并没有被阻塞。主线程继续跑后续代码这就是“异步不阻塞”的本质。3.2 当 Task 完成之后恢复动作由谁执行Task.Delay(10) 在系统定时器到期后会向 Task 发出完成信号。Task 完成时需要执行刚才注册的 continuation。这里有一个抉择这个 continuation 是直接在线程池线程上执行还是切回原先把状态机挂起的线程常规 console 这类没有同步上下文的场景里continuation 一般在线程池线程直接跑。但如果原调用发生在 UI 程序或者 ASP.NET 程序里情况会不一样。状态机在遇到 await 时会读取当前线程的 SynchronizationContext把它捕获下来。等到 Delay 完成为了让后续代码能操作 UI 控件或者保持请求上下文它会把 continuation 包装后 Post 回那个上下文。恢复时实际上再次调用状态机的 MoveNext。state 现在等于 0MoveNext 进入 case 0它先取出上次暂存的 awaiter调用GetResult()如果等待过程中发生了异常这一步会直接把异常抛出来如果没有异常就开始执行 await 后面的代码tmp 1也就是 61得到 7。没有更多 await于是 builder.SetResult(7)把结果写入 Task。这样这个异步方法的完整生命周期就结束了。回看这些步骤所有“接着上次的位置继续跑”的魔法核心就是那个存下来的 state 和一堆被提升为字段的局部变量。所谓状态机本质是一段带“书签”的方法执行体。3.3 局部变量去哪了为什么编译器这么像在写闭包你可以把状态机的字段提升理解为闭包捕获的增强版。闭包原理中是当你引用了外部变量编译器会创建闭包对象把局部变量变成对象的字段以便延长生命周期。状态机内部也很像方法里任何需要在 await 之后继续使用的局部变量都必须被提升到状态机实例的字段里因为 MoveNext 每次恢复执行时栈帧已经重新搭起来了。所以你会观察到几种现象async 方法里如果有耗时少、不跨 await 的局部变量编译器优化后可能不会提升成字段因为不需要跨恢复点保存。await 之后如果还要用某个参数或中间变量它一定会变成字段而且如果你反编译会看到字段名长得乱七八糟比如3__tmp这种带尖括号的名字。在调试器里看一个正在等待中的 async 方法局部变量窗口所看到的变量往往已经不是真正栈上的局部变量了。这些现象都属于“栈帧被显式保存成了堆对象”的必然结果。搞懂这一层以后查“为什么异步方法里的变量值有延迟”之类的问题就会淡定很多。4. 同步上下文与线程模型为什么 UI 程序容易死锁4.1 同步上下文是什么为什么会影响状态机的恢复SynchronizationContext 是一个抽象概念负责把一段代码封装后安排在合适的线程或调度器上执行。Windows 窗体和 WPF 都有一个基于消息泵的同步上下文写 UI 的异步方法通常在 UI 线程上开始执行遇到 await 时会把这个上下文捕获下来。等任务完成后continuation 会通过上下文.Post 或 Send 回到 UI 线程继续执行。这个设计极大方便了我们更新控件await 之后代码又回到了原来的 UI 线程不需要手动 Invoke。但随之而来的问题是如果 UI 线程被阻塞它就没办法处理消息泵里的 continuation这为死锁留下了巨大的伏笔。4.2 ConfigureAwait(false) 到底切了什么ConfigureAwait(false)的意思是我没有必要回到原来的同步上下文任务完成以后随便找个线程执行 continuation 就行。它不是在切换线程而是在告诉状态机“你不要捕获当前上下文”。这样恢复动作就不会 Post 回 UI 线程而是直接在完成任务的那个线程池线程上继续跑。所以从行为上看代码还是在某个线程池线程执行并不是主线。什么时候该用 ConfigureAwait(false)经验做法是在库代码里一般使用 false因为你不知道这个库会被什么样的调用方使用。如果在 UI 层或者 ASP.NET Core 这类需要考虑特定上下文的代码里滥用 false则可能丢失上下文。 .NET Core 里默认本来就没有 ASP.NET 的 SynchronizationContext所以配合习惯也不同。实际项目里应该结合代码运行环境判断而不是无脑全局 false。4.3 最经典的 WinForms/WPF 死锁场景看下面这段代码private void Button_Click(object sender, EventArgs e) { var text GetDataAsync().Result; Use(text); } private async Taskstring GetDataAsync() { await Task.Delay(100); return ok; }点击按钮后UI 线程执行 GetDataAsync运行到 await Task.Delay 时状态机已经捕获了 UI 上下文。由于 Delay 没完成GetDataAsync 立即给调用方返回一个未完成 Task。调用方向接着调用 .Result 去阻塞当前线程等待这个 Task 完成。于是 UI 线程被卡住消息泵不再处理任何消息。过了 100msDelay 完成恢复动作想把 continuation Post 回 UI 上下文却发现 UI 线程已经被自己的 .Result 占住没法处理这个回调。两方互等这就是典型的死锁。这个小例子完美解释了状态机恢复机制和线程阻塞之间的化学反应。所以规范上才会反复强调在带有同步上下文的程序里你不要在 UI 线程上同步阻塞一个 async 方法。4.4 死锁排查的实际经验我曾经排查过一个 WinForms 项目的卡死问题现象是整个程序过一段时间后会假死点哪里都没反应。dump 抓下来一看堆栈里 UI 线程停在某个 .Result另外一个线程池线程停在 SetResult 之后的 continuation 分发。这个模式非常鲜明。你不需要去猜什么“资源竞争”十有八九就是“同步阻塞异步恢复”的双重等待。排查异步死锁的时候第一件事是找代码里所有Task.Wait()、Task.Result、Task.GetAwaiter().GetResult()的出现点第二确认调用线程有没有 SynchronizationContext第三看这个 Task 内部会不会尝试回到同一个上下文。遇到类似现象时最直接的解决方案是让 async 方法从头到尾走异步链把同步阻塞调用改成外层也能 await 的形式。如果确实有历史包袱非得同步转异步那也要把内部的等待代码明确 ConfigureAwait(false)而不是堵在 UI 线程上。5. 实战中的细节和“坑位”排查经验5.1 async void 为什么是大坑C# 里 async 方法除了可以返回 Task、Task 还有一个可怕的变体 async void。这种签名最初是为了给事件处理器用因为事件委托不能等待 Task。但它带来的问题极其现实async void 方法里一旦抛出异常异常不会像 Task 方法那样被包装进 Task而是直接抛到当前 SynchronizationContext 上很多时候会导致整个进程崩溃。真正推荐的做法是在事件处理器里永远只让 async void 承担一个薄薄的事件转发角色把实质工作放到一个返回 Task 的方法里并且在异步方法内部尽量自己做好异常处理。使用 async void 的方法还必须注意它不能直接被等待调用方也没有机会感知它的生命周期。所以如果你在业务方法或者库方法里出现 async void基本属于设计失误。5.2 异步方法里的异常传播机制什么样在状态机体系里异常处理跟同步方法的差别很大。MoveNext 外层会环绕一个 try-catch当方法内抛出异常且没有被捕获时catch 会调用 builder.SetException把异常封装到返回的 Task 中同时将状态机标记为完成状态。之后调用方不管是通过await还是 Wait/Result 拿到 Task异常会从内部重新抛出。所以一个容易忽略的点在 async 方法里异常发生的位置和它被抛给调用方的位置在线程和时间上是可以分离的。假如有人调用了 async 方法但没有 await也没有挂接异常处理那个 Task 里的异常会被视为未被观察。.NET Framework 中未观察的 Task 异常可能导致进程崩溃.NET Core 之后默认策略虽然温和一些但仍然不建议让异常进入“未观察”状态。调试时你会发现异步异常里常常包含两个阶段的信息一个是原始抛出点一个是 await 恢复点。保留原始栈的关键在于不要在 async 方法内部用throw;这种多余包装也不要轻易 catch 之后重新抛一个新对象否则你会丢掉部分上下文。真正要从异常源头定位问题可以把ExceptionDispatchInfo机制和状态机行为联系起来理解。5.3 状态机不是零成本ValueTask 解决了什么每次调用一个 async 方法至少会分配一个状态机对象如果指定返回 Task还要分配一个 Task 对象。高频次调用会对 GC 产生压力尤其是循环里频繁 await“几乎肯定同步完成”的小操作时这种分配看起来有些浪费。ValueTask 是应对这类问题的优化手段。它可以把一个可能同步完成的结果直接保存在结构体里不需要额外分配 Task 对象。如果返回值在大多时候已经算好ValueTask 比 Task 轻很多。但值类型的对象要警惕“多次 await”的问题ValueTask 只能被 await 一次别再把它缓存在字段里并多次消费。常规项目里如果调用量不大完全用 Task 也没毛病毕竟 ValueTask 带来的实现约束额外增加了出错的概率。状态机对象本身的分配在多数应用里不是性能瓶颈但网络服务的高频基础设施代码会被放大。遇到 CPU 频繁 GC 的情况可以从异步计数器的缓存块里看一下状态机类型是否占了大量对象实例。真实案例中我曾遇到过每秒几万次的一处数据库访问全部通过 async 方法包装检查性能时发现状态机对象分配占托管堆分配的三成。优化方式是把那个访问路径改成了 ValueTask 和缓存 Task 的方法同时减少了内部不该串行等待的那层 async。5.4 常见问题排查速查表现象常见原因建议排查方向UI 卡死后 Task 永远不完成同步上下文被阻塞continuation 无法回到 UI 线程找 .Result/Wait 删掉改 async/await 链条async void 方法异常导致进程退出异常没有进到 Task 通道直接抛到上下文事件处理函数只做包装业务方法返回 Taskawait 之后拿不到期望的 UI 对象用了 ConfigureAwait(false) 或在错误线程恢复检查 UI 层是否丢失上下文日志里没有异常栈原始位置await 链路中层层的重新包装避免无脑 catch throw new Exception高并发下 GC 压力大async 每次调用分配状态机和 Task高频调用点使用 ValueTask 或缓存已完成 Task明明写了 await 但没有捕获到任务结果某些 API 同步完成了走了 IsCompleted 短路分支理解同步完成/异步完成分支5.5 不是所有代码都适合 async讲到异步大家容易产生一个误区以为只要把方法标上 async 就高性能了。实际上受 CPU 密集计算限制的代码并不适合用 async/await 来获得并发收益它只会增加任务调度和状态机分配的额外开销。对于真正耗 CPU 的循环比如图像处理、机器学习推理、协议帧编解码正确做法是保留同步方法需要并行的时候用 Task.Run 分发给多个线程执行该方法本身并不需要写成 async。async/await 主要面向 IO 等待比如网络请求、文件读写、数据库访问、串口通讯、上下位机通信这类的持续等待消耗。写工业上位机程序的朋友应该比较有体会串口数据等待时如果用阻塞读会让 UI 线程死掉正确操作是把读数据的动作发到一个异步 IO 流程里通过 await 让出线程而不是把一个 Math 密集算法硬安上 async 关键字。6. 自己写一个简化版异步状态机试试手感6.1 手工模拟“两次进入继续执行”的核心逻辑为了加深理解可以自己实现一个极简状态机模拟异步方法断点恢复。核心思路是用一个枚举或者 int 记录当前步骤用一个 DoSomething 方法每次只跑一次当前状态允许完成的动作执行完根据逻辑决定是停止还是推进到下一步。这里不是要去替代编译器而是通过手写这种常见的做法让你真正建立“状态迁移”的思考模型。比如很多编码器解析协议帧时就是这么设计的收到一个字节状态从“头部”迁移到“长度”再从“长度”迁移到“载荷”中间每一步都等待后续数据到达。C# 的 async 状态机在内存中的组织和这个模型一脉相承区别只是它的状态分支由编译器自动生成迁移条件封装在 awaiter 的完成回调里。自己写这个 model 时建议把状态定义成不可变的 int 或枚举保证每次进入 MoveNext 都能根据 state 精确地执行对应步骤不要在状态切换时手滑把顺序倒过来。调试时输出每个状态入口能直观看到程序被“切”成几段。6.2 手写模型与真实编译器的核心差异很多人上手写 Playbook 之后会问为什么自己的状态机每次都要在循环判断里不断跑一遍而 compiler 同步就返回了差异主要在于真实状态机遇到 await 并且未完成时会当场 return从代码路径上立刻离开当前执行流等到 continuation 被调用时再从头调用 MoveNext再靠 switch 跳到对应 case 继续。它不是在一个循环里反复消耗 CPU也不是用线程 Sleep 等待任务完成。另一个差异是真实的 awaitable 接口允许编译器把 continuation 注册到底层的事件里去比如 IO 完成端口触发回调、System.Threading.Timer 到期触发回调、socket 数据到达触发回调。这些都是事件驱动模式触发源不是无限轮询而是来自底层设备、系统或者线程池的主动通知。拿到这个差别你就能明白为什么 async/await 适合 IO 等待了。6.3 状态机原理给我带来的启发我一直很建议大家在内部培训或者新人带教时专门找一个下午用几段断点把 async 状态机跑一遍而不是直接念“避免死锁”的结论。当你在状态机的临时窗口里看到那个 state 从 -1 变成 0又看到局部变量 tmp 出现在对象字段里时很多概念就不用背了。实战中拿到任何 async 卡死、性能分配过高、异常丢失问题我都会先在脑子里问三个问题这个方法是哪个同步上下文里启动的方法内部的等待点是否会捕获上下文完成后由谁调度回来。这三个问题搞明白了至少一半异步问题已经定位。至于剩下的优化问题无非是谁在创建状态机、能不能复用、能不能用 ValueTask、能不能在 IO 层直接让数据到达时唤醒。把状态机原理理解到位之后这些问题都不需要从网上找现成的代码片段来套你自己就能推出来合理的设计。从个人经验上讲我觉得最有价值的练习不是去背源码实现而是把那些看似“正常”的异步代码亲手改造成会出问题的代码再观察现象。比如在 UI 程序里故意写一个同步阻塞调用把 ConfigureAwait(false) 去掉加上反复调试观察线程 ID 的变化用 dump 捉一次死锁。这比任何一篇文字都更能让你记住async/await 背后真正重要的不是关键字语法而是那个可以自由暂停、恢复、调度和保存现场的状态机结构。遇到任何异步问题顺着这条主线走基本不会跑偏。
返回列表