Microsoft added async features to the C# language with more than the usual fanfare. We were told async
and await
would fundamentally change how .NET developers write software, and future development would be async by default.
After awaiting the future for 7 years, I still brace myself anytime I start working with an async codebase. It is not a question of if, but a question of when. When will async let me down and give me a headache?
The headaches usually fall into one of these categories.
Last fall I was working on a project where I needed to execute code during a DocumentProcessed
event. Inside the event, I could only use async APIs to read from one stream and write into a second stream. The delegate for the event looked like:
public delegate void ProcessDocumentDelegate(MarkdownDocument document);
If you’ve worked with async in C#, you’ll know that the void
return type kills asynchrony. The event handler cannot return a Task
, so there is nothing the method can return to encapsulate the work in progress.
The bigger problem here is that even if the event handler could return a Task
, the code raising the event is old code with no knowledge of Task
objects. I could not allow control to leave the event handler until my async work completed. Thus, I was facing the dreaded "sync over async" obstacle.
There is no getting around or going over this obstacle without feeling dirty. You have to hope you are writing code in a .NET Core console application where you can hold your nose and use .Result
without fear of deadlocking. If the code is intended for a library with the possibility of execution in different environments, then the saying abandon hope all ye who enter here comes to mind.
When working with an old code base you can assume you’ll run into problems where async code needs to interact with sync code. But, the situation can happen in new code, and with new frameworks, too. For example, the ConfigureServices method of ASP.NET Core.
public void ConfigureServices(IServiceCollection services) { // ... }
There are many reasons why you might need async method calls inside ConfigureServices
. You might need to obtain an access token or fetch a key over HTTPs from a service like Key Vault. Fortunately, there are a few different solutions for this scenario, and all of them we move the async calls out of ConfigureServices
.
The easiest way out is to hope you are using a library designed like the KeyVault library, which moves async code into a callback, and invokes the callback later in an async context.
AuthenticationCallback callback = async (authority,resource,scope) => { // ... var authResult = await authContext.AcquireTokenAsync(resource, credential); return authResult.AccessToken; }; var client = new KeyVaultClient(callback);
Another approach is to move the code into Program.cs, where we finally (after waiting for 2.1 releases of C#) have an async Main
method to start the process. Finally, you can use one of the approaches described in Andrew Lock’s three part series on Running async tasks on app startup in ASP.NET Core.
The Task
class first appeared in .NET 4.0 ten years ago as part of the Task Parallel Library. Task was an abstraction with a focus on parallelism, not asynchrony. Because of this early focus, the Task API has morphed and changed over the years as Microsoft tries to push developers into the pit of async success. Unfortunately, the Task
abstraction has left behind a trail of public APIs and code samples that funnel innocent developers into a pit of weeping and despair.
Example - which of the following can execute compute-bound work independently for several minutes? (Choose all that apply)
new Task(work); Task.Run(work); Task.Factory.StartNew(work); Task.Factory.StartNew(work).ConfigureAwait(false); Task.Factory.StartNew(work, TaskCreationOptions.LongRunning);
Answer: all of the above, but some of them better than others, depending on your context.
Another disappointing feature of async
is how good code can turn bad when using the code in an async context. For example, what sort of problem can the following code create?
using (var streamWriter = new StreamWriter(context.Response.Body)) { await streamWriter.WriteAsync("Hello World"); }
Answer: in a system trying to keep threads as busy as possible, the above code blocks a thread when flushing the writer during Dispose
. Thanks to David Fowler’s Async Guidance for pointing out this problem, and other subtleties.
After all these years with tasks and async
, there are still too many traps to catch developers. There is no single pattern to follow for common edge cases like sync over async, yet so many places in C# demand synchronous code (constructors, dispose, and iteration come to mind). Yes, new language features, like async iterators, might shorten this list, but I’m not convinced the pitfalls will disappear. I can only hope that, like the ConfigureAwait
disaster, we don’t have to live with the work arounds sprinkled all through our code.