В общем случае мы не всегда знаем о тех исключениях, которые произойдут в наших программах потому что практически всегда мы используем что-то, что написано другими людьми и что находится в других подсистемах и библиотеках. Мало того что возможны самые разные ситуации в вашем собственном коде, в коде других библиотек, так ещё и существует множество проблем, связанных с исполнением кода в изолированных доменах. И как раз в этом случае было бы крайне полезно уметь получать данные о работе изолированного кода. Ведь вполне реальной может быть ситуация, когда сторонний код перехватывает все без исключения ошибки, заглушив их fault
блоком:
try {
// ...
} catch {
// do nothing, just to make code call safer
}
В такой ситуации может оказаться, что выполнение кода уже не так безопасно как выглядит, но сообщений о том, что произошли какие-то проблемы мы не имеем. Второй вариант -- когда приложение глушит некоторое, пусть даже легальное, исключение. А результат -- следующее исключение в случайном месте вызовет падение приложения в некотором будущем от случайной, казалось бы, ошибки. Тут хотелось бы иметь представление, какая была предыстория этой ошибки. Каков ход событий привёл к такой ситуации. И один из способов сделать это возможным -- использовать дополнительные события, которые относятся к исключительным ситуациям: AppDomain.FirstChanceException
и AppDomain.UnhandledException
.
Фактически, когда вы "бросаете исключение", то вызывается обычный метод некоторой внутренней подсистемы Throw
, который внутри себя проделывает следующие операции:
- Вызывает
AppDomain.FirstChanceException
- Ищет в цепочке обработчиков подходящий по фильтрам
- Вызывает обработчик, предварительно откатив стек на нужный кадр
- Если обработчик найден не был, вызывается
AppDomain.UnhandledException
, обрушивая поток, в котором произошло исключение.
Сразу следует оговориться, ответив на мучающий многие умы вопрос: есть ли возможность как-то отменить исключение, возникшее в неконтролируемом коде, который исполняется в изолированном домене, не обрушивая тем самым поток, в котором это исключение было выброшено? Ответ лаконичен и прост: нет. Если исключение не перехватывается на всем диапазоне вызванных методов, оно не может быть обработано в принципе. Иначе возникает странная ситуация: если мы при помощи AppDomain.FirstChanceException
обрабатываем (некий синтетический catch
) исключение, то на какой кадр должен откатиться стек потока? Как это задать в рамках правил .NET CLR? Никак. Это просто не возможно. Единственное что мы можем сделать -- запротоколировать полученную информацию для будущих исследований.
Второе, о чем следует рассказать "на берегу" -- это почему эти события введены не у Thread
, а у AppDomain
. Ведь если следовать логике, исключения возникают где? В потоке исполнения команд. Т.е. фактически у Thread
. Так почему же проблемы возникают у домена? Ответ очень прост: для каких ситуаций создавались AppDomain.FirstChanceException
и AppDomain.UnhandledException
? Помимо всего прочего -- для создания песочниц для плагинов. Т.е. для ситуаций, когда есть некий AppDomain
, который настроен на PartialTrust. Внутри этого AppDomain может происходить что угодно: там в любой момент могут создаваться потоки, или использоваться уже существующие из ThreadPool. Тогда получается что мы, будучи находясь снаружи от этого процесса (не мы писали тот код) не можем никак подписаться на события внутренних потоков. Просто потому что мы понятия не имеем что там за потоки были созданы. Зато мы гарантированно имеем AppDomain
, который организует песочницу и ссылка на который у нас есть.
Итак, по факту нам предоставляется два краевых события: что-то произошло, чего не предполагалось (FirstChanceExecption
) и "все плохо", никто не обработал исключительную ситуацию: она оказалась не предусмотренной. А потому поток исполнения команд не имеет смысла и он (Thread
) будет отгружен.
Что можно получить, имея данные события и почему плохо что разработчики обходят эти события стороной?
Это событие по своей сути носит чисто информационный характер и не может быть "обработано". Его задача -- уведомить вас, что в рамках данного домена произошло исключение, и оно после обработки события начнёт обрабатываться кодом приложения. Его исполнение несёт за собой пару особенностей, о которых необходимо помнить во время проектирования обработчика.
Но давайте для начала посмотрим на простой синтетический пример его обработки:
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<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. А это значит, что он возникает для любого потока, стартованного в определённом домене. Т.е. если у нас есть домен, внутри которого стартовано несколько потоков, то FirstChanceException
могут идти в параллель. А это значит, что нам необходимо как-то защитить себя синхронизацией: например при помощи lock
.
Второй способ -- попробовать увести обработку в соседний поток, принадлежащий другому домену приложений. Однако тут стоит оговориться, что при такой реализации мы должны построить выделенный домен именно под эту задачу чтобы не получилось так, что этот домен могут положить другие потоки, которые являются рабочими:
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
. Это сообщение -- очень плохая новость для нас поскольку обозначает что не нашлось никого кто смог бы найти способ обработки возникшей ошибки в некотором потоке. Также, если произошла такая ситуация, все что мы можем сделать -- это "разгрести" последствия такой ошибки. Т.е. каким-либо образом зачистить ресурсы, принадлежащие только этому потоку, если таковые создавались. Однако, ещё более лучшая ситуация -- обрабатывать исключения, находясь в "корне" потоков не заваливая поток. Т.е. по сути ставить 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
со всей необходимой логикой. Именно со всей: с логированием и очисткой ресурсов. Иначе это будет совершенно не правильно: брать и гасить все исключения, как будто их и не было вовсе.