OdeToCode IC Logo

await the async Letdown

Monday, March 4, 2019

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.

Old Code Is Still Sync Code

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.

New Code Is Sometimes Still Sync Code

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 API is a Minefield

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.

Good Code Turns Bad

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.

Awaiting the End

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.


Comments
Gravatar bill Monday, March 4, 2019
Finally, someone said it out loud. The edge cases make me nuts...
Gravatar Morten Mertner Tuesday, March 5, 2019
I completely agree. Reusing Task was a huge mistake (especially considering that there were plenty of viable alternatives: Promise, Future, etc.). Let's also not forget all the pitfalls of TaskCompletionSource (don't forget the RunContinuationsAsynchonously) and Task.Delay (hidden timer allocations). I'm still hoping that one day we can configure the default for ConfigureAwait on a per-project basis..
Gravatar Chris Marisic Tuesday, March 5, 2019
Kudos Scott, Bill and Morten. So many times I've talked to developers, avoid async KEYWORD unless you have no choice at all, or you really truly know where you have actual long IO waits. I've even benchmarked that database connectivity is uniformly worse with async, maybe there is some sweet spot it excels past synchronous but whether it was 10M rows or 1000M rows i was working with synchronous was always clock measured faster. Pretty much every single usage of Task.StartNew is wrong. You literally need a whole book on knowing when StartNow is actually the correct usage. (Thank you stephencleary) The debacles of ConfigureAwait and how that creates insane traps for library developers. [Was this ever fixed?]
Gravatar Alois Kraus Tuesday, March 5, 2019
Your first task example new Task(work) is different because it will not run the task right away. You need to start it. The other examples all create and start the task right away. I also tell developers to stay away from the Task Apis because no one understands what the compiler is doing to your code and when exactly what will be executed. Simply starting tasks all over the place is not the right solution because if you schedule nearly unbound work you will end up with a starved threadpool and some nasty deadlocks if some tasks depend on global locks and such things. In my opinion developers should learn to write allocation free code with Span T, System.IO.Pipelines, ... to take the sleeping giant which blocks all of your threads (the Garbage Collector) out of your hot code path. That will give you much better scalability than simply throwing many tasks at your problem just to discover that once your code is running on all cores that you are allocation and hence GC bound.
Gravatar scott Tuesday, March 5, 2019
@Alious: Yes, those are all pieces of the problems I'm describing.
Gravatar Glenn Watson Wednesday, March 6, 2019
There is the System.Reactive framework for handling that delegate case in the first instance. Allows you to asynchronous processing of event based structures with a Linq like API. Not really async per say but when you're doing more of a "push" based model can be useful.
Gravatar Sean G. Wright Wednesday, March 6, 2019
@Alois "In my opinion developers should learn to write allocation free code with Span T, System.IO.Pipelines" Now that we have these nice new APIs, patterns, and paradigms I think this is a great suggestion! But we will have to interact with asynchronous code at some point and the existing APIs are a minefield. If they weren't then Stephen Cleary wouldn't have needed to write 100 blog posts and answer 10,000 Stackoverflow questions all covering Task.*
Wednesday, March 6, 2019
Couldn't agree more
Your Comment