Last active
November 14, 2023 16:04
-
-
Save abeldantas/44b7281ec6c223e3cdf967a541684256 to your computer and use it in GitHub Desktop.
No Fire and Forget Tasks in Unity
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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