A few notes on C# async
I recently started using C# 5.0’s async/await extensively in a side project, while also testing our product’s support for the feature. Interactions between language features always provide plenty of opportunities for bugs. My test case was ADO.NET, whose public API in .NET 4.5 is full of asynchronous versions of existing methods. It was an interesting experience, so here are my observations:
-
At last, asynchronous code looks almost as straightforward as synchronous code. Asynchronous APIs finally look the way they should have from the beginning, without the machinery of
BeginSmth()/EndSmth()/IAsyncResult, or the inverted control flow of the event-based asynchronous pattern, complete with CLR events that are difficult to compose.What still concerns me is how asynchronous code should coexist with the rest of the codebase. Monads and comonads can be awkward at their boundaries with ordinary code or other monads: somewhere, an asynchronous operation may need to be waited on synchronously, defeating the purpose of
async, while there is no honest escape from theIOmonad at all. In parts of an application that are naturally asynchronous, such as UI event handlers, this is often less of a problem: they can work on a fire-and-forget basis, with no caller needing to await the resulting tasks. Elsewhere,Task<T>starts spreading through return types, including methods that do no lengthy work themselves but call other methods already returningTask<T>. -
As soon as cancellation is needed, explicitly passing and storing
CancellationTokenvalues everywhere starts to clutter the code. API designers also have to provide overloads with and without a token. F#’sAsync<T>has a clear advantage here. For this purpose, a rough analogy is a task with an implicitly propagated cancellation token. An asynchronous computation can retrieve the current token and register code to run when cancellation is requested, without changing the caller’s code. You can start using cancellation when you need it, without first making sure a token has been threaded through every operation.The biggest problem is how easy it is to forget to pass the token in C# async code. I managed to do this repeatedly. Missing a token at one of several call sites often causes no obvious failure: the method just stops a little later. These omissions are difficult to find unless cancellation is tested regularly. My code ran in an infinite loop, polling web services, so I used cancellation every time I shut down the application. That was what helped me find the missing tokens quickly.
The first draft of this post contained the sentence “Detecting omitted
CancellationTokenarguments would be a useful feature for an IDE tool.” Writing the post took long enough that I had time to implement it in our product:
-
When a language allows constructs that serve little purpose, people will inevitably use them. In simple forwarding cases, for example, an
awaitin tail position can be redundant, adding an otherwise empty continuation:private async Task CannotBeInCancelledState(CancellationToken token) { await Task.Run( cancellationToken: token, action: async () => { await smth; }); }This is another useful inspection for an IDE tool, especially since I recently wrote a detector for tail positions in C#. I keep seeing these redundant tail-position
awaitexpressions in blog posts and code from colleagues, and I have written a few myself. -
If you are designing an asynchronous API with cancellation support, think about what callers should expect when cancellation is requested. The SQL Server part of ADO.NET sometimes throws
SqlExceptionwhen an asynchronous operation is canceled, leaving the task in theFaultedstate. I could not pin down exactly when this happens; perhaps it is during the early stages of sending a SQL query to the server.At other times, it throws the conceptually appropriate
OperationCanceledException. This exception can carry theCancellationTokenthat caused the operation to stop up the stack, allowing the task associated with that token to recognize cancellation and transition toCanceled. That is how I would expect it to work, but ADO.NET does not always preserve the token. Consider this example; resources are deliberately left undisposed to keep it focused:var source = new CancellationTokenSource(); var connection = new SqlConnection(connectionString); await connection.OpenAsync(source.Token); // create a command with an expensive query and execute it asynchronously var command = new SqlCommand("select count(*) from [?]", connection); var task = command.ExecuteScalarAsync(source.Token); Thread.Sleep(100); // wait briefly source.Cancel(); // request cancellation await task.ContinueWith(t => { Console.WriteLine("Status: {0}", t.Status); try { Console.WriteLine("Result: {0}", t.Result); } catch (AggregateException exc) { var cancelled = exc.InnerExceptions.OfType<OperationCanceledException>().First(); Console.WriteLine("== source.Token? {0}", cancelled.CancellationToken == source.Token); Console.WriteLine("== CancellationToken.None? {0}", cancelled.CancellationToken == CancellationToken.None); } });The result is:
Status: Canceled == source.Token? False == CancellationToken.None? TruePreserve the original token. Callers who compare cancellation tokens for equality will appreciate it.
-
I still find it unclear when an async method or a TPL API will expose an
AggregateException, when it will not, and how much I need to worry about the distinction. In my project, any exception is simply logged and the operation restarted after a delay, so I have not investigated deeply. There is plenty of material online about exceptions in asynchronous code, but I would like to know what to expect without having to look it up. -
Asynchronous lambdas and anonymous methods are very welcome; working without them would be painful. They do introduce an inconsistency, though: asynchronous code can be anonymous, while iterator code can only appear in methods. An
iteratormodifier, like the one in the latest VB.NET, could make anonymous iterators possible. That would introduce another inconsistency: existing iterator methods could omit the modifier for compatibility, while lambdas would need it. Eric Lippert has argued that anonymous methods and iterators are both complex transformations, making their combination difficult to justify. Given that lambdas can now be combined with the async transformation, I find that argument less convincing. -
Avoid
asyncmethods that returnvoid, even if you are sure nobody will care when the operation finishes. They exist primarily for compatibility with existing delegate types such asEventHandlerandAction, which do not returnTask. I suspect asynchronous lambdas were especially important here. If an async operation needs to be exposed through one of these delegate types, I would prefer a task-returning implementation with a separatevoidadapter. I have heard that there was considerable discussion about removingasync voidfrom the design before .NET 4.5 shipped, but it remained.There is also a testing trap: without a returned
Task, a test framework may not know whether an asynchronous test has actually finished. Worse, it may report success as soon as the method returns at its first suspendingawait. Detecting this is another useful IDE inspection, planned for the next version of our product. -
Looking at a loop such as
while (await reader.ReadAsync()), it is natural to wonder about the overhead of anawaiton every iteration. A useful property of C# async is that, depending on the API, an entire async method can complete synchronously. In that case, its state machine does not need to be moved onto the heap. The compiler generates a value type for the state machine, which is boxed when necessary: at the firstawaitthat actually suspends execution.This is particularly relevant to the WinRT APIs used by the Metro side of Windows 8, where potentially lengthy operations are asynchronous and have no ordinary synchronous equivalents. When awaited operations complete synchronously, the performance difference can sometimes come down to just a few heap allocations.
There is a trade-off. Unlike a
yield returniterator, an async method is not entirely deferred: it starts executing synchronously and continues until anawaitactually suspends it. With the optimization described above, it can be hard to predict how long this synchronous portion will take. That matters when you call an async method without immediately awaiting it, intending to start work and await the result later. You can introduce an asynchronous boundary withawait Task.Yield();inside the method, or useTask.Run()at the call site. Immediate execution also has benefits: argument-validation code can run straight away, rather than being deferred as it is in an iterator, although exceptions from a task-returning async method are still recorded in that task.Another useful detail of async code generation is that reference fields in the state machine are cleared to
nullwhen they are no longer needed. This avoids a retention problem found in C# iterators: an iterator can keep references to locals even after reaching a state where those locals can never be used again. Enumerable iterators also retain the original parameter values in caseGetEnumerator()is called again. Clearing dead references in async state machines lets the referenced objects be collected sooner. As far as I understand, the compiler omits these assignments tonullwhen optimizations are disabled, so that the values remain available to the debugger. -
Like C# 4.0’s
dynamic, the async transformation is tightly tied to a small set of framework types, particularlySystem.Threading.Tasks.Task. We build our product with the C# 5.0 compiler, but cannot use async because it must run on .NET 3.5: we support Visual Studio versions going back to 2005. We also have our own threading infrastructure, including thread pools and dispatchers. I would like language features to depend less heavily on specific framework types. -
My solution is only a few thousand lines long, so debugging async code has not been a problem for me. Colleagues using it in larger projects, however, often complain about losing stack traces and being unable to tell how execution reached a particular asynchronous call. This is a conceptual problem, but I think it ought to be solvable. Could stack traces be captured efficiently before each
await, at least in debug builds? Otherwise, the Visual Studio debugging experience has been good, apart from stepping into code occasionally jumping around unpredictably atawaitexpressions.