Исключения среди исключений в .NET

Роман Носов, Team Leader
Оглавление

Введение

В свое время я случайно узнал, что исключения в моём горячо любимом языке C# — и, как следствие, во всем .NET — не все ведут себя одинаково. Причём, что ещё гораздо интереснее, далеко не все и не всегда могут быть обработаны и перехвачены. Что, казалось бы, полностью противоречит интуитивному восприятию конструкции try-catch-finally.

Изучая этот вопрос, я находил всё новые и новые исключения среди исключений, которые оказывались «сильнее», чем конструкция try-catch-finally. К тому моменту, когда мой список вырос до 7 пунктов, я внезапно осознал, что нигде не было такого места, где можно было бы найти их все сразу. Максимум — 2 или 3 случая, рассмотренных в одной статье.

Это и подтолкнуло меня к написанию данной статьи.

Теория

Границы применимости

Прежде чем говорить об исключениях и их обработке посредством try-catch-finally, давайте обозначим границы применимости этих сценариев — каких случаев они касаются.

Первый и очевидный случай — это когда мы хотим обработать исключение и своими руками пишем конструкцию try-catch-finally.

try 
{ 
	//Some work here
}
catch (Exception) 
{
  //Some exception handling
}
finally
{
  //Some work that we expect always to be done
}

Второй случай — это когда мы используем конструкции, которые при компиляции разворачиваются в try-finally, то есть по сути являются синтаксическим сахаром. Для .NET это конструкции using и lock и foreach:

using (var resourseWorker = new ResourseWorker()) 
{
  // Some work with disposable object
}
lock (x)
{
  // Your code...
} 
foreach (var element in enumerableCollection)
{
  //Your code...
}

Аналогичные конструкции с try-catch-finally

var resourseWorker = new ResourseWorker();
try {
  //Some work with disposable object
}
finally {
  ((IDisposable)resourseWorker).Dispose();
}
bool __lockWasTaken = false;
try {
  System.Threading.Monitor.Enter(x, ref __lockWasTaken);
  //Your code...
}
finally {
    if (__lockWasTaken) System.Threading.Monitor.Exit(x);
}
var enumerator = enumearbleCollection.GetEnumerator();
try {
  while (enumerator.MoveNext()) {
    var element = enumerator.Current;
    //Your code...
  }
}
finally {
  //Dispose enumerator if needed
}

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

Для начала давайте всё-таки оговорим, что сама по себе конструкция trу-catch-finally очень устойчива, и разберём несколько пограничных случаев, где она справляется.

Выбрасывание исключения из блока catch

try
{
  throw new Exception("Exception from try!");
}
catch (Exception)
{
  throw new Exception("Exception from catch!");
}
finally
{
  Console.WriteLine("Yes! It will be executed and logged");
}

С данным случаем, скорее всего, встречались многие читатели данной статьи. Но, как показала статистика моих опросов, некоторые полагают, что в данном случае блок finally может не исполниться. Тем не менее, как мы можем увидеть из примера кода, finally исполняется независимо от того, в какой момент и в каком блоке прервётся исполнение приложения.

Обойти блок finally с помощью goto

var counter = 0;
StartLabel:
Console.WriteLine("Start\n");
try
{
  if (counter++ == 0)
  {
    Console.WriteLine("Try: 1\n");
    goto StartLabel;
  }
  Console.WriteLine("Try: 2\n");
  goto EndLabel;
}
finally
{
  Console.WriteLine("Finally\n");
}
Console.WriteLine("End: 1");
EndLabel:
Console.WriteLine("End: 2");

Вывод в консоль

Start

Try: 1

Finally

Start

Try: 2

Finally

End: 2

Интуитивно можно было бы предположить, что с помощью оператора goto мы обходим блок finally, но, как видим, он всё равно выполняется. Связано это непосредственно с триггером, при котором .NET его выполняет: 'The statements of a finally block are always executed when control leaves a try statement'. Так как goto приводит к тому, что область исполнения покидает блок try, то finally исполняется. Из неочевидного можно отметить, что даже если goto передаст управление «вверх» по коду, это всё равно приведёт к выполнению блока finally. Именно поэтому в нашем примере блок finally выполнился дважды.

Уничтожить поток с помощью Thread.Abort

void ThreadLogic()
{
  try
  {
    Task.Delay(10000).Wait();
  }
  catch (ThreadAbortException)
  {
    Console.WriteLine("Catch: Yes! It will be logged");
  }
  finally
  {
    Console.WriteLine("Finally: Yes! It will be logged");
  }
  Console.WriteLine("End: No! It will not be logged");
}
var thread = new Thread(ThreadLogic);
thread.Start();
Task.Delay(150).Wait();
thread.Abort(); 

Здесь всё достаточно очевидно, ведь метод Abort не уничтожает поток, а только приводит к тому, что в потоке выбрасывается ThreadAbortException. И, как следствие, это не может представлять сложности для конструкции try-catch-finally.

Из интересного можно отметить ненадёжность и опасность данного метода. С одной стороны, это связано с тем, что мы не знаем, сколько времени понадобится для прерывания потока и даже будет ли он прерван вообще. А с другой — мы не можем заранее предсказать, насколько важную работу прервём и может ли это повредить другие потоки или всё приложение в целом. Но об этом достаточно давно говорит и сам Microsoft в документации.

Получить Exception, не являющийся наследником класса System.Exception

В общем случае кажется, что это противоречит описанию класса Exception, в соответствии с которым System.Exception — базовый класс для всех исключений. Но правда в том, что он является базовым классом для всех исключений только внутри .NET. А мы всё ещё можем получить исключение из не-.NET-кода, если будем использовать библиотеки, написанные на других языках, или же каким-либо другим способом использовать вставки других языков.

На самом деле try-catch может перехватить и такое исключение, просто для этого нужно использовать catch без указания класса. Такой catch называется general catch, то есть общий. И он может перехватывать абсолютно любые ошибки.

Самое интересное — почему try-catch-finally всё-таки может нас подвести

Уничтожение процесса

Кажется достаточно очевидным, что если уничтожить процесс, то в нём никак не смогут исполниться ни блок catch, ни finally. С другой стороны, сразу возникают следующие вопросы:

  1. Насколько этот случай возможен и часто встречаем?
  2. Не можем ли мы им пренебречь по принципу «Нет процесса — нет проблем»?

Первый случай, где подобное является обыденностью, — это микросервисная архитектура. Ведь чем больше серверов, тем больше шансов, что какой-то из них упадёт (по любой причине), и тогда, само собой, все его процессы будут уничтожены. В наши дни, когда докеры и контейнеры приобретают всё большую популярность, а архитектура приложения почти всегда включает пару-тройку (а то и несколько десятков) разных микросервисов, это является вполне реальной возможностью.

Другой случай, когда уничтожение процесса весьма вероятно, — это мобильные приложения. Так как мобильный телефон — это устройство телефонии, то для него критически важна бесперебойная работа в любой ситуации. Поэтому ОС телефона всегда оставляет за собой право «пристрелить» любой процесс, если она почувствует, что у неё заканчиваются ресурсы.

Оба рассмотренных случая приводят к тому, что процесс нашего приложения, внутри которого в этот момент мог выполняться блок try, будет уничтожен. И, как следствие, соответствующие ему блоки catch и finally исполнены не будут.

Перейдём ко второму вопросу. Казалось бы, нет процесса — нет проблемы: должно ли нас волновать то, что в уничтоженном процессе не исполнится какой-то код? На самом деле должно. Так, в микросервисной архитектуре мы часто подразумеваем, что в блоке finally может быть какое-либо микросервисное взаимодействие, например закрытие транзакции или соединения. Или же мы должны были положить или забрать что-то из очереди. Или — самое тривиальное — мы могли рассчитывать, что при невыполнении операции мы попадём в catch и залогируем это.

Здесь важно понимать следующее. Да, такое поведение противоречит интуитивным ожиданиям разработчиков — что код внутри finally исполнится всегда, а catch-блок будет исполнен, если прервётся выполнение логики, помещённой в try. Но, несмотря на это, данное поведение полностью соответствует документации. Ведь catch-блок, согласно документации, исполняется в момент возникновения исключения. Оно не обязано быть .NET-исключением, но оно должно распознаваться посредством SEH (Structured Exception Handling) Windows — а при уничтожении процесса никакого исключения не генерируется. Что касается finally, то по документации он исполнится в тот момент, когда мы покидаем блок try, а при уничтожении процесса мы не успеваем покинуть этот блок.

Поэтому давайте перейдём к более спортивному и менее очевидному случаю.

FailFast & Exit

В .NET существует метод Environment.FailFast, который делает следующее: пишет в лог Windows событие о факте своего вызова, выбрасывает исключение ExecutionEngineException и затем незамедлительно уничтожает процесс, внутри которого был вызван. Это естественным образом приводит к тому, что конструкция try-catch-finally против него бессильна.

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

Еще один метод статического класса окружения — Environment.Exit. Он также приводит к уничтожению процесса, внутри которого вызван, из-за чего блок finally не срабатывает.

Это чуть менее очевидно, так как формально метод Exit информирует о завершении приложения с некоторым кодом, возможно даже успешным. И тем не менее наша конструкция его не обрабатывает.

Corrupted State Exception

Corrupted State Exception (CSE), или «исключение повреждённого состояния», — класс исключений, который является частью SEH. Как следствие, CLR и наше приложение знают, когда это исключение происходит. Но, несмотря на это, оно не перехватывается и не обрабатывается конструкцией try-catch-finally.

Отказ от обработки этого исключения — это осознанное решение со стороны Microsoft, введённое начиная с версии .NET 4.0 и связанное непосредственно с причиной возникновения CSE.

Как и следует из названия, данное исключение возникает из-за повреждённого состояния приложения, а именно из-за повреждения памяти приложения — в куче или на стеке. А значит, мы не можем предсказать, как поведёт себя приложение, если продолжит работу. Потому Microsoft и пришёл к выводу, что в данном случае безопаснее просто мгновенно упасть, не пытаясь что-либо обработать.

Самые частые причины повреждения памяти — следующие:

  1. Неаккуратное использование небезопасного кода (unsafe code).
  2. Что-то пошло не так у Windows при обработке нашего процесса.
  3. Баг непосредственно движка .NET.

Несмотря на то, что это исключение не обрабатывается, мы всё же можем узнать, что оно произошло. Перед тем, как уничтожить процесс, .NET всегда делает запись о происшедшем в Windows Event Viewer и выгружает состояние приложения в логи. Там мы всегда можем найти информацию о происшедшем, а при необходимости — если решим, что ничего страшного не произошло, — даже восстановить приложение с того момента, на котором получено исключение.

Мы можем даже перехватить это исключение — хотя Windows настоятельно рекомендует этого не делать — если воспользуемся атрибутом [HandleProcessCorruptedStateExcepionsAttribute]. Он навешивается на метод, и тогда конструкция try-catch-finally начинает перехватывать Corrupted State Exception.

Кроме того, при желании мы можем применить данное поведение на всё приложение. Для этого нужно в его конфигах прописать элемент <legacyCorruptedStateExceptionsPolicy>. Но это сработает только для .NET Framework. .NET Core не способен перехватить исключение повреждённого состояния и, как следствие, будет его игнорировать, хотя формально там этот атрибут есть.

Как было сказано в самом начале, исключение повреждённого состояния — это не конкретное исключение, а целый класс исключений. В исходниках .NET можно увидеть, что в методе ‘IsProcessCorruptedStateException’ их выделяется 8 видов:

  1. STATUS_ACCESS_VIOLATION
  2. STATUS_STACK_OVERFLOW
  3. EXCEPTION_ILLEGAL_INSTRUCTION
  4. EXCEPTION_IN_PAGE_ERROR
  5. EXCEPTION_INVALID_DISPOSITION
  6. EXCEPTION_NONCONTINUABLE_EXCEPTION
  7. EXCEPTION_PRIV_INSTRUCTION
  8. STATUS_UNWIND_CONSOLIDATE

InvalidProgramException

Такое исключение возникает, когда CLR не может прочитать и интерпретировать промежуточный байт-код. Как сообщает MSDN, получение данного исключения обычно означает наличие бага в компиляторе, сгенерировавшем код, на котором была получена ошибка.

Но есть и другой способ получить это исключение — с помощью динамической генерации кода через ILGenerator. Так как мы создаём промежуточный код динамически, он может оказаться невалидным, и тогда попытка его исполнения также приведёт к InvalidProgramException.

AppDomain & FirstChanceException

Как мы знаем, в .NET есть глобальный объект AppDomain, а также мы можем воспользоваться рядом подписок на разные события. В данном случае нас интересуют подписки, связанные с исключениями. Их две:

  1. FirstChanceException — эта подписка срабатывает, когда любое исключение пробрасывается в приложении в первый раз.
  2. UnhandledException — эта подписка срабатывает, когда какое-либо исключение было выброшено, но ничем не было перехвачено.

Разберём, как работает FirstChanceException. Когда выбрасывается исключение, .NET прогоняет все подписки на это событие. Только после этого приложение определяет, как нужно обработать исключение: какие блоки finally должны исполниться, с какого момента приложение должно продолжить своё исполнение и будет ли исключение перехвачено. Как следствие, если в любом из методов подписок произойдёт исключение и оно не будет перехвачено, то .NET никогда не вернётся к обработке первоначального исключения, а приложение не передаст управление блокам catch и finally.

AppDomain.CurrentDomain.FirstChanceException += (sender, eventArgs) =>
{
  Console.WriteLine($"Log from FirstChanceException: {eventArgs.Exception.Message}\n");
  if (eventArgs.Exception.Message != "Exception from FirstChanceException!")
    throw new Exception("Exception from FirstChanceException!");
};

Отметим, что в данном примере кода выбрасывание исключения находится под условием. В противном случае это приводило бы к вечной рекурсии, так как FirstChanceException реагирует и на исключения внутри себя.

AppDomain.CurrentDomain.FirstChanceException += (sender, eventArgs) =>
{
  Console.WriteLine("Log from FirstChanceException: " + eventArgs.Exception.Message); 
  throw new Exception("Exception from FirstChanceException");
};

Как следствие, использование FirstChanceException само по себе крайне опасно, так как любое исключение внутри приведёт к моментальной смерти всего процесса. Но есть способ частично обезопасить себя, оборачивая всю логику метода в блок try:

AppDomain.CurrentDomain.FirstChanceException += (sender, eventArgs) =>
{
  try
  {
    Console.WriteLine($"Log from FirstChanceException: {eventArgs.Exception.Message}\n");
    if (eventArgs.Exception.Message != "Exception from FirstChanceException!")
      throw new Exception("Exception from FirstChanceException!");
  }
  catch { /* ignored */ }
}; 

Но нужно помнить, что это всё ещё не защищает нас от вечной рекурсии:

AppDomain.CurrentDomain.FirstChanceException += (sender, eventArgs) =>
{
	try
  {
    Console.WriteLine("Log from FirstChanceException: " + eventArgs.Exception.Message); 
    throw new Exception("Exception from FirstChanceException");
  }
  catch { /* ignored */ }
}; 

Таким образом, данная подписка ни в коем случае не должна использоваться для реализации бизнес-логики. Но если она такая опасная, то для чего она может нам понадобиться? Например, она может быть полезна для логирования и сбора статистики по исключениям. Тем не менее, мы можем оказаться в ситуации, когда логи будут недоступны, и тогда запись в них также будет приводить к исключениям. По этой причине даже такое использование предпочтительно использовать не на продакшене.

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

Из интересного также отметим, что данные подписки не срабатывают на исключения повреждённого состояния (CSE).

OutOfMemoryException

OutOfMemoryException по поведению ничем не отличается от всех остальных исключений.

Но давайте разберём такой случай: блок finally (или catch) запускается, а затем по какой-то причине не может выполниться. В общем случае такое поведение для нас (разработчиков), ничем не лучше, чем когда эти блоки вовсе не запустились. Это возможно, если мы получаем исключение в начале исполнения блока. С другой стороны, логика блока finally обычно достаточно минималистична и надёжна, ведь мы хотим, чтобы она всегда выполнялась. Тогда вопрос в другом: можем ли мы получить исключение внутри блока не по вине его кода?

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

Но что произойдёт, если мы до блока try выделим некоторое близкое к критическому количество памяти, а затем внутри него выделим ещё немного памяти — достаточно, чтобы это привело к исключению?

double[] array1 = new double[200_000_000];
double[][] array2 = new double[1000][];
double[] array3;
int i = 0;
try
{
  Console.WriteLine("Try: Yes! It will be logged");
  for (; i < array2.Length; i++)
    array2[i] = new double[100_000];
  Console.WriteLine("Try: No! It will be not logged");
}
catch (Exception e)
{
  Console.WriteLine($"Catch, i = {i}: Yes! It will be logged, but value of “i” always will be different in range from 320 to 380"); 
}
finally
{
  array3 = new double[500_000];
  Console.WriteLine("Finally: No! It will be not logged");
} 

Исполнение кода приведёт к тому, что блок finally запустится, но не выполнится — ему просто не хватит на это памяти. Как результат, мы получим исключение в блоке finally несмотря на то, что собственно код внутри блока не содержал ничего нелегального и выглядел достаточно безопасно и надёжно.

Как было отмечено в выводе в консоль, значение переменной счётчика на момент получения исключения нехватки памяти в первый раз всегда будет разное. Это связано с тем, что данное исключение плавающее — мы никогда не можем гарантированно предсказать заранее, когда именно мы не сможем выделить новую память, так как это зависит от многих внешних факторов: операционной системы, внутреннего движка .NET, сборщика мусора.

Добавим, сколько памяти можно использовать, чтобы не получить OutOfMemoryException.

Максимальный размер объекта:

  • .NET Framework — 2 GB. При попытке создания объекта большего размера мы получим исключение, но это поведение можно переопределить в файле конфигурации <gcAllowVeryLargeObjects>.
  • .NET Core — никаких ограничений по размеру объекта нет.

Максимальное количество аллоцируемой виртуальной памяти:

  • 32-bit процесс + 32-bit система — 2GB,
  • 32-bit процесс + 64-bit система — 4GB,
  • 64-bit процесс + 64-bit система — 8TB.

Возвращаясь к примеру выше, для стабильного получения исключения мне пришлось скомпилировать приложение как 32-битное. Ведь в противном случае потолок выделяемой памяти был бы 8 терабайт.

StackOverflowException

Первое, что приходит в голову — это то, что мы можем повторить идею, используемую для исключения OutOfMemoryException: почти полностью заполнить стек, попасть в блок try, получить исключение, оказаться в блоке finally с почти полностью забитым стеком и снова получить исключение.

Но на самом деле так не выйдет по той причине, что StackOverflowException не перехватывается конструкцией try-catch-finally начиная с версии .NET Framework 2.0.

Внимательный читатель мог обратить внимание, что формально исключение «превышения стека» также является и исключением повреждённого состояния.

Это ведёт к закономерному вопросу: заслуживает ли данное исключение отдельного обсуждения? Определённо заслуживает, ведь его поведение полностью отличается от других исключений поврежденного состояния. Первое отличие — его обработка перестала поддерживаться начиная со второй версии, а не с четвёртой. Второе — никакими атрибутами это поведение изменить нельзя. Как следствие, StackOverflowException никогда не перехватывается.

Хотя, строго говоря, не совсем никогда. Существуют хаки вроде хостинга .NET в unmanaged приложении и/или переопределения поведения при переполнении стека. Но так как тема данной статьи — это специфические ситуации, которые могут неожиданно возникнуть в любом нашем приложении, то мы такие экзотические варианты рассматривать не будем.

Итак, хорошо известно, что самая частая причина переполнения стека — это излишняя вложенность методов или рекурсия. Следовательно, самый очевидный способ борьбы с исключением — это уменьшение вложенности методов или приёмы, позволяющие разворачивать рекурсию в циклы.

Но при этом мы упускаем из виду альтернативную причину получения этого исключения. Как мы все знаем, ахиллесова пята производительности любого языка со сборщиком мусора — это сам сборщик мусора. Как следствие, если мы будем, когда это допустимо, выделять память на стеке вместо кучи, то мы сможем облегчить задачу сборщика и улучшить производительность. Например, мы можем выделять массивы на стеке с помощью связки Span и stackalloc. Но если мы будем злоупотреблять этим приёмом или выделять слишком большие массивы, то это тоже приведёт к исключению переполнения стека, а оно, как мы помним, бескомпромиссно и никак не перехватывается.

Span<int> stackSpan1 = stackalloc int[150000];
Span<int> stackSpan2 = stackalloc int[150000];
Span<int> stackSpan3 = stackalloc int[150000]; //Here I got OutOfMemoryException 
Span<int> stackSpan4 = stackalloc int[150000]; 

Тем не менее, мы не обязаны отказываться от этого приёма, и здесь нам может помочь RuntimeHelper, а именно следующие два его метода:

  1. EnsureSufficientExecutionStack — проверяет, достаточно ли на стеке места для выполнения средней .NET-функции. Если недостаточно, то выбрасывается исключение InsufficientExecutionStackException.
  2. TryEnsureSufficientExecutionStack — аналог предыдущего метода, возвращающий логическое значение — есть ли место на стеке.

Возможно, вам, как и мне, словосочетание «достаточно места на стеке для средней функции» показалось очень неуместным для технической статьи. Дело в том, что MSDN в своей документации использует именно такое словосочетание — без уточнения, сколько же это памяти. В хабрапосте об особых исключения .NET есть информация о том, что под этим подразумевается, но без указания источника. Я эту информацию не проверял и пруфов в официальной документации не нашёл.

  • .NET Framework — половина размера стека
    • x86 — 512 KB
    • x64 — 2 MB
  • .NET Core — 64/128 KB

Как мы видим, .NET Core меньше склонен к панике и гораздо позже уведомит нас о том, что стек подходит к концу. Это позволяет гораздо полноценнее и эффективнее оперировать памятью стека.

bool isStackOk = RuntimeHelpers.TryEnsureSufficientExecutionStack();
Span<int> stackSpan = isStackOk ? stackalloc int[10000] : new int[10000]; 

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

Заключение

Большинство «разочарований» в конструкции try-catch-finally происходит по причине расхождений между правилами её срабатывания и тем, как программисты их интерпретируют.

Ведь в большинстве случаев программист ожидает, что при сбое в программе обязательно исполнится блок catch, а блок finally так и вовсе будет выполняться всегда. Но на самом деле, как мы видим, catch выполняется только в том случае, если в приложении было выброшено исключение. А finally сработает в тот момент, когда область исполнения .NET покинет блоки try-catch, и только при условии, что CLR в этот момент будет в добром здравии.

Тем не менее, остаётся актуальным вопрос: нужно ли пытаться предотвратить исключительные исключения? Я бы на него ответил так: нет особого смысла думать об этом сильно заранее, поскольку любая из описанных в статье ситуаций достаточно редка и экзотична. Гораздо разумнее просто держать в голове, что правила игры могут измениться, и даже самая устойчивая и надёжная конструкция в языке может отказать. За свои 6 лет в коммерческой разработке я столкнулся с такой проблемой лишь единожды, но понимание сути — что именно произошло и почему это возможно — сэкономило мне много часов поисков и отладки. Надеюсь, это окажется полезно и вам.

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

Вам также может понравиться: