异步的原理是什么?C# 如何基于状态机实现异步? 您所在的位置:网站首页 什么是状态机什么是状态图 异步的原理是什么?C# 如何基于状态机实现异步?

异步的原理是什么?C# 如何基于状态机实现异步?

2024-04-22 02:37| 来源: 网络整理| 查看: 265

13 August 2022 CSharp

参考资料:C# in Depth, Fourth Edition - Jon Skeet

1. 通过续延简单理解异步的原理

上篇文章提到了“续延(continuation)”这个概念,通过解释它,简单解释一下异步的原理。提前说一下,原理不等于实现,实际上 C# 实现异步的方式是基于状态机的,稍微有点复杂。

public static async Task Main() { var sw = Stopwatch.StartNew(); Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId); await Task.Delay(100); Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId); Console.WriteLine(sw.ElapsedMilliseconds); }

输出结果:

1.Main: 1 2.Main: 5 115 在执行到 await Task.Delay(100); 前,以同步方式执行 当执行到 await Task.Delay(100); 时,会检查是否已经得到 Task.Delay(100); 的执行结果,如果没有得到结果,就会创建一个续延,然后等待,等得到结果后,就执行该续延 在该例中,等待 100ms 后执行续延。续延会执行方法的剩余内容,即从 Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId); 继续执行 2. 续延(continuation)“本质上”是什么?“实际上”是什么?Task 扮演了什么角色?

注意,这里说的是“本质上”,不是“实际上”。 实际上 C# 实现异步的方式是用了 try-catch 和 switch-case 等。

续延“本质上”是个回调函数(Action 委托),异步操作执行完成后会调用这个回调函数 在异步方法中,续延负责维护方法的状态。就像闭包能捕获(维护)变量上下文一样,续延可以记录方法执行的位置,方便以后恢复方法的执行

续延“本质上”就是可以保存程序执行状态的回调,负责接收异步操作的结果。顺带一提,Task.ContinueWith() 是用来附加续延的方法。

让编译器为我们创建续延,是 await 关键字的作用之一。

前面说了一堆“本质上”,是为了方便理解,但可能弄巧成拙把诸位绕晕了。现在说一下“实际上”。

续延并没有像回调函数一样传递给异步方法,所以并不是异步方法执行完成后调用续延,而是异步操作发起并且返回了一个“令牌”,这个“令牌”可被续延使用 “令牌”通常就是 Task 或 Task 类型的对象,即异步方法返回的对象。但并不强制必须是它们,异步方法也可以返回其它类型或 void “令牌”代表正在执行的操作,它可能在返回给调用方之前就执行完了,也可能还在执行,也可能被取消了。它的状态(Status)是 Task.Status 属性 “令牌”用于通知调用方在该操作完成之前不能进行后续的处理操作 3. 异步的流程

扯到这里,就可以简单说一下异步的流程了:

执行某个方法,例如 Main() 一直执行到一个异步方法,启动这个异步方法,异步方法会立刻返回一个令牌,拿到这个令牌 执行一些其它的同步或异步的操作,也可以什么都不做 通过“令牌”等待异步操作完成(await) 执行其它操作,包括异步操作的后续操作,即续延中的操作

在(1. 通过续延简单理解异步的原理)开头那段示例代码中,实际上就是在第 4 步卡 100ms,await 语句通过 Task.Delay(100) 这个异步方法返回的“令牌” Task 来等待异步操作完成。

示例代码可能不太明显,像下面这样写就很明显了,这里的 Task t 就是异步操作返回的“令牌”:

public static async Task Main() { Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId); Task t = Task.Delay(100); await t; Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId); }

你完全可以在 Task t = Task.Delay(100);语句和 await t; 语句之间插入一些其它的代码,相当于你点外卖,叫了一份黄焖鸡米饭,然后去做别的事了,等其它事情做完,而且饭送到之后(假设骑手可以等你做完其它事再开门取餐),再回来处理黄焖鸡米饭送到之后的续延,比如坐到桌前吃饭。

如果直接 await Task.Delay(100); 或者 Task.Delay(100).Wait();,就会一直等待 Delay 100ms 之后才继续往下执行。就相当于你叫了份黄焖鸡米饭,然后你坐等骑手来送餐,期间什么都不做。

4. 等待异步方法(await)为什么能够不阻塞线程?

因为正在被调用的异步方法遇到 await 后立刻返回了(令牌)。 对调用方来说相当于调用异步方法的这行代码立刻执行完了。

异步方法立即返回一个令牌,之后异步方法继续执行着,调用方拿着异步操作返回的“令牌”,就能知道异步操作何时能完成,然后做出自己接下来要执行操作还是要等待等选择。

5. 异步实际上的原理

从逻辑上理解很简单,但实际上编译器做了很多很难理解的事。我在以前就尝试过反编译异步代码来看看它到底整了啥活,但是只看到一堆 switch-case 和 goto。

比如看这段代码:

static async Task PrintAndWait(TimeSpan delay) { Console.WriteLine("Before first delay"); await Task.Delay(delay); Console.WriteLine("Between delays"); await Task.Delay(delay); Console.WriteLine("After second delay"); } static void Main() { PrintAndWait(TimeSpan.FromSeconds(1)).Wait(); }

这段代码来自 C# in Depth, Fourth Edition,书的作者大慈大悲地把编译器生成的代码翻译成了人话,让我们能看懂,至少能看懂变量名和方法名。下面我们来看编译器生成的代码,一些给我们理解代码造成障碍的 Attribute 我已经删掉了。

6. 状态机

异步实现的原理基于状态机。

状态机的状态

状态当然是状态机最重要的部分,毕竟它就叫“状态”机。

图 1 - 状态机的状态

可以分为这 4 种状态。但异步方法中每个 await 表达式是单独的暂停状态,即可能有多个暂停状态,每次 await 表达式返回后都会触发后续代码的执行。每次状态机需要暂停的时候就记录下状态,为了将来能从当前执行位置恢复执行。现在听起来很抽象,后面我们一起看看代码就知道了。

state 是 int 类型,它有以下几种可能:

-1:尚未启动/正在执行 -2:执行完成/执行出错 其他值:正在某个 await 表达式处暂停

为什么“尚未启动”和“正在执行”状态用同一个值表示?后面讲完状态机我再解释。

为什么“执行完成”和“执行出错”状态用同一个值表示?因为对与状态机来说,这两种情况都属于执行完了,再没它什么事了。

状态机结构体

编译器会生成一个 private 的结构体 PrintAndWaitStateMachine 代表异步方法:

private struct PrintAndWaitStateMachine : IAsyncStateMachine { public int state; // 状态机的状态(需要恢复的位置) public AsyncTaskMethodBuilder builder; // 通用异步基础架构类型关联的 builder private TaskAwaiter awaiter; // 当恢复执行时用于获取结果的 awaiter public TimeSpan delay; // 原始方法参数 // 状态机主要的工作代码 void IAsyncStateMachine.MoveNext() { // 暂时省略...... } void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { this.builder.SetStateMachine(stateMachine); // 连接 builder 和装箱后的状态机 } }

由于我们是基于发布的构建,所以编译器生成的是结构体。如果我们基于调试构建,编译器会生成一个 class。两者主要是性能上的区别。

在分析状态机结构体之前,我们先看一下负责创建状态机对象的存根方法。

存根方法(stub method) private static unsafe Task PrintAndWait(TimeSpan delay) { // 初始化状态机 var machine = new PrintAndWaitStateMachine { delay = delay, builder = AsyncTaskMethodBuilder.Create(), state = -1 }; // 运行状态机,直到它需要等待时 machine.builder.Start(ref machine); // 返回代表异步操作的 Task return machine.builder.Task; } delay 本是 PrintAndWait(TimeSpan delay) 方法的形参,被编译成状态机结构体的一个字段 builder 取决于 async 方法返回类型。可以使用 builder 来生成成功信息或失败信息,处理 await 等,它可理解为 helper state 初始状态为 -1

异步实现的原理基于状态机。编译器会生成一个 private 的嵌套结构体 PrintAndWaitStateMachine 代表异步方法,也就需要一个存根方法 PrintAndWait() 来构建结构体对象。该存根方法与原方法同名,它是一切的开始。

通过观察存根方法的代码,分析一下它做的事:

初始化状态机,得到一个状态机对象 调用状态机的 builder 的 Start() 方法来启动状态机,同时把状态机对象本身作为参数传给它(引用传递,为了保证 state 等字段一致) 返回 builder 的 Task,这就是我们前面说过的“异步方法立即返回”的那一刻。

这样在运行 Start() 方法的过程中,状态机 machine 的 state 的变化就会直接反映在 machine 这个对象上,我们不需要让 Start 返回什么值,我们只需要取 machine 的值就知道 Start 执行到什么情况了。

前面我们状态机结构体代码中有一个最重要的 MoveNext() 方法。状态机 start 之后不会创建新线程,只是执行 MoveNext() 方法,执行到状态机 await 另一个异步方法或者状态机停止为止。不管是哪种,MoveNext() 都会返回,存根方法中调用的 Start() 也会返回。这样就可以把 Task 作为整个异步方法的结果返回给调用方。

状态机的结构

这里我重新贴一下状态机的代码:

private struct PrintAndWaitStateMachine : IAsyncStateMachine { public int state; // 状态机的状态(需要恢复的位置) public AsyncTaskMethodBuilder builder; // 通用异步基础架构类型关联的 builder private TaskAwaiter awaiter; // 当恢复执行时用于获取结果的 awaiter public TimeSpan delay; // 原始方法参数 // 状态机主要的工作代码 void IAsyncStateMachine.MoveNext() { // 暂时省略...... } void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { this.builder.SetStateMachine(stateMachine); // 连接 builder 和装箱后的状态机 } } 实现了 IAsyncStateMachine 接口,该接口有 MoveNext() 和 SetStateMachine(IAsyncStateMachine stateMachine) 方法 各字段保存了状态机步进时需要的信息 状态机启动时以及从“暂停状态”恢复到“运行状态”后会调用 MoveNext()

在完成一些内部事务之后,Start() 方法会调用 MoveNext() 方法来迈出异步方法的第一步。

结构体中还有几个字段,如前面讲过的 state 等。状态机从某个暂停状态恢复时可能会取这些字段的值。

简单说一下 awaiter 这个字段。任何特定的状态机一次只能 await 一个值,所以每个泛型 Task 一次只能有一个与这个泛型相关的 awaiter。例如我们有一个 async 方法,里面一共有 6 个 await 语句,其中有两个是 await Task 类型的返回,一个 await Task,三个 await Task,那么就会编译成 3 个 awaiter 字段,他们的类型分别是:TaskAwaiter,TaskAwaiter,TaskAwaiter。在这 6 次 await 时会复用这三个字段。

MoveNext()

现在来看一下最重要的 MoveNext() 方法。

为了防止忘记,先放一下原方法的代码:

static async Task PrintAndWait(TimeSpan delay) { Console.WriteLine("Before first delay"); await Task.Delay(delay); Console.WriteLine("Between delays"); await Task.Delay(delay); Console.WriteLine("After second delay"); }

然后看一下 MoveNext() 的代码:

void IAsyncStateMachine.MoveNext() { int num = this.state; try { TaskAwaiter awaiter1; switch (num) { default: goto MethodStart; // case 的数量与 await 表达式的数量相等 case 0: goto FirstAwaitContinuation; case 1: goto SecondAwaitContinuation; } MethodStart: Console.WriteLine("Before first delay"); // 第一个 await 表达式之前的代码 awaiter1 = Task.Delay(this.delay).GetAwaiter(); if (awaiter1.IsCompleted) { goto GetFirstAwaitResult; } this.state = num = 0; // 设置状态机状态 this.awaiter = awaiter1; // 设置第一个 awaiter this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this); return; FirstAwaitContinuation: // 从续延中恢复执行的代码 awaiter1 = this.awaiter; this.awaiter = default(TaskAwaiter); this.state = num = -1; GetFirstAwaitResult: // 快速路径和慢速路径汇合处 awaiter1.GetResult(); // 👇 剩余代码,包括更多的标签以及 awaiter 等 Console.WriteLine("Between delays"); TaskAwaiter awaiter2 = Task.Delay(this.delay).GetAwaiter(); if (awaiter2.IsCompleted) { goto GetSecondAwaitResult; } this.state = num = 1; this.awaiter = awaiter2; this.builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this); return; SecondAwaitContinuation: awaiter2 = this.awaiter; this.awaiter = default(TaskAwaiter); this.state = num = -1; GetSecondAwaitResult: awaiter2.GetResult(); Console.WriteLine("After second delay"); } catch (Exception exception) { // 执行出错 this.state = -2; this.builder.SetException(exception); // 通过 builder 填充异常信息 return; } // 正常执行完成 this.state = -2; this.builder.SetResult(); // 通过 builder 填充方法完成的信息 }

过一遍代码,估计有的朋友就看懂了这异步是怎么实现的了。

MoveNext() 方法会在 async 方法第一次调用时被调用,然后每次恢复执行时都被调用一次。 在 await 表达式时,有两种可能,await 的异步操作尚未完成,或者已经完成。例如上面代码中: MethodStart: Console.WriteLine("Before first delay"); awaiter1 = Task.Delay(this.delay).GetAwaiter(); // 两种可能 if (awaiter1.IsCompleted) { goto GetFirstAwaitResult; } this.state = num = 0; this.awaiter = awaiter1; this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this); return; FirstAwaitContinuation: // ...... GetFirstAwaitResult: awaiter1.GetResult(); Console.WriteLine("Between delays"); // ......

如果 awaiter1 等待的异步操作已经完成,即 awaiter1.IsCompleted == true,就会直接运行 goto GetFirstAwaitResult;,也就是直接运行下面的 GetFirstAwaitResult: 代码块。这被称为快速路径(fast path)。

如果并未完成,即 awaiter1.IsCompleted == false,就会继续往下走,状态机进入暂停状态,状态机的 state 字段被置为 0,awaiter 字段被置为还在等的 awaiter1,最后 return。一直等到状态机恢复执行时,MoveNext() 方法再次被调用,进入 switch-case 语句:

switch (num) { default: goto MethodStart; case 0: goto FirstAwaitContinuation; case 1: goto SecondAwaitContinuation; }

因为 num 取的 state 的值,也就是 0,所以直接 goto FirstAwaitContinuation;,代码走进了 FirstAwait 的续延(最开始提到的续延概念),也就是代码走进了 FirstAwaitContinuation: 代码块:

FirstAwaitContinuation: awaiter1 = this.awaiter; this.awaiter = default(TaskAwaiter); this.state = num = -1; GetFirstAwaitResult: awaiter1.GetResult(); Console.WriteLine("Between delays"); // ......

FirstAwaitContinuation: 代码块就赋值一下变量,调整一下状态到 -1(尚未启动/正在执行),就是做了一些状态机恢复运行后的准备操作,然后就又走进了 GetFirstAwaitResult: 代码块。跟快速路径交汇。这叫做慢速路径(slow path)。

如果我们 Delay 的一直是 0ms,或者 await 的一直都是已经完成的异步操作,就会一直走快速路径,MoveNext() 方法从头到尾就只会被调用一次。

看到这里,相信大家都可以读懂上面代码的执行过程了,也就看懂了状态机,也就知道了 C# 异步的实现。

总结一下最重要的 MoveNext() 方法,该方法的工作:

从正确的位置开始执行(起始位置或中间位置,均能正确执行) 即将暂停时保存状态(为状态机结构体对象的 state,awaiter 以及其它变量赋值) 需要暂停时安排一个续延(为 state 赋值即安排续延,因为 MoveNext() 被重新调用后会根据 state 的值选择接下来要运行的代码块,也就是续延) 从 awaiter 获得返回值(上例中没有 await 语句返回值,只有苍白的 awaiter2.GetResult(); 这种语句,没明确体现出来,不过不影响我们理解异步的实现) 通过 builder 生成异常。catch 住异常后执行了这些代码: this.state = -2; this.builder.SetException(exception); // 通过 builder 填充异常信息 return; // 直接 return,并没有 throw 只是通过 builder 来 Set 了异常,然后就 return 了,并没有重新 throw 出异常(只有 StackOverflowException 或 ThreadAbortException 等特殊异常才会直接被抛出去) 通过 builder 生成返回值或完成方法

到现在,C# 异步的实现部分已经基本扯完了。看到这里前面留下的问题:为什么“尚未启动”和“正在执行”状态用同一个值表示?也已经有答案了。因为正常情况下 MoveNext() 只会在状态机刚启动或者恢复执行的时候被调用,所以“尚未启动”和“正在执行”可以用同一个状态码表示。

异步方法的流程图

最后我把 C# in Depth, Fourth Edition 里的异步方法的流程图搞过来了:

图 2 - MoveNext 方法流程图

调用 SetStateMachine() 方法以及 try-catch 没有放到流程图中,但不影响理解。

总结

知道了 C# 如何实现异步之后,我们就能彻底整明白 await 这种看起来很简单实际上很复杂的表达式到底做了什么了。后面有时间的话我再写一下。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有