Post

异步状态机与单线程异步

异步状态机与单线程异步

intro

C#作为第一个应用await async异步关键字的语言,他的异步实现一定值得我们学习和参考。

这个视频就以C#为例,探究一下异步的实现原理,

最后在看看js的异步实现,比较两者的异同。

Task

说起异步,不得不讲C#的Task类型。Task是指一个可等待任务

Task在System.Threading.Tasks命名空间中,这也可以看出,Task在C#中是和多线程有关系的。

这里要重点关注Task的几个关键属性和方法:

GetAwaiter() 用来获取该任务的等待器。等待器顾名思义,就是用来标识任务的完成情况的。等待器的方法GetResult()可以阻塞拿到等待器对应的任务的结果.

等待器还有一个重要方法,OnCompleted(), 该方法可以设置等待器的回调。这其实与js中的Promise.then功能相同。

状态机

C#的异步靠的是状态机。具体是AsyncVoidMethodBuilderIAsyncStateMachine

IAsyncStateMachine.MoveNext() 用来切换状态机的状态。

AsyncVoidMethodBuilder.AwaitOnCompleted(TAwait, TStateMachine) 接受两个参数,一个是任务等待器,一个是状态机实例,当等待器标记完成后,该方法会自动切换状态机的状态(调用MoveNext)

异步实现

现在了解了前置知识,就可以尝试实现await的功能

1
2
3
4
5
6
7
8
9
  public static async void DoSomethingAsync()
  {
      await Task.Delay(1000);
      Console.WriteLine("Done 1");
      await Task.Delay(1000);
      Console.WriteLine("Down 2");
      await Task.Delay(1000);
      Console.WriteLine("Done 3");
  }

实验的方法中有三个await语句,await的语义为“等待完成但不阻塞”,也就是说,该方法在await任务完成前,是不会继续往下执行,而是直接返回调用方,直到await任务完成后,才往下执行。

这里有两个关键点:等待的时候返回调用方,也就是非阻塞;完成后继续向后执行,这就需要用到AwaitOnCompleted,回调方法。

到这里,聪明的朋友应该想到了,如何把上面的样例方法包装成状态机了。

三个await语句,就对应了三个状态,这样当样例方法执行到第一个await,就把状态标记为1,并设置好等待器回调,然后直接返回。当任务完成,回调触发,再次进入状态机,由于根据标记,执行样例方法的第二部分。以此类推。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class DoSomethingStateMachine : IAsyncStateMachine{
  public int state;
  public Awaiter awaiter;
  public void MoveNext(){
    if(state == -1){
      var awaiter1 = Task.Delay(1000).GetAwaiter();
      if(!awaiter1.IsCompleted){
        state = 1;
        awaiter = awaiter1;
        AsyncVoidMethodBuilder.AwaitOnCompleted(awaiter1, this);
        return;
      }else{
        
      }
    }
    if(state == 1){
      var awaiter2 = Task.Delay(1000).GetAwaiter();
      if(!awaiter2.IsCompleted){
        state = 2;
        awaiter = awaiter2;
        AsyncVoidMethodBuilder.AwaitOnCompleted(awaiter2, this);
        return;
      }
    }
    if(state == 2){
      var awaiter3 = Task.Delay(1000).GetAwaiter();
      if(!awaiter3.IsCompleted){
        state = 3;
        awaiter = awaiter3;
        AsyncVoidMethodBuilder.AwaitOnCompleted(awaiter3, this);
        return;
      }
    }
    // ...
  }
}

异步常见问题

  1. 异步一定需要多线程吗?

    在C#中,异步关键字await/async的实现,靠的是Task,而Task的执行是交给线程池的。线程池是由.NET运行时管理的。所以异步在C#中是依赖于多线程的,至少 .net 程序是有一个 主线程,以及 托管线程池。而线程池中的工作线程数量,是可以配置的,但即便配置为1,异步也可以正常工作。

    但需要注意的是,C#的await、async是依靠多线程,但异步这个功能,并不只有状态机+线程池一种实现方式。比如后面我们会提到js的await、async

  2. 异步可以在单核心计算机工作吗?

    毫无疑问是可以的。

    对这个问题有疑问的同学,一定是混淆了进程和线程的概念。单核心CPU可以通过时间片划分,来“同时”运行不同的应用程序,而每一个应用就是一个进程。进程拥有自己的上下文环境,进程的切换由操作系统来管理。因此程序员并不需要关心CPU的核心数量。

    同理,应用程序可能需要一些主逻辑之外的处理器资源,来执行一些辅助任务,因此操作系统也提供了线程模型。无论是线程还是进程,都是面向程序员的对CPU资源的抽象,并不对应于真正的物理CPU的核心数量

    因为C#的异步并不是面向CPU核心的,CPU的计算资源是由OS来管理的,通过时间片划分,即便是单核心处理器,OS也可以提供应用程序多线程功能。

与Javascript的比较

js是单线程的,但是js也有await/async,既然说异步依赖于多线程,那js是怎么实现异步的?

js确实是单线程,正因如此,js的await实现方式与C#完全不同。js使用“事件循环”来处理异步任务。

由于只有一个主线程,因此js引擎将主线程的执行过程分为以下几个块:同步任务(包括js脚本),异步任务(宏任务和微任务)。

首先同步任务会占用主线程,直到同步任务完成,其次,执行异步任务。微任务优先级要高于宏任务,在下一个宏任务开始之前,需要将微任务队列清空。

如果我们用C#来实现类似的事件循环,那就长这样

public static class Javascript
{
    public static Queue<Action> MacroQueue = new Queue<Action>();
    public static Queue<Action> MicroQueue = new Queue<Action>();
    public static Action SyncAction;

    public static void SyncMain()
    {
        SyncAction();
        while (MicroQueue.Count + MacroQueue.Count > 0)
        {
            while (MicroQueue.Count > 0)
            {
                MicroQueue.Dequeue()();
            }
            while(MacroQueue.Count > 0)
            {
                while (MicroQueue.Count > 0)
                {
                    MicroQueue.Dequeue()();
                }
                MacroQueue.Dequeue()();
            }
        }
    }
} 

现在我们回到之前的问题:

异步到底是不是必须依赖于多线程?

现在我们可以回答,不依赖。异步的实现方式可以有多种,C#中是“状态机+线程池“,JS中是”事件循环“。异步这个概念本身,是为了保证主线程的响应性。

This post is licensed under CC BY 4.0 by the author.