Thoroughly understand the unity async task feature

Keywords: Unity Multithreading Task

Is it 2017??

Using collaborative programs in Unity is usually a good way to solve some problems, but it also has some disadvantages:

  • 1. The collaborator cannot return a value. This encourages programmers to create huge monolithic collaborations instead of writing them in many small ways. There are some alternative methods, such as passing the callback parameter of action < > type to the collaboration program, or converting the final untyped value generated from the collaboration program after the collaboration process is completed, but these methods are easy to use and error prone.

  • 2. Collaborative programs make error handling difficult. You cannot put yield in a try catch, so you cannot handle exceptions. In addition, when an exception does occur, the stack trace only tells you the coroutine that threw the exception, so you must guess from which coroutine it might be called.

With the release of Unity 2017, we can now replace our asynchronous method with a new C function called async await. Compared with collaborative program, it has many good functions.

To enable this feature, you only need to open the player settings (file - > build settings - > player settings.. - > other settings) and change the "Scripting Runtime Version" to (. NET 4.x).

Let's take a simple example. In view of the following cooperation process:

public class AsyncExample : MonoBehaviour
{
    IEnumerator Start()
    {
        Debug.Log("Waiting 1 second...");
        yield return new WaitForSeconds(1.0f);
        Debug.Log("Done!");
    }
}

The equivalent way to do this using async await is as follows:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        Debug.Log("Waiting 1 second...");
        await Task.Delay(TimeSpan.FromSeconds(1));
        Debug.Log("Done!");
    }
}

In both cases, it helps to be a little aware of what's going on under the hood.

In short, Unity coroutine is implemented using C's built-in support for iterator blocks . the IEnumerator iterator object you provide to the StartCoroutine method is saved by unity, which advances each frame to get the new value generated by your collaborator. Unity then reads different values of "return" to trigger special case behavior, such as executing a nested procedure (when returning another ienumeror) with a delay of some seconds (when an instance of type WaitForSeconds is returned), or just wait until the next frame (when null is returned).

Unfortunately, because async await is a very new fact in Unity, the built-in support for collaborative programs described above does not exist in a similar way as async await, which means that we must add a lot of this support ourselves.

Unity does provide us with an important part. As you can see in the above example, by default, our asynchronous methods will run on the main unity thread. In non-uniform C applications, asynchronous methods usually run automatically on different threads, which is a big problem in unity because we can't always work with unity in these cases API. Without the support of unity engine, our calls to unity methods / objects in asynchronous methods sometimes fail because they will be executed on a separate thread. This is how it works because unity provides a default SynchronizationContext called UnitySynchronizationContext, which automatically collects any asynchronous code queued per frame , and continue running them on the main unified thread.

However, it turns out that this is enough for us to start using async await! We only need some auxiliary code to let us do something interesting, not just a simple time delay.

Customize Awaiters

At present, we can't write a lot of interesting asynchronous code. We can call other asynchronous methods. We can use Task.Delay, just like the above example, but not many.

For a simple example, let's add the ability to directly 'wait' on the TimeSpan instead of calling Task.Delay like the above example every time. Like this:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        await TimeSpan.FromSeconds(1);
    }
}

All we need to do is add a custom GetAwaiter extension method to the TimeSpan class:

 public static class AwaitExtensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
    {
        return Task.Delay(timeSpan).GetAwaiter();
    }
}

This is effective because in order to support "waiting" for a given object in the new version of C, all we need is that the object has a method named GetAwaiter, which returns an Awaiter object. This is good because it allows us to wait for anything we want by using the above extension method without changing the actual TimeSpan class.

We can also use the same method to support waiting for other types of objects, including all classes used by Unity for coroutine instructions! We can make WaitForSeconds, WaitForFixedUpdate, WWW, etc. wait for them in the same way in the coroutine. We can also add a GetAwaiter method to IEnumerator to support waiting for the coroutine to allow communication with the coroutine The old IEnumerator code exchanges asynchronous code.

The code that implements all this can be found from Asset store or github WarehouseRelease section of Download. This allows you to:

public class AsyncExample : MonoBehaviour
{
    public async void Start()
    {
        // Wait one second
        await new WaitForSeconds(1.0f);
 
        // Wait for IEnumerator to complete
        await CustomCoroutineAsync();
 
        await LoadModelAsync();
 
        // You can also get the final yielded value from the coroutine
        var value = (string)(await CustomCoroutineWithReturnValue());
        // value is equal to "asdf" here
 
        // Open notepad and wait for the user to exit
        var returnCode = await Process.Start("notepad.exe");
 
        // Load another scene and wait for it to finish loading
        await SceneManager.LoadSceneAsync("scene2");
    }
 
    async Task LoadModelAsync()
    {
        var assetBundle = await GetAssetBundle("www.my-server.com/myfile");
        var prefab = await assetBundle.LoadAssetAsync<GameObject>("myasset");
        GameObject.Instantiate(prefab);
        assetBundle.Unload(false);
    }
 
    async Task<AssetBundle> GetAssetBundle(string url)
    {
        return (await new WWW(url)).assetBundle
    }
 
    IEnumerator CustomCoroutineAsync()
    {
        yield return new WaitForSeconds(1.0f);
    }
 
    IEnumerator CustomCoroutineWithReturnValue()
    {
        yield return new WaitForSeconds(1.0f);
        yield return "asdf";
    }
}

As you can see, using asynchronous wait can be very powerful, especially when you start combining multiple asynchronous methods like the LoadModelAsync method above.

Note that for asynchronous methods that return values, we use the generic version of Task and pass the return type as a generic parameter, just like GetAssetBundle above.

Also note that in most cases, using WaitForSeconds above is actually preferable to our TimeSpan extension method, because WaitForSeconds will use Unity game time, and our TimeSpan extension method will always use real-time (so it will not be affected by the change of Time.timeScale)

Trigger asynchronous code and exception handling

You may have noticed that one thing in our code above is that some methods are defined as' async void 'and some methods are defined as' async Task'. So when should we use another one?

The main difference here is that other asynchronous methods cannot wait for methods defined as "async void". This indicates that we should always prefer to define our asynchronous methods with the return type Task so that we can "wait" for them.

The only exception to this rule is when you want to call an asynchronous method from non asynchronous code. See the following example:

public class AsyncExample : MonoBehaviour
{
    public void OnGUI()
    {
        if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
        {
            RunTaskAsync();
        }
    }
 
    async Task RunTaskAsync()
    {
        Debug.Log("Started task...");
        await new WaitForSeconds(1.0f);
        throw new Exception();
    }
}

In this example, when the user clicks the button, we want to start the asynchronous method. This code will compile and run, but it has a major problem. If any exceptions occur in the RunTaskAsync method, they will occur silently. The exceptions will not be logged to the unified console.

This is because when exceptions occur when an asynchronous method returns a Task, they will be caught by the returned Task object instead of being thrown and processed by Unity. The reason for this behavior is sufficient: asynchronous code is allowed to work normally with the try catch block. Take the following code as an example:

async Task DoSomethingAsync()
{
    var task = DoSomethingElseAsync();
 
    try
    {
        await task;
    }
    catch (Exception e)
    {
        // do something
    }
}
 
async Task DoSomethingElseAsync()
{
    throw new Exception();
}

Here, the exception is caught by the Task returned by the DoSomethingElseAsync method and is re thrown only when "waiting". As you can see, Calling asynchronous methods is different from waiting for them , which is why the Task object must catch exceptions.

Therefore, in the OnGUI example above, when an exception is thrown in the RunTaskAsync method, it will be captured by the returned Task object. Since there is nothing on the Task, the exception will not bubble to Unity, so it will never be recorded.

But this reminds us of the problem that we want to call asynchronous methods from non asynchronous code in these cases. In the above example, we want to start the RunTaskAsync asynchronous method from within the OnGUI method. We don't care about waiting for it to complete, so we don't want to just add await so that exceptions can be logged.

The rule of thumb to remember is:

Never call the async Task method without a Task waiting to return. If you don't want to wait for the asynchronous behavior to complete, you should call the async void method.

So our example becomes:

public class AsyncExample : MonoBehaviour
{
    public void OnGUI()
    {
        if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
        {
            RunTask();
        }
    }
 
    async void RunTask()
    {
        await RunTaskAsync();
    }
 
    async Task RunTaskAsync()
    {
        Debug.Log("Started task...");
        await new WaitForSeconds(1.0f);
        throw new Exception();
    }
}

If you run this code again, you should now see that an exception is logged. This is because when an exception is thrown during await in the RunTask method, it will bubble to Unity and record it to the console, because in this case, no Task object can catch it.

The method marked "async void" represents the root level "entry point" for some asynchronous behavior. A good way to think about them is that they are "launch and forget" tasks that perform certain operations in the background if any calling code continues immediately.

By the way, this is also a good reason to follow the Convention of always using the suffix "Async" on asynchronous methods that return tasks. This is the standard practice for most code bases that use Async await. It helps convey the fact that the method should always start with 'await', but it also allows you to create an async void counterpart for methods that do not contain a suffix.

It is also worth mentioning that if you compile code in Visual Studio, you should receive a warning when you try to call the asynchronous task method without related waiting. This is a good way to avoid this error.

As an alternative to creating your own "async void" method, you can also use auxiliary methods (included in the source code related to this article) to perform waiting. In this case, our example will be:

public class AsyncExample : MonoBehaviour
{
    public void OnGUI()
    {
        if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
        {
            RunTaskAsync().WrapErrors();
        }
    }
 
    async Task RunTaskAsync()
    {
        Debug.Log("Started task...");
        await new WaitForSeconds(1.0f);
        throw new Exception();
    }
}

The WrapErrors () method is just a general way to ensure that the waiting task is, so Unity will always receive any thrown exceptions. It just waited, and that's it:

public static async void WrapErrors(this Task task)
{
    await task;
}

Call asynchronously from a coroutine

For some code bases, migrating from CO process to async await seems to be a difficult task. We can simplify this process by gradually adopting async await. However, in order to do this, we need to be able to call not only IEnumerator code from asynchronous code, but also asynchronous code from IEnumerator code. Fortunately, we can easily add using another extension method:

public static class TaskExtensions
{
    public static IEnumerator AsIEnumerator(this Task task)
    {
        while (!task.IsCompleted)
        {
            yield return null;
        }
 
        if (task.IsFaulted)
        {
            throw task.Exception;
        }
    }
}

Now we can call asynchronous methods from coroutines, as follows:

public class AsyncExample : MonoBehaviour
{
    public void Start()
    {
        StartCoroutine(RunTask());
    }
 
    IEnumerator RunTask()
    {
        yield return RunTaskAsync().AsIEnumerator();
    }
 
    async Task RunTaskAsync()
    {
        // run async code
    }
}

Multithreading

We can also use async await to execute multiple threads. You can do this in two ways. The first method is to use the ConfigureAwait method, as shown below:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Here we are on the unity thread
 
        await Task.Delay(TimeSpan.FromSeconds(1.0f)).ConfigureAwait(false);
 
        // Here we may or may not be on the unity thread depending on how the task that we
        // execute before the ConfigureAwait is implemented
    }
}

As mentioned above, Unity provides something called the default SynchronizationContext, which will execute asynchronous code on the main Unity thread by default. The ConfigureAwait method allows us to override this behavior, so the result will be await. The following code will no longer guarantee to run on the main Unity thread, but inherit the context from the task we are executing. In some cases, it may be what we want.

If you want to explicitly execute code on a background thread, you can also do the following:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // We are on the unity thread here
 
        await new WaitForBackgroundThread();
 
        // We are now on a background thread
        // NOTE: Do not call any unity objects here or anything in the unity api!
    }
}

WaitForBackgroundThread is a class included in the source code of this article. It will complete the task of starting a new thread and ensure that the default SynchronizationContext behavior of Unity is rewritten.

What about going back to the Unity thread?

You just wait for any Unity specific objects we create above. For example:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Unity thread
 
        await new WaitForBackgroundThread();
 
        // Background thread
 
        await new WaitForSeconds(1.0f);
 
        // Unity thread again
    }
}

The included source code also provides a WaitForUpdate() class. If you only want to return the Unity thread without any delay, you can use it:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Unity thread
 
        await new WaitForBackgroundThread();
 
        // Background thread
 
        await new WaitForUpdate();
 
        // Unity thread again
    }
}

Of course, if you use background threads, you need to be very careful to avoid concurrency problems. However, in many cases, improving performance is worth it.

Pitfalls and best practices

  • Avoid async void supporting asynchronous tasks, except when you want to start 'fire and forget' asynchronous code from non asynchronous code
  • Append the suffix "Async" to all asynchronous methods that return a Task. This helps convey the fact that it should always start with 'await' and allows you to easily add asynchronous void correspondence without conflict
  • It is not feasible to debug asynchrony with breakpoints in visual studio. However, as here As shown in, the "VS Unity for Unity" team indicates that work is under way

UniRx

Another way to do asynchronous logic is to use UniRx Such libraries are used for reactive programming. Personally, I am a big fan of this code and have used it widely in many projects I have participated in. Fortunately, it's easy to use with async await and another custom await. For example:

public class AsyncExample : MonoBehaviour
{
    public Button TestButton;
 
    async void Start()
    {
        await TestButton.OnClickAsObservable();
        Debug.Log("Clicked Button!");
    }
}

I found that UniRx observable has different uses from long-running asynchronous methods / collaborative programs, so they are naturally suitable for using async await workflow, as shown in the above example. I won't introduce it in detail here, because UniRx and reactive programming are a separate topic, but I will say that once you are satisfied with the data flu in the "stream" of UniRx, there will be no turning back.

source code

You can Asset store or Version part of github repo Download the source code that contains async await support.

Posted by pages on Mon, 06 Dec 2021 15:11:54 -0800