C# Task 指南
C# Task 指南
目标 & 背景
前段时间在不同的技术交流群中,发现很多开发者在使用 async
时,多多少少会犯各种各样的错误,而这些错误想要纠正并不是三言两语能讲明白的,再加上很多资料也多少有些误导初学者使用的问题
希望本篇文章可以帮助你理解 async
,并减少一些基础错误,本文中涉及的部分代码已经推送到 AsyncTutorial 仓库
理解 Task-like
引用 c#的await/async的优缺点是什么? - hez2010的回答 - 知乎
C# 的
async/await
其实就是一个通用的异步编程模型1,编译器会对async
方法采用CPS
变化,以await
为分界线将方法进行拆分,然后使用一个状态机来驱动执行
不管是 C# 原生 Task2、ETTask3、UniTask4,我们都统一称为 Task-like
自定义状态机切换
以下部分参考了 Bart De Smet5早年的一部视频,但是比较可惜原链接失效了,建议后续结合 AsyncTutorial 仓库阅读
void 状态机
假设我们有这样一段函数,当然实际情况肯定不会这么写,而且 请尽可能的不要在项目中使用 async void,后面会说为什么
private async void _Example() { return; }
上面的 _Example
函数其实等价于
private void _Custom()
{
var state_machine = new VoidAsyncStateMachine {builder = AsyncVoidMethodBuilder.Create()};
state_machine.builder.Start(ref state_machine);
}
public struct VoidAsyncStateMachine : IAsyncStateMachine
{
public AsyncVoidMethodBuilder builder;
public void MoveNext() { builder.SetResult(); }
public void SetStateMachine(IAsyncStateMachine state_machine) { }
}
因为是 void,所以需要讲的内容不多,我们接着向下进行
return int 状态机
首先我们还是先看下面这段代码,这里用 Task
包了一个 int
的结果返回出去
private Task<int> _Example() { return Task.FromResult(1); }
这里就和 void
示例不同了,这里的 builder 使用的是 AsyncTaskMethodBuilder
,且同样继承了 IAsyncStateMachine
接口,直接在 MoveNext
函数中对 builder 设置结果,实现和 _Example
同样的效果
private Task<int> _Custom()
{
var state_machine = new ReturnIntAsyncStateMachine {builder = AsyncTaskMethodBuilder<int>.Create()};
state_machine.builder.Start(ref state_machine);
return state_machine.builder.Task;
}
public struct ReturnIntAsyncStateMachine : IAsyncStateMachine
{
public AsyncTaskMethodBuilder<int> builder;
public void MoveNext() { builder.SetResult(1); }
public void SetStateMachine(IAsyncStateMachine state_machine) { }
}
但是这样并不能很好的展示状态机的实际效果,我们接着向下看
delay 状态机
这里就不一样了,先等待了 1 秒,然后再返回,同样的我们手写这段状态机
private async Task<int> _Example()
{
await Task.Delay(1000);
return 1;
}
这里注意看,我们多了两个变量,一个是 _state
另一个是 _awaiter
,我们通过 _state
记录当前状态机的状态 0/1
,通过 _awaiter
记录和转移 Task.Delay
本身的状态机
private Task<int> _Custom()
{
var state_machine = new DelayAsyncStateMachine {builder = AsyncTaskMethodBuilder<int>.Create()};
state_machine.builder.Start(ref state_machine);
return state_machine.builder.Task;
}
public struct DelayAsyncStateMachine : IAsyncStateMachine
{
public AsyncTaskMethodBuilder<int> builder;
private int _state;
private TaskAwaiter _awaiter;
public void MoveNext()
{
if(_state == 0)
{
_awaiter = Task.Delay(1000).GetAwaiter();
// lucky check
if(_awaiter.IsCompleted)
{
_state = 1;
goto state1;
}
// 我们只希望 _awaiter 被赋值一次
// 此时说明 Delay 的内容没有完成
// 需要告诉 builder 等待 _awaiter 完成, 才可以继续向下 MoveNext
// 此处通过断点调试, 查看堆栈会非常清晰
_state = 1;
builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return;
}
state1:
if(_state == 1)
{
_awaiter.GetResult();
builder.SetResult(1);
}
}
public void SetStateMachine(IAsyncStateMachine state_machine) { builder.SetStateMachine(state_machine); }
}
首先,当第一次进入 MoveNext
时,我们做了一个 lucky check
,因为有可能第一次进入时, delay 就已经结束了,所以会有上方的 goto
代码
而当第一次j进入,没有结束时,就需要将状态转移到 delay
自己的状态机中,这样当 delay
结束时,会再次进入这段 MoveNext
中,此时 _state = 1
当 _state
为 1 时,直接对 builder 设置结果,到此,这段异步结束,并将结果返回给调用者
下图在检查
_state
处增加的断点堆栈,从堆栈中可以清晰的看出MoveNext
是如何被重新拉起的,至于开始的UnitySynchronizationContext
,我们后面会提到
await button
我们希望实现如下代码,直接对一个按钮进行异步等待,此时就需要用到 INotifyCompletion
接口了
[SerializeField]
private Button _btn;
public async Task Example()
{
await _btn;
Debug.Log("Clicked");
}
C# 在检测一个对象是否可以 await
时,有如下几个必要条件
- 是否有 GetAwaiter 方法(静态扩展也 ok)
- GetAwaiter 方法是否有返回值(下面称为 obj)
- obj 是否继承了 INotifyCompletion 接口
- obj 是否有 bool IsCompleted { get; }
下方的代码就是按照这几个必要条件进行实现的,首先在 OnCompleted
事件中,对 continuation
回调进行缓存,并对按钮增加 click 事件
当用户点击按钮时,会触发 _OnClicked
函数,此时移除按钮已经增加的事件,并执行缓存的 continuation
继续向外执行,这样代码就会执行 Example
中的打印日志函数
public class AwaitableButton : INotifyCompletion
{
public bool IsCompleted => _is_completed;
private readonly Button _btn;
private Action _continuation;
private bool _is_completed;
public AwaitableButton(Button btn) { _btn = btn; }
private void _OnClicked()
{
_btn.onClick.RemoveListener(_OnClicked);
_continuation();
_is_completed = true;
}
public void OnCompleted(Action continuation)
{
_continuation = continuation;
_btn.onClick.AddListener(_OnClicked);
}
public void GetResult() { }
}
public static class ButtonEx
{
public static AwaitableButton GetAwaiter(this Button self) { return new AwaitableButton(self); }
}
基础概念
Fire & Forget
在日常使用 async
时,我们会面临两种情况,一种是这个任务需要等待,另一种并不希望等待,需要等待的很好理解,直接 await xxx
即可,而另一种情况,我们可以理解为 Fire&Forget
,如在 UniTask
环境下,xxx.Forget()
Debug.Log("Start");
DelayLog.Forget();
Debug.Log("End");
private async UniTask DelayLog()
{
await UniTask.Delay(1000);
Debug.Log("Delayed");
}
在上述情况中,日志的打印顺序就变成了
Start
End
Delayed
在不同的库中,Fire&Forget
的调用方式不尽相同
UniTask | ETTask | Task |
---|---|---|
xxx.Forget(); | xxx.Coroutine(); | _ = xxx; |
CS4014 警告
这个警告是告诉开发者,这里有一个 Task-like
的代码没有 await
,但是在实际项目开发过程中,我们是完全不希望有任何 CS4014 警告的
所有异步代码,如果不需要等待,必须手动设置为
Fire&Forget
,否则必须await
这个也是为什么 ET 在每个 asmdef 文件下都会有一个 csc.rsp
文件的原因,具体内容如下
-warnaserror
Unity 中 async 异常处理
在仓库示例中,对 Task、ETTask、UniTask 都写了相同的用例,这里直接说结论
- 一定要在项目开始运行前增加
UnobservedException
回调 - 尽可能的不要使用
async void
除非你做过充分的测试
整体的异常捕获可以分为三个部分
- 可正常捕获
- 可被
UnobservedException
捕获 - 无法捕获 或者被
UnitySynchronizationContext
捕获
第三点最特殊,因为 Unity
版本不一致结果不一样,所以这是为什么要做充分测试的原因
具体代码示例
后续的示例统一用 UniTask
,需要需要其他参考,可去仓库自行阅读
第一种情况,可以正常捕获的异常,代码非常简单,也很好理解
try
{
await _TestUniTask();
}
catch(Exception)
{
// 此处完全可以 catch 到结果
Debug.LogError("Catch UniTask");
}
private async UniTask _TestUniTask()
{
await UniTask.CompletedTask;
throw new Exception(nameof(_TestUniTask));
}
第二种情况一般是在 Fire&Forget
异步发生时,对该段代码捕获异常时,此时的 try-catch
代码段是无法捕获 _TestUniTaskVoid
函数抛出的异常,而是会被 UniTaskScheduler.UnobservedTaskException
的全局异常捕获
这也是为什么一定要在项目运行开始前增加对
UnobservedException
监听的原因
try
{
// 会被全局的 UnobservedTaskException 处理
_TestUniTaskVoid().Forget();
}
catch(Exception)
{
// BUG catch 无效!
Debug.LogError("Catch UniTaskVoid");
}
private async UniTaskVoid _TestUniTaskVoid()
{
await UniTask.CompletedTask;
throw new Exception(nameof(_TestUniTaskVoid));
}
第三种情况一般是发生在 async void
函数中,这种情况最为特殊,会被 Unity 托底的 UnitySynchronizationContext
抛出异常
但是,这个异常并不是所有版本的
Unity
都会被正确抛出!请一定在自己项目中充分测试
try
{
_TestUniTask_Async_Void();
}
catch(Exception)
{
// BUG catch 无效!
Debug.LogError("Catch async void");
}
private async void _TestUniTask_Async_Void()
{
await UniTask.CompletedTask;
throw new Exception(nameof(_TestUniTask_Async_Void));
}
实际案例
下方的示例仍然以 UniTask
举例
同步转 async
我们还是以一个按钮点击为示例,将点击按钮封装成 async
形式,此时我们需要借助 UniTaskCompletionSource
,一般缩写为 tcs
[SerializeField]
private Button _btn;
private UniTaskCompletionSource _tcs;
private void Awake()
{
_btn.onClick.AddListener(_OnClick);
}
private void _OnClick()
{
_tcs?.TrySetResult();
}
public UniTask OnClickAsync()
{
_tcs?.TrySetCanceled();
_tcs = new UniTaskCompletionSource();
return _tcs.Task;
}
这段逻辑也很好理解,当外部调用异步等待方法时,将旧的取消掉(如果不必要也可以不取消),并获取一个新的实例,并等待这个任务
直到用户点击了指定的按钮,在按钮回调的事件中,设置一下当前任务的结果,即可完成同步转 async
的需求,其他类似的需求同理
取消
异步任务的取消我们需要借助 CancellationToken
, 一般缩写为 ct
, 而当外部需要取消时,我们会发现 ct
并没有直接取消的函数,而真正取消的函数存放在 CancellationTokenSource
中,一般缩写为 cts
[SerializeField]
private Button _btn;
private CancellationTokenSource _cts;
private void Awake()
{
_cts = new CancellationTokenSource();
_btn.onClick.AddListener(_OnClick);
_DelayLog.Forget();
}
private void _OnClick()
{
_cts.Cancel();
}
private void _DelayLog()
{
await UniTask.Delay(10000, cancellationToken: _cts.Token);
Debug.Log("Finished");
}
上述代码,在 Awake
时,开启一个延迟 10 秒的异步任务,并传递一个可以取消的令牌,并在用户点击按钮后,取消这个任务
当用户点击按钮,取消后,因为 async 本身的机制原因,会向外抛出异常,而这个异常的成本是非常高的,在部分十分注重性能的代码上需要使用 SuppressCancellationThrow
函数
private void _DelayLog()
{
var canceled = await UniTask.Delay(10000, cancellationToken: _cts.Token).SuppressCancellationThrow();
if(canceled)
{
return;
}
Debug.Log("Finished");
}
WhenAll
在日常开发中,比如配表的异步加载,如果我们直接在循环中挨个 await
,就浪费了并发加载的性能
foreach(ACategory category in for_load)
{
await category.BeginInit();
}
此时我们就需要借助 WhenAll
来进行并发加载
List<UniTask> tasks = new();
foreach(ACategory category in for_load)
{
tasks.Add(category.BeginInit(_loader, settings));
}
await UniTask.WhenAll(tasks.List);
但是这么写仍然有问题,假设你有 1000
个配表,每个配表刚好被打包成一个 ab,也就是说,每一个 Task 任务都对应了一次 IO
,这样相当于同时开启了 1000
个 IO
请求,此时就需要引入分组加载,比如下方代码 50 个为一组,加载完毕后,再加载下一组
List<UniTask> tasks = new();
foreach(ACategory category in for_load)
{
tasks.Add(category.BeginInit(_loader, settings));
if(tasks.Count < 50)
{
continue;
}
await UniTask.WhenAll(tasks.List);
tasks.Clear();
}
if(tasks.Count > 0)
{
await UniTask.WhenAll(tasks.List);
}
此处建议阅读 ET 中 ETTaskHelper 中对 WaitAll6 的实现,非常精彩
WhenAny
一般 WhenAny
的使用场景是需要竞争的几个任务,优先拿最快的那一个,比如当前有 10 个服务器,找一个连接速度最快的,或者发送 http 请求时,当超过 N 秒后,需要转菊花
这里我们以转菊花为例子,注释写的比较清楚,这里就不做过多的解释了
// 标记当前请求为可以多次等待
var request_task = http_request.Send().AsUniTask().Preserve();
// 创建一个等待 Task
var waiting_task = UniTask.Delay(3000);
// 同时开启两个异步事件
int result = await UniTask.WhenAny(waiting_task, request_task);
// 如果 waiting 先完成
if(result == 0)
{
// 那么通知外部显示菊花
await waiting.Invoke();
// 然后再次等待请求结束
http_response = await request_task;
}
// 此时请求先结束, 直接赋值
else
{
http_response = request_task.GetAwaiter().GetResult();
}
// 返回结果...
这里的
Preserve
函数是UniTask
中的特例,因为一个 Task 不允许多次await
的缘故
最后
在最新的 Unity 版本后续规划中7,官方也提到了要内置的 async
实现,同时也有 UniTask
作者的参与,未来至少在 Unity
引擎下,async
的权重会越来越重要,甚至不可或缺,尤其是 hybridclr8热更的诞生,让 Unity
国内生态重回 C#
如果你希望更深一步阅读相关文章,这里推荐 C# 源码 以及 stephen cleary 的博客进行拓展阅读
最后,希望本篇文章可以帮助你更好的理解 async
的本质,以及解决部分日常开发中碰到的常见问题及错误
参考
异步编程模型 APM: https://learn.microsoft.com/zh-cn/dotnet/standard/asynchronous-programming-patterns/asynchronous-programming-model-apm ↩︎
原生 Task: https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.task?view=net-6.0 ↩︎
ETTask: https://github.com/egametang/ET/tree/master/Unity/Assets/Scripts/ThirdParty/ETTask ↩︎
Async programming deep dive: https://www.youtube.com/watch?v=_hZ8rk_effg ↩︎
ETTask WaitAll: https://github.com/egametang/ET/blob/master/Unity/Assets/Scripts/ThirdParty/ETTask/ETTaskHelper.cs ↩︎
Unity 2023.1: https://forum.unity.com/threads/unity-future-net-development-status.1092205/page-16#post-8024567 ↩︎
hybridclr: https://github.com/focus-creative-games/hybridclr ↩︎
- 感谢你赐予我前进的力量