My C-Sharp and Unity’s problem: Async/Await/Task, Multithreaded and Synchronization Context

I was working on my university course project these day, and we are using Unity/C#. For function which isn’t return immediately, I used coroutine and yield return waituntil to wait for it. I didn’t know much about C# so at that time I didn’t even know we have async/await/task, my thinking method is imprisoned in only using Unity API, after reading some article I suddenly realize I should definitely try this async/await/task things.

So the first problem I encountered is I mixed up the concept of multithreading and asynchronous though I kind of remember there is a task schedule thing. So basically asynchronous can be executed in only one thread, that is, at some point, I call await, and C# will generate a function that check at each frame to see if the task is completed (If it is, it trigger callback function), while my main thread is keep going at this time. Async can be used with multithreading, but multithreading is not necessary.

In Unity/C#, if we want to create a new thread, we use Task.Run() or similar function in Task class. However, isn’t that only the main thread can access Unity API? If so, the usage of async/await will be limited, as least coroutine is better in this case.

At that time I was seeking of a substitution of Promise, most of implementations are using coroutine, but using coroutine too much will mixed up my project and moreover, it takes a long time to repeat the work to create a new coroutine. Then I find this async/await/task thing, isn’t it similar to Promise? I mean they can both wait until something is done, the only different now is how to implement a .then() function. The good new is there is a .ContinueWith() function for task class, so if my async function return a task (they usually does), I can follow with a .CoutinueWith() function likes:

async Task func(){
    //...dosomething...
}

//...

func().ContinueWith(()=>{
    //...callback here...
});

Everything works fine so far, but when I put it into use, here’s the problem: the code in ContinueWith will actually runs by Task.Run(), which make it executed in another thread, without permission to access Unity API. However, I found there was another parameter for this ContinueWith() function, which takes a TaskScheduler. Emm, what the hack is TaskSchedualer? Anyway just for a try I put TaskScheduler.FromCurrentSynchronizationContext() into this parameter without knowing what it is, but it works! But how? So I have two guess here:

  1. I move this task to run in the main thread.
  2. There’s some black magic.

So I print out the thread id, but it was absolutely a different thread, I realize to figure out this problem, I need to find out what is this synchronization context thing.

Synchronization context, basically is a environment for thread to communicate, each thread can be in 0 or 1 synchronization context. For those threads in the same synchronization context, they will be executed in a certain sequence (can be define by custom synchronization context, or just use a default one). For Unity, they defined a custom synchronization context:

Unity overwrites the default SynchronizationContext with a custom UnitySynchronizationContext and runs all the tasks on the main thread in both Edit and Play modes. To utilize async tasks, you must manually create and handle your own threads with a TaskFactory, as well as use the default SynchronizationContext instead of the Unity version. To listen for enter and exit play mode events to stop the tasks manually, use EditorApplication.playModeStateChanged. However, if you take this approach, most of the Unity scripting APIs are not available to use because you are not using the UnitySynchronizationContext.

After reading this you will find that Unity doesn’t forbid other threads from accessing Unity API, it forbids access from different synchronization context. So if I move a thread to the main synchronization context, it should work. Here’s my test code:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class TestScript : MonoBehaviour
{
    private SynchronizationContext _mainThreadSC;
    // Start is called before the first frame update
    void Start()
    {
        _mainThreadSC = SynchronizationContext.Current;
        Debug.Log("Sure is main thread: " + Thread.CurrentThread.ManagedThreadId);
        DisableAfterSecond();
    }
    
    private async void DisableAfterSecond()
    {

        Task.Run((async () =>
        {
            SynchronizationContext.SetSynchronizationContext(_mainThreadSC);
            if (_mainThreadSC == SynchronizationContext.Current)
            {
                Debug.Log("Same synchronization context");
            }
            else
            {
                Debug.Log("Different synchronization context");
            }
            Debug.Log("Async function: " + Thread.CurrentThread.ManagedThreadId);
            await Task.Delay(1000);
            this.gameObject.SetActive(false);
            for (int i = 0; i < 100000; i++)
            {
                Debug.Log("Thread test");
            }
        }));

    }
}

Fortunately, it works as expected, it run in a different thread, but it still can access the Unity API. But the bad thing is, notice the for loop, because now this thread is in the same synchronization context as the main thread, and for each thread, this synchronization context will run the code for current frame in turn. So for DisableAfterSecond, there’s a frame that it need to print 100K message, and it will block the main thread, which cause the game frozen.

A simple solution to it is call await Task.Yield(); after each iteration of for loop, it will delay the next iteration to next frame. In some case it is useful but it is not cool at all. The suggested way in MSDN is we run code which doesn’t require Unity API in another synchronization context, and change to main synchronization context if we need to execute something relevant to Unity API, after that, we return to our default synchronization context.

image: Nested BackgroundWorkers in a UI Context