Use code generators to transform a .NET codebase to async/await

Dominic Ullmann
ELCA IT
Published in
6 min readFeb 22, 2022

The asynchronous programming model with async/await has the potential to solve many issues related to responsiveness, performance, and scalability. However, migrating an existing code base to async/await is a tough task with uncertainties. In this article, I am showing 2 approaches and providing some recommendations on how to speed up and simplify this migration by automating part of the transition using code generation.

Although Microsoft has introduced the async/await feature quite some years ago with C# 5 / Visual Studio 2012, we still see code that is not yet using this programming model although it would be beneficial.
To leverage this programming model, all the main libraries you use should offer an async API so that your async methods don’t block threads needlessly. For some libraries, it took some time to support this (e.g. NHibernate provided it in Version 5 end of 2017). Meanwhile, this should be true for most libraries and therefore now could be a good time to upgrade your code to the async/await asynchronous programming model.

async/await optimizes system resource usage (especially it reduces the time threads are just waiting). Photo by hp koch on Unsplash

As a showcase for this migration, I will start from the sevices migrated to .NET core in the following article: Migrating a large codebase from .NET 4.8 to .NET (Core) and just add some dummy implementations to show the migration process.

This code is organized in multiple layers: the outermost layer is the web API and the innermost is the repository.

Migration strategies

Conceptually, there are two possible ways to start the migration: Start at the outermost layer and go inwards from there or start with the innermost layer and go outwards. In the second approach, we potentially have to fix many callers potentially quite early when touching e.g. a heavily used method on the innermost layer. If we start from the outside, we could start easily but as soon as we change one of the inner layers, we have to adapt potentially many callers and run into the same problem.

Before we address this issue, let’s have a look at what the code would look like for the 2 cases. If we start with the web API we would do the following:

We adapt the signature to the async/await signature, but we block inside and just wrap the synchronously acquired result into a task result. By doing this we don’t benefit as we’re only using synchronous APIs inside. So the control flow could not return while we are waiting for the database I/O to complete. Additionally, we have to touch this code again to call the lower layer async APIs as soon as they are migrated.

If we start from the inside, the repository would change like this:

We change the signature and call the asynchronous NHibernate API instead of the synchronous one. In this case, the LoadFromDb changed from a blocking method to a non-blocking one. The issue here is, that all the users of IRepository1.LoadFromDb needs to be adapted as well now. Those callers would then use the following pattern to wait synchronously for an asynchronous operation:

We are accessing the Result property of the Task returned from LoadFromDb to wait for the async operation to complete, so that we can return the result to the caller of DoOperation1Sync. Note that synchronously waiting for an async operation to complete can lead to thread pool exhaustion and/or deadlocks and should therefore just be used as an intermediary step.

Changing all the code in the Repositories as well as the BusinessServices to async methods is quite some work and potentially error-prone. It also forces us to touch many code locations which makes it difficult to introduce this incrementally and in parallel to normal development work. To better support incremental migration, we will adapt the start from the innermost layer approach. Instead of transforming the synchronous method to an asynchronous one and forcing all the callers to adapt, we will just add the asynchronous implementation additionally. The repository will look like this then:

With this approach, we have the advantage that we can migrate the callers one after the other and therefore perform the migration in much smaller steps. Sadly, this approach also has a big downside at the moment. We now have all the code in the inner layers mostly duplicated. This is very problematic for maintainability.

Improved migration strategy

Luckily we don’t need to write the additional async version by hand. We can generate it automatically from the synchronous implementation. And there is a very useful tool for this: AsyncGenerator. This tool was used e.g. by the NHibernate team to introduce async support for NHibernate while still keeping the synchronous APIs functional.

This generator takes a list of projects, scans for missing async implementations, and will generate them based on the synchronous implementation. To start, we add the async signature to the interface, but will not provide an implementation. The tool will then add the async version by rewriting the synchronous code so that it calls the equivalent async methods. When we are done with the innermost layer, we can continue with the next higher layer and also let the async generator generate the code for it. For the outermost layer, we probably don’t need to keep the sync implementation. We could just take the migrated code and replace the sync version with it. When we are done with the outermost layer, we can then also cleanup the inner layers and start replacing the synchronous code with the async one and remove the code generation for the fully migrated classes. The following diagram summarizes this approach:

The outermost layer then looks like this after the completion of the migration:

If you are not happy with what the AsyncGenerator generates for a specific part, you could extract the relevant sync part into your own sync method and provide the async implementation for this part yourself:

Integrating with synchronous libraries

Some libraries still don’t support the asynchronous programming model (e.g. some libraries for accessing external systems), but they can still be used in this approach. The AsyncGenerator will just call these synchronous APIs as needed and wrap the calls in e.g. a Task.FromResult invocation when needed as shown in the example for the “starting with the outermost layer” approach above.

In the case you need to integrate with a long-running synchronous task, you could wrap such calls into a Task.Run invocation to prevent blocking the caller:

Cancellation Support

The asynchronous programming model also supports cancellation. The AsyncGenerator can also generate and use methods supporting CancellationTokens. If you let it generate signatures with CancellationTokens it will pass them on to the called methods (if they support cancellation). The generated code looks then e.g. like this:

Conclusion

Adapting an existing synchronous code base to the asynchronous programming model looks like a huge effort. But when choosing a clever approach, it can be done in a quite convenient way without the need to stop further development of the software during the migration. The approach shown here uses a generator tool to fill in the asynchronous implementations from the synchronous ones without breaking the existing code. This tool has proven its usefulness already for the migration of the NHibernate code base, but the tool can be used for any other project as well. In comparison to the fully manual approach, it becomes feasible now to introduce the asynchronous programming model into a large older synchronous code base.

In one of ELCA’s projects, we have migrated a codebase of around 500'000 lines of C# to async/await, in parallel to the normal development. Using the above approach we were able to focus on parts of the application where we expected the most benefits. Due to the code generation idea, we did not introduce maintainability issues due to duplicated code or scalability issues due to synchronous waiting for asynchronous code. We were also able to write new code fully async easily as we could just generate missing async implementations if existing synchronous only code would have to be called.

--

--