One is the consequence of the other. The GC doesn’t run every frame and the frequency it runs depends on the amount of garbage that is generated. Thus, the more allocations, the more frequently the GC kicks in. If your code doesn’t allocate at all every frame, for 1000 frames, the GC won’t be a problem.
That is neat, I had no idea. That changes the scenario a bit, at least to me. With that said, UniTask has been around at least since 2019 (that’s when I started using it) and based on what I see from Unity’s documentation (and from the Unity 2022 project I just tried to use Awaitable and couldn’t resolve it), it seems like the type was introduced in Unity 2023. Correct me if I’m wrong.
At work we are still using Unity 2021 due to legacy SDKs (thanks, Pico). On my personal projects, I stick to LTS, so I’m on 2022. It seems like UniTask (which has been extremely reliable, BTW) will still be a part of the code I write for some time.
Still on the Awaitable subject, I’ve got a question. Unity’s documentation says:
…once awaited, Unity returns the Awaitable object to the internal Awaitable pool. This has one significant implication: you should never await more than once on an Awaitable instance.
UniTask has a similar limitation. You can get around it with extra calls (that involve allocating heap memory), but that’s beside the point.
My question is: what happens if I try to access properties like Awaitable.IsCompleted after awaiting on it? The instance is back at the pool and could be inactive, or it could already be recycled.
first of all, i’ve never seen anyone await a task or anything multiple times, because that generally does not make sense in any case.
second, to answer your question: afaik it’s simply undefined behavior to await it multiple times, because it might or might not be recycled, meaning you’ll get an unfinished task or a result that might very well not be from your intended source, since it can be recycled for a different operation and thus contain that one’s result.
and yes, Awaitable is a more recent addition to Unity, but therefore it’s important to know of it going forward.
I haven’t seen anything about Edit and Continue (which usually works in managed-only debugging, but that’s ok). I’m asking specifically, because I’m not sure whether this works when CoreCLR is hosted in native executable.
As far as I know, awaiting an Awaitable will return it to the pool. So I would assume that touching it in any way afterwards is invalid, including accessing “IsCompleted” (because something else may claim it from the pool, so the Awaitable object is no longer referring to the same operation).
…Which makes me wonder what happens if you don’t await an Awaitable (i.e. when storing several in a list and checking IsCompleted on them, which you may do with a Task). Does it just “leak”?
This sounds super promising! Anything that is intuitive and makes programming more about doing stuff and less about scaffolding is a good friend to a game dev.
Does it have more relaxed constraints than jobs and burst? I don’t see how but it would be nice to not have to deal with the scaffolding of data formatting to speak with monos and the engine.
If you never await (and never try to get the result) I would assume it just leaks yeah.
One thing I find a bit scary with awaitables though is exceptions and running on other threads.
Like the following:
private async void SomeFuncOnMainThreadTouchingUnityApi()
{
var vectors = new List<Vector3>();
try
{
vectors = await CalculateNewVectors();
}
catch (Exception)
{
// Log error and continue on, we have some safe backup
// values we can use.
}
// If we threw an exception before running
// Awaitable.MainThreadAsync, what thread are we on now. Is
// this legal?
SomeUnityApi(vectors);
}
private async Awaitable<List<Vector3>> CalculateNewVectors()
{
var vectors = new List<Vector3>();
await Awaitable.BackgroundThreadAsync();
// call some function that ends up throwing
await Awaitable.MainThreadAsync();
return vectors;
}
Would have been nice to have some sort of scoped way of restoring the original context in these situations. You can do it with try finally, but that’s annoying. We tried seeing if we could set up an IDisposable wrapper, something like:
public struct ScopedBackgroundThread
: IDisposable
{
public void async Dispose()
{
await Awaitable.MainThreadAsync();
}
}
You can’t have an async dispose function, so this doesn’t actually work.
It seems like the decision to make Awaitable a class instead of a struct will limit potential changes in the future. If it were a struct, it could still have the behavior it has now (by implementing it as a wrapper around an internal class), but it would also have been possible to store a “version” value inside it to detect misuse automatically. Since it’s a pooled reference type, you can’t really make use of techniques like that…
You can’t have an async dispose function, so this doesn’t actually work.
IAsyncDisposable exists for this purpose (and supports await using syntax), so you could play around with that.
Cool, I wasn’t aware of this interface. Unfortunately it only solves half the problem (at least based on my limited experiments and experience with C# stuff).
_
public struct BackgroundThreadScope
: IAsyncDisposable
{
// Can't because return value must be Task, Task<T>,
// task-like, IAsyncEnumerable<T>, or IAsyncEnumerator<T>
public static async BackgroundThreadScope Scope()
{
await Awaitable.BackgroundThreadAsync();
return new BackgroundThreadScope();
}
public async ValueTask DisposeAsync()
{
await Awaitable.MainThreadAsync();
}
}
public async void Func()
{
// Would prefer to be able to use:
using var _ = BackgroundThreadScope.Scope();
// But guess we'll have to settle for:
await Awaitable.BackgroundThreadAsync();
await using var scope = new BackgroundThreadScope();
}
The best way to find out is to test the code. My guess would be that it would run on the background thread, because you haven’t asked it to switch back to the main thread yet. That seems to be in line with vanilla C#/.NET behavior with Task. If you would like to guarantee that it runs on the main thread, you could do something like this inside CalculateNewVectors:
try
{
var vectors = new List<Vector3>();
await Awaitable.BackgroundThreadAsync();
// call some function that ends up throwing
}
finally
{
await Awaitable.MainThreadAsync();
}
return vectors;
Could you please share your own impression on the difference between Mono and CoreCLR for the Editor at this moment? Is it totally a game changer? Is it comparable with other popular engines?
Does it meet your expectations?
What do other people usually say when they try it for the first time? Is it “Wooooh” or “Oh, it’s faster, isn’t it?”.
In this case, they still have to reload whole groups of assemblies, and the cooperative nature of the feature requires libraries to be compatible with this mechanism.