The Problem with C# 5′s async/await Pattern
C# 5 brings a fantastic new feature … built-in asynchrony (not to be confused with concurrency). The compiler has added two new keywords, async and await, which allows your code to transparently change execution contexts. For example, instead of writing:
Task .StartNew(() => MakeSomeDecisionSlowly()) .ContinueWith(result => ProcessResult(result));
You can simply say:
bool result = await MakeSomeDecisionSlowly(); ProcessResult(result);
While the two pieces of code are exactly equivalent, the second code is so much simpler to understand. Because you don’t really have to think about the fact that there is an asynchronous context switch going on, you are free to focus on your program’s logic, rather than worrying about manually orchestrating what tasks are doing what.
However, after using these features for several days while porting my KhanAcademy windows phone app to windows 8, I’ve come to approach them with a bit of caution. While they are great for low level IO-bound tasks, in my opinion they don’t scale well to higher level API design. A few things to watch out for:
If you use await somewhere in your code, then the containing method needs to be marked with the async keyword, and there are specific rules about return types. Namely, the method must either be void, or return Task<T>. For example, the example above would have to be in a method
public async void DoThatThing() { … }
Or, if you wanted to return the result of ‘MakeSomeDecisionSlowly’ back to the caller, it would have to look like this:
public async Task<bool> DoThatThing() { … }
Which is great, and all, but then it means that the calling method must be decorated with ‘async’ as well. If you are refactoring an existing codebase, this can have the effect of rippling up the inheritance chain with numerous (albeit small) changes … depending on the situation.
Ok, fine, maybe that’s not a huge deal; it’s kind of annoying but I can definitely deal with that kind of refactoring. The big problem I have with async/await is that outside of relatively trivial pieces of code, it can tend to wipe away many benefits of proper abstractions.
Let’s say you are implementing a remote API call, and you have several requirements:
- Process the results if
- You have a network connection (common mobile requirement)
- The server is reachable (what if you’re connected at starbucks, but haven’t accepted the wifi’s terms of use?)
- The server returns a code 200 (bugs happen)
- The server indicates that your credentials are ok (what if the user’s password expired, or was changed by the user on your website?)
- The server’s json response indicates a successful request
- If any of the above conditions are false, notify the user of the problem (with specific text for the situation).
This is a very common scenario in any occasionally connected mobile application, and you can see that there are no less than five distinct decisions that need to be made during the course of an API call. If you use async/await blindly, you have two problems:
First, you are deferring these decisions to the caller … you’ll do a nice asynchronous call, and return a Task<string> with the json results; or if you’re sophisticated, a Task<T> with the results already parsed and converted to a strongly typed object. However, it’s up to the caller to handle exceptions, check for network connections, deal with individual response scenarios (logged out, invalid request, etc.). So you may end up with every call site looking like this:
if (HasConnection())
{
try
{
MyObject result = await MakeRemoteAPICall();
ProcessSuccessfulResult(result);
}
catch(Exception e)
{
ProcessError(e.Message);
}
}
else
{
ProcessError("No Connection");
}
Second, providing further abstractions is difficult because you are limited to a single return type. So if you want to handle all of the stated requirements for the caller, you will have to wrap up the result in a special return value that can provide some details to the user, such as ‘was it successful’, the error message if it wasn’t, and the parsed return value if it was. While the pros and cons of using error codes for return values have been debated for ages; I tend to prefer simpler return values for local APIs, since a complex return value adds an additional burden on the caller to know how to interpret the results. At best, every call site would look something like this:
Result<MyObject> value = await MakeRemoteAPICall();
if (value.WasSuccessful)
{
ProcessSuccessfulResult(value.Result);
}
else
{
ProcessError(value.ErrorMessage);
}
That’s a bit better than before, but still rather verbose for my taste.
I propose that the callback style of API design still has a place for situations like this. Consider the following sample:
MakeRemoteAPICall( result => ProcessSucessfulResult(result), error => ProcessError(error));
For the local consumer of this API, this is logically the same: if the call was successful do this, otherwise do that. But the caller doesn’t have to worry about all the nuances of deciding whether a call was successful or not. They can focus on how to process the results, rather than dealing with all of this ceremony. And after all, isn’t that what we’re all aiming to do, simplify our code?
I would love to hear thoughts about how this kind of code can be made even simpler. Would love to hear any opinions, for or against. Thanks!
edit: lively debate over on Reddit
http://www.reddit.com/r/programming/comments/txhfq/the_problem_with_c_5s_asyncawait_pattern/
Scott Brickey Said,
May 21, 2012 @ 10:24 am
Why wouldn’t your method return a typed object with an Error property? this is the same thing that Web Service calls have done for a while… on the return, just check whether an error occurred… if no error, use resulting data… if error, do something else (for an occasionally connected system, queue the request to some internal buffer so that the request can be resubmitted when connected).
Duarte Nunes Said,
May 21, 2012 @ 10:40 am
The beauty of the TAP (Task-based Asynchronous Pattern) is that it is easily composable. You can easily chain sequences of tasks with ContinueWith, giving you flexibility to abstract complex workflows. The caller can do: var t = MakeRemoveAPICall(); t.ContinueWith(t => ProcessSuccessfulResult(t.Result), TaskContinuationOptions.OnlyOnRanToCompletion); t.ContinueWith(t => ProcessError(t.Exception), TaskContinuationOptions.OnlyOnFaulted). When you have an object or value representing a computation (doesn’t really matter if it’s asynchronous), you can always use it for composition, which is better than nested callbacks.
Joel P Said,
May 21, 2012 @ 10:56 am
Good thinking on this. I do like the idea of moving the “did it succeed?” question into the actual code that calls the service and out of the caller (or callers). Separates concerns much better.
Having done mostly REST apps the past few years, I’ve had great success distinguishing responses broadly between Success, Rejection, and Failure. These map to 2xx, 4xx, and 5xx HTTP response codes, but this three-bucket classification system works for non-HTTP services and subsystems as well.
A Failure indicates the request could not be processed all the way through. There’s no network, the other system is down, an exception was thrown somewhere along the way, etc. Retrying is appropriate, as once the problem is resolved the request should (hopefully) succeed.
A Rejection on the other hand indicates that no technical issues occurred. The app understood your request, performed any desired processing, and has confirmed that this request can not succeed. Retrying a million times would not change anything.
So I’d tweak MakeRemoteAPICall to take three continuations – ProcessSuccessfulResult(result), ProcessFailure(error), ProcessRejection(error). It’s abstract enough to not be tied to how any particular service works, and it keeps the caller’s knowledge of the workings of that service to a minimum, but it provides enough info and context for that caller to intelligently handle the three broad classes of outcomes.
Hoop Somuah Said,
May 21, 2012 @ 11:00 am
First off I’ll make two statements in the interest of full disclosure:
1. I work for Microsoft. No where near the developer tools side of the company but I find that it’s important to point that out
2. Everything I say here is my opinion and in no way reflects Microsoft’s position on anything.
I like your 2 main points:
* simplicity is key, I like the effort being made to make asynchronous code more readable.
* There are performance tradeoffs whenever you use things like this. In the Task world I could call ContinueWith and pass it an option to ExecuteSynchronously which would execute the continuation without scheduling it. I’m unclear how the C# compiler deals with this in the async/await world and not knowing that is mildly frustrating.
One thing that puzzles me is didn’t you have the response handling issue before with the Task pattern as well? I know I always have. In the TPL world you have to use a ContinueWith and you have access to the success or failure of the Task but you still need the complex code patterns to handle the result of the operation.
I think this is a general challenge with C#, one that F# doesn’t have (in my opinion) because they have discriminated unions and when you use those in a “match” expression, it makes it really easy to write easy to read code that handles all the possible results of an operation. The F# compiler will even tell you if you miss a case. I’ve wished for a long time that C# had both discriminated Unions and match expressions to simplify things. When using F#’s asynchronous workflows, there is a notion of let! and do! which when used in an async block, are very similar to the async/await pattern in C#. I’ve found the code to be very readable.
Thanks for the post!
FMM Said,
May 21, 2012 @ 11:02 am
I think a lot of this has to do with how much detail you need in the exceptional cases. If you just care about the “happy path”, and any deviation from it means bail out and throw up an error message, then your API design will be much different than if you had a different logic path to follow (say, wait and retry for no connection, but bail out on a 500 return code) depending on which deviation from the happy path occurred.
This is one of the reasons for case classes and pattern matching in languages like Scala (http://www.scala-lang.org/node/107). Your logic can be very clearly and concisely structured if your results from an async operation could be declared as a case class.
Birchof Said,
May 21, 2012 @ 10:05 pm
I had to delete and restart this comment a few times. but in the end my disagreement with this article comes down to the title. There is no problem with async\await if you use it for what it is intended to be used for, which you partially allude to as low level logic.
From my perspective, the only value of this article is to highlight the fact that like all things the new language features of C# 5 are not a silver bullet. And developers will need to consider when and where they are used. As a result the title really should be. Something along the lines of “When NOT to use async\await in C# 5″.
Damien Said,
May 23, 2012 @ 5:03 am
I don’t get either of your points.
> First, you are deferring these decisions to the caller … you’ll do a nice asynchronous call, and return a Task with the json results; or if you’re sophisticated, a Task with the results already parsed and converted to a strongly typed object. However, it’s up to the caller to handle exceptions, check for network connections, deal with individual response scenarios
If you hadn’t used async/await, and you were returning string or T, you’d be in exactly the same position.
> Second, providing further abstractions is difficult because you are limited to a single return type.
Similarly, if you weren’t using async/await, every method in .NET is already limited to a single return type.
Stephen Cleary Said,
July 18, 2012 @ 2:27 pm
Side note: Your first two code samples are not equivalent. The first one starts a task on a background thread, while the second one executes the task asynchronously on the current thread. This is a common beginner mistake for async.
It’s true that async grows through the code base. This is true even if you’re doing asynchronous code without async/await (which I have done a lot of). It’s a fundamental property of asynchronous code in general, not async/await in particular.
It’s true that async/await error handling is based on exceptions rather than error return values. This is a natural side result of async/await being more functional in nature.
There *are* pain points when mixing async and synchronous code. But those same pain points exist today when mixing asynchronous and synchronous code.
Bottom line: for people who need asynchronous code (like me), async/await is a huge, huge step forward. It’s a tremendously well-thought-out pattern.
Check out my blog for an intro to async – and how to think in async. I’ve also got an AsyncEx library on CodePlex that helps with managing a partially-async code base, though there is no perfect solution (and there cannot be one).
Ed Rowland Said,
July 24, 2012 @ 11:37 pm
I’m not sure I see the problem. async/await supports exceptions across async/await boundaries. This looks very natural to me:
async Task ExecuteJSonRequest(…)
{
WebReponse response t = await MakeWebRequest();
if (response.ReturnCode != 200)
{
throw new Exception(“Request failed.”);
}
JSonPacket packet = await ParseInbackground(response);
await packet.ProcessInBackground();
…
etc.
}
And then the caller handles exceptions as
public async void DoSomeUserAction(…)
{
try {
await ExecuteJSonRequest(); //MUST await to get potential exceptions.
} catch (Exception e)
{
ErrorDialog(e);
}
}
This catches both synchronous and asynchronous exceptions. await will rethrow any exceptions that occur in the async methods.
The absolute unequivocal rule: all error messages originate in an exception, and are caught at the highest level in the calling code as is practically possible.
I highly recommend using inner exceptions as well to preserve complete error context. Error dialogs build message text by walking the chain of inner exception messages. As the exception moves up the calling chain, exceptions are wrapped with outer exceptions that provide progressively more user-friendly messages.
e.g.:
Error
Unable to retrieve user preferences from ‘xyyzzg.com’. (top-level)
Unexpected error occurred while fetching
http://xyyzzg.com/login.asp‘.
(an inner exception).
HTTP Error 303: Proxy authentication failed. (the raw error from HttpGet in an inner-inner exception).