Since we’ve started diving into multithreading, we can’t skip a crucial concept like SynchronizationContext which is used in async/await and other places like UI synchronization. But what exactly does it do?

To illustrate the problem, let’s imagine a carpenter’s workshop with three separate workstations (our threads). Certain notes, like dimensions and other specifics, are written on chalkboards. Each workstation has its own chalkboard (representing our data).

Each workstation is essentially a separate working environment, and our chalkboards are the data. Sometimes, a task can be completed entirely within one workstation. Other times, we might need to „jump” to a different workstation to continue working on something more extensively. What controls which workstation (or thread) we move to? That’s precisely the role of SynchronizationContext.

In the real world, different projects might use different environments. The same applies to the programming world. For example, WPF can only refresh its UI after returning to the main thread. If something is executed outside this thread, you might end up with a blocked UI.

Managing how threads should synchronize for different „projects” or, in our tech world, frameworks like WPF, Blazor, or ASP.NET, would be quite difficult and complex. This is exactly what our SynchronizationContext takes care of.

What is SynchronizationContext?

  • SynchronizationContext is an abstraction that allows you to dispatch tasks (delegates) to be executed in a specific context, rather than on a particular thread.
  • Every thread can have a current SynchronizationContext assigned to it, and asynchronous code can use this information to correctly synchronize operations.
  • SynchronizationContext enables the creation of components that function correctly, regardless of whether they are running in a desktop application, a web application, or a service.

Task-based Asynchronous Pattern

Microsoft Task Base Asynchronous Pattern

If a synchronization context (SynchronizationContext object) is associated with the thread that was executing the asynchronous method at the time of suspension (for example, if the SynchronizationContext.Current property is not null), the asynchronous method resumes on that same synchronization context by using the context’s Post method. Otherwise, it relies on the task scheduler (TaskScheduler object) that was current at the time of suspension. Typically, this is the default task scheduler (TaskScheduler.Default), which targets the thread pool. This task scheduler determines whether the awaited asynchronous operation should resume where it completed or whether the resumption should be scheduled. The default scheduler typically allows the continuation to run on the thread that the awaited operation completed. (Microsoft Learn)

Three Key Aspects

  • Queuing Work to the Context – SynchronizationContext allows you to dispatch tasks asynchronously (Post) or synchronously (Send) for execution within a specific context. This ensures, for instance, that operations completing background work can safely update the UI, regardless of the platform.
  • Current Context on the ThreadEach thread has a current SynchronizationContext assigned to it (SynchronizationContext.Current). This can be shared by multiple threads or even changed, though that’s less common. This allows for dynamic adaptation of synchronization methods to the current execution environment.
  • Counting Asynchronous Operations – SynchronizationContext tracks the number of ongoing asynchronous operations (OperationStarted, OperationCompleted). This is particularly important in environments like ASP.NET, where a request’s completion depends on all asynchronous operations being finished.

Example implementations

To better understand what’s going on in the implementations, you’d have to delve into the code that’s in the contexts. I’ll leave a link to github, but I’ll also show the implementations of the most important methods:
Post method – asynchronous work queuing
Send method – synchronization

Default Implementation

Default synchronizationcontext.cs

public virtual void Send(SendOrPostCallback d, Object state)
{
	d(state);
}

public virtual void Post(SendOrPostCallback d, Object state)
{
	ThreadPool.QueueUserWorkItem(new WaitCallback(d), state);
}

The default SynchronizationContext is a minimalist implementation that queues tasks to be executed on threads from a thread pool (ThreadPool.QueueUserWorkItem in Post). Executes tasks synchronously in Send.

Window Forms

WindowsFormsSynchronizationContext.cs

Its task is to ensure that asynchronous operations and calls from other threads are executed on the main UI thread, which manages the user interface.

ASP.Net

AspNetSynchronizationContext.cs

Ensures that during continuation, data such as HttpContext.Current and other context data related to the HTTP request are available, which is important for the correct operation of the web application.

Own Implementation

We use BlockingCollection for thread-safe queue

private readonly BlockingCollection<(SendOrPostCallback, object)> _items = new();

RunOnCurrentThread method – BlockingCollection has ’GetConsumingEnumerable’ method which will iterate through items if they exist. If not, it will just wait politely for the next tasks, when those are added iteration will resume.

private void RunOnCurrentThread()
{
	// ...
	foreach (var (callback, state) in _items.GetConsumingEnumerable())
	{
		callback(state);
	}
}

CreateCopy – for now We just return current context. We should create a new one, and send the state… But since we don’t have one, for now in the simple implementation we do it as simply as possible.

public override SynchronizationContext CreateCopy() 
{
	return this;
}

The Post method is simple. It simply adds a task to the queue.

public override void Post(SendOrPostCallback d, object state)
{
	_items.Add((d, state));
}

The Send method – checks if we are using the same thread, if so – we simply call the method (deadlock prevention)
simpleContext.Post(_ => { syncContext.Send(s => Console.WriteLine(„The same thread”), null); }, null);

public override void Send(SendOrPostCallback d, object state)
{
	if (Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId)
	{
		d(state);
	}
	else
	{
		//...
	}
}

The Send method – If a new thread – we call the method asynchronously using the post method, but using ManualResetEventSlim – which allows you to wait until the method finishes in another thread.
simpleContext.Send(_ => Log(„Synchronous task #1”), null);

public override void Send(SendOrPostCallback d, object state)
{
	if (Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId)
	{
		//...
	}
	else
	{
		using (var waitHandle = new ManualResetEventSlim(false))
		{
			Post(s =>
			{
				try
				{
					d(s);
				}
				finally
				{
					waitHandle.Set();
				}
			}, state);

			waitHandle.Wait(); // wait until Set is fired
		}
	}
}

Whole Implementation:

SimpleSynchronizationContext.cs

public class SimpleSynchronizationContext : SynchronizationContext, IDisposable
{
    // Thread-safe collections for task to be done
    private readonly BlockingCollection<(SendOrPostCallback, object)> _items = new();
    // All activities will be on this thread
    private readonly Thread _workerThread; 

    public SimpleSynchronizationContext()
    {
        _workerThread = new Thread(RunOnCurrentThread)
        {
            IsBackground = true,
            Name = "SyncContextWorker"
        };
        _workerThread.Start();
    }

    public override void Post(SendOrPostCallback d, object state)
    {
        // Add to items to be done (asynchronic)
        _items.Add((d, state)); // TODO: care of 'IsAddingCompleted' and CompleteAdding()
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        // In case when This thread create new Item to be done. (Deadlock prevention)
        if (Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId)
        {
            d(state);
        }
        else
        {
            // ManualResetEventSlim - is used to signal something from one thread to another
            // in this case We just wait for ending another task.
            using (var waitHandle = new ManualResetEventSlim(false))
            {
                Post(s =>
                {
                    try
                    {
                        d(s);
                    }
                    finally
                    {
                        waitHandle.Set();
                    }
                }, state);

                waitHandle.Wait(); // wait until Set is fired
            }
        }
    }

    private void RunOnCurrentThread()
    {
        SetSynchronizationContext(this);
        
        // If there is any elements, lets consume, if not ... Sleep
        foreach (var (callback, state) in _items.GetConsumingEnumerable())
        {
            callback(state);
        }
    }

    public void Dispose()
    {
        _items.CompleteAdding();

        // Avoid deadlock - when We are in the same thread which is disposed - We cannot Join Thread.
        if (Thread.CurrentThread.ManagedThreadId != _workerThread.ManagedThreadId)
        {
            _workerThread.Join();
        }
        _items.Dispose();
    }
    
    public override SynchronizationContext CreateCopy() 
    {
        // TODO: We could create new SimpleSnychronizationContext with new state, but for now I skip it and just return current. 
        return this;
    }
}

And easy way to demonstrate how it works:

Console.WriteLine($"Main program thread ID: {Thread.CurrentThread.ManagedThreadId}");

using (var syncContext = new SimpleSynchronizationContext())
{
SynchronizationContext.SetSynchronizationContext(syncContext);

for (int i = 1; i <= 3; i++)
{
int itemNumber = i;
syncContext.Post(_ => Log($"Asynchronous task #{itemNumber}"), null);
}

syncContext.Send(_ => Log("Synchronous task #1"), null);
}

void Log(string message)
{
string logEntry = $"[Thread: {Thread.CurrentThread.ManagedThreadId, -2}] | {DateTime.Now:HH:mm:ss.fff} | {message}";
Console.WriteLine(logEntry);
}

And output:

Main program thread ID: 1
[Thread: 6 ] | 20:49:29.139 | Asynchronous task #1
[Thread: 6 ] | 20:49:29.160 | Asynchronous task #2
[Thread: 6 ] | 20:49:29.160 | Asynchronous task #3
[Thread: 6 ] | 20:49:29.161 | Synchronous task #1

Display code

Bibliography

https://learn.microsoft.com/en-us/archive/msdn-magazine/2011/february/msdn-magazine-parallel-computing-it-s-all-about-the-synchronizationcontext

https://www.codeproject.com/Articles/5311504/Understanding-Synchronization-Context-Task-Configu

https://cezarywalenciuk.pl/blog/programing/asynchroniczny-c–synchronizationcontext-i-taskscheduler

https://alperenbayramoglu2.medium.com/synchronizationcontext-configure-await-in-c-2bfc736e1455

Categorized in:

Tagged in: