Как разрешить исключения Task для распространения обратно в поток пользовательского интерфейса?

В TPL, если исключение выбрано Task, это исключение захватывается и сохраняется в Task.Exception, а затем следуют всем правилам наблюдаемых исключений. Если он никогда не наблюдал, он в конце концов возвращался к потоку финализатора и выходил из строя.

Есть ли способ предотвратить запуск задачи этим исключением и просто позволить ей распространять вместо этого?

Задание, которое меня интересует, уже будет работать в потоке пользовательского интерфейса (любезно предоставлено TaskScheduler.FromCurrentSynchronizationContext), и я хочу исключение чтобы сбежать, поэтому его можно обработать моим существующим обработчиком Application.ThreadException.

В основном я хочу, чтобы необработанные исключения в Задаче выполнялись как необработанные исключения в обработчике кнопок: сразу распространяются на поток пользовательского интерфейса и обрабатываются ThreadException.

Ответ 1

Ok Joe... как и было обещано, вот как вы можете в общем случае решить эту проблему с помощью пользовательского подкласса TaskScheduler. Я протестировал эту реализацию, и она работает как шарм. Не забывайте, что вы не можете подключить отладчик, если хотите увидеть Application.ThreadException, чтобы он действительно срабатывал!!!

Пользовательский TaskScheduler

Эта пользовательская реализация TaskScheduler привязана к определенному SynchronizationContext при "рождении" и будет принимать каждый входящий 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 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, над которой будет работать. Я оставляю это как упражнение для читателя. Это не сложно, просто... не нужно демонстрировать функциональность, о которой вы просите.
  • Я использую простой/примитивный подход к планированию/выполнению задач, которые используют ThreadPool. Есть определенно более богатые реализации, которые нужно иметь, но опять-таки фокус этой реализации - это просто исключение маршалинга из обращений к потоку "Приложение".

Хорошо, теперь у вас есть пара вариантов использования этого TaskScheduler:

Предварительная настройка экземпляра TaskFactory

Этот подход позволяет вам настроить TaskFactory один раз, а затем любую задачу, с которой вы начинаете с того, что экземпляр factory будет использовать пользовательский 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);

Ответ 2

Я нашел решение, которое работает некоторое время некоторое время.

Отдельная задача

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

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

Это расписание вызова task.Wait() в потоке пользовательского интерфейса. Поскольку я не делаю Wait, пока не узнаю, что задача уже выполнена, она фактически не блокируется; он просто проверяет, есть ли какое-то исключение, и если да, то это будет бросать. Поскольку обратный вызов SynchronizationContext.Post выполняется непосредственно из цикла сообщений (вне контекста Task), TPL не останавливает исключение и может нормально распространяться - как если бы это было необработанное исключение в обработчик click-click.

Одна дополнительная морщина заключается в том, что я не хочу называть WaitAll, если задача была отменена. Если вы ждете отложенной задачи, TPL выбрасывает TaskCanceledException, который не имеет смысла перебрасывать.

Несколько задач

В моем фактическом коде у меня есть несколько задач - начальная задача и несколько продолжений. Если какой-либо из них (потенциально более одного) получает исключение, я хочу распространить AggregateException обратно на поток пользовательского интерфейса. Вот как справиться с этим:

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

Одинаковая история: после завершения всех задач вызовите WaitAll вне контекста Task. Он не будет блокироваться, так как задачи уже завершены; это просто простой способ выбросить AggregateException, если какая-либо из заданных проблем.

Сначала я беспокоился, что если одна из задач продолжения использовала что-то вроде TaskContinuationOptions.OnlyOnRanToCompletion, а первая задача была сбой, тогда WaitAll может зависать (так как задача продолжения никогда не запускалась, и я боялся, что WaitAll заблокирует ее выполнение). Но оказалось, что дизайнеры TPL были умнее, чем это: если задача продолжения не будет выполняться из-за флагов OnlyOn или NotOn, эта задача продолжения переходит в состояние Canceled, поэтому она не будет блокировать WaitAll.

Изменить

Когда я использую версию с несколькими задачами, вызов WaitAll вызывает AggregateException, но AggregateException не выполняет обработчик ThreadException: вместо этого передается только одно из его внутренних исключений до ThreadException. Поэтому, если несколько задач бросают исключения, только один из них достигает обработчика исключения нитей. Я не понимаю, почему это так, но я пытаюсь понять это.

Ответ 3

Нет никакого способа, чтобы я знал, что эти исключения распространяются как исключения из основного потока. Почему бы просто не зацепить того же обработчика, который вы подключаете к Application.ThreadException, к TaskScheduler.UnobservedTaskException?

Ответ 4

Что-то вроде этого костюма?

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

runningTask.Await();