利用.NET下的async/await机制来简化回调和状态机的实现

在开发GUI程序或者游戏的过程中,我想各位可能都写过如下的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void DoSomething() 
{
...blabla
ShowDialogA(onDialogClosed: DoSomethingStep2);
}

void DoSomethingStep2()
{
...blabla
if(some condition)
ShowDialogB(onDialogClosed: DoSomethingStep3);
}

void DoSomethingStep3()
{
...
}

稍微……有些啰嗦,对吧?

那么,有什么办法来解决这个问题呢?有,那就是async/await。

你可能很早就了解过async和await了,不过大部分介绍它的文章都只介绍了如何利用.NET已经提供的方法进行诸如IO等耗时操作,而且好像还都牵扯到了线程之类一听就让人心惊胆寒的东西。“我写的这一坨业务代码可没考虑过线程安全啊。”没关系,接下来我就来介绍如何利用async/await来解决我们上文遇到的问题,绝对的线程安全,我保证。

await后面可以跟什么

Task?仅仅如此吗?事实上,如果你检查一下Task.Yield()方法的返回值,你就会发现它的返回值就不是一个Task,而是一个YieldAwait

那什么可以跟在await后面呢?事实上,根据这个链接,一个方法只要满足如下的条件即可被await

  • Compiler should be able to find an instance or an extension method called GetAwaiter. The return type of this method should follow certain requirements:

  • The type should implement INotifyCompletion interface.

  • The type should have bool IsCompleted {get;} property and T GetResult() method.

翻译过来就是

  • 编译器需要能够找到一个 GetAwaiter方法,无论是实例方法还是扩展方法。同时这个GetAwiter方法的返回值需要满足如下条件:
    • 这个类型需要实现 INotifyCompletion
    • 这个类型需要有一个bool IsCompleted {get;}属性,同时需要有个T GetResult()方法。

那么,为什么会有这些要求呢?这就需要我们知道await偷偷在编译期都做了些什么。

当我们在代码中编写了这样的代码后:

1
2
3
4
5
6
async void Func() 
{
DoSomethingStep1();
var a = await someAwaitable;
DoSomethingStep2(a);
}

C#在编译期就会将这个代码转变成类似下文中的代码:

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
struct StateMachine
{
private int _state = -1;
private OurAwaitableType _step1Awaiter;

void MoveNext()
{

if (_state != 0)
{
DoSomethingStep1();
_step1Awaiter = someAwaitable.GetAwaiter();
if (!_step1Awaiter.IsCompleted)
{
_state = 0;
_step1Awaiter.OnCompleted(MoveNext); //该方法来自INotifyCompletion。
return;
}
}

var a = _step1Awaiter.GetResult();
DoSomethingStep2(a);
}
}


如果你想知道更具体的翻译后代码,可以参考这个链接

可以看到,生成的代码将我们原来的方法分成了两部分,当_state != 0时执行第一部分,当_state == 0时执行第二部分。

原来的代码中的await被拆成了

1
2
3
4
5
6
7
_step1Awaiter = someAwaitable.GetAwaiter();
if (!_step1Awaiter.IsCompleted)
{
_state = 0;
_step1Awaiter.OnCompleted(MoveNext); //该方法来自INotifyCompletion。
return;
}

我们首先获得Awaiter,然后判断该Awaiter是否已经完成,若已经完成则直接执行后续的代码,否则我们通过OnCompleted注册该Awaiter完成时应该执行的后续代码并return。

我们可以看到,这里完全利用了我们上述所提到的三个条件。GetAwaiter用于获取AwaiterINotifyCompletion用于注册我们的下一步回调。而IsCompleted用于判断Awaiter是否已经完成来省略掉不必要的回调注册。

由此,我们便可以写出一个简单的我们自己的Awaiter实现:

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
37
38
39
40
41

class ShowDialogAwaitable : INotifyCompletion
{

public bool IsCompleted { get; private set; }

private int _result;
private Action _continuation;

public ShowDialogAwaitable()
{
Console.WriteLine($"ShowDialogA {Environment.CurrentManagedThreadId}");
DialogManager.ShowDialogA(onDialogClosed: OnDialogClosed);
}

private void OnDialogClosed(int a)
{
_result = a;
IsCompleted = true;
_continuation?.Invoke();
}


public ShowDialogAwaitable GetAwaiter() => this;

public int GetResult() => _result;

public void OnCompleted(Action continuation)
{
Console.WriteLine($"OnCompleted {Environment.CurrentManagedThreadId} {Environment.StackTrace}");
if (IsCompleted)
{
continuation.Invoke();
}
else
{
_continuation += continuation;
}
}
}

我们在代码中增加了一些log,可以让我们更好地看清代码的执行逻辑。

再让我们加上一些测试用代码:

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

async void Func()
{

Console.WriteLine($"Before Await {Environment.CurrentManagedThreadId}");
var value = await new ShowDialogAwaitable();
Console.WriteLine($"After Await Value = {value} {Environment.CurrentManagedThreadId} {Environment.StackTrace}");
}

Console.WriteLine($"Start {Environment.CurrentManagedThreadId}");
Func();
Thread.Sleep(1000);
Console.WriteLine($"After Sleep {Environment.CurrentManagedThreadId}");
DialogManager.Update();

public static class DialogManager
{
private static Action<int>? _callback;
public static void ShowDialogA(Action<int> onDialogClosed)
{
_callback += onDialogClosed;
}

public static void Update()
{
_callback?.Invoke(5);
_callback = null;
}
}

让我们执行一下试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Start 1
Before Await 1
ShowDialogA 1
OnCompleted 1 at System.Environment.get_StackTrace()
at ShowDialogAwaitable.OnCompleted(Action continuation) in [Omitted Path]\Program.cs:line 64
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AwaitOnCompleted[TAwaiter,TStateMachine](TAwaiter& awaiter, TStateMachine& stateMachine, Task`1& taskField)
at Program.<<Main>$>g__Func|0_0() in [Omitted Path]\Program.cs:line 10
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Program.<<Main>$>g__Func|0_0()
at Program.<Main>$(String[] args) in [Omitted Path]\Program.cs:line 15
After Sleep 1
After Await Value = 5 1 at System.Environment.get_StackTrace()
at Program.<<Main>$>g__Func|0_0() in [Omitted Path]\Program.cs:line 11
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
at ShowDialogAwaitable.OnDialogClosed(Int32 a) in [Omitted Path]\Program.cs:line 54
at DialogManager.Update() in [Omitted Path]\Program.cs:line 30
at Program.<Main>$(String[] args) in [Omitted Path]\Program.cs:line 18


首先,可以看到所有的代码都是在同一个线程执行的,我们的确实现了线程安全。

然后,通过StackTrace可以发现,如同我们分析的一样,我们的方法被await分成了两部分,第二部分会在我们执行await通过OnCompleted设置的回调时被执行。

至此,我们便实现了开头所述的用async与await来实现回调的方案。

那我们的async方法该返回什么呢?

在上面的代码中,我们的async方法返回类型是void。对于top level的方法,我们自然可以这样做,但是如果我们希望我们的async方法能够await其他方法,那便不能再使用void,那么,我们该用什么呢?

Task?

Task可以吗?会不会有额外的线程切换等问题?让我们来试一下。

1
2
3
4
5
6
7
8
9
10
11

async Task<int> Bar()
{
return await new ShowDialogAwaitable();
}
async void Func()
{
Console.WriteLine($"Before Await {Environment.CurrentManagedThreadId}");
var value = await Bar();
Console.WriteLine($"After Await Bar Value = {value} {Environment.CurrentManagedThreadId} {Environment.StackTrace}");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
After Sleep 1
After Await Bar Value = 5 1 at System.Environment.get_StackTrace()
at Program.<<Main>$>g__Func|0_1() in [Omitted Path]\Program.cs:line 15
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(IAsyncStateMachineBox box, Boolean allowInlining)
at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
at System.Threading.Tasks.Task`1.TrySetResult(TResult result)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(Task`1 task, TResult result)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetResult(TResult result)
at Program.<<Main>$>g__Bar|0_0() in [Omitted Path]\Program.cs:line 10
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
at ShowDialogAwaitable.OnDialogClosed(Int32 a) in [Omitted Path]\Program.cs:line 146
at DialogManager.Update() in [Omitted Path]\Program.cs:line 34
at Program.<Main>$(String[] args) in [Omitted Path]\Program.cs:line 22


这里我们省略了After Sleep以前的控制台输出。

可以看到,这里我们仍然是在主线程内执行的后续代码,StackTrace显示,我们是从OnDialogClosed的回调一路执行到的await Bar();的后续代码,所以,使用Task在这里是没有问题的。

但是,仔细观察上面的StackTrace,我们可以发现为了调用到后续代码,Task使用了足足7个方法:

1
2
3
4
5
6
7
8
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(IAsyncStateMachineBox box, Boolean allowInlining)
at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
at System.Threading.Tasks.Task`1.TrySetResult(TResult result)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(Task`1 task, TResult result)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetResult(TResult result)

那么,对于我们这种需求较为简单,也没有跨线程需求的代码,有更简洁快速的方案吗?

有,那就是实现我们自己的Task类。

Task-like types

C#的开发者也考虑到了这个问题,async方法的返回值并不限于TaskTask<T>void,实际上,任何类都可以作为async方法的返回值,只要它满足一定的条件:

  • 可以被Await。

  • 被一个Attribute修饰:AsyncMethodBuilder

    • 该Attribute需要有一个参数,对应一个Builder类,而该Builder类的具体需求,可以参考这里

在我们继续之前,我们先来考虑要这样的一个Type需要支持哪些功能。

首先,很明显,它需要可以被await。

然后,当它代表的async方法执行完毕或者抛出异常时,这个Type的Awaiter的IsCompleted属性需要返回true,且其GetResult方法需要返回对应的返回值或抛出异常。

有了这些需求,我们再来看看AsyncMethodBuilder所需要实现的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyTaskMethodBuilder<T>
{
public static MyTaskMethodBuilder<T> Create();

public void Start<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IAsyncStateMachine;

public void SetStateMachine(IAsyncStateMachine stateMachine);
public void SetException(Exception exception);
public void SetResult(T result);

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<T> Task { get; }
}

其中,SetExceptionSetResult即是为了满足我们提到的第二点需求而增加的方法。

Start用于执行该async方法的第一步,我们需要在该方法内,或者该方法返回后的某个时机执行stateMachine.MoveNext()。例如,如果我们希望让该Task对应的async方法从开头到第一个await(如果awaiter的IsCompleted为false)之间的所有内容都在另外的线程上执行,我们便可以在Start中将stateMachine.MoveNext() post到其他线程上。

AwaitOnCompletedAwaitUnsafeOnCompleted用于在Task对应的async方法执行过程中,遇到了await之后,向awaiter添加回调时所用到的方法。如果Awaiter实现了ICriticalNotifyCompletion接口,会调用AwaitUnsafeOnCompleted方法,否则则会调用AwaitOnCompleted方法。同样,这里我们需要调用awaiter的OnCompletedstateMachine.MoveNext()回调注册给awaiter。

关于ICriticalNotifyCompletion,我们会在后面介绍其与INotifyCompletion的区别,目前,可以将其看做是一个省去了部分操作的INotifyCompletion

知道了这些,我们便可以实现一个mini版的task,MiniTask

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

class MiniTaskBuilder<T>
{
private MiniTaskBuilder()
{
Task = new MiniTask<T>();
}

public static MiniTaskBuilder<T> Create()
=> new MiniTaskBuilder<T>();

public void SetResult(T result) => Task.SetResult(result);

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

public MiniTask<T> Task { get; }

public void SetException(Exception exception)
{
Task.SetException(exception);
}

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

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

public void SetStateMachine(IAsyncStateMachine stateMachine)
{
}
}

[AsyncMethodBuilder(typeof(MiniTaskBuilder<>))]
class MiniTask<T> : INotifyCompletion
{
private T _result = default!;
private Action? _action;
private ExceptionDispatchInfo? _exception;
public bool IsCompleted { get; private set; }

public void SetResult(T result)
{
_result = result;
IsCompleted = true;
_action?.Invoke();
}

public MiniTask<T> GetAwaiter() => this;

public T GetResult()
{
_exception?.Throw();
return _result;
}

public void OnCompleted(Action continuation)
{
if (IsCompleted)
{
continuation.Invoke();
}
else
{
_action += continuation;
}
}

public void SetException(Exception exception)
{
if (IsCompleted)
{
return;
}
_exception = ExceptionDispatchInfo.Capture(exception);
}
}

现在,让我们试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

async MiniTask<int> Bar()
{
return await new ShowDialogAwaitable();
}
async void Func()
{
Console.WriteLine($"Before Await {Environment.CurrentManagedThreadId}");
var value = await Bar();
Console.WriteLine($"After Await Bar Value = {value} {Environment.CurrentManagedThreadId} {Environment.StackTrace}");
}

Func();
Thread.Sleep(1000);
DialogManager.Update();

输出如下:

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
Before Await 1
ShowDialogA 1
OnCompleted 1 at System.Environment.get_StackTrace()
at PlayGround.ShowDialogAwaitable.OnCompleted(Action continuation) in [Omitted Path]\Awaitable.cs:line 48
at PlayGround.MiniTaskBuilder`1.AwaitOnCompleted[TAwaiter,TStateMachine](TAwaiter& awaiter, TStateMachine& stateMachine) in [Omitted Path]\MIniTask.cs:line 35
at Program.<<Main>$>g__Bar|0_0() in [Omitted Path]\Program.cs:line 64
at PlayGround.MiniTaskBuilder`1.Start[TStateMachine](TStateMachine& stateMachine) in [Omitted Path]\MIniTask.cs:line 21
at Program.<<Main>$>g__Bar|0_0()
at Program.<<Main>$>g__Func|0_1() in [Omitted Path]\Program.cs:line 69
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Program.<<Main>$>g__Func|0_1()
at Program.<Main>$(String[] args) in [Omitted Path]\Program.cs:line 73
After Await Bar Value = 5 1 at System.Environment.get_StackTrace()
at Program.<<Main>$>g__Func|0_1() in [Omitted Path]\Program.cs:line 70
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
at PlayGround.MiniTask`1.SetResult(T result) in [Omitted Path]\MIniTask.cs:line 62
at PlayGround.MiniTaskBuilder`1.SetResult(T result) in [Omitted Path]\MIniTask.cs:line 16
at Program.<<Main>$>g__Bar|0_0() in [Omitted Path]\Program.cs:line 65
at PlayGround.ShowDialogAwaitable.OnDialogClosed(Int32 a) in [Omitted Path]\Awaitable.cs:line 38
at PlayGround.DialogManager.Update() in [Omitted Path]\Awaitable.cs:line 15
at Program.<Main>$(String[] args) in [Omitted Path]\Program.cs:line 75

Process finished with exit code 0.

哈,StackTrace看起来短多了。

关于ICriticalNotifyCompletionINotifyCompletion的区别

考虑如下代码

1
2
3
4
5
AsyncLocal<int> V = new AsyncLocal<int>();
V.Value = 1;
var awaiter = Task.Yield().GetAwaiter();
awaiter.UnsafeOnCompleted(() => Console.WriteLine($"{V.Value}!"));
Thread.Sleep(1000);

这段代码的运行结果为0!,而如果我们将来自ICriticalNotifyCompletionUnsafeOnCompleted改为来自INotifyCompletionOnCompleted,那么运行结果又会变为1!

.NET中,所有的AsyncLocal以及其他的一些”运行环境”相关的变量,都会存在一个叫做ExecutionContext东西里面。而当我们通过UnsafeOnCompleted将回调注册到Awaiter上时,Awaiter将不会以它执行时所保留的ExecutionContext来执行回调,而OnCompleted正相反。

那么为什么C#生成的代码默认会使用UnsafeOnCompleted呢?

通常情况下,当我们的await之后的代码被调用时,我们应该将ExecutionContext切换为我们这个async方法执行时所保留的ExecutionContext,而不是使用Awaiter执行时的ExecutionContext,所以,这里我们即使通过OnCompleted恢复了Awaiter的ExecutionContext,也会在之后立刻换成我们自己的ExecutionContext,白白增加了一次ExecutionContext的切换。

那么我们的Awaiter的OnCompleted似乎并没有实现ExecutionContext

没错。

1
2
3
4
5
6
AsyncLocal<int> V = new AsyncLocal<int>();
V.Value = 1;
var awaiter = new ShowDialogAwaitable().GetAwaiter();
awaiter.OnCompleted(() => Console.WriteLine($"Value {V.Value}"));
V.Value = 2;
DialogManager.Update();

这段代码会输出Value 2,但是,这段代码却会输出Value 1

1
2
3
4
5
6
AsyncLocal<int> V = new AsyncLocal<int>();
V.Value = 1;
var awaiter = Task.Yield().GetAwaiter();
awaiter.OnCompleted(() => Console.WriteLine($"Value {V.Value}"));
V.Value = 2;
Thread.Sleep(100);
好像MiniTask也没有恢复ExecutionContext

没错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

AsyncLocal<int> Value = new AsyncLocal<int>();
int Value2;

async MiniTask<int> Bar()
{
Console.WriteLine($"Before Value {Value.Value} {Value2}");
var a = await new ShowDialogAwaitable();
Console.WriteLine($"After Value {Value.Value} {Value2}");
return a;
}
async void Func()
{
var value = await Bar();
}

Value2 = 1;
Value.Value = 1;
Func();
Value2 = 2;
Value.Value = 2;
Thread.Sleep(1000);
DialogManager.Update();

这段代码的输出为

1
2
Before Value 1 1
After Value 2 2

而若我们将MiniTask换成Task,则输出变为

1
2
Before Value 1 1
After Value 1 2
为什么不实现?

UniTask也干了(不是

大概还是没有必要?我们的实现并未牵扯到线程切换,而且在自己代码的执行过程中ExecutionContext中的内容也并未发生过修改,所以执行回调时的ExecutionContext和async方法执行时以及awaiter执行时的ExecutionContext通常情况下就是同一个。

当然,若你的代码用到了AsyncLocal,你大可以实现你自己的ExecutionContext逻辑。

可是我还想用Task.Delay()或者其他Task

请注意,以下部分不适用于Unity,Unity有自己的SynchronizationContext实现,而且Unity在Playmode下开启的Task并不会随着Playmode的退出而停止,具体请参考此处

Task.Delay()以及其他IO方法会生成其自己的线程,同时它也不可能知道我们要如何在主线程的何处执行我们传递给Awaiter的回调,所以它只能在另外的线程执行回调。让我们来试一下:

1
2
3
4
5
6
7
8
9
10

async void Foo()
{
Console.WriteLine($"Before {Environment.CurrentManagedThreadId}");
await Task.Delay(100);
Console.WriteLine($"After {Environment.CurrentManagedThreadId}");
}

Foo();
Thread.Sleep(1000);

输出如下:

1
2
Before 1
After 8

线程的确改变了,难道我们就没办法告诉Task.Delay()应该如何执行我们的回调吗?

还是有办法的,那就是通过SynchronizationContext

.NET中,每个线程都会有一个属于其自己的SynchronizationContext,它规定了如何如果我接下来要在当前Context中执行一段代码,应该如何执行。简单而言,对于一个规范的Awaiter而言,Awaiter应在其OnCompleted被调用时,将此时CurrentThread的SynchronizationContext记录下来,等到Awaiter所等待的任务完成后,通过该SynchronizationContext去执行该回调。

SynchronizationContext有两个重要的方法SendPost,前者用于同步地执行传递给它的SendOrPostCallback回调,亦即当该方法返回时,SendOrPostCallback回调也应该已经执行完毕。而Post则是异步执行,当该方法返回时并不要求回调已经执行完毕。

下面便是我们自己的SynchronizationContext的实现:

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 MySynchronizationContext : SynchronizationContext
{
private readonly ConcurrentQueue<Msg> _callbacks = new ConcurrentQueue<Msg>();

public override void Post(SendOrPostCallback d, object? state)
{
_callbacks.Enqueue(new Msg(d, state));
}

public override void Send(SendOrPostCallback d, object? state)
{
throw new InvalidOperationException("Send not supported.");
}

public void Update()
{
while (_callbacks.TryDequeue(out var msg))
{
msg.Callback(msg.State);
}
}

private struct Msg
{
public readonly SendOrPostCallback Callback;
public readonly object? State;

public Msg(SendOrPostCallback callback, object? state)
{
Callback = callback;
State = state;
}
}

}

让我们来试一试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

var context = new MySynchronizationContext();
SynchronizationContext.SetSynchronizationContext(context);
async void Foo()
{
Console.WriteLine($"Before {Environment.CurrentManagedThreadId}");
await Task.Delay(100);
Console.WriteLine($"After {Environment.CurrentManagedThreadId} {Environment.StackTrace}");
}

Foo();
while (true)
{
context.Update();
Thread.Sleep(100);
}

输出为:

1
2
3
4
5
6
7
Before 1
After 1 at System.Environment.get_StackTrace()
at Program.<<Main>$>g__Foo|0_0() in [Omitted Path]\Program.cs:line 68
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext(Thread threadPoolThread)
at PlayGround.MySynchronizationContext.Update() in [Omitted Path]\MySynchronizationContext.cs:line 18
at Program.<Main>$(String[] args) in [Omitted Path]\Program.cs:line 74

可以看出,我们这次的确是在同一个线程了。从StackTrace也可以看出,回调是通过MySynchronizationContext.Update方法执行的。

注意,这里我们的Send方法因为用不到所以偷懒直接抛出了异常,当我们正式实现该方法时,因为我们的async方法不是线程安全的,我们不能像SynchronizationContext默认做的那样直接在callsite执行d(state)

等等,你的ShowDialogAwaitable好像也没用到SynchronizationContext

没错,而且UniTask也做了

一般情况下我们并不推荐使用SynchronizationContext,而且也不推荐在游戏引擎中使用Task.Delay()等方法,最好还是直接在游戏引擎的Update中用deltaTime来读秒做Delay吧。

小试牛刀

最后,让我们写个简单的RPG系统来看看async/await的威力:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using TMPro;
using UnityEngine;

public class RPGGameManager : MonoBehaviour
{
private IAction _action;
private Queue<IAction> _actions = new Queue<IAction>();
private BattleLogic _logic;
private TextMeshProUGUI _text;

private void Awake()
{
_text = GetComponent<TextMeshProUGUI>();
}

// Start is called before the first frame update
void Start()
{
_logic = new BattleLogic(this);
_logic.Start();
}

// Update is called once per frame
void Update()
{
if (_action != null)
{
_action.Update();
if (_action.IsCompleted)
{
_action = null;
}
}
if (_action == null)
{
_actions.TryDequeue(out _action);
}
}

public enum Command
{
Attack, Skill, Escape
}

public enum Skill
{
FireBall, Heal
}

private SelectActionAwaitable<Command> SelectCommand()
{
var a = new SelectActionAwaitable<Command>("Select Command, 1. Attack, 2. Skill, 3. Escape", 3, _text);
_actions.Enqueue(a);
return a;
}

private SelectActionAwaitable<Skill> SelectSkill()
{
var a = new SelectActionAwaitable<Skill>("Select Skill, 1. FireBall, 2. Heal", 2, _text);
_actions.Enqueue(a);
return a;
}

private DisplayMessageAwaitable DisplayMessage(string msg)
{
var a = new DisplayMessageAwaitable(msg, _text);
_actions.Enqueue(a);
return a;
}

private interface IAction
{
void Update();

bool IsCompleted { get; }
}

private class DisplayMessageAwaitable : MiniTask<int>, IAction
{

private float _time;

public DisplayMessageAwaitable(string msg, TextMeshProUGUI text)
{
text.text = msg;
_time = 0f;
}

public void Update()
{
_time += Time.deltaTime;
if (_time >= 3)
{
SetResult(0);
}
}
}

class SelectActionAwaitable<T> : MiniTask<T>, IAction where T : Enum
{
private readonly int _max;

public SelectActionAwaitable(string msg, int max, TextMeshProUGUI text)
{
_max = max;
text.text = msg;
}

public void Update()
{
for (var i = 0; i < _max; i++)
{
if (Input.GetKeyDown(KeyCode.Alpha1 + i))
{
SetResult((T)(object)i);
return;
}
}
}
}


private class BattleLogic
{
private readonly RPGGameManager _actionFactory;
private int _playerHp = 10;
private int _enemyHp = 5;

public BattleLogic(RPGGameManager actionFactory)
{
_actionFactory = actionFactory;
}

public async void Start()
{
while (await StartTurn())
{
//empty
}
await _actionFactory.DisplayMessage($"Battle End");
}

private async MiniTask<bool> StartTurn()
{
if (!await StartPlayerTurn())
{
return false;
}

return await StartEnemyTurn();
}

private async Task<bool> StartEnemyTurn()
{
_playerHp -= Mathf.Min(3, _playerHp);
await _actionFactory.DisplayMessage($"Deal 3 Damage to Player, Player Hp Left = {_playerHp}");
return _playerHp > 0;
}

private async Task<bool> StartPlayerTurn()
{
var command = await _actionFactory.SelectCommand();
switch (command)
{
case Command.Attack:
_enemyHp -= Mathf.Min(1, _enemyHp);
await _actionFactory.DisplayMessage($"Deal 1 Damage to Enemy, Enemy Hp Left = {_enemyHp}");
return _enemyHp > 0;
case Command.Skill:
return await HandlePlayerSkill();
case Command.Escape:
await _actionFactory.DisplayMessage($"Escaped!");
return false;
default:
throw new InvalidOperationException();
}
}

private async Task<bool> HandlePlayerSkill()
{
var skill = await _actionFactory.SelectSkill();
switch (skill)
{
case Skill.Heal:
_playerHp = 10;
await _actionFactory.DisplayMessage("Player Hp Restored to Max!");
return true;
case Skill.FireBall:
_enemyHp = 0;
await _actionFactory.DisplayMessage("Weak Point! Enemy is dead!");
return false;
default:
throw new InvalidOperationException();
}
}
}

}

怎么样,虽然由笔者自己来说稍显自夸,但代码的逻辑是不是非常清晰?而且天然地将代码分成了负责控制显示的Action部分和负责逻辑的GameLogic部分。试想如果这个游戏逻辑用callback或者手写状态机的方式来完成,是不是就复杂多了?

目前来看,async/await比较适合用于长流程且不可被打断的复杂逻辑,例如回合制战斗、游戏教程等强制用户按照固定流程操作的场合,或者也可以用于等待UI动画等场合。当然,网络无限宽广,如果正在阅读本篇拙作的您有什么绝妙的想法,不要记在空间有限的书边,笔者正热切地欢迎您的分享(博客的评论系统还没完成,您可以暂时将您的想法发送至:wlzqkwd#Gmail.com)。

扩展阅读:

https://devblogs.microsoft.com/premier-developer/dissecting-the-async-methods-in-c/
介绍了async与await背后的原理

https://devblogs.microsoft.com/premier-developer/extending-the-async-methods-in-c/
介绍了如何实现自己的Awaitable以及task-like types

https://www.codeproject.com/Articles/5274751/Understanding-the-SynchronizationContext-in-NET-wi
介绍了什么是SynchronizationContext,以及如何写一个属于自己的SynchronizationContext

https://github.com/Cysharp/UniTask/
一个Unity下提供async/await支持的库,本文的撰写过程中从该项目借鉴了部分内容(包括shamelessly地省略掉许多和ExecutionContext以及SynchronizationContext相关地代码)

https://www.cnblogs.com/eventhorizon/p/15912383.html
一个讲解Task与async/await功能的系列文章,更深刻地介绍了Task以及async/await内部的逻辑。