Cancel and Restart an Async Task in Unity

A method to handle complexities and reduce boilerplate

Cover image
by Tommy Leung on March 7th, 2020

If you've been migrating from coroutines to async/await then you've likely had to cancel an async task.

The CancellationToken approach is not quite as straight forward to use as StopCoroutine.

Canceled async tasks also do not stop immediately but will do so at its earliest convenience meaning we have to handle that in our logic.

This whole system does add quite a bit of extra boilerplate compared to coroutines.

But if your game is particularly performance critical or you want to yield execution from outside a MonoBehaviour without workarounds or you need asynchronous methods to return values then replacing coroutines with UniTask is still worth it.

The logic can get particularly complex if you have some async tasks that should only have one instance running at a time but are fired off as a response to evente.

And these events can fire multiple times before the running async task has finished so we need to stop the currently running task and then start a new one.

In this article, we'll walk you through one method to handle canceling and restarting async tasks.

Encapsulating the Task

First, we will wrap our task logic in a class to make it easier to reason about. This class will have a Run() method that kicks off the async work and two properties to store a CancellationTokenSource and the active UniTask.

Note that this article assumes the use of UniTask.

using UniRx.Async;

public class SelfCancelingTask
{
	private CancellationTokenSource cancellationTokenSource;
	private UniTask activeTask;

	public async UniTask<int> Run()
	{
		
	}
}

We will be using Delay to simulate some async task doing work for some amount of time.

You can convert it to do the actual async work needed in your project.

// add additional using statements
using UnityEngine;
using System.Threading;

// ...

public async UniTask<int> Run()
{
	// random amount of time
	int ms = Random.Range(1000, 3000);

	this.cancellationTokenSource = new CancellationTokenSource();

	// save a reference to the task
	this.activeTask = UniTask.Delay(ms, false, PlayerLoopTiming.Update, this.cancellationTokenSource.Token);

	// do the work and suppress exception if canceled
	// handle with isCanceled bool instead
	bool isCanceled = await this.activeTask.SuppressCancellationThrow();
	if (isCanceled)
	{
		return -1;
	}

	return ms;
}

Everything is easy-peasy on the first run but let's say this task is run based on keystroke events and it is called again a few frames later. 🤔

Handling Cancel and Restart

If Run() is called again before activeTask has finished then we'll need to cancel it and start a new task.

// add this property to class
private int cancelCount = 0;

// ...

public async UniTask<int> Run(int id)
{
	// check task is still running
	if (this.activeTask.Status == AwaiterStatus.Pending)
	{
		// request cancel
		this.cancellationTokenSource.Cancel();

		++this.cancelCount;

		// wait for it to get canceled
		await this.activeTask.SuppressCancellationThrow();

		--this.cancelCount;

		if (this.cancelCount > 0)
		{
			// we only want to continue for the last run
			return -1;
		}
	}

	// ...
}

Because canceling a task does not happen immediately, Run() could get called again before the task is canceled.

We are using cancelCount as a reference count and only starting a new task when cancelCount reaches 0. This ensures only 1 new task is started no matter how many times Run() was called before activeTask was canceled.

Notice that we await this.activeTask after calling Cancel().

Complete Run() Example

public async UniTask<int> Run(int id)
{
	// check task is still running
	if (this.activeTask.Status == AwaiterStatus.Pending)
	{
		// request cancel
		this.cancellationTokenSource.Cancel();

		++this.cancelCount;

		// wait for it to get canceled
		await this.activeTask.SuppressCancellationThrow();

		--this.cancelCount;

		if (this.cancelCount > 0)
		{
			// we only want to continue for the last run
			return -1;
		}
	}

	// random amount of time
	int ms = Random.Range(1000, 3000);

	this.cancellationTokenSource = new CancellationTokenSource();

	// save a reference to the task
	this.activeTask = UniTask.Delay(ms, false, PlayerLoopTiming.Update, this.cancellationTokenSource.Token);

	// do the work and suppress exception if canceled
	// handle with isCanceled bool instead
	bool isCanceled = await this.activeTask.SuppressCancellationThrow();
	if (isCanceled)
	{
		return -1;
	}

	return ms;
}

The Run() method can, in theory, be made generic or put in an abstract class for better reusability and reduced boilerplate.

Let me know in the comments below or tweet @SmartGameDev if this method does not work for you or if you found an edge case that I've missed! 🤨

Don't miss out on future time-saving Unity tips & techniques! Enter your email into the box below to subscribe.