🌐 Translate:
Table of contents

In 2025, writing asynchronous code with C# Async Tasks is a skill Unity developers can no longer avoid. Unity’s support for C# async tasks goes back as far as Unity 2017, although fewer people used it at the time. This post records my experiments implementing a MyTask class and using it in Unity to understand how C# achieves asynchronous execution. I learned from Stephen Toub’s Deep .NET: Writing async/await from scratch in C# with Stephen Toub and Scott Hanselman and his post How Async/Await Really Works in C#.

Warm Up


This section briefly reviews synchronous and asynchronous execution and the syntax of Coroutines and Async Tasks. If you already understand the difference between them and are only curious about how C# Async Tasks work, skip ahead.

Synchronous vs. asynchronous execution

For Unity developers, asynchronous execution is not unusual. We have long used Unity’s purpose-built Coroutine class for asynchronous work and logic that spans multiple frames.

Here is a simple example that changes an object’s opacity from 1 to 0 without a Coroutine:

[ContextMenu("Perform Fading Synchronously")]
public void PerformFade()
{
    Debug.Log("Start");
    Debug.Log("====Code executes synchronously; the main thread executes other code only after Fade() completes====");

    Fade();

    Debug.Log("Exit");
}

public void Fade()
{
    Color c = _renderer.material.color;
    for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
    {
        c.a = alpha;
        _renderer.material.color = c;
    }
    Debug.Log("Fading Completed");
}

128BD5FD-9B94-444E-A30A-AF69C455C043.png

Exit is printed only after Fade() finishes. The user cannot see the opacity transition from 1 to 0 because Unity’s main thread waits for this code to finish before rendering the frame; it cannot do anything else in the meantime.

With a Coroutine, the opacity can change by -0.1 each frame, spreading the transition across ten frames.

[ContextMenu("Perform Fading With Coroutine")]
public void PerformFadeCoroutine()
{
    Debug.Log("Start");
    Debug.Log("====Code execution won't wait for the Coroutine to complete====");

    StartCoroutine(FadeCoroutine());

    Debug.Log("Exit");
}

public IEnumerator FadeCoroutine()
{
    Color c = _renderer.material.color;
    for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
    {
        c.a = alpha;
        _renderer.material.color = c;
        yield return new WaitForEndOfFrame();
    }
    Debug.Log("Fading completed");
}

3EE4B961-8393-4FAC-9172-6E308C18686E.png

The PerformFadeCoroutine() function finishes immediately after printing its logs, while the Coroutine continues asynchronously until the fade completes after ten frames.

Unity Coroutine vs. Async Task

If Coroutines are so useful, why do we need Async Tasks? The main reason, in my opinion, is that StartCoroutine returns void. It has no return value, so it is difficult to obtain a Coroutine’s result directly from code. We end up adding flag variables for different states and passing callbacks into the Coroutine for the logic that follows each state, which makes the logic difficult to maintain and extend.

Consider a popup dialog that plays an animation and cannot be interacted with until the animation completes. The Confirm and Cancel buttons perform different actions.

Using a Coroutine

public void PerformAnimatedDialog()
{
    StartCoroutine(MyAnimatedDialog(
        () =>
        {
            // Something to do after the dialog opens.
        },
        () =>
        {
            // Logic for clicking Confirm.
        },
        () =>
        {
            // Logic for clicking Cancel.
        }));

    PlayDialogAudio();
}

public IEnumerator MyAnimatedDialog(Action onAnimationCompleted, Action onConfirm, Action onCancel)
{
    var dialog = Instantiate(_dialogPrefab).GetComponent<MyDialog>();
    var animator = dialog.GetComponent<Animator>();

    yield return new WaitUntil(() => animator.GetCurrentAnimatorStateInfo(0).IsName("Open") &&
                                     animator.GetCurrentAnimatorStateInfo(0).normalizedTime >= 1.0f);

    // Only allow button clicks after the animation completes.
    dialog.ConfirmButton.onClick.AddListener(() => onConfirm());
    dialog.CancelButton.onClick.AddListener(() => onCancel());

    onAnimationCompleted?.Invoke();
}

We need callbacks for every state. To keep callbacks from interfering with each other, we may even need additional flags in the script.

💡 Imagine that a button in this popup could open another popup, or that the opening animation could be canceled halfway through. The logic would become much more complicated, and the next programmer would scatter breakpoints and logs through the IDE to reconstruct the callback order.

Using an Async Task

public async Task PerformAnimatedDialogAsync()
{
    var onConfirmTcs = new TaskCompletionSource<bool>();
    var onCancelTcs = new TaskCompletionSource<bool>();

    var buttonTasks = new List<Task> { onConfirmTcs.Task, onCancelTcs.Task };

    var dialog = Instantiate(_dialogPrefab).GetComponent<MyDialog>();
    PlayDialogAudio();

    // Wait for the popup animation.
    await dialog.PopupAnimationTask();
    dialog.RegisterButtons(onConfirmTcs, onCancelTcs);

    // Wait until either button is clicked.
    var waitButtonsTask = Task.WhenAny(buttonTasks);
    await waitButtonsTask;

    dialog.UnregisterButtons();

    if (waitButtonsTask.Result == onCancelTcs.Task)
    {
        // Implement the behavior for clicking Cancel.
    }
    else
    {
        // Implement the behavior for clicking Confirm.
    }
}

Here the popup animation is a Task, and the logic after each button click is no longer passed as a callback; each button is an independent Task.

The execution order is:

  1. Wait for PopupAnimationTask to complete, then register the button events.
  2. Wait for either button Task (waitButtonsTask), then unregister the button events.
  3. Use waitButtonsTask.Result to determine which button was clicked and run the corresponding logic.

The method itself also has a Task return value. If another UI calls this popup, it can inspect the Task’s state to determine whether the popup has completed without relying on callbacks.

💡 To cancel a Task (or several Tasks chained together), use the CancellationToken from a CancellationTokenSource.

How C# Async Tasks work


The implementation has three parts: a Thread Pool, a Task, and Async/Await, which enables asynchronous execution.

Thread Pool

Before writing the Task, we need a custom Thread Pool, MyThreadPool:

public static class MyThreadPool
{
  private static readonly BlockingCollection<(Action, ExecutionContext)> s_workItems = new();
  public static void QueueUserWorkItem(Action action) => s_workItems.Add((action, ExecutionContext.Capture()));

  static MyThreadPool()
  {
     for (int i = 0; i < Environment.ProcessorCount; i++)
     {
        new Thread(() =>
        {
           while (true)
           {
              (Action workItem,ExecutionContext? context) = s_workItems.Take();
              if (context is null)
              {
                 workItem();
              }
              else
              {
                 ExecutionContext.Run(context, state=> ((Action)state!).Invoke(), workItem);
              }
           }
        })
        { IsBackground = true }.Start();
     }
  }
}

The class creates N worker threads. Each checks whether the BlockingCollection s_workItems contains an item. If it does, a worker executes that item. Items are callbacks enqueued by Tasks or code run through Task.Run.

💡 A BlockingCollection is a thread-safe collection that supports access from different threads. If its count is zero, the thread blocks until an item is available.

Implementing Task

A Task marks whether work has completed and stores a callback to execute afterward. A minimal Task has this API:

public class MyTask
{
    private void Complete(Exception? exception);

    public bool IsCompleted();
    // Mark the task complete immediately.
    public void SetResult() => Complete(null);
    public void SetException(Exception exception) => Complete(exception);

    // Block execution until the task completes.
    public void Wait();
    // Set a continuation callback to invoke when the task completes.
    public void ContinueWith(Action action);

    private bool _completed;
    private Exception? _exception;
    private Action? _continuation;
    private ExecutionContext? _context;
}

IsCompleted checks whether the Task has completed. SetResult and SetException complete the Task; SetException also propagates an exception. Wait pauses the current thread until the Task completes. It is not the “pause, then resume” mechanism of asynchronous execution; it is a forced wait for code that wants to read a result synchronously. ContinueWith accepts a delegate to invoke when the Task completes.

💡 Task callbacks are executed through C#’s Thread Pool. The Complete and ContinueWith implementations below enqueue callbacks into the pool.

Complete

private void Complete(Exception? exception)
{
   lock (this)
   {
      if (_completed)
         throw new InvalidOperationException("Tried to complete an already completed task!!");

      _completed = true;
      _exception = exception;
      if (_continuation is not null)
      {
         MyThreadPool.QueueUserWorkItem(() =>
         {
            if (_context is null)
            {
               _continuation();
            }
            else
            {
               ExecutionContext.Run(_context, state=> ((Action)state!).Invoke(), _continuation);
            }
         });
      }
   }
}

Complete (called by either SetResult or SetException) marks the Task as complete. A Task can complete only once; calling Complete again throws an exception. After completion, if a continuation exists, the Thread Pool executes it.

💡 lock (this) is used here to prevent races, but it is not ideal: it locks on a public object, so external code could acquire the same lock and cause a deadlock. The official C# Task uses a lock-free implementation.

ContinueWith

public void ContinueWith(Action action)
{
   lock (this)
   {
      if (_completed)
      {
         MyThreadPool.QueueUserWorkItem(action);
      }
      else
      {
         _continuation = action;
         _context = ExecutionContext.Capture();
      }
   }
}

The flow is simple:

  • If the Task has completed, send the callback directly to the Thread Pool.
  • If it has not completed, store the callback in _continuation; Complete runs it automatically later.

Wait

public void Wait()
{
   ManualResetEventSlim? mres = null;
   lock (this)
   {
      if (!_completed)
      {
         mres = new ManualResetEventSlim();
         // mres.Set() is cached in the Task through ContinueWith
         // and executed by Complete when the Task finishes.
         ContinueWith(mres.Set);
      }
   }
   mres?.Wait();
   if (_exception != null)
   {
      ExceptionDispatchInfo.Throw(_exception);
   }
}

Wait checks whether the Task has completed. If not, it registers ManualResetEventSlim.Set() as the continuation. The current thread remains blocked until Set() is called; once the Task completes, execution continues.

💡 This also reveals a deadlock risk: pausing the current thread can deadlock if the Task needs that same thread to perform work before it can complete. There is an example later.

ExecutionContext

💡 We will return to ExecutionContext after implementing the complete Async Task, because it needs to be explained together with SynchronizationContext. For now, remember that it exists.

Task.Delay

We can implement a MyTask version of Delay with C#’s Timer:

// Create a MyTask and complete it after the specified delay.
public static MyTask Delay(int delayTime)
{
    MyTask t = new();
    new Timer(_ => t.SetResult()).Change(delayTime, -1);
    return t;
}

Testing Delay and Wait

[ContextMenu("Demo Delay With Synchronous Waiting")]
public void DelayWithTask()
{
   void DemoDelayTasks()
   {
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("Hello"));
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("World"));
      MyTask.Delay(1000).Wait();
      Debug.Log(LogWithTimestamp("How's your day?"));
      MyTask.Delay(1000).Wait();
      Debug.Log("======end======");
   }

   new Thread(() =>
   {
      Debug.Log("======start======");
      DemoDelayTasks();
      Debug.Log("======end======");
   }).Start();
}

FA5A76B4-5A32-4766-8C5F-C17FE6B28CFD.png

Each string is printed, then the program waits one second. The entire process is synchronous:

  • Thread line 1: ======start======
  • Thread line 2: wait for all Tasks in DemoDelayTasks in sequence
  • Thread line 3: ======end======

We still have not implemented true asynchronous execution. This waiting is synchronous on the same thread; while each MyTask.Delay runs, the current thread cannot do anything else.

💡 The example wraps all execution in a new Thread() so that calling Wait does not freeze Unity’s main thread and hide the logs. In practice, we almost never call Wait directly.

Chaining multiple Tasks—the requirement

MyTask.Wait currently applies to one Task. To make Task A depend on Tasks B and C—waiting for all three when waiting for A—ContinueWith and its return value must support returning a Task instead of void.

Another ContinueWith overload

public MyTask ContinueWith(Func<MyTask> action)
{
    MyTask t = new();
    Action callback = () =>
    {
        try
        {
            MyTask next = action();
            next.ContinueWith(delegate
            {
                if (next._exception is not null)
                    t.SetException(next._exception);
                else
                    t.SetResult();
            });
        }
        catch (Exception e)
        {
            Debug.Log("catch exception in continue with : " + e);
            t.SetException(e);
        }
    };

    lock (this)
    {
        if (_completed)
            MyThreadPool.QueueUserWorkItem(callback);
        else
        {
            _continuation = callback;
            _context = ExecutionContext.Capture();
        }
    }
    return t;
}

Chaining example

public void DemoTaskChain()
{
   new Thread(() =>
   {
      Debug.Log("======start======");
      Debug.Log(LogWithTimestamp("Hello"));
      MyTask.Delay(1000).ContinueWith(delegate
      {
         Debug.Log(LogWithTimestamp("World"));
         return MyTask.Delay(1000).ContinueWith(delegate
         {
            Debug.Log(LogWithTimestamp("How's your day?"));
         });
      }).Wait();
      Debug.Log("======end======");
   }).Start();
}

FA5A76B4-5A32-4766-8C5F-C17FE6B28CFD.png

Calling Wait on the first Task keeps the current thread paused until all Tasks finish.

Task.Run

Next, implement the common Task.Run operation:

public static MyTask Run(Action action)
{
    MyTask t = new();
    MyThreadPool.QueueUserWorkItem(() =>
    {
        try
        {
            action();
        }
        catch (Exception e)
        {
            t.SetException(e);
            return;
        }
        t.SetResult();
    });
    return t;
}

This lets the delegate run on a thread in MyThreadPool.

[ContextMenu("Demo Task Run")]
public void DemoTaskRun()
{
   new Thread(PrintNumWithThreadPool).Start();

   void PrintNumWithThreadPool()
   {
      Debug.Log("======Start======");
      for (int i = 0; i < 10; i++)
      {
         int value = i;
         // Because Wait() is called, the current thread waits synchronously
         // for each Task.Run before entering the next loop.
         MyTask.Run(() => Debug.Log(value)).Wait();
      }
      Debug.Log("======end======");
   }
}

FBA7D090-C84A-4CD7-A9A8-87458509873B.png

The numbers appear in order because each Task.Run is enqueued only after the previous one completes.

Task.WhenAll

If order does not matter and we want to fire every Task.Run at once and receive a notification after all Tasks finish, implement Task.WhenAll.

public static MyTask WhenAll(List<MyTask> tasks)
{
    MyTask t = new();
    if (tasks.Count == 0)
    {
        t.SetResult();
    }
    else
    {
        int remaining = tasks.Count;
        Action continuation = () =>
        {
            // Complete WhenAll only when the remaining count reaches zero.
            if (Interlocked.Decrement(ref remaining) == 0)
                t.SetResult();
        };
        foreach (var task in tasks)
            task.ContinueWith(continuation);
    }
    return t;
}

All callbacks execute on Thread Pool threads, so Interlocked is required when checking and modifying the shared count to make the operation thread-safe and avoid a race condition.

public void DemoTaskRun()
{
   new Thread(PrintNumWithThreadPool).Start();

   void PrintNumWithThreadPool()
   {
      Debug.Log("======Start======");
      var allTasks = new List<MyTask>();
      // Fire every MyTask.Run and store each Task in the list.
      for (int i = 0; i < 10; i++)
      {
         int value = i;
         allTasks.Add(MyTask.Run(() => Debug.Log(value)));
      }
      // Wait for every Task in allTasks to finish.
      MyTask.WhenAll(allTasks).Wait();
      // This is equivalent to waiting on every Task individually,
      // except that we wait for only the single WhenAll Task.
      Debug.Log("======end======");
   }
}

DE3946A5-A4CD-475C-AC15-D7E1BA7F86B4.png

The numbers are no longer ordered; they reflect the execution order of the Thread Pool workers. The final end is printed after every MyTask.Run finishes.

How Async/Await works


At this point I still have not implemented true asynchronous execution. Wait forces a thread to wait for a Task, preventing it from doing anything else. That is synchronous. Unity’s Coroutine runs on the main thread without blocking it. How does C#’s await avoid stopping the entire thread?

Iterators save the day

To support asynchronous execution, review C#’s IEnumerator and IEnumerable:

public static IEnumerable<int> Fib()
{
    int prev = 0, next = 1;
    yield return prev;
    yield return next;
    while (true)
    {
        int sum = prev + next;
        yield return sum;
        prev = next;
        next = sum;
    }
}

public static void PrintFibonacci()
{
    using IEnumerator<int> e = Fib().GetEnumerator();
    while (e.MoveNext())
    {
        int i = e.Current;
        if (i > 100) break;
        Console.Write($"{i} ");
    }
}

Each call to MoveNext runs the next lines of Fib() until it reaches yield return, returns that value as Current, and records the current state. The next call resumes at the following line. The program prints:

0 1 1 2 3 5 8 13 21 34 55 89

The compiler-generated state machine behind IEnumerator/IEnumerable—yielding a value and returning to the previous execution point on the next MoveNext—is the basis of async/await.

Combining an iterator with Task.ContinueWith

Instead of calling MoveNext manually in a while loop, call it through each Task’s ContinueWith:

static MyTask IterateAsync(IEnumerable<MyTask> tasks)
{
    var t = new MyTask();
    IEnumerator<MyTask> e = tasks.GetEnumerator();

    void Process()
    {
        try
        {
            if (e.MoveNext())
            {
                e.Current.ContinueWith(_ => Process());
                return;
            }
        }
        catch (Exception exception)
        {
            t.SetException(exception);
            return;
        }
        t.SetResult();
    }

    Process();
    return t;
}

When an IEnumerable containing Tasks is passed to MyTask.IterateAsync, each MoveNext executes and obtains a new Task. The callback that calls MoveNext again is registered as that Task’s continuation. When the Task completes, the callback advances to the next Task. Repeating until the enumerator ends completes IterateAsync.

Use it to rewrite DelayWithTask:

[ContextMenu("Demo Delay Asynchronously")]
public void DelayWithTaskIterateAsync()
{
   Debug.Log("======start======");
   MyTask.IterateAsync(DemoDelayTasksIterate());
   Debug.Log("======end======");
}

private IEnumerable<MyTask> DemoDelayTasksIterate()
{
   yield return MyTask.Delay(1000);
   Debug.Log(LogWithTimestamp("Hello"));
   yield return MyTask.Delay(1000);
   Debug.Log(LogWithTimestamp("World"));
   yield return MyTask.Delay(1000);
   Debug.Log(LogWithTimestamp("How's your day?"));
   yield return MyTask.Delay(1000);
}

DelayWithTask result

DemoDelayTasksIterate result

The original DelayWithTask calls Wait on every Task, forcing synchronous waiting and delaying the final end log until every Task completes. In DelayWithTaskIterateAsync, the final end log appears while none of the Tasks in the enumerable has finished; the Tasks then complete asynchronously.

We have successfully implemented asynchronous execution.

Now compare the same logic using C#’s official async/await Task:

[ContextMenu("Demo Delay async await")]
public void DelayWithTaskAsync()
{
   Debug.Log("======start======");
   DemoDelayTasksAsync();
   Debug.Log("======end======");
}

private async void DemoDelayTasksAsync()
{
   await Task.Delay(1000);
   Debug.Log(LogWithTimestamp("Hello"));
   await Task.Delay(1000);
   Debug.Log(LogWithTimestamp("World"));
   await Task.Delay(1000);
   Debug.Log(LogWithTimestamp("How's your day?"));
   await Task.Delay(1000);
}

The result matches the asynchronous implementation above. The official async/await tells the compiler to generate an equivalent iterator state machine and code similar to IterateAsync.

DotPeek decompiles DelayWithTaskAsync into:

[ContextMenu("Demo Delay async await")]
public void DelayWithTaskAsync()
{
  Debug.Log((object) "======start======");
  this.DemoDelayTasksAsync();
  Debug.Log((object) "======end======");
}

[AsyncStateMachine(typeof (MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8))]
[DebuggerStepThrough]
private void DemoDelayTasksAsync()
{
  MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8 stateMachine = new MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8();
  stateMachine.\u003C\u003Et__builder = AsyncVoidMethodBuilder.Create();
  stateMachine.\u003C\u003E4__this = this;
  stateMachine.\u003C\u003E1__state = -1;
  stateMachine.\u003C\u003Et__builder.Start<MyTaskDemo.\u003CDemoDelayTasksAsync\u003Ed__8>(ref stateMachine);
}

I still do not fully understand the state-machine internals, but its Start calls the iterator’s MoveNext. MoveNext contains logic similar to IterateAsync.Process that passes a callback to Task.ContinueWith, and the state machine ends after all Tasks finish.

Like the modified MyTask.ContinueWith and IterateAsync, the builder itself wraps a Task. That is why a method with no return value can compile:

private async Task MyMethod()
{
}

// The compiler effectively generates an AsyncTaskMethodBuilder,
// starts the state machine, and returns builder.Task.

With async Task, the compiler generates the state machine, uses AsyncTaskMethodBuilder, and returns the builder’s Task.

Adding await and async support to a custom Task

Only a small amount of code is needed to use ordinary async/await syntax with MyTask.

Supporting await

Implement the awaiter pattern. Add an Awaiter struct to MyTask that implements INotifyCompletion:

public class MyTask
{
    private bool _completed;
    private bool _canceled;
    private Exception? _exception;
    private Action? _continuation;
    private ExecutionContext? _context;

    public struct Awaiter : INotifyCompletion
    {
        private MyTask task;
        public Awaiter(MyTask t) { task = t; }
        public bool IsCompleted => task.IsCompleted;
        public void OnCompleted(Action continuation) => task.ContinueWith(continuation);
        public void GetResult() => task.Wait();
    }

    public Awaiter GetAwaiter() => new(this);
}

Add the GetAwaiter getter to MyTask, and a normal async method can now await a custom Task:

[ContextMenu("Print Num With custom Awaiter")]
private async Task PrintNumbersWithCustomAwaiter()
{
   Debug.Log("start of print number task");
   for (int i = 0; i < 5; i++)
   {
      await MyTask.Delay(1000);
      Debug.Log(string.Format("Print Num: {0}, Local Time: {1:HH:mm:ss}", i, DateTime.Now));
   }
   Debug.Log("=======end======");
}

Supporting async

The method above still returns Task, not MyTask. Add an AsyncMethodBuilder attribute to MyTask:

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public class MyTask
{
    // ...
}

[ContextMenu("Print Num With custom Awaiter")]
private async MyTask PrintNumbersWithCustomAwaiter()
{
   Debug.Log("start of print number task");
   for (int i = 0; i < 5; i++)
   {
      await MyTask.Delay(1000);
      Debug.Log(string.Format("Print Num: {0}, Local Time: {1:HH:mm:ss}", i, DateTime.Now));
   }
   Debug.Log("=======end======");
}

A31CBFCF-8D14-43BE-8A67-506BA5F9EC85.png

Common questions


What do ExecutionContext and SynchronizationContext do?

ExecutionContext

The official documentation defines ExecutionContext as a container for the current thread’s contexts and state, including security context, call context, and synchronization context. It also preserves values such as AsyncLocal, which can flow through one async method while the method moves between threads.

The following example demonstrates its purpose:

static AsyncLocal<int> _asyncLocalInt = new AsyncLocal<int>();

static async MyTask AsyncMethodA()
{
   _asyncLocalInt.Value = 10;
   var t1 = AsyncMethodB(10);
   _asyncLocalInt.Value = 60;
   var t2 = AsyncMethodC(60);
   await t1;
   await t2;
}

static async MyTask AsyncMethodB(int expectedValue)
{
   Debug.Log(string.Format("Entering AsyncMethod B, Expected {0}, AsyncLocal value is {1} ", expectedValue, _asyncLocalInt.Value));
   await MyTask.Delay(100);
   Debug.Log(string.Format("Exiting AsyncMethod B, Expected {0}, AsyncLocal value is '{1}'", expectedValue, _asyncLocalInt.Value));
}

static async MyTask AsyncMethodC(int expectedValue)
{
   Debug.Log(string.Format("Entering AsyncMethod C, Expected {0}, AsyncLocal value is {1} ", expectedValue, _asyncLocalInt.Value));
   await MyTask.Delay(100);
   Debug.Log(string.Format("Exiting AsyncMethod C, Expected {0}, AsyncLocal value is '{1}'", expectedValue, _asyncLocalInt.Value));
}

[ContextMenu("Test AsyncLocal Variable")]
public async void TestAsyncVariable() => await AsyncMethodA();

AsyncMethodA sets _asyncLocalInt to 10, starts B, sets it to 60 before B finishes, and starts C. B should print 10 after its delay; C should print 60.

If MyTask.Complete ignores ExecutionContext and directly runs the continuation on the Thread Pool, the value of _asyncLocalInt after awaiting a Task is wrong because the thread’s state was not preserved.

2DB0C075-9BAA-4754-B356-0438D53E6416.png

Await works by using a Task’s _continuation to call the iterator’s MoveNext and resume at the previous yield return. Because _continuation runs on a Thread Pool thread, the thread environment may differ after every await. ExecutionContext preserves the correct state.

SynchronizationContext

SynchronizationContext determines the time and environment in which code runs. It is abstract and must be implemented by the application; Unity provides UnitySynchronizationContext, which ensures that continuations after asynchronous Tasks return to the main thread.

My understanding is that ExecutionContext is a copy of state: ExecutionContext.Capture() copies the current values, preserving them as asynchronous code moves between threads. SynchronizationContext specifies the execution environment and time (for example, which thread should run the code). Code sent through SynchronizationContext.Post() runs at the specified time and on the specified thread.

Which thread runs code after await?

It depends. In MyTask.Complete, continuations are enqueued on the Thread Pool, and await resumes by calling the Task’s continuation and the iterator’s MoveNext. That sounds as if it always runs on a Thread Pool worker, but most applications have their own SynchronizationContext. C# uses the current context after await to return the continuation to the desired environment. In Unity, that is the main thread.

private string LogWithThreadId(string log)
{
   return string.Format("{0}, Thread ID: {1}", log, Thread.CurrentThread.ManagedThreadId);
}

[ContextMenu("Thread Id Test")]
public async Task TestThreadID()
{
   // SynchronizationContext.SetSynchronizationContext(null);
   Debug.Log(LogWithThreadId("before await, "));
   await Task.Delay(1000);
   Debug.Log(LogWithThreadId("after await 1, "));
   await Task.Delay(1000);
   Debug.Log(LogWithThreadId("after await 2, "));
   await Task.Delay(1000);
   Debug.Log(LogWithThreadId("after await 3, "));
}

03FEAB37-D2E9-45CA-B89A-A401719F3640.png

Each continuation returns to the main thread. Uncommenting SynchronizationContext.SetSynchronizationContext(null) produces the opposite result:

086B3260-84CC-4D1D-BA44-4AAC03A9CC5C.png

After the await completes, execution remains on the Thread Pool worker. Calling a Unity API there produces an error. This is where SynchronizationContext is essential.

Is async Task multithreaded?

I would say not by itself. async is a combination of a state machine and an iterator. Chaining Task.ContinueWith, MoveNext, and yield return pauses execution and resumes at the previous code location; this does not inherently involve multiple threads.

A Task only records whether work has completed (C# delegates SetResult and SetException to TaskCompletionSource). Using async Task does not mean multithreaded execution. Task.Run is an exception because it explicitly runs work on another thread.

The await syntax itself is also based on a Task, but its continuation may run on a worker thread depending on the SynchronizationContext. In ordinary Unity code, the context returns the continuation to the main thread.

What is the problem with async void?

An async void method is a fire-and-forget async method. UI and button events sometimes use it because a return value is not useful, but it has serious problems:

  1. Unlike a method returning a Task, it provides no Task through which completion can be checked.
  2. An exception from an async Task function is stored on the Task. An exception from async void is raised directly on the SynchronizationContext, so the caller cannot catch it; a serious exception can crash the program.
private async void ThrowExceptionAsync()
{
   await Task.Run(() => throw new InvalidOperationException());
}

[ContextMenu("Test Async Void Trap")]
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
   try
   {
      ThrowExceptionAsync();
      Thread.Sleep(100);
      GC.Collect();
   }
   catch (Exception e)
   {
      // The exception is never caught here.
      Debug.Log("I caught the exception : " + e);
   }
}

F3AA2787-EAC2-4B95-BE80-7503D0911FA4.png

The inner exception cannot be caught, and a serious one may crash the program. A helper can handle fire-and-forget Tasks:

public static class TaskEx
{
  public static void FireAndForget(this Task task)
  {
     task.ContinueWith(x =>
     {
        Debug.Log("TaskUnhandled : " + x.Exception);
     }, TaskContinuationOptions.OnlyOnFaulted);
  }
}

private async Task ThrowExceptionAsync()
{
   await Task.Run(() => throw new InvalidOperationException());
}

[ContextMenu("Test Async Void Trap")]
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
   try
   {
      ThrowExceptionAsync().FireAndForget();
      Thread.Sleep(100);
      GC.Collect();
   }
   catch (Exception e)
   {
      Debug.Log("I caught the exception : " + e);
   }
}

8BF9C3DE-272C-4E10-A0A4-96DB8DF133D3.png

Another dangerous pattern is calling an async Task function without awaiting it. Errors may produce no visible error and eventually become an UnobservedTaskException, which can be even more dangerous than async void.

private async Task ThrowExceptionAsync()
{
   await Task.Run(() => throw new InvalidOperationException());
}

[ContextMenu("Test Async Task Trap")]
public void AsyncTaskExceptions_AreUnobserved()
{
   ThrowExceptionAsync();
   Thread.Sleep(100);
   GC.Collect();
}

86FDA938-1E05-410F-9312-2C97E4D0B6FF.png

It is frightening. When using fire-and-forget, always handle exceptions correctly, and use async void only for events rather than merely for convenience.

Should Unity use Coroutines or Async Tasks?

Unity Coroutine characteristics:

  • Every yield return waits at least one game frame (yield return null).
  • It is tied to the MonoBehaviour lifecycle; destroying the GameObject interrupts the Coroutine.
  • It always runs on the main thread.
  • It has no return value.
  • It provides Unity game-logic helpers such as WaitForSeconds and WaitForEndOfFrame.

C# Async Task characteristics:

  • The await duration is not tied to a game frame.
  • It has no Unity-specific game-logic helpers.
  • It is not tied to the MonoBehaviour lifecycle.
  • It does not necessarily run on the main thread.
  • It has a return value.
  • Fire-and-forget usage requires care.

For me, the greatest advantage of Async Tasks is handling complicated user-input/output flows. They can be expressed with readable, extensible code, and the returned Task makes it easy to inspect the completion of one or many Tasks and control the overall flow.

Unity Coroutines are convenient precisely because they are tied to a GameObject. If logic needs to run according to an in-game animation, the one-frame granularity of yield return is intuitive. They are also convenient for fire-and-forget work where the result does not matter, such as playing a sound after a certain frame. If the sound’s GameObject is destroyed halfway through, its lifecycle ends with the object and the sound does not continue.

UniTask is an Async Task library built for Unity. It provides exception handling, Tasks tied to game frames and GameObject lifecycles, and helpers for awaiting Coroutines, making it extremely convenient.

In Japan, UniTask is now almost a required skill for Unity-related jobs.