[DotNetBook] Исключения: события об исключительных ситуациях

34uae6usmglyw10vxga3sgfgh8c.jpeg С этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .NET CLR, и .NET в целом. За ссылками — добро пожаловать по кат.


События об исключительных ситуациях

В общем случае мы не всегда знаем о тех исключениях, которые произойдут в наших программах потому что практически всегда мы используем что-то, что написано другими людьми и что находится в других подсистемах и библиотеках. Мало того что возможны самые разные ситуации в вашем собственном коде, в коде других библиотек, так еще и существует множество проблем, связанных с исполнением кода в изолированных доменах. И как раз в этом случае было бы крайне полезно уметь получать данные о работе изолированного кода. Ведь вполне реальной может быть ситуация, когда сторонний код перехватывает все без исключения ошибки, заглушив их fault блоком:

    try {
        // ...
    } catch {
        // do nothing, just to make code call more safe
    }

В такой ситуации может оказаться что выполнение кода уже не так безопасно как выглядит, но сообщений о том что произошли какие-то проблемы мы не имеем. Второй вариант — когда приложение глушит некоторое, пусть даже легальное, исключение. А результат — следующее исключение в случайном месте вызовет падение приложения в некотором будущем от случайной казалось бы ошибки. Тут хотелось бы иметь представление, какая была предыстория этой ошибки. Каков ход событий привел к такой ситуации. И один из способов сделать это возможным — использовать дополнительные события, которые относятся к исключительным ситуациям: AppDomain.FirstChanceException и AppDomain.UnhandledException.


Данная статья — первая из четырех в цикле статей про исключения. Полный цикл:
— Архитектура системы типов
— Cобытия об исключительных ситуациях (эта статья)
— Виды исключительных ситуаций
— Сериализация и блоки обработки

Фактически, когда вы «бросаете исключение», то вызывается обычный метод некоторой внутренней подсистемы Throw, который внутри себя проделывает следующие операции:


  • Вызывает AppDomain.FirstChanceException
  • Ищет в цепочке обработчиков подходящий по фильтрам
  • Вызывает обработчик предварительно откатив стек на нужный кадр
  • Если обработчик найден не был, вызывается AppDomain.UnhandledException, обрушивая поток, в котором произошло исключение.

Сразу следует оговориться, ответив на мучающий многие умы вопрос: есть ли возможность как-то отменить исключение, возникшее в неконтролируемом коде, который исполняется в изолированном домене, не обрушивая тем самым поток, в котором это исключение было выброшено? Ответ лаконичен и прост: нет. Если исключение не перехватывается на всем диапазоне вызванных методов, оно не может быть обработано в принципе. Иначе возникает странная ситуация: если мы при помощи AppDomain.FirstChanceException обрабатываем (некий синтетический catch) исключение, то на какой кадр должен откатиться стек потока? Как это задать в рамках правил .NET CLR? Никак. Это просто не возможно. Единственное что мы можем сделать — запротоколировать полученную информацию для будущих исследований.

Второе, о чем следует рассказать «на берегу» — это почему эти события введены не у Thread, а у AppDomain. Ведь если следовать логике, исключения возникают где? В потоке исполнения команд. Т.е. фактически у Thread. Так почему же проблемы возникают у домена? Ответ очень прост: для каких ситуаций создавались AppDomain.FirstChanceException и AppDomain.UnhandledException? Помимо всего прочего — для создания песочниц для плагинов. Т.е. для ситуаций, когда есть некий AppDomain, который настроен на PartialTrust. Внутри этого AppDomain может происходить что угодно: там в любой момент могут создаваться потоки, или использоваться уже существующие из ThreadPool. Тогда получается что мы, будучи находясь снаружи от этого процесса (не мы писали тот код) не можем никак подписаться на события внутренних потоков. Просто потому что мы понятия не имеем что там за потоки были созданы. Зато мы гарантированно имеем AppDomain, который организует песочницу и ссылка на который у нас есть.

Итак, по факту нам предоставляется два краевых события: что-то произошло, чего не предполагалось (FirstChanceExecption) и «все плохо», никто не обработал исключительную ситуацию: она оказалась не предусмотренной. А потому поток исполнения команд не имеет смысла и он (Thread) будет отгружен.

Что можно получить, имея данные события и почему плохо что разработчики обходят эти события стороной?


AppDomain.FirstChanceException

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

Но давайте для начала посмотрим на простой синтетический пример его обработки:

void Main()
{
    var counter = 0;

    AppDomain.CurrentDomain.FirstChanceException += (_, args) => {
        Console.WriteLine(args.Exception.Message);
        if(++counter == 1) {
            throw new ArgumentOutOfRangeException();
        }
    };

    throw new Exception("Hello!");
}

Чем примечателен данный код? Где бы некий код ни сгенерировал бы исключение первое что произойдет — это его логгирование в консоль. Т.е. даже если вы забудете или не сможете предусмотреть обработку некоторого типа исключения оно все равно появится в журнале событий, которое вы организуете. Второе — несколько странное условие выброса внутреннего исключения. Все дело в том что внутри обработчика FirstChanceException вы не можете просто взять и бросить еще одно исключение. Скорее даже так: внутри обработчика FirstChanceException вы не имеете возможности бросить хоть какое-либо исключение. Если вы так сделаете, возможны два варианта событий. При первом, если бы не было условия if(++counter == 1), мы бы получили бесконечный выброс FirstChanceException для все новых и новых ArgumentOutOfRangeException. А что это значит? Это значит, что на определенном этапе мы бы получили StackOverflowException: throw new Exception("Hello!") вызывает CLR метод Throw, который вызывает FirstChanceException, который вызывает Throw уже для ArgumentOutOfRangeException и далее — по рекурсии. Второй вариант — мы защитились по глубине рекурсии при помощи условия по counter. Т.е. в данном случае мы бросаем исключение только один раз. Результат более чем неожиданный: мы получим исключительную ситуацию, которая фактически отрабатывает внутри инструкции Throw. А что подходит более всего для данного типа ошибки? Согласно ECMA-335 если инструкция была введена в исключительное положение, должно быть выброшено ExecutionEngineException! А эту исключительную ситуацию мы обработать никак не в состоянии. Она приводит к полному вылету из приложения. Какие же варианты безопасной обработки у нас есть?

Первое, что приходит в голову — это выставить try-catch блок на весь код обработчика FirstChanceException:

void Main()
{
    var fceStarted = false;
    var sync = new object();
    EventHandler handler;
    handler = new EventHandler((_, args) =>
    {
        lock (sync)
        {
            if (fceStarted)
            {
                // Этот код по сути - заглушка, призванная уведомить что исключение по своей сути - родилось не в основном коде приложения, 
                // а в try блоке ниже.
                Console.WriteLine($"FirstChanceException inside FirstChanceException ({args.Exception.GetType().FullName})");
                return;
            }
            fceStarted = true;

            try
            {
                // не безопасное логгирование куда угодно. Например, в БД
                Console.WriteLine(args.Exception.Message);
                throw new ArgumentOutOfRangeException();
            }
            catch (Exception exception)
            {
                // это логгирование должно быть максимально безопасным
                Console.WriteLine("Success");
            }
            finally
            {
                fceStarted = false;
            }
        }
    });
    AppDomain.CurrentDomain.FirstChanceException += handler;

    try
    {
        throw new Exception("Hello!");
    } finally {
        AppDomain.CurrentDomain.FirstChanceException -= handler;
    }
}

OUTPUT:

Hello!
Specified argument was out of the range of valid values.
FirstChanceException inside FirstChanceException (System.ArgumentOutOfRangeException)
Success

!Exception: Hello!

Т.е. с одной стороны у нас есть код обработки события FirstChanceException, а с другой — дополнительный код обработки исключений в самом FirstChanceException. Однако методики логгирования обоих ситуаций должны отличаться. Если логгирование обработки события может идти как угодно, то обработка ошибки логики обработки FirstChanceException должно идти без исключительных ситуаций в принципе. Второе, что вы наверняка заметили — это синхронизация между потоками. Тут может возникнуть вопрос: зачем она тут если любое исключение рождено в каком-либо потоке, а значит FirstChanceException по идее должен быть потокобезопасным. Однако, все не так жизнерадостно. FirstChanceException у нас возникает у AppDomain. А это значит, что он возникает для любого потока, стартованного в определенном домене. Т.е. если у нас есть домен, внутри которого стартовано несколько потоков, то FirstChanceException могут идти в параллель. А это значит, что нам необходимо как-то защитить себя синхронизацией: например при помощи lock.

Второй способ — попробовать увести обработку в соседний поток, принадлежащий другому домену приложений. Однако тут стоит оговориться что при такой реализации мы должны построить выделенный домен именно под эту задачу чтобы не получилось так что этот домен могут положить другие потоки, которые являются рабочими:

static void  Main()
{
    using (ApplicationLogger.Go(AppDomain.CurrentDomain))
    {
        throw new Exception("Hello!");
    }
}

public class ApplicationLogger : MarshalByRefObject
{
    ConcurrentQueue queue = new ConcurrentQueue();
    CancellationTokenSource cancellation;
    ManualResetEvent @event;

    public void LogFCE(Exception message)
    {
        queue.Enqueue(message);
    }

    private void StartThread()
    {
        cancellation = new CancellationTokenSource();
        @event = new ManualResetEvent(false);
        var thread = new Thread(() =>
        {
            while (!cancellation.IsCancellationRequested)
            {
                if (queue.TryDequeue(out var exception))
                {
                    Console.WriteLine(exception.Message);
                }
                Thread.Yield();
            }
            @event.Set();
        });
        thread.Start();
    }

    private void StopAndWait()
    {
        cancellation.Cancel();
        @event.WaitOne();
    }

    public static IDisposable Go(AppDomain observable)
    {
        var dom = AppDomain.CreateDomain("ApplicationLogger", null, new AppDomainSetup
        {
            ApplicationBase = AppDomain.CurrentDomain.BaseDirectory,
        });

        var proxy = (ApplicationLogger)dom.CreateInstanceAndUnwrap(typeof(ApplicationLogger).Assembly.FullName, typeof(ApplicationLogger).FullName);
        proxy.StartThread();

        var subscription = new EventHandler((_, args) =>
        {
            proxy.LogFCE(args.Exception);
        });
        observable.FirstChanceException += subscription;

        return new Subscription(() => {
            observable.FirstChanceException -= subscription;
            proxy.StopAndWait();
        });
    }

    private class Subscription : IDisposable
    {
        Action act;
        public Subscription (Action act) {
            this.act = act;
        }
        public void Dispose()
        {
            act();
        }
    }
}

В данном случае обработка FirstChanceException происходит максимально безопасно: в соседнем потоке, принадлежащим соседнему домену. Ошибки обработки сообщения при этом не могут обрушить рабочие потоки приложения. Плюс отдельно можно послушать UnhandledException домена логгирования сообщений: фатальные ошибки при логгировании не обрушат все приложение.


AppDomain.UnhandledException

Второе сообщение, которое мы можем перехватить и которое касается обработки исключительных ситуаций — это AppDomain.UnhandledException. Это сообщение — очень плохая новость для нас поскольку обозначает что не нашлось никого кто смог бы найти способ обработки возникшей ошибки в некотором потоке. Также, если произошла такая ситуация, все что мы можем сделать — это «разгрести» последствия такой ошибки. Т.е. каким-либо образом зачистить ресурсы, принадлежащие только этому потоку если таковые создавались. Однако, еще более лучшая ситуация — обрабатывать исключения, находясь в «корне» потоков не заваливая поток. Т.е. по сути ставить try-catch. Давайте попробуем рассмотреть целесообразность такого поведения.

Пусть мы имеем библиотеку, которой необходимо создавать потоки и осуществлять какую-то логику в этих потоках. Мы, как пользователи этой библиотеки интересуемся только гарантией вызовов API, а также получением сообщений об ошибках. Если библиотека будет рушить потоки не нотифицируя об этом, нам это мало чем может помочь. Мало того обрушение потока приведет к сообщению AppDomain.UnhandledException, в котором нет информации о том, какой конкретно поток лег на бок. Если же речь идет о нашем коде, обрушивающийся поток нам тоже вряд-ли будет полезным. Во всяком случае необходимости в этом я не встречал. Наша задача — обработать ошибки правильно, отдать информацию об их возникновении в журнал ошибок и корректно завершить работу потока. Т.е. по сути обернуть метод, с которого стартует поток в try-catch:

    ThreadPool.QueueUserWorkitem(_ => {
        using(Disposables aggregator = ...){
            try {
                // do work here, plus:
                aggregator.Add(subscriptions);
                aggregator.Add(dependantResources);
            } catch (Exception ex)
            {
                logger.Error(ex, "Unhandled exception");
            }
        }
    });

В такой схеме мы получим то что надо: с одной стороны мы не обрушим поток. С другой — корректно очистим локальные ресурсы если они были созданы. Ну и в довесок — организуем журналирование полученной ошибки. Но постойте, скажете вы. Как-то вы лихо соскочили с вопроса события AppDomain.UnhandledException. Неужели оно совсем не нужно? Нужно. Но только для того чтобы сообщить что мы забыли обернуть какие-то потоки в try-catch со всей необходимой логикой. Именно со всей: с логгированием и очисткой ресурсов. Иначе это будет совершенно не правильно: брать и гасить все исключения, как будто их и не было вовсе.


Ссылка на всю книгу


© Habrahabr.ru