async and uncaught Exceptions

Hello everyone,

In one of the projects I’m working on, we’re using async and await in order to execute code asynchronously. (One advantage over Coroutines is the ability to return values.)
If there occur any exceptions that aren’t caught and logged manually, they are dropped silently without any logging done by Unity.

As a simple example, the following code won’t cause an exception:

public class AsyncException : MonoBehaviour
{
    async Task Start()
    {
        await this.ExceptionalTask();
        Debug.LogWarningFormat("asddjlkahsldk");
    }

    async Task ExceptionalTask()
    {
        await Task.Delay(2);
        Debug.LogError("Throw dad error!");
        throw new System.NullReferenceException("asdljsldjkfh");
    }
}

Whereas the following one will log an exception, since I’m explicitly telling it to log an exception:

public class AsyncException : MonoBehaviour
{
    async Task Start()
    {
        try {
            await this.ExceptionalTask();
            Debug.LogWarningFormat("asddjlkahsldk");
        }
        catch(System.Exception exception)
        {
            Debug.LogException(exception);
        }
    }

    async Task ExceptionalTask()
    {
        await Task.Delay(2);
        Debug.LogError("Throw dad error!");
        throw new System.NullReferenceException("asdljsldjkfh");
    }
}

(Just add the component to a GameObject in the Scene and enter the play mode.)

Is there some setting to enable automatic logging of uncaught exceptions? Is this an oversight in the async handling in Unity? (Can I expect Unity to add logging of uncaught Exceptions?)

5 Likes

That’s not a bug.

Start and other messages are not awaited by the engine, but instead they’re just called like normal methods. (Well ok, the exception to that are some messages that are allowed to be declared having an IEnumerator as return value, which let’s Unity know you want to execute them as a coroutine directly)

Back to the Tasks… So since Start is called “synchronously”, the exception would bubble up once more, but at that point, there’s no more an async context that allows you to handle exceptions from awaitables in any way. It just disappears into the nirvana.

Instead, treat Start as some sort of entry point / root level at which you can start to kick off new async functionality and declare it as async void Start().

I’m seeing the same behaviour with uncaught exceptions when using the signature:

async Task Update()

I’m not sure I follow Suddoha’s explanation above. Why would the signature that returns a Task not handle exceptions? As a consumer of the engine, I found it much more intuitive for the signature that returns a Task to be able to handle exceptions.

async Task Update() // doesn't handle exception
async void Update() // handles exception

It would be nice if at the very least there’s some minimum documentation on the expected behaviour of the two different signatures (or anything at all about how async/await is handled by Unity).

4 Likes

I recently upgraded to 2021.2. While I was in 2020.2, I had no problem with await and exceptions. However, I noticed they are all swallowed silently now. This is making debugging a lot slower.

I think this is a bug.

3 Likes

I could reproduce this issue just now, using Unity version 2020.3.33f1.

        public async void Start() {
            Debug.Log("Test");
            Task.Run(() => {
                throw new System.NotImplementedException("test");
            });

            await Task.Run(() => {
                throw new System.NotImplementedException("test 2");
            });
}

The code above logs “Test”, then the exception “test 2”.

        public async Task Start() {
            Debug.Log("Test");
            Task.Run(() => {
                throw new System.NotImplementedException("test");
            });

            await Task.Run(() => {
                throw new System.NotImplementedException("test 2");
            });
}

The code above only logs “Test”.

That’s some weird behavior!

An aside: Does someone know how I could have all exceptions (including the non awaited ones above) be logged to the console?

Update: I tried doing this

using System;
using UnityEngine;

namespace MarcosPereira.Utility {
    public static class UnhandledExceptionHandler {
        [RuntimeInitializeOnLoadMethod]
        private static void OnLoad() {
            AppDomain.CurrentDomain.UnhandledException +=
                (object sender, UnhandledExceptionEventArgs args) =>
                    Debug.LogError((Exception) args.ExceptionObject);
        }
    }
}

but it makes no difference at all.

@marcospgp : My current working theory is as follows:
If an exception is thrown before the first await was hit, the exception is thrown directly before a Task is returned, and it’s handled the same way it would otherwise be handled.
After the first await (or in your example right from the beginning in any method executed using Task.Run([...])), if the method returns a Task, the exception is attached to the Task to be evaluated later on once the Task gets awaited, and otherwise it can’t be attached and thus not triggered by an await and Unity knows that it has to handle it.
If an exception is attached to a Task, then it could happen at some point in the future that it will be awaited, but it might not have happened, since multiple Tasks are waited for at the same time, including the one with the exception. Just handling this exception automatically by Unity could cause it to be handled later on again, leading to duplicated log messages. Or it might already be handled by the code, and then there’s an error log that must be ignored, which is not a good approach.

If this is the case, there might not be a good approach for the environment to deal with this. except for the garbage collection. If a Task with an exception is collected, and has an exception attached to it, and never got awaited, then it would be safe to log an error message. This would still have the disadvantage that the logging happens delayed to the actual occurrence of the exception, but it would be better than the current approach. This would require though that the differentiation between awaited (i. e. already handled) or not is possible at all.

1 Like

I first learned about async await in the context of javascript promises, where uncaught exceptions are never swallowed into oblivion. I don’t think there’s ever any scenario where implicitly ignoring an exception makes sense, so I’m surprised a simple missed await can cause that behavior.

To add to the confusion, it seems Unity has its own custom SynchronizationContext (still not fully clear on what those are) that causes tasks to run on the main thread (so basically async await becomes a non-hacky coroutine?)

It’s not clear to me whether Task.Run() forces async code to run on a separate thread, so I have to do some more research which eats into precious dev time.

Async await is so elegant, why did Unity have to go with the hacky C# jobs follow up to the hacky coroutine (hijacking IEnumerators for pseudo parallel programming)? :frowning:

This should be expected behaviour as you’re not trying to catch any exceptions there. You should also know that tasks can eat exceptions if they’re not awaited. This is just how async code behaves in C#. Exceptions are passed to awaited tasks so higher level call sites can handle them. If you’re writing async code and don’t understand how exceptions work then it’s worth spending some time learning.

Although, for game code, you probably want to avoid exceptions and use a light weight error result instead. Only use exceptions for truly fatal errors that aren’t expected (you don’t have code to recover form gracefully) and thus should just exit the program. For expected errors (API timeout etc, purchase fail etc) you really don’t want the performance overhead of triggering a full stack trace which is what every exception till trigger when being created. Exceptions are for “crap that should never have happened I want the engineers to know all the possible debug and state into in the world right so we can fix this” kind of fatal errors.

Why you’d write start/update to return a task is a mystery though - the Unity engine isn’t going to await them, they should always be async void as they will be fire/forget. You may have read on the internet that this is “bad” and are thus upset Unity are doing this, if that’s the case, I recommend reading more in depth about what the compiler actually does with async and await. There’s some good articles around that break it down and show the underlying IL code that’s generated and give good explanations as to what’s going on.

You should know what a synchronisation context is if you’re using async/await. 15 minutes of reading will save you many more hours in debugging code you wrote when not thinking about how it might be important to synchronise their 3rd async API service request back to the main thread. Makes sense to me that Unity might one comes ones, it’s a multi threaded engine after all. You also don’t seem to understand what coroutines do under the hood either. It’s not complex but worth knowing.

Coroutines were a well established pattern when unity implemented them way back in Unity 3.0 on early versions of the mono scripting engine, way before async in .net. You could even write your own coroutine systems using IEnumerator and yield - when you understand them well you’ll see they’re really more of a state machine than anything (same with async/await).

Jobs solves a completely different problem to C# asyc. Jobs is about achieving maximum performance though data-oriented programming. It’s not your daddy’s async and not something a managed language like C# is even capable of. Jobs are all compiled to native code to make the data being processed hyper cache friendly/optimised so you really hone in on cutting out any wasted CPU cycles. Tech like that is how they can get a PS4 to turn out visuals like Spiderman, in fact the technical leads from Insomniac who worked on Spiderman (Mike Acton and Andreas Frederiksson) joined Unity to work on Jobs and DOTs. Unity seemingly dropped the ball on getting them in sync with the rest of the engine team but I still wouldn’t trash it, it’s movement in a direction Unity didn’t even have on the roadmap before they joined.

In the future try doing a little reading on the systems you’re having trouble understanding - knowing the history and why of things will make you a much better engineer and probably save you a lot of dev time too.

“This is just how it is” isn’t a great explanation, and doesn’t refute how implicitly swallowing exceptions is not good or even expected behavior.

How many exceptions are you planning to handle per frame? And why would you sacrifice logging in exchange for (an unclear gain in) performance in the editor?

Exceptions can be caught and handled if they are expected to occur, too.

It’s to test if there’s a difference in exception throwing behavior. And the result is unexpected, since returning a Task causes exceptions to be swallowed - when one expects the void returning methods to swallow exceptions instead.

I have done a lot more than 15 minutes of reading, but still don’t know whether code inside a Task.Run() runs under Unity’s SynchronizationContext (and is thus single threaded) or if it is handled by C#'s default context. Perhaps you could help me?

I understand that it’s an old concept, but using enumerators - which define how a collection should be traversed - as a (single threaded!) parallel programming paradigm is not at all elegant. It makes no sense to ignore async/await and keep overloading enumerators with that functionality.

No they don’t. They’re about writing multithreaded code, except in a much more restrictive way - which comes as a result of a bigger focus on optimization, I understand. But having to define a struct for each multithreaded task is one scary small step behind recreating UnityScript.

Thank you for the kind advice!

The explication is sometimes in the Microsoft documentation, sometimes in the blogs of the engineers who work on the language features. I agree the details of exception handling with async are not great, Microsoft maybe could have done a better job but at the same time, the people creating these language features tend to be a lot smarter than me and are often away of 100 things I never even though of as being a problem untill I dig into one of their blog posts and normally come away with a vague “ok so this being a bit weird means I don’t need to care about those 50 other things when writing async code, cool, I can live with that”.

The gain is explicitly clear. Any exception is going to hit the GC and cause dropped frames in a mobile game or on Quest. Using exceptions for expected errors is actually exception flow control and considered an anti-pattern. This is why so many frameworks use an error object in the API response instead of throwing an exception. Also, when you work on projects with 20+ engineers you can end up with a lot of systems throwing exceptions all over for results that are expected and don’t need expensive stack traces. I think it’s just a good habit that’ll serve most people well if they want a long career in game engineering. Most of the time you shouldn’t care much about performance until you profile but exceptions should maybe be the exception (pardon the pun) when it comes to games or rolling our an API services. A single exception in the wrong place in a service layer on a server could cost a lot of real money when you scale up to billions of requests a day. Likewise, a single exception in your quest game could see your game’s rating drop from Comfortable (consistent 90fps without hitches) to Moderate.

Not sure what that means but generally agree exception handling with tasks is crappy. I’m not sure Unity can really do anything about that though and even if they could, they probably shouldn’t diverge from the C# spec.

Starting a new task queues that task for execution on a threadpool thread. Threads execute in the context of the application - so Unity has a main application thread and starting a task from it creates a context for which threads in that Unity instance will run. The main unity thread will continue on with work while your task runs on a thread in the context of your application. The synchronisation context can be used to tell the task which thread to synchronise with when it’s work is done (when control returns to the call site) - be it the main thread or some other context.

As in, what thread should pick up control and continue executing the code directly after the awaited call once the task is completed (or faulted). Sometimes you want the main thread, sometimes you want the calling context (possibly a thread pool), sometimes you may want something else (jobs thread pool? dedicated rendering or physics thread?).

Enumerators are a language feature, the concept of yielding execution is the core of coroutines, ignore iteration and collections. Yield suspects execution till a later time, a feature present in many different languages stretching back to the 1960s. Yield is powerful when well understood. Coroutines provide concurrency but not parallelism. Parallelism was ~never~ their intent and they existed in Unity way before async support was possible in the scripting engine. I’m honestly not sure why you’re angry about them, if Unity removed them millions of games would no longer compile. If you don’t like Coroutines don’t use them but it seems to me you don’t really understand them well enough to decide if they would or would not be a good fit for a particular use case.

Sure, some Unity APIs use coroutines when they probably should use Tasks, but that tends to be service level stuff that I always want a layer of my own code in front of anyway and then you can just wrap the coroutine with your prefer flavor of handling async flow control.

There are also other concepts for handling concurrency with and without parallelism. I’ve a well tested C# promise library (futures pattern / monads) that helps with asynchronous flow control via a nice fluid API. It’s entirely single threaded and that’s fine. Tasks are also essentially promises/futures that can also be multithreaded but having a task that results in different flows based on the task result produces kinda messy code so I still use that single threaded promise library a lot because not all code actually needs to be multi threaded - Monads are a perfectly fine solution worth learning about.

Not sure I understand how you see Data orientated design/programming as the same as c# async. They’re different ideas with different goals. I think it’s ok for both ideas to coexist - not all games are alike and have the same performance requirements or work on the same kinds of data. Maybe we just agree to disagree here.

2 Likes

Does someone know whether TaskScheduler.UnobservedTaskException is related to this issue and could be used in order to ensure no unawaited exceptions are swallowed?

I built a wrapper around Task.Run() that

  • Ignores the result of tasks if play mode is exited while they are running;
  • Forces exceptions to be logged to the console, even if the task is not awaited.

I wrote a small blog post and shared the code here.

Note that ignoring the result is enough to avoid most issues of allowing tasks to keep running outside play mode, as Unity APIs are only accessible after the await, when execution returns to the main thread (due to Unity’s custom SynchronizationContext).

Looking back at Unity’s SynchronizationContext, maybe the reason these unawaited tasks have their exceptions swallowed is this try with no catch.

New observation: I got

TaskScheduler.UnobservedTaskException +=
                (_, e) => UnityEngine.Debug.LogException(e.Exception);

to work, but it seems like the handler only fires after scripts are reloaded in the editor. So I won’t see an issue, but then if I change a script I will see the previously thrown exception in the console.

I tried calling System.GC.Collect(); to see if it would fire right away, but it doesn’t change anything.

Update: If I call

await Task.Delay(1000);

System.GC.Collect();

the exception is indeed logged without having to reload scripts. So forcing garbage collection does cause unobserved tasks to be handled!

So I updated my SafeTask wrapper accordingly: Cancel async tasks in Unity upon exiting play mode · GitHub

3 Likes

This seems to be the root of the problem. I’m trying to understand why is that but I couldn’t find any information.
If someone has any relevant information (article, post, book ref, etc), please share.

A few useful resources:
https://devblogs.microsoft.com/pfxteam/task-exception-handling-in-net-4-5/?WT.mc_id=DT-MVP-5003978
https://www.meziantou.net/fire-and-forget-a-task-in-dotnet.htm

https://www.youtube.com/watch?v=ZFWxSQ-KjUc

If an exception is thrown inside a task, it’s scoped to that task. Thus, the Main Unity Thread (which started that task) will have no idea about it.

For anyone struggling with tasks, I’m throwing in my AsyncRoot utility that I’m using everywhere to start new tasks from non-async contexts, and safely log their exceptions.
Uses UniTask for most of its functionalities. Been using if for over 1 year with success, true life saver.

Updated code:
Updated Code

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using Cysharp.Threading.Tasks;
using LibCore.Sys.Logging;
namespace LibCore.Threading
{
    /// <summary>
    /// Helper to wrap execution of UniTasks from a sync method while handling any exceptions. 
    /// The pattern "DoAsync().Forget()" also works, but centralizing these calls might prove useful in the future
    /// TODO run all tasks as bound, but those that don't explicitly request that will be bound to a unique, persistent, hidden-in-hierarchy gameobject, so as to minimize dangling tasks from leaking from playmode
    /// </summary>
    public static class AsyncRoot
    {
        static AsyncRoot()
        {
            TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
        }
        public static void RunForget(Func<UniTask> taskFactory) => _ = Run(taskFactory);
        public static Task Run(Func<UniTask> taskFactory)
        {
            var task = taskFactory().AsTask();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
            return task;
        }
        public static Task<T> Run<T>(Func<UniTask<T>> taskFactory)
        {
            var task = taskFactory().AsTask();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
            return task;
        }
        /// <summary>
        /// For running bound to an object use GetCancellationTokenOnDestroy() extension at task creation and then the standard Task.Run(.., ..)
        /// </summary>
        /// <param name="taskFactory"></param>
        public static Task<T> Run<T>(Func<Task<T>> taskFactory)
        {
            var task = taskFactory();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
            return task;
        }
        public static Task Run(Func<Task> taskFactory)
        {
            var task = taskFactory();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
            return task;
        }
        public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
        }
        public static void RunBound(Component boundTo, Func<UniTask> taskFactory)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
        }
        // Not tested
        public static Task<(bool isCancelled, T result)> RunBound<T>(Component boundTo, Func<UniTask<T>> taskFactory)
        {
            return RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
        }
        /// <summary>
        /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
        /// </summary>
        public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
        }
        /// <summary>
        /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
        /// </summary>
        public static void RunBound(Component boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
        }
        static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith)
        {
            RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
        }
        // Not tested
        static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith)
        {
            return RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
        }
        static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory)
        {
            RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
        }
        // Not tested
        static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory)
        {
            return RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
        }
        static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
        {
            var task = taskFactory()
                .AttachExternalCancellation(cancellationToken)
                .SuppressCancellationThrow()
                .AsTask();
            task.ContinueWith(continueWith, continuationOptions);
        }
        // Not tested
        static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
        {
            var task = taskFactory()
                .AttachExternalCancellation(cancellationToken)
                .SuppressCancellationThrow()
                .AsTask();
            task.ContinueWith(continueWith, continuationOptions);
            return task;
        }
        /// <summary>
        /// IMPORTANT: Using LogException because throwing doesn't work on non-main thread (might try calling a dispatcher, but letting it as it is for now)
        /// </summary>
        /// <param name="task"></param>
        static void LogException(Task task)
        {
            Debug.LogException(task.Exception);
        }
        static void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
        {
            // This is called on GC, if the tasks didn't have a ContinueWith for Failure, nor had anyone handling their exceptions.
            // This shouldn't normally happen if all tasks are started from this class, but it's good to log them
            L.Deb(e.Exception);
        }
    }
}

Original code (ignore it, use the above pls):
Code

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using Cysharp.Threading.Tasks;

namespace LibCore.Threading
{
    /// <summary>
    /// Helper to wrap execution of UniTasks from a sync method while handling any exceptions.
    /// The pattern "DoAsync().Forget()" also works, but centralizing these calls might prove useful in the future
    /// TODO run all tasks as bound, but those that don't explicitly request that will be bound to a unique, persistent, hidden-in-hierarchy gameobject, so as to minimize dangling tasks from leaking from playmode
    /// </summary>
    public static class AsyncRoot
    {
        public static void RunForget(Func<UniTask> taskFactory) => _ = Run(taskFactory);

        public static Task Run(Func<UniTask> taskFactory)
        {
            var task = taskFactory().AsTask();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);

            return task;
        }

        public static Task<T> Run<T>(Func<UniTask<T>> taskFactory)
        {
            var task = taskFactory().AsTask();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);

            return task;
        }

        /// <summary>
        /// For running bound to an object use GetCancellationTokenOnDestroy() extension at task creation and then the standard Task.Run(.., ..)
        /// </summary>
        /// <param name="taskFactory"></param>
        public static Task<T> Run<T>(Func<Task<T>> taskFactory)
        {
            var task = taskFactory();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);

            return task;
        }
        public static Task Run(Func<Task> taskFactory)
        {
            var task = taskFactory();
            task.ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);

            return task;
        }

        public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
        }

        public static void RunBound(Component boundTo, Func<UniTask> taskFactory)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
        }

        // Not tested
        public static Task<(bool isCancelled, T result)> RunBound<T>(Component boundTo, Func<UniTask<T>> taskFactory)
        {
            return RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory);
        }

        /// <summary>
        /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
        /// </summary>
        public static void RunBound(GameObject boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
        }

        /// <summary>
        /// Note you need to check for <see cref="continueWith.IsFaulted"/> before assuming success
        /// </summary>
        public static void RunBound(Component boundTo, Func<UniTask> taskFactory, Action<Task> continueWith)
        {
            RunInternal(boundTo.GetCancellationTokenOnDestroy(), taskFactory, continueWith);
        }

        static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith)
        {
            RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
        }

        // Not tested
        static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith)
        {
            return RunInternal(cancellationToken, taskFactory, continueWith, TaskContinuationOptions.None);
        }

        static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory)
        {
            RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
        }

        // Not tested
        static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory)
        {
            return RunInternal(cancellationToken, taskFactory, LogException, TaskContinuationOptions.OnlyOnFaulted);
        }

        static void RunInternal(CancellationToken cancellationToken, Func<UniTask> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
        {
            var task = taskFactory()
                .AttachExternalCancellation(cancellationToken)
                .SuppressCancellationThrow()
                .AsTask();
            task.ContinueWith(continueWith, continuationOptions);
        }

        // Not tested
        static Task<(bool isCancelled, T result)> RunInternal<T>(CancellationToken cancellationToken, Func<UniTask<T>> taskFactory, Action<Task> continueWith, TaskContinuationOptions continuationOptions)
        {
            var task = taskFactory()
                .AttachExternalCancellation(cancellationToken)
                .SuppressCancellationThrow()
                .AsTask();

             task.ContinueWith(continueWith, continuationOptions);

            return task;
        }

        /// <summary>
        /// IMPORTANT: Using LogException because throwing doesn't work on non-main thread (might try calling a dispatcher, but letting it as it is for now)
        /// </summary>
        /// <param name="task"></param>
        static void LogException(Task task)
        {
            Debug.LogException(task.Exception);
        }
    }
}

I was watching the Video you linked, and I guess it’s not fully applicable to a Unity project. In general, there are 3 cases that were mentioned in the video:

  • properly used async and await - Exceptions behave as expected (also in Unity)
  • Unhandled Exceptions with async void - in Unity, these are just getting logged similar to regular exceptions that you’re not catching, and this is actually sometimes more desirable than async Task
  • Unhandled Exceptions with async Task (i. e. no await and other state checking of the Task) - any exception will just be “attached” to the Task just as shown in the video, but without any indication that something went wrong (the exception might get logged once the Task gets garbage collected)

So in summary, point 1 is no problem either way, point 2 doesn’t apply to Unity because it doesn’t have the same impact, and point 3 should be more important than described in the video, since you won’t notice that something is wrong, and you might only figure it out if you detect that some things in the game are just not happening as expected (without exceptions being logged, of course).

Regarding async void: in my opinion it’s better to get exceptions logged in Unity than not getting them logged, and Unity messages (Awake, Start, …) can have Task as return value, but the resulting Task will be ignored by Unity (i. e. no logging). Maybe you should deal with starting asynchronous code from Awake and Start in a slightly different manner (i. e. only call an async method, store the Task and check the state of the task later), but for simple or event some “quick and dirty” code, async void would be the better option here. (Note: I’m really only talking about the special case of Unity messages. Other methods should avoid async void since the caller will not be able to deal with any kinds of errors.)

You are right. I didn’t mean to say it was 100% applicable to Unity, I just find them interesting resources to understand the context of the topic.

This actually solved my problem and I can now see the previously swallowed exceptions from unawaited async functions.

Is there any situation where this would fail? I don’t understand the need to use other, more complex code, instead of just calling the above at the start of the app?

1 Like