Unity: 8 Reasons to Choose Async Over Coroutine

Unity: 8 Reasons to Choose Async Over Coroutine

Introduction

When it comes to executing a piece of code across multiple frames in Unity, the first thing that comes to mind is coroutines. This is not surprising, as most of the examples found online are implemented using coroutines. However, few people know that Unity has supported working with async/await since the 2017 version.

So why do most developers still prefer coroutines over async/await? Firstly, as mentioned earlier, most examples are written using coroutines. Secondly, async/await seems to be very difficult for beginner developers. Lastly, developers tend to favor an approach proven over the years when it comes to commercial projects where stability is crucial.

But technology doesn't stand still, and libraries have emerged that make working with async/await in Unity convenient, stable, and, most importantly, high-performance. One such library is UniTask.

I won't list all the advantages of this library, but I will highlight the main ones:

  • Struct based and uses a custom AsyncMethodBuilder to achieve zero allocation

  • Makes all Unity AsyncOperations and Coroutines awaitable

  • Runs completely on Unity's PlayerLoop so doesn't use threads and runs on WebGL

It's important to note that this article isn't intended to convince you to rewrite your current projects. Instead, it aims to provide reasons that can play a key role in choosing an approach when implementing future projects.

P.S. The code provided in this article is given as an illustrative example and has been intentionally simplified in favor of clarity. Please don't mindlessly copy it into your production code.

1. Has return value

Coroutines cannot return values. Therefore, if it is necessary to get a result from a method, one must rely on a callback using the Action<T> or cast IEnumerator.Current to the required type after the coroutine has been completed. However, these approaches are inconvenient to use and prone to errors.

Let's consider an example where we need to download an image from the network and return it as the result of method execution.

Using coroutines, the implementation might look like this:

private IEnumerator Start()
{
   yield return DownloadImageCoroutine(_imageUrl, texture =>
   {
       _image.texture = texture;
   });
}

private IEnumerator DownloadImageCoroutine(string imageUrl,
   Action<Texture2D> callback)
{
   using var request = UnityWebRequestTexture.GetTexture(imageUrl);

   yield return request.SendWebRequest();

   callback?.Invoke(request.result == UnityWebRequest.Result.Success
       ? DownloadHandlerTexture.GetContent(request)
       : null);
}

The same is done using async/await:

private async UniTaskVoid Start()
{
   _image.texture = await DownloadImageAsync(_imageUrl);
}

private async UniTask<Texture2D> DownloadImageAsync(string imageUrl)
{
   using var request = UnityWebRequestTexture.GetTexture(imageUrl);

   await request.SendWebRequest();

   return request.result == UnityWebRequest.Result.Success
       ? DownloadHandlerTexture.GetContent(request)
       : null;
}

In the implementation using async/await, there is no need to use callbacks, making the code cleaner and easier to read. If you are tired of dealing with constant callbacks, async/await is your choice.

2. Parallel processing

Now, let's consider a scenario where you need to download multiple images simultaneously.

This task can be solved using coroutines in the following manner:

private IEnumerator Start()
{
   var textures = new List<Texture2D>();

   yield return WhenAll(_imageUrls.Select(imageUrl =>
   {
       return DownloadImageCoroutine(imageUrl, texture =>
       {
           textures.Add(texture);
       });
   }));

   for (var i = 0; i < textures.Count; i++)
   {
       _images[i].texture = textures[i];
   }
}

private IEnumerator WhenAll(IEnumerable<IEnumerator> routines)
{
   var startedCoroutines = routines.Select(StartCoroutine).ToArray();

   foreach (var startedCoroutine in startedCoroutines)
   {
       yield return startedCoroutine;
   }
}

Here's the implementation using async/await:

private async UniTaskVoid Start()
{
   var textures = 
       await UniTask.WhenAll(_imageUrls.Select(DownloadImageAsync));

   for (var i = 0; i < textures.Length; i++)
   {
       _images[i].texture = textures[i];
   }
}

From the examples above, you can see that when using coroutines, you would need to implement the WhenAll method yourself. In contrast, UniTask provides it out of the box as well as the WhenAny method. If you attempt to implement WhenAny using coroutines, you will be surprised by how quickly the complexity of the source code increases.

3. Supports try/catch

The next advantage of async/await over coroutines is the support for try/catch blocks. By wrapping the code in a try/catch block, we can catch and handle errors in one place, regardless of where they occur in the call stack. When attempting to wrap yield return statements, the compiler will throw an error.

It is not possible to wrap yield return in a try/catch block:

private IEnumerator Start()
{
   try
   {
       yield return ConstructScene(); // Compiler error!
   }
   catch (Exception exception)
   {
       Debug.LogError(exception.Message);
   }
}

Using async/await, there is no such limitation:

private async void Start()
{
   try
   {
       await ConstructSceneAsync();
   }
   catch (Exception exception)
   {
       Debug.LogError(exception.Message);
   }
}

4. Always exits

In addition to the previous point, let's take a look at the try/finally block.

Implementation using coroutine:

private IEnumerator ShowEffectCoroutine(RawImage container)
{
   var texture = new RenderTexture(256, 256, 0);
   try
   {
       container.texture = texture;
       for (var i = 0; i < _frameCount; i++)
       {
           /*
            * Update effect.
            */
           yield return null;
       }
   }
   finally
   {
       texture.Release();
   }
}

Implementation using async/await:

private async UniTask ShowEffectAsync(RawImage container)
{
   var texture = new RenderTexture(256, 256, 0);
   try
   {
       container.texture = texture;
       for (var i = 0; i < _frameCount; i++)
       {
           /*
            * Update effect.
            */
           await UniTask.Yield();
       }
   }
   finally
   {
       texture.Release();
   }
}

The provided examples implement the exact same logic. However, in the case of a coroutine, if it stops due to an exception or if the GameObject on which it was started is deleted, the finally block will not be reached. This can lead to unexpected behavior. On the other hand, in an async/await implementation, this problem does not exist, and the finally block will be executed as expected, regardless of any interruptions. If you have code that uses coroutines along with a try/finally block, it is important to pay attention to it, you might have a memory leak there.

5. Lifetime handled manually

Another advantage of async/await over coroutines is that you don't need a MonoBehaviour to start an async operation, and you have more control over its lifecycle. There is no longer a need to keep a MonoBehaviour class on the scene whose only job is to keep coroutines running.

However, with great power comes great responsibility. Let's examine the following example.

Coroutine implementation:

private IEnumerator Start()
{
   StartCoroutine(RotateCoroutine());

   yield return new WaitForSeconds(1.0f);
   Destroy(gameObject);
}

private IEnumerator RotateCoroutine()
{
   while (true)
   {
       transform.Rotate(Vector3.up, 1.0f);
       yield return null;
   }
}

Async/await implementation:

private async UniTaskVoid Start()
{
   RotateAsync().Forget();

   await UniTask.Delay(1000);
   Destroy(gameObject);
}

private async UniTaskVoid RotateAsync()
{
   while (true)
   {
       transform.Rotate(Vector3.up, 1.0f);
       await UniTask.Yield();
   }
}

As mentioned earlier, the lifecycle of an async method is independent of MonoBehaviour. Therefore, after the object is destroyed, if the RotateAsync method continues to execute, a MissingReferenceException will be thrown when accessing the transform of the object that no longer exists. In the case of a coroutine, the execution of the RotateCoroutine method would automatically stop when the MonoBehaviour is removed, as all running coroutines on it.

There are two approaches to address this issue. First, you can stop the execution of the async method by passing a CancellationToken to it (we will explore this option in more detail later). The second, and the more logical and correct approach, is to move the logic that needs to be executed every frame to the Update method. Why do we need the overhead of creating and maintaining additional objects?

6. Full control

As previously mentioned, since the lifecycle of an async method is not tied to MonoBehaviour, we have complete control over the running operation, unlike coroutines.

Let's consider an example of implementing cancellation for an async operation, omitting additional checks, and focusing on the main logic.

When using coroutines, cancellation is typically implemented like this:

public void StartOperation()
{
   _downloadCoroutine =
       StartCoroutine(DownloadImageCoroutine(_imageUrl, texture =>
       {
           _image.texture = texture;
       }));
}

public void CancelOperation()
{
   StopCoroutine(_downloadCoroutine);
}

private IEnumerator DownloadImageCoroutine(string imageUrl,
   Action<Texture2D> callback)
{
   var request = UnityWebRequestTexture.GetTexture(imageUrl);

   try
   {
       yield return request.SendWebRequest();

       callback?.Invoke(
           request.result == UnityWebRequest.Result.Success
               ? DownloadHandlerTexture.GetContent(request)
               : null);
   }
   finally
   {
       request.Dispose();
   }
}

However, attentive readers may have already noticed the problem. If we cancel the operation while it is in the middle of loading, the finally block will not be reached, and Dispose will not be called. How can we address this situation?

This is where CancellationToken comes to the rescue:

public void StartOperation(CancellationToken token = default)
{
   StartCoroutine(DownloadImageCoroutine(_imageUrl, texture =>
   {
       _image.texture = texture;
   }, token));
}

private IEnumerator DownloadImageCoroutine(string imageUrl,
   Action<Texture2D> callback, CancellationToken token)
{
   var request = UnityWebRequestTexture.GetTexture(imageUrl);

   try
   {
       var asyncOperation = request.SendWebRequest();
       while (asyncOperation.isDone == false)
       {
           if (token.IsCancellationRequested)
           {
               request.Abort();
               yield break;
           }

           yield return null;
       }

       callback?.Invoke(
           request.result == UnityWebRequest.Result.Success
               ? DownloadHandlerTexture.GetContent(request)
               : null);
   }
   finally
   {
       request.Dispose();
   }
}

This solution is better, now when the operation is canceled, the finally block will be executed. However, we are still not fully protected against deactivating the object or deleting the MonoBehaviour. On the other hand, in an implementation using async/await, there is no such problem.

Async/await implementation using CancellationToken:

public async UniTask StartOperationAsync(CancellationToken token = default)
{
   _image.texture = await DownloadImageAsync(_imageUrl, token);
}

private async UniTask<Texture2D> DownloadImageAsync(string imageUrl,
   CancellationToken token)
{
   var request = UnityWebRequestTexture.GetTexture(imageUrl);

   try
   {
       await request.SendWebRequest().WithCancellation(token);

       return request.result == UnityWebRequest.Result.Success
           ? DownloadHandlerTexture.GetContent(request)
           : null;
   }
   finally
   {
       request.Dispose();
   }
}

In general, passing a CancellationToken to an async method is a good practice, and it is recommended to consider using it in such methods.

CancellationToken can be created by CancellationTokenSource or MonoBehaviour's extension method GetCancellationTokenOnDestroy.

private void Start()
{
   var cancellationToken = this.GetCancellationTokenOnDestroy();

   StartOperationAsync(cancellationToken).Forget();
}

Since Unity 2022.2 all MonoBehaviour contains destroyCancellationToken.

7. Preserves call stack

Let's take a look at the call stack provided when an error occurs.

Call stack when an error occurs in a coroutine:

Call stack when an error occurs in an async method:

In the case of a coroutine, we can observe that the error occurred in the CreatePlayer method. However, it is not evident from the call stack who invoked this method. If the CreatePlayer method is only called from one place, it may not be challenging to trace the entire calls stack. But, if it is invoked from multiple places, it becomes more difficult to determine the complete call stack. Conversely, with async/await, the entire call stack where a potential problem may exist is immediately visible. This saves a significant amount of time when debugging and searching for errors.

8. Allocation & Performance

Well, the last item on the list, but certainly not the least important, is the aspect of performance and memory usage. As mentioned earlier, UniTask is the struct-based solution and uses the custom AsyncMethodBuilder to achieve zero allocation. Additionally, UniTask does not use threads and SynchronizationContext, and it is completely integrated with Unity, unlike Task, which results in improved performance by eliminating the overhead of context switching.

To better understand the details of performance and memory usage, it's recommended to read the article directly from the author, available here. I will just provide the test results.

Since the testing was conducted in the Unity editor, the AsyncStateMachine generated by the C# compiler is a class, which explains the observed memory allocations when using UniTask. In a release build, the AsyncStateMachine will be a struct, and no memory will be allocated. Nonetheless, even considering this, UniTask still allocates significantly less memory compared to Coroutine and Task.

You can find the benchmark repository on GitHub. Just ensure that you are using the latest version of UniTask for accurate results.

Conclusion

I hope these points provide you with a fresh perspective on the usage of async/await in Unity and encourage you to consider it as an alternative to coroutines.

For a deeper dive into async/await, check out the second article in this series, which covers eight common Async mistakes.

You can find an example of utilizing the UniTask in a project on my GitHub. Additionally, the provided resource includes a list of references that will offer you a comprehensive understanding of how async/await functions in C#.

P.S. I welcome any comments, additions, and constructive criticism you may have.