Unity: 8 Common Async Mistakes

Unity: 8 Common Async Mistakes

Introduction

This is the second article in a series about using async/await in Unity. In the first, we looked at eight reasons to choose Async over Coroutine. If you are not familiar yet, I recommend starting with it. In this one, we will look at the most common async/await mistakes. The trilogy will culminate with an in-depth comparison between UniTask and Task, and why I recommend using UniTask. Additionally, we'll touch upon Awaitable, a new feature actively being developed by the Unity team.

But before we dive into the topic at hand, let me briefly share the backstory behind this series of articles. A little over a year ago, I was offered to teach a course on Unity development on a widely recognized educational platform. While reviewing the course material, I stumbled upon the following snippet of code.

public void DisplayDialogs(List<Dialog> dialogs)
{
    StartCoroutine(DialogCoroutine(dialogs));
}

private IEnumerator DialogCoroutine(List<Dialog> dialogs)
{
    var dialogTask = ShowDialogs(dialogs);

    while (!dialogTask.IsCompleted)
    {
        yield return null;
    }

    dialogTask.Wait(); // Make sure that possible exceptions are handled.
}

private Task ShowDialogs(List<Dialog> dialogs)
{
    ...
}

In this code snippet, it seems that a decision was made to combine coroutines and async approaches, indicating a struggle with choosing between them. As a result, a coroutine is initiated to monitor the status of the running dialogTask, while the asynchronous task itself is launched without the await keyword. Notably, on line 15, the method Wait() is used, indicating an awareness that invoking an asynchronous method without the await keyword can "hide" any exceptions that might occur within it. The situation here is far from ideal, ranging from inefficient use of CPU resources, as it juggles both Coroutine and Task, to a lack of a fundamental understanding of working with async/await.

Even ChatGPT identifies the problem and offers a solution, which can be found here. While some of the source code comments might be open to debate, the final result is correct.

At this point, it became obvious that the material required improvement, but rewriting the entire course was not feasible due to time constraints. As a result, the decision was made to initiate a series of articles that would delve into the nuances of working with async/await in Unity and beyond. The goal is to minimize the occurrence of complex and convoluted practices in educational materials.

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. Use UniTask instead of Task

The first point in our discussion is a recommendation that can save you from a lot of future mistakes: consider using UniTask over the standard Task. We will delve deeper into the differences between the two in the upcoming article, but for now, let's highlight a couple of key points.

In addition to the benefit of zero memory allocation, UniTask runs on Unity's main thread, the same as coroutines. While it may not provide the same level of asynchrony as Task with its threads, it suffices for most scenarios. Moreover, you can combine these approaches when needed.

What advantages does working on the main thread provide? Apart from the obvious, such as the ability to invoke Unity API calls from async methods and compatibility with WebGL, two major benefits stand out. Firstly, unlike Task, UniTask doesn't "hide" exceptions that occur in async methods when they are called incorrectly. Second, and perhaps most crucially, UniTask eliminates the risk of encountering a «deadlock».

To sum it up, adopting UniTask significantly reduces the chances of encountering pitfalls when working with async/await in Unity.

2. Async void

Now, let's dive into the common mistakes, starting with the age-old classic: async void.

So, what's the issue with the following method?

public async void SomeMethod()
{
    // Async operation.
}

The major issue with this code is that when someone wants to call the SomeMethod(), they won't even realize it's an asynchronous method until they inspect its implementation. Even the IDE won't provide any hints.

This leads us to the first problem. Suppose we want to safeguard ourselves against potential exceptions within the SomeMethod(). To achieve this, we'd typically wrap the method call in a try/catch block.

private void Awake()
{
    try
    {
        _class.SomeMethod();
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);
    }
}

Seems reliable, doesn't it? Well, not quite. If an exception occurs within the SomeMethod(), we won't be able to catch it. While Unity might continue to run our program despite errors, in most applications, unhandled exceptions that occur in the async void method will terminate the application.

In C# async void methods are typically used for «fire-and-forget» scenarios, such as event handlers or top-level asynchronous code. By design, these methods are intended to be non-blocking and not wait for completion.

Let's take a closer look at the example above with a slight modification:

private void Awake()
{
    try
    {
        SomeMethod();
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);
    }

    print("Method end.");
}

private async void SomeMethod()
{
    print("Before exception.");
    throw new Exception();
}

When we run this code, the console output will be as follows:

  1. Before exception.

  2. Method end.

  3. Exception of type 'System.Exception' was thrown.

The initial question that naturally arises when examining the log is: Why was our exception thrown after Method end, rather than before?

To answer this question, let's break down the sequence of events step by step:

  1. When we call SomeMethod() without await, it starts executing asynchronously. However, it still runs on the main Unity thread.

  2. As expected, Before exception is printed.

  3. An exception is thrown within SomeMethod(), but since there's no await, it doesn't wait for the exception handling. The exception propagates up to the caller, which is Awake().

  4. Awake() continues executing without waiting for SomeMethod() to finish. This means Method end is printed after Before exception.

  5. Finally, the exception that occurred is displayed in the console.

Now it becomes evident that by the time the generated exception reaches the Awake() method, we have exited not only the try/catch block but also the Awake() method itself.

If we analyze the execution frame by frame, we will see the following:

  • Frame: 0 Before exception.

  • Frame: 0 Method end.

  • Frame: 0 OnEnable()

  • Frame: 1 Start()

  • Frame: 1 Update()

  • Frame: 1 Exception of type 'System.Exception' was thrown.

  • Frame: 1 LateUpdate()

It is clear here that the exception was thrown on frame zero, but it only becomes visible in the next frame.

It's important to note that UniTask provides UniTaskVoid. UniTaskVoid is a lightweight version of UniTask and serves as an alternative to async void. If you don't require awaiting (fire-and-forget), then using UniTaskVoid is a more suitable choice.

public void StartOperation()
{
    FireAndForgetMethodAsync().Forget();
}

private async UniTaskVoid FireAndForgetMethodAsync()
{
    // Async operation.
}

async void is a standard C# task system, while the UniTaskVoid runs on UniTask systems. And, if we modify the SomeMethod() from the previous example, replacing void with UniTaskVoid, the log will display the following.

  • Frame: 0 Before exception.

  • Frame: 0 Exception of type 'System.Exception' was thrown.

  • Frame: 0 Method end.

This happens because the SomeMethod() is now managed by UniTask. And since UniTaskVoid does not have awaitable completion, it reports errors immediately to UniTaskScheduler.UnobservedTaskException. However, it's essential to note that even with UniTaskVoid, the try/catch block will not catch this exception. Consequently, you will still need to implement exception handling within the method itself.

UniTaskVoid can also be used in MonoBehaviour's Start() method:

public class AsyncSample : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        // Async initialization.
    }
}

3. Concurrency

Another common issue I frequently encounter in code is ignoring the fact that the same asynchronous method can be invoked multiple times and executed concurrently. Which, at best, is a waste of resources.

Consider a scenario where we have a list of items that gets updated each time a user initiates a «pull-to-refresh» action.

private void OnPullToRefresh()
{
    RefreshListItemsAsync(destroyCancellationToken).Forget();
}

private async UniTaskVoid RefreshListItemsAsync(CancellationToken cancellationToken)
{
    _list.RefreshData(await DownloadDataAsync(cancellationToken));
}

What do you think will happen when a user repeatedly triggers the «pull-to-refresh» until new data appears? As you might guess, the RefreshListItemsAsync() method will run exactly as many times as the OnPullToRefresh() event is called. For instance, if a user, initiates the list refresh three times and then simply observes without any further interaction, he will see the data refresh three times. This behavior seems more like a bug than a feature.

To solve this issue, we can declare a variable _isRefreshing, which will act as an indicator for list updates. Depending on its value, we can decide whether to execute the RefreshListItemsAsync() method or not.

private bool _isRefreshing;

private void OnPullToRefresh()
{
    if (_isRefreshing == false)
    {
        RefreshListItemsAsync(destroyCancellationToken).Forget();
    }
}

private async UniTaskVoid RefreshListItemsAsync(CancellationToken cancellationToken)
{
    _isRefreshing = true;

    try
    {
        _list.RefreshData(await DownloadDataAsync(cancellationToken));
    }
    finally
    {
        _isRefreshing = false;
    }
}

While the bool variable approach is suitable for «fire-and-forget» scenarios, it might not be sufficient when we require a method to return a result, especially if multiple requests for the result can originate from various parts of the program.

Consider a situation where we send a time-consuming request to a server, and different parts of the code initiate this request and await its result.

public class NetworkService
{
    private readonly Client _client = new();

    public async UniTask<Data> LongServerRequestAsync(CancellationToken cancellationToken)
    {
        return await _client.LongServerRequestAsync(cancellationToken);
    }
}

When we employ the bool variable approach, we get something like this:

public class NetworkService
{
    private readonly Client _client = new();

    private bool _isBusy;
    private Data _requestResult;

    public async UniTask<Data> LongServerRequestAsync(CancellationToken cancellationToken)
    {
        return _isBusy
            ? await WaitForResultAsync(cancellationToken)
            : await MakeRequestAsync(cancellationToken);
    }

    private async UniTask<Data> MakeRequestAsync(CancellationToken cancellationToken)
    {
        _isBusy = true;

        try
        {
            _requestResult = await _client.LongServerRequestAsync(cancellationToken);

            return _requestResult;
        }
        finally
        {
            _isBusy = false;
        }
    }

    private async UniTask<Data> WaitForResultAsync(CancellationToken cancellationToken)
    {
        while (_isBusy)
        {
            await UniTask.Yield(cancellationToken);
        }

        return _requestResult;
    }
}

Here, we're performing a check: if the server request has already been sent, we await the result from WaitForResultAsync(). Otherwise, we initiate a new request using MakeRequestAsync(). This approach requires too much code and logic to solve such a trivial task, don't you think?

Thankfully, there exists a more elegant solution. We can declare a variable _longServerRequestTask, and store the server request task within it. When someone calls the LongServerRequestAsync() method, we can simply check the status of this task. If it's in the completed state, we initiate a new request. Otherwise, we await the completion of the ongoing request.

public class NetworkService
{
    private readonly Client _client = new();

    private AsyncLazy<Data> _longServerRequestTask;

    public async UniTask<Data> LongServerRequestAsync(CancellationToken cancellationToken)
    {
        if (IsServerRequestCompleted())
        {
            _longServerRequestTask = _client
                .LongServerRequestAsync(cancellationToken)
                .ToAsyncLazy();
        }

        return await _longServerRequestTask.Task;
    }

    private bool IsServerRequestCompleted()
    {
        return _longServerRequestTask?.Task.Status.IsCompleted() ?? true;
    }
}

Please note that the _longServerRequestTask field is of type AsyncLazy<Data>, not UniTask<Data>. This is because UniTask doesn't allow await tasks multiple times, similar to ValueTask. This limitation stems from UniTask's design as a reusable structure; after an await operation, the instance is returned to the pool. Attempting to await it again will result in an exception since the instance might already be in use. To work around this limitation, we decorate our UniTask<Data> with an AsyncLazy<Data> class.

In just a few lines of code, we've alleviated our server from handling unnecessary requests. This approach appears much simpler and clearer, doesn't it?

The choice between the suggested approaches will depend on the specific requirements of your application. If your goal is to prevent the same asynchronous method from running concurrently, a boolean variable may suffice. However, if you need more control over the running task, storing it as a variable is a more flexible solution.

4. Asynchronous object creation

Sometimes there is a need to create a class asynchronously. Unfortunately, we can't add the async keyword to the constructor.

Here's the typical workaround:

public class ResourcesService
{
    private IResourcesProvider _resourcesProvider;

    public ResourcesService(IResourcesProviderFactory factory)
    {
        CreateResourcesProviderAsync(factory).Forget();
    }

    private async UniTaskVoid CreateResourcesProviderAsync(
        IResourcesProviderFactory factory)
    {
        _resourcesProvider = await factory.CreateResourcesProviderAsync();
    }

    public async UniTask<T> DownloadResourceAsync<T>(string resourcePath,
        CancellationToken cancellationToken)
    {
        while (_resourcesProvider is null)
        {
            await UniTask.Yield(cancellationToken);
        }

        return await _resourcesProvider.DownloadAsync<T>(resourcePath, cancellationToken);
    }
}

In this approach, we invoke the CreateConnectionAsync() method within the constructor following the «fire-and-forget» principle. In the while loop of the DownloadResourceAsync() method, we repeatedly check whether our _resourcesProvider has been created. If not, we await its creation. While this approach functions as intended, it can come across as a workaround.

A more elegant solution to this challenge involves employing the «static factory method» pattern. To implement this, we set the constructor of the ResourcesService class as private and replace the private CreateResourcesProviderAsync() method with a public CreateAsync() method, which will return the created instance of the class.

public class ResourcesService
{
    private readonly IResourcesProvider _resourcesProvider;

    private ResourcesService(IResourcesProvider resourcesProvider)
    {
        _resourcesProvider = resourcesProvider;
    }

    public static async UniTask<ResourcesService> CreateAsync(
        IResourcesProviderFactory factory)
    {
        return new ResourcesService(await factory.CreateResourcesProviderAsync());
    }

    public async UniTask<T> DownloadResourceAsync<T>(string resourcePath,
        CancellationToken cancellationToken)
    {
        return await _resourcesProvider.DownloadAsync<T>(resourcePath, cancellationToken);
    }
}

As evident from this implementation, it offers more flexibility, enabling precise control over the creation of the ResourcesService class. Consequently, we can be confident that all essential fields have been properly initialized, eliminating the need for additional checks.

5. WhenAny

Another frequent mistake involves the use of the WhenAny() method. Consider a scenario where we need to display an advertisement and wait for the user to either press the "Escape" key or click the "Skip" button to close the window.

Typically, this is implemented as follows:

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    var skipTask = _skipButton.WaitForClickAsync(cancellationToken);
    var escapeTask = _input.GetKeyUpAsync(KeyCode.Escape, cancellationToken);

    await UniTask.WhenAny(skipTask, escapeTask);
}

It appears to work flawlessly, right? The WhenAny() method will complete when any of the tasks passed to it completes. However, there's a nuance that often escapes notice. The nuance lies in the fact that WhenAny() does not automatically complete the remaining tasks. In other words, if the user clicks the "Skip" button, the WaitForClickAsync() method will finish, and the skipTask will be marked as completed. However, the escapeTask will continue running until the "Escape" button is pressed or the cancellationToken is triggered. The same scenario applies to the skipTask if the user selects "Escape" instead of the "Skip" button. These bugs often go unnoticed, as they manifest themselves only in terms of wasted CPU resources.

The appropriate solution in this case would involve the use of CreateLinkedTokenSource:

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    using var linkedCts = CancellationTokenSource
                .CreateLinkedTokenSource(cancellationToken);

    var skipTask = _skipButton.WaitForClickAsync(linkedCts.Token);
    var escapeTask = _input.GetKeyUpAsync(KeyCode.Escape, linkedCts.Token);

    await UniTask.WhenAny(skipTask, escapeTask);

    linkedCts.Cancel();
}

In this approach, we create linkedCts and pass its token to our methods to enable the ability to cancel running tasks later. CreateLinkedTokenSource creates a CancellationTokenSource that will automatically invoke the Cancel() method if the provided cancellationToken triggers. In other words, if the cancellationToken is activated, both skipTask and escapeTask will be interrupted, and we will exit the method with an OperationCanceledException raised at the await UniTask.WhenAny(...) line. Otherwise, if one of our tasks, let's say skipTask, completes, our WhenAny() method will finish, and we will manually cancel the escapeTask by invoking linkedCts.Cancel().

It's important to note that when cancellation happens due to the cancellationToken passed to the method, we receive an OperationCanceledException. However, when we invoke linkedCts.Cancel(), no exception is thrown. The reason behind this difference lies in the fact that in the first case, we are still monitoring the running tasks within the WhenAny() method, while in the second case, there is no one awaiting for the remaining task to complete. Consequently, the exception is indeed thrown but will be hidden.

You can observe this exception as follows:

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    using var linkedCts = CancellationTokenSource
                .CreateLinkedTokenSource(cancellationToken);

    var skipTask = _skipButton
        .WaitForClickAsync(linkedCts.Token)
        .ToAsyncLazy();

    var escapeTask = _input
        .GetKeyUpAsync(KeyCode.Escape, linkedCts.Token)
        .ToAsyncLazy();

    await UniTask.WhenAny(skipTask.Task, escapeTask.Task);

    linkedCts.Cancel();

    await skipTask.Task;
    await escapeTask.Task;
}

This approach is useful when we need to execute additional logic before exiting the WaitForInputAsync() method, while ensuring that all running tasks are complete or canceled. However, it's worth emphasizing that if we intend to introduce additional logic at the end of a method, we should utilize the SuppressCancellationThrow() extension method to suppress the OperationCanceledException, rather than prematurely exiting the method.

private async UniTask WaitForInputAsync(CancellationToken cancellationToken)
{
    ...

    await skipTask.Task.SuppressCancellationThrow();
    await escapeTask.Task.SuppressCancellationThrow();

    // Additional logic...
}

6. TaskCompletionSource

Did you know that you can make any method asynchronous, even when the external API you are working with doesn't support asynchrony? You can achieve this by utilizing UniTaskCompletionSource, which enables the creation of a task that can be handed out to consumers. The consumers can use the members of the task the same way as they would in any other scenario handling task member variables. However, unlike most tasks, the state of a task created by a UniTaskCompletionSource is controlled explicitly by the methods on UniTaskCompletionSource.

Consider the following class as an example:

public class ThirdPartyClass
{
    public event EventHandler Done;

    public void StartAction()
    {
        ...

        Done?.Invoke(this, EventArgs.Empty);
    }

    public void StopAction()
    {
        ...
    }
}

In this class, there isn't a single asynchronous method, but we do have a Done event that triggers when StartAction() completes. Using this event, we can transform the StartAction() method into an asynchronous one.

Let's create an extension for this class:

public static async UniTask StartActionAsync(this ThirdPartyClass thirdPartyClass,
    CancellationToken cancellationToken = default)
{
    var tcs = new UniTaskCompletionSource();

    void OnDone(object sender, EventArgs e)
    {
        thirdPartyClass.Done -= OnDone;
        tcs.TrySetResult();
    }

    thirdPartyClass.Done += OnDone;
    thirdPartyClass.StartAction();

    try
    {
        await tcs.Task.AttachExternalCancellation(cancellationToken);
    }
    catch (OperationCanceledException)
    {
        thirdPartyClass.StopAction();
        throw;
    }
}

Now, we can seamlessly integrate the ThirdPartyClass class into our async/await pipeline:

private async UniTask SomeMethodAsync(CancellationToken cancellationToken)
{
    ...

    await thirdPartyClass.StartActionAsync(cancellationToken);

    ...
}

Let's examine the implementation of the StartActionAsync() method more closely. Do you notice a potential «event memory leak»? We have a subscription to the Done event, and there's an unsubscription within the OnDone() method. But what if the operation is canceled? Indeed, the OnDone() method will not be called, and the unsubscription will not take place.

A more robust approach is to move the unsubscription to the finally block:

public static async UniTask StartActionAsync(this ThirdPartyClass thirdPartyClass,
    CancellationToken cancellationToken = default)
{
    var tcs = new UniTaskCompletionSource();

    void OnDone(object sender, EventArgs e)
    {
        tcs.TrySetResult();
    }

    try
    {
        thirdPartyClass.Done += OnDone;
        thirdPartyClass.StartAction();

        await tcs.Task.AttachExternalCancellation(cancellationToken);
    }
    catch (OperationCanceledException)
    {
        thirdPartyClass.StopAction();
        throw;
    }
    finally
    {
        thirdPartyClass.Done -= OnDone;
    }
}

Now, we've eliminated the potential memory leak, and the unsubscription will be done in any case.

7. Cancelling uncancellable operations

The next mistake is associated with a misconception regarding the cancellation of asynchronous operations.

To illustrate this, consider a library with the following class:

public class PurchaseManager
{
    ...

    public async Task InitializeAsync()
    {
        // Async initialization.
    }
}

This is a typical third-party library, and our objective is to implement cancelable initialization. To achieve this, we can utilize the AttachExternalCancellation() extension method provided by UniTask.

Here's the resulting code:

public class PurchaseService
{
    private readonly PurchaseManager _purchaseManager = new();

    public async UniTask InitializeAsync(CancellationToken cancellationToken)
    {
        await _purchaseManager
            .InitializeAsync()
            .AsUniTask()
            .AttachExternalCancellation(cancellationToken);
    }
}

In this code snippet, we attach the cancellationToken to our asynchronous method, even though the method itself doesn't inherently support cancellation. This code appears to work as expected in most cases. However, to be more precise, it often gives the impression of working as expected. To gain a deeper understanding, let's delve into the implementation of AttachExternalCancellation().

Typically, the AttachExternalCancellation() is implemented as follows:

public static async Task AttachExternalCancellation(this Task task,
    CancellationToken cancellationToken)
{
    var tcs =
        new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);

    await using (cancellationToken.Register(state =>
                 {
                     ((TaskCompletionSource<object>) state).TrySetResult(null);
                 }, tcs))
    {
        var resultTask = await Task.WhenAny(task, tcs.Task);

        if (resultTask == tcs.Task)
        {
            throw new OperationCanceledException(cancellationToken);
        }

        await task;
    }
}

In UniTask, the implementation differs slightly, but the core concept remains unchanged. Can you spot the issue here? Canceling any asynchronous operation that lacks built-in cancellation support is essentially a formality. Even after cancellation, the asynchronous operation will continue to run until it reaches completion.

We can attempt an implementation like this:

public class PurchaseService
{
    private readonly PurchaseManager _purchaseManager = new();

    private Task _purchaseManagerTask;

    public async UniTask InitializePurchaseManagerAsync(CancellationToken cancellationToken)
    {
        if (_purchaseManagerTask is null)
        {
            _purchaseManagerTask = _purchaseManager.InitializeAsync();
        }
        else if (_purchaseManagerTask.IsCompleted)
        {
            throw new Exception("Purchase manager is already initialized.");
        }

        await _purchaseManagerTask
            .AsUniTask()
            .AttachExternalCancellation(cancellationToken);
    }
}

While this approach is improved and prevents re-initialization, it is still not perfect. There is no one-size-fits-all solution in such cases. Therefore, consider adding CancellationToken support to your asynchronous code.

This brings us smoothly to our final point.

8. CancellationToken

Yet another common mistake is running an asynchronous operation without passing the CancellationToken to it.

Consider a scenario where we have a pop-up window that, upon opening, loads an image:

public class PopUpWindow : MonoBehaviour
{
    [SerializeField] private RawImage _rawImage;
    [SerializeField] private GameObject _loadingIndicator;

    public void Open(string imageUrl)
    {
        gameObject.SetActive(true);
        DownloadImageAsync(imageUrl).Forget();
    }

    public void Close()
    {
        gameObject.SetActive(false);
        Destroy(_rawImage.texture);
    }

    private async UniTaskVoid DownloadImageAsync(string imageUrl)
    {
        _loadingIndicator.SetActive(true);

        try
        {
            _rawImage.texture = await DownloadTextureAsync(imageUrl);
        }
        finally
        {
            _loadingIndicator.SetActive(false);
        }
    }

    private static async UniTask<Texture2D> DownloadTextureAsync(string uri)
    {
        using var www = UnityWebRequestTexture.GetTexture(uri);

        await www.SendWebRequest();

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

What do you anticipate will happen if you close the window before the image finishes loading? You guessed it right, it continues to load in the background, consuming resources needlessly. More significantly, if we close the window before the image loads, Destroy(_rawImage.texture) will be invoked. However, the image hasn't loaded yet. It only appears after the download is complete. Consequently, this leads to a memory leak.

We can easily fix this by implementing CancellationToken support:

public class PopUpWindow : MonoBehaviour
{
    ...

    private CancellationTokenSource _cancellationTokenSource;

    public void Close()
    {
        gameObject.SetActive(false);

        if (_cancellationTokenSource is null)
        {
            Destroy(_rawImage.texture);
        }
        else
        {
            _cancellationTokenSource.Cancel();
        }
    }

    private async UniTaskVoid DownloadImageAsync(string imageUrl)
    {
        _loadingIndicator.SetActive(true);

        _cancellationTokenSource = new CancellationTokenSource();

        try
        {
            var (isCanceled, texture) =
                await DownloadTextureAsync(imageUrl, _cancellationTokenSource.Token)
                    .SuppressCancellationThrow();

            if (isCanceled == false)
            {
                _rawImage.texture = texture;
            }
            else
            {
                print("Operation was canceled.");
            }
        }
        finally
        {
            _loadingIndicator.SetActive(false);

            _cancellationTokenSource.Dispose();
            _cancellationTokenSource = null;
        }
    }

    private static async UniTask<Texture2D> DownloadTextureAsync(string uri,
        CancellationToken cancellationToken = default)
    {
        using var www = UnityWebRequestTexture.GetTexture(uri);

        await www
            .SendWebRequest()
            .WithCancellation(cancellationToken);

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

An interesting aspect here is the utilization of the cancellationToken in the DownloadTextureAsync() method. UniTask provides the WithCancellation() extension method for UnityWebRequestAsyncOperation right out of the box. Notably, this isn't the same as AttachExternalCancellation() since, under the hood, it invokes the Abort() method on our request. Additionally, observe how we handle the cancellation operation in the DownloadImageAsync() method. Here, we employ the SuppressCancellationThrow() method, allowing us to suppress the OperationCanceledException. This can be beneficial when optimizing for performance since exception throwing is a resource-intensive operation.

Unmanaged resources
Speaking of memory leaks, this is a very common mistake. We had a seemingly simple test task: download a random set of images from the network. We left it open for candidates to choose between using coroutines or async/await. However, our focus was specifically on their ability to handle unmanaged resources. Can you guess what percentage of candidates inadvertently caused a memory leak? Over a span of two years, we observed this error in every single solution, no matter how disheartening it may sound.

It's worth noting that CancellationTokenSource implements the IDisposable interface. If you create it manually, it's advisable to invoke the Dispose() method. Of course, if you create a CancellationTokenSource as shown in the example above, there's no need for deallocation, and calling Dispose() is unnecessary. However, if you create a CancellationTokenSource that is used for timeouts (created with timers or uses the CancelAfter() method), then it becomes essential to call Dispose().

This example does not dispose the CancellationTokenSource and as a result, the timer stays in the queue after each request is made:

public async UniTask<Stream> HttpClientWithCancellationBadAsync()
{
    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

    var response = await _httpClient.GetAsync("...", cts.Token);

    return await response.Content.ReadAsStreamAsync();
}

This example disposes the CancellationTokenSource and properly removes the timer from the queue:

public async UniTask<Stream> HttpClientWithCancellationGoodAsync()
{
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
    {
        var response = await _httpClient.GetAsync("...", cts.Token);

        return await response.Content.ReadAsStreamAsync();
    }
}

To eliminate the need to worry about when to call Dispose() and when not to, a good practice is to always call it, as you would with all classes that implement IDisposable.

In Unity, starting from version 2022.2, the MonoBehaviour class includes a destroyCancellationToken. This is useful when you need to terminate the execution of asynchronous code upon object destruction. For those using earlier Unity versions, UniTask provides an extension method called GetCancellationTokenOnDestroy() for the MonoBehaviour class. If you want to monitor the application quitting, you can utilize Application.exitCancellationToken.

Conclusion

In this article, I've focused only on the most common mistakes that I frequently encounter in async/await usage. However, the topic of async/await is incredibly vast, and it's impossible to cover every aspect in a single article. If you have your own top list of common mistakes, please share them in the comments below. I'd love to hear your insights and experiences.

I hope that this article has enhanced your understanding of working with async/await. Ideally, it has provided you with valuable insights that will help you address issues in your ongoing projects.

P.S. Comments, additions, and constructive criticism are welcomed.