8 Reasons to Ditch Coroutine for Async

Introduction

When it comes to asynchronous operations in Unity, the first thing that comes to mind is coroutine. And this is not surprising, since most of the examples on the network are implemented through them. But few people know that Unity has supported working with async/await since the 2017 version.

So why do most developers still use coroutine instead of async/await? First, as I mentioned, most of the examples are written using coroutine. Secondly, async/await seems to be very difficult for beginner developers. And thirdly, when it comes to commercial projects, where stability is the main criterion, preference is given to an approach that has been proven over the years.

But technologies do not stand still and libraries appear that make working with async / await in Unity convenient, stable and, most importantly, high-performance. And I’m talking about the library UniTask.

I will not list all the advantages of this library, but will highlight only the main ones:

  • Uses task structures and a custom AsyncMethodBuilder to achieve zero allocation

  • Allows the use of the await keyword with all Unity AsyncOperations and Coroutines

  • Doesn’t use threads and runs entirely on the Unity PlayerLoop, allowing for async/await in WebGL, Wasm, etc.

I must say right away that the article was not created to force you to rewrite current projects, but only lists the reasons that can play a key role in choosing an approach for implementing future ones.

PS The code from the following paragraphs is given as an example and may contain errors. Don’t mindlessly copy it into your product.

1. Has return value

Coroutines cannot return values. Therefore, if it is necessary to get a result from a method, a callback of the Action type is used, or casting IEnumerator.Current to the required type after the completion of the coroutine, but these approaches are at least inconvenient to use and error prone.

Let’s look at an example where we need to download an image from the network and return it as the result of a method execution.

Using coroutine, this can be done 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 with async/await is done like this:

private async void 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 via async/await, there is no need to use callback and such code is easier to read. So if you are tired of constant callbacks, then async/await is your choice.

2. Parallel processing

And now imagine that you need to load n images, and do it in parallel.

You can solve a similar problem using coroutine like this:

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 is the same implemented using async/await:

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

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

From the above examples, you can see that using coroutine you need to implement the WhenAll method yourself, while UniTask provides it out of the box, as well as the WhenAny method. Try, at your leisure, to implement WhenAny using coroutine, you will be surprised how quickly the complexity of the source code increases.

3.Support try/catch

Another advantage of async/await over coroutine is the support for a try/catch block. Therefore, by wrapping our code in try / catch, we can catch and handle the error in one place, wherever it occurs in the call stack. When you try to wrap yield return, the compiler will throw an error.

You cannot wrap yield return in a try/catch block:

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

Using async/await there is no such problem:

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

4. Always exits

In addition to the previous point, let’s 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 given examples implement exactly the same logic. But in the case of a coroutine, when it stops, an exception occurs, or the object on which it was started is deleted, the finally block will not be reached. In an async/await implementation, there is no such problem and the finally block will be executed anyway, as expected. So if you have code that uses coroutine and a try/finally block, pay attention to it, you might have a memory leak there.

5. Lifetime handled manually

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

But with great power comes great responsibility. Let’s look at the following example.

Implementation on coroutine:

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;
   }
}

Implementation on async/await:

private async void 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 above, the lifecycle of an async method is independent of MonoBehaviour. Therefore, after the object is destroyed, a MissingReferenceException will be thrown in the RotateAsync method, since it will continue to execute, while the transform of the object we are accessing will no longer exist. In the case of a coroutine, the execution of the RotateCoroutine method will automatically stop, since when the MonoBehaviour is removed, all coroutines running on it stop.

In fact, there are two approaches to solve this problem. First, stop the execution of the async method by passing a CancellationToken to it, we will analyze this option in more detail later. The second, the most logical and correct one, is to simply move the logic that should be executed on every frame to Update. Why do we need the overhead of creating and maintaining additional objects?

6.Full control

As mentioned above, since the life cycle of an async method does not depend on MonoBehaviour, we have full control over the running operation. What can not be said about coroutine.

Let’s look at an example with the implementation of the mechanism for canceling an asynchronous operation. Let’s skip all the checks and concentrate only on the main logic.

Using a coroutine, cancellation is usually 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();
   }
}

But the attentive reader has already noticed the problem. If we cancel the operation somewhere in the middle of loading, then the finally block will not be reached and Dispose will not be called. How to be in this situation?

This is where CancellationToken can come 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();
   }
}

Already better, now when the operation is canceled, the finally block will be executed. But we are still not immune to deactivating the object or deleting the MonoBehaviour. So it turns out that we do not have full control over the coroutine. In the implementation via async/await, there is no such problem.

Async/await implementation using CancellationToken:

public async UniTaskVoid StartOperation(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 asynchronous method is good practice, and it is desirable to provide for its use in these methods.

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 coroutine:

Call stack when an error occurs in a coroutine
Call stack when an error occurs in a coroutine

Call stack when an error occurs in an async method:

Call stack when an error occurs in an async method
Call stack when an error occurs in an async method

In the case of coroutine, we see that the error occurred in the CreatePlayer method, but it is not clear who called this method. Well, if the CreatePlayer method is called in only one place, then it will not be difficult to trace the entire chain of calls, but what if it is called from several places? In the case of async/await, we immediately see the entire chain of calls where we could potentially have a problem, which saves a lot of time when looking for errors.

8. Allocation & Performance

Well, the last in the list, but not the last in importance, is the item about performance and memory usage. As mentioned above, UniTask uses task structures and a custom AsyncMethodBuilder to achieve zero allocation. Also, UniTask does not use ExecutionContext and SynchronizationContext, unlike Task, which allows you to achieve high performance in Unity, as it eliminates the overhead of context switching.

I will not delve into all the subtleties regarding performance and memory use here, for this it is better to read an article from the author himself right herebut I will give only the results of testing.

Memory allocation when using UniTask, Coroutine and Task
Memory allocation when using UniTask, Coroutine and Task

Since testing was done in the Unity editor, the AsyncStateMachine generated by the C# compiler is a class, so we see memory allocations when using UniTask. In the release build, AsyncStateMachine will be a struct and no memory will be allocated. But even despite this, UniTask allocates significantly less memory than Coroutine and Task.

Repository with performance tests can be found here. Just make sure you are using the latest version of UniTask.

Conclusion

I hope these points are enough for you to look at the use of async / await in Unity in a new way, and start considering it as an alternative to coroutine.

An example of using the UniTask library in a project can be found here. There you can also find a list of sources that will help you get a complete picture of how async/await works in C#.

I’m currently preparing an article about the most common mistakes when using async / await, so if you are interested in understanding this topic in more detail, stay tuned.

PS I will be glad to any comments, additions and constructive criticism.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *