Top-down introduction to Asynchronous Programming in C#
Ships at sea as seen from a ferry from Redwood City to San Francisco, July 2018
In this blog post I’ll give a top-down introduction of 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 you 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.
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 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].
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.
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].
Purdue University, West Lafayette, IN, I think sometime during winter 2018–2019
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.
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.
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;}
A tree at Purdue University, West Lafayette, IN, 1st or 2nd week of October, 2018
This section is unnecessary 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);}
A bridge I saw from the window of Amtrak en route to Portland from Seattle or maybe the other around
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 1Runtime: 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 8Runtime: 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 await
ed 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 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.
Santa Monica on a hottest August day in 2018 (my most favorite picture of the past 12 months)
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.
[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.
Originally published at deeptanshumalik.com on July 3, 2019.