1

I need to do some work on a specific thread (for all intents and purposes, we can say this is the UI thread), but the method requesting that work to be done may or may not be executing in a different thread. I am completely new to multithreaded programming, but have arrived at the conclusion that the correct approach to this is to use a TaskScheduler.

After toying around for a while with a custom implementation, I found FromCurrentSynchronizationContext. This appears to do exactly what I need and save me a lot of trouble (famous last words and all that).

My question comes down to whether I am overlooking anything that will get me into trouble, or maybe I'm overcomplicating the issue altogether. Here's what I'm doing now:

TaskScheduler

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

namespace Internals
{
    internal static class MainThreadTaskScheduler
    {
        private static readonly object taskSchedulerLock = new();
        private static readonly Thread taskSchedulerThread;
        private static readonly TaskScheduler taskScheduler;

        static MainThreadTaskScheduler()
        {
            lock (taskSchedulerLock)
            {
                // despite calling it the "main thread", we don't really care which thread
                // this is initialized with, we just need to always use the same one for
                // executing the scheduled tasks
                taskSchedulerThread = Thread.CurrentThread;
                if (SynchronizationContext.Current is null)
                {
                    // in implementation this may be null, a default context works
                    SynchronizationContext.SetSynchronizationContext(new());
                }
                taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
            }
        }

        public static Task Schedule(Action action)
        {
            lock (taskSchedulerLock)
            {
                if (Thread.CurrentThread == taskSchedulerThread)
                {
                    // if we are already on the main thread, just run the delegate
                    action();
                    return Task.CompletedTask;
                }
                return Task.Factory.StartNew(action, CancellationToken.None,
                    TaskCreationOptions.None, taskScheduler);
            }
        }

        public static Task<TResult> Schedule<TResult>(Func<TResult> func)
        {
            lock (taskSchedulerLock)
            {
                if (Thread.CurrentThread == taskSchedulerThread)
                {
                    // if we are already on the main thread, just run the delegate
                    return Task.FromResult(func());
                }
                return Task.Factory.StartNew(func, CancellationToken.None,
                    TaskCreationOptions.None, taskScheduler);
            }
        }
    }
}

Usage

// ...elsewhere...
public static bool RunTaskInMainThread()
{
    // we need to synchronously return the result from the main thread regardless of
    // which thread we are currently executing in
    return MainThreadTaskScheduler.Schedule(() => SomeMethod()).GetAwaiter().GetResult();
}

I had attempted to make RunTaskInMainThread an async method and use await, but it kept causing my program to hang rather than yielding a result. I'm sure I was just using that incorrectly, but I don't know how to implement it here (bonus question: how can I use await here?).

Am I doing anything wrong here? Is there a better way to get the same results?

monkey0506
  • 2,489
  • 1
  • 21
  • 27
  • To avoid the hang, use configureawait(false) – John V Apr 30 '22 at 06:17
  • Related: [How to run a Task on a custom TaskScheduler using await?](https://stackoverflow.com/questions/15428604/how-to-run-a-task-on-a-custom-taskscheduler-using-await) – Theodor Zoulias Apr 30 '22 at 06:34
  • Thanks for the tips. It seems it would end up being more trouble than it's worth when I'm not even necessarily worried about the call being *asynchronous* so much as just that it **must** be run in a particular thread. – monkey0506 Apr 30 '22 at 07:00
  • Also, I forgot to mention that this is on .NET 6.0, so [Dispatcher](https://learn.microsoft.com/en-us/dotnet/api/system.windows.threading.dispatcher?view=netframework-4.8) is not a viable solution either. – monkey0506 Apr 30 '22 at 07:01

1 Answers1

0

You are not in the right direction. Not because you are not smart, but because in the general area that you are trying to move there are traps all over the place.

  1. The TaskSchedulers are not compatible with async/await. They were invented before async/await was a thing, for tasks that we now call delegate-based tasks (tasks that represent the completion of a specific delegate), in contrast with the kind of tasks that are created by async methods and are now known as promise-style tasks (tasks that represent just a promise that sometime in the future they'll complete).

  2. The SynchronizationContext class is useless by itself. It's only useful as a base class for implementing derived classes like the WindowsFormsSynchronizationContext or Stephen Cleary's AsyncContextSynchronizationContext. It's a pity that this class was not defined as abstract, like the TaskScheduler is, to prevent programmers from trying to use it as is. Implementing a proper SynchronizationContext-derived class is not trivial.

  3. When a thread is used for scheduling work via a scheduler, either a TaskScheduler or a SynchronizationContext, the thread is then owned by the scheduler. You can't have a thread that is shared by two schedulers, or by a scheduler and some method that wants to use that thread at any time on demand. That's why when start a message loop on the UI thread with the Application.Run method, this call is blocking. Any code that follows this call will not execute before the loops is completed (before the associated windows Form is closed). The same is true and with Stephen Cleary's AsyncContext. The AsyncContext.Run call is blocking (example).

Some more links:

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • Sorry, but *why* is `SynchronizationContext` "useless" (except as a base class)? Point #1 I've already come to some understanding of, but your other two points I'm unclear on. If a `Thread` has a `null` value for `SynchronizationContext` are you saying that it must always only have a `null` value? If so, why does `SetSynchronizationContext` exist? Alternately, are you saying that if I call `SetSynchronizationContext` that the value I pass in there becomes **solely** responsible for dispatching and handling **ALL** work done by that thread? – monkey0506 Apr 30 '22 at 08:17
  • `You can't have a thread that is shared by...a scheduler and some method that wants to use that thread at any time on demand.` This is also confusing to me. Isn't that precisely what the purpose of a scheduler is for, so that the thread(s) on which the scheduler operates can be used on-demand? Obviously any given thread would have to go through the work that has been scheduled for it, but you seem to be saying that a thread can't have a scheduler and also be used on-demand for processing work. – monkey0506 Apr 30 '22 at 08:21
  • @monkey0506 the base `SynchronizationContext` class schedules work on the `ThreadPool`. So when you install it on a some thread with `SynchronizationContext.SetSynchronizationContext(new());`, any `await` operation that will capture this `SynchronizationContext` will not schedule the continuation on the same thread. It will schedule it on the `ThreadPool` instead, on threads that have `SynchronizationContext.Current` equal to `null`. Which is unlikely what you want or expect. – Theodor Zoulias Apr 30 '22 at 08:33
  • @monkey0506 as for not being able to share a thread used by a scheduler, I mean basically that somewhere there will be a blocking call, and the worker thread will be either doing work scheduled through the scheduler, or will be blocked waiting for some demand for work to arrive. This was not intuitively obvious for me when I started learning about the schedulers, and I guess that it might not be obvious to others as well. – Theodor Zoulias Apr 30 '22 at 08:34
  • `will not schedule the continuation on the same thread` I am new to the terminology, so pardon my ignorance, but is "the continuation" the point at which the code returns to when the scheduled task is completed, similar to the return point on the stack after a method call in synchronous programming? If that's the case, at least in my naivety, I wouldn't expect the continuation point to be guaranteed to be on the same thread as the task was run. If that is a standard practice then it's good to know that this violates that. Otherwise I'm still unclear why it would be a "bad thing". – monkey0506 Apr 30 '22 at 08:44
  • In my actual implementation, my only requirements for this `internal` class are that the `TaskScheduler` must schedule all tasks on the same thread and that the caller must be able to synchronously wait on the task to finish. I can modify it to create its own thread. What I was doing before finding `FromCurrentSynchronizationContext` was based on similar code to your answer that you linked. I changed to this version because it seemed simpler to maintain. – monkey0506 Apr 30 '22 at 08:49
  • @monkey0506 by "continuation" I mean the code that follows an `await` operation. For example when you have `await Task.Delay(1000);`, any code that is below this line is the continuation that will run one second later. The `await` by default captures the current `SynchronizationContext`, and schedules the continuation on that context. You can prevent this by adding `.ConfigureAwait(false)` at the await point. But capturing a base `SynchronizationContext` instance will not have the effect that you expect. The continuation will run on the `ThreadPool`, instead of running on the previous thread. – Theodor Zoulias Apr 30 '22 at 08:55
  • @monkey0506 *"the caller must be able to synchronously wait on the task to finish"* -- Why is that? Why not do the work synchronously by itself, and instead wait synchronously for some other thread to do the work? Do you want to prevent concurrent executions? Or do you have a requirement for running the code on an STA thread? – Theodor Zoulias Apr 30 '22 at 08:59