Asynchrony in C# 5, Part Seven: Exceptions

Asynchrony in C# 5, Part Seven: Exceptions

Rate This
  • Comments 5

Resuming where we left off (ha ha ha!) after that brief interruption: exception handling in "resumable" methods like our coroutine-like asynchronous methods is more than a little bit weird. To get a sense of how weird it is, you might want to first refresh your memory of my recent series on the design of iterator blocks, particularly the post about the difference between a "push" model and a "pull" model. Briefly though:

In a regular code block, a try block surrounding a normal "synchronous" call site observes any exceptions that occur within the call:

try { Q(); }
catch { ... }
finally { ... }

If Q() throws an exception then the catch block runs; when control leaves Q() by regular or exceptional means, the finally block runs. Nothing unusual here.

Now consider an iterator block that has been rewritten into a MoveNext method of an enumerator. When that thing is called it is called synchronously. If it throws an exception then the exception is handled by the nearest try-protected region on the call stack. But suppose the iterator block itself has a try-protected region that yields control back to the caller:

try { yield return whatever; }
catch { ... }
finally { ... }

The yield statement returns control back to the caller, but the return does not activate the finally block. And if an exception is thrown in the caller then the MoveNext() is no longer on the stack. Its exception handler has vanished. The exception model of iterator blocks is pretty weird. The finally block only runs when control is in the MoveNext() method and leaves the try-protected region by some mechanism other than yield return. Or when the enumerator is disposed early, which can happen if the caller throws an exception that activates a finally block that disposes the enumerator. In short: if the thing you've yielded control to has an exception that leaves the loop that is iterating the enumerator then the finally block of the iterator probably runs, but the catch block does not! Bizarre. That's why we made it illegal for you to yield in a try block that has a catch.

So what on earth are we going to do for methods with "awaits" in them? The situation is like the situation with iterator blocks, but even more bizarre because of course the asynchronous task can itself throw an exception:

async Task M()
{
  try { await DoSomethingAsync(); }
  catch { ... }
  finally { ... }
}

We do want it to be legal to await something in a try block that has a catch. Suppose DoSomethingAsync throws before it returns a task. No problem there; M is still on the stack, so the catch block runs. Suppose DoSomethingAsync returns a task. M signs up the rest of itself as the continuation of the task, and immediately returns another task to its caller. What happens when the job associated with the task returned by DoSomethingAsync is scheduled to run, and it throws an exception? Logically we want M to still be "on the stack" so that its catch and finally run, just like it would if DoSomething had been a synchronous call. (Unlike iterator blocks: we want the catch to run, not just the finally!) But M is long gone; it has signed up a delegate that contains code that looks just like it as the continuation of a task, but M and its try block are vanished. The task might not even be running on the thread that M ran on. Heck, it might not even be running on the same continent if the task is actually farmed out to some service provider "in the cloud". What do we do?

I said a few episodes back that exception handling in continuation passing style is easy; you just pass around two continuations, one for the exceptional situation and one for the regular situation. That's not actually what we do here. Instead what we do is: if the job throws an otherwise-uncaught exception then it is caught and the exception is stored in the task. The task is then signaled as having completed unsuccessfully. When the continuation of the task resumes, we do a "goto" into the middle of the try block (somehow) and check to see if the task blew up. If it did, then we can re-throw the exception right there, and hey, this time there is a try-catch-finally that can handle the exception.

But suppose we do not handle the exception; maybe the catch block doesn't match. What do we do then? M's original caller is again, long gone; the continuation is probably being called by some top-level message pump somewhere. What do we do? Well, remember, M returned a task. We cache the exception again in that task, and then signal that task as having completed unsuccessfully. Thus the buck is passed to the caller, which is of course what exception throwing is all about: making your caller do the work of cleaning up your mess.

In short, M() is generated as something like this pseudo-C#:

Task M()
{
  var builder = AsyncMethodBuilder.Create();
  var state = State.Begin;
  Action continuation = ()=>
  {
    try
    {
      if (state == State.AfterDoSomething) goto AfterDoSomething;
      try
      {
        var awaiter = DoSomethingAsync().GetAwaiter;
        state= State.AfterDoSomething;;
        if (awaiter.BeginAwait(continuation))
          return without running the finally;
      AfterDoSomething:
        awaiter.EndAwait(); // throws an exception if the task completed unsuccessfully
        builder.SetResult();
        return;
      }
      catch { ... }
      finally { ... }
    }
    catch (Exception exception)
    {
      builder.SetException(exception); // signal this task as having completed unsuccessfully
      return;
    }
    builder.SetResult();
  };
  continuation();
  return builder.Task;
}

(Of course there are problems here; you cannot do a goto into the middle of a try block, the label is out of scope, and so on. Ve have vays of making the compiler generate IL that works; it doesn't have to be legal C#. This is just a sketch.)

If the EndAwait throws an exception cached from the asynchronous operation then the catch and finally blocks run normally. If the inner catch block doesn't handle it, or throws another exception, then the outer catch block gets it, caches it in the task, and signals the task as having completed abnormally.

I have ignored several important cases in this brief sketch. For example, what if the method M is void returning? In that situation there is no task constructed for M, and so there is nothing to be signalled as completed unsuccessfully, and nowhere to cache the exception. What if DoSomethingAsync does a WhenAll on ten sub-tasks and two of them throw an exception? What about the same scenario but with WhenAny?

Next time I'll talk a bit about these cases, muse about exception handling philosophy in general, and ask you whether that philosophy gives good guidance or not. Then we'll take a short break for American Thanksgiving, and then pick up with some topic other than asynchrony.

  • It would seem to me that in your example the finally block may be run twice -- the first time when M returns because it is awaiting on DoSomethingAsync and the second time when the continuation resumes inside the try block.  Is this correct?

    I forgot to make a note on that "return" that the compiler needs to ensure that the finally does not run. Thanks for catching that. - Eric

  • *WhenAll*

    If I am waiting on 10 tasks to complete their work (maybe to merge the result), I think that I need them all to succeed. Should one (or more) fail, the exception ought to be rethrown.

    Now the question is what to do when 2 of them have thrown?

    Thinking about it, I see two different approaches, which both make sense:

    (a) Wrap them into a MultipleExceptionsException (poor name obviously)  and throw that;

    (b) Throw the first one reported and consider that the program has failed at this point, ignore the rest.

    To me (a) looks more flexible. If I am waiting on 10 different tasks (e.g. maybe the parallell compilation of 10 source files), it's nice to be able to know the outcome of the 10 tasks I've started (e.g. report all compilation errors and not just the first file to fail). This could be useful for other scenarios, such as compensation code, etc.

    There are drawbacks to this approach, but I think code that calls WaitAll should simply be aware that this new exception type could be raised (or to simplify things: it's the *only* one that could be thrown, even if there's just 1 exception amongst the 10 tasks).

    *WhenAny*

    WhenAny returns as soon as the first task completes / fails. This should be reported (returned value or rethrown exception). Further processing is up to the programmer:

    (a) coder has the result it wanted... the rest of the results and/or exceptions are discarded

    (b) coder can still call WhenAny on the rest of the tasks (e.g. the first one had failed) or use other methods to check the remaining Tasks status (cancel / completion / failure).

    *void-returning*

    This is one is more tricky... I see 2 major possibilities, none looking really better than the other. It kind of reminds me the question of exceptions raised in worker threads (which has changed between .NET 1 and 2).

    Is there a guarantee that fire-and-forget async code is really called at some point? I.e. If I am returning from the main thread just after the call (before it can actually be completed), does the application quit without executing it? If the guarantee is this weak, then I'd say just ignore the exception... The main program can't really rely on it anyway, so what's the matter. It's a "best effort" approach.

    On the other hand, if you can rely on async code being executed eventually (unlike GC), then the contract is a bit different. I could fire-and-forget a "write this to a log file"... It's not important when it completes (and has no result), as long as it does get written to the log at some point. This would rather favor .NET 2 policy regarding unhandled exceptions in worker threads: the application is misbehaving, kill the whole appdomain down before it corrupts anything.

  • with all this new compiler functionality,  i can't wait to see what decompilers like reflector will show as the C# source of the IL.  Will it be your illegal M() function?

  • drdamour: It's already possible for .Net languages to generate IL that's not possible to create with C#. Examples include VB features of filter conditions on catch blocks and branching into try blocks. Reflector handles this by generating invalid C#.

    To test this, I just wrote a VB.Net function that includes a GoTo into a Try block and decompiled it into C#. Reflector generates a goto into the try block, similar to Eric's illegal M() function.

  • I'm going to pre-empt your next post. WhenAny and WhenAll are bad names or poor methods. Let me explain.

    WhenAny suggests the meaning "When any task finishes, successfully or not, forward that result.". But the more useful case is to interpret it as a disjunction. Then the meaning becomes "When any task succeeds, give me that result. Otherwise give me one or all of the faults.". I'll call these variants WhenAnyFirst and WhenAnyBest.

    WhenAll suggests the meaning "Wait for all tasks to finish. Then give me any faults or else give me the results.". Another possible interpretation is as a conjunction. Then the meaning becomes the more efficient "Give me any fault immediately, or else give me all the results.". I'll call these variants WhenAllWait and WhenAllShortCircuit.

    Notice that both WhenAll and WhenAny suggest the less practical meaning! I (almost always) want WhenAny to favor results over faults and WhenAll to short circuit on faults. But whenever I read the method names I get the opposite impression! So either the names are poor or the methods could be more practical.

Page 1 of 1 (5 items)