Обработка исключений в асинхронном коде при переходе на .NET 4.5

В посте я попытаюсь раскрыть подводные камни, которые возникают при обработке исключений в асинхронном коде в .NET 4 в контексте .NET 4.5

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

Рассмотрим, как будет себя вести код из примера в зависимости от того .NET каких версий установлен на сервере:

class Program
{
    static void Main(string[] args)
    {
        Task.Factory.StartNew(() => { throw new Exception(); });

        Thread.Sleep(1000);

        GC.Collect();
    }
}


Если на сервере установлен .NET 4 и не установлен .NET 4.5, процесс завершится вследствие необработанного исключения. Генерация исключений из задач, завершения которых никто не ожидает, происходит при сборке мусора. Если бы пример выглядел так:

class Program
{
    static void Main(string[] args)
    {
        Task.Factory.StartNew(() => { throw new Exception(); });

        Thread.Sleep(1000);
    }
}


То проблему было бы тяжело заметить.

Для обработки таких исключений у типа TaskScheduler есть событие TaskUnobservedException. Событие позволяет перехватить исключение и предотвратить завершение процесса.

Если на сервере установлен .NET 4.5, поведение меняется, процесс не завершается вследствие необработанного исключения.
Если в .NET 4.5 осталось бы стандартное поведение из .NET 4, то в примере ниже при сборке мусора после вызова метода SomeMethod процесс бы завершился, т.к. исключение из Async2() осталось бы необработанным:

public static async Task SomeMethod()
{
    try
    {
        var t1 = Async1();
        var t2 = Async2();

        await t1;
        await t2;
    }
    catch 
    {
        
    }
}

public static Task Async1()
{
    return Task.Factory.StartNew(() => { throw new Exception(); });
}

public static Task Async2()
{
    return Task.Factory.StartNew(() => { throw new Exception(); });
}


Чтобы после установки .NET 4.5 вернуть стандартное поведение из .NET 4, необходимо добавить в конфигурационный файл приложения ключ ThrowUnobservedTaskExceptions.

На практике подобное изменение поведения при переходе от одной версии фреймворка к другой опасно тем, что на лайве может быть не установлен .NET 4.5, а разработчик работает в системе с .NET 4.5. В этом случае разработчик может пропустить подобную ошибку. Поэтому при разработке настоятельно рекомендуется тестировать приложение с включенным ключом ThrowUnobservedTaskExceptions.

В .NET 4.5 есть еще одно нововведение, которое может доставить немало проблем — async void методы, которые иначе обрабатываются компилятором, нежели async Task методы. Для обработки async void методов используется AsyncVoidMethodBuilder, а для async Task методов AsyncTaskMethodBuilder. При возникновении ошибок, в случае если нет контекста синхронизации, исключение будет выброшено в пул потоков, что ведет к завершению процесса. Такое исключение можно перехватить, но предотвратить завершение процесса не получится. async void методы должны использоваться только для обработки событий от UI элементов.

Пример неочевидного использования async void метода, которое приводит к завершению процесса:

new List<int>().ForEach(async i => { throw new Exception(); });

Если у вас нет необходимости в async void методах, можно в CI добавить правило, которое при появлении в IL использования AsyncVoidMethodBuilder, считало бы билд неуспешным.

Источники:
  1. http://www.jaylee.org/post/2012/07/08/c-sharp-async-tips-and-tricks-part-2-async-void.aspx
  2. http://blogs.msdn.com/b/cellfish/archive/2012/10/18/the-tale-of-an-unobservedtaskexception.aspx
  3. http://blogs.msdn.com/b/pfxteam/archive/2011/09/28/10217876.aspx

Средняя зарплата в IT

110 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 8 813 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 15

    +2
    Скажите, пожалуйста, в каких реальных ситуациях можно столкнуться с описанными проблемами?
      +3
      Вызвать метод, возвращающий Task, не сделав на него await, например. Забыть элементарно.
        –2
        Вам не кажется, что в этом случае можно получить ошибки пострашнее неотловленного Exception? Например, негарантированное выполнение метода?
          +1
          Вообще говоря нет. Сейчас практически все методы возвращают так называемые «горячие» таски (то есть, не надо вызывать Start, как иногда было во времена чистого TPL), так что выполнение метода начнётся. А вот исключение мы рискуем не поймать вообще.
            –2
            Мне казалось, что Start не гарантирует, что выполнение метода начнется прямо сейчас, только то, что он будет положен в очередь шедулера, разве нет?

            (ну и начало выполнения метода тоже не гарантирует его завершения)
              +2
              Всё, что попадает в очередь задач, рано или поздно будет выполнено, если
              1) не упало/завершилось приложение
              2) все потоки из пула не заблокированы синхронной операцией ожидания
                –3
                … а приложение может завершиться по куче причин. Самый банальный (хотя и нереалистичный) сценарий — это забыть сделать await на таске в теле консольного приложения, после чего оно радостно закончится.
        +3
        Например у вас есть метод для сохранения информации о пользователе. По результатам вы пушите событие об этом. И кто то из подписчиков запускает задачу и не дожидается её завершения, возможно считая, что неважно как отработает задача и не важно выполнится ли она вообще. Например это может быть запись в БД какой то трэкинговой информации.
          –3
          Выглядит как хрупкий дизайн. Возможно, проблему надо на уровне дизайна же и решать?
            +1
            См. «fire and forget».
              0
              Я знаю, что такое fire and forget, я просто считаю, что f-n-f должен быть гарантированно безопасным для выполняющей его системы (т.е. как раз 4.5 в этом вопросе несколько более логичен).
                0
                4.5, увы, как раз менее логичен, потому что async void (который суть чистый fire and forget — он не дает вызывающему опции подождать в принципе) все равно валит процесс. А вот async Task — теперь не валит. При этом в 4.0 поведение было одинаковое.

                Имхо все же в fire and forget, последний должен быть явным, а не по умолчанию.

                Но сам по себе дизайн с f'n'f вполне нормален.
                  0
                  Дизайн, включающий f-n-f — нормален, вопрос реализации f-n-f. Я просто всегда стараюсь следить, чтобы ошибки в «некритичных» ветвях выполнения (которые -and-forget) не могли всплыть до основного потока выполнения.
        +5
        Мы с активным использованием тасков и await огребли массу проблем с неотловленными исключениями как раз из-за того, что все разработчики сидят на VS 2013 (и, соответственно, .NET 4.5), но продукт собирается в т.ч. и для VS 2010, а там может быть и .NET 4.0. Причем чаще это были даже не косяки, а просто попытка by design запустить таск в фоне и не ждать его.

        После продолжительного хождения по граблям, кстати, решили, что мы не будем поддерживать 4.0 :) тем не менее, в процессе появилась пачка вспомогательных методов для того, чтобы явно управлять поведением тасков в ситуациях без await. Может, кому еще пригодится.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое