TaskCanceledException с ContinueWith

Вопрос или проблема

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

static void Main(string[] args)
{
    RunTest();
}

private static void RunTest()
{
    Task.Delay(1000).ContinueWith(t => Console.WriteLine("{0}", t.Exception), TaskContinuationOptions.OnlyOnFaulted).Wait();
}

Насколько я знаю, это просто должно приостановить выполнение на секунду, а затем закрыться. ContinueWith не должен вызываться (это относится только к моему реальному случаю использования). Однако вместо этого я получаю TaskCanceledException, и понятия не имею, откуда это берётся!

Я также получил эту ошибку:

System.Threading.Tasks.TaskCanceledException: 'Задача была отменена.'

Фрагмент кода выглядел так:

private void CallMediator<TRequest>(TRequest request) where TRequest : IRequest<Unit>
{
    _ = Task.Run(async () =>
    {
        var mediator = _serviceScopeFactory.CreateScope().ServiceProvider.GetService<IMediator>()!;
        await mediator.Send(request).ContinueWith(LogException, TaskContinuationOptions.OnlyOnFaulted);
    });
}

private void LogException(Task task)
{
    if (task.Exception != null)
    {
        _logger.LogError(task.Exception, "{ErrorMessage}", task.Exception.Message);
    }
}

Читая документацию к методу ContinueWith, там есть следующие замечания:

Возвращённая задача не будет запланирована на выполнение, пока текущая задача не будет завершена. Если критерии продолжения, указанные через параметр continuationOptions, не выполнены, задача продолжения будет отменена, а не запланирована.

Таким образом, для меня сначала была вызвана первая задача (mediator.Send(request)), затем продолжилась задача ContinueWith(...), которую я awaitил. Однако, поскольку в первой задаче исключение не произошло, вторая задача была отменена. Поэтому, ожидая вторую задачу, я получил TaskCanceledException.

Что я сделал, так это изменил код на этот:

private void CallMediator<TRequest>(TRequest request) where TRequest : IRequest<Unit>
{
    _ = Task.Run(async () =>
    {
        var mediator = _serviceScopeFactory.CreateScope().ServiceProvider.GetService<IMediator>()!;
        try
        {
            _ = await mediator.Send(request);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "{ErrorMessage}", ex.Message);
        }
    });
}

Вместо использования .ContinueWith(...) я заменил его просто на обычный блок try-catch на случай, если задача, которая меня интересует, завершится с ошибкой. Думаю, это упрощает код и делает его более читаемым.

В вопросе есть такая строка кода:

Task.Delay(1000).ContinueWith(t => Console.WriteLine("{0}", t.Exception), TaskContinuationOptions.OnlyOnFaulted).Wait();

Я бы переписал это на:

try
{
    Task.Delay(1000).Wait();
}
catch (Exception ex)
{
    Console.WriteLine("{0}", ex);
}

Вы используете неверный параметр taskcontinuationoption:

Смотрите следующую ссылку: https://msdn.microsoft.com/en-us/library/system.threading.tasks.taskcontinuationoptions%28v=vs.110%29.aspx

Там говорится:
Указывает, что задача продолжения должна быть запланирована только в том случае, если предшествующая задача выбросила необработанное исключение. Этот параметр неvalid для многозадачных продолжений.

Как ребята сказали выше, этот вызов требует, чтобы предшествующая задача была в состоянии ошибки, иначе возникнет TaskCanceledException. Для этого конкретного случая вы можете обобщить ContinueWith, чтобы обрабатывать все статусы:

await Task.Delay(1000).ContinueWith(
    task => 
        {
            /* учитывайте, что отмененная задача выбрасывает в следующей строке TaskCancelledException */

            if (!task.IsFaulted) {
                return;
            }

            Console.WriteLine("{0}", task.Exception);
            // делаем что-то вроде 'throw task.Exception.InnerException'
        });

Ответ или решение

Если вы сталкиваетесь с исключением TaskCanceledException при использовании ContinueWith в асинхронном коде, это может быть связано с тем, что параметр TaskContinuationOptions.OnlyOnFaulted вызывает отмену задачи продолжения, если заранее определенная (antecedent) задача завершилась успешно или была отменена.

Рассмотрим ваш код подробнее:

private static void RunTest()
{
    Task.Delay(1000).ContinueWith(t => Console.WriteLine("{0}", t.Exception), TaskContinuationOptions.OnlyOnFaulted).Wait();
}

В данном случае, Task.Delay(1000) – это задача, которая должна завершиться успешно через 1 секунду. Однако, когда используется ContinueWith с опцией OnlyOnFaulted, дальнейшие действия не будут выполнены, если задача не завершилась с ошибкой. Это означает, что в момент, когда ContinueWith проверяет состояние задачи, она завершилась успешно, и как следствие, задача продолжения отменяется. Именно на этом этапе возникает TaskCanceledException.

Чтобы избежать этой проблемы, вы можете изменить проход кода следующим образом:

  1. Изменить способ обработки результата асинхронной задачи.
  2. Использовать обычный блок try-catch для обработки ошибок вместо ContinueWith.

Вот как может выглядеть исправленный код:

private static void RunTest()
{
    try
    {
        Task.Delay(1000).Wait(); // Ждем 1 секунду
    }
    catch (Exception ex)
    {
        Console.WriteLine("{0}", ex);
    }
}

Это подход избавляет вас от необходимости беспокоиться об исключениях, возникающих в контексте ContinueWith и делает код более читабельным.

Что касается более сложного случая с вызовом mediator.Send(request), вы вполне правильно подошли к решению с использованием try-catch:

private void CallMediator<TRequest>(TRequest request) where TRequest : IRequest<Unit>
{
    _ = Task.Run(async () =>
    {
        var mediator = _serviceScopeFactory.CreateScope().ServiceProvider.GetService<IMediator>()!;
        try
        {
            await mediator.Send(request);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "{ErrorMessage}", ex.Message);
        }
    });
}

Таким образом, вы обрабатываете исключения прямо в блоке try-catch, что является подходом более универсальным и безопасным в случаях, когда исключения могут быть выброшены.

Таким образом, рекомендуется не полагаться на ContinueWith с OnlyOnFaulted, если вы не уверены, что задача всегда завершится ошибкой, так как это может привести к неочевидным результатам и сложностям в обработке исключений.

Оцените материал
Добавить комментарий

Капча загружается...