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 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. coroutines. (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 return type. This function can contain any logic you want the coroutine to execute. IEnumerator To start a coroutine, you need to call the method on a instance and pass the coroutine function as an argument: StartCoroutine MonoBehaviour 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 , , , , as well as some others. It’s important to remember that using them leads to allocations, so they should be reused wherever possible. WaitForSeconds WaitForEndOfFrame WaitForFixedUpdate WaitForSecondsRealtime WaitUntil 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 will be created. Instead of this, we can move creation outside of the loop and avoid allocations: new WaitForSeconds(.1f) 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 can be used with all the methods provided by Unity because s are descendants of : yield return Async AsyncOperation 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 that launches it. If the is turned off or destroyed, the coroutine stops being processed. MonoBehaviour GameObject The structure cannot be used due to the presence of the yield syntax. try-catch-finally At least one frame will pass after the before the next code starts to execute. yield return Allocation of the lambda and the coroutine itself 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. Promises 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: C-Sharp-Promise UnityFx.Async C# Promises uPromise 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 from he Promises/A+ organization, a Promise can be in one of three states: specifications t : the initial state, this means that the asynchronous operation is still in progress, and the outcome of the operation is not yet known. Pending ( ): the resolved state is accompanied by a value that represents the outcome of the operation. Fulfilled Resolved : 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. Rejected 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 / and / : ThenAll Promise.All 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: : Creating Promises involves additional overhead compared to using other methods of asynchronous programming, like coroutines. In some cases, this can lead to decreased performance. Overhead : 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. Debugging : 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. Exception Handling 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: (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. EAP (Asynchronous Programming Model): This approach is based on two methods. The method returns the interface. The method takes ; if the operation is not completed at the time of the call, the thread is blocked. APM BeginSmth IAsyncResult EndSmth IAsyncResult EndSmth (Task-based Asynchronous Pattern): This concept was improved by the introduction of async/await and types and . TAP Task 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 , contain an inside, and the return value must be , or (not recommended). async await Task Task<T> void 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: First, the code preceding the call to the first asynchronous operation ( ) will be executed. SyncMethodA The first asynchronous operation is launched and expected to be executed. Meanwhile, the code to be called when the asynchronous operation is completed (the "continuation") will be saved. await Task.Delay(1000) After the first asynchronous operation is completed, the "continuation" — the code until the next asynchronous operation ( ) will start executing. SyncMethodB The second asynchronous operation ( ) is launched and expected to be executed. At the same time, the continuation — the code following the second asynchronous operation ( ) will be preserved. await Task.Delay(2000) SyncMethodC After the completion of the second asynchronous operation, will be executed, followed by execution and waiting for the third asynchronous operation . SyncMethodC 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 and : WhenAll 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 compiler transforms async/await calls into an state machine, which is a sequential set of actions that must be performed to complete the asynchronous operation. C# IAsyncStateMachine 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 method is transformed into creating and initializing a state machine with the annotation , and the state machine itself has a number of states equal to the number of await calls. Example [AsyncStateMachine(typeof(ExampleStateMachine))] 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 call, the current synchronization context will be obtained. 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. AwaitUnsafeOnCompleted SynchronizationContext SynchronizationContext In different environments, the has different implementations. For example, in .NET, there are: SynchronizationContext : WPF System.Windows.Threading.DispatcherSynchronizationContext : WinForms System.Windows.Forms.WindowsFormsSynchronizationContext : WinRT System.Threading.WinRTSynchronizationContext : ASP.NET System.Web.AspNetSynchronizationContext also has its own synchronization context, , 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 : Unity UnitySynchronizationContext 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 , we can safely use methods (such as ) right after an asynchronous operation has been completed, since the execution of this code will continue in the main Unity thread. UnitySynchronizationContext UnityEngine Debug.Log() TaskCompletitionSource<T> This class allows you to manage a object. It was created to adapt old asynchronous methods to TAP, but it’s also very useful when we want to wrap a around some long-running operation that upon some event. Task Task In the following example, the object inside will complete after 3 seconds from start, and we’ll get its result in the method: Task taskCompletionSource Update 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 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 Token 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 . First, a is created, then its is passed to the asynchronous operation: TaskCompletionSource CancellationTokenSource Token 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 will be thrown and the property will be set to . OperationCanceledException Task.IsCanceled true New async features in Unity 2022.2 It’s important to note that 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. Task You always need to accompany with the corresponding . This leads to some redundancy of code, and in Unity 2022.2 built-in tokens at the level and the entire level appeared. await Task CancellationToken MonoBehaviour Application Let's see how the previous example changes when using the of the object: destroyCancellationToken MonoBehaviour 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 and complete the task in the method. For tasks not associated with a particular , we can use . This will terminate the task when exiting Play Mode (in Editor) or when quitting the application. CancellationTokenSource OnDestroy MonoBehaviour UnityEngine.Application.exitCancellationToken UniTask Despite the convenience of using and the capabilities provided by .NET Tasks, they have significant drawbacks when used in Unity: objects are too cumbersome and cause many allocations. Task is not matched to Unity threading (single-thread). Task The library bypasses these restrictions without using threads or . It achieves the absence of allocations by using the struct-based type. UniTask SynchronizationContext UniTask<T> 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 to with extension methods: AsyncOperations UnitTask 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 method uses to load an asset asynchronously. The method is then used to convert the returned by into a , which can be awaited. LoadAsset Resources.LoadAsync AsUniTask AsyncOperation LoadAsync UniTask As before, you can organize any combination of execution order using and : UniTask.WhenAll 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 called which can be used to replace for better performance. SynchronizationContext UniTaskSynchronizationContext UnitySynchronizationContext Awaitable API In the first alpha version of Unity 2023.1, the 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. Awaitable 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 , they are less sophisticated but take performance-enhancing shortcuts based on Unity-specific assumptions. System.Threading.Tasks Here are the main differences compared to .NET Tasks: The object can only be awaited once; it cannot be awaited by multiple async functions. Awaitable won't block until completion. Calling it before the operation is finished is undefined behavior. Awaiter.GetResults() Never capture an . For security reasons, .NET Tasks capture execution contexts when awaiting in order to propagate impersonation contexts across asynchronous calls. ExecutionContext Never capture . Coroutine continuations are executed synchronously from the code that raises the completion. In most cases, this will be from the Unity main frame. SynchronizationContext 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 has been improved to avoid bounds checks in typical get/release sequences generated by async state machines. ObjectPool Stack<T> To obtain the result of a lengthy operation, you can use the type. You can manage the completion of an using and similar to : Awaitable<T> Awaitable AwaitableCompletionSource AwaitableCompletionSource<T> , 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: and . They allow you to exit the main thread and return to it. BackgroundThreadAsync() MainThreadAsync() 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.