C# Task 指南

目标 & 背景

前段时间在不同的技术交流群中,发现很多开发者在使用 async 时,多多少少会犯各种各样的错误,而这些错误想要纠正并不是三言两语能讲明白的,再加上很多资料也多少有些误导初学者使用的问题

希望本篇文章可以帮助你理解 async,并减少一些基础错误,本文中涉及的部分代码已经推送到 AsyncTutorial 仓库

理解 Task-like

引用 c#的await/async的优缺点是什么? - hez2010的回答 - 知乎

C# 的 async/await 其实就是一个通用的异步编程模型[^1],编译器会对 async 方法采用 CPS 变化,以 await 为分界线将方法进行拆分,然后使用一个状态机来驱动执行

不管是 C# 原生 Task[^2]、ETTask[^3]、UniTask[^4],我们都统一称为 Task-like

自定义状态机切换

以下部分参考了 Bart De Smet[^5]早年的一部视频,但是比较可惜原链接失效了,建议后续结合 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 的调用方式不尽相同

UniTaskETTaskTask
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,这样相当于同时开启了 1000IO 请求,此时就需要引入分组加载,比如下方代码 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 中对 WaitAll[^6] 的实现,非常精彩

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 的权重会越来越重要,甚至不可或缺,尤其是 hybridclr[^8]热更的诞生,让 Unity 国内生态重回 C#

如果你希望更深一步阅读相关文章,这里推荐 C# 源码 以及 stephen cleary 的博客进行拓展阅读

最后,希望本篇文章可以帮助你更好的理解 async 的本质,以及解决部分日常开发中碰到的常见问题及错误

参考

[^1]: 异步编程模型 APM: https://learn.microsoft.com/zh-cn/dotnet/standard/asynchronous-programming-patterns/asynchronous-programming-model-apm [^2]: 原生 Task: https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.task?view=net-6.0 [^3]: ETTask: https://github.com/egametang/ET/tree/master/Unity/Assets/Scripts/ThirdParty/ETTask [^4]: UniTask: https://github.com/Cysharp/UniTask [^5]: Async programming deep dive: https://www.youtube.com/watch?v=_hZ8rk_effg [^6]: ETTask WaitAll: https://github.com/egametang/ET/blob/master/Unity/Assets/Scripts/ThirdParty/ETTask/ETTaskHelper.cs [^7]: Unity 2023.1: https://forum.unity.com/threads/unity-future-net-development-status.1092205/page-16#post-8024567 [^8]: hybridclr: https://github.com/focus-creative-games/hybridclr