Search code examples
c#unity-game-engineasynchronoustask-parallel-librarysynchronizationcontext

Custom TaskFactory does not use custom SynchronizationContext


Note: this is under Unity3D

I need to run an inner task using a custom synchronization context, like the following:

  • outer task -> on default scheduler
    • inner task -> on custom scheduler with custom synchronization context

This is because some objects in inner task can only be created on a specific thread that the custom synchronization context will post to, on the other hand, I want the outer task to be run normally, i.e. ThreadPool or whatever Task.Run uses.

Following advice from this question:

How to run a Task on a custom TaskScheduler using await?

Unfortunately, that doesn't work, the synchronization context is still the default one:

OUTER JOB: 'null'
0
        INNER JOB: 'null' <------------- this context should not be the default one
        0,      1,      2,      3,      4,
1
        INNER JOB: 'null'
        0,      1,      2,      3,      4,
2
        INNER JOB: 'null'
        0,      1,      2,      3,      4,
Done, press any key to exit

Code:

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

internal static class Program
{
    private static TaskFactory CustomFactory { get; set; }

    private static async Task Main(string[] args)
    {
        // create a custom task factory with a custom synchronization context,
        // and make sure to restore initial context after it's been created

        var initial = SynchronizationContext.Current;

        SynchronizationContext.SetSynchronizationContext(new CustomSynchronizationContext());

        CustomFactory = new TaskFactory(
            CancellationToken.None,
            TaskCreationOptions.DenyChildAttach,
            TaskContinuationOptions.None,
            TaskScheduler.FromCurrentSynchronizationContext()
        );

        SynchronizationContext.SetSynchronizationContext(initial);


        // now do some work on initial context

        await Task.Run(async () =>
        {
            Console.WriteLine($"OUTER JOB: {GetCurrentContext()}");

            for (var i = 0; i < 3; i++)
            {
                Console.WriteLine(i);
                await Task.Delay(100);

                // now do some work on custom context
                await RunOnCustomScheduler(DoSpecialWork);
            }
        });

        Console.WriteLine("Done, press any key to exit");
        Console.ReadKey();
    }

    private static void DoSpecialWork()
    {
        Console.WriteLine($"\tINNER JOB: {GetCurrentContext()}"); // TODO wrong context

        for (var i = 0; i < 5; i++)
        {
            Thread.Sleep(250);
            Console.Write($"\t{i}, ");
        }

        Console.WriteLine();
    }

    private static Task RunOnCustomScheduler(Action action)
    {
        return CustomFactory.StartNew(action);
    }

    private static string GetCurrentContext()
    {
        return SynchronizationContext.Current?.ToString() ?? "'null'";
    }
}

public class CustomSynchronizationContext : SynchronizationContext
{
}

When I debug, I can see that the custom factory indeed has the custom scheduler with a custom context, but in practice it doesn't work however.

Question:

How can I get my custom scheduler to use the custom context it's been created from?

Final answer:

I was awaiting a method inside thread I wanted to avoid working in, wrapping that stuff in await Task.Run (plus some details left out for brevity) fixed it. Now I can run my long task outside Unity thread but still make Unity calls inside it using custom factory approach in question above.

i.e. it was already working but I wasn't using it correctly


Solution

  • Your CustomSynchronizationContext is indeed used for running the DoSpecialWork method. You can confirm this by overriding the Post method of the base class:

    public class CustomSynchronizationContext : SynchronizationContext
    {
        public override void Post(SendOrPostCallback d, object state)
        {
            Console.WriteLine($"@Post");
            base.Post(d, state);
        }
    }
    

    Then run your program again and you'll see three @Post appearing in the console.

    The reason that the SynchronizationContext.Current is null is because this property is associated with the current thread. Here is the source code:

    // Get the current SynchronizationContext on the current thread
    public static SynchronizationContext Current 
    {
        get      
        {
            return Thread.CurrentThread.GetExecutionContextReader().SynchronizationContext ?? GetThreadLocalContext();
        }
    }
    

    You could install your CustomSynchronizationContext in each thread of the ThreadPool, but this is not recommended. It's not required either. In the absence of a SynchronizationContext.Current the async continuations are running in the current TaskScheduler, and the current TaskScheduler inside the DoSpecialWorkAsync method is the one that you have created yourself with the TaskScheduler.FromCurrentSynchronizationContext() method, which is associated with your CustomSynchronizationContext instance.