2

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

aybe
  • 15,516
  • 9
  • 57
  • 105

2 Answers2

2

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.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
0

You can achieve that very easily using ReactiveExtensions (nuget System.Reactive):

public static Task RunOnCustomScheduler(Action action, IScheduler scheduler)
{
    return Observable.Return(Unit.Default)
        .ObserveOn(scheduler) // switch context
        .Do(_ => action.Invoke()) // do the work
        // .Select(_ => func.Invoke()) // change Action to Func if you want to return stuff.
        //.Select(async _ => await func.Invoke()) // can work with async stuff, though ConfigureAwait may become tricky
        .ToTask();
}

You can also pass SynchronizationContext instead of IScheduler.

Remember to ConfigureAwait(false/true) though when you call this method to configure how you want to continue

Krzysztof Skowronek
  • 2,796
  • 1
  • 13
  • 29