On Async/Await with Awaitables as a Coroutine replacement

There has been a lot of people in the community here that’s been talking about how Awaitables will be able to replace Coroutines as a standard way of doing operations over time. I want to discuss this, because I’m a bit confused on if you all are seeing something I’m not.

My experience with async/await in Unity is that I tried it a bunch back in 2018, before Awaitables, and had to back out hard because of two major issues - I found that cancelling an async function was a pain, and that the potential of async functions continuing to run after exiting play mode was too much of a downside.

After that, I have used async/await a bunch with UniTask when I interact with async C# APIs - like when I used the great ImageLoader for a tool we built that needed to grab data from the web on background threads. That worked great, and the ability to return a value from the async function helped a lot with the code flow! So for the whole “pass data from a job on a different thread when it’s done” workflow, async/await is fantastic.

That all being said,now that Unity 6 has some new support for async/await, I have now tried to use Awaitables as a coroutine replacement here and there for testing, and I am very much not impressed! I have run into some repeated problems, and I kinda want to know if there are workaround that I’m not aware of, or if everyone here that’s async/await-for-coroutine-work fans don’t think there are big downsides.

When I’m talking about async/await as coroutine replacements, I’m specifically talking about running a task on the main thread over time as a part of gameplay - simple stuff like moving an object from A to B as a one-off thing without having a bunch of fields that you juggle in an Update loop.

So a short list of the things that I ahve run into that I find makes a big difference between async/await with Awaitable and Coroutines are:

  • Where are my exceptions?

What happens here if I don’t have an Animator on the same GameObject?

public class TestScript : MonoBehaviour
{
    private async Awaitable Start()
    {
        var animator = GetComponent<Animator>();
        await Awaitable.NextFrameAsync();
        animator.SetTrigger("lol");
    }
}

This happens:

While with a Coroutine:

public class TestScript : MonoBehaviour
{
    private IEnumerator Start()
    {
        var animator = GetComponent<Animator>();
        yield return null;
        animator.SetTrigger("lol");
    }
}

There’s my exception! Coroutines to the rescue.

In general, any exception that’s inside of an Awaitable is just eaten. You end up having to wrap every single piece of code you write in a try/catch if you want to know about errors. This makes async/await code a lot harder to debug, or clunkier to write, depending on if you want to take on the try/catch overhead.

Funnily, you do get the exception if you do an async void function instead of an async Awaitable, but then the exceptions from correct use of cancellationTokens also bubbles up, which is a dissaster, so you end up either having to ignore a bunch of exceptions, or (again) wrap everything in a try-catch setup

  • The exceptions are the worst as well

Reading a stack trace from an async function is just bad. This is a C# thing, the team at Microsoft just really forgot about error messages when they built this:

public class TestScript : MonoBehaviour
{
    private void Start() => Foo();
    private async void Foo() => throw new Exception();
}

Where’s Start? Nowhere to be found. And this is still easy, if you have worked in heavily nested async codebases, you have seen some pretty horrendous stack traces with inexplicable inner error lines all over your actual lines of code.

You do have the same problem with Coroutines, but with a lot less lines of just garbage, and it actually manages to give you the correct stact trace if you have an error before a yield, which helps in a lot of cases. And, again, you don’t have to worry about just dropping errors on the floor.

  • Cancelling is pretty bad

Awaitable has a Cancel function. It’s a footgun unlike anything you have ever seen;

public class TestScript : MonoBehaviour
{
    private bool running = false;
    private Awaitable counterAwaitable;

    private void Update()
    {
        if (Keyboard.current.spaceKey.wasPressedThisFrame)
        {
            if (!running)
                counterAwaitable = Counter();
            else
                counterAwaitable.Cancel();
            running = !running;
        }
    }

    private async Awaitable Counter()
    {
        var value = 0;
        while (true)
        {
            Debug.Log(value++);
            await Awaitable.WaitForSecondsAsync(1f);
        }
    }
}

The Cancel function there doesn’t do anything! That’s because we’re not waiting for the awaitable, it’s just plodding happily along on it’s own, and Cancel doesn’t actually stop it, it raises an exception on things that are waiting for it. I guess? I can’t quite tell what it’s supposed to do, but it seems to only be usefull if you nest stuff?

Anyway, the Cancel is a red herring, and that makes me sad, because the async/await way of cancelling things is CancellationTokens and ugh;

public class TestScript : MonoBehaviour
{
    private bool running = false;
    private CancellationTokenSource stopCounterSource;

    private void Update()
    {
        if (Keyboard.current.spaceKey.wasPressedThisFrame)
        {
            if (!running)
            {
                stopCounterSource = new CancellationTokenSource();
                _ = Counter(stopCounterSource.Token);
            }
            else
                stopCounterSource.Cancel();
            running = !running;
        }
    }

    private async Awaitable Counter(CancellationToken token)
    {
        var value = 0;
        while (true)
        {
            Debug.Log(value++);
            await Awaitable.WaitForSecondsAsync(1f, token);
        }
    }
}

That works. You just have to remember to inject the CancellationToken into every. single. await. call. That makes the code a lot more annoying, and it’s a lot easier to introduce bugs - just forget a single pass of the token.

On the other hand, the tokens might make cleanup a bit easier - if you need to do a cleanup if something was cancelled during an Await, you can do a little:

if (token.IsCancellationRequested)
    CleanupLocalStuff();

Although in that case you have to remember to not pass the token into something like a WaitForSecondsAsync, because that’ll throw and your cleanup won’t be reached.
So that’s hard, and has a bunch of rules, and to be honest coroutines that need cleanup if stopped in the middle are very, very rare, and it’s not very hard to ad-hoc the few cases where that shows up.
So, in my opinion you’re giving up the super simple way of cancelling a coroutine just in case you need something you rarely need and could already solve.

Oh, and everybody probably knows how to cancel a coroutine, but for completeness, the above code is this with coroutines:

public class TestScript : MonoBehaviour
{
    private Coroutine counterRoutine;

    private void Update()
    {
        if (Keyboard.current.spaceKey.wasPressedThisFrame)
        {
            if (counterRoutine == null)
            {
                counterRoutine = StartCoroutine(Counter());
            }
            else
            {
                StopCoroutine(counterRoutine);
                counterRoutine = null;
            }
        }
    }

    private IEnumerator Counter()
    {
        var value = 0;
        while (true)
        {
            Debug.Log(value++);
            yield return new WaitForSeconds(1f);
        }
    }
}
  • Starting the things just feels icky?

In order to start a coroutine, you use StartCoroutine. In order to start an async/await function on the main thread, you just, uh, call the async function! And then the synchronizationContext and a bunch of C# black magic makes it wait. This is a bit cool, and makes async/await feel a bit like good old (\s) UnityScript where you could just say yield WaitForSeconds(1f) in the middle of a function and now everybody that called that function started doing StartCoroutine under the hood.

The problem is that if you just call the function, you get a compiler warning:

if (Keyboard.current.spaceKey.wasPressedThisFrame)
    Counter();

image
And, like, yeah, that’s what you want when you start a coroutine. Fire it off, forget it, and keep doing other stuff on the main thread. But that’s a warning case here because coroutine work isn’t what async/await was made for, so you have to write just weird code to shut up the compiler:

if (Keyboard.current.spaceKey.wasPressedThisFrame)
    _ = Counter();

Now you might be saying “but you’re supposed to await the Awaitable returned from the function”, but there has to be an entry-point from non-async code somewhere. This isn’t a .NET web app where main is async and everything flows from there. This isn’t a dealbreaker or even that bad, but it is annoying for sure.

  • Tying things to object lifetime is not default, and clunky

A coroutine is tied to the lifetime of some MonoBehaviour. This is very often something you want, and if you don’t want it, any third rate Unity programmer is able to make a singleton that allows you to start a coroutine from wherever:

StaticCoroutine.Start(MyRoutine()); // This is not hard to make

an Awaitable, on the other hand, will by default not have it’s lifetime tied to a MonoBehaviour, and you instead have to include the destroyCancellationToken if you want it to stop when you destroy the behaviour or load a scene or whatever. Which isn’t that bad, but now normal cancellation requires you to create these icky linked cancellation tokens, which is just an incredible amount of extra boilerplate to do basic stuff.

So we have taken a good default (lifetime tied to objects) and replaced it with a bad default (run forever lol), and the non-standard case has gone from “a solution you write once” to “all your code now has more boilerplate”.

  • Passing data back wasn’t that hard in the first place, but I guess this is a bit cool

One of the upsides for async/await is that it’s very easy to pass back data to the waiter. Just have a Task or in our case, an Awaitable!
For the Coroutines/Awaitable situation, that problem is only relevant when you nest them. In those cases, there’s a noticeable difference;

public class AwaitableTestScript : MonoBehaviour
{
    private async Awaitable Start()
    {
        int value = await FindValue();
        Debug.Log(value);
    }

    private async Awaitable<int> FindValue()
    {
        await Awaitable.WaitForSecondsAsync(1f);
        return Random.Range(0, 5);
    }
}

public class CoroutineTestScript : MonoBehaviour
{
    private IEnumerator Start()
    {
        int value = 0;
        yield return FindValue(val => value = val);
        Debug.Log(value);
    }

    IEnumerator FindValue(Action<int> ReturnValue)
    {
        yield return new WaitForSeconds(1f);
        ReturnValue(Random.Range(0, 5));
    }
}

You get to skip a lambda, so there’s for sure less boilerplate. I don’t know if this is enough to cover for all of the other downsides - I’d much rather have to write one extra line in the very rare case that I want to return a value from a nested coroutine than losing exceptions on every single coroutine I make. But I’ll not say that this is not nice, it is.

  • Finally, all that being said jumping to a different thread is pretty cool

There’s a thing you can do in Awaitables that you can’t at all do in Coroutines, which is to jump to a different thread with just a single function call;

    private void Start()
    {
        _ = DoSomeOffThreadWork();
    }

    private async Awaitable DoSomeOffThreadWork()
    {
        Debug.Log("Start on frame " + Time.frameCount);
        var data = 0f;

        await Awaitable.BackgroundThreadAsync();
        for (int i = 0; i < 1000000; i++)
            data = (data + (Mathf.Sqrt(i) % 973 * Mathf.Sqrt(i) % 639 * Mathf.Sqrt(i) % 17)) % 127;
        await Awaitable.MainThreadAsync();

        Debug.Log("Done After frame " + Time.frameCount);
        transform.position = new Vector3(data, data, data);
    }

That’s pretty awesome to have out of the box - just do stuff on a different thread, jump back, inside of the same method. Pain-free, and easy. There is a way to achieve the same thing with coroutines, kinda, if you use Thread Ninja, but it’s not out of the box support. It does show that Unity could totally add multithreading support to coroutines if they wanted to.

Conclusion, I guess

So my experience in Unity 6 so far is that Awaitables really doesn’t cut it as a coroutine replacement. It doesn’t mean that async/await or Awaitables are bad - they for sure have their place! But for general gameplay code, coroutines seems to have less boilerplate, especially if you want your error messages, and much fewer footguns.

I do think that this can be solved, though, if the engineering team at Unity that deals with this puts some heavy work into making the synchronization context do more sensible things. I’m not quite sure how much power you have to change things around, but making exceptions from Awaitable calls not be swallowed unless they’re from CancellationTokens would be a big improvement. If it’s possible to have Awaitable.Cancel also cancel the Awaitable instead of doing, idk something else, then you’re at parity with Coroutines, in which case getting rid of them in order to reduce code base size might be something to look into.

But right now, I’m not seeing it! I find Awaitables harder to work with, with no upsides that makes it worth it.

If you are a user of async/await with Awaitables for gameplay code, and you like it, I’d love to hear how you’re doing things. In particular;

  • How do you start an async thing from a non-async context? Do you just do the _ = discard, or do you live with the warning message, or?
  • How do you deal with missing exceptions? Live with them, try/catch, something else I’m missing?
  • What’s your approach to cancelling?
18 Likes

Haven’t used Awaitable yet, so here are my answers using Task.
A lot of boilerplate (which can be moved to Extension method), but to see exceptions when you start async logic from non-async context, I use this in one place:

ReloadAsync(state).ContinueWith(task => {
    if (!task.IsFaulted) {
        return;
    }
    var exception = task.Exception;
    if (exception == null) {
        return;
    }
    foreach (var innerException in exception.InnerExceptions) {
        Debug.LogException(innerException);
    }
});

Regarding canceling, I used provided destroyCancellationToken in one place and following code:

public async void Save() {
    var asyncSave = AsyncSave();
    try {
        await asyncSave;
    } catch (OperationCanceledException) {
        return;
    }
    return;

    async Task AsyncSave() {
        save.interactable = false;
        await saver.Save(saver.CurrentSave);
        await ShowAndFadeOutSaved();
        save.interactable = true;
    }

    async Task ShowAndFadeOutSaved() {
        saved.gameObject.SetActive(true);
        for (var elapsed = 0f; elapsed < savedSeconds; elapsed += UnityEngine.Time.deltaTime) {
            destroyCancellationToken.ThrowIfCancellationRequested();
            var normalizedTime = elapsed / savedSeconds;
            saved.alpha = Mathf.Lerp(1, 0, savedCurve.Evaluate(normalizedTime));
            await Task.Yield();
        }
        saved.gameObject.SetActive(false);
    }
}

I personally think UnityEngine.Awaitable was poorly designed. I wrote ProtoPromise async library that solves most (not all) of the mentioned issues.

In v0 of the library, I had a direct Promise.Cancel() method like Awaitable.Cancel(). I very quickly found out that it was practically useless (for the same reasons you discovered), and promptly removed it and implemented my own CancelationToken.

Cancelation tokens do add boilerplate, but it really is necessary to get proper async behavior. If you just make it a habit to always add an optional CancelationToken cancelationToken = default optional parameter to the end of every async function, and always pass it through, it’s not that bad. You don’t have to handle the cancelations at every step.

As for Exception logging with Awaitable, I don’t know what Unity got wrong there. It’s not an issue in my library. It should be trivial for Unity to fix that by just adding a finalizer that logs it if it was never awaited. (Task has a similar issue, but it gets sent to the TaskScheduler.UnobservedTaskException event, which you can subscribe to and log yourself.)

Uncaught OperationCanceledException is swallowed by default in my library, just like you want. When I designed it, I figured cancelations are a normal part of program flow, and it doesn’t make sense to log an error for normal behavior. .Net disagrees and forces you to catch those exceptions yourself.

Reading a stack trace from an async function is just bad. This is a C# thing, the team at Microsoft just really forgot about error messages when they built this:
Where’s Start? Nowhere to be found. And this is still easy, if you have worked in heavily nested async codebases, you have seen some pretty horrendous stack traces with inexplicable inner error lines all over your actual lines of code.

This is another case that my library handles. You can configure it to automatically capture causality traces in DEBUG mode (disabled by default due to performance).

Although in that case you have to remember to not pass the token into something like a WaitForSecondsAsync, because that’ll throw and your cleanup won’t be reached.

I think that’s a non-issue. Cleanup should be performed in finally blocks. If you really need to do cleanup only in the cancelation case, you can just do it in a catch (OperationCanceledException) block. Or if you’re using my library, you can do var result = await promise.AwaitNoThrow(); if (result.State == Promise.State.Canceled) { ... }.

The problem is that if you just call the function, you get a compiler warning:

if (Keyboard.current.spaceKey.wasPressedThisFrame)
    Counter();

And, like, yeah, that’s what you want when you start a coroutine. Fire it off, forget it, and keep doing other stuff on the main thread. But that’s a warning case here because coroutine work isn’t what async/await was made for, so you have to write just weird code to shut up the compiler:

if (Keyboard.current.spaceKey.wasPressedThisFrame)
    _ = Counter();

  • How do you start an async thing from a non-async context? Do you just do the _ = discard, or do you live with the warning message, or?

With my library (and also with UniTask), you would just call Counter().Forget(). Discards used to suppress await warnings are certainly a little weird, but I think that’s the standard way to do it since Tasks have their legacy quirks since they existed before async/await. I don’t think Awaitable should follow that same warning suppression strategy, but I’m not sure what Unity was thinking there.

  • Tying things to object lifetime is not default, and clunky

Yeah, this is the nature of async functions being started directly from calling them. A while ago I thought about writing a new custom async type PromiseRoutine as a hybrid between async functions and coroutines to solve that problem, but I never got around to doing it. Also, it’s unclear what behavior to employ when you need to call a nested async function that lives on a different object. I think Coroutines have the same problem, what happens if you yield return otherComponent.StartCoroutine(...)? It seems to just work when both objects are alive, but the behavior is undocumented what happens under the hood when one of the objects is destroyed and the other is not.

Cancelation tokens give you the programmer full control over what behavior should occur.

  • How do you deal with missing exceptions? Live with them, try/catch, something else I’m missing?

I use ProtoPromise which handles them properly.

  • What’s your approach to cancelling?

Get used to the fact that cancelation tokens always need to be passed through.

2 Likes

Automated cancellation handling is definitely coroutine’s killer feature :heart: Manually calling CancellationToken.ThrowIfCancellationRequested after every await feels ridiculously inelegant in comparison.

Awaitable.Cancel is basically worthless in my opinion. It just doesn’t make any sense, given how dangerous it is to hold on to Awaitable references and try to call methods on them after passing them away somewhere else to be awaited :thinking:

Luckily Awaitables do get cancelled automatically when existing play mode, which does solve the main pain-point that async-await used to have in the past. The introduction of the Awaitable API was really a turning point for me, where I started seeing using async-await as a much more viable option in Unity.

Async methods do have some great features compared to coroutines as well:

  1. Return value support! The usefulness of this cannot be overstated.
  2. Usable in any context, not just in MonoBehaviours.
  3. Edit Mode support.
  4. Event-based instead of polling based.

Just imagine writing the equivalent of this using coroutines:

public sealed class LoginHandler
{
	readonly IWebSocket webSocket;
	readonly IWebRequestHandler webRequestHandler;
	
	public LoginHandler(IWebSocket webSocket, IWebRequestHandler webRequestHandler)
	{
		this.webSocket = webSocket;
		this.webRequestHandler = webRequestHandler;
	}
	
	public async Awaitable<User> Login(string username, string password)
	{
		await webSocket.IsConnected;
		return Deserialize<User>(await webRequestHandler.SendRequest("https://api.example.com/login"));
	}
}
1 Like

There’s EditorCoutoutines

2 Likes

You really shouldn’t be doing that. You should pass the token to the async function you’re calling. That only needs to be done if the async function you’re calling doesn’t support cancelation token. If you own the function, you should update it to support cancelation token. If not, then it’s unavoidable, but the author should probably update it.

Completely agree.

3 Likes

That is true, I was exaggerating. That boilerplate is luckily usually only needed on the side of services and utility methods, not every single client.

First of all, thank you for creating this thread. I was also following the discussion on the .NET/CoreCLR thread and I was hoping someone moved the conversation somewhere else.

I’m also new to Awaitable and have many questions, but I’d like to jump in some of your points. Given my lack of experience with it, a lot of what I will say is about Task and UniTask, because that’s what I have experience with. I think it’s still valuable input because all those 3 types rely on the async/await mechanism.

On “Where are my exceptions?”

Why would you make the Start function return an Awaitable here, if it’s not meant to be awaited by another function call? The same question could be applied to Task or UniTask. The return type is used so you can call that method and await on it. My IDE (Rider) even displays a warning on it, suggesting to use void instead. When I do, the exception is logged as expected. Does that have a drawback I’m not aware of?

That’s not true, is it? I just ran the following test:

private async void Start()
{
	Debug.Log("Hello 1");
	await DoThing();
	Debug.Log("Hello 2");
}

private async Awaitable DoThing()
{
	Debug.Log("Doing a thing...");
	await Awaitable.NextFrameAsync();
	throw new Exception("Oops");
}

And the exception is thrown and logged properly. Notice that Start is of type void here.

Regarding the wrapping every piece of code, that’s not necessary at all. The exception will be thrown up on the call stack and if nobody catches it, Unity will, and it will be logged. Your asynchronous/Awaitable method has the choice to either ignore the exceptions (and the includes cancellations, more on that later), or catch it. Just like any other C# code.

Unfortunately, Coroutines and error handling don’t go very well together because yield statements can’t be placed inside try /catch /finally blocks. Consequently, there is no way for a method waiting for a Coroutine to finish executing (using a yield return statement) to get notified about the thrown exception. Async/await (whether it’s with Task, UniTask or Awaitable) doesn’t have that limitation.

That’s a feature. It’s part of C#'s TAP (Task-based Asynchronous Pattern). Like I said above, every caller has the choice to catch the OperationCanceledException or let it propagate upwards (or both, actually). This gives the programmer flexibility on how to handle task cancellation, which Coroutines don’t give.

On “The exceptions are the worst as well”.

I agree. They are not good. UniTask suffers of a similar problem, but at least their logged call stack is usually longer, so you can follow more calls.

On “Cancelling is pretty bad”

Yes, that’s it. The documentation even says so. Once more, that’s no different from Task and UniTask. A similar thing will happen to a Coroutine if you simply call the method, store it on a variable but never call StartCoroutine:

private void Start()
{
	var coroutine = MyCoroutine();
}

private IEnumerator MyCoroutine()
{
	yield return new WaitForSeconds(1);
	throw new Exception("Oops");
}

The “Oops” exception will never be thrown. That’s becaues the code executed synchronously up until the yield, and stopped. You need to “await” (calling StartCoroutine) to actually make it run to completion. The same applies to Awaitable: when you call counterAwaitable = Counter(), the method will run synchronously up until the first await call. If you call Cancel, an exception will be thrown, but only if you await for it. That’s because the method is “paused”, and its execution will only resume when you await it. This last phrase turned out not to be true, as @sisus_co brought up later. But the idea still holds: to get exceptions, you need to await the Awaitable.

Whether that’s annoying is subjective. With that said, I find it annoying too.

But you can’t simply forget to send the token if the API requires one. And requiring a CancellationToken on asynchronous method that can be awaited is considered a good practice.

You should still pass the token, so the wait is cancelled when you want it to. In that case, if you want to do some cleanup, skip the if (token.IsCancellationRequested) and use a try/catch (OperationCancelledException) instead. The exception is not a “problem”, it’s a feature. It’s how C#'s TAP modeled task cancellation.

That’s true, but the problem is: the coroutine doesn’t know it’s been cancelled. It can’t run a cleanup code by itself, and someone else needs to do it. On the other hand, with Awaitable, you can use try/catch inside the called method, do your own cleanup, and at the end throw anyway, to communicate the cancellation upwards. You have the choice. Coroutines don’t give you one.

On “Starting the things just feels icky?”

Like I said above, the code you wrote doesn’t run the Awaitable to completion. You need to call await on it. That code is the same as creating a coroutine, but not starting it. This last phrase turned out not to be true, as @sisus_co brought up later.

Regarding the “fire and forget” behavior, as someone said above, UniTask has the Forget method, but I’m not sure what the Awaitable equivalent would be.

On “Tying things to object lifetime is not default, and clunky”

I see that as an advantage for 2 reasons:

  • With UniTask for example, I don’t need a MonoBehavior. I can have regular C# code running async/await. That’s not true for Coroutines.
  • I often would like for a UniTask to keep running after an object has been destroyed for some weird reason, and I can implement that. With Coroutines, you can’t without using a dedicated MonoBehaviour to run such coroutines.

On “Passing data back wasn’t that hard in the first place, but I guess this is a bit cool”

It was not that hard, but it sure as hell is more readable with Coroutines.

My take

I wrote an article on Coroutine vs. UniTask while ago and I talked about some of the points that I believe UniTask (and possibly Awaitable) come on top of coroutines. Here’s a summary.

  • Availability: you don’t need a MonoBehaviour instance to run a UniTask. The same is true for Awaitable.
  • Outcome accessibility: being able to return a value without boilerplate is soooo nice.
  • Stopping and cancellation: both the running UniTask (and Awaitable) and the caller are aware of the cancellation, which makes modeling some behaviors much easier.
  • Lifetime management: A Coroutine is tightly coupled to the MonoBehaviour that started it. If its MonoBehaviour is destroyed, the Coroutine stops automatically. That might be a nice feature, but the lack of control sometimes bites you in the ass.
  • Error handling: as I said above, Coroutines and error handling don’t go very well together because yield statements can’t be placed inside try /catch /finally blocks.
  • Multithreading support: this is auto-explanatory.

To answer your questions

I use UniTask, so, when necessary (I have some rules on when this can be used), I call UniTask.Forget.

Like I said, I don’t get that problem, and I believe that your examples didn’t work because you were not awaiting the Awaitable.

I follow C# TAP’s approach: cooperative cancelling with CancellationToken. Yes, it’s a pain that I need to pass it as argument to asynchronous methods all the time, but I believe its benefits outweigh the annoyance.

Conclusion

I am still not convinced that Awaitable will replace UniTask for me, but I replaced Coroutine with UniTask years ago and I am not looking back.

I hope my reply adds to the discussion, and I am eager to see what other people think about my take on it. :slight_smile:

Edit after writing

One thing I forgot to mention on my initial post is that one of the aspects that I like about using UniTask (and hopefully will with Awaitable) is that it’s one solution that can do all. It’s not the best solution for each specific use case (none of the options are, in my opinion), but it can do it all, without huge workarounds. Particularly, it can:

  • Mimic almost all behavior of Coroutine.
  • Be used outside MonoBehaviour.
  • Used with multithreading.
  • Mixed and matched with Coroutines (you can call await on them!) and Task, when necessary. An example is when you’re dealing with libraries that don’t support async/await, but do support Coroutine.
  • As a bonus, it offers support for DOTween.

That means that I can choose only 1 solution for everything asynchronous: async/await. It works with Task, UniTask, Awaitable, AsyncOperation and any other type that implements C#'s TAP. I don’t need to keep the intrinsics of both Coroutine and async/await in my head, and I don’t need to write code that does both. I have this one tool that yes, definitely takes some time to get used to, but once I got the gist of it, I’m done. I can forget about Coroutine and move on.

4 Likes

That’s actually great, I didn’t know this. If the awaited code was inside try-finally, does finally execute in this case?

I’ve observed two gotchas: if the object has never been active, this CancellationToken won’t be triggered. In fact, object’s OnDestroy isn’t called either. That was a pain for me because I call Initialize to asynchronously initialize some deactivated panels’ data in advance. I had to call SetActive(true) followed by SetActive(false) to initialize the object’s lifecycle which is a solution I’m not enjoying.

The other gotcha is, accessing destroyCancellationToken after the object is destroyed will actually throw an exception: UnityCsReference/Runtime/Export/Scripting/MonoBehaviour.bindings.cs at 9cecb4a6817863f0134896edafa84753ae2be96f · Unity-Technologies/UnityCsReference · GitHub

That’s how I do it as well. I have cases where an async function must be awaited by a caller but not by another caller. So I use your pattern in the latter case.

Actually I have the opposite experience because I have two types of services: some run on coroutines (value returned by callback function) and some run on Tasks. I’m unable to know the caller site of coroutines by looking at their callstack but the Tasks’ stacktrace show the caller site properly.

How do you think UniTask compares against Task/Awaitables? Would you continue using Awaitables or are you planning to return to UniTask?

1 Like

Could you explain what Forget does? I haven’t used UniTask yet, and looking at the source I didn’t find any logic inside the method:

[AsyncMethodBuilder(typeof(AsyncUniTaskVoidMethodBuilder))]
public readonly struct UniTaskVoid
{
    public void Forget()
    {
    }
}

Edit: Was looking at the wrong source.

public static void Forget(this UniTask task)
{
    var awaiter = task.GetAwaiter();
    if (awaiter.IsCompleted)
    {
        try
        {
            awaiter.GetResult();
        }
        catch (Exception ex)
        {
            UniTaskScheduler.PublishUnobservedTaskException(ex);
        }
    }
    else
    {
        awaiter.SourceOnCompleted(state =>
        {
            using (var t = (StateTuple<UniTask.Awaiter>)state)
            {
                try
                {
                    t.Item1.GetResult();
                }
                catch (Exception ex)
                {
                    UniTaskScheduler.PublishUnobservedTaskException(ex);
                }
            }
        }, StateTuple.Create(awaiter));
    }
}

So Forget for UniTaskVoid just suppresses warning and for UniTask it’s also logging exceptions?

That’s what I’ve experienced from using it, yes. It awaits for the task (so it’s different from just calling the method), gets rid of the compiler warning and logs the uncaught exceptions. So something really similar to Coroutine’s “fire and forget” behavior.

Really strange that async void Start logs exceptions and async Awaitable Start doesn’t. Looks like a bug to me, unless there’s some explanation behind it and it’s documented somewhere.
Hm, did some tests and starting async void method from non-async context also logs exceptions, but both async Awaitable and async Task don’t. I also added one solution to logging these exceptions (both for Awaitable and Task)

public class TestAsync : MonoBehaviour {
    void Start() => Do();

    async void Do() {
        await Task.Yield(); // or Awaitable.NextFrameAsync();
        Debug.Log($"{GetType().Name}.{nameof(Do)}");
        var animator = GetComponent<Animator>();
        await Task.Yield(); // or Awaitable.NextFrameAsync();
        animator.SetTrigger("lol"); // this is logged
    }
}

public class TestAwaitableAsync : MonoBehaviour {
    void Start() {
        _ = Do1();
        Forget(Do2());
    }

    async Awaitable Do1() {
        await Awaitable.NextFrameAsync();
        Debug.Log($"{GetType().Name}.{nameof(Do1)}");
        var animator = GetComponent<Animator>();
        await Awaitable.NextFrameAsync();
        animator.SetTrigger("lol"); // this isn't logged
    }

    async Awaitable Do2() {
        await Awaitable.NextFrameAsync();
        Debug.Log($"{GetType().Name}.{nameof(Do2)}");
        var animator = GetComponent<Animator>();
        await Awaitable.NextFrameAsync();
        animator.SetTrigger("lol"); // this is logged because of Forget logic
    }

    /// I based this on Forget method of UniTask.
    /// Awaitable's OnCompleted receives parameterless Action,
    /// so there's more Garbage compared to UniTask (from my observation)
    static void Forget(Awaitable task) {
        var awaiter = task.GetAwaiter();
        if (awaiter.IsCompleted) {
            try {
                awaiter.GetResult();
            } catch (Exception ex) {
                Debug.LogException(ex);
            }
        } else {
            awaiter.OnCompleted(() => {
                try {
                    awaiter.GetResult();
                } catch (Exception ex) {
                    Debug.LogException(ex);
                }
            });
        }
    }
}

public class TestTaskAsync : MonoBehaviour {
    void Start() {
        _ = Do1();
        Forget(Do2());
    }

    async Task Do1() {
        await Task.Yield();
        Debug.Log($"{GetType().Name}.{nameof(Do1)}");
        var animator = GetComponent<Animator>();
        await Task.Yield();
        animator.SetTrigger("lol"); // this isn't logged
    }

    async Task Do2() {
        await Task.Yield();
        Debug.Log($"{GetType().Name}.{nameof(Do2)}");
        var animator = GetComponent<Animator>();
        await Task.Yield();
        animator.SetTrigger("lol"); // this is logged because of Forget logic
    }

    static void Forget(Task taskParam) {
        taskParam.ContinueWith(task => {
            if (!task.IsFaulted) {
                return;
            }
            var exception = task.Exception;
            if (exception == null) {
                return;
            }
            foreach (var innerException in exception.InnerExceptions) {
                Debug.LogException(innerException);
            }
        });
    }
}

So it seems that if you want to see your exceptions in async Start method, you either need to use async void Start, or void Start in which you start your async logic yourself and provide code for logging exceptions.

No, code inside finally does not get executed.

Unity doesn’t seem to call SetResult(OperationCanceledException) on awaitable instances when exiting play mode, but just clears ones that have been internally queued for deferred execution.

1 Like

Your mental model about async methods is actually wrong here.

Simply executing an async method is enough to start it and have it run to completion.

In the below example, the async LaunchNuke method will execute all the way to the last line, despite never being awaited:

public sealed class Test : MonoBehaviour
{
	void Start() => LaunchNuke();

	async Awaitable LaunchNuke()
	{
		for(int i = 10; i >= 0; i--)
		{
			Debug.Log($"Launching nuke in {i} seconds...");
			await Awaitable.WaitForSecondsAsync(1f);
		}

		Debug.Log("KA-BOOM!!");
		Destroy(gameObject);
	}
}

This is because C# does some magical stuff during the lowering process, and actually replaces the contents of all async methods with code that creates the async state machine object and starts it.

2 Likes

You are right. The task will continue executing, and I thought it would not. Thank you for the heads up! TIL. I will update my original post to reflect this.

What does happen, at least in the case of Task, is that thrown exceptions will not be forwarded to the caller, because, well… they are not awaiting for it. That explains why OP was not getting exceptions.

With that said, it seems like Awaitable is missing the equivalent of UniTask’s Forget to silence the compiler warning in these fire-and-forget cases, and keep logging exceptions.

2 Likes

Yeah. And doing that with Awaitables manually is really awkward too. It’s best to always use await when possible.

For those rare situations where you can’t do that, because you need your current method’s execution to continue onwards after starting the other async method, you can use an extension method or a utility class to help avoid all the boiler-plate:

public static class AwaitableExtensions
{
	public static void Forget(this Awaitable awaitable)
	{
		var awaiter = awaitable.GetAwaiter();
		if(!awaiter.IsCompleted)
		{
			awaiter.OnCompleted(HandleLogException);
			return;
		}

		HandleLogException();

		void HandleLogException()
		{
			try
			{
				awaiter.GetResult();
			}
			catch (Exception ex)
			{
				Debug.LogException(ex);
			}
		}
	}

	public static void Forget<TResult>(this Awaitable<TResult> awaitable)
	{
		var awaiter = awaitable.GetAwaiter();
		if(!awaiter.IsCompleted)
		{
			awaiter.OnCompleted(HandleLogException);
			return;
		}

		HandleLogException();

		void HandleLogException()
		{
			try
			{
				_ = awaiter.GetResult();
			}
			catch (Exception ex)
			{
				Debug.LogException(ex);
			}
		}
	}
}
3 Likes

To be honest that was a much longer post than I have time to sink my teeth into right now (Not that I don’t. You always have interesting stuff. Just short on time atm). But the short answer to the first major question - No. I don’t use Awaitables or literally any async libraries to handle operations over time. That’s not really their purpose. They are mostly for promise-based programming where you don’t know how long something will take and you’re not super interested in the result right now but you eventually need to trigger some code when it happens. They aren’t meant for actions that happen on regular intervals over a period of time. For that stuff I would use some kind of global ticking system that can be registered to. Or perhaps Behaviour Trees if it is something with a lot of dynamic logic and state. If I’m in a hurry to test something or the code is only used once where performance doesn’t matter to me then I’ll use a coroutine.

In all honestly I actually kinda like coroutines on the surface for exactly what they are meant for. The only reason I don’t use them more is simply due to the garbage they create. If Unity had a solid generational GC or they were garbage-free then I wouldn’t think twice about it and I’d use them a lot more.

Right after writing my last reply, I thought “I’m sure that if I were to use Awaitable, I would just write an extension Forget method”. You beat me to it. :stuck_out_tongue:

A thought: wouldn’t something like this work in that case?

var awaitable = DoAwaitableThing();
// Do your other code here.
...
await awaitable;

It would not replace all the use cases of Forget, but it would let you start the Awaitable, perform other operations, then await its completion—possibly catching exceptions.

I see your point, but I don’t agree in 2 ways:

  • I don’t agree that “that’s not really their purpose”. I believe that their purpose is to write asynchronous code, whatever that is. C#'s documentation says the following about TAP:

    The Task Parallel Library (TPL) is based on the concept of a task , which represents an asynchronous operation.

    If TAP was only meant to be used “where you don’t know how long something will take”, APIs like Task.Delay and Task.Yield would not exist.

  • I don’t agree that just because a language feature wasn’t build for what I am using it, I should not use it. In fact, I think the act of repurposing language features for something novel (not that this would be the case) is super fun, and it might open a door that was not obvious for other developers. One example of that is how Unity used IEnumerator + yield as “lazy evaluation” to implement Coroutines.

That’s totally valid. But how do you deal with composition, error handling and cancellation, without leaning back into callback hell?

2 Likes

Good point. That’s an option as well.

1 Like