如何避免异步无效?

How do I avoid async void?

注意:原来这个问题是 Unity 特有的。

I read that async void was to be avoided. 我正在尝试使用 Result 这样做,但我的应用程序一直锁定。如何避免使用 async void?

public async void PrintNumberWithAwait()
{
    int number = await GetNumber();
    Debug.Log(number); //Successfully prints "5"
}

public void PrintNumberWithResult()
{
    int number = GetNumber().Result;
    Debug.Log(number); //Application Freezes
}

private async Task<int> GetNumber()
{
    await Task.Delay(1000);
    return 5;
}

我认为这是正确的,但我一定遗漏了什么。如何在没有 async void 的情况下使用 async/await?

我 运行 我的测试分别使用以下代码(一次注释掉一个):

PrintNumberWithAwait();
PrintNumberWithResult();

你误解了要避免的async void的意思。

这并不意味着您不应该使用没有附加结果的任务。它只是说调用它们的异步方法应该 return a Task,而不是 void.

只需从

获取异步方法的签名
public async void PrintNumberWithAwait()

并将void替换为Task

public async Task PrintNumberWithAwait()
{
    int number = await GetNumber();
    Debug.Log(number); //Successfully prints "5"
}

现在调用方法可以选择等待结果,只要他们愿意。或者:

await PrintNumberWithAwait();

Task t = PrintNumberWithAwait();
// Do other stuff
// ...
await t;

精简版

Unity 的同步上下文是单线程的。所以:

  1. 结果挂起,直到任务完成
  2. 任务无法完成,因为继续被推送到主线程,即等待

详细版

您说您正在使用 Unity。 Unity 是“99%”的单线程框架。所以我想这段代码是在主线程上执行的,UI线程。

让我们详细了解您的代码在执行 PrintNumberWithResult() 时执行的操作。

  1. [主线程] 从 PrintNumberWithResult() 调用 GetNumber()
  2. [主线程] 你执行await Task.Delay()
  3. [Main Thread] await 行下的代码(return 5)被“推送”到“代码列表”中,然后执行任务(延迟)已完成。 小见识:这种“continuations code”的列表是由一个 class 调用了 SynchronizationContext(它是 c# 的东西,不是 Unity 的东西)。您可以将此 class 视为说明等待之间的代码如何以及何时被调用的人。标准的 .NET 实现使用一个线程池(因此是一组线程),它将在任务完成后执行代码。这就像一个“高级回调”。现在在 Unity 中这是不同的。他们实现了一个自定义同步上下文,确保所有代码始终在主线程中执行。我们现在可以继续
  4. [MAIN THREAD] Delay Task 还没有完成,所以你在PrintNumberWithResult 方法中有一个早期的return,你执行.Result,那个使主线程挂在那里,直到任务完成。
  5. [死锁]。在这一点上发生了两件事。主线程正在等待任务完成。 Custom Unity 的同步上下文将代码推送到 await 之上,以便在主线程中执行。但是主线程正在等待!所以它永远不会免费执行该代码。

解决方案 永远不要调用 .Result.

如果您想触发并忘记任务操作,请使用 Task.Run(() => )。可是等等!这在 Unity 中不是一个好主意! (也许它在其他 .NET 应用程序中)。

如果您在 Unity 中使用 Task.Run(),您将强制使用默认的 .NET 同步上下文执行异步代码,该上下文使用线程池,如果您是,这可能会导致一些同步问题调用一些 Unity 相关的 API.

在这种情况下你想要做的是使用异步 void(不是真正与异常处理相关的原因),一个你永远不会等待(更好)的异步任务方法,或者使用库作为 UniTask 使用 Unity 进行异步等待(我认为最好的)。

非常完整,很好地解释了“问题”/主题。

但稍微扩展一下,您的 async void 示例“有效”的原因之一是:对于 Debug.Log,同步上下文无关紧要。您可以安全地 Debug.Log 来自不同的线程和后台任务,Unity 在控制台中处理它们。 BUT 一旦您尝试使用仅在主线程上允许的 Unity API 中的任何内容(基本上所有立即依赖或更改场景内容的内容)它可能会中断,因为不能保证 await 最终出现在主线程上。

然而,现在呢?

没有什么是反对在 Unity 中使用 ThreadTask.Run 的!

您只需确保将任何结果分派回 Unity 主线程即可。

所以我只是想举一些实际的例子来说明如何做到这一点。


经常使用的是所谓的“主线程调度程序”..基本上只是来自任何线程的 ConcurrentQueue which allows you to Enqueue callback Actions 然后 TryDequeue 并在 Update 例程中调用它们在 Unity 主线程上。

这看起来有点像

/// <summary>
/// A simple dispatcher behaviour for passing any multi-threading action into the next Update call in the Unity main thread
/// </summary>
public class MainThreadDispatcher : MonoBehaviour
{
    /// <summary>
    /// The currently existing instance of this class (singleton)
    /// </summary>
    private static MainThreadDispatcher _instance;

    /// <summary>
    /// A thread-safe queue (first-in, first-out) for dispatching actions from multiple threads into the Unity main thread
    /// </summary>
    private readonly ConcurrentQueue<Action> actions = new ConcurrentQueue<Action>();

    /// <summary>
    /// Public read-only property to access the instance
    /// </summary>
    public static MainThreadDispatcher Instance => _instance;

    private void Awake ()
    {
        // Ensure singleton 
        if(_instance && _instance != this)
        {
            Destroy (gameObject);
            return;
        }

        _instance = this;

        // Keep when the scene changes 
        // sometimes you might not want that though
        DontDestroyOnLoad (gameObject);
    }

    private void Update ()
    {
        // In the main thread work through all dispatched callbacks and invoke them
        while(actions.TryDequeue(out var action))
        {
            action?.Invoke();
        }
    }

    /// <summary>
    /// Dispatch an action into the next <see cref="Update"/> call in the Unity main thread
    /// </summary>
    /// <param name="action">The action to execute in the next <see cref="Update"/> call</param>
    public void DoInNextUpdate(Action action)
    {
        // Simply append the action thread-safe so it is scheduled for the next Update call
        actions.Enqueue(action);
    }
}

当然你需要把它附加到你场景中的一个对象上,然后从任何你可以使用的地方,例如

public void DoSomethingAsync()
{
    // Run SomethingWrapper async and pass in a callback what to do with the result
    Task.Run(async () => await SomethingWrapper(result => 
    { 
        // since the SomethingWrapper forwards this callback to the MainThreadDispatcher
        // this will be executed on the Unity main thread in the next Update frame
        new GameObject(result.ToString()); 
    }));
}

private async Task<int> Something()
{
    await Task.Delay(3000);

    return 42;
}

private async Task SomethingWrapper (Action<int> handleResult)
{
    // cleanly await the result
    var number = await Something ();

    // Then dispatch given callback into the main thread
    MainThreadDispatcher.Instance.DoInNextUpdate(() =>
    {
        handleResult?.Invoke(number);
    });
}

如果您正在进行大量异步操作并希望在某个时候将它们全部分派回主线程,那么这很有意义。


另一种可能性是使用 Coroutines. A Coroutine is basically a bit like a temporary Update method (the MoveNext of the IEnumerator is called once a frame by default) so you can just repeatedly check if your task is done already on the main thread. This is what Unity uses themselves e.g. for UnityWebRequest 并且看起来有点像

public void DoSomethingAsync()
{
    // For this this needs to be a MonoBehaviour of course
    StartCorouine (SomethingRoutine ());
}

private IEnumerator SomethingRoutine()
{
    // Start the task async but don't wait for the result here
    var task = Task.Run(Something);

    // Rather wait this way
    // Basically each frame check if the task is still runnning
    while(!task.IsCompleted)
    {
        // Use the default yield which basically "pauses" the routine here
        // allows Unity to execute the rest of the frame
        // and continues from here in the next frame
        yield return null;
    }

    // Safety check if the task actually finished or is canceled or faulty
    if(task.IsCompletedSuccessfully)
    {
        // Since we already waited until the task is finished 
        // This time Result will not freeze the app
        var number = task.Result;
        new GameObject (number.ToString());
    }
    else
    {
        Debug.LogWarning("Task failed or canceled");
    }
}

private async Task<int> Something ()
{
    await Task.Delay(3000);

    return 42;
}