[DotNetBook] Винятки: події про виняткових ситуаціях

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

Події про виняткових ситуаціях

У загальному випадку ми не завжди знаємо про ті винятки, які відбудуться в наших програмах, тому що практично завжди ми використовуємо те, що написано іншими людьми і що знаходиться в інших підсистемах і бібліотеках. Мало того, що можливі різні ситуації у вашому власному коді, в коді інших бібліотек, так ще й існує безліч проблем, пов’язаних з виконанням коду в ізольованих доменів. І якраз в цьому випадку було б вкрай корисно вміти отримувати дані про роботу ізольованого коду. Адже цілком реальною може бути ситуація, коли сторонній код перехоплює всі без винятку помилки, заглушивши їх fault блоком:

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

У такій ситуації може виявитися що виконання коду вже не так безпечно, як виглядає, але повідомлень про те що сталися якісь проблеми ми не маємо. Другий варіант — коли додаток глушить якийсь, нехай навіть легальне, виняток. А результат — наступне виняток у випадковому місці викличе падіння додатка в деякому майбутньому від випадкової здавалося б помилки. Тут хотілося б мати уявлення, яка була передісторія цієї помилки. Який хід подій призвів до такої ситуації. І один із способів зробити це можливим використовувати додаткові події, які відносяться до виключних ситуацій: 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, який організовує пісочницю і посилання на який у нас є.

Читайте також  Навіщо вчити цей Garbage Query Language?

Отже, за фактом нам надається два крайових події: щось сталося, чого не передбачалося (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! А цю виняткову ситуацію ми обробити ніяк не в змозі. Вона призводить до повного вильоту з програми. Які ж варіанти безпечної обробки у нас є?

Читайте також  Пишемо з IoC Starter. Базовий маппінг запитів, використовуючи context, web і orm

Перше, що приходить в голову — це виставити 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 вами)
{
 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);
});
 вами.FirstChanceException += subscription;

 return new Subscription(() => {
 вами.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 зі всієї необхідної логікою. Саме з усієї: з логгированием і очищенням ресурсів. Інакше це буде зовсім не правильно: брати і гасити всі винятки, як ніби їх і не було зовсім.

Посилання на всю книгу

  • CLR Book: GitHub
  • Реліз 0.5.0 книги, PDF: GitHub Release

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *