Skip to content

Instantly share code, notes, and snippets.

@abeldantas
Last active November 14, 2023 16:04
Show Gist options
  • Save abeldantas/44b7281ec6c223e3cdf967a541684256 to your computer and use it in GitHub Desktop.
Save abeldantas/44b7281ec6c223e3cdf967a541684256 to your computer and use it in GitHub Desktop.
No Fire and Forget Tasks in Unity
using System;
using System.Collections;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using Awaiter = Cysharp.Threading.Tasks.UniTask.Awaiter;
/// <summary>
/// Coroutines are great, and if we can fulfill all requirements using them, then good, no need to mess with Tasks!
///
/// Tasks offer some nice QOL improvements over Coroutines, the System.Threading namespace offers much functionality.
/// UniTask (https://github.com/Cysharp/UniTask) brings additional Unity compatibility for Tasks.
/// But regardless if we use UniTask or just Tasks, for the benefits they offer, Tasks can become unwieldy if we don't
/// take a `managed approach`.
///
/// My main point with this example is that when using Tasks we should always take a managed approach, otherwise
/// we're better off with Coroutines.
///
/// Managed approach means that we cannot `Fire and Forget` by having `async void` methods, and kicking off
/// Tasks that we might loose track of.
///
/// A common problem is the `kicking-off` for the first asynchronous process, which often happens on the
/// Start method of a MonoBehaviour, that is where you're most likely to see an `async void`. Here I used an awaiter
/// to avoid that.
///
/// Furthermore, in general, it is better to have TaskManager MonoBehaviour or MonoBehaviour Singleton that other
/// MonoBehaviours can call upon that abstracts out part of the task management and that offers a unified entry-point
/// to all asynchronous processes (sometimes keyed by screen or scene).
/// </summary>
public class UniTaskExample : MonoBehaviour
{
// This allows us to know the state of all the asynchronous processes running on this MonoBehaviour
Awaiter globalAwaiter;
// This cancellation token allows us to 'kill' one of the asynchronous processes (the only one right now)
CancellationTokenSource cancellationSource;
void OnEnable() // Our OnEnable function is synchronous, not async void (we could do this in Start too)
{
cancellationSource = new CancellationTokenSource();
var cancellationToken = cancellationSource.Token;
// Our synchronous Start method starts the asynchronous task and keeps a Awaiter reference for it
globalAwaiter = AsynchronousTask( cancellationToken )
.ContinueWith( ourString =>
{
Debug.Log( "We can add some extra behaviour at the end of the asynchronous task! " +
$"In this case, let's just print: {ourString}" );
} ) // At the end we want to do some stuff
.GetAwaiter(); // We want to store a reference for the process so we can manage it
}
void Update()
{
// The global awaiter gives us a way to ensure no ghost processes are running wild
if ( globalAwaiter.IsCompleted )
{
Debug.Log( "No asynchronous processes running, disabling gameObject." );
gameObject.SetActive( false );
}
}
async UniTask<string> AsynchronousTask( CancellationToken cancellationToken )
{
// With both coroutines and async processes I like to use Guids to debug which process is actually happening
// Or if multiple processes of the same thing are colliding
var guid = Guid.NewGuid().ToString();
// Here we have an example of awaiting a coroutine as a task, but it could be any other async task code
await LoopingCoroutine( guid, cancellationToken )
.ToUniTask( cancellationToken: cancellationToken ) // Convert to UniTask so I can add a timeout
.SuppressCancellationThrow() // If we don't do this, the cancellation will throw and exception and whatever is bellow will not execute - sometimes that is what we want
.TimeoutWithoutException( TimeSpan.FromSeconds( 5 ) ); // We don't want it to run more than 5 seconds, this is something that it not easy to do with regular coroutines
// Remember, with Tasks we don't need to use callbacks (we can, but we don't need to) because
// we can just return values from the execution of the task, we can return values!
return await WaitAndGiveMeAString( cancellationToken );
}
async UniTask<string> WaitAndGiveMeAString( CancellationToken cancellationToken )
{
// Notice how we are using the overload to pass in the cancellationToken
await UniTask.WaitForSeconds( 1, false, PlayerLoopTiming.Update, cancellationToken )
.SuppressCancellationThrow(); // If we don't do this, the cancellation will throw and exception and whatever is bellow will not execute - sometimes that is what we want
// We still need to explicitly check the cancellation and handle any custom cancellation behaviour
if ( cancellationToken.IsCancellationRequested )
{
return "The process was cancelled.";
}
return "The process was not cancelled, it ran until the end.";
}
[ContextMenu( "Cancel Tasks" )]
public void CancelTask()
{
cancellationSource.Cancel(); // This is how we issue cancellations
}
IEnumerator LoopingCoroutine( string id, CancellationToken cancel )
{
while ( true )
{
// With tasks and cancellations, within our processes, we must explicitly check if the cancellation
// is requested. Unlike with Coroutines, we can specify specific cancellation behaviours
if ( cancel.IsCancellationRequested )
{
yield break;
}
yield return new WaitForSeconds( 1 );
Debug.Log( $"A process is running on {id}" );
}
}
public void OnDisable()
{
CancelTask();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment