Asynchronues logo

Asynchronous Programming

Karol Rossa
CodeX
7 min readMay 17, 2022

--

I want to cover a couple of things regarding asynchronous programming in .NET. The main concept can also be applied to other languages, so feel free to read through. We will talk about common misconceptions of async and parallel programming and how to decide on the proper approach. I will also describe how async works. In the following articles, I will show an example of a performance boost that you can achieve using an asynchronous approach. We will also discuss how to use async in synchronous context. Hopefully, you will never have to do it :)

Async vs. Parallel

In parallel programming, work is done in parallel :) at the same time. The amount of work that can be done simultaneously depends on the number of cores of the processor. It is most useful when dealing with a job heavy on CPU processing time and can be split into smaller chunks.

Parallel Processing
Parallel Processing

We can implement asynchronous code even on one core, but it can take advantage of multi-core machines. Async is not about doing multiple units of work simultaneously but switching between tasks that currently need computing time. It can save a lot of processing time when dealing with anything external to the process, like calls to DB or HTTP Clients. After making a call to a network resource using HTTP Client, we need to wait for a response. Instead of blocking the thread, we could complete additional work on it.

Asynchronous Processing
Asynchronous Processing

What is Async

Asynchronous programming is about releasing threads awaiting external resources so we can use them for other work. I have mentioned before that async can work on a single thread, but today almost all machines are equipped with multi-core processors. I will show you how we can benefit from this. Also, keep in mind that we have to use async from top to bottom of the call stack while writing async code.

Under the hood, async creates a state machine. You can find countless articles comparing async to boiling water or preparing food in the kitchen. This time we will talk about how it works. Let’s look at the example below and go over it step by step.

Code Repository

Dump() is my custom function for printing information into console.

Code Version A
Code Version A
Code Version B
Code Version B

Code B is different only by the amount of work done in ProcessAsync before awaiting task from ExternaAsync.

Result A
Result A
Result B
Result B

1 The entry point to our application. Thread 1 takes care of the work.

2 Nothing exciting yet, just initializing our process and calling the ExternalAsync function like a standard synchronous function. Thread 1 also completes this work.

3 Initialize external work synchronously still on thread 1. But now, we hit the first await in our program. Task.Delay(100) symbolizes an async call to external resources like the HttpClient.GetAsync function. At this time, work is delegated to an external driver, and we have to wait for the result. But instead of waiting and doing nothing, we immediately return to the ProcessAsync function.

After returning to ProcessAsync, we can complete additional work while the HTTP request runs. We can also notice that async can work in parallel. Now we will consider both cases.
A (1_000) — a small amount of work that is done before ExternalAsync can finish.
B (1_000_000_000) — a heavy workload that takes more time to finish than ExternalAsync

Version A

4 In the output, we can see that both checkpoints ‘PROCESS more work 1/2’ appear one after the other on thread 1. We have nothing else to do when we hit await on the task. Task has not been resolved yet, and we need to wait for it. So thread 1 is released to the thread pool and can be used for something different, like handling another call to the controller.

5 External work is finally done, and execution continues on a new thread 5. After finalizing work in the ExternalAsync method, we return the result to the ProcessAsync.

6 Only external call is executed in parallel, but this is handled by some external driver and not by our application. In this example, work in ProcessAsync continues on thread 5. Our code is executed in sequence, but we do not block thread 1 when waiting for an async call. This alone gives our application a performance boost.

Version B

4–5 After investigating the output, we can see that the task in the ExternalAsync method has finished before more work in the ProcessAsync and was executed in parallel.

  • Thread_Id(1) PROCESS more work 1
  • Thread_Id(5) External finalize
  • Thread_Id(1) PROCESS more work 2

We have achieved parallel processing without any additional code, just by knowing how async works and calling ExternalAsync, and awaiting its task later in code. We can see it clearly because of different thread ids (1–5). If we awaited ExternalAsync on line 4, parallelization of our code would not be possible.

6 In this example, when hitting await in the ProcessAsync, the task was already resolved, so we can continue work on the same thread 1. I hope that we will be able to write better asynchronous code that also takes advantage of parallelization with this knowledge.

How Async Works

Three parts define asynchronous: async, await, and Task. Async creates a state machine with one initial state. Each await adds one additional state, so the number of states equals the number of await + 1. The Task is a wrapper around the result. The Task contains information on the status and result and can be awaited.

We have learned that each await adds a state to our state machine. We should avoid it if possible. For example, when adding an abstraction layer with setup or logging (like PROXY), we are just passing through a call to an async method. Keep in mind that we have to await all Tasks in our code base eventually. If an asynchronous method that is not eventually awaited throws an exception, that exception will be lost, and we will not have any recollection of an error.

Returning Task
Returning Task

Tips and Notes

Asynchronous programming is a vast topic. I can’t cover everything in a single article. I have mentioned stuff that will allow you to write efficient async code. Now we will talk about a couple more guidelines. Again we will touch on the basics of each topic.

Cancellation Token

When writing async code for your use, you can omit to pass the Cancellation Token if you are not going to use it. It allows you to cancel long-running Tasks. It would be best to remember it when creating libraries for general use. Just add it as the last parameter with the default value.

Cancellation Token
Cancellation Token

SynchronizationContext and ConfigureAwait

The main aspect of SynchronizationContext is to provide a way to queue a unit of work to a context. Context is not bound to a single thread and can be passed between them. It is primarily used in applications with UI like WPF or Windows Forms. It allows you to do background work and, while returning, make changes in the UI thread thanks to passing SynchronizationContext. A big change in .NET Core was to remove SynchronizationContext from ASP.NET.

ConfigureAwait is used to avoid forcing the callback to be invoked on the original context. By default, .NET uses the original context. Not passing the original context to the callback function has many benefits.

ConfigureAwait(continueOnCapturedContext: false)

Improved Performance. When all callbacks are queued to the same context, it adds delay in executing them in order. Sometimes just checking for SynchronizationContext can add unwanted lag into the path.

Avoid Deadlocks. In some rare situations, when async code is executed synchronously (.Result) and SynchronizationContext limits the number of operations that can run on it to 1. Imagine a situation when the main thread uses the context. The async method is invoked on it with .Result which makes it a synchronous call that blocks the main thread. Now the async method finishes on a different thread and can’t access context because it is blocked in the main thread.

You should always avoid forcing the use of the original context if not necessary by configuring await to false.

client.AsyncCall().ConfigureAwait(fasle)

ValueTask

ValueTask is a struct that wraps either a value or a task and returns it. It should be used as a return type for methods that could run either synchronously or asynchronously. For example, a method that can retrieve value from cache or needs to make an async call to an external resource.

ValueTask
ValueTask

Task allocation takes more resources than returning a plain value. If a method is executed synchronously, there is no need to create a Task. So ValueTask wraps a plain integer value. This works because ValueTask implements necessary methods, especially GetAwaiter(), and can be consumed by the caller as Task. Here you can check the difference in memory allocation.

There are just a couple of operations that should not be performed on a ValueTask instance:

  • Awaiting the instance multiple times.
  • Calling AsTask multiple times.
  • Using .Result or .GetAwaiter().GetResult() when the operation hasn’t yet completed, or using them multiple times.

--

--