10

In TPL, if an exception is thrown by a Task, that exception is captured and stored in Task.Exception, and then follows all the rules on observed exceptions. If it's never observed, it's eventually rethrown on the finalizer thread and crashes the process.

Is there a way to prevent the Task from catching that exception, and just letting it propagate instead?

The Task I'm interested in would already be running on the UI thread (courtesy of TaskScheduler.FromCurrentSynchronizationContext), and I want the exception to escape so it can be handled by my existing Application.ThreadException handler.

I basically want unhandled exceptions in the Task to behave like unhandled exceptions in a button-click handler: immediately propagate on the UI thread, and be handled by ThreadException.

4 답변


11

Ok Joe... as promised, here's how you can generically solve this problem with a custom TaskScheduler subclass. I've tested this implementation and it works like a charm. Don't forget you can't have the debugger attached if you want to see Application.ThreadException to actually fire!!!

The Custom TaskScheduler

This custom TaskScheduler implementation gets tied to a specific SynchronizationContext at "birth" and will take each incoming Task that it needs to execute, chain a Continuation on to it that will only fire if the logical Task faults and, when that fires, it Posts back to the SynchronizationContext where it will throw the exception from the Task that faulted.

public sealed class SynchronizationContextFaultPropagatingTaskScheduler : TaskScheduler
{
    #region Fields

    private SynchronizationContext synchronizationContext;
    private ConcurrentQueue<Task> taskQueue = new ConcurrentQueue<Task>();

    #endregion

    #region Constructors

    public SynchronizationContextFaultPropagatingTaskScheduler() : this(SynchronizationContext.Current)
    {
    }

    public SynchronizationContextFaultPropagatingTaskScheduler(SynchronizationContext synchronizationContext)
    {
        this.synchronizationContext = synchronizationContext;
    }

    #endregion

    #region Base class overrides

    protected override void QueueTask(Task task)
    {
        // Add a continuation to the task that will only execute if faulted and then post the exception back to the synchronization context
        task.ContinueWith(antecedent =>
            {
                this.synchronizationContext.Post(sendState =>
                {
                    throw (Exception)sendState;
                },
                antecedent.Exception);
            },
            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);

        // Enqueue this task
        this.taskQueue.Enqueue(task);

        // Make sure we're processing all queued tasks
        this.EnsureTasksAreBeingExecuted();
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        // Excercise for the reader
        return false;
    }

    protected override IEnumerable<Task> GetScheduledTasks()
    {
        return this.taskQueue.ToArray();
    }

    #endregion

    #region Helper methods

    private void EnsureTasksAreBeingExecuted()
    {
        // Check if there's actually any tasks left at this point as it may have already been picked up by a previously executing thread pool thread (avoids queueing something up to the thread pool that will do nothing)
        if(this.taskQueue.Count > 0)
        {
            ThreadPool.UnsafeQueueUserWorkItem(_ =>
            {
                Task nextTask;

                // This thread pool thread will be used to drain the queue for as long as there are tasks in it
                while(this.taskQueue.TryDequeue(out nextTask))
                {
                    base.TryExecuteTask(nextTask);
                }
            },
            null);
        }
    }

    #endregion
}

Some notes/disclaimers on this implementation:

  • If you use the parameterless constructor, it will pick up the current SynchronizationContext... so if you just construct this on a WinForms thread (main form constructor, whatever) and it will work automatically. Bonus, I also have a constructor where you can explicitly pass in the SynchronizationContext that you got from somewhere else.
  • I have not provided an implementation of TryExecuteTaskInline so this implementation will just always just queue the Task to be worked on. I leave this as an excercise for the reader. It's not hard, just... not necessary to demonstrate the functionality you're asking for.
  • I'm using a simple/primitive approach to scheduling/executing the Tasks that leverages the ThreadPool. There are definitely richer implementations to be had, but again the focus of this implementation is simply about marshaling exceptions back to the "Application" thread

Ok, now you have a couple options for using this TaskScheduler:

Pre-configure TaskFactory Instance

This approach allows you to setup a TaskFactory once and then any task you start with that factory instance will use the custom TaskScheduler. That would basically look something like this:

At application startup

private static readonly TaskFactory MyTaskFactory = new TaskFactory(new SynchronizationContextFaultPropagatingTaskScheduler());

Throughout code

MyTaskFactory.StartNew(_ =>
{
    // ... task impl here ...
});

Explicit TaskScheduler Per-Call

Another approach is to just create an instance of the custom TaskScheduler and then pass that into StartNew on the default TaskFactory every time you start a task.

At application startup

private static readonly SynchronizationContextFaultPropagatingTaskScheduler MyFaultPropagatingTaskScheduler = new SynchronizationContextFaultPropagatingTaskScheduler();

Throughout code

Task.Factory.StartNew(_ =>
{
    // ... task impl here ...
},
CancellationToken.None // your specific cancellationtoken here (if any)
TaskCreationOptions.None, // your proper options here
MyFaultPropagatingTaskScheduler);


  • +1 for an interesting approach and thorough explanation. Unfortunately I need to be able to run some of the tasks on the UI thread (e.g. interim tasks that just update progress bars and such), so the fact that this always schedules tasks on the thread pool means it wouldn't be enough as-is. I wonder if I could adapt this idea into a decorator that wraps another TaskScheduler. - Joe White
  • @JoeWhite - they kinda screwed the API design in this case. It was precisely my intent when I started off to do a decorator approach where I just hook the continuation in my QueueTask impl and then delegate to an inner TaskScheduler provided by whoever constructed me. Unfortunately TaskScheduler::QueueTask is protected only, so there's no way to actually delegate that way. :( The only way I can think to accomplish this would be another impl where you queue tasks to be executed by delegating them to SynchronizationContext::Post just like TaskScheduler::FromCurrentSynchronizationContext does. - Drew Marsh

4

I found a solution that works adequately some of the time.

Single task

var synchronizationContext = SynchronizationContext.Current;
var task = Task.Factory.StartNew(...);

task.ContinueWith(task =>
    synchronizationContext.Post(state => {
        if (!task.IsCanceled)
            task.Wait();
    }, null));

This schedules a call to task.Wait() on the UI thread. Since I don't do the Wait until I know the task is already done, it won't actually block; it will just check to see if there was an exception, and if so, it will throw. Since the SynchronizationContext.Post callback is executed straight from the message loop (outside the context of a Task), the TPL won't stop the exception, and it can propagate normally -- just as if it was an unhandled exception in a button-click handler.

One extra wrinkle is that I don't want to call WaitAll if the task was canceled. If you wait on a canceled task, TPL throws a TaskCanceledException, which it makes no sense to re-throw.

Multiple tasks

In my actual code, I have multiple tasks -- an initial task and multiple continuations. If any of those (potentially more than one) get an exception, I want to propagate an AggregateException back to the UI thread. Here's how to handle that:

var synchronizationContext = SynchronizationContext.Current;
var firstTask = Task.Factory.StartNew(...);
var secondTask = firstTask.ContinueWith(...);
var thirdTask = secondTask.ContinueWith(...);

Task.Factory.ContinueWhenAll(
    new[] { firstTask, secondTask, thirdTask },
    tasks => synchronizationContext.Post(state =>
        Task.WaitAll(tasks.Where(task => !task.IsCanceled).ToArray()), null));

Same story: once all the tasks have completed, call WaitAll outside the context of a Task. It won't block, since the tasks are already completed; it's just an easy way to throw an AggregateException if any of the tasks faulted.

At first I worried that, if one of the continuation tasks used something like TaskContinuationOptions.OnlyOnRanToCompletion, and the first task faulted, then the WaitAll call might hang (since the continuation task would never run, and I worried that WaitAll would block waiting for it to run). But it turns out the TPL designers were cleverer than that -- if the continuation task won't be run because of OnlyOn or NotOn flags, that continuation task transitions to the Canceled state, so it won't block the WaitAll.

Edit

When I use the multiple-tasks version, the WaitAll call throws an AggregateException, but that AggregateException doesn't make it through to the ThreadException handler: instead only one of its inner exceptions gets passed to ThreadException. So if multiple tasks threw exceptions, only one of them reaches the thread-exception handler. I'm not clear on why this is, but I'm trying to figure it out.


  • Well, this works, but it forces you to slap a continuation on to every task in your application. I didn't realize that was an acceptable solution for you, I was looking for a more generic solution. I think I have a way to do it generically with a custom TaskScheduler and was going to test/post that solution this morning. If you're still interested in that lemme know and I'll continue to work it out, otherwise I'm glad this solution works for you. - Drew Marsh
  • @DrewMarsh, my approach isn't working out as well as I had initially hoped (see latest edit to my answer); and as you pointed out, it's got to be plugged in manually every time. So I'm not particularly attached to this solution -- if you have other ideas, I'd love to hear them. - Joe White
  • The problem now that I think about it is that, even with a custom TaskScheduler, you'd have to use that custom TaskScheduler everywhere. It's more lightweight than having to slap a continuation onto everything, but it would still require changes to all areas of your code. Also, if you were calling any third party methods that returned you Tasks you would still have to chain on a continuation and Wait() there since there's no guarantee the third party library going to use your TaskScheduler for creating their Tasks (highly unlikely actually). Hmmm... tricky. - Drew Marsh
  • For what it's worth, we actually will already have a continuation on everything (we disable the UI when we start a task, and need to re-enable it when we're done, which means a continuation that runs on the UI thread). But we don't get Tasks from third-party methods, so a TaskScheduler would probably be workable, especially if it could solve the problem of not all the tasks' exceptions coming through. - Joe White
  • Joe, ok cool, I'll try and whip together the sample during lunch today for ya then. - Drew Marsh

0

There's no way that I'm aware of to have these exceptions propagate up like exceptions from the main thread. Why not just hook the same handler that you're hooking to Application.ThreadException to TaskScheduler.UnobservedTaskException as well?


  • I thought UnobservedTaskException only fired when the Task was about to get garbage collected without its exception ever being observed. If so, it doesn't apply -- I want to propagate the exception immediately. - Joe White
  • Sorry Joe, I didn't understand that was what you were looking for. Your understanding of UnobservedTaskException being deferred 'til after Task cleanup is correct. Let me think about this more. - Drew Marsh

0

Does something like this suit?

public static async void Await(this Task task, Action action = null)
{
   await task;
   if (action != null)
      action();
}

runningTask.Await();

Linked


Related

Latest