平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“.NET 11 Runtime Async示例”来说,它看着像小点,放到项目里常会牵出环境、配置、兼容性和维护成本。下面按实际采用顺序,把思路、关键写法和容易踩坑的地方讲清楚,便于大家直接对照操作。
传统 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);
}
}
我们编译出程序集后让 ILSpy 反编译 IL 得到:
internal class Program
{
[MethodImpl(MethodImplOptions.Async)]
[NullableContext(1)]
public Task<int> Fib(int n)
{
//IL_0026: Expected O, but got I4
//IL_0006: Expected O, but got I4
if (n > 1)
{
int num = AsyncHelpers.Await(Fib(n - 1));
int num2 = AsyncHelpers.Await(Fib(n - 2));
return (Task<int>)(num + num2);
}
return (Task<int>)n;
}
}
除了原始逻辑之外什么状态机都没有!
结合项目来看,运行后,JIT 给我们编译出来了类似下面的代码,虽然很长但姑且先贴在这里,下面会解释。
Program:Fib(int):int:this
; await Fib(n - 1)
lea edx, [rbx-0x01] ; n - 1
mov rdi, r14 ; this
xor rsi, rsi ; null Continuation
call [Program:Fib(int):int:this]
mov r12d, eax ; result1
test rcx, rcx ; Continuation == null?
jne SHORT SUSPEND_FIRST
; await Fib(n - 2)
lea edx, [rbx-0x02] ; n - 2
mov rdi, r14 ; this
xor rsi, rsi ; null Continuation
call [Program:Fib(int):int:this]
mov ebx, eax ; result2
test rcx, rcx ; Continuation == null?
jne SHORT SUSPEND_SECOND
; 两个调用都同步完成的情况,直接返回结果。
add ebx, r12d
mov eax, ebx ; return value
xor ecx, ecx ; null Continuation
ret
SUSPEND_FIRST:
; Fib(n - 1) 暂停了,于是我们必须创建一个 Continuation 来保存当前的执行状态。
mov rdi, rcx
mov rsi, 0x... ; Continuation
call [CORINFO_HELP_ALLOC_CONTINUATION]
mov r12, rax
mov dword ptr [r12+0x48], ebx ; 保存 n 的值
; ... 保存其他需要保存的状态 ...
mov rcx, r12 ; return Continuation
ret
SUSPEND_SECOND:
; Fib(n - 2) 暂停了,于是我们必须创建一个 Continuation 来保存当前的执行状态。
mov rdi, rcx
mov rsi, 0x... ; Continuation type
call [CORINFO_HELP_ALLOC_CONTINUATION]
mov r15, rax
mov dword ptr [r15+0x4C], r12d ; 保存 Fib(n - 1) 的结果
; ... 保存其他需要保存的状态 ...
mov rcx, r15 ; return Continuation
ret
; --------------------------------------------
Program:Fib(int):Task<int>:this
mov rdi, rbx ; this
mov edx, r15d ; n
xor rsi, rsi ; null Continuation
call [Program:Fib(int):int:this] ; 调用真正的 Runtime Async 方法
mov ebx, eax ; result
test rcx, rcx ; Continuation == null?
jne THUNK_SUSPENDED
; return Task.FromResult(ebx)
mov rax, <Task<int>>
ret
THUNK_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 = null
call [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, rsi
call [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 的性能表现。测试代码见:(链接已移除)。
这个测试包含了各种不同的场景:
- 结合项目来看,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 都能以最小的开销执行。
到此这篇关于.NET 11 Runtime Async 详解的文章就介绍到这了,更多相关.NET Runtime Async内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多兼容脚本之家!
- .NET Runtime 是什么及主要功能
- Asp.Net 程序错误Runtime Error原因与解决
- 在.NET Core中async与await采用场景及区别介绍
- 谈谈对.NET中async/await的理解
- 浅析.NET中AsyncLocal的实现原理
- .Net借助TaskFactory.FromAsync简化APM
- .NET core项目AsyncLocal在链路追踪中的应用
- .NET Core借助 AsyncLocal 实现共享变量的代码详解
- .NET实现异步编程async和await