Unity Async Tutorial: What's the Difference from Native .Net?
I won’t introduce what Async/Await is here; it’s similar to coroutines but more concise. This article assumes you have knowledge of modern asynchronous programming.
Let’s summarize Unity’s internal implementation to help clarify what exactly is happening. Simply put, Unity has made some internal adjustments to ensure two things when the caller is on the main thread: 1. The caller will not experience a thread switch, and 2. The called asynchronous function will not execute on another thread.
Unity’s Goals for the Async Pattern
The Async pattern is more modern and represents the future direction, but Unity currently does not intend to replace coroutines (IEnumerators) with Async. Its primary purpose is for operations like I/O tasks.
Unity Async Has Higher Performance Than Coroutines but Lower Concurrency
It’s suitable for use with singleton/main entity objects, such as cameras, UI, I/O methods, etc. For a small number of objects, Async performance is higher than coroutines. For code where a large number of Objects need to run in parallel, coroutines should still be used.
Performance Section
Prefer Returning Unity’s Wrapped Awaitable Over Task (Unity 6)
.Net’s Async uses Task, as shown below:
async Task function() {
await Task.Delay(1000);
}The native .Net Task creates a new object each time it’s returned, whereas Unity’s wrapped Awaitable objects are shared, resulting in lower GC pressure. Awaitable also has the advantage of “free” (no-cost) thread switching, as explained in the next section.
async Awaitable function() {
while(true) {
await Awaitable.NextFrameAsync();
}
}For methods that require repeated await operations, like the example code above, please use an Awaitable return value. Of course, for one-time await methods like I/O operations, it doesn’t matter.
Because Awaitable objects are shared (used by others after being awaited), they cannot be awaited repeatedly (as in the code below), as this will cause a deadlock.
async Awaitable function() {
while(true) {
var returned_awaitable = Awaitable.NextFrameAsync();
await returned_awaitable;
await returned_awaitable; //deadlock
}
}Unity Manages Async Context (Switching) Internally, Differently from .Net
The main concept of Async, resuming (Resume), refers to switching back and continuing execution of the code after await.
The “context” determines how the code after await is resumed. .Net uses the thread pool to switch between different asynchronous code segments. For example, the following .Net code:
using System;
async Task function() {
Console.WriteLine($"task start: Tid {Environment.CurrentManagedThreadId}");
await Task.Delay(1000);
Console.WriteLine($"task end: Tid {Environment.CurrentManagedThreadId}");
}
Console.WriteLine($"caller start, Tid{Environment.CurrentManagedThreadId}");
await function();
Console.WriteLine($"caller end, Tid{Environment.CurrentManagedThreadId}");Execution result:
caller start, Tid12
task start: Tid 12
task end: Tid 38
caller end, Tid38We can see that thread switching occurred for both the caller and the executor.
To ensure that methods called from the main thread execute only on the main thread, Unity has wrapped its own context method called UnitySynchronizationContext (this is internal implementation; we just need to know about it, not manage it).
So don’t worry about threading issues. Regardless of whether the return value is Awaitable or .Net’s Task, as long as it’s called from the main thread, it will be managed by UnitySynchronizationContext and guaranteed to execute on the main thread.
However, for Async functions returning Task, when resuming (Resume), they can only resume on the next frame rather than immediately. Additionally, Unity needs to perform an extra step to replace the .Net context. Therefore, Awaitable has higher performance.
Of course, Async functions called from other threads, which have no requirement to run on the main thread, will still use the .Net thread pool.
Usage Patterns
Fire-and-Forget Pattern
You can call an Async function without using await. The system will continue executing that Async function internally.
async Awaitable worker() {
await Awaitable.WaitForSecondsAsync(5);
Debug.Log($"worker end, frame {Time.frameCount}");
}
void caller() {
_ = worker();
Debug.Log($"caller end, frame {Time.frameCount}");
}Result:
caller end, frame 1
worker end, frame 292caller did not write await, but worker will still execute; it’s just not waited for completion.
In .Net, worker would continue executing in the thread pool. In Unity, it is continuously executed by the main thread loop.
This pattern is also commonly used to call Async functions from ordinary synchronous (Sync) functions.
Note: This method is equivalent to async void, executing at the top level (stack trace is lost). Exceptions in the code will be invisible, manifesting as if the code never executed. This doesn’t mean the pattern is unusable.
Unity Async Makes Multi-threaded Programming Convenient
This allows for easy implementation of multi-threaded code without needing Jobs, etc. It lets any code segment execute on a different thread.
If you wish to switch to a background thread pool (similar to .Net’s thread pool implementation) or switch to the main thread, you can specify it manually. For example:
async Awaitable function() {
await Awaitable.BackgroundThreadAsync();
...
return null;
}The code after this await line, until the function ends, will execute on the background .Net managed thread pool.
If the caller is on the main thread, the switch will not change the caller’s thread, which is very convenient. Note that switching requires at least one frame of time.
Switching to the main thread is done with await Awaitable.MainThreadAsync();, but it’s generally not necessary to call this.
Unfortunately, under WebGL (wasm), you cannot use .Net multi-threading, including any Task methods and Awaitable.BackgroundThreadAsync().
Async Functions Can Be Defined as Void, Convenient for Various event Callbacks
If you want a callback to execute your Async function, you don’t need to wrap it in a regular function to call your asynchronous function. You can use async void, similar to the following:
async void function(CallbackContext ctx) {
try{
...do something...
await Awaitable.WaitForSecondsAsync(5);
}catch (Exception e){
Debug.LogError(e);
}
}
void Start() {
InputAction.performed += function;
}Note that async void executes at the top level, so exceptions within the function will not be caught externally. You need to handle them yourself with a catch block.
Asynchronous Form of MonoBehaviour Events
MonoBehaviour events like Start, Update, etc., can be used in async mode, e.g., async Awaitable Start() {}. However, remember that the asynchronous Start or Awake will have Update called before they finish executing.
55a10c6