如何允许任务异常传播回UI线程?

10
在TPL中,如果Task引发异常,则该异常会被捕获并存储在Task.Exception中,并遵循所有已观察异常规则。如果它从未被观察到,则最终将在终结器线程上重新抛出并导致进程崩溃。
有没有一种方法可以防止Task捕获该异常,而是让它传播呢?
我感兴趣的Task已经在UI线程上运行(由于TaskScheduler.FromCurrentSynchronizationContext),我希望异常能够逃逸,以便可以通过我现有的Application.ThreadException处理程序进行处理。
基本上,我希望任务中未处理的异常的行为类似于按钮单击处理程序中未处理的异常:立即在UI线程上传播,并由ThreadException处理。
4个回答

11

Ok Joe... 如约而至,以下是如何使用自定义的 TaskScheduler subclass 通用解决此问题的方法。我已经测试了这个实现,它运行得很好。 别忘了 如果你想看到Application.ThreadException 的实际触发,就不能使用调试器来进行调试!!!

自定义 TaskScheduler

这个自定义 TaskScheduler 实现在 "出生" 时与特定的 SynchronizationContext 绑定,并且将每个需要执行的传入的 Task,连接一个 Continuation,仅在逻辑 Task 错误时触发。当这个 Continuation 被触发后,它会 Post 回到 SynchronizationContext,在那里抛出故障的 Task 的异常。

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
}

关于此实现的一些注意事项/免责声明:

  • 如果您使用无参数构造函数,则它将选择当前的SynchronizationContext...,因此,如果您只是在WinForms线程(主表单构造函数等)上构造它,它将自动工作。此外,我还提供了一个构造函数,您可以显式地传递从其他地方获取的SynchronizationContext。
  • 我没有提供TryExecuteTaskInline的实现,因此此实现将始终仅将Task排队以进行处理。我将这留给读者作为练习。这并不难,只是...没有必要演示您所要求的功能。
  • 我正在使用一种简单/基础的方法来安排/执行利用线程池的任务。确实可以有更丰富的实现方式,但是再次强调,这个实现的重点仅在于将异常传递回“应用程序”线程。

好的,现在您有几个选项可以使用此TaskScheduler:

预配置TaskFactory实例

这种方法允许您一次设置一个TaskFactory,然后使用该工厂实例启动任何任务都将使用自定义的TaskScheduler。它基本上看起来像这样:

在应用程序启动时

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

整个代码中

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

每次调用显式的 TaskScheduler

另一种方法是仅创建自定义 TaskScheduler 的实例,然后每次启动任务时将其传递给默认的 TaskFactory 上的 StartNew

在应用程序启动时

private static readonly SynchronizationContextFaultPropagatingTaskScheduler MyFaultPropagatingTaskScheduler = new SynchronizationContextFaultPropagatingTaskScheduler();

代码中

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

对于这种有趣的方法和详细的解释,我给你点赞。不幸的是,我需要在UI线程上运行一些任务(例如更新进度条等中间任务),因此这个方法总是将任务调度到线程池中,这意味着它本身并不足够。我想知道是否可以将这个想法改编成一个装饰器,用来包装另一个TaskScheduler。 - Joe White
@JoeWhite - 在这种情况下,他们有点搞砸了API设计。当我开始使用装饰器方法时,我的意图就是在我的QueueTask实现中挂接继续,并委托给由构造我的人提供的内部TaskScheduler。不幸的是,TaskScheduler :: QueueTask仅受保护,因此没有办法通过这种方式进行委派。 :( 我能想到的唯一实现方式是另一个实现,其中您将要执行的任务排队,委托它们到SynchronizationContext :: Post,就像TaskScheduler :: FromCurrentSynchronizationContext一样。 - Drew Marsh

4
我发现有一个解决方案,但它只能在某些情况下有效。

单任务

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

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

这会在UI线程上调度一个task.Wait()的调用。由于只有在我知道任务已经完成后才执行Wait,因此它实际上不会被阻塞; 它只是检查是否有异常,如果有异常,则会抛出异常。由于SynchronizationContext.Post回调直接从消息循环(在Task的上下文之外)执行,因此TPL不会停止异常,它可以像按钮点击处理程序中未处理的异常一样正常传播。
另一个问题是,如果任务被取消,我不想调用WaitAll。如果等待已取消的任务,TPL会抛出TaskCanceledException,重新抛出毫无意义。

多个任务

在我的实际代码中,我有多个任务--一个初始任务和多个继续任务。如果其中任何一个(可能不止一个)发生异常,我希望将AggregateException传播回UI线程。以下是如何处理:
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));

同样的故事:一旦所有任务都完成,就在Task的上下文之外调用WaitAll。它不会阻塞,因为任务已经完成;这只是一种轻松的方法,如果任何任务出现故障,就抛出AggregateException
起初我担心,如果其中一个延续任务使用像TaskContinuationOptions.OnlyOnRanToCompletion这样的东西,并且第一个任务出现故障,那么WaitAll调用可能会挂起(因为延续任务永远不会运行,我担心WaitAll会阻塞等待它运行)。但事实证明TPL设计者比那更聪明--如果延续任务由于OnlyOnNotOn标志而不会运行,则该延续任务转换为Canceled状态,因此它不会阻塞WaitAll
编辑:
当我使用多个任务版本时,WaitAll调用会抛出AggregateException,但是该AggregateException无法通过ThreadException处理程序传递:相反,只有一个内部异常传递给ThreadException。因此,如果多个任务引发异常,则只有其中一个到达线程异常处理程序。我不清楚这是为什么,但我正在努力弄清楚。

这个方法可以工作,但它强制你给应用程序中的每个任务都加上一个 continuation。我并不知道这对你来说是可接受的解决方案,我正在寻找一种更通用的解决方案。我想我有一个使用自定义 TaskScheduler 通用地解决此问题的方法,今天早上我会测试并发布该解决方案。如果你仍然对此感兴趣,请告诉我,我将继续努力解决它,否则我很高兴这个解决方案能够满足你的需求。 - Drew Marsh
@DrewMarsh,我的方法并没有像我最初希望的那样有效(请参见我答案的最新编辑);正如你所指出的,每次都必须手动插入。因此,我对这个解决方案并不特别依恋--如果你有其他想法,我很乐意听取。 - Joe White
现在我考虑起来的问题是,即使使用自定义的 TaskScheduler,您仍然需要在所有地方使用该自定义的 TaskScheduler。这比在所有内容上添加 continuation 要轻量级,但仍需要更改代码的所有区域。此外,如果您调用任何返回任务的第三方方法,则仍需要在那里链式添加一个 continuation 并 Wait(),因为没有保证第三方库将使用您的 TaskScheduler 来创建它们的任务(实际上很不可能)。嗯...棘手。 - Drew Marsh
说实话,我们实际上已经在所有事情上有了一个延续(当我们开始任务时禁用UI,并在完成后需要重新启用它,这意味着在UI线程上运行的延续)。但是我们无法从第三方方法中获取任务,因此TaskScheduler可能是可行的,特别是如果它可以解决不是所有任务异常都通过的问题。 - Joe White
Joe,好的,没问题,我会尽量在今天午餐时间为你准备一个示例。 - Drew Marsh

0

我不知道有什么办法可以让这些异常像主线程的异常一样传播上来。为什么不把你钩子到 Application.ThreadException 的处理程序也钩到 TaskScheduler.UnobservedTaskException 上呢?


我原本以为UnobservedTaskException只有在Task被垃圾回收之前,并且其异常从未被观察到时才会触发。如果是这样的话,它就不适用——我希望立即传播异常。 - Joe White
抱歉,乔,我没有理解你正在寻找的内容。你对于UnobservedTaskException被推迟到任务清理之后的理解是正确的。让我再仔细考虑一下这个问题。 - Drew Marsh

0

这样的东西适合吗?

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

runningTask.Await();

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接