Handling Swallowed Exceptions in Unity Async Methods Using Awaitable

Posted by : on

Category : Unity

Introduction

With the introduction of Awaitable which I cover in my post Asynchronous Code In Unity Using Awaitable and AwaitableCompletionSource a new challenge has emerged for Unity developers: understanding how and when exceptions are thrown when using asynchronous methods.

There’s often confusion between exceptions being thrown and exceptions being “lost” or never appearing at all, depending on how asynchronous methods are implemented. The truth is, exceptions are never actually lost, they always occur, but due to the nature of asynchronous code, their appearance can be delayed or entirely suppressed based on when and how we use asynchronous methods.

The differences in how exceptions are handled in synchronous versus asynchronous code are commonly referred to as exceptions being “swallowed”. While this can happen in any C# program, it’s especially relevant in Unity. Unity relies heavily on engine-driven event methods (like Start, Update, etc.) that are called from the native side of the engine. These are typically expected to run synchronously, which conflicts with the typical async/await flow in C# applications, where async void is generally considered bad practice.

Swallowed Exceptions

Unity provides various event methods, but for the purposes of this discussion, we’ll use Start as an example. We’ll examine how different declarations of the Start method, combined with different async implementations, result in different behaviors in terms of when and how exceptions are thrown.

First, it’s important to understand Unity’s equivalent of Task: the Awaitable type. An Awaitable represents the result of an asynchronous operation once that result becomes available. By using the await keyword, we tell the program: pause here, wait for the asynchronous operation to finish, and then continue execution.

The result of an asynchronous operation falls into one of two main categories: either it completed successfully or it failed by throwing an exception. Each of these categories can be further broken down:

  • If the operation succeeds, the result might be a value (e.g., Awaitable<Foo> returns a value of type Foo), or it might return nothing (Awaitable with no generic type).

  • If the operation fails, the result is an exception. This might be due to a cancellation token or an error within the code.

The key point here is that exceptions are considered results of the operation, just like return values.

Async and Awaitable

This is why exceptions can appear to be “never thrown”. The programmer typically writes code that says: execute this operation, and only give me the result when it is ready AND when I explicitly ask for it. This request for the result happens when we use the await keyword, which “unwraps” the Awaitable.

It wouldn’t make sense to throw an exception if the result isn’t needed. For example, if you check the weather from four different websites asynchronously and only need the first successful response, the results from the other three, successful or failed, are irrelevant. Throwing an exception for an unused result would be unnecessary and disruptive.

This brings us to Unity’s MonoBehaviour event methods. Suppose we have the following asynchronous method:

private async Awaitable DelayedCodeAsync(bool shouldThrow)
{
    Debug.Log("Initial code")

    if (shouldThrow)
        throw new OperationCanceledException();
    
    await Awaitable.WaitForSecondsAsync(1);
    
    Debug.Log("Delayed code");
}

This method has two possible outcomes: it either completes without a return value or throws an OperationCanceledException. Both of the possible results, either the code running to completion without a return value, or the OperationCanceledException will be stored in the Awaitable returned type. When we await this method, two things can happen, either the result is available, and the program will try to keep running the code after the await or the result won’t be available so the program will continue with running other code until it is.

The result can either be the execution of the whole code block, which is the

await Awaitable.WaitForSecondsAsync(1);
    
Debug.Log("Delayed code");

part, or a new instance of the OperationCanceledException that will be thrown when we await the Awaitable.

Void Event Methods

Let’s explore what happens when we call this method from three different variations of Unity’s Start method:

async void Start()
async Awaitable Start()
void Start()

async void Start()

async void Start()
{
    await DelayedCodeAsync(aBool);        
    Debug.Log("end of Start");
}

In this version, the result of `DelayedCodeAsync is unwrapped when it’s available.

  • If aBool is true: the console prints Initial code, then the stored exception is thrown.

  • If aBool is false: the console prints Initial code, waits one second, prints Delayed code, and finally prints end of Start, as our code would wait for the asynchronous operation to complete before continuing with the code after the await.

async Awaitable Start()

async Awaitable Start()
{
    await DelayedCodeAsync(aBool);
    Debug.Log("end of Start");
}

This code will also “unwrap” the result of the DelayedCodeAsync method, when this is available, but because the return type is also an Awaitable, this result won’t execute as before, but will be stored in the provided Awaitable, the same way it was stored for our DelayedCodeAsync method.

  • If the aBool is true, it will print in the console Initial code and then IT WILL NOT throw the exception, as it will be stored inside the Awaitable.

  • If the aBool is false, it will print in the console Initial code, after one second will print Delayed code and finally will print the end of Start, as our code would wait for the asynchronous operation to complete before continuing with the code after the await, the same as before.

void Start()

void Start()
{
    DelayedCodeAsync(aBool);
    Debug.Log("end of Start");
}

Since the method is not async, we can’t use await. This means the result of DelayedCodeAsync is never unwrapped.

  • If the aBool is true: the console prints Initial code, the exception will stop the rest of the execution of the DelayedCodeAsync method, IT WILL NOT be thrown as in the second case, BUT end of Start is still printed, as our Start method won’t have to wait for the result of the operation to complete. It is not using the await, so the method will execute synchronously.

  • This can be understood better, if the aBool is false: the console prints Initial code, then end of Start and after one second Delayed code.

Differentiating Asynchronous from Non-Asynchronous Results

From the previous discussion, we can see that the third option (void Start()) may sometimes be desirable in Unity. In many cases, we don’t want to delay the execution of event methods like Update, instead, we only need asynchronous behavior in the methods they call. Keeping the event method synchronous allows it to run efficiently every frame without interruption

However, this approach introduces a challenge. As we saw earlier, if the Awaitable is never unwrapped, any exception that occurs during the asynchronous operation won’t be thrown. Instead, it’s stored inside the Awaitable. Fortunately, we can address this by slightly refactoring our code.

Remember: any exception thrown inside an async method that returns an Awaitable is treated as part of the result, it gets “captured” and stored in the Awaitable. But if we separate the exception-throwing logic from the asynchronous operation, we can force the exception to be thrown synchronously, while still returning the asynchronous portion of the code as an Awaitable.

Here’s how we can refactor our DelayedCodeAsync method.

First, we define a new asynchronous method that only handles the delay and final Debug.Log, this will be the part returned as the Awaitable:

private async Awaitable AsyncDelay()
{
    await Awaitable.WaitForSecondsAsync(1); 
    Debug.Log("Delayed code");
}

Next, we create a synchronous method that performs the initial logic and potentially throws an exception before returning the Awaitable from AsyncDelay:

private Awaitable DelayedCode(bool shouldThrow)
{
    Debug.Log("Initial code")

    if (shouldThrow)
        throw new OperationCanceledException()

    return AsyncDelay();
}

With this refactoring, the behavior remains the same for the first two Start method variations:

async void Start()
async Awaitable Start()

In both cases, the await keyword ensures that the asynchronous code is awaited, so only Initial code is logged before the exception is handled. If shouldThrow is true, the exception is thrown in the first case (async void Start()), but merely stored in the second (async Awaitable Start()), just like before. In both cases, if an exception occurs, the end of Start message will not be printed.

However, things change in the third case:

void Start()
{
    DelayedCode(aBool);
    Debug.Log("end of Start");
}

Now, if aBool is false, the output remains the same as before: Initial code, end of Start, and then, after a second, Delayed code. But if aBool is true, the exception will be thrown immediately. Since it occurs outside of the asynchronous portion, it’s not stored in the Awaitable, it’s thrown directly as part of the synchronous execution.

Conclusion

These are the possible combinations we encounter when dealing with Unity’s event methods and asynchronous code. When we don’t want these methods themselves to run asynchronously, meaning we don’t want them to wait for operations to complete, we can refactor our code as shown to ensure that exceptions are thrown synchronously.

The key takeaway is this: exceptions in asynchronous methods are considered results and are stored inside the Awaitable. They will only be thrown if and when the result is explicitly awaited. If we want exceptions to be thrown immediately, we must ensure they occur outside of the asynchronous context—before returning the Awaitable.

As a side note, although I named the method DelayedCode, a more appropriate name would be DelayedCodeAsync, since it still contains asynchronous execution via the AsyncDelay method and returns an Awaitable that can be awaited.

Thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: