Task与异步编程|02 重新认识Task
经过一定时间的使用,对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方法回立刻同步执行它的回调,继续逻辑,这时候如果在一个循环中不断的去调用这样一个“异步”方法,可能直接把堆栈打爆。
这时候有两种可行的方式来避免堆栈过深
- 总是异步的执行回调,哪怕是同步完成的,但这样做很明显会有性能劣势
- 提供一种新的机制,当方法同步完成时,不进入回调而允许用户自行继续处理逻辑
前者明显不太行,所以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,回忆我们在迭代器那里进行的异步任务,每当任务完成时,我们都会继续推进迭代器,直到结束,在这里也是这样,每当一个异步任务完成,我们都重新推进一次状态机
好了,现在让我们深入这个方法,看看他究竟干了什么
首先,根据状态机和任务,获取一个状态机包围盒
这个包围盒是什么?我们继续往里看
这一大串方法太复杂了,但是关键点只有两个
- 首先我们捕获当前的执行上下文
- 其次我们把执行上下文和状态机都保存到状态机包围盒内
继续代码,我们传入了awaiter以及上面获取的状态机包围盒
这里就会产生分支了,根据我们awaiter的实现类型,会执行不同的逻辑,对于大部分Task,会进入第一个if,对于我们自己实现的Awaiter,则会进入最后的try,我们直接看最后这个try方法
还记得awaiter的UnsafeonCompleted方法不?贴出来回忆一下
public interface ICriticalNotifyCompletion : INotifyCompletion
{
void UnsafeOnCompleted(Action continuation);
}
是不是越来越清晰了?对比上面的代码截图,这里这个延续Action,就是状态机包围盒的MoveNextAction方法
那我们就看一下这个方法是什么
有点绕,但是不难发现,虽然绕了十万八千里,但最后其实就是调用了状态机的MoveNext方法
真相大白!!!
任务结束之后会调用的方法,就是状态机的MoveNext,并且这是在任务开始的那一刻就注册到Task对应的Awaiter中的!
那么我们还剩下最后一个问题,任务何时结束?
emm,很遗憾,任务何时结束我们并不知道,这应当由任务本身决定,我们仅仅持有任务所对应的一个等待器,并直到任务结束之后的延续是什么,但我们还是不能知道任务何时结束
这是理所当然的,当我挂起一个异步任务,我不应该,也没办法知道,这个异步操作会再何时结束,不然为什么叫异步?我根本不需要关心它的结束时间才对!