The Complete Asynchronous Programming Primer for Unity Development

Written by dmitrii | Published 2023/03/30
Tech Story Tags: unity | c-sharp | asynchronous | game-development | unity-development | asynchronous-programming | programming | hackernoon-top-story | hackernoon-tr | hackernoon-ko | hackernoon-de | hackernoon-bn

TLDRIn this article, we’re going to talk about avoiding any such issues. We’ll recommend asynchronous programming techniques to perform these tasks in a separate thread, thus leaving the main thread free to perform other tasks. This will help ensure smooth and responsive gameplay, and (hopefully) satisfied gamers.via the TL;DR App

Some tasks in game development are not synchronous — they are asynchronous. This means they are not linearly executed within the game code. Some of these asynchronous tasks can require quite a long time to complete, while others are associated with intensive computations.

Some of the most common gaming asynchronous tasks are as follows:

  • Performing network requests

  • Loading scenes, resources, and other assets

  • Reading and writing files

  • Artificial intelligence for decision making

  • Long animation sequences

  • Processing large amounts of data

  • Pathfinding

Now, crucially, since all Unity code runs in one thread, any task like one of those mentioned above, if they were performed synchronously, would lead to the main thread being blocked, and thus, frame drops.

Hello everyone, my name is Dmitrii Ivashchenko and I am the Head of the Development Team at MY.GAMES. In this article, we’re going to talk about avoiding any such issues. We’ll recommend asynchronous programming techniques to perform these tasks in a separate thread, thus leaving the main thread free to perform other tasks. This will help ensure smooth and responsive gameplay, and (hopefully) satisfied gamers.

Coroutines

First up, let’s talk about coroutines. They were introduced in Unity in 2011, even before async / await appeared in .NET. In Unity, coroutines allow us to perform a set of instructions over multiple frames, instead of executing them all at once. They are similar to threads, but are lightweight and integrated into Unity's update loop, making them well suited for game development.

(By the way, historically speaking, coroutines were the first way to perform asynchronous operations in Unity, so most articles on the Internet are about them.)

To create a coroutine, you need to declare a function with the IEnumerator return type. This function can contain any logic you want the coroutine to execute.

To start a coroutine, you need to call the StartCoroutine method on a MonoBehaviour instance and pass the coroutine function as an argument:

public class Example : MonoBehaviour
{
    void Start()
    {
        StartCoroutine(MyCoroutine());
    }

    IEnumerator MyCoroutine()
    {
        Debug.Log("Starting coroutine");
		    yield return null;
		    Debug.Log("Executing coroutine");
		    yield return null;
		    Debug.Log("Finishing coroutine");
    }
}

There are several yield instructions available in Unity, such as WaitForSeconds, WaitForEndOfFrame, WaitForFixedUpdate, WaitForSecondsRealtime, WaitUntil as well as some others. It’s important to remember that using them leads to allocations, so they should be reused wherever possible.

For example, consider this method from the documentation:

IEnumerator Fade()
{
    Color c = renderer.material.color;
    for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
    {
        c.a = alpha;
        renderer.material.color = c;
        yield return new WaitForSeconds(.1f);
    }
}

With each iteration of the loop, a new instance of new WaitForSeconds(.1f) will be created. Instead of this, we can move creation outside of the loop and avoid allocations:

IEnumerator Fade()
{
    Color c = renderer.material.color;

		**var waitForSeconds = new WaitForSeconds(0.2f);**

    for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
    {
        c.a = alpha;
        renderer.material.color = c;
        yield return **waitForSeconds**;
    }
}

Another significant property to note is that yield return can be used with all the Async methods provided by Unity because AsyncOperations are descendants of YieldInstruction:

yield return SceneManager.LoadSceneAsync("path/to/scene.unity");

Some possible pitfalls of coroutines

This all being said, coroutines also have a few drawbacks to note:

  • It’s impossible to return the result of a long operation. You still need callbacks that will be passed to the coroutine and called when it is finished to extract any data from it.
  • A coroutine is strictly tied to the MonoBehaviour that launches it. If the GameObject is turned off or destroyed, the coroutine stops being processed.
  • The try-catch-finally structure cannot be used due to the presence of the yield syntax.
  • At least one frame will pass after the yield return before the next code starts to execute.
  • Allocation of the lambda and the coroutine itself

Promises

Promises are a pattern for organizing and making asynchronous operations more readable. They have become popular due to their use in many third-party JavaScript libraries, and, since ES6, have been implemented natively.

When using Promises, we immediately return an object from your asynchronous function. This allows the caller to wait for the resolution (or an error) of the operation.

Essentially, this makes it so that asynchronous methods can return values and “act” like synchronous methods: instead of returning the final value right away, they give a “promise” that they will return a value sometime in the future.

There are several Promises implementations for Unity:

The main way to interact with a Promise is via callback functions.

You can define a callback function that will be called when a Promise is resolved, and another callback function that will be called if the Promise is rejected. These callbacks receive the outcome of the asynchronous operation as arguments, which can then be used to perform further operations.

According to these specifications from the Promises/A+ organization, a Promise can be in one of three states:

  • Pending: the initial state, this means that the asynchronous operation is still in progress, and the outcome of the operation is not yet known.
  • Fulfilled (Resolved): the resolved state is accompanied by a value that represents the outcome of the operation.
  • Rejected: if the asynchronous operation fails for any reason, the Promise is said to be "rejected". The rejected state is accompanied by the reason for the failure.

More on Promises

Additionally, promises can be chained together, so that the outcome of one Promise can be used to determine the outcome of another Promise.

For example, you can create a Promise that fetches some data from a server, and then use that data to create another Promise that performs some calculation and other actions:

var promise = MakeRequest("https://some.api")
		.Then(response => Parse(response))
		.Then(result => OnRequestSuccess(result))
		.Then(() => PlaySomeAnimation())
		.Catch(exception => OnRequestFailed(exception));

Here’s an example of how to organize a method that performs an asynchronous operation:

public IPromise<string> MakeRequest(string url)
{
    // Create a new promise object
    var promise = new Promise<string>();
    // Create a new web client
    using var client = new WebClient();
    
    // Add a handler for the DownloadStringCompleted event
    client.DownloadStringCompleted += (sender, eventArgs) =>
    {
        // If an error occurred, reject the promise
        if (eventArgs.Error != null)
        {
            promise.Reject(eventArgs.Error);
        }
        // Otherwise, resolve the promise with the result
        else
        {
            promise.Resolve(eventArgs.Result);
        }
    };

    // Start the download asynchronously
    client.DownloadStringAsync(new Uri(url), null);

    // Return the promise
    return promise;
}

We could also wrap coroutines in a Promise:

void Start()
{
    // Load the scene and then show the intro animation
    LoadScene("path/to/scene.unity")
        .Then(() => ShowIntroAnimation())
        .Then( ... );
}

// Load a scene and return a promise
Promise LoadScene(string sceneName)
{
    // Create a new promise
    var promise = new Promise();
    // Start a coroutine to load the scene
    StartCoroutine(LoadSceneRoutine(promise, sceneName));
    // Return the promise
    return promise;
}

IEnumerator LoadSceneRoutine(Promise promise, string sceneName)
{
    // Load the scene asynchronously
    yield return SceneManager.LoadSceneAsync(sceneName);
    // Resolve the promise once the scene is loaded
    promise.Resolve();
}

And of course, you can organize any combination of promise execution order using ThenAll / Promise.All and ThenRace / Promise.Race:

// Execute the following two promises in sequence
Promise.Sequence(
    () => Promise.All( // Execute the two promises in parallel
        RunAnimation("Foo"),
        PlaySound("Bar")
    ),
    () => Promise.Race( // Execute the two promises in a race
        RunAnimation("One"),
        PlaySound("Two")
    )
);

The “unpromising” parts of promises

Despite all the convenience of use, promises also have some drawbacks:

  • Overhead: Creating Promises involves additional overhead compared to using other methods of asynchronous programming, like coroutines. In some cases, this can lead to decreased performance.
  • Debugging: Debugging Promises can be more difficult than debugging other asynchronous programming patterns. It can be difficult to trace the execution flow and identify the source of bugs.
  • Exception Handling: Exception handling can be more complex with Promises compared to other asynchronous programming patterns. It can be difficult to manage errors and exceptions that occur within a Promise chain.

Async/Await Tasks

The async/await feature has been a part of C# since version 5.0 (2012), and it was introduced in Unity 2017 with the implementation of the .NET 4.x runtime.

In the history of .NET, the following stages can be distinguished:

  1. EAP (Event-based Asynchronous Pattern): This approach is based on events that are triggered upon completion of an operation and a regular method that invokes this operation.
  2. APM (Asynchronous Programming Model): This approach is based on two methods. The BeginSmth method returns the IAsyncResult interface. The EndSmth method takes IAsyncResult; if the operation is not completed at the time of the EndSmth call, the thread is blocked.
  3. TAP (Task-based Asynchronous Pattern): This concept was improved by the introduction of async/await and types Task and Task<TResult>.

The previous approaches were rendered obsolete due to the success of the last approach.

To create an asynchronous method, the method must be marked with the keyword async, contain an await inside, and the return value must be Task, Task<T> or void (not recommended).

public async Task Example()
{
    SyncMethodA();
    await Task.Delay(1000); // the first async operation
		SyncMethodB();
    await Task.Delay(2000); // the second async operation
		SyncMethodC();
    await Task.Delay(3000); // the third async operation
}

In this example, the execution will take place like this:

  1. First, the code preceding the call to the first asynchronous operation (SyncMethodA) will be executed.
  2. The first asynchronous operation await Task.Delay(1000) is launched and expected to be executed. Meanwhile, the code to be called when the asynchronous operation is completed (the "continuation") will be saved.
  3. After the first asynchronous operation is completed, the "continuation" — the code until the next asynchronous operation (SyncMethodB) will start executing.
  4. The second asynchronous operation (await Task.Delay(2000)) is launched and expected to be executed. At the same time, the continuation — the code following the second asynchronous operation (SyncMethodC) will be preserved.
  5. After the completion of the second asynchronous operation, SyncMethodC will be executed, followed by execution and waiting for the third asynchronous operation await Task.Delay(3000).

This is a simplified explanation, as in fact async/await is syntactic sugar to allow for convenient calling of asynchronous methods and waiting for their completion.

You can also organize any combination of execution orders using WhenAll and WhenAny:

var allTasks = Task.WhenAll(
    Task.Run(() => { /* ... */ }), 
    Task.Run(() => { /* ... */ }), 
    Task.Run(() => { /* ... */ })
);
allTasks.ContinueWith(t =>
{
    Console.WriteLine("All the tasks are completed");
});

var anyTask = Task.WhenAny(
    Task.Run(() => { /* ... */ }), 
    Task.Run(() => { /* ... */ }), 
    Task.Run(() => { /* ... */ })
);
anyTask.ContinueWith(t =>
{
    Console.WriteLine("One of tasks is completed");
});

IAsyncStateMachine

The C# compiler transforms async/await calls into an IAsyncStateMachine state machine, which is a sequential set of actions that must be performed to complete the asynchronous operation.

Every time you call an await operation, the state machine completes its work and waits for the completion of that operation, after which it continues to execute the next operation. This allows asynchronous operations to be performed in the background without blocking the main thread, and also makes asynchronous method calls simpler and more readable.

Thus, the Example method is transformed into creating and initializing a state machine with the annotation [AsyncStateMachine(typeof(ExampleStateMachine))], and the state machine itself has a number of states equal to the number of await calls.

  • Example of the transformed method Example

    [AsyncStateMachine(typeof(ExampleStateMachine))]
    public /*async*/ Task Example()
    {
        // Create a new instance of the ExampleStateMachine class
        ExampleStateMachine stateMachine = new ExampleStateMachine();
        
        // Create a new AsyncTaskMethodBuilder and assign it to the taskMethodBuilder property of the stateMachine instance
        stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create();
        
        // Set the currentState property of the stateMachine instance to -1
        stateMachine.currentState = -1;
        
        // Start the stateMachine instance
        stateMachine.taskMethodBuilder.Start(ref stateMachine);
        
        // Return the Task property of the taskMethodBuilder
        return stateMachine.taskMethodBuilder.Task;
    }
    

  • Example of a generated state machine ExampleStateMachine

    [CompilerGenerated]
    private sealed class ExampleStateMachine : IAsyncStateMachine
    {
        public int currentState; 
        public AsyncTaskMethodBuilder taskMethodBuilder;
        private TaskAwaiter taskAwaiter;
    
        public int paramInt;
        private int localInt;
    
        void IAsyncStateMachine.MoveNext()
        {
            int num = currentState;
            try
            {
                TaskAwaiter awaiter3;
                TaskAwaiter awaiter2;
                TaskAwaiter awaiter;
                
                switch (num)
                {
                    default:
                        localInt = paramInt;  
                        // Call the first synchronous method
                        SyncMethodA();  
                        // Create a task awaiter for a delay of 1000 milliseconds
                        awaiter3 = Task.Delay(1000).GetAwaiter();  
                        // If the task is not completed, set the current state to 0 and store the awaiter
                        if (!awaiter3.IsCompleted) 
                        {
                            currentState = 0; 
                            taskAwaiter = awaiter3; 
                            // Store the current state machine
                            ExampleStateMachine stateMachine = this; 
                            // Await the task and pass the state machine
                            taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine); 
                            return;
                        }
                        // If the task is completed, jump to the label after the first await
                        goto Il_AfterFirstAwait; 
                    case 0: 
                        // Retrieve the awaiter from the taskAwaiter field
                        awaiter3 = taskAwaiter; 
                        // Reset the taskAwaiter field
                        taskAwaiter = default(TaskAwaiter); 
                        currentState = -1; 
                        // Jump to the label after the first await
                        goto Il_AfterFirstAwait; 
                    case 1: 
                        // Retrieve the awaiter from the taskAwaiter field
                        awaiter2 = taskAwaiter;
                        // Reset the taskAwaiter field
                        taskAwaiter = default(TaskAwaiter);
                        currentState = -1;
                        // Jump to the label after the second await
                        goto Il_AfterSecondAwait;
                    case 2: 
                        // Retrieve the awaiter from the taskAwaiter field
                        awaiter = taskAwaiter;
                        // Reset the taskAwaiter field
                        taskAwaiter = default(TaskAwaiter);
                        currentState = -1;
                        break;
                        
                    Il_AfterFirstAwait: 
                        awaiter3.GetResult(); 
                        // Call the second synchronous method
                        SyncMethodB(); 
                        // Create a task awaiter for a delay of 2000 milliseconds
                        awaiter2 = Task.Delay(2000).GetAwaiter(); 
                        // If the task is not completed, set the current state to 1 and store the awaiter
                        if (!awaiter2.IsCompleted) 
                        {
                            currentState = 1;
                            taskAwaiter = awaiter2;
                            // Store the current state machine
                            ExampleStateMachine stateMachine = this;
                            // Await the task and pass the state machine
                            taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine);
                            return;
                        }
                        // If the task is completed, jump to the label after the second await
                        goto Il_AfterSecondAwait;
    
                    Il_AfterSecondAwait:
                        // Get the result of the second awaiter
                        awaiter2.GetResult();
                        // Call the SyncMethodC
                        SyncMethodC(); 
                        // Create a new awaiter with a delay of 3000 milliseconds
                        awaiter = Task.Delay(3000).GetAwaiter(); 
                        // If the awaiter is not completed, set the current state to 2 and store the awaiter
                        if (!awaiter.IsCompleted)
                        {
                            currentState = 2;
                            taskAwaiter = awaiter;
                            // Set the stateMachine to this
                            ExampleStateMachine stateMachine = this;
                            // Await the task and pass the state machine
                            taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                            return;
                        }
                        break;
                }
    
                // Get the result of the awaiter
                awaiter.GetResult();
            }
            catch (Exception exception)
            {
                currentState = -2;
                taskMethodBuilder.SetException(exception);
                return;
            }
            currentState = -2;
            taskMethodBuilder.SetResult(); 
        }
    
        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { /*...*/ }
    }
    

SynchronizationContext

In the AwaitUnsafeOnCompleted call, the current synchronization context SynchronizationContext will be obtained. SynchronizationContext is a concept in C# used to represent a context that controls the execution of a set of asynchronous operations. It is used to coordinate the execution of code across multiple threads and to ensure that code is executed in a specific order. The main purpose of SynchronizationContext is to provide a way to control the scheduling and execution of asynchronous operations in a multithreaded environment.

In different environments, the SynchronizationContext has different implementations. For example, in .NET, there are:

  • WPF: System.Windows.Threading.DispatcherSynchronizationContext
  • WinForms: System.Windows.Forms.WindowsFormsSynchronizationContext
  • WinRT: System.Threading.WinRTSynchronizationContext
  • ASP.NET: System.Web.AspNetSynchronizationContext

Unity also has its own synchronization context, UnitySynchronizationContext, which enables us to use asynchronous operations with binding to the PlayerLoop API. The following code example shows how to rotate an object in each frame using Task.Yield():

private async void Start()
{
    while (true)
    {
        transform.Rotate(0, Time.deltaTime * 50, 0);
        await Task.Yield();
    }
}

Another example of using async/await in Unity to make a network request:

using UnityEngine;
using System.Net.Http;
using System.Threading.Tasks;

public class NetworkRequestExample : MonoBehaviour
{
    private async void Start()
    {
        string response = await GetDataFromAPI();
        Debug.Log("Response from API: " + response);
    }

    private async Task<string> GetDataFromAPI()
    {
        using (var client = new HttpClient())
        {
            var response = await client.GetStringAsync("https://api.example.com/data");
            return response;
        }
    }
}

Thanks to UnitySynchronizationContext, we can safely use UnityEngine methods (such as Debug.Log()) right after an asynchronous operation has been completed, since the execution of this code will continue in the main Unity thread.

TaskCompletitionSource<T>

This class allows you to manage a Task object. It was created to adapt old asynchronous methods to TAP, but it’s also very useful when we want to wrap a Task around some long-running operation that upon some event.

In the following example, the Task object inside taskCompletionSource will complete after 3 seconds from start, and we’ll get its result in the Update method:

using System.Threading.Tasks;
using UnityEngine;

public class Example : MonoBehaviour
{
    private TaskCompletionSource<int> taskCompletionSource;

    private void Start()
    {
        // Create a new TaskCompletionSource
        taskCompletionSource = new TaskCompletionSource<int>();
        // Start a coroutine to wait 3 seconds 
				// and then set the result of the TaskCompletionSource
        StartCoroutine(WaitAndComplete());
    }

    private IEnumerator WaitAndComplete()
    {
        yield return new WaitForSeconds(3);
        // Set the result of the TaskCompletionSource
        taskCompletionSource.SetResult(10);
    }

    private async void Update()
    {
        // Await the result of the TaskCompletionSource
        int result = await taskCompletionSource.Task;
        // Log the result to the console
        Debug.Log("Result: " + result);
    }
}

Cancellation Token

A Cancellation Token is used in C# to signal that a task or operation should be canceled. The token is passed to the task or operation, and the code within the task or operation can check the token periodically to determine whether the task or operation should be stopped. This allows for a clean and graceful cancellation of a task or operation, instead of just abruptly killing it.

Cancellation Tokens are commonly used in situations where a long-running task can be cancelled by the user, or if the task is no longer needed, like a cancel button in a user interface.

The overall pattern resembles the use of TaskCompletionSource. First, a CancellationTokenSource is created, then its Token is passed to the asynchronous operation:

public class ExampleMonoBehaviour : MonoBehaviour
{
	private CancellationTokenSource _cancellationTokenSource;

	private async void Start()
	{
		// Create a new CancellationTokenSource
		_cancellationTokenSource = new CancellationTokenSource();
		// Get the token from the CancellationTokenSource
		CancellationToken token = _cancellationTokenSource.Token;

		try
		{
			// Start a new Task and pass in the token
			await Task.Run(() => DoSomething(token), token);
		}
		catch (OperationCanceledException)
		{
			Debug.Log("Task was cancelled");
		}
	}

	private void DoSomething(CancellationToken token)
	{
		for (int i = 0; i < 100; i++)
		{
			// Check if the token has been cancelled
			if (token.IsCancellationRequested)
			{
				// Return if the token has been cancelled
				return;
			}

			Debug.Log("Doing something...");
			// Sleep for 1 second
			Thread.Sleep(1000);
		}
	}

	private void OnDestroy()
	{
		// Cancel the token when the object is destroyed
		_cancellationTokenSource.Cancel();
	}
}

When the operation is canceled, an OperationCanceledException will be thrown and the Task.IsCanceled property will be set to true.

New async features in Unity 2022.2

It’s important to note that Task objects are managed by the .NET runtime, not by Unity, and if the object executing the task is destroyed (or if the game exits play mode in the editor), the task will continue to run as Unity has no means to cancel it.

You always need to accompany await Task with the corresponding CancellationToken. This leads to some redundancy of code, and in Unity 2022.2 built-in tokens at the MonoBehaviour level and the entire Application level appeared.

Let's see how the previous example changes when using the destroyCancellationToken of the MonoBehaviour object:

using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class ExampleMonoBehaviour : MonoBehaviour
{
	private async void Start()
	{
		// Get the cancellation token from the MonoBehaviour
		CancellationToken token = this.destroyCancellationToken;

		try
		{
			// Start a new Task and pass in the token
			await Task.Run(() => DoSomething(token), token);
		}
		catch (OperationCanceledException)
		{
			Debug.Log("Task was cancelled");
		}
	}

	private void DoSomething(CancellationToken token)
	{
		for (int i = 0; i < 100; i++)
		{
			// Check if the token has been cancelled
			if (token.IsCancellationRequested)
			{
				// Return if the token has been cancelled
				return;
			}

			Debug.Log("Doing something...");
			// Sleep for 1 second
			Thread.Sleep(1000);
		}
	}
}

We no longer need to manually create a CancellationTokenSource and complete the task in the OnDestroy method. For tasks not associated with a particular MonoBehaviour, we can use UnityEngine.Application.exitCancellationToken. This will terminate the task when exiting Play Mode (in Editor) or when quitting the application.

UniTask

Despite the convenience of using and the capabilities provided by .NET Tasks, they have significant drawbacks when used in Unity:

  • Task objects are too cumbersome and cause many allocations.
  • Task is not matched to Unity threading (single-thread).

The UniTask library bypasses these restrictions without using threads or SynchronizationContext. It achieves the absence of allocations by using the UniTask<T> struct-based type.

UniTask requires the .NET 4.x scripting runtime version, with Unity 2018.4.13f1 being the official lowest supported version.

Also you can convert all the AsyncOperations to UnitTask with extension methods:

using UnityEngine;
using UniTask;

public class AssetLoader : MonoBehaviour
{
    public async void LoadAsset(string assetName)
    {
        var loadRequest = Resources.LoadAsync<GameObject>(assetName);
        await loadRequest.AsUniTask();

        var asset = loadRequest.asset as GameObject;
        if (asset != null)
        {
            // Do something with the loaded asset
        }
    }
}

In this example, the LoadAsset method uses Resources.LoadAsync to load an asset asynchronously. The AsUniTask method is then used to convert the AsyncOperation returned by LoadAsync into a UniTask, which can be awaited.

As before, you can organize any combination of execution order using UniTask.WhenAll and UniTask.WhenAny:

using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class Example : MonoBehaviour
{
    private async void Start()
    {
        // Start two Tasks and wait for both to complete
        await UniTask.WhenAll(Task1(), Task2());

        // Start two Tasks and wait for one to complete
        await UniTask.WhenAny(Task1(), Task2());
    }

    private async UniTask Task1()
    {
        // Do something
    }

    private async UniTask Task2()
    {
        // Do something
    }
}

In UniTask, there is another implementation of SynchronizationContext called UniTaskSynchronizationContext which can be used to replace UnitySynchronizationContext for better performance.

Awaitable API

In the first alpha version of Unity 2023.1, the Awaitable class was introduced. Awaitable Coroutines are async/await-compatible Task-like types designed to run in Unity. Unlike .NET Tasks, they are managed by the engine, not the runtime.

private async Awaitable DoSomethingAsync()
{
	 // awaiting built-in events
   await Awaitable.EndOfFrameAsync();
   await Awaitable.WaitForSecondsAsync();

	 // awaiting .NET Tasks
	 await Task.Delay(2000, destroyCancellationToken);
   await Task.Yield();

	 // awaiting AsyncOperations
   await SceneManager.LoadSceneAsync("path/to/scene.unity");

   // ...
}

They can be awaited and used as the return type of an async method. Compared to System.Threading.Tasks, they are less sophisticated but take performance-enhancing shortcuts based on Unity-specific assumptions.

Here are the main differences compared to .NET Tasks:

  • The Awaitable object can only be awaited once; it cannot be awaited by multiple async functions.
  • Awaiter.GetResults() won't block until completion. Calling it before the operation is finished is undefined behavior.
  • Never capture an ExecutionContext. For security reasons, .NET Tasks capture execution contexts when awaiting in order to propagate impersonation contexts across asynchronous calls.
  • Never capture SynchronizationContext. Coroutine continuations are executed synchronously from the code that raises the completion. In most cases, this will be from the Unity main frame.
  • Awaitables are pooled objects to prevent excessive allocations. These are reference types, so they can be referenced across different stacks, copied efficiently, and so on. The ObjectPool has been improved to avoid Stack<T> bounds checks in typical get/release sequences generated by async state machines.

To obtain the result of a lengthy operation, you can use the Awaitable<T> type. You can manage the completion of an Awaitable using AwaitableCompletionSource and AwaitableCompletionSource<T>, similar to TaskCompletitionSource:

using UnityEngine;
using Cysharp.Threading.Tasks;

public class ExampleBehaviour : MonoBehaviour
{
    private AwaitableCompletionSource<bool> _completionSource;

    private async void Start()
    {
        // Create a new AwaitableCompletionSource
        _completionSource = new AwaitableCompletionSource<bool>();

        // Start a coroutine to wait 3 seconds
        // and then set the result of the AwaitableCompletionSource
        StartCoroutine(WaitAndComplete());

        // Await the result of the AwaitableCompletionSource
        bool result = await _completionSource.Awaitable;
        // Log the result to the console
        Debug.Log("Result: " + result);
    }

    private IEnumerator WaitAndComplete()
    {
        yield return new WaitForSeconds(3);
        // Set the result of the AwaitableCompletionSource
        _completionSource.SetResult(true);
    }
}

Sometimes it’s necessary to perform massive calculations that can lead to game freezes. For this, it is better to use Awaitable methods: BackgroundThreadAsync() and MainThreadAsync(). They allow you to exit the main thread and return to it.

private async Awaitable DoCalculationsAsync()
{
		// Awaiting execution on a ThreadPool background thread.
		await Awaitable.BackgroundThreadAsync();

		var result = PerformSomeHeavyCalculations();

		// Awaiting execution on the Unity main thread.
		await Awaitable.MainThreadAsync();

		// Using the result in main thread
		Debug.Log(result);
}

This way, Awaitables eliminate the drawbacks of using .NET Tasks and also allow for awaiting PlayerLoop events and AsyncOperations.

Conclusion

As we can see, with the development of Unity, there are more and more tools for organizing asynchronous operations:

Unity

Coroutines

Promises

.NET Tasks

UniTask

Built-in Cancellation Tokens

Awaitable API

5.6

2017.1

2018.4

2022.2

2023.1

We have considered all the main ways of asynchronous programming in Unity. Depending on the complexity of your task and the version of Unity you are using, you can use a wide range of technologies from Coroutines and Promises to Tasks and Awaitables, to ensure a smooth and seamless gameplay in your games. Thanks for reading, and we await your next masterpieces.


Written by dmitrii | Crafting mobile games and robust backend systems for over a decade
Published by HackerNoon on 2023/03/30