传统 async/await
.NET 自古以来就提供了 async/await 异步编程模型,这套机制允许开发者以同步方式编写异步代码,从而简化了异步编程的复杂性。
async/await 机制本质上是利用 CPS(Continuation Passing Style)变换来实现的。
首先 async/await 模型下,会采用 async 关键字让用户来标记一个方法为异步方法,被标记的方法则会作为 CPS 变换的入口点。当然,async/await 模型下,async 关键字其实并不是必须的,C# 之所以要求 async 关键字,其实只是要让编译器知道在这个方法里,await 不是一个普通的识别符,而是一个用来标记暂停点的关键字。例如在 C++ 中,同样采用了 async/await 模型,但 C++ 并不要求 async 关键字。
而 await 关键字的作用是告诉编译器这里有暂停点,也就是当前方法需要等待一个异步操作完成,从而编译器会以 await 为边界,将当前异步方法拆分成多个部分,并在被 await 的异步操作完成后继续执行剩余的代码。
以下是一个简单的示例:
public async Task<int> GetDataAsync()
{// 模拟异步操作await Task.Delay(1000);return 42;
}
上面这个例子中,Task.Delay(1000) 是一个异步操作,await 关键字会暂停 GetDataAsync 方法的执行,直到 Task.Delay 完成,然后继续执行返回值为 42 的代码。因此,最简单的办法就是将异步方法拆分成多个部分,每个部分在 await 处暂停,等待异步操作完成后继续执行:
class StateMachine
{private int state = 0;// 创建一个用来存储结果的 Task<int>,这个 Task<int> 会在当前异步方法完成时被设置为完成状态。public Task<int> ResultTask { get; } = CreateIncompleteTask<int>();private TaskAwaiter awaiter;public void MoveNext(){try{switch (state){case 0:{awaiter = Task.Delay(1000).GetAwaiter();if (!awaiter.IsCompleted){// 记录恢复位置。state = 1;// 注册 continuation。// 当 Task.Delay 完成后,会触发此前注册的 continuation,使状态机再次执行 MoveNext。// continuation 最终在哪里执行取决于 awaiter 以及当前的 SynchronizationContext / TaskScheduler 等。awaiter.OnCompleted(MoveNext);return;}goto case 1;}case 1:{state = -1;// 确认被 await 的操作已经成功完成,如果失败则会在这里抛出异常。awaiter.GetResult();// 把 Task<int> 完成并把结果设置成 42。CompleteTask(ResultTask, 42);return;}}}catch (Exception ex){// 如果在 MoveNext 中抛出了异常,则把 Task<int> 设置为失败状态。// 这样调用方在 await GetDataAsync() 时就能接收到异常并进行处理。FailTask(ResultTask, ex);}}
}
这么一来,整个异步方法就被拆分成了多个状态机的状态,每个状态对应着 await 关键字的边界。当异步操作完成时,状态机会继续执行剩余的代码。于是 GetDataAsync 方法实际上就会被编译成:
public Task<int> GetDataAsync()
{var stateMachine = new StateMachine();stateMachine.MoveNext();return stateMachine.ResultTask;
}
上面的 CreateIncompleteTask 和 CompleteTask 只是为了说明原理而使用的伪代码。实际的 C# 并不会直接操作 Task,而是通过 AsyncTaskMethodBuilder<int> 来创建并完成代表整个异步方法的 Task<int>。
传统 async 的局限性
你可能会注意到,虽然 async/await 提供了简洁的异步编程模型,但它也有一些局限性。
首先,C# 编译器在变换异步方法的时候,其实是不知道一个异步调用到底会不会真正暂停的。实际上,很多异步方法可能根本不会暂停,例如:
public async Task<int> GetDataAsync()
{return await GetValueAsync();
}public async Task<int> GetValueAsync()
{return 42;
}
C# 编译器会为两个方法都生成状态机和 Task<int>,因为 C# 编译器的编译单元是方法,无法在编译 GetDataAsync 的时候看到 GetValueAsync 的具体实现。
那你说,既然 C# 编译器无法判断,那到运行时,轮到 JIT 编译器这个方法的时候总该能判断了吧?
其实也不行。C# 编译器会把异步方法改写成状态机,并通过 MoveNext、awaiter 和 method builder 来驱动执行。这样一来,当代码最终交给 JIT 时,JIT 看到的已经不是 A -- await B -- await C 这样直接的异步调用链,而是一系列状态机、Task、awaiter 和 continuation 之间的交互。这会使很多原本可以跨方法进行的优化变得非常困难。尤其是在整个异步调用链实际上都没有发生暂停的情况下,从语义上看这些调用完全可以像普通的同步函数调用一样执行,但 C# 编译器已经提前把这种高层异步语义拆散了,JIT 很难再把它重新恢复出来。
其次,MoveNext 方法通常非常大,因为它包含了整个异步方法的逻辑。JIT 在编译 MoveNext 时通常会因为代码体积过大而避免内联,从而进一步导致 JIT 看不到整个异步调用链,因此哪怕 JIT 想要做一些跨方法的优化也很难做到。类似的原因,JIT 也很难把多个异步调用链给内联到一起。
最后,async/await 模型下,异步方法的返回值是一个 Task 或 Task<T>,这通常意味着每次调用异步方法都会创建一个新的 Task 对象。这在高性能场景下可能会带来额外的内存分配。于是诞生了诸如 ValueTask 这样的优化方案,通过把返回值类型改成值类型并通过 IValueTaskSource 来实现异步操作的复用,从而减少内存分配。
上述问题在暂停真正发生的情况下其实并不是什么太大的问题,因此如果代码真正暂停了,那 JIT 就算看穿了整个异步调用链,也无法做任何优化,并且由于被暂停的代码是在之后才被恢复执行的,因此也确实需要一个 Task 对象来存储结果。
然而事实证明其实很多异步方法根本不会暂停,尤其是在调用链较深的情况以及各种基于异步模型来做的分布式计算系统中:
- 很多异步方法的调用链实际上只有最里层的异步方法才会真正暂停,而上层的异步方法只是简单地把结果传递下去。
- 还有一些异步方法的调用链实际上根本不会暂停,例如在一个异步方法里调用了一个同步方法,而这个同步方法又调用了另一个异步方法,这样的调用链实际上是同步的。
- 更有不少系统是基于异步模型来做的分布式计算系统,虽然它们的调用链看起来是异步的,但实际上大部分负载都是同步的。
这样一来,传统 async/await 模型每遇到一个异步方法就得进行状态机的变换,从而引入了不必要的性能开销。
Green Thread
其实在本文即将重点介绍的 Runtime Async 之前,.NET 还实验过 Green Thread 的方案,类似于 goroutine 和 Java Virtual Thread,在用户态实现轻量级线程,从而避免了线程切换的开销。
然而这种方案有天然的缺陷:
Green Thread 再轻量其本质上仍然是一个完整的执行上下文,因此至少需要保存寄存器状态、调用栈以及运行时调度所需的各种元数据。例如 goroutine 的用户栈初始大小大约就是 2 KB,随后再根据需要动态扩张,但有这 2KB 都够创建几百个 async 状态机了。
另外,Green Thread 需要运行时在用户态实现线程调度,调度行为和运行时高度耦合,导致开发者无法自由地控制调度行为。
还有,由于 Green Thread 并不是操作系统线程,真正的系统调用最终仍然需要由底层承载它的系统线程来执行。因此在涉及系统调用时,运行时还需要处理 Green Thread 与系统线程之间的切换、挂起与恢复等额外工作,这种开销可以达到普通线程直接执行系统调用的几十倍。.NET 的 Green Thread 实验中发现 Green Thread 上做系统调用 1 亿次,从原来的约 300 ms 增加到约 1800 ms,直接原地慢了 5 倍以上。
再有,Green Thread 和硬件安全机制也有冲突。例如 Intel CET Shadow Stack 会由硬件维护一份受保护的返回地址栈,并在函数返回时检查普通调用栈中的返回地址是否与 Shadow Stack 一致。而 Green Thread 通常会在用户态自行切换调用栈,因此运行时不仅需要切换普通栈指针,还必须正确维护与底层系统线程相关的 Shadow Stack 状态。这使得 Green Thread 与这类硬件控制流保护机制的集成变得更加复杂,甚至需要操作系统提供专门的支持。
除此之外,线程亲和性也是一个问题。Green Thread 通常由运行时调度,并不保证恢复执行时仍然运行在原来的系统线程上。但现实中存在大量依赖特定系统线程的 API,例如部分 GUI、OS 以及各种依赖 thread-local 的代码。这就得把 Green Thread 固定到某个系统线程,或者在进入相关代码时执行额外的调度和切换。一旦大量代码具有这种要求,比如 GUI 应用中消息循环可能会以每秒上万次的频率调用线程亲和的 API,那么 Green Thread 的调度开销就会变得非常大,甚至比直接使用系统线程还要慢。
.NET 官方在实现完 Green Thread 后发现这玩意不仅局限性很大,而且扔到 asp.net core 里跑发现 RPS 居然不升反降,合着 Green Thread 需要妥协这么多东西最后还不如原来的 async/await 性能好。于是宣布放弃 Green Thread 的实验,转而开发 Runtime Async。
Runtime Async
传统 async/await 需要由 C# 编译器在编译时生成状态机,这破坏了 JIT 对整个异步调用链的优化能力。那解决这个问题的办法非常简单,C# 编译器什么都不做,把原始的异步控制流直接交给 JIT 处理不就行了吗?于是 Runtime Async 就诞生了。
Runtime Async 给 .NET 运行时引入了一套全新的调用约定:Async Calling Convention。这个调用约定会使用 MethodImplOptions.Async 来标记,当然这是内部表示,用户并不能直接使用。用户编写的代码仍然是原来的 async/await 形式:
async Task<int> A()
{return await B();
}
在传统 async 中,JIT 看到的是 C# 编译器已经生成好的 MoveNext 状态机;而在 Runtime Async 中,JIT 可以直接看到这个方法原始的异步控制流,并将 Runtime Async 方法按照一种特殊的 async calling convention 编译。这套调用约定会在在普通的方法调用约定之外,再额外传递一个 Continuation 对象。
例如,一个普通的方法调用类似于:
result = B(args);
而在 Runtime Async 中,调用约定会变成:
(result, continuation) = B(continuation, args);
这里的 continuation 用来表示整个异步调用链在发生暂停后继续执行所需要的状态。
当第一次调用异步方法时,传入的 Continuation 为 null,方法就像普通同步方法一样从头开始执行。如果整个方法执行过程中都没有真正发生暂停,那么它就会直接返回正常的结果,同时返回一个空的 Continuation 表示整个调用链没有发生暂停。
但如果执行到某个 await 时,被等待的异步操作尚未完成,那么当前异步调用链就需要暂停。此时运行时会保存继续执行所需要的状态,并返回一个非空的 Continuation 对象给调用方,里面存储了保存的异步状态。调用方在收到非空的 Continuation 后,就知道整个异步调用链已经暂停了,并且需要在被等待的异步操作完成后继续执行。
等到被等待的异步操作完成以后,运行时会再次进入这个 Runtime Async 方法,并把之前保存的 Continuation 作为额外参数传回来。此时方法就会从上次暂停的地方继续执行,直到整个异步调用链完成。
这么一来,正常返回值和额外的 Continuation 都属于调用约定的一部分,因此它们都可以直接通过寄存器传递,而不需要先包装到某个对象中再返回。也就是说,Runtime Async 保留普通返回值原本的 ABI,同时额外增加一条用于传递 Continuation 的通道。只要目标架构的调用约定允许,返回值和 Continuation 都可以被放进寄存器里。当异步调用没有真正发生暂停时,整条调用链的数据传递形式可以说跟普通同步函数调用没区别:参数走寄存器,返回值走寄存器,额外的 Continuation 也走寄存器,并不需要为每一层 async 调用创建额外的结果包装对象,于是这部分的开销直接归零。
也就是说,虽然你的方法返回的是 Task<T>,但在整个异步调用链中,如果没有真正发生暂停,那么这个 Task<T> 对象就根本不会被创建,而是直接返回 T 的值。
另外,由于 JIT 能够直接看到完整的异步调用控制流,这使得其可以在整个异步调用链中进行跨方法的优化,甚至还可以在整个异步调用链中进行内联,从而进一步提高性能。
例子
接下来让我们看看 Runtime Async 会生成什么样的代码。考虑下面这个递归计算斐波那契数列的异步方法:
class Program
{async Task<int> Fib(int n){if (n <= 1)return n;return await Fib(n - 1) + await Fib(n - 2);}
}
JIT 给我们编译出来了类似下面的代码,虽然很长但姑且先贴在这里,下面会解释。
Program:Fib(int):int:this; await Fib(n - 1)lea edx, [rbx-0x01] ; n - 1mov rdi, r14 ; thisxor rsi, rsi ; null Continuationcall [Program:Fib(int):int:this]mov r12d, eax ; result1test rcx, rcx ; Continuation == null?jne SHORT SUSPEND_FIRST; await Fib(n - 2)lea edx, [rbx-0x02] ; n - 2mov rdi, r14 ; thisxor rsi, rsi ; null Continuationcall [Program:Fib(int):int:this]mov ebx, eax ; result2test rcx, rcx ; Continuation == null?jne SHORT SUSPEND_SECOND; 两个调用都同步完成的情况,直接返回结果。add ebx, r12dmov eax, ebx ; return valuexor ecx, ecx ; null ContinuationretSUSPEND_FIRST:; Fib(n - 1) 暂停了,于是我们必须创建一个 Continuation 来保存当前的执行状态。mov rdi, rcxmov rsi, 0x... ; Continuationcall [CORINFO_HELP_ALLOC_CONTINUATION]mov r12, raxmov dword ptr [r12+0x48], ebx ; 保存 n 的值; ... 保存其他需要保存的状态 ...mov rcx, r12 ; return ContinuationretSUSPEND_SECOND:; Fib(n - 2) 暂停了,于是我们必须创建一个 Continuation 来保存当前的执行状态。mov rdi, rcxmov rsi, 0x... ; Continuation typecall [CORINFO_HELP_ALLOC_CONTINUATION]mov r15, raxmov dword ptr [r15+0x4C], r12d ; 保存 Fib(n - 1) 的结果; ... 保存其他需要保存的状态 ...mov rcx, r15 ; return Continuationret; --------------------------------------------Program:Fib(int):Task<int>:thismov rdi, rbx ; thismov edx, r15d ; nxor rsi, rsi ; null Continuationcall [Program:Fib(int):int:this] ; 调用真正的 Runtime Async 方法mov ebx, eax ; resulttest rcx, rcx ; Continuation == null?jne THUNK_SUSPENDED; return Task.FromResult(ebx)mov rax, <Task<int>>retTHUNK_SUSPENDED:; var task = new RuntimeAsyncTask<int>();; 把 continuation 连接到 task;; return task;
可以看到对于这个方法,JIT 实际上会生成一个采用 Async Calling Convention 的内部版本 Program:Fib(int):int:this,返回值类型已经不是原来的 Task<int> 了。
在 x64 上,这个方法通过寄存器传递参数(this 指针、Continuation 指针和 n 的值):
mov r14, rdi ; this
mov r15, rsi ; Continuation
mov ebx, edx ; n
第一次调用 Runtime Async 方法时,并没有需要恢复的状态,因此传入的 Continuation 为 null。
例如第一次递归调用:
await Fib(n - 1)
被编译成:
lea edx, [rbx-0x01] ; n - 1
mov rdi, r14 ; this
xor rsi, rsi ; Continuation = nullcall [Program:Fib(int):int:this]
而 Fib(n - 1) 实际上返回了两个值:
eax = Fib 的 int 返回值
rcx = Continuation
当然,这里其实并不是一个 (int, Continuation) 元组;这是 ABI 上的两个独立返回通道。
于是调用方只需要:
mov r12d, eax
test rcx, rcx ; Continuation 是否为 null
jne SUSPEND ; 如果不为 null,说明发生了暂停
就可以同时获得异步方法的返回结果,并判断这次调用是否发生了暂停。
如果 rcx == null,说明调用已经同步完成,此时 eax 中就是有效的返回值,于是程序可以立即继续执行:
lea edx, [rbx-0x02]
mov rdi, r14
xor rsi, rsicall [Program:Fib(int):int:this] ; 进行第二次递归调用 Fib(n - 2)
换成接近 C# 的伪代码,就是:
var (result1, continuation1) = Fib(null, n - 1);if (continuation1 != null)Suspend(continuation1);var (result2, continuation2) = Fib(null, n - 2);
// ...
当然,Continuation 非空的情况也能直接从生成代码中看到。
第一次递归调用之后:
call [Program:Fib(int):int:this]mov r12d, eax
test rcx, rcx
jne SHORT SUSPEND
如果 rcx != null,说明被调用的 Fib 没有同步完成。这时候当前 Fib 自己也必须暂停。
JIT 才会在这一刻真正创建保存当前执行状态所需要的 Continuation:
mov rdi, rcx
mov rsi, 0x... ; Continuation type
call [CORINFO_HELP_ALLOC_CONTINUATION]mov r12, rax
随后把恢复执行时仍然需要的局部状态保存进去:
mov dword ptr [r12+0x48], ebx
最后:
mov rcx, r12
ret
把刚刚创建好的 Continuation 放进 rcx,沿着 Async Calling Convention 返回给上一层。相较于 Green Thread,Runtime Async 的 Continuation 只是一个非常轻量级的对象,它只需要保存非常少量的东西,例如跨越暂停点后仍然存活的局部变量、当前需要从哪个暂停点恢复、被等待操作的返回值或异常状态等等。保存这这些东西只需要几十个字节,因此 Runtime Async 的开销远小于 Green Thread。
最终,等价的 C# 伪代码类似于:
var (result1, continuation1) = Fib(null, n - 1);if (continuation1 != null)Suspend(continuation1);var (result2, continuation2) = Fib(null, n - 2);if (continuation2 != null)Suspend(continuation2);return result1 + result2;
而实际上,因为这个 Fibonacci 示例中的所有调用都会同步完成,所以正常执行路径最终只是不断递归调用,于是实际上等价为:
var result1 = Fib(n - 1);
var result2 = Fib(n - 2);
return result1 + result2;
你会发现,整个调用链中根本没有创建任何 Task 对象,也没有任何状态机的开销,整个调用链就像普通的同步函数调用一样执行。所有的异步抽象开销全部消失了!这与传统 async 的执行模型有本质区别。
不过相信你会发现,为什么上面明明有 Program:Fib(int):int:this,却同时还有 Program:Fib(int):System.Threading.Tasks.Task`1[int]:this 呢?这是因为 Runtime Async 内部的方法调用采用新的 Async Calling Convention,但从普通 C# 代码看来,Fib 的签名仍然是 Task<int> Fib(int)。因此运行时需要在两种调用约定之间放置一个边界,这个边界就是 async thunk。对于这里的 Task<int> 方法,它负责把 Runtime Async 内部的普通返回值 + Continuation 转换成外部调用方所期待的 Task<int>。
而这个 thunk 中其实也有前面说过的类似代码:
xor rsi, rsi
call [Program:Fib(int):int:this]mov ebx, eax
test rcx, rcx ; Continuation 是否为 null
也就是先调用真正的 Runtime Async 方法后,检查返回的 Continuation 是否为 null,如果为 null 说明已经同步完成,那么直接返回一个 Task<int> 对象包装一下结果即可。而且这样一来,如果 thunk 后续能够被内联,并且 JIT 能证明这个 Task 不会逃逸,就存在进一步通过逃逸分析消除这次分配。
性能测试
接下来我们来看看 Runtime Async 的性能表现。测试代码见:https://gist.github.com/hez2010/d1802e7c7ab10e21a92dcba2afe0a58d 。
这个测试包含了各种不同的场景:
- Synchronous baseline:同步基准测试,直接调用普通方法
- Async method, no suspension:异步方法,但没有发生暂停。
- Completed Task await:异步方法,等待一个已经完成的 Task
- Completed ValueTask await:异步方法,等待一个已经完成的 ValueTask
- Task.Yield suspension:异步方法,等待一个 Task.Yield 导致的暂停
- ThreadPool continuation:异步方法,等待一个 ThreadPool 上的 continuation 导致的暂停
- TaskCompletionSource continuation:异步方法,等待一个 TaskCompletionSource 导致的暂停
- Async state-machine chain:异步状态机调用链,等待一个嵌套了多层的异步调用链,最里层由 Task.Yield 导致暂停
测试目前最新的 .NET 11 每日构建版本的 Runtime Async(Async2),对比 .NET 10 的传统 async(Async1)。预热之后各个测试运行一亿次,结果如下:
| Benchmark | Ops | Async1 Time/op | Async2 Time/op | Ratio | Async1 Throughput | Async2 Throughput | Async1 Total Alloc | Async2 Total Alloc | Async1 Bytes/op | Async2 Bytes/op | Async1 Gen0 | Async2 Gen0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Synchronous baseline | 100.0M | 0.33 ns | 0.33 ns | 1.00× | 3.008B ops/s | 3.004B ops/s | 696 B | 696 B | 0 | 0 | 0 | 0 |
| Async method, no suspension | 100.0M | 6.58 ns | 0.34 ns | 19.63× | 152.0M ops/s | 2.984B ops/s | 7.20 GB | 696 B | 72.0000 | 0 | 459 | 0 |
| Completed Task await | 100.0M | 4.01 ns | 0.33 ns | 12.02× | 249.1M ops/s | 2.995B ops/s | 7.20 GB | 696 B | 72.0000 | 0 | 459 | 0 |
| Completed ValueTask await | 100.0M | 0.75 ns | 0.33 ns | 2.25× | 1.329B ops/s | 2.987B ops/s | 696 B | 696 B | 0 | 0 | 0 | 0 |
| Task.Yield suspension | 100.0M | 242.89 ns | 34.68 ns | 7.00× | 4.12M ops/s | 28.84M ops/s | 992 B | 1,000 B | 0 | 0 | 0 | 0 |
| ThreadPool continuation | 100.0M | 324.78 ns | 102.35 ns | 3.17× | 3.08M ops/s | 9.77M ops/s | 16.00 GB | 15.20 GB | 160.0000 | 152.0000 | 1,027 | 969 |
| TaskCompletionSource continuation | 100.0M | 455.50 ns | 114.16 ns | 3.99× | 2.20M ops/s | 8.76M ops/s | 16.00 GB | 16.00 GB | 160.0001 | 160.0000 | 1,027 | 1,021 |
| Async state-machine chain | 100.0M | 678.33 ns | 91.68 ns | 7.40× | 1.47M ops/s | 10.91M ops/s | 30.10 GB | 19.20 GB | 300.9802 | 192.0000 | 1,927 | 1,226 |
结果简直令人震惊!Runtime Async 在没有发生暂停的情况下,几乎完全消除了传统 async 的开销,性能提升了近 20 倍,执行速度跟同步方法的基线几乎没有差别。
而在发生暂停的情况下,Runtime Async 也有显著的性能提升,ThreadPool continuation 和 TaskCompletionSource continuation 的性能提升了 3~4 倍。调用链更深的 Async state-machine chain 的性能更是提升了 7.4 倍,并且调用链越深性能提升还会越大!
另外,在所有测试中,Runtime Async 的内存分配都比传统 async 少了很多。尤其是在没有发生暂停的情况下,Runtime Async 直接把内存分配和 GC 全都降到了 0,这意味着整个调用链中没有创建任何 Task 对象,也没有任何状态机的开销。
总结
Runtime Async 是 .NET 11 引入的一套全新的异步执行机制。它不再让 C# 编译器提前把 async 方法展开成状态机,而是把异步控制流保留到运行时,由 JIT 直接处理和优化。
这一套机制也真正实现了 pay for play:不暂停就不为异步抽象付费,而真正暂停时也只需要为实际使用的状态付费。无论暂停还是不暂停,Runtime Async 都能以最小的开销执行。
