Tasks and Unhandled Exceptions

Stephen Toub - MSFT

Prior to the .NET Framework 2.0, unhandled exceptions were largely ignored by the runtime.  For example, if a work item queued to the ThreadPool threw an exception that went unhandled by that work item, the ThreadPool would eat that exception and continue on its merry way.  Similarly, if a finalizer running on the finalizer thread threw an exception, the system would eat the exception and continue on executing other finalizers.

That changed for .NET 2.0.  Exceptions are meant to indicate a problem, and automatically eating unhandled exceptions often hides significant errors and reliability problems in an application.  As such, for .NET 2.0, this unhandled exception behavior was changed by default to more closely match how exceptions are handled by Windows in general: if an exception goes unhandled, the process comes crashing down.  While this may seem harsh, allowing the process to crash typically makes it easier to catch an underlying problem, but more importantly, it prevents the application from continuing to hobble along in a potentially very bad state, with corrupted data, etc.  Thus, since .NET 2.0, if a work item running on the ThreadPool throws an unhandled exception, by default the process crashes.

There’s a notable exemption to this behavior, even after 2.0: the Asynchronous Programming Model (APM) pattern.  With the APM pattern, work is started asynchronously with a BeginXx method, and at some point later the results of the work are retrieved with a corresponding EndXx method.  If the asynchronous work throws an exception, that exception is then propagated out of the call to the EndXx method when the EndXx method is invoked.  This, of course, counts on the EndXx method being invoked.  If the developer makes a mistake such that End is never invoked, any exception that occurred as part of the asynchronous invocation will likely go unnoticed, as would have happened in more situations in .NET 1.x.

With Tasks in .NET 4.0, we face a similar situation as does the APM pattern.  A Task represents an asynchronous operation that may throw an unhandled exception, but unlike work items generated by ThreadPool.QueueUserWorkItem, a Task instance is used to later join with the asynchronous work.  As such, any unhandled exceptions will be stored into the relevant Task instance, later thrown any time that Task is waited on (and also available through the Task’s Exception property).  In fire-and-forget scenarios, where the developer has no intention of joining with the Task, or if the developer simply forgets to join with the Task, the exception may never be observed, and thus if we did nothing special, we’d be in a bad situation very much like that which the APM pattern faces with unhandled exceptions.

To address this, Tasks keep track of whether an unhandled exception has been “observed.”  In this context, “observed” means that code has joined with the Task in some fashion in order to at least be made aware of the exception.  This could be calling Wait/WaitAll on the Task.  It could be checking the Task’s Exception property after the Task has completed.  Or it could be using a Task<TResult>’s Result property.  If a Task sees that its exception has been observed in some manner, life is good.  If, however, all references to a Task are removed (making the Task available for garbage collection), and if its exception hasn’t yet been observed, the Task knows that its exception will never be observed.  In such a case, the Task takes advantage of finalization, and uses a helper object to propagate the unhandled exception on the finalizer thread.  With the behavior described earlier, that exception on the finalizer thread will go unhandled and invoke the default unhandled exception logic, which is to log the issue and crash the process.

In this manner, we can have our cake and eat it, too.  As with the APM, we make any exceptions that occurred asynchronously available later for the app to retrieve.  And as with work items on the ThreadPool, if an exception goes unhandled, it will cause the process to be torn down.

Of course, there may still be situations where you do want a fire-and-forget task, but where you want to automatically “observe” any unhandled exception, either to log it or something similar, or just to prevent the process from crashing in a situation where you know the exception will be benign.  In that case, you can take advantage of continuations to address your needs.

Consider a Task t.  After creating the Task, I can run code like the following:

t.ContinueWith(c => { var ignored = c.Exception; },
    TaskContinuationOptions.OnlyOnFaulted |
    TaskContinuationOptions.ExecuteSynchronously |
    TaskContinuationOptions.DetachedFromParent);

This code creates a continuation off of Task t that will only be scheduled if Task t completes in a Faulted state, meaning that it completed due to an exception going unhandled.  When this continuation runs, it will observe the Task’s exception, preventing it from getting finalized.  You could even wrap this kind of logic up into an extension method, such as:

public static Task IgnoreExceptions(this Task task)
{
    task.ContinueWith(c => { var ignored = c.Exception; },
        TaskContinuationOptions.OnlyOnFaulted |
        TaskContinuationOptions.ExecuteSynchronously |
        TaskContinuationOptions.DetachedFromParent);
    return task;
}

With this in hand, any time I create a fire-and-forget Task where I want an unhandled exception to be ignored, I can simply tag on IgnoreExceptions, e.g. instead of:

var t = Task.Factory.StartNew(…);

I could write:

var t = Task.Factory.StartNew(…).IgnoreExceptions();

Of course, the exception handling logic in Task exists for good reasons, to prevent unhandled exceptions from going unnoticed, and as such it’s not a great idea to liberally sprinkle use of an IgnoreExceptions extension method like this.  But in some situations, it can be quite useful.

There’s a corollary to IgnoreExceptions which may also be useful.  One of the downsides to the logic we use to tear down the process is that it relies on finalization.  Finalization isn’t guaranteed to occur in a timely fashion, so an exception may go unhandled and it may be some time before the app then crashes; some time is better than never, but it’s still not ideal.  If you want a more timely crash, you could use an extension method like the following:

public static Task FailFastOnException(this Task task)
{
    task.ContinueWith(c => Environment.FailFast(“Task faulted”, c.Exception),
        TaskContinuationOptions.OnlyOnFaulted |
        TaskContinuationOptions.ExecuteSynchronously |
        TaskContinuationOptions.DetachedFromParent);
    return task;
}

Rather than ignoring the exception, this uses Environment.FailFast to immediately crash the process.  In this manner, I could write code like the following:

var t = Task.Factory.StartNew(…).FailFastOnException();

and as soon as Task t faults, the process will crash.  This could be beneficial when debugging, as you’ll know immediately when something goes wrong.  There are lots of variations on this as well.  Debugger.Break could be used to immediately break into an attached debugger, you could raise an event of your choosing, the exception could be logged to an application log file, and so forth.  In this manner, continuations are quite powerful.

(For anyone interested in extending Parallel Extensions with extension methods like this, the Parallel Extensions Extras project in the Beta 1 samples available at https://code.msdn.microsoft.com/ParExtSamples includes a plethora of interesting and useful examples. Enjoy!)

0 comments

Discussion is closed.

Feedback usabilla icon