Replacing Coroutines in Unity with C# Task Async Pattern (TAP)

Search for a command to run...

No comments yet. Be the first to comment.
Build. Craft. Explore.

Заселяємо південь України!

Hey! We just published the second content update for our game Sloboda 2. This update is free for anyone who already purchased our game. If you want to purchase it, then pick your platforms: Apple App Store: https://apps.apple.com/us/app/loca-deserta-...

Привіт, компана! Ось і настав час Січневого оновлення для нашої гри Слобода 2! Це оновлення безкоштовне для всіх, хто придбав гру. Якщо ж ще не придбали, то можете це зробити по цих посиланнях: Apple App Store: https://apps.apple.com/ua/app/loca-dese...

Статистика запуску гри Трохи більше двох тижнів тому ми випустили гру на всіх магазинах. І одразу ж попали в Топ платних ігор 🇺🇦українського App Store! Купити можна на iOS по посиланню: https://apps.apple.com/ua/app/loca-deserta-sloboda-2/id164250...

In any game you have to code an asynchronous logic. For example: timeouts, countdowns, progress bars or delayed interactions.
In my city-building game Loca Deserta: Sloboda one of the main interactions in game to build/upgrade/produce is done by standing near the objects. If you stand 1 second near the table it will open its UI like this (notice green progress circle enlarging as the progress goes on):

The logic of that delayed interaction is following:
One of the ways to do this is by using Unity's Coroutines: https://docs.unity3d.com/Manual/Coroutines.html
They give a built-in mechanism for starting and cancelling async actions.
The code that starts coroutine to mine a stone resource in my game:
public void StartMining()
{
actionCoroutine = StartCoroutine(processMining());
audioSource.PlayOneShot(actionSound);
Actions?.Invoke(MineableActions.STARTED, this);
playMiningParticle(true);
}
IEnumerator processMining()
{
yield return DelayedRoutine.delayBy((float)miningDurationInSeconds);
executeMining();
}
And in OnTriggerExit, when the player leaves the collided object, we can cancel launched coroutine in case it was not yet finished. The game should 'forget' about the launched action:
private void stopMining()
{
if (actionCoroutine != null)
{
StopCoroutine(actionCoroutine);
actionCoroutine = null;
Actions?.Invoke(MineableActions.STOPPED, this);
audioSource.Stop();
playMiningParticle(false);
}
}
This code works perfectly fine. But the performance of Coroutines on low-end mobile phones is below desired.
Here is the profile frame when the collision happens and Unity creates coroutine for you recorded on Galaxy Note 8 device:

It takes...200-300ms to launch a coroutine! The bad thing: it was happening on each collision call. Players could feel the lag once they wanted to open a UI or cut a tree.
The performance on Galaxy S21 Ultra was way better but still I could see spikes when coroutine was created.
You can google dozens of questions regarding the performance issues with coroutines:
The C# official way of dealing with asynchronous tasks is TAP: https://docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap .
Almost all async Task methods support https://docs.microsoft.com/en-us/dotnet/api/system.threading.cancellationtoken?view=net-6.0 parameter. It is used to check if your task was cancelled by the source or not.
Read the MS docs and then return back to my article to get basic understanding what's going on.
The rewritten Coroutine code with TAP pattern looks like this:
public async void StartMiningTask()
{
audioSource.PlayOneShot(actionSound);
Actions?.Invoke(MineableActions.STARTED, this);
playMiningParticle(true);
cancellationTokenSource = new CancellationTokenSource();
await ProcessMining(cancellationTokenSource.Token);
}
We add async keyword, add Task suffix to the method name (common pattern). And instead of saving coroutine to variable we create new CancellationTokenSource and send it to awaited method ProcessMiningTask.
ProcessMiningTask method uses Task.Delay and sends it the same CancellationToken it received . If the CancellationToken is activated then the Task.Delay would throw exception that we catch:
async Task ProcessMiningTask(CancellationToken token)
{
try
{
await Task.Delay(miningDurationInSeconds * 1000, token);
ExecuteMining();
}
catch (TaskCanceledException _)
{
}
finally
{
cancellationTokenSource = null;
}
}
And StopMining method checks for active source and cancels the Task. Then proceeds with standard gameplay logic:
private void StopMining()
{
if (cancellationTokenSource == null)
{
return;
}
cancellationTokenSource.Cancel();
Actions?.Invoke(MineableActions.STOPPED, this);
audioSource.Stop();
PlayMiningParticle(false);
}
That's how easy it was to rewrite using standard C# pattern!
In your case the Coroutines in Unity might work and perform well but definitely not in my case. Also I prefer standard C# .NET features instead of relying on Unity-specific implementations of common patterns.
After I've got rid of all of these Coroutine calls there are no longer spikes on collisions:

As usual, do and use what suites you the best!
If you are into games like Settlers, Minecraft, Unrailed! or just any Voxel Art games, then you can follow my progress on creating complete new game from scratch!
