Asynchronous Programming 101


In this blog post I’ll give a top-down introduction to asynchronous programming in C#. This post may be useful for anyone new to C# and for students who have learnt about threads and concurrency. I believe that a top-down approach will be ideal as it’ll first show what async programming is so that you can be interested in the how and why of it.

The code used in this blog is available on my GitHub here.

San Francisco, on some lazy Saturday afternoon in July, 2018.

A quick review of the fundamentals

Latency & Throughput

Latency is a measure of units of time taken to do work. Throughput is measure of the units of work done per unit of time.

Concurrency

Concurrency means multiple computations are happening at the same time[1]. It is different than parallelism as explained in this stackoverflow answer:

  • Concurrency: A condition that exists when at least two threads are making progress. A more generalized form of parallelism that can include time-slicing as a form of virtual parallelism[2].
  • Parallelism: A condition that arises when at least two threads are executing simultaneously[2].

Tasks

A Task is an abstraction that represents an operation[3]. This operation may or may not be backed by more than one thread and it may or may not be executed concurrently. We can use Tasks instead of working with threads directly so that we don’t need to manually, explicitly implement locking, joining and synchronizing operations for threads and we don’t need to manually create or destroy threads directly either. By default in C# Tasks use threads from a Thread Pool. A Thread Pool is a design pattern that maintains multiple threads such that they can be reused/recycled. We can use this design pattern is to reduce the latency of creating, starting and deleting threads repeatedly.

Synchronous versus Asynchronous Operations

A synchronous operation will do its work before returning to the caller whereas an asynchronous operation may do some (including all) part of its work after returning to the caller[3].

An analogy to wrap up these fundamental concepts

Let’s consider a laundromat. If you put clothes in two machines at literally the same time, for example one washing machine with one hand and another washing machine with the other hand at the same time, then you’re loading the clothes in parallel. But if you load clothes simultaneously in two washing machine such that at a given instance in time you are only loading clothes in one washing machine at a time, then you are loading the clothes concurrently.

The time taken by the washing machine to wash one load of clothes will be the latency of a washing machine. To reduce time spent waiting for n loads of clothes to be washed, instead of running one machine n times, n machines can be run at the same time: the work done by a machine takes the same amount of time in both the cases but the difference is that the in the latter case amount of work done at in the same amount of time (n loads washed at the same time) is more, i.e., the throughput is more.

To do all these things maybe you asked the person at the front desk for quarters: you couldn’t do anything at the time, you were at the front of a queue and you gave the person two dollar bills and waited for them to return quarters. This was a synchronous operation. When a washing machine is running you still retain control- you can go ahead and load and start running another washing machine while the first one is still running - this makes the running of a washing machine an asynchronous operation.

Async Programming Demo

Let’s look at a demo to understand how async functions are defined and called and how async programming impacts program latency by improving throughput.

Asynchronous functions in the C# are defined by adding the async keyword to the function definition. They can be called like any other functions. Async Functions in C# must return a Task or some type of Task. In the demo program below we will see how these tasks can be initiated, how they can be run synchronously and asynchronously and how the code flow can be stopped to wait for the result of a task by using the await keyword.

Dummy Synchronous and Asynchronous Functions

Let’s implement a dummy async function:

private async Task<int> longRunningMultiplyBy10(int number)
{
    Console.WriteLine("# Initiated long running op on for the input number=" + number + " from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " #");

    //100 delays of 100 ms = 10 seconds
    for (int i = 0; i < 100 ; i++)
    {
        await Task.Delay(100);
    }

    Console.WriteLine("# Completed long running op on for the input number=" + number + " from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " #");
    return number * 10;
}

In this function we use the native asynchronous Delay function by calling it asynchronously using the await keyword to wait for a total of 10 seconds and then multiply the input number by 10 and return it. We will now see the difference in performance when it is called synchronously and asynchronously. To do so we will implement functions to take to input numbers, perform the dummy long running operation on each of them and return their sum.

The function to call longRunningMultiplyBy10 synchronously is:

private int syncMultiplyBy10AndAdd(int num1, int num2)
{
    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int a = longRunningMultiplyBy10(num1).Result;

    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int b = longRunningMultiplyBy10(num2).Result;

    Console.WriteLine("Executing return from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    return a + b;
}

Let’s write a function that uses asynchronous programming:

private async Task<int> asyncMultiplyBy10AndAddAwaitImmediately(int num1, int num2)
{
    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int a = await longRunningMultiplyBy10(num1);

    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int b = await longRunningMultiplyBy10(num2);

    Console.WriteLine("Executing return from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    return a + b;
}

We can observe here that we don’t need to await on the long running operation until we need to access the Task’s result— let’s write a function to reflect this and observe its performance as well.

private async Task<int> asyncMultiplyBy10AndAddAwaitAtEnd(int num1, int num2)
{
    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    Task<int> a = longRunningMultiplyBy10(num1);

    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    Task<int> b = longRunningMultiplyBy10(num2);

    Console.WriteLine("Executing return from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    return await a + await b;
}

Helper functions to call dummy functions and record their performance

This section is unneccessary and possibly a distraction, skip to next section

We will make a call to demo to call our demo functions and measure their performance as follows:

private void demo()
{
    Console.WriteLine("##### Testing synchronous code #####");
    demoSyncFunction(syncMultiplyBy10AndAdd);
    Console.WriteLine("####################################" + Environment.NewLine);

    Console.WriteLine("##### Testing await immediately #####");
    demoAsyncFunction(asyncMultiplyBy10AndAddAwaitImmediately);
    Console.WriteLine("####################################" + Environment.NewLine);
    Console.WriteLine();

    Console.WriteLine("##### Testing await at end #####");
    demoAsyncFunction(asyncMultiplyBy10AndAddAwaitAtEnd);
    Console.WriteLine("####################################" + Environment.NewLine);
    Console.WriteLine();
}

private void demoSyncFunction(Func<int, int, int> asyncDemoFunction)
{
    int num1 = 15;
    int num2 = 33;
    int result;
    Stopwatch stopWatch = new Stopwatch();

    stopWatch.Start();
    result = asyncDemoFunction(num1, num2);
    stopWatch.Stop();
    displayRuntime(stopWatch.Elapsed);

}

private void demoAsyncFunction(Func<int, int, Task<int>> asyncDemoFunction)
{
    int num1 = 15;
    int num2 = 33;
    Stopwatch stopWatch = new Stopwatch();

    stopWatch.Start();

    int result = asyncDemoFunction(num1, num2).Result;

    stopWatch.Stop();

    displayRuntime(stopWatch.Elapsed);
}

private void displayRuntime(TimeSpan ts)
{
    string elapsedTime = String.Format("{0:00}min:{1:00}s.{2:00}ms",
        ts.Minutes, ts.Seconds,
        ts.Milliseconds / 10);

    Console.WriteLine("Runtime: " + elapsedTime);
}

The Results

The output is as follows:

For the function syncMultiplyBy10AndAdd

private int syncMultiplyBy10AndAdd(int num1, int num2)
{
    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int a = longRunningMultiplyBy10(num1).Result;

    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int b = longRunningMultiplyBy10(num2).Result;

    Console.WriteLine("Executing return from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    return a + b;
}

The output is:

##### Testing synchronous code #####
Calling long running Op from Thread 1
# Initiated long running op on for the input number=15 from Thread 1 #
# Completed long running op on for the input number=15 from Thread 8 #
Calling long running Op from Thread 1
# Initiated long running op on for the input number=33 from Thread 1 #
# Completed long running op on for the input number=33 from Thread 8 #
Executing return from Thread 1
Runtime: 00min:20s.08ms
####################################

We observe that Thread 1 began executing syncMultiplyBy10AndAdd. When it called longRunningMultiplyBy10 the execution of syncMultiplyBy10AndAdd was blocked. Thread 1 then began the execution of the long running operation which ran asynchronously. As the function ran asynchronously Thread 1 was released from the task of executing longRunningMultiplyBy10 . However, because syncMultiplyBy10AndAdd is synchronous, when longRunningMultiplyBy10 completed Thread 1 remained responsible for executing syncMultiplyBy10AndAdd throughout. We note that the function took 20s.08ms to execute.

Now when the function asyncMultiplyBy10AndAddAwaitImmediately was executed:

private async Task<int> asyncMultiplyBy10AndAddAwaitImmediately(int num1, int num2)
{
    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int a = await longRunningMultiplyBy10(num1);

    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    int b = await longRunningMultiplyBy10(num2);

    Console.WriteLine("Executing return from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    return a + b;
}

The output was

##### Testing await immediately #####
Calling long running Op from Thread 1
# Initiated long running op on for the input number=15 from Thread 1 #
# Completed long running op on for the input number=15 from Thread 7 #
Calling long running Op from Thread 7
# Initiated long running op on for the input number=33 from Thread 7 #
# Completed long running op on for the input number=33 from Thread 8 #
Executing return from Thread 8
Runtime: 00min:20s.04ms
####################################

We observe that Thread 1 began executing asyncMultiplyBy10AndAddAwaitImmediately. The difference this time is that because the call to longRunningMultiplyBy10 was asynchronous Thread 1 was released from the task of executing asyncMultiplyBy10AndAddAwaitImmediately and another thread from the thread pool, Thread 7, resumed the execution. We note that the function took 20s.04ms. This is definitely faster than the synchronous-calls implementation and we can intuitively see how at scale in the real world the improvement in latency will be significant.

We can also observe an inefficiency: we awaited the completion of the long running operation earlier than when we needed to use its result. So although we used the thread pool to assign the task of executing asyncMultiplyBy10AndAddAwaitImmediately more efficiently to a thread by using async programming, we didn’t leverage the full power of async programming to allow long running operations (in this case longRunningMultiplyBy10) to run concurrently with the execution of the caller function (in this case asyncMultiplyBy10AndAddAwaitImmediately).

When we address this inefficiency and don’t await the results of the tasks until we need to use them and then test our function

private async Task<int> asyncMultiplyBy10AndAddAwaitAtEnd(int num1, int num2)
{
    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    Task<int> a = longRunningMultiplyBy10(num1);

    Console.WriteLine("Calling long running Op from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    Task<int> b = longRunningMultiplyBy10(num2);

    Console.WriteLine("Executing return from Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId);
    return await a + await b;
}

we get the following result:

##### Testing await at end #####
Calling long running Op from Thread 1
# Initiated long running op on for the input number=15 from Thread 1 #
Calling long running Op from Thread 1
# Initiated long running op on for the input number=33 from Thread 1 #
Executing return from Thread 1
# Completed long running op on for the input number=33 from Thread 8 #
# Completed long running op on for the input number=15 from Thread 6 #
Runtime: 00min:10s.04ms
####################################

The execution of the long running operation started as soon as it was called. The executions of the caller function and the long running operations continued concurrently. And the runtime of the effectively asynchronously coded function was only 00min:10s.04ms: that’s a solid 50% latency improvement over the synchronous implementation that called 2 long running operations. Since the asynchronously coded function operation calls the 2 long running ops in a way that allows them to run concurrently, the 50% improvement is as expected.

What is Async Programming and How it Works

As we have now observed, async programming allows us to effectively utilize threads to run tasks concurrently. When an async function is called the control is returned to the caller function immediately (as soon as the synchronous part of the asynchronous function is completed) and the execution of the caller and the called functions continues concurrently on separate threads until the caller function must await the completion of the called function. This helps improve the overall program latency (the total time the program takes to run) by increasing the throughput (the work done per unit of time) by effectively utilizing the thread pool to assign instances of tasks to different threads that run concurrently.

References

[1] https://web.mit.edu/6.005/www/fa14/classes/17-concurrency/
[2] Defining Multithreading Terms Oracle Multithreaded Programming Guide
[3] Chapter 14 Concurrency & Asynchrony of C# 5.0 in a Nutshell: The Definitive Reference Fifth Edition by Joseph Albahari, Ben Albahari (Author)

A shoutout to Hilton Lange: his explanation of asynchronous programming and demo async program inspired me to write this blog.

Related Posts

Pictures I clicked in the past 12 months that I am in awe of today

I was recently going through pictures that I had clicked in the past 12 months. As I did so I came across pictures I am in awe of today. This post is a collection of some of those pictures.

A Most Life Affirming Article by Riva Tez

“To be in awe. That’s what the days are for.”

Sky Colors in US Tech Hubs

Using open data to compare daily sky colors all year long in the major four major US tech-hubs of San Francisco, Seattle, Boston, New York City

Concurrent Programming Reading list

This blog post is a collection of articles about threads, locks and concurrency.