有关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。生成的d__0被传入,用于驱动状态机。

用过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对象。那么除了Task对象之外,还有其他的awaitable对象。例如Task::ConfigureTask()会得到一个ConfiguredTaskAwaitable对象,这个对象可以await。除此之外Task::Yield()会返回YieldAwaitable[8]。

我们可以自己构建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

3,080 Responses to “有关async/await的实现背后”

  1. SmittMip说道:

    deep web drug markets onion market

  2. FrankSah说道:

    dark web drug marketplace dark market 2022