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 typeFoo
), 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
istrue
: the console printsInitial code
, then the stored exception is thrown.If
aBool
isfalse
: the console printsInitial code
, waits one second, printsDelayed code
, and finally printsend of Start
, as our code would wait for the asynchronous operation to complete before continuing with the code after theawait
.
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
istrue
, it will print in the consoleInitial code
and then IT WILL NOT throw the exception, as it will be stored inside theAwaitable
.If the
aBool
isfalse
, it will print in the consoleInitial code
, after one second will printDelayed code
and finally will print theend of Start
, as our code would wait for the asynchronous operation to complete before continuing with the code after theawait
, 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
istrue
: the console printsInitial code
, the exception will stop the rest of the execution of theDelayedCodeAsync
method, IT WILL NOT be thrown as in the second case, BUTend of Start
is still printed, as ourStart
method won’t have to wait for the result of the operation to complete. It is not using theawait
, so the method will execute synchronously.This can be understood better, if the
aBool
isfalse
: the console printsInitial code
, thenend of Start
and after one secondDelayed 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.