经过一定时间的使用,对Task与异步编程有了更加清晰的认知,故翻新一篇新的文章作为记录

异步的前世今生

这一部分内容基本翻译自 How Async/Await Really Works in C# - .NET Blog (microsoft.com)

添加了一小部分个人理解

从一个简单的同步例子开始,一个拷贝字节流的同步方法

// Synchronously copy all data from source to destination.
public void CopyStreamToStream(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }
}

这个方法没有任何问题,只不过在他彻底执行结束以前,程序不能做其他任何事情(阻塞)

而我们仅需要一丢丢的改动,就可以让这个方法不再阻塞

// Asynchronously copy all data from source to destination.
public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

几乎没有改动,只是添加了几个关键字,这是因为所有繁重的工作都由编译器替我们做好了…

buy why?这一切是如何发生的?真的如此简单吗?

且听我慢慢道来…

基于Begin/End的异步

早在.Net Framework1.0时,就已经支持异步了,当时是一种Begin/End的模式

举个例子,对于一个同步方法

class Handler
{
    public int DoStuff(string arg);
}

如果想要修改为异步,那需要相应的Begin/End方法

class Handler:IAsyncResult
{
    public IAsyncResult BeginDoStuff(string arg, AsyncCallback? callback, object? state);
    public int EndDoStuff(IAsyncResult asyncResult);
}

Begin方法包含原方法的输入参数,同时还有一个回调以及一个object可以传入(可选)

其返回值就是一个Handle,实现了接口IAsyncResult

public interface IAsyncResult
{
    object? AsyncState { get; }
}

public delegate void AsyncCallback(IAsyncResult ar);

其调用过程如下

try
{
    var handler = new Handler()
    handler.BeginDoStuff(arg, iar =>
    {
        try
        {
            Handler handler = (Handler)iar.AsyncState!;
            int i = handler.EndDoStuff(iar);
            Use(i);
        }
        catch (Exception e2)
        {
            ... // handle exceptions from EndDoStuff and Use
        }
    }, handler);
}
catch (Exception e)
{
    ... // handle exceptions thrown from the synchronous call to BeginDoStuff
}

看起来没什么问题(当然和await比显得非常臃肿)

但实际上可能存在一个问题,那就是堆栈溢出。异步操作并不总是异步完成的,它只是允许异步完成而已,更多的时候,它可能同步就完成了。此时Begin方法回立刻同步执行它的回调,继续逻辑,这时候如果在一个循环中不断的去调用这样一个“异步”方法,可能直接把堆栈打爆。

这时候有两种可行的方式来避免堆栈过深

  1. 总是异步的执行回调,哪怕是同步完成的,但这样做很明显会有性能劣势
  2. 提供一种新的机制,当方法同步完成时,不进入回调而允许用户自行继续处理逻辑

前者明显不太行,所以C#这边提供了一种新的机制,为接口IAsyncResult添加了2个新的属性

public interface IAsyncResult
{
    object? AsyncState { get; }

    bool IsCompleted { get; } //是否完成
    bool CompletedSynchronously { get; }//是否同步完成了
}

此时,我们就需要再进行异步操作的时候进行同步完成检查

try
{
    IAsyncResult ar = handler.BeginDoStuff(arg, iar =>
    {
        if (!iar.CompletedSynchronously)
        {
            try
            {
                Handler handler = (Handler)iar.AsyncState!;
                int i = handler.EndDoStuff(iar);
                Use(i);
            }
            catch (Exception e2)
            {
                ... // handle exceptions from EndDoStuff and Use
            }
        }
    }, handler);
    if (ar.CompletedSynchronously)
    {
        int i = handler.EndDoStuff(ar);
        Use(i);
    }
}
catch (Exception e)
{
    ... // handle exceptions that emerge synchronously from BeginDoStuff and possibly EndDoStuff/Use
}

呃,这个方法很好,很安全,就是…这是不是太麻烦了?

一个如此简单的异步操作(如今看来),又是try-catch,又是同步判断的,很难想象嵌套异步会复杂到什么程度!

基于事件的异步

.NET Framework 2.0提供了一些新的异步操作模式,一种基于事件的异步操作

比如上面的DoStuff操作需要修改为:

class Handler
{
    public int DoStuff(string arg);

    public void DoStuffAsync(string arg, object? userToken);
    public event DoStuffEventHandler? DoStuffCompleted;
}

public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);

public class DoStuffEventArgs : AsyncCompletedEventArgs
{
    public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) :
        base(error, canceled, usertoken) => Result = result;

    public int Result { get; }
}

这个长得很像事件框架(不如说他就是….),你需要提前注册回调方法到DoStuffCompleted中,然后调用异步方法,异步方法结束时会触发对应的事件

看起来很美好,但是….如果每次开启异步操作前都要注册事件,而且一个异步操作可能在多个地方发起…光是想想就觉得很蛋疼

实际上他确实很蛋疼,所以这种模式几乎没有推广,很快就被淘汰了…

但是!虽然这个模式不太行,伴随它一起在.NET Framework 2.0引入的一个概念却非常重要,那就是———SynchronizationContext(同步上下文)

详细展开这个概念会花费非常多的笔墨(很多概念与Unity编程并没有直接关系),尽管多学习一些冷知识不是什么坏事,但为了避免陷入细节的汪洋,在这里我不会详细展开这个概念

简单来说,SC(同步上下文)需要配合Thread(多线程)来使用,废话,SC就是用于多线程直接交互…

你可以利用SC,把一个异步方法中抛入指定的上下文中执行

有点绕?请看一个UI按钮的例子…

如果我们点击按钮之后会进行一次非常非常慢的请求,怎么办?

很简单,我们可以开一个线程异步处理嘛

private void button1_Click(object sender, EventArgs e)
{
    //首先,我们开个线程,异步处理消息
    ThreadPool.QueueUserWorkItem(_ =>
    {
        //假设这是一个非常非常慢的消息处理
        string message = ComputeMessage();
        //等消息处理完,在设置按钮的文字
        button1.Text = message;
    });
}

很合理对吧?

合理个蛋!在这里我们开了一个新线程去处理消息,这没有问题,但是消息处理完怎么就能直接访问btn1呢?要知道我们点击事件可能处于A线程,消息处理可能处于B线程,不同线程怎么能这么简单的互相访问?

于是,SC需要登场了

我们改写一下这个方法

private void button1_Click(object sender, EventArgs e)
{
    //先获取当前同步上下文
    SynchronizationContext? sc = SynchronizationContext.Current;
    //进行异步操作
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        //利用同步上下文,将回调发送到正确的线程中执行
        sc.Post(_ => {button1.text = message}, null);
    });
}

就是这么简单,万事大吉!

PS:这里我隐去了很多细节,尽可能不引入更多抽象的概念,也因如此,很多描述可能并不够准确

基于Task的异步

终于,在.Net Framework 4.0 我们引入了Task的概念…

然鹅,实际上…Task本身并没有异步的能力,他仅仅只是一个数据结构——某个异步操作结束后,异步的结果/相关信息会被存储到Task中

比如Task[bool],它只是代表当某一个异步操作结束后,会把一个bool结果存放到Task内,经此而已

但是随着Task携带的某些特性,却能够彻底改变整个异步方法的流程,最关键之一就是,它自身携带了异步完成时的回调!

当然,说再多不如做一遍,所以我们干脆自己来实现一遍Task吧!(开玩笑…我们只能实现一个究极简略的mini版本)

class MyTask
{
    private bool _completed; //是否结束了
    private Exception? _error; //异步过程中的error
    private Action<MyTask>? _continuation; //异步完成后的回调
    private ExecutionContext? _ec; //这是啥?别急..下面细说
    ...
}

抛去ExecutionContext,MyTask的其他几个属性,如今看来应该是很容易理解的…(如果这是一个Task[T],那我们还需要一个private T _result)

如上面所说,Task的核心之一就是_continuation,既异步完成时的回调(或者也叫延续,就是之后要干啥的意思),那我们必然需要一个方法来添加回调(延续)

public void ContinueWith(Action<MyTask> action)
{
    lock (this)
    {
        //如果Task已经结束了,直接创建一个工作项去执行回调
        if (_completed)
        {
            ThreadPool.QueueUserWorkItem(_ => action(this));
        }
        //如果还没结束,就先把回调存起来
        else
        {
            _continuation = action;
            //这玩意等等说
            _ec = ExecutionContext.Capture();
        }
    }
}

然后,对于一个异步数据结构来说,我们当然需要一些方法来表示异步操作的结束,对吧?

//异步操作正常结束了
public void SetResult() => Complete(null);
//异步操作结束了,但有异常!
public void SetException(Exception error) => Complete(error);

private void Complete(Exception? error)
{
    lock (this)
    {
        if (_completed)
        {
            throw new InvalidOperationException("Already completed");
        }

        _error = error;
        _completed = true;

        //如果我们添加了回调,那么在异步结束的时候进行调用,同时需要考虑是否有异常
        if (_continuation is not null)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                if (_ec is not null)
                {
                    ExecutionContext.Run(_ec, _ => _continuation(this), null);
                }
                else
                {
                    _continuation(this);
                }
            });
        }
    }
}

虽然真正的Task会复杂非常非常多,但实际上Task核心确实就是这些了,此时我们已经可以尝试的进行任务了,比如我们可以有一个开始任务的方法

public static MyTask Run(Action action)
{
    var t = new MyTask();

    ThreadPool.QueueUserWorkItem(_ =>
    {
        try
        {
            action();
            t.SetResult();
        }
        catch (Exception e)
        {
            t.SetException(e);
        }
    });

    return t;
}

ValueTask

Task的出现完全改写了异步模式,如今(2024年4月27日14:07:12)仍然是.Net异步模式的主力军,但就上面的内容而言,它有什么问题吗?

其实还是那个问题——异步操作并不总是异步完成的,它只是允许被异步完成而已

如上面的内容所写,每一次异步都会返回一个MyTask,注意,这是一个Class,类的分配必然是有开销的,尽管大多数时候,对于一个异步操作而言,它可能微不足道——但如果是同步完成的任务呢?

while (some_condition)
{
    var task = TrySomeAsync()
}

如果这里的异步方法每次都是同步完成的,那么MyTask的内存分配将是灾难性的…

实际上,MyTask内还应该有一个静态单利属性——CompleteTask…,代表这个任务已经完成了

class MyTask
{
    private static MyTask _completeTask = new MyTask(...);
    public static MyTask CompleteTask => _completeTask;
}

如果任务已经完成了,那其实不需要新分配一个MyTask,直接返回CompleteTask即可(如果你用过ETTask,或者UniTask,会发现他们也都提供了这样一个类似的东西)

问题解决了吗?还没有…

要知道真正的Task是支持泛型的(Task[T]),你不可以给每一个Task泛型类都准备这样一个静态属性吧?

嘿,你别说,.NET还真是这么干的(一部分),比如Task[bool],它要么是true要么是false,那我们完全可以提前缓存好两个通用的Task

但如果是Task[int]呢?不可能缓存几十万个吧?Task[1] Task[2]…那也太蠢了…

但实际上.NET确实会缓存一些,可能是Task[1-8],我并不想太深入挖掘这些细节,所以在这里我们只需要知道,为了优化Task的内存分配,我们是可以缓存一些静态Task模板的。

那么有没有办法进一步做出优化呢?

你好,有的,ValueTask,它来了!关于ValueTask的内容,这里有更深层次的解释,我不展开太多了

public readonly struct ValueTask<TResult>
{
   private readonly Task<TResult>? _task;
   private readonly TResult _result;
   ...
}

它与Task最大,也是最关键的区别就是…它是一个结构体

没错!只是如此而已,所以高频率分配ValueTask并不会有那么大的内存分配问题

除此以外,我们还能发现,ValueTask = TResult + Task,所以如果一个异步操作是同步完成的,那你可以直接不分配Task,看到那个问号没,这是一个可空类型!这时候你直接分配TResult就完事了,效率极高!

事情到这里已经挺完美了,但美中不足的是…“延续”仍然是一个回调方法…

经常写业务逻辑的都应该很厌烦回调,它让你的代码非常的不优雅,同时又可能引起这样那样的问题

迭代器

迭代器?哪个迭代器?EMM…没错,就是IEnumerable[T]…

事实上.NET里,迭代器的概念出现的很早,可能在2.0就出现了,这可比Task的出现早上非常多,但这么一个早期概念却为Task贡献了一块重要的拼图!

关于迭代器,好几年前我就写过一篇文章,迭代器与协程 - 旧亚楠 (logarius996.icu),呃,应该勉强还能看看…

了解过迭代器的都知道,它的核心就是一个状态机+MoveNext方法,这可以用于Task吗?

其实真的可以…比如(这可能有点抽象了)

static Task IterateAsync(IEnumerable<Task> tasks)
{
    var tcs = new TaskCompletionSource();
	//假设我们可以拿到task的一个迭代器
    IEnumerator<Task> e = tasks.GetEnumerator();

    void Process()
    {
        try
        {
            //如果task的迭代器还可以继续(说明task还没有结束)
            if (e.MoveNext())
            {
                //那我们给task添加一个回调,这个回调是什么呢?这个回调就是再调用一遍这个方法...
                e.Current.ContinueWith(t => Process());
                //然后return了
                return;
            }
        }
        catch (Exception e)
        {
            tcs.SetException(e);
            return;
        }
        //当迭代器不能MoveNext时,说明任务链已经结束了,所以不会进入if,会执行到这里,设置Task结果
        tcs.SetResult();
    };
    //这里必须主动调用第一次,进行MoveNext
    Process();

    return tcs.Task;
}

仔细理解一下这个抽象例子,这个例子很小巧,但是其设计非常精妙!

回到最开头的那个例子中,我们做出如下改写

static Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    return IterateAsync(Impl(source, destination));

    static IEnumerable<Task> Impl(Stream source, Stream destination)
    {
        var buffer = new byte[0x1000];
        while (true)
        {
            //第一次MoveNext,迭代器的Current会被设置成read(注意,这是个Task<int>啊)
            Task<int> read = source.ReadAsync(buffer, 0, buffer.Length);
            yield return read; //状态机会停留在这里,直到你调用下一次MoveNext,注意,在这里我们还给Current(也就是read)添加了一个延续操作,延续操作就是MoveNext()
            //等异步操作结束,调用MoveNext继续状态机,后门就是往返循环了,直到迭代器不能继续MoveNext
            int numRead = read.Result;
            if (numRead <= 0)
            {
                //这就是不能MoveNext的条件,到这里会结束迭代器(也就是结束任务)
                break;
            }

            Task write = destination.WriteAsync(buffer, 0, numRead);
            yield return write;
            write.Wait();
        }
    }
}

如果不太理解,那就需要去看看我上面关于迭代器的那个博客,里面有反编译后的代码辅助,比较好理解

但是,这一部分确实有点烧脑了(猪脑过载)…如果你让我这么写异步,那我还不如不用呢,这也太折磨了…

async/await

神说,要简化异步,于是await出现了!

说在最开头,await不是一种语法上的特性,它只是一个语法糖,目的是让编译器为你生成一大堆异步迭代器,免得你自己去手写!比如上面那一大堆代码,完全可以让编译器自动生成。

回到最开头关于await的那个例子

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

编译器在干啥

反编译一下上面那个方法,可以发现编译器生成了一大堆东西

//这个是原方法入口
public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    <CopyStreamToStreamAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.source = source;
    stateMachine.destination = destination;
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

//生成了一个状态机
private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public Stream source;
    public Stream destination;
    private byte[] <buffer>5__2;
    private TaskAwaiter <>u__1;
    private TaskAwaiter<int> <>u__2;

    ...
}

咱们一点一点来解析这些内容

d__0 stateMachine = default;

默认初始化这个状态机,并且,虽然这里的代码看不太出来,但实际上只有当任务需要异步完成时,这个状态机才会被堆栈挂起,如果任务同步就完成了,那么状态机在方法堆栈内就会同步结束(记住这个知识点就会,下面会再聊到它)

​ stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();

这里给状态机创建一个builder,实际上每个类型的Task都需要自己对应的builder,这一部分是需要手动实现的,比如对于我们的自己实现的MyTask,我们也可以给它进行扩展

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public class MyTask
{
    ...
}

public struct MyTaskMethodBuilder
{
    public static MyTaskMethodBuilder Create() { ... }

    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { ... }
    public void SetStateMachine(IAsyncStateMachine stateMachine) { ... }

    public void SetResult() { ... }
    public void SetException(Exception exception) { ... }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }

    public MyTask Task { get { ... } }
}

​ stateMachine.<>t__builder.Start(ref stateMachine);

最后是builder的Start方法,其实这个方法只是进状态机的第一次MoveNext

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    stateMachine.MoveNext();
}

ExecutionContext

这玩意上面已经提到过很多次了,到现在我们再来真正聊一下

对于函数方法,我们都很熟悉,我们可以把参数在方法之间传递来传递去,这是显式的数据传递,但实际业务开发中,我们也经常会进行隐式的数据传递

这很好理解,比如

class static ConstClass
{
    public static int Value = 10;
}

class People
{
    public int Age = 0;
    
	public void SetAge()
    {
       Age = ConstClass.Value;
    }
}

我们没有传递任何参数给这个方法,而是在方法内部自行去获取了一些参数,其实这些参数就可以说是被方法隐性依赖/传递的

此时,我们可以把ConstClass.Value称之为环境数据——它不被方法直接传递,但我们需要在方法内用到

某种意义上来说,上下文其实是一个类似的意思

这样的共享数据对于同步环境来说当然没有任何问题,但很明显不适用异步,你并不确定异步操作会在什么时候读取环境数据,而这些环境数据都是可读可写的,可能某个异步读取数据之前,数据已经被另一个异步方法给改写了,这很容易发生,不然为什么多线程操作需要锁?尽管异步不代表多线程,但它仍然有类似的风险。

so….ExecutionContext(执行上下文)出现了,它存在于[ThreadStatic](线程本地状态)中,每一个线程都会有自己的本地状态,线程间不会互相干扰

当我们执行一个异步方法时,执行上下文会先调用Capture方法,保存当前线程环境,等异步任务完成,需要继续逻辑时,再通过执行上下文恢复到之前保存的环境中

这与.Net的另一个特性有关,即AsyncLocal[T]

var number = new AsyncLocal<int>();

number.Value = 42;
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine(number.Value));
number.Value = 0;

Console.ReadLine();

这永远会打印42,哪怕我们已经把value改为0了,因为执行上下文捕捉的线程环境,是执行那一刻的环境

这个特性就不再额外展开了(Again,为了避免陷入细节的汪洋~~~)

MoveNext

private void MoveNext()
{
    try
    {
        ... // all of the code from the CopyStreamToStreamAsync method body, but not exactly as it was written
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();
}

首先我们忽略具体的逻辑部分(try里面的部分),先看一下整个MoveNext的周期

这个周期是相当完整的,不管具体的异步任务是什么,当任务完成时候,MoveNext都会设置任务为对应的状态(如果有异常就抛出,没有异常就完成任务)

同时不难发现,整个结果控制并没有通过状态机本身去控制,而是通过其builder属性去控制的,并且在最开头生成的代码中,我们也能发现,返回的Task也在builder中

而builder这一块的内容用户是可以自定义的,所以实际上这里给了我们非常灵活的控制权限

现在,让我们回到具体的任务逻辑中(try里面的部分)

private void MoveNext()
{
    try
    {
        int num = <>1__state;

        TaskAwaiter<int> awaiter;
        if (num != 0)
        {
            if (num != 1)
            {
                <buffer>5__2 = new byte[4096];
                goto IL_008b;
            }

            awaiter = <>u__2;
            <>u__2 = default(TaskAwaiter<int>);
            num = (<>1__state = -1);
            goto IL_00f0;
        }

        TaskAwaiter awaiter2 = <>u__1;
        <>u__1 = default(TaskAwaiter);
        num = (<>1__state = -1);
        IL_0084:
        awaiter2.GetResult();

        IL_008b:
        awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter();
        if (!awaiter.IsCompleted)
        {
            num = (<>1__state = 1);
            <>u__2 = awaiter;
            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
            return;
        }
        IL_00f0:
        int result;
        if ((result = awaiter.GetResult()) != 0)
        {
            awaiter2 = destination.WriteAsync(<buffer>5__2, 0, result).GetAwaiter();
            if (!awaiter2.IsCompleted)
            {
                num = (<>1__state = 0);
                <>u__1 = awaiter2;
                <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                return;
            }
            goto IL_0084;
        }
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();
}

这自动生成的内容疑似有点太多了…我们一点点来看…

   int num = <>1__state;
   
   TaskAwaiter<int> awaiter;
   if (num != 0)
   {
       if (num != 1)
       {
           <buffer>5__2 = new byte[4096];
           goto IL_008b;
       }
   
       ...
   }
   
   IL_008b:
   awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter();
   if (!awaiter.IsCompleted)
   {
       num = (<>1__state = 1);
       <>u__2 = awaiter;
       <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
       return;
   }
   ...

最开始初始化状态机的时候,我们的状态是-1,所以会连续进入2个if,创建buffer,然后goto IL_008b,此时会创建一个awaiter

嗯?什么是awaiter?

awaiter代表一种可等待的对象,就像IEnumerator代表可迭代对象一样,虽然说我们讲了一整篇的Task,但如果C#要求只有Task才能被等待那也太蠢了,事实上,只要满足等待条件的对象都可以被等待

假如一个类型T是可等待的,那么它必须满足

  • T必须具备(可以是扩展方法)无参方法,该方法返回一个类型,假设我们称呼这个类为Awaiter

    public class T
    {
        // 一个返回等待器的实例方法
        public Awaiter GetAwaiter()
        {
            return new Awaiter();
        }
    }
    
  • Awaiter必须实现INotifyCompletion或者ICriticalNotifyCompletion接口

    public interface INotifyCompletion
    {
        void OnCompleted(Action continuation);
    }
      
    public interface ICriticalNotifyCompletion : INotifyCompletion
    {
        void UnsafeOnCompleted(Action continuation);
    }
    
  • Awaiter类型必须具有一个可读的实例属性IsCompleted,是bool

    // 对外仅可读
    public bool IsCompleted { get; private set;}
    
  • Awaiter类型必须具有一个非泛型的无参方法GetResult(),表达式的返回值需要和任务目标保持一致,如果异步操作异常,那么异常也会在这个方法里抛出

    public string GetResult()
    {
    	return "result";
    }
    

我们可以扩展MyTask,使他变成一个可等待对象

class MyTask
{
    ...
   	//条件1
    public MyTaskAwaiter GetAwaiter() => new MyTaskAwaiter { _task = this };
													
    public struct MyTaskAwaiter : ICriticalNotifyCompletion //条件2
    {
        internal MyTask _task;
	    //条件3
        public bool IsCompleted => _task._completed;
        public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        public void UnsafeOnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        //条件4
        public void GetResult() => _task.Wait();
    }
}

回到上面,我们获取awaiter后,首先判断它是否完成,如果已经完成了,那么不会进入if,正常继续逻辑,这没啥好说的,我们主要关注的是当awaiter没有同步完成时会发生什么?

我们会调用builder的AwaitUnsafeOnCompleted方法,并把awaiter和状态机传进去,先不深入细节,我们不妨自己假设一下,我们现在需要这个方法做些什么?

现在我们有一个异步任务被挂起了,我们当然要知道这个异步任务何时完成,并且这个异步任务完成之后我们应该干什么

我们先讨论后者,即异步任务完成后的回调(延续),这个回调应该如何触发?这个回调应该干些什么?

  • 这个回调应该如何触发?

    这是一个很困难的问题,但简单回顾一些我们上面的一些代码,不难发现,我们不是搞了一个可等待对象吗?按理说,它是可等待的,那它自然应该提供相关的方法。事实上也确实如此,Awaiter必须实现2个接口之一,这两个接口类似的都有一个OnCompleted方法,此方法参数就是任务完成时的回调

  • 这个回调应该是什么?

    是MoveNext,回忆我们在迭代器那里进行的异步任务,每当任务完成时,我们都会继续推进迭代器,直到结束,在这里也是这样,每当一个异步任务完成,我们都重新推进一次状态机

好了,现在让我们深入这个方法,看看他究竟干了什么

首先,根据状态机和任务,获取一个状态机包围盒

image-20240427230718967

这个包围盒是什么?我们继续往里看

image-20240427232617964

这一大串方法太复杂了,但是关键点只有两个

  • 首先我们捕获当前的执行上下文
  • 其次我们把执行上下文和状态机都保存到状态机包围盒内

继续代码,我们传入了awaiter以及上面获取的状态机包围盒

image-20240427232917879

这里就会产生分支了,根据我们awaiter的实现类型,会执行不同的逻辑,对于大部分Task,会进入第一个if,对于我们自己实现的Awaiter,则会进入最后的try,我们直接看最后这个try方法

还记得awaiter的UnsafeonCompleted方法不?贴出来回忆一下

public interface ICriticalNotifyCompletion : INotifyCompletion
{
    void UnsafeOnCompleted(Action continuation);
}

是不是越来越清晰了?对比上面的代码截图,这里这个延续Action,就是状态机包围盒的MoveNextAction方法

那我们就看一下这个方法是什么

image-20240427233656637

有点绕,但是不难发现,虽然绕了十万八千里,但最后其实就是调用了状态机的MoveNext方法

真相大白!!!

任务结束之后会调用的方法,就是状态机的MoveNext,并且这是在任务开始的那一刻就注册到Task对应的Awaiter中的!

那么我们还剩下最后一个问题,任务何时结束?

emm,很遗憾,任务何时结束我们并不知道,这应当由任务本身决定,我们仅仅持有任务所对应的一个等待器,并直到任务结束之后的延续是什么,但我们还是不能知道任务何时结束

这是理所当然的,当我挂起一个异步任务,我不应该,也没办法知道,这个异步操作会再何时结束,不然为什么叫异步?我根本不需要关心它的结束时间才对!