有关async/await的实现背后
目录
开始用async/await
最近几个项目中涉及到了.Net Framework 4.5和WinRT,这才算用上了之前一直认为是新·语法糖的async/await异步编程方式。类似之前需要等待一段时间才能完成的的代码——比如网络访问、文件读写、新dialog中用户交互等——可以不用启动新的线程、等待回调、返回到UI线程后再更新界面,而是直接按照同步方式进行调用。代码的可读性会大大提高,这一点毋庸置疑,例如下面的(伪)代码:
void Download( string fileUri ) {
Networking.Download( fileUri, ( response ) => {
Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => {
DownloadStatus = "Done!";
} );
} );
}
就可以转写为:
async void Download( string fileUri ) {
await Networking.Download( fileUri );
DownloadStatus = "Done!";
}
至少最后那堆括号没有了。实际上async/await提供的不仅是语义上更符合常理的用法。那么这是如何实现的?
[Symbian中的实现]
说点题外话。有过Symbian开发经验的程序员一定也遇到过异步方法需要同步调用的头疼情况,例如用户选取Gallery中的图片或者需要等待网络请求完成后再继续操作。由于Active Object的存在,Symbian中的单线程模拟异步方式实现起来麻烦得很,所以后来大家要么用局部TReqeustStatus+FOREVER循环,要么用User::WaitForRequest()的方式卡死当前上下文。结果就是导致了无数奇怪的Bug。这部分和本文其实没什么太大关系,只是因为当年被Symbian荼毒过深,不由自主想起来了而已:)。
新模型的基本概念
先从结论说起:async/await理论上讲还是C#语言层次的语法糖,在IL层面不涉及到新的指令;但它的实现并非“启动一个新线程去更新数据”,而是利用同步上下文实现了并行性(Concurrency),比起多线程来说,这个实现无论在时间还是空间消耗上,性能并不逊色[2]。
退一步说,不要说新的线程,async关键字本身甚至不会导致函数以异步方式运行,不管这个函数是否有Task或者Task<>返回值,它所做的就是标记内部可能会有await实现[3]。而在另一方面,await用于标记可等待(awaitable)的实现方法。简单说来就是在await出现的地方,把后面需要同步执行的代码作为“后续(continuation)”记录,当作异步方法的回调;这样异步方法调用部分将会和同步方法“分离(slicing)”开来,包装成数段代码,后面几节会很清晰地看到——后续的同步代码会作为delegate在异步操作完成后执行。
从IL入手
了解语法层面新关键字、功能的实现,ildasm永远是最好用的。大概在大前年还是前年,项目不太紧张的时候我跟着《Microsoft .Net IL汇编语言程序设计》和另外一本《IL Book》看了一段时间IL指令集,记了些笔记,还手写过点代码片段。可惜后来扔下了,因为越看越觉得过于细节接触IL指令集没什么大用,随用随查才是王道。基础指令还是应该记下来,以备不时之需,必须现在就需要了。这里我就不列出具体的指令作用,如果有需要的同学可以参考Under the covers of the async modifier and await operator in .NET 4.5 and C# Metro style applications这篇精彩的分析[4],里面用表格形式说明了具体类似于stloc, stfld, ldarg, ldloc, new等等指令和IL code中各field的作用。
那么先写出C#代码:
class AsyncClass {
public async void FooAsync() {
int i = 10;
var files = await ApplicationData.Current.LocalFolder.GetFilesAsync();
i = 20;
}
public async Task FooAsync2() {
int i = 10;
var files = await ApplicationData.Current.LocalFolder.GetFilesAsync();
i = 20;
return string.Empty;
}
}
用ildasm打开,大概的情况如图:
首先值得值得注意的是,就如同匿名方法、扩展方法一样,async/await并未引入任何新的IL指令,一切都是通过上层实现的。可以看到IL Assemler生成了两个形如< FooAsync >d__0的结构体,继续打开看看:
这个结构体从IAsyncStateMachine实现,包含很多field和两个方法MoveNext和SetStateMachine。这些field中包括执行上下文的this指针(AsyncClass)、async方法内用到的局部变量(files, i, j),再加上框架生成的状态机相关的类(awaiter等)。SetStateMachine明显是IAsyncStateMachine接口中定义的需要实现的方法,那么MoveNext打开看看(IL代码太多,故都折叠了):
.method private hidebysig newslot virtual final
instance void MoveNext() cil managed
{
.override [System.Threading.Tasks]System.Runtime.CompilerServices.IAsyncStateMachine::MoveNext
// 代码大小 213 (0xd5)
.maxstack 3
.locals init ([0] bool '<>t__doFinallyBodies',
[1] class [System.Runtime]System.Exception '<>t__ex',
[2] int32 CS$4$0000,
[3] valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1> CS$0$0001,
[4] valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1> CS$0$0002,
[5] class [System.Runtime]System.Collections.Generic.IReadOnlyList`1 CS$0$0003)
.try
{
IL_0000: ldc.i4.1
IL_0001: stloc.0
IL_0002: ldarg.0
IL_0003: ldfld int32 BehindAsync.AsyncClass/'d__0'::'<>1__state'
IL_0008: stloc.2
IL_0009: ldloc.2
IL_000a: ldc.i4.s -3
IL_000c: beq.s IL_0014
IL_000e: ldloc.2
IL_000f: ldc.i4.0
IL_0010: beq.s IL_0019
IL_0012: br.s IL_001b
IL_0014: br IL_00a4
IL_0019: br.s IL_0065
IL_001b: br.s IL_001d
IL_001d: nop
IL_001e: ldarg.0
IL_001f: ldc.i4.s 10
IL_0021: stfld int32 BehindAsync.AsyncClass/'d__0'::'5__1'
IL_0026: call class [Windows]Windows.Storage.ApplicationData [Windows]Windows.Storage.ApplicationData::get_Current()
IL_002b: callvirt instance class [Windows]Windows.Storage.StorageFolder [Windows]Windows.Storage.ApplicationData::get_LocalFolder()
IL_0030: callvirt instance class [Windows]Windows.Foundation.IAsyncOperation`1> [Windows]Windows.Storage.StorageFolder::GetFilesAsync()
IL_0035: call valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1 [System.Runtime.WindowsRuntime]System.WindowsRuntimeSystemExtensions::GetAwaiter>(class [Windows]Windows.Foundation.IAsyncOperation`1)
IL_003a: stloc.3
IL_003b: ldloca.s CS$0$0001
IL_003d: call instance bool valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1>::get_IsCompleted()
IL_0042: brtrue.s IL_0083
IL_0044: ldarg.0
IL_0045: ldc.i4.0
IL_0046: stfld int32 BehindAsync.AsyncClass/'d__0'::'<>1__state'
IL_004b: ldarg.0
IL_004c: ldloc.3
IL_004d: stfld valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1> BehindAsync.AsyncClass/'d__0'::'<>u__$awaiter3'
IL_0052: ldarg.0
IL_0053: ldflda valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder BehindAsync.AsyncClass/'d__0'::'<>t__builder'
IL_0058: ldloca.s CS$0$0001
IL_005a: ldarg.0
IL_005b: call instance void [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::AwaitUnsafeOnCompleted>,valuetype BehindAsync.AsyncClass/'d__0'>(!!0&,
!!1&)
IL_0060: nop
IL_0061: ldc.i4.0
IL_0062: stloc.0
IL_0063: leave.s IL_00d3
IL_0065: ldarg.0
IL_0066: ldfld valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1> BehindAsync.AsyncClass/'d__0'::'<>u__$awaiter3'
IL_006b: stloc.3
IL_006c: ldarg.0
IL_006d: ldloca.s CS$0$0002
IL_006f: initobj valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1>
IL_0075: ldloc.s CS$0$0002
IL_0077: stfld valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1> BehindAsync.AsyncClass/'d__0'::'<>u__$awaiter3'
IL_007c: ldarg.0
IL_007d: ldc.i4.m1
IL_007e: stfld int32 BehindAsync.AsyncClass/'d__0'::'<>1__state'
IL_0083: ldloca.s CS$0$0001
IL_0085: call instance !0 valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1>::GetResult()
IL_008a: ldloca.s CS$0$0001
IL_008c: initobj valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.TaskAwaiter`1>
IL_0092: stloc.s CS$0$0003
IL_0094: ldarg.0
IL_0095: ldloc.s CS$0$0003
IL_0097: stfld class [System.Runtime]System.Collections.Generic.IReadOnlyList`1 BehindAsync.AsyncClass/'d__0'::'5__2'
IL_009c: ldarg.0
IL_009d: ldc.i4.s 20
IL_009f: stfld int32 BehindAsync.AsyncClass/'d__0'::'5__1'
IL_00a4: leave.s IL_00be
} // end .try
catch [System.Runtime]System.Exception
{
IL_00a6: stloc.1
IL_00a7: ldarg.0
IL_00a8: ldc.i4.s -2
IL_00aa: stfld int32 BehindAsync.AsyncClass/'d__0'::'<>1__state'
IL_00af: ldarg.0
IL_00b0: ldflda valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder BehindAsync.AsyncClass/'d__0'::'<>t__builder'
IL_00b5: ldloc.1
IL_00b6: call instance void [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::SetException(class [System.Runtime]System.Exception)
IL_00bb: nop
IL_00bc: leave.s IL_00d3
} // end handler
IL_00be: nop
IL_00bf: ldarg.0
IL_00c0: ldc.i4.s -2
IL_00c2: stfld int32 BehindAsync.AsyncClass/'d__0'::'<>1__state'
IL_00c7: ldarg.0
IL_00c8: ldflda valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder BehindAsync.AsyncClass/'d__0'::'<>t__builder'
IL_00cd: call instance void [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::SetResult()
IL_00d2: nop
IL_00d3: nop
IL_00d4: ret
} // end of method 'd__0'::MoveNext
虽然一眼看到了实际的异步方法调用确实被包装在了MoveNext中,但这里面的处理略显复杂。跟着IL代码从头走下来是最简单直接的途径,无他。那么具体这个方法做了什么事?
首先可以看到IL_0003到0C段,状态机通过switch语句进行了状态__state检查:
- 是否同-3相等,如果是跳转到IL_0014处。这个跳转又直接br到了IL_00a4,直接退出了程序,那么这里应该是__state==COMPLETED一类的处理。
- 是否同0相等,如果是跳转到IL_0019处,接着跳转到IL_0065,用于处理最后一段同步代码。这里__state被赋值为-1,awaiter被初始化,等待调用。接下来awaiter.GetResult()调用,__state赋值-2,刚刚得到的Result被用于AsyncVoidMethodBuilder::SetResult(),这里应该是没有后续await方法的情况。函数返回,等待下一步调用(退出)。
- 是否既不为-3又不为0,如果是则跳转到IL_001b,接着跳转到IL_001d,用于处理第一段同步代码。这里实际的异步方法被调用。awaiter::get_IsCompleted()被检查是否为true,也就是说如果awaitable Task被完成了的话,直接跳转到刚刚awaiter.GetResult()的地方;如果未完成,则__state赋值为0,调用AsyncVoidMethodBuilder::AwaitUnsafeOnCompleted,传入awaiter和this作为参数,函数就此返回,等待下一步调用。
除了正常流程之外,对于执行期间的异常也进行了捕获。出现后__state赋值为-2,并且调用AsyncVoidMethodBuilder::SetException进行处理。
框架为了驱动整个流程,似乎初始时候的__state不能为0,这个猜测也在后面得到了印证。对于所生成结构体运转的结构和理解就是这样了,那么回到原先的AsyncClass处,打开第一个方法FooAsync():
.method public hidebysig instance void FooAsync() cil managed
{
.custom instance void [System.Diagnostics.Debug]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = ( 01 00 00 00 )
.custom instance void [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [System.Runtime]System.Type) = ( 01 00 25 42 65 68 69 6E 64 41 73 79 6E 63 2E 41 // ..%BehindAsync.A
73 79 6E 63 43 6C 61 73 73 2B 3C 46 6F 6F 41 73 // syncClass+d__0..
// 代码大小 48 (0x30)
.maxstack 2
.locals init (valuetype BehindAsync.AsyncClass/'d__0' V_0,
valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder V_1)
IL_0000: ldloca.s V_0
IL_0002: ldarg.0
IL_0003: stfld class BehindAsync.AsyncClass BehindAsync.AsyncClass/'d__0'::'<>4__this'
IL_0008: ldloca.s V_0
IL_000a: call valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Create()
IL_000f: stfld valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder BehindAsync.AsyncClass/'d__0'::'<>t__builder'
IL_0014: ldloca.s V_0
IL_0016: ldc.i4.m1
IL_0017: stfld int32 BehindAsync.AsyncClass/'d__0'::'<>1__state'
IL_001c: ldloca.s V_0
IL_001e: ldfld valuetype [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder BehindAsync.AsyncClass/'d__0'::'<>t__builder'
IL_0023: stloc.1
IL_0024: ldloca.s V_1
IL_0026: ldloca.s V_0
IL_0028: call instance void [System.Threading.Tasks]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Startd__0'>(!!0&)
IL_002d: br.s IL_002f
IL_002f: ret
} // end of method AsyncClass::FooAsync
在此方法中,我们对于await XXXAsync()的调用被翻译成了AsyncVoidMethodBuilder的创建(000a)、初始化(000f-0023)和启动(0028)过程。可以注意到在IL_0016处,刚刚看到的__state被初始化为-1。生成的
用过IL代码了解流程可以保证准确性,但其可读性连差强人意都算不上。后面利用大牛们写的一些C#层次的分析文章,我会回顾一下上面提到的流程,以便更加清楚整个状态机是如何运转起来的。
回到C#代码
IL Spy的作者Daniel Grunwald在2.0版本中试着对async/await相关机制进行了反编译的支持,其中提到编译器生成的代码(compiler generation)类似于yield语句的代码结构,就如我们在上面IL代码中看到的一样,整个awaitable task运转过程是由状态机驱动的。只不过那篇Blog撰写时候IL Spy的async/await解析支持还在制作中,所以生成的C#代码并非完美。
下面则是一个更接近于实际代码的版本[7],整体的数据结构和代码流程和上面一节的分析没有太大区别。
[CompilerGenerated]
[StructLayout(LayoutKind.Auto)]
internal struct MethodAsyncStateMachine : IAsyncStateMachine
{
public int State;
public AsyncTaskMethodBuilder Builder;
public int Arg0;
public int Arg1;
public int Result;
private TaskAwaiter awaitor;
void IAsyncStateMachine.MoveNext()
{
try
{
if (this.State != 0)
{
this.awaitor = HelperMethods.MethodTask(this.Arg0, this.Arg1).GetAwaiter();
if (!this.awaitor.IsCompleted)
{
this.State = 0;
this.Builder.AwaitUnsafeOnCompleted(ref this.awaitor, ref this);
return;
}
}
else
{
this.State = -1;
}
this.Result = this.awaitor.GetResult();
}
catch (Exception exception)
{
this.State = -2;
this.Builder.SetException(exception);
return;
}
this.State = -2;
this.Builder.SetResult(this.Result);
}
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
{
this.Builder.SetStateMachine(param0);
}
}
那么回想提一下,开始时候我们提到过同一个方法内的方法分片(slicing),也就是说不连续的await调用会被视为多段代码,同步调用亦是如此。那么在具体实现上,这些代码段是作为多个状态存在吗?试验一下即可,把FooAsync改为下面这样:
public async void FooAsync() {
int i = 10;
var files = await ApplicationData.Current.LocalFolder.GetFilesAsync();
i = 20;
var folders = await ApplicationData.Current.LocalFolder.GetFoldersAsync();
i = 30;
var items = await ApplicationData.Current.LocalFolder.GetItemsAsync();
int j = 0;
}
用ildasm打开后可以看到,整体的代码并没有出现奇怪的变化,只是随着代码量变大指令调用也多了(不贴了,因为太多,关键在于那个switch table),可视的变化包括:多了两个awaiter;__state的判断多了“1”和“2”两种情况。似乎可以推断出,在StateMachine Struct::MoveNext(),是将原有代码按照一段同步异步代码+一段同步代码组合起来作为一个State,并且State的序号从0开始。以每一段await调用为分界点,将代码切分,异步代码部分作为awaiter存在,后续的同步代码则用匿名函数包装作为回调,在awaiter返回后调用。结合之前的分析结果,__state对应到具体的状态上大概是:
- state == -3:直接结束(似乎只用于比较结果)
- state == -2:流程完毕
- state == -1:流程开始
- state >= 0:第state + 1段异步同步+同步代码
但从实际的生成代码来看,在MoveNext()中调用时视觉上和我们的分析刚好反过来,在switch语句中是一段同步代码跟着一段异步的,在case语句块中的逻辑类似于:执行同步代码、执行异步代码、检查是否Complete、更新__state(置为后续state或者-2)。实际的生成代码中,则是将struct本身指针的MoveNext()方法调用置于awaiter的完成回调中,利用递归实现了状态机的变化[7],更容易阅读和接近实际情况的代码也可以在[7]中看到。
await, awaiter, awaitable
什么是awaitable?顾名思义就是可以用await进行调用的对象。从上面几节的分析可以得出,XXXAsync函数之所以可用await关键字修饰,在于它返回了Task对象(或是Task
我们可以自己构建awaitable对象吗?当然可以,只不过.Net并未提供相关接口,都需要自己进行实现。我们在用var folders = await ApplicationData.Current.LocalFolder.GetFoldersAsync();调用方法的时候,实际上等同于
var operation = ApplicationData.Current.LocalFolder.GetFoldersAsync();
var task = operation.AsTask>();
var awaiter = task.GetAwaiter();
var folders = awaiter.GetResult();
这样一来,我们就知道awaitable对象必须返回一个awaiter,而awaiter需要信号量标识是否完成以及提供返回结果的接口——实现ICriticalNotifyCompletion:
- bool IsCompleted { get; }
- void OnCompleted(Action continuation); (这里的continuation就是刚刚提到MoveNext中异步代码后续的同步代码)
- TResult GetResult();
注意到没,这些Property和Function刚才都在生成代码中见过了。这是一个泛型版本,非泛型的版本仅仅是GetResult()不返回值。在[8]中已经有了实现INotifyCompletion的泛型/非泛型版本的完备版本,同时也有利用现有Task、TaskAwaiter对Action和Func直接进行扩展方法书写,让其支持await调用的例子,大家可以移步参考。
最后一点……Task
Task::ConfigureTask()拿到的ConfiguredTaskAwaitable到底有什么用?ConfigureTask方法接受一个bool值,如果为false则系统可能不会准确地返回到当前的上下文继续执行callback的同步代码,而是在系统认为合适的地方执行[5]。换句话说,如果你在UI线程利用AsTask().ConfirgureTask(false)进行了异步操作,那么要小心这行代码后面的同步代码是否修改了UI控件或是绑定的数据,因为它不一定会回到UI线程继续执行。
Task还提供了一对方法WhenAny()和WhenAll()以及它们的泛型版本,用来对多个同时进行的任务进行同步。从函数名字可以一目了然地清楚它们的作用:传入多个awaitable object后,可以实现类似使用Mutex或是ResetEvent的线程同步。
参考资料
[1] Asynchronous Programming with Async and Await (C# and Visual Basic), MSDN
[2] What is the cost of async/await?, Bnaya Eshet
[3] Async/Await FAQ, MSDN
[4] Under the covers of the async modifier and await operator in .NET 4.5 and C# Metro style applications, Pete Brown
[5] Diving deep with WinRT and await, Windows 8 app developer blog
[6] Decompiling Async/Await, Daniel Grunwald
[7] Understanding C# async / await (1) Compilation, Dixin
[8] Understanding C# async / await (2) Awaitable / Awaiter Pattern, Dixin