await 一写,方法就被剪成两半最大的问题:一大半“等待”是白等微软试过绿线程,最后砍了Runtime Async:不暂停,就不收钱成绩单:最快快了近 20 倍写在最后:抽象的账单

中午点了份外卖,你会站在门口干等一小时吗?不会。你会继续敲代码、继续开会,手机一响再下去拿——等的事让它等着,手里的活别停。

程序里的 async/await 干的就是这件事:遇到要等的操作(比如读文件、发网络请求),先把 CPU 让出去干别的,等结果回来了再接着跑。这套写法优雅得像常识,用了十几年,几乎所有主流语言都有。

但微软最近干了一件大事:把这个用了十几年的模型从底层整个重做了一遍。原因说出来有点意外——你写下的每一行 await,很多时候都在“白等”,而且为了这场白等,程序一直在偷偷交一笔看不见的税。今天咱们把这笔账算明白。

先看一段最常见的异步代码:

打开网易新闻 查看精彩图片

await 的意思是:这里有暂停点。而 C# 编译器一看到暂停点,就会掏出剪刀,把整个方法沿着每个 await 剪开,重新组装成一个{BOLD('状态机')}——用大白话讲,就是把“先 A 再 B 再 C”的一段代码,改造成“执行到一半先存档,被叫醒后读档接着跑”的机器:

打开网易新闻 查看精彩图片

方法返回值也被换成了 Task——一个“提货凭证”,调用方拿着它等结果。这套机制好不好用?好用。写代码的人体验非常顺:异步写得像同步一样直白。但代价藏在你看不见的地方。

关键漏洞在于:编译器改写代码时,是{BOLD('一个方法一个方法看的')}。它不知道你 await 的那个方法内部到底会不会真的暂停。比如:

打开网易新闻 查看精彩图片

调用它的方法明明不用暂停,但编译器照样给两边都造一套状态机、都发一张 Task 提货凭证。那 JIT 运行时能不能帮忙补救?也很难——因为编译器早就把原始代码拆成了状态机碎片,JIT 看到的已经不是连贯的调用链,而是一堆“存档、读档、传凭证”的机械动作,想优化也无从下手。

而现实世界里,这种白等比想象中普遍得多:

  • • 调用链通常只有最里层真的在等:外面几层 await 只是“传话筒”,把结果原样递上去,自己根本没停过;
  • • 有的调用链从头到尾都不会暂停:异步套异步,最里层却是同步逻辑,整条链实际是同步跑完的;
  • • 大量分布式计算系统:代码长得是异步的,实际负载大部分是同步的。

结果就是:每一层 async 方法都提前交了“状态机 + Task 对象”的钱,哪怕它压根没等过一下。

其实在重做 async 之前,.NET 团队还试过另一条路:{BOLD('绿线程')}(Green Thread)——类似 Go 语言的 goroutine、Java 的虚拟线程,在用户态养一堆超轻量的“小线程”。听起来很美好,实测一堆坑:

  • • 再轻也是个完整线程:寄存器、调用栈、调度元数据一样不能少。goroutine 的栈起步就要约 2KB——这点空间够创建几百个 async 状态机;
  • • 碰系统调用就露馅:绿线程最终还得借真正的系统线程去干系统调用的活,来回切换折腾。官方实测做 1 亿次系统调用,耗时从约 300ms 涨到约 1800ms,直接慢了 5 倍以上;
  • • 跟线程绑定的代码合不来:图形界面、各种依赖“必须在某个固定线程上跑”的 API,都会让调度开销暴涨,甚至比直接用系统线程还慢;
  • • 最扎心的一锤:扔进 ASP.NET Core 实测,每秒请求数(RPS)不升反降。

又重又慢还不讨好,这个实验最终被官方放弃,团队转头去搞了真正的答案:Runtime Async。

新思路反而简单粗暴:{BOLD('编译器什么都别拆了')},把 async 方法原始的异步控制流原封不动交给 JIT。以前 JIT 看到的是编译器嚼碎的状态机残渣,现在它直接看到完整逻辑,想优化、想内联,随它。

为此运行时引入了一套全新的调用约定:每次调用异步方法,多塞一个“存档对象”(Continuation),并把结果和存档一起传回来:

打开网易新闻 查看精彩图片

规则很好懂:第一次调用时存档是空的,方法就像普通函数一样一口气往下跑——{BOLD('只要中途没真的暂停,直接把结果带回来,存档还是空的,Task 对象从头到尾都不会被创建')}。只有真的在某处暂停了,运行时才补一个轻量存档(只需几十个字节,记下局部变量和恢复点),暂停结束后带着存档回来接着跑。

看一个递归算斐波那契的异步方法被新编译器处理后的样子:

打开网易新闻 查看精彩图片

注意看:除了原始逻辑,{BOLD('一个状态机都没有')}。状态机、提货凭证、存档……全部消失,直到真正需要暂停的那一刻才出现。这就是官方说的 pay for play:不为用不上的抽象付费。

官方用每天构建的 .NET 11 实测(对比 .NET 10 的传统 async,每组跑一亿次),挑几组有代表性的数据:

打开网易新闻 查看精彩图片

最惊人的是第一行:没有暂停时,异步代码的耗时和纯同步方法的基线(0.33 纳秒){BOLD('几乎完全一样')}——异步抽象的开销被彻底抹平。内存同样夸张:无暂停场景下传统方案跑一亿次分配了 7.2GB 内存,新方案是{BOLD('0 字节')},GC 压力直接归零。而且调用链嵌得越深,提速越明显。

打开网易新闻 查看精彩图片

这事最值得普通人咂摸的一点是:{BOLD('好用和快,经常是一对冤家')}。async/await 让无数程序员告别了手写回调地狱,但这份“顺滑”背后,编译器一直在默默拆你的代码、发你的凭证、收你的税。十几年没人觉得有问题,直到有人较真地算了笔账。

Runtime Async 给出的答案也很漂亮:不是推翻好用的语法,而是{BOLD('让底层聪明到配得上这份顺滑')}——你照样写 await,但没暂停时,一切像没发生过一样轻。这种“按真实使用付费”的设计哲学,值得每个写代码的人记在心里。

留个问题:你在写异步代码时踩过什么坑?是忘了 await 导致结果没等到,还是死锁卡了半天?评论区聊聊,点赞最高的下期展开讲——关注不迷路。