Search code examples
c#unity-game-engineasynchronousbackground-process

Run on Unity Main Thread from Background Threads using a TaskScheduler reference connected to the UnitySynchronizationContext


I am working on a system to run tasks on background threads (non unity main thread) but need the ability to run code on the main thread intermittently. I found that I can use the Task.Factory to launch a Task on a specific TaskScheduler and that I can get a reference to such a scheduler from the UnitySynchronizationContext which runs all tasks on the Unity main thread. This seems to work fine in this example below where a main thread task is launched from a background task:

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

public class AsyncExploration : MonoBehaviour
{
    private TaskScheduler unityScheduler;

    void Awake()
    {
        Debug.Log($"Main Thread is id {Thread.CurrentThread.ManagedThreadId}/{System.Environment.CurrentManagedThreadId}");
        // capturing reference
        unityScheduler = TaskScheduler.FromCurrentSynchronizationContext();
    }

    void Start()
    {
        // Task running in Default Task Scheduler
        Task.Run(() => {
            Debug.Log($"Random task running on thread {Thread.CurrentThread.ManagedThreadId}");

            // Starting Task using the Unity Scheduler
            Task.Factory.StartNew(() => {
                Debug.Log($"Inner task with unity scheduler running on thread {Thread.CurrentThread.ManagedThreadId}");
            }, CancellationToken.None, TaskCreationOptions.None, unityScheduler);
        });
    }
}

With the output:

> Main Thread is id 1/1
> Random task running on thread 966
> Inner task with unity scheduler running on thread 1

Can such a reference stored in a global variable be used to reliably run tasks on the main thread or are there any problems or downsides I am potentially overlooking?


Solution

  • It's perfectly fine caching the TaskScheduler returned from TaskScheduler.FromCurrentSynchronizationContext() or the SynchronizationContext itself.

    Why?

    Consider this code

    // UI Thread
    // Here SynchronizationContext.Current is captured
    await Task.Run(async () =>
    {
        // Here we are on the ThreadPool
        await Task.Delay(1000);
    });
    // Here SynchronizationContext.Current is restored
    // Ui Thread
    Something();
    

    When you await a Task your current SynchronizationContext is captured (i.e. is saved into a variable), and it will be used to run the continuation.

    Note That Task can run indefinitely long! So that Synchronization Context should be valid indefinitely long!

    Can you cache TaskScheduler.FromCurrentSynchronizationContext()?

    If you take a look at the source code, you'll se that TaskScheduler.FromCurrentSynchronizationContext() returns an instance of SynchronizationContextTaskScheduler.

    • In the constructor it immediately saves in a private field the current SynchronizationContext.
    • While in the QueueTask() method (That is the method is called when you schedule a Task using Task.Factory.StartNew(actionDoSchedule,..,..,taskScheduler)), just uses that SynchronizationContext to execute the Task.

    So the TaskScheduler is valid until the SynchronizationContext is valid. But the SynchronizationContext is always valid!