Friday, February 11, 2022

Asynchrony in C#

 Here is a simple example of asynchronous programming in C#:

        async static Task Task1()
        {
            Console.WriteLine("Task 1");
        }

        async static Task Task2()
        {
            Console.WriteLine("Task 2");
        }

        async static void Main(string[] args)
        {
            await Task1();
            await Task2();
        }

A Main method sequentially calling two other methods - Task1 and Task2 respectively. Thanks to magic keywords - async and await this feels intuitive, almost like synchronous programming. Without those two the above code would probably look something like that:

        static void Task1()
        {
            Console.WriteLine("Task 1");
        }

        static void Task2()
        {
            Console.WriteLine("Task 2");
        }

         static void Main(string[] args)
        {
            var scheduler = TaskScheduler.FromCurrentSynchronizationContext();

            var task1 = Task.Factory.StartNew(() =>
            {
                Task1();
            }, default, TaskCreationOptions.None, scheduler);

            var task2 = task1.ContinueWith(task => {
                Task2();
            }, scheduler);

            task2
.Wait();
        }

Which is perfectly valid technically asynchronous programming, it's just not as elegant, although it certainly gives you deeper understanding of what's happening under the hood and most importantly much more control over the process. Speaking of control, have you ever heard of SynchronizationContext? You see, sometimes we find ourselves in a situation where there is only one thread allowed to execute specific code, for instance, a UI thread in WinForms or WPF application. What I mean is, in WinForms or WPF application all your event handlers gonna be executed on the same UI thread because their particular SynchronizationContext implementations were designed to be single-threaded. In case your wondering, WinForms implementation is WindowsFormsSynchronizationContext and WPF one is called DispatcherSynchronizationContext. So, if you have a bunch of calls and only one thread you need some kinda way to queue and order them and that's precisely what SynchronizationContext does. Or doesn't, depending on specific implementation. The default one is thread-free, which basically means it carelessly executes your tasks independently using ThreadPool.

Ok, now let's talk about ConfigureAwait. If your application has a SynchronizationContext, this means the compiler will try to execute continuations Task1 and Task2 on the same thread Main is being executed on:

        async static void Main(string[] args)
        {
            await Task1();
            await Task2();
        }

The above code is equivalent to this:

        async static void Main(string[] args)
        {
            await Task1().ConfigureAwait(true);
            await Task2().ConfigureAwait(true);
        }

But what about this one?

        async static void Main(string[] args)
        {
            await Task1().ConfigureAwait(false); /* Continuation of Main. */
            await Task2().ConfigureAwait(false); /* Continuation of Task1. */
            /* The rest of the body of Main, as in variable definitions, method calls and so on.
                Everything here is also gonna become a continuation.
            */

        }

In this case, you explicitly tell the compiler that you dont care in what SynchronizationContext and on what thread the continuations gonna be executed. This means:

  • Task1 can be executed on a different thread in a different SynchronizationContext than Main;
  • Task2 can be executed either on the same thread as Task1, which is faster than doing it on a different thread or it can be executed on any available thread;
  • Whatever comes after Task2 can be executed either on the same thread as Task2 or any available thread;

Underneath all that syntactic sugar there are three abstractions: TaskScheduler, SynchronizationContext and ThreadPool, specifically in that order.

  1. TaskScheduler schedules your tasks for execution.
  2. SynchronizationContext synchronizes task execution.
  3. ThreadPool executes your functions using a bunch of threads it's in charge of, so it couldn't care less about your asynchrony.

Here is how you create a TaskScheduler from the current SynchronizationContext:

        var scheduler = TaskScheduler.FromCurrentSynchronizationContext();

And that's how you create a continuation using a callback and the scheduler we
previously obtained from the current SynchronizationContext:

         static void Main(string[] args)
        {
            var scheduler = TaskScheduler.FromCurrentSynchronizationContext();

            var task1 = Task.Factory.StartNew(() =>
            {
                Task1();
            }, default, TaskCreationOptions.None, scheduler);

            var task2 = task1.ContinueWith(task => {
                Task2();
            }, scheduler);

            task2.Wait();
        }

That way, Main, Task1 and Task2 are gonna be executed on the same thread and in the same SynchronizationContext.

 

No comments:

Post a Comment