This is getting really annoying since every time I exit play mode I’ll have to open task manager, force close unity, and reopen it. I have a rough idea on where and when this happens, but it happens on approx. 30kb worth of scripts, combined with the fact that it is related to asnyc/await operations and maybe even navmeshagents. I have made sure to call Dispose and ResetPath in OnDestroy but it still happens. This is still a bit vague so feel free to ask for more info.
Here are some snippets of code:
private void OnDestroy()
{
repathingTimer.Dispose(); //is of type 'Timer', partially shown below
teacherAgent.ResetPath();
close = true; //tells some async function to stop its operation
}
public sealed class Timer : IDisposable
{
public async Task Start()
{
//check for stuff
while ((millisecondsElapsed < Interval && Status == TimerStatus.Ticking) || (millisecondsElapsed > 0 && Status == TimerStatus.Reversing))
{
//do stuff
await Task.Yield();
millisecondsElapsed += (int)(Time.deltaTime * 1000f * (Status == TimerStatus.Reversing ? -1f * reverseMultiplier : 1f));
//invoke an event
}
//finalize some stuff
}
public void Dispose()
{
if (Status == TimerStatus.Disposed)
{
return;
}
//close timer then proceed
Status = TimerStatus.Disposed;
Interval = millisecondsElapsed = -1;
//setting events to null
GC.SuppressFinalize(this);
}
~Timer()
{
this.Dispose();
}
}
}
//example of where the 'close' variable is used
public async Task ObserveStrong(Transform objectToObserve, Timer timer)
{
//prepare some stuff
for ((Quaternion angle, float duration) locationToLookAt; timer.Status == Timer.TimerStatus.Reversing;)
{
if (close)
{
return;
}
//do some stuff
}
//finalize some stuff
}
As previously mentioned, this is overly complex code for a timer. There are several key issues to address:
Adding Time.deltaTime repeatedly can lead to incorrect results for long-running timers due to floating-point precision errors and the explicit conversion to int on each iteration.
Finalizers have very niche use cases in C# and can create several issues due to the garbage collector, specifically:
Memory release is delayed because the GC must track finalizers that haven’t run yet.
Objects with finalizers are considered root objects, meaning they retain references to other objects, preventing those objects from being collected. This results in wasted memory.
The programmer cannot predict when or how objects enter the finalization queue, making the order of finalizer execution unpredictable.
A special case arises when a finalizer needs to wait for an event, potentially blocking other finalizers in the queue and wasting processing power and memory.
Another reason to be cautious with finalizers is that they may run if an object throws an exception during construction. If the exception is caught, the object may not have been properly initialized, so a finalizer should never assume that the object’s fields have been initialized correctly.
In this case, at minimum there is double the job the GC has to do for each timer instance, when traversing the object graph and discovering objects eligible for garbage collection, because instead of releasing the object with the finalizer and all its direct and indirect references, it has to traverse that part again.
Using a variable like close is not the correct way to stop an asynchronous operation. Instead, use the CancellationTokenSource class.
Declaring an async method without an await expression, such as in ObserveStrong, serves no meaningful purpose.
The freezing part has something to do with how the finalizer is used and how the asynchronous part doesn’t stop when the play mode stops.
With the exception of the first issue, I believe most of these problems stem from how the asynchronous part with Task type is used here, which doesn’t integrate well with Unity’s game loop. Instead of await Task.Yield(), try using Unity’s Awaitable type. Awaitable.NextFrameAsync should achieve the desired behavior while respecting Unity’s game loop. This way, when you stop playing in the editor, you won’t need the extra workarounds this code currently implements to handle Task.
Having code that repeats in a loop and awaits Awaitable.NextFrameAsync, means that it runs once each frame, just invoke your event in that loop.
If you implement the second, you don’t need the first as the Awaitable doesn’t need a mechanism to be stopped when you exit play mode, it stops automatically.
If you need your code to execute asynchronously once each frame, use await Awaitable.NextFrameAsync() or await Awaitable.EndOfFrameAsync() just look at the examples at the documentation.
It has different implementations with different complexity, some have more functionality some less and it also has one using the Awaitable just mix and match the implementation and the functionality depending on your needs.
Unity allows you to customize the PlayerLoop if you truly need a “per-frame” update method.
However, in 100% of these cases you can architect your game around a single MonoBehaviour (make it singleton if you have to) that calls out to other systems either hardcoded or via events or via a baseclass or interface driven registration system (eg RegisterForMyUpdateEvents(IMyUpdateEventReceiver receiver)).