We’ve previously had a chance to learn about SynchronizationContext, so now it’s time to explore the second crucial component related to thread synchronization in .NET: TaskScheduler. These two mechanisms are fundamental to mastering multi-threaded work in the .NET ecosystem.

As its name suggests, the primary role of a TaskScheduler is to schedule tasks. It guarantees that every task will be executed and is responsible for deciding when and on which thread it runs. It employs various mechanisms to optimize performance and throughput.
In very, very rare cases, you can use your own TaskScheduler. If you have specific or non-standard scheduling requirements, this is the class you should be looking into. At the end of this post, you’ll find a very simple custom TaskScheduler implementation I’ve written.
How is TaskScheduler Different from SynchronizationContext?
SynchronizationContext operates on a different level of abstraction. Depending on the execution environment (like ASP.NET, WPF, Blazor, etc.), it provides mechanisms related to tasks such as data synchronization or UI updates.
It is not responsible for scheduling when Tasks are launched or their order of execution.
Synchronization with the UI Context
In user interface applications (like Windows Forms or WPF), access to UI elements is restricted to the thread that created them. TaskScheduler allows you to schedule tasks for a specific synchronization context by using the TaskScheduler.FromCurrentSynchronizationContext()
method. This enables you to perform operations in the background and then safely update the user interface on the appropriate thread.
We’ve covered SynchronizationContext
here before – it’s what helps manage multithreading in various environments.
Below is the code using FromCurrentSynchronizationContext(). This is just an example, we probably won’t need to use it.
private void button_Click(object sender, EventArgs e)
{
// We get the TaskScheduler associated with the current synchronization context (UI thread)
var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
// We run the task in the background
Task.Run(() =>
{
System.Threading.Thread.Sleep(1000);
return "Operation Result";
})
// Continuing the task on the UI thread FromCurrentSynchronizationContext
.ContinueWith(t =>
{
// Updating a UI control
label1.Text = t.Result;
}, uiScheduler);
}
This is possible because in the TaskScheduler implementation, SchedulingContext is assigned. As I wrote, this is a different abstraction. And let’s also remember that in everyday use of Task Asynchronous Pattern we don’t have to remember about it 🙂 but it’s worth notice…
Default Implementation
Alright… There’s a lot of theory here, but maybe let’s find out how it usually works.
Global queue and local queues The thread pool manages a global FIFO (first-in, first-out) queue – this is where top-level tasks go, meaning those not created in the context of another task. Local queues operate on the LIFO (last-in, first-out) principle – Child or nested tasks are placed in local queues specific to the thread on which the parent task is executing. This improves performance and reduces contention between threads.
How does this improve performance? The implementation currently includes the following mechanisms:
Work stealing
To ensure load balancing and prevent threads from becoming idle, the thread pool uses a work-stealing algorithm. When a thread’s local queue is empty, the thread can „steal” tasks from the local queues of other threads. This results in more efficient resource utilization and increased task throughput.
Long-running tasks
If a task is long-running and could potentially block the local queue, you can use the TaskCreationOptions to signal to the scheduler that it should run the task on a separate thread. This avoids blocking other tasks in the local and global queues.
Task longRunningTask = Task.Factory.StartNew(
() => { /* Your long-running task logic */ },
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default
);
There are more options listed in link below.
Task inlining
In some cases, when a task is awaited, it can be executed synchronously on the waiting thread. This improves performance by avoiding the overhead of scheduling it on a new thread. Task inlining is only used when the task is in the local queue of the relevant thread to prevent errors.
Let’s write our own task scheduler.
public class SingleThreadTaskScheduler : System.Threading.Tasks.TaskScheduler, IDisposable
{
// Thread-save queue.
private readonly BlockingCollection<Task> _tasks = new BlockingCollection<Task>();
private readonly Thread _mainThread;
public SingleThreadTaskScheduler()
{
// our main thread - it will run all tasks
_mainThread = new Thread(ProcessTasks);
_mainThread.IsBackground = true;
_mainThread.Start();
}
private void ProcessTasks()
{
// Running tasks - GetConsumingEnumerable is waiting till new tasks or CompleteAdding()
foreach (var task in _tasks.GetConsumingEnumerable())
{
TryExecuteTask(task);
}
}
protected override void QueueTask(Task task)
{
// Simple as it is ... just add Task to Queue.
_tasks.Add(task);
}
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
// Run Task only if it is running in a running thread
return Thread.CurrentThread == _mainThread && TryExecuteTask(task);
}
protected override IEnumerable<Task> GetScheduledTasks()
{
return _tasks.ToArray();
}
public void Dispose()
{
_tasks.CompleteAdding(); // We do not need to add tasks anymore.
_mainThread.Join(); // Waiting to complete all tasks.
_tasks.Dispose();
}
}
How to check that it’s working?
using var scheduler = new SingleThreadTaskScheduler();
var taskFactory = new TaskFactory(scheduler);
var numberOfTasks = 10;
var tasks = new List<Task>();
foreach (var i in Enumerable.Range(0, numberOfTasks))
{
int taskNum = i;
var task = taskFactory.StartNew(() =>
{
Console.WriteLine($"Starting task {taskNum} on thread: {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(50); // Simulate work
Console.WriteLine($"Finished task {taskNum}");
});
tasks.Add(task);
}
await Task.WhenAll(tasks);
MKasperczyk GitHub – TaskSchedulerApp
- I used BlockingCollection to ensure thread-safety when adding tasks. We can add tasks to it from multiple threads. It also provides the GetConsumingEnumerable method, which waits for a new item to appear in the queue, retrieves it, and then processes it. If the queue is empty, the loop 'sleeps’ without consuming CPU power.
- In the constructor, I create a thread that will execute every task.
- ProcessTasks – This is the most important method; it executes tasks one by one. If there are no tasks left, it waits until a new one appears.
A very simple implementation that shows the general workings of a TaskScheduler.
Of course, here is the translation:
Summary
TaskScheduler is a mechanism that is already so well-refined that the default solution requires no modifications, and you will likely never have to write your own implementation. But it’s worth knowing how it works, and we have covered the basics of what it is and its fundamental algorithms.
It’s important to remember about LongRunning tasks, as this can increase your application’s performance. There are other options you can read about, and although you probably won’t use them, it’s good to be aware of them.
Bibliography
- https://learn.microsoft.com/pl-pl/dotnet/fundamentals/runtime-libraries/system-threading-tasks-taskscheduler
- https://www.reddit.com/r/csharp/comments/5sfcon/creating_task_scheduler_in_c/
- https://cezarywalenciuk.pl/blog/programing/asynchroniczny-c–synchronizationcontext-i-taskscheduler
- https://www.reddit.com/r/dotnet/comments/pfz82s/what_is_exactly_a_c_task