Freigeben über


How to avoid Scary problems when using the Task Parallel Library

Recently our team was close to an important milestone, and were fixing the last remaining bugs before signing off.  There were some intermittent failures that needed to be tracked down before release. In this post I will describe some of the problems that can show up when using Task Parallel Library so hopefully you can avoid these issues in the first place.

Avoiding a race condition using ContinueWith

First lets assume we have the following code calling an asynchronous method which computes a value and stores it in m_field:

  1: private void RunContinueWith()
  2: {
  3:     Task task = null;
  4:  
  5:     Console.WriteLine("In calling method.");
  6:     // task = this.DoContinueWithAsync();
  7:     task = this.DoContinueWithRaceConditionAsync();
  8:     Console.WriteLine("Returned to calling method.");
  9:  
  10:     task.Wait();
  11:            
  12:     Console.WriteLine("Hashcode = {0}", m_field.GetHashCode());
  13: }

It may seem natural to implement this method as follows:

  1: private Task DoContinueWithRaceConditionAsync()
  2: {
  3:     Task task = Task.Factory.StartNew(
  4:         () =>
  5:         {
  6:             Console.WriteLine("Task part 1.");
  7:         });
  8:  
  9:     task.ContinueWith(
  10:             t =>
  11:             {
  12:                 Console.WriteLine("Task part 2.");
  13:                 Thread.Sleep(3000);
  14:                 m_field = new object();
  15:             });
  16:  
  17:     return task;
  18: }

There is a subtle race condition in this method implementation, however. Do you see it? It was probably introduced because the person writing the code wanted it to be a bit more readable. 

Because the ContinueWith method creates a second task that runs after the first task, the variable task now returns the first task and not the second task because of the code formatting. The caller is expecting the entire operation to complete when the returned task completes, but since it doesn’t return the correct task, there is a NullReferenceException when accessing m_field.GetHashCode().

There are two ways to fix the problem. Change task.ContinueWith to update the variable task as follows:

  1: task = task.ConintueWith(

or make sure that task is assigned the value of the second task from the beginning by ensuring that the methods are chained together:

  1: private Task DoContinueWithAsync()
  2: {
  3:     Task task = Task.Factory.StartNew(
  4:         () => 
  5:             { 
  6:                 Console.WriteLine("Task part 1."); 
  7:             })
  8:         .ContinueWith(
  9:             t => 
  10:             { 
  11:                 Console.WriteLine("Task part 2.");
  12:                 Thread.Sleep(3000);
  13:                 m_field = new object();
  14:             });
  15:  
  16:     return task;
  17: }

Like I mentioned earlier, this is very subtle but important for the stability of your code.

Unobserved Exceptions

Another problem that had to be addressed before signing off was exceptions reported from the finalizers of the tasks during garbage collection.  If an exception is not observed before the task is garbage collected, the finalizer will throw an exception.  It is very difficult to trace the problem back to the code where the exception should have been handled.  You can see an example unhandled exception and some code to cause the exception to be reported in the finalizer here:

  1: private void RunUnobservedExceptions()
  2: {
  3:     this.Unobserved();
  4:  
  5:     Console.WriteLine("Press <Enter> to force collection.");
  6:     Console.ReadLine();
  7:  
  8:     GC.AddMemoryPressure(1 * 1000 * 1000);
  9:     GC.Collect();
  10:     GC.WaitForPendingFinalizers();
  11: }
  12:  
  13: private void Unobserved()
  14: {
  15:     Task t = DoUnobservedAsync();
  16:  
  17:     while (!t.IsCompleted)
  18:     {
  19:         Thread.Sleep(1000);
  20:     }
  21: }
  22:  
  23: private Task DoUnobservedAsync()
  24: {
  25:     return Task.Factory.StartNew(
  26:         () =>
  27:         {
  28:             throw new InvalidOperationException("Failed.");
  29:         });
  30: }

Because the IsCompleted property of the task doesn’t result in the exception being observed, you get a dialog from Visual Studio when running in the debugger that looks like this:

unobserved

Well, that is no fun. To avoid this we need to make sure we check and handle the exceptions before the Task goes out of scope.

How to observe exceptions

There are a few ways to observe exceptions. The first it to observe using the Wait method:

  1: private void ObservedUsingWait()
  2: {
  3:     Task t = this.DoUnobservedAsync();
  4:  
  5:     try
  6:     {
  7:  
  8:         t.Wait();
  9:     }
  10:     catch (AggregateException exception)
  11:     {
  12:             // TODO
  13:     }
  14: }

A second way to observe the exception is to check the Exception property explicitly:

  1: private void ObservedUsingException()
  2: {
  3:     Task t = this.DoUnobservedAsync();
  4:  
  5:     while (!t.IsCompleted)
  6:     {
  7:         Thread.Sleep(1000);
  8:     }
  9:  
  10:     if (t.Exception != null)
  11:     {
  12:         Console.WriteLine("Failed with exception: {0}", t.Exception.Message);
  13:     }
  14: }

This is a good option if you don’t want the exception re-thrown and caught.

If the task is computing some result, you can observe any exceptions by accessing the result:

  1: private void ObservedUsingResult()
  2: {
  3:     Task<int> task = Task.Factory.StartNew<int>( 
  4:         () => {
  5:             throw new InvalidOperationException("Failed.");
  6:         });
  7:  
  8:     try
  9:     {
  10:         // Will wait and throw exception on failure.
  11:         Console.WriteLine("Result is {0}", task.Result);
  12:     }
  13:     catch (AggregateException exception)
  14:     {
  15:         // TODO
  16:     }
  17: }

The last way to observe the exceptions, is to handle the exceptions individually using the Handle method:

  1: private void ObservedUsingHandle()
  2: {
  3:     Task t = this.DoUnobservedAsync();
  4:  
  5:     while (!t.IsCompleted)
  6:     {
  7:         Thread.Sleep(1000);
  8:     }
  9:  
  10:     // Mark all exceptions as handled.
  11:     t.Exception.Handle(
  12:         (ex ) => 
  13:             {
  14:                 Console.WriteLine("Ignoring exception with message: {0}", ex.Message);
  15:                 return true; 
  16:             });
  17:  
  18: }

The down side of this approach, is that it is way too easy to be sloppy and just ignore all exceptions. If an exception is not ignored (i.e. false is returned), however it is re-thrown and it needs to be handled elsewhere.

Observing Exceptions and ContinueWith

Things get a little more complicated when you need to observe an exception and are using ContinueWith. The first thing to remember is that ContinueWith creates a task for itself but it needs to observe the task that preceded it before it goes out of scope. Here is an example:

  1: private void ObserveExceptionInContinueWith()
  2: {
  3:     Task t = Task.Factory.StartNew(
  4:         () =>
  5:         {
  6:             throw new InvalidOperationException("Failed.");
  7:         })
  8:         .ContinueWith(
  9:             previousTask =>
  10:             {
  11:                 if (previousTask.Exception != null)
  12:                 {
  13:                     Console.WriteLine("Exception: {0}", previousTask.Exception.Message);
  14:                 }
  15:             });
  16:  
  17:     while (!t.IsCompleted)
  18:     {
  19:         Thread.Sleep(1000);
  20:     }
  21: }

In this case, the first tasks exception is observed, and the the second task cannot fail, so everything is fine. You can also have the ContinueWith run only if there is a fault by specifying a special option:

  1: private void ObserveExceptionInContinueWithOnlyOnFaulted()
  2: {
  3:     Task t = Task.Factory.StartNew(
  4:         () =>
  5:         {
  6:             throw new InvalidOperationException("Failed.");
  7:         })
  8:         .ContinueWith(
  9:             previousTask =>
  10:             {                       
  11:                 // We know previousTask.Exception is not null. No need to check.
  12:  
  13:                 Console.WriteLine("Exception: {0}", previousTask.Exception.Message);
  14:             }, TaskContinuationOptions.OnlyOnFaulted);
  15:  
  16:     while (!t.IsCompleted)
  17:     {
  18:         Thread.Sleep(1000);
  19:     }
  20: }

This works fine as well. The only problem is when you use tracing to observe the exception and it is only available in debug builds:

  1: Debug.Assert(false, previousTask.Exception.Message);

In this case you may test extensively on your debug builds, but get exceptions in your retail product. Be careful. The OnlyOnFaulted option does not observe the exception. You still need to add the code to do it.

The last case that can be tricky is returning false from the delegate passed to the Handle method:

  1: private void ObserveHandleInContinueWith()
  2: {
  3:     Task t = Task.Factory.StartNew(
  4:         () =>
  5:         {
  6:             throw new InvalidOperationException("Failed.");
  7:         })
  8:         .ContinueWith(
  9:             previousTask =>
  10:             {
  11:                 // We know previousTask.Exception is not null. No need to check.
  12:                 previousTask.Exception.Handle(
  13:                     (ex) =>
  14:                     {
  15:                         Console.WriteLine("Exception: {0}", ex.Message);
  16:                         return false;
  17:                     });
  18:             }, TaskContinuationOptions.OnlyOnFaulted);
  19:  
  20:     while (!t.IsCompleted)
  21:     {
  22:         Thread.Sleep(1000);
  23:     }
  24: }

Since you are touching the Exception property of the previousTask you would think this should be fine. Since the Handle method will re-throw any unhandled exceptions, however, the ContinueWith task will have an exception and if unobserved, will be discovered in the finalizer. So beware.

OnlyOnFaulted and NotOnFaulted

You may be tempted to use OnlyOnFaulted and NotOnFaulted to separate your success and failure code into separate delegates like this:

  1: private void ContinueWithFaultedNotFaulted()
  2: {
  3:     Task t = Task.Factory.StartNew(
  4:         () =>
  5:         {
  6:             throw new InvalidOperationException("Failed.");
  7:         })
  8:         .ContinueWith(
  9:             previousTask =>
  10:             {
  11:                 // We know previousTask.Exception is not null. No need to check.
  12:  
  13:                 Console.WriteLine("Exception: {0}", previousTask.Exception.Message);
  14:             }, TaskContinuationOptions.OnlyOnFaulted)
  15:             .ContinueWith(
  16:             previousTask =>
  17:             {
  18:                 Console.WriteLine("Continue on success.");
  19:             }, TaskContinuationOptions.NotOnFaulted);
  20:  
  21:     while (!t.IsCompleted)
  22:     {
  23:         Thread.Sleep(1000);
  24:     }
  25: }

Unfortunately, this doesn’t work. It runs both the success and failed cases:

  1: Exception: One or more errors occurred.
  2: Continue on success.
  3: Press <Enter> to force collection.

Why? Because the success case runs if the previous task doesn’t fault. Since the previous task is the ContinueWith that runs only when the original task faults, it will run. This may be confusing at first.

The reverse also does not work:

  1: private void ContinueWithNotFaultedFaulted()
  2: {
  3:     Task t = Task.Factory.StartNew(
  4:         () =>
  5:         {
  6:             throw new InvalidOperationException("Failed.");
  7:         })
  8:         .ContinueWith(
  9:             previousTask =>
  10:             {
  11:                 Console.WriteLine("Continue on success.");
  12:             }, TaskContinuationOptions.NotOnFaulted)
  13:         .ContinueWith(
  14:             previousTask =>
  15:             {
  16:                 // We know previousTask.Exception is not null. No need to check.
  17:  
  18:                 Console.WriteLine("Exception: {0}", previousTask.Exception.Message);
  19:             }, TaskContinuationOptions.OnlyOnFaulted)
  20:             ;
  21:  
  22:     while (!t.IsCompleted)
  23:     {
  24:         Thread.Sleep(1000);
  25:     }
  26: }

In this case, the first task fails, so the first and thus subsequent ContinueWith statements do not run, and the exception is not observed. To handle both the success and failure cases, you really need to handle both in the first ContinueWith statement:

  1: private void ContinueWithCombined()
  2: {
  3:     Task t = Task.Factory.StartNew(
  4:         () =>
  5:         {
  6:             throw new InvalidOperationException("Failed.");
  7:         })
  8:         .ContinueWith(
  9:             previousTask =>
  10:             {
  11:                 if (previousTask.IsFaulted)
  12:                 {
  13:                     // We know previousTask.Exception is not null. No need to check.
  14:                     Console.WriteLine("Exception: {0}", previousTask.Exception.Message);
  15:                     return;
  16:                 }
  17:  
  18:                 Console.WriteLine("Continue on success.");
  19:             });
  20:  
  21:     while (!t.IsCompleted)
  22:     {
  23:         Thread.Sleep(1000);
  24:     }
  25: }

Summary

In this post I covered some issues that may catch you off guard using the Task Parallel Library.  Hopefully you will find these tips helpful, and it will help keep bugs from creeping into your code. There is enough scary stuff going around this time of year. You don’t want your code to be scary.