On Async/Await with Awaitables as a Coroutine replacement

Well I did say ‘mostly’ :wink: ( which reminds me of a fun clip of NASA talking about the surface of one of Jupiter’s moons lol).

Perhaps I’m missing some context since I only skimmed the first part of the OP so please correct me if I’m way out of the park on this one. It seems Baste was talking about regular actions that happen over a period of time. So in this context I’m not talking about async anything. It’s purely synchronous. I’m not talking about long tasks that will stop the whole program while they happen. I’m talking about incremental updates that happen every frame or maybe every fixed amount of time but are wholly blocking and it doesn’t matter because the action will be finished in a blink. To my admittedly caveman brain, async libraries seem a bit overkill for that. If I need error messages to go out to unknown receivers I can use events. If I want to cancel the action it’s as simple as removing it from the list of things to update.

Where I find things like the TPL extremely useful is when I specifically need to get the result of something and I don’t know how long that will take. Like generating a procedural map. Or downloading a file. I can just write the logic like I were assuming it was all going to happen in one frame immediately even though it doesn’t.

There are clever ways you could chain logic together that are predicated on waiting on in-game actions. For example an AI that issues commands to do things like ‘Move to this location in front of a door’, then ‘Open this door’, then "Use this ability’, then ‘Wait Five Seconds’, then ‘Run Away’ which could all take a different amount of time or even fail. I usually just default to BehaviourTrees for that kind of thing but I’ve seen plenty of examples of Promise-based systems doing the same thing. But that’s still promised-based programming. I’ve never personally seen an example where a Task was re-starting itself every single frame.

I don’t know, even for a simple use case like waiting for something to become ready, I find async-await the most elegant option:

await something.IsReady;
something.DoSomething();

vs

while(!something.IsReady)
{
	yield return null;
}

something.DoSomething();

or

if(!something.IsReady)
{
	something.BecameReady += OnBecameReady;
	return;

	void OnBecameReady()
	{
		something.BecameReady -= OnBecameReady;
		something.DoSomething();
	}
}

something.DoSomething();

It also just gives me a little additional peace of mind to know that there will basically be zero overhead while something is awaiting for the event that triggers the async method to continue with its execution, and that it will continue execution at the exact moment when that event occurs, unlike with coroutines, where every coroutine is getting re-executed potentially over a hundred times per second to re-check if it’s now time to continue onwards with the method’s evaluation.

3 Likes

Ah, this is one thing I didn’t go into, and it turns out that I was a bit wrong about how things were working. Essentially, having an async Start function works as expected as long as what you’re waiting for is an Awaitable.

If you’re not waiting for an Awaitable, things go sour fast. Here’s something you can test:

public class TestScript : MonoBehaviour
{
    private async void Start()
    {
        var text = await File.ReadAllTextAsync("VeryLongFile.txt");
        Debug.Log($"the file has length {text.Length}");
    }
}

public class TestScript2 : MonoBehaviour
{
    private async void Start()
    {
        await Awaitable.NextFrameAsync();
        Debug.Log("Exiting play mode");
        EditorApplication.isPlaying = false;
    }
}

Make VeryLargeFile.txt, and make sure it’s got enough stuff that your computer can’t read it in a single frame. Put both scripts in a scene. Press play. Here’s the fun result:

image

Note how TestScript finishes a whole second after we have exited play mode. Whoops!

Let me try for something even more fun, let me try making some modification to the scene in TestScript:

public class TestScript : MonoBehaviour
{
    public Transform someTransform;

    private async void Start()
    {
        var text = await File.ReadAllTextAsync("VeryLongFile.txt", destroyCancellationToken);
        Debug.Log($"the file has length {text.Length}");
        someTransform.position = new Vector3(0f, text.Length, 0f);
    }
}

Not surprisingly, the transform will be moved outside of play mode, which means that you’re doing destructive modifications to your serialized scene from gameplay code. That is very much not great.

You can fix that by passing the destroyCancellationToken:

var text = await File.ReadAllTextAsync("VeryLongFile.txt", destroyCancellationToken);

But then, again;


And as somebody said upthread, you should really not be getting exceptions when the code does the thing you want it to do. That’s incredibly disruptive to work, as it makes people just ignore the console. If it’s by design, then that design is incredibly bad and should be ignored.
And holy hell I’m not doing

string text;
try
{
    text = await File.ReadAllTextAsync("VeryLongFile.txt", destroyCancellationToken);
}
catch (OperationCanceledException)
{
    return;
}

That just makes the code an utter pain to read. Any API that requires you to try-catch everywhere by design is, again, incredibly bad and should be ignored.

Anyway, I was using Awaitable Start functions as it seems like there’s some cases where that made the function stop instead of continuing to run, but that doesn’t seem consistent now, so I’ve investigated more and understand less.

4 Likes

Why shouldn’t it? You are never cancelling it. Just because you left play mode? The editor is the one keeping it running here. What would be the equivalent of that in a build? The async operation would be aborted, and never log the exception.

That’s subjective, but if you would like to focus your frustration at anyone, do it at C#/.NET design team. That’s how TAP works. Without this workflow, the asynchronous method doesn’t know it’s been cancelled (like I’ve pointed before), and neither does its caller. And that’s what coroutines give you.

I think you are missing the point on how task cancellation works. You don’t have to try/catch everywhere. If you are really not interested in cancellation, you can do it only once on the call chain: on the root method—in this case Start.

If you are not willing to do that at all, then you can just adapt @sisus_co helper Forget method so OperationCancelledException is not logged, while the other exceptions are. The compiler will be happy, “relevant” exceptions will be logged, OperationCancelledException will not be logged (which personally believe is a bad thing, but alas), and the Awaitable will stop running when the object gets destroyed (if you pass the cancellation token).

If you are not even willing do that, then you are most likely better off with Coroutines, yeah.

I mean the whole point here is to go “hey, is async/await a good replacement for coroutines”. I think that if the API ends up being more cumbersome, and if it doesn’t automatically interact correctly with play mode, then it’s for sure a worse option.

I’ll try to clarify some things, sorry in advance if I’m overexplaining or not explaining enough some of them.

The async/await feature in C# has nothing to do with Unity, it doesn’t have knowledge of Unity’s play mode, Unity API or Unity’s game loop. There are essentially two things that it is using: A type that can be after the await and a type that can be used as the return type of an async method. Many times, these can be the same type, like the Task and ValueTask.

An async method returns an async type or void. The async type will contain the result of an asynchronous operation when this is done, this operation exists after the await keyword. The result can be the expected result or an exception that was thrown, either because there was an exception thrown by the asynchronous operation or because the asynchronous operation was cancelled.

The result can be obtained from the user by awaiting that type. This is why, an async method that returns such a type seems to swallow the exceptions. It doesn’t, the exceptions exist inside the type, because they are considered the result of the operation and will be thrown when that type is awaited with the await keyword to get the result.

If the async method returns void, then there is no type to store the result of the asynchronous operation that executes after the await. This means that the result, if it is an exception is thrown the moment it happens, because there is no type available to store it.

The Task, because it is made by Microsoft, has also no knowledge of Unity, its play mode or its game loop. When using Task as the return type, it will continue to execute the operation asynchronously until there is a result (normal result, exception thrown by the code, or exception thrown because cancelled).

This is the reason why Unity made the Awaitable. Because it respects Unity’s game loop, stops when we are exiting play mode and has static methods that have knowledge of that game loop.

Async/Await could be used before the Awaitable with Task but, the same as now, it was very cumbersome to use because of not respecting Unity’s loop and needing manual cancellations. If you need correct interaction with play mode, then use Awaitable. The File.ReadAllTextAsync returns a Task because it is a .NET asynchronous method, so it won’t work with Unity’s game loop.

The main advantage of the Awaitable over coroutines is that it can contain and return the result, where coroutines cannot return a result and that it uses the await keyword to write asynchronous code in a synchronous way.

These are just an overview, sorry again if I explained things you already know.

4 Likes

I think the older 2015 Unite video on async put it well when they said “with great power comes great responsibility”. Async is definitely more powerful and we can do a lot more with it than we can with coroutines on their own, but we just need to keep in mind a few extra things.

So maybe it’s not going to outright replace coroutines, but it lets us do more than we could with coroutines alone.

I recently finished up updating my current project’s UI system to an async/functional paradigm to get myself out of callback-hell (using UniTask as opposed to Awaitables (mind you UnTask has started using Awaitables for some things internally)).

Yes, a lot methods now end with ‘Async’ and take a CancellationToken, and there’s a try/catch at the entry point, but overall it solved a lot of problems, will prevent future bugs, and actually cut out a massive amount of code from the overarching UI system (almost half of it!).

So I think it’s not unreasonable to expect to do a little extra work for a lot of extra power.

I don’t think we should be telling new users to use async, mind. But definitely something intermediate coders should be learning.

And as the Unite video also said, coroutines still have a place for fire-and-forget stuff as well.

Had an interesting thing happen at work today that I find quite relevant to the discussion in this thread.

One of my coworkers was investigating a resource leak, and after about a day of debugging, finally managed to track down the issue to the usage of a using statement inside a coroutine. So something like to this:

IEnumerator LeakyCoroutine()
{
	using(var disposable = new Disposable())
	{
		for(int i = 0; i < 10; i++)
		{
			Debug.Log(i);
			yield return null;
		}
	}
}

As it turns out, if you were to use StopCoroutine to stop the above coroutine before it has finished completely, then Dispose will never get called for the disposable object :fearful:

Async/await on the other hand, does not suffer from the same problem:

public async Awaitable SafeAwaitable(CancellationToken cancellationToken)
{
	using(var disposable = new Disposable())
	{
		for(int i = 0; i < 10; i++)
		{
			Debug.Log(i);
			await Awaitable.NextFrameAsync(cancellationToken);
		}
	}
}

In this case Dispose would get reliably called on the disposable object, even if you were to cancel the awaitable before it has finished completely.

5 Likes