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

发布于 2024-12-21 10:12:42 字数 938 浏览 2 评论 0 原文

在 TPL 中,如果任务抛出异常,该异常被捕获并存储在 Task.Exception,然后遵循 观察到的异常。如果从未观察到它,它最终会在终结器线程上重新抛出并使进程崩溃。

有没有办法防止任务捕获该异常,而只是让它传播?

我感兴趣的任务已经在 UI 线程上运行(由 TaskScheduler.FromCurrentSynchronizationContext),并且我希望异常转义,以便可以由我现有的 Application.ThreadException 处理程序。

我基本上希望任务中未处理的异常表现得像按钮单击处理程序中未处理的异常:立即在 UI 线程上传播,并由 ThreadException 处理。

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.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(4

鹿港巷口少年归 2024-12-28 10:12:42

好吧,乔...正如所承诺的,以下是如何使用自定义 TaskScheduler 子类来一般性地解决此问题。我已经测试了这个实现,它的效果非常好。 不要忘记如果您想看到 Application.ThreadException 实际触发,则不能附加调试器!

自定义TaskScheduler

这个自定义TaskScheduler 实现在“诞生”时与特定的SynchronizationContext 绑定在一起,并将接受它需要执行的每个传入的Task,将Continuation 链接到它上面仅当逻辑 Task 发生故障时才会触发,并且当逻辑 Task 发生故障时,它会 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 的实例,然后将其传递到 StartNew每次启动任务时都会在默认的 TaskFactory 上。

应用程序启动时

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);

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);
百思不得你姐 2024-12-28 10:12:42

我找到了一个在某些时候有效的解决方案。

单个任务

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

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

这会在 UI 线程上安排对 task.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 标志而导致继续任务不会运行,则该继续任务将转换为 < code>Canceled 状态,因此不会阻塞 WaitAll

编辑

当我使用多任务版本时, WaitAll 调用会抛出 AggregateException,但该 AggregateException 不会到达 < code>ThreadException 处理程序:仅将其内部异常的一个传递给ThreadException。因此,如果多个任务引发异常,则只有其中一个任务到达线程异常处理程序。我不清楚为什么会这样,但我正在努力弄清楚。

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.

旧话新听 2024-12-28 10:12:42

我不知道如何让这些异常像主线程中的异常一样传播。为什么不将与 Application.ThreadException 挂钩的同一处理程序挂钩到 TaskScheduler.UnobservedTaskException 也是如此?

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?

段念尘 2024-12-28 10:12:42

有类似这套衣服的吗?

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

runningTask.Await();

Does something like this suit?

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

runningTask.Await();
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文