Хабрахабр

[DotNetBook] События об исключительных ситуациях и как на пустом месте получить StackOverflow и ExecutionEngineException

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

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

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

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

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

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

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

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

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

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

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

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

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

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

void Main()
{ var fceStarted = false; var sync = new object(); EventHandler<FirstChanceExceptionEventArgs> handler; handler = new EventHandler<FirstChanceExceptionEventArgs>((_, 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. Однако, все не так жизнерадостно. Т.е. А это значит, что он возникает для любого потока, стартованного в определенном домене. А это значит, что нам необходимо как-то защитить себя синхронизацией: например при помощи lock. если у нас есть домен, внутри которого стартовано несколько потоков, то FirstChanceException могут идти в параллель.

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

static void Main()
{ using (ApplicationLogger.Go(AppDomain.CurrentDomain)) { throw new Exception("Hello!"); }
} public class ApplicationLogger : MarshalByRefObject
{ ConcurrentQueue<Exception> queue = new ConcurrentQueue<Exception>(); 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<FirstChanceExceptionEventArgs>((_, 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

UnhandledException. Второе сообщение, которое мы можем перехватить и которое касается обработки исключительных ситуаций — это AppDomain. Также, если произошла такая ситуация, все что мы можем сделать — это "разгрести" последствия такой ошибки. Это сообщение — очень плохая новость для нас поскольку обозначает что не нашлось никого кто смог бы найти способ обработки возникшей ошибки в некотором потоке. каким-либо образом зачистить ресурсы, принадлежащие только этому потоку если таковые создавались. Т.е. Т.е. Однако, еще более лучшая ситуация — обрабатывать исключения, находясь в "корне" потоков не заваливая поток. Давайте попробуем рассмотреть целесообразность такого поведения. по сути ставить 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"); } } });

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

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

Показать больше

Похожие публикации

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»