Fundamental of Cancellation of UniTasks
An UniTask is a kind of asynchronous processing, which takes time to complete. When executing asynchronous processing, you often want to stop that process before it finishes in some cases (e.g. you tried to retrieve data from a server, but it seems to take unacceptable time to be completed, therefore you want to cancel the process and notify it to the user).
In this article, we are going to learn how we can cancel the process of an UniTask running.
CancellationToken Object
An UniTask can be canceled using a CancellationToken
object. As an example, create the following code, attach it to an object, and run the code.
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private CancellationTokenSource cancellationTokenSource = new();
private void Start()
{
CancellationToken cancellationToken = cancellationTokenSource.Token;
_ = StartTimerAsync("Timer A", 3, cancellationToken);
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("Spacekey pushed!");
cancellationTokenSource.Cancel();
}
}
private void OnDestroy()
{
cancellationTokenSource?.Cancel();
cancellationTokenSource?.Dispose();
}
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
}
Debug.Log($"{name} finished!");
}
}
C#If you don’t anything after starting the execution, the timer will finish in the specified seconds.
If you press the spacekey within the specified seconds, the timer will stop.
We are going to see how the UniTask was canceled.
Firstly, the StartTimerAsync
method requires a CancellationToken
object as its parameter. The CancellationToken
object is an object that causes cancellation of UniTasks, and we can create it in many ways. In the example, we create an instance of the CancellationTokenSource
class and retrieve a CancellationToken
object from it.
private CancellationTokenSource cancellationTokenSource = new();
private void Start()
{
CancellationToken cancellationToken = cancellationTokenSource.Token;
_ = StartTimerAsync("Timer A", 3, cancellationToken);
}
C#The CancellationToken
object given to the StartTimerAsync
method as an argument is then given to the UniTask.Delay
method. The UniTask.Delay
method throws a OperationCanceledException
exception when the given CancellationToken
object is canceled, which stops the process.
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
C#The CancellationToken
object retrieved from the CancellationTokenSource
instance is canceled when the Cancel
method of the CancellationTokenSource
instance is called. In the example, the instance of the CancellationTokenSource
class is canceled when the spacekey is pressed, which leads to the cancellation of the CancellationToken
object.
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("Spacekey pushed!");
cancellationTokenSource.Cancel();
}
}
C#In summary, the process of the StartTimerAsync
method is canceled by following the steps below:
- The spacekey is pressed
- The
Cancel
method of theCancellationTokenSource
instance is called - The
CancellationToken
object that was given to theStartTimerAsync
method is canceled - A
OperationCanceledException
exception is thrown by theUniTask.Delay
method - The process of the
StartTimerAsync
method is canceled by the thrown exception
Additionally, as the CancellationTokenSource
class implements the IDisposable
interface, we have to call the Dispose
method. Furthermore, the Cancel
method must be called before the Dispose
method is called. The reason of this is that we should ensure that the process of the UniTask which received the CancellationToken
object retrieved from the CancellationTokenSource
instance finishes at the end of the program. We are going to learn about this in detail later.
private void OnDestroy()
{
cancellationTokenSource?.Cancel();
cancellationTokenSource?.Dispose();
}
C#Define Behavior of UniTasks When Canceled
Cancellation of UniTasks is caused by a thrown OperationCanceledException
. Therefore, using try-catch
statement, we can define behavior of a canceled UniTask.
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
}
Debug.Log($"{name} finished!");
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
throw;
}
}
C#Also, using try-catch-finally
, we can define a process that is absolutely executed regardless of whether or not an UniTask is canceled.
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
}
Debug.Log($"{name} finished!");
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
throw;
}
finally
{
Debug.Log("From finally...");
}
}
C#Should a thrown OperationCancellationException Be Thrown Again?
The OperationCanceledException
, which is thrown when an UniTask is canceled, is a kind of exception. Hence, after the exception is handled by catch
statement, the following behavior veries depending on whether the exception is thrown by throw;
again.
For example, the following two codes show different results when their UniTasks are canceled.
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
}
Debug.Log($"{name} finished!");
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
throw;
}
Debug.Log("End of method");
}
C#private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
}
Debug.Log($"{name} finished!");
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
}
Debug.Log("End of method");
}
C#When the UniTask is canceled, the first code doesn’t show the message “End of method” while the second code does.
That’s because in the first code, the processes after the try-catch
statement are canceled due to the exception that is thrown again in the catch
statement, on the other hand, in the second code, the processes after the try-catch
statement are not affected by the cancellation because the handeled exception is squashed in the catch
statement.
When you define a process that is executed only when an UniTask is canceled by catching the thrown OperationCanceledException
, you should consider whether you want to ignore that cancellation or whether you want to propagate the cancellation in other places in order to define proper behavior of the code depending on your requirement.
Define Cancellation Judgement By Yourself
The cancellation of UniTasks in the previous section examples is caused by a CancellationToken
being canceled and a OperationCanceledException
being thrown by the UniTask.Delay
method to which the canceled CancellationToken
was given.
You can basically leave such processes to already-prepared methods like the UniTask.Delay
method, but you can also check whether a CancellationToken
was canceled and throw an exception if necessary by yourself.
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
if (cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException();
}
}
Debug.Log($"{name} finished!");
}
C#In the above example, whether the CancellationToken
had been canceled is checked by the cancellationToken.IsCancellationRequested
method, and an exception is thrown if it had been canceled.
This code can also be written as follows:
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
cancellationToken.ThrowIfCancellationRequested();
}
Debug.Log($"{name} finished!");
}
C#The ThrowIfCancellationRequested
method checks the status of cancellation of a CancellationToken
and throws a OperationCanceledException
if it had been canceled.
Note that the behavior of this example is slightly different from that of codes where a CancellationToken
is given to the UniTask.Delay
method directly. If a CancellationToken
is given to the UniTask.Delay
directly, the following processes are canceled immediately when the CancellationToken
is canceled. In the above example, however, when the CancellationToken
is canceled, an exception is thrown after the process of the UniTask.Delay
method finishes, and then the following processes are canceled (you can see this difference clearly by specifying long delay time for the UniTask.Delay
method).
How to Create a CancellationToken
In the previous section examples, we retrieved a CancellationToken
object that is required to cancel an UniTask from an instance of the CancellationTokenSource
class.
In practice, we can obtain a CancellationToken
object not only from CancellationTokenSource
but also in several other ways. We are going to learn them in this section.
Retrieve a CancellationToken from a CancellationTokenSource
The most standard way to retrieve a CancellationToken
is to retrieve it from an instance of the CancellationTokenSource
class as we learned already.
Both CancellationToken
and CancellationTokenSource
are actually provided as C# standard functions, which are used to cancel asynchronous processing of the C# standard Task
class. They are defined in the System.Threading
namespace.
using System.Threading;
// Create a CancellationTokenSource instance
CancellationTokenSource cancellationTokenSource = new();
// Retrieve a CancellationToken
CancellationToken cancellationToken = cancellationTokenSource.Token;
// Cancel the CancellationTokenSource when it is required
cancellationTokenSource.Cancel();
// MUST dispose of the CancellationSource after using it
cancellationTokenSource.Dispose();
C#Convert an UniTask into a CancellationToken
We can obtain a CancellationToken
by converting an UniTask. This CancellationToken
is canceled when the source UniTask finishes.
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
CancellationToken tokenFromUniTask = StartTimerAsync("Timer A", 2, default).ToCancellationToken();
_ = StartTimerAsync("Timer B", 5, tokenFromUniTask);
}
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
}
Debug.Log($"{name} finished!");
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
}
}
}
C#In this example, the StartTimerAsync
method are called twice, specifying 2 seconds (for Timer A) and 5 seconds (for Timer B), respectively. The UniTask of Timer A is converted into a CancellationToken
object by the ToUniTask
method, and it is given to the UniTask of Timer B.
When the program starts, as the specified number of seconds for Timer A is shorter than that for Timer B, the UniTask of Timer A finishes faster than that of Timer B. When the UniTask of Timer A finishes, the converted CancellationToken
is canceled, which leads the UniTask of Timer B to which it is given to be canceled.
In this way, converting an UniTask by the ToCancellationToken
method, we can create a CancellationToken
that is canceled when the source UniTask finishes.
Generate a CancellationToken from a GameObject
We can obtain a CancellationToken
from a GameObject by the GetCancellationTokenOnDestroy
method, where the CancellationToken
is canceled when the source GameObject is destroyed.
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private async UniTaskVoid Start()
{
GameObject sourceObject = new();
CancellationToken tokenFromObject = sourceObject.GetCancellationTokenOnDestroy();
_ = StartTimerAsync("Timer A", 2, tokenFromObject);
await UniTask.Delay(TimeSpan.FromSeconds(1));
Destroy(sourceObject);
}
private async UniTask StartTimerAsync(string name, int seconds, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (seconds > 0)
{
Debug.Log($"{name}: {seconds} sec.");
seconds--;
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
}
Debug.Log($"{name} finished!");
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
}
}
}
C#In this example, we create a GameObject
at first, and then we obtain a CancellationToken
object from it by the GetCancellationTokenOnDestroy
method. The obtained object is given to the StartTimerAsync
method. After the method is called, the source GameObject
is destroyed in 1 second.
You employ this way, for example, if you create a CancellationToken
that is given to an UniTask that will never finish. As we learn later, it must be ensured that all UniTasks are absolutely canceled when it becomes unnecessary because otherwise resource leak might occur.
One common scenario where some UniTasks become unnecessary is when a scene transitions from the current one to a new one. UniTasks that are needed only for the current scene are unnecessary for the new scene, therefore they must be canceled. When a scene transition occurs, as all objects in the old scene are destroyed, UniTasks whose CancellationToken
is obtained by the GetCancellationTokenOnDestroy
method are automatically canceled.
CancellationToken tokenFromObject = this.GetCancellationTokenOnDestroy();
// An UniTask that never finishes
_ = NeverFinishAsync(tokenFromObject);
// A scene transitioni occurs heare, then the object will be destroyed and the CancellationToken will be canceled.
SceneManager.LoadScene("Next Scene");
C#Although we can write similar code with a CancellationTokenSource
and the OnDestroy
method, the code will be redundant.
private CancellationTokenSource cancellationTokenSource = new();
private void Start()
{
CancellationToken cancellationToken = cancellationTokenSource.Token;
_ = NeverFinishAsync(cancellationToken);
}
private void OnDestroy()
{
cancellationTokenSource?.Cancel();
cancellationTokenSource?.Dispose();
}
C#Assure an UniTask Finishes
We learned that we can cancel the process of an UniTask by passing a CancellationToken
to that UniTask. Now, you should consider a CancellationToken
as an essential parameter rather than an optional parameter given to an UniTask to enable the cancellation, unless you have any special reason. That’s because you must ensure that all UniTasks have finished, regardless of whether they were canceled or completed successfuly when they are no longer needed.
To understand why we must ensure an unnecessary UniTask finishes its process, take a look at an example.
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
public class SampleManager : MonoBehaviour
{
private static int count = 0;
private async UniTaskVoid Start()
{
count++;
_ = RepeatMessageAsync($"Message from scene {count}");
await UniTask.Delay(TimeSpan.FromSeconds(1));
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
private async UniTask RepeatMessageAsync(string message)
{
while (true)
{
Debug.Log(message);
await UniTask.Delay(TimeSpan.FromSeconds(1));
}
}
}
C#The RepeatMessageAsync
method is an UniTask that shows a specified message every 1 second indefinitely. In the Start
method, this method is called and the current scene is reloaded in 1 second. Additionally, the count
static field is defined in order to determine which scene the method is called in.
If you run the above code, you will get the following result.
As you can see from the result, even if a scene transition occurs, a process of an UniTask doesn’t finish. The number of running UniTasks increases indefinitely as a new scene is loaded. Resource leak defenitely occurs here, and hence the application may crash due to memory exhaustion.
The thing is that the lifespan of an UniTask has nothing to do with that of the GameObject in which the UniTask is called. Even if a new scene is loaded and a GameObject in which an UniTask is called is destroyed, the UniTask will not disappear. Therefore, we must ensure that all running UniTasks terminate when thery become unnecessary in order to avoid resource leak.
The above code can be modified as follows:
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
using UnityEngine.SceneManagement;
public class SampleManager : MonoBehaviour
{
private static int count = 0;
private async UniTaskVoid Start()
{
count++;
_ = RepeatMessageAsync($"Message from scene {count}", this.GetCancellationTokenOnDestroy());
await UniTask.Delay(TimeSpan.FromSeconds(1));
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
private async UniTask RepeatMessageAsync(string message, CancellationToken cancellationToken)
{
while (true)
{
Debug.Log(message);
await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: cancellationToken);
}
}
}
C#In this code, the RepeatMessageAsync
method can be canceled by a CancellationToken
, and it is given a CancellationToken
generated by the GetCancellationTokenOnDestroy
method is when called. Thus when a new scene is loaded, the SampleManager
object is destroyed and the CancellationToken
bound to that object is canceled, which ensures the UniTask that was called from the old scene terminates. As you can see from the result, an old process terminates when the scene switches.
Again, we must ensure that all running UniTasks terminate their processes when they are no longer needed. If any UniTask whose process will never finish exists, the number of running UniTask may be enormous, which leads the application to crush, or unintended behavior may be caused by UniTasks that exist unintentionally. Even if an UniTask was designed to run indefinately in a specific scene without cancellation, a CancellationToken
must be provided in order to ensure that it will be absolutely canceled when the scene finishes.
UniTask Tracker
Even if you strive to ensure all UniTasks are completed or canceled appropriately, it is understandable to let some UniTasks remain unintentionally. Here, you can check if there are any UniTasks that continue their processes unintentionally by UniTask Tracker.
Open UniTask Tracker by selecting Window/UniTaskTracker on the top menu, enable “Enable AutoReload” and “Enable Tracking,” and run the bad code and the good code.
The number of UniTasks with the “Pending” state increases for the bad code while an old UniTask transitions to the “Canceled” state and terminates at the same time an new UniTask with the “Pending” state appears for the good code.
You should use UniTask Tracker regularly to check if there are any UniTasks that don’t terminate appropriately when you utilize UniTask for your development.
Exercise
Cancel an UniTask with CancellationTokenSource
Modify the following program to cancel Tasks A and Task B when the spacekey is pressed and cancel Task C when the returnkey is pressed. You use the CancellationTokenSource
class to generate a CancellationToken
object.
The StartTaskAsync
method is an UniTask that never finishes until it is canceled. The UniTask.DelayFrame
method is an UniTask that inserts delay corresponding to a specified frame counts and provided by UniTask library.
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
_ = StartTaskAsync("Task A", default);
_ = StartTaskAsync("Task B", default);
_ = StartTaskAsync("Task C", default);
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("Spacekey pushed!");
}
if (Input.GetKeyDown(KeyCode.Return))
{
Debug.Log("Returnkey pushed!");
}
}
private async UniTask StartTaskAsync(string name, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (true)
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
}
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
throw;
}
}
}
C#Convert an UniTask into a CancellationToken
Modify the program in the previous question to cancel UniTasks with CancellationToken
objects obtained from UniTasks without ones from the CancellationTokenSource
instance. That is, you replace the process of deciding whether the key is being pressed in the Update
method with UniTasks, from which you obtain CancellationToken
objects.
Sample Answers
Cancel an UniTask with CanellationTokenSource
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
CancellationTokenSource spacekeyTokenSource = new();
CancellationTokenSource returnkeyTokenSource = new();
private void Start()
{
_ = StartTaskAsync("Task A", spacekeyTokenSource.Token);
_ = StartTaskAsync("Task B", spacekeyTokenSource.Token);
_ = StartTaskAsync("Task C", returnkeyTokenSource.Token);
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("Spacekey pushed!");
spacekeyTokenSource.Cancel();
}
if (Input.GetKeyDown(KeyCode.Return))
{
Debug.Log("Returnkey pushed!");
returnkeyTokenSource.Cancel();
}
}
private void OnDestroy()
{
spacekeyTokenSource?.Cancel();
spacekeyTokenSource?.Dispose();
returnkeyTokenSource?.Cancel();
returnkeyTokenSource?.Dispose();
}
private async UniTask StartTaskAsync(string name, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (true)
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
}
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
throw;
}
}
}
C#Maker sure to cancel the CancellationTokenSource
(to ensure that the unnecessary UniTasks stop) and dispose of it (because the CancellationTokenSource
class implements the IDisposable
interface).
Convert an UniTask to a CancellationToken
using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
public class SampleManager : MonoBehaviour
{
private void Start()
{
var spacekeyToken = WaitUntilSpacekeyPushed(this.GetCancellationTokenOnDestroy()).ToCancellationToken();
var returnkeyToken = WaitUntilReturnkeyPushed(this.GetCancellationTokenOnDestroy()).ToCancellationToken();
_ = StartTaskAsync("Task A", spacekeyToken);
_ = StartTaskAsync("Task B", spacekeyToken);
_ = StartTaskAsync("Task C", returnkeyToken);
}
private async UniTask WaitUntilSpacekeyPushed(CancellationToken cancellationToken)
{
while (!Input.GetKeyDown(KeyCode.Space))
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
}
}
private async UniTask WaitUntilReturnkeyPushed(CancellationToken cancellationToken)
{
while (!Input.GetKeyDown(KeyCode.Return))
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
}
}
private async UniTask StartTaskAsync(string name, CancellationToken cancellationToken)
{
try
{
Debug.Log($"{name} started...");
while (true)
{
await UniTask.DelayFrame(1, cancellationToken: cancellationToken);
}
}
catch (OperationCanceledException)
{
Debug.Log($"{name} canceled!");
throw;
}
}
}
C#Use the GetCancellationTokenOnDestroy
method when calling the two UniTasks that are converted into CancellationToken
s in order to ensure that these UniTasks terminate when they are no longer necessary.
Comments