Введение

В свое время я случайно узнал, что исключения в моём горячо любимом языке 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 = enumerableCollection.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 лет в коммерческой разработке я столкнулся с такой проблемой лишь единожды, но понимание сути — что именно произошло и почему это возможно — сэкономило мне много часов поисков и отладки. Надеюсь, это окажется полезно и вам. 

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

Комментарии (18)


  1. ondister
    22.12.2021 14:00
    +4

    Третий случай try-finally это foreach. Сторонние библиотеки это четвертый случай.


    1. LaniChatah Автор
      22.12.2021 14:40

      Спасибо за замечание, добавлю в статью данный пример


    1. CSDev
      22.12.2021 17:33
      +4

      Кстати, try-finally с foreach имеет свои особенности.

      Допустим, есть метод:

      IEnumerable<int> CountDown(int count, int? stopAt = null, int? failAt = null)
      {
          try
          {
              while (count > -1)
              {
                  yield return count;
      
                  if (count == stopAt)
                      yield break;
      
                  if (count == failAt)
                      throw new Exception($"Inner failure at {nameof(count)} = {count}");
      
                  count--;
              }
          }
          finally
          {
              Console.WriteLine("Finally!!!");
          }
      }

      Тогда

      foreach (var i in CountDown(3))
          Console.WriteLine(i);

      Даст

      3
      2
      1
      0
      Finally!!!

      CountDown(3, stopAt: 1) выведет

      3
      2
      1
      Finally!!!

      А CountDown(3, failAt: 1)

      3
      2
      1
      Unhandled exception. System.Exception: Inner failure at count = 1
         at TryFinally.Program.CountDown(Int32 count, Nullable`1 stopAt, Nullable`1 failAt)+MoveNext() in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 63
         at TryFinally.Program.Main(String[] args) in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 10
      Finally!!!

      Ранний break finally не помеха:

      Код
      foreach (var i in CountDown(3, failAt: 1))
      {
          Console.WriteLine(i);
          break;
      }
      3
      Finally!!!

      Грубо говоря

      foreach (var i in CountDown(3))
          Console.WriteLine(i);

      развернется в

      Код
      var enumerator = CountDown(3).GetEnumerator();
      try
      {
          int i;
          while (enumerator.MoveNext())
          {
              i = enumerator.Current;
              Console.WriteLine(i);
          }
      }
      finally
      {
          enumerator?.Dispose();
      }

      и результат будет идентичен foreach и для CountDown(3).GetEnumerator(), и для CountDown(3, stopAt: 1).GetEnumerator(), и для CountDown(3, failAt: 1).GetEnumerator(), и для break; после Console.WriteLine(i);.

      Если закомментировать enumerator.Dispose();

      Код
      var enumerator = CountDown(3, stopAt: 1).GetEnumerator();
      try
      {
          int i;
          while (enumerator.MoveNext())
          {
              i = enumerator.Current;
              Console.WriteLine(i);
              break;
          }
      }
      finally
      {
          // enumerator.Dispose();
      }

      то в выводе получим просто 3, и можно предположить, что код блока finally метода CountDown вызывается в enumerator.Dispose().

      Но если также закомментировать и break;, то вывод будет:

      3
      2
      1
      Finally!!!

      Если открыть сборку с методом с помощью ILSpy, метод примерно выглядит так:

      Код
      IEnumerable<int> CountDown(int count, int? stopAt = null, int? failAt = null)
      {
          try
          {
              while (true)
              {
                  if (count > -1)
                  {
                      yield return count;
                      if (count != stopAt)
                      {
                          if (count == failAt)
                          {
                              break;
                          }
                          count--;
                          continue;
                      }
                      yield break; // <- triggers 'finally'
                  }
                  yield break; // <- triggers 'finally'
              }
              throw new Exception(string.Format("Inner failure at {0} = {1}", "count", count));
          }
          finally
          {
              Console.WriteLine("Finally!!!");
          }
      }

      И можно заметить, что код блока finally метода CountDown вызывается после yield break;. Один из них используется явно, а второй добавлен автоматически в конец метода компилятором.

      А теперь выполним

      var enumerator = CountDown(3, failAt: 1).GetEnumerator();
      try
      {
          int i;
          while (enumerator.MoveNext())
          {
              i = enumerator.Current;
              Console.WriteLine(i);
          }
      }
      finally
      {
          // enumerator.Dispose();
      }

      и получим

      3
      2
      1
      Unhandled exception. System.Exception: Inner failure at count = 1
         at TryFinally.Program.CountDown(Int32 count, Nullable`1 stopAt, Nullable`1 failAt)+MoveNext() in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 66
         at TryFinally.Program.Main(String[] args) in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 40
      Finally!!!

      То есть исключение тоже запустило выполнение блока finally.

      Таким образом, при использовании try-finally с yield return, блок finally выполняется:

      • в enumerator.Dispose();

      • после исключения

      • и после yield break;

      И не выполняется вовсе при невыполнении ни одного из этих условий.

      Кстати

      var enumerator = CountDown(3, failAt: 1).GetEnumerator();
      enumerator.Dispose();

      Все равно не запустит finally. Нужен хотябы один вызов enumerator.MoveNext().


      1. fkafka
        22.12.2021 20:45
        +3

        Я сильно детально в код выше не вникал, но тут, скорее, не особенность "foreach", а особенность "yield". Подобный пример, наверняка, можно и с async/await придумать. И там и там суть похожая - то что в тексте выглядит как обычный линейный код на деле таковым не является - компилятор разбивает его на отдельные части (конечный автомат) и эти части могут выполняться вовсе не в те моменты времени в которые ожидается интуитивно.

        Нужен хотябы один вызов enumerator.MoveNext().

        Да, вот как раз в этом и дело. По-моему, просто пока не будет первого MoveNext() выполнение метода с yield вообще даже не начнется.


        1. CSDev
          23.12.2021 22:53
          +1

          Да, действительно, это скорее про особенности yield и того, что генерируется под копотом.
          И код действительно нелинеен. Но, к счастью, и не параллелен, и поэтому наглядно пробегается в дебагере. Выглядит занятно. Особенное если это какое-нибудь нагромождение IEnumerable(IEnumerable(IEnumerable)) типа цепочек LINQ.

          И про

          Да, вот как раз в этом и дело. По-моему, просто пока не будет первого MoveNext() выполнение метода с yield вообще даже не начнется.

          верно подмечено.

          Поэтому, кстати, рекомендуют отделять проверки аргументов от yield. Иначе можно получить исключение в несовсем ожидаемом месте.

          Пример:

          Пусть есть такой код:

          static object CreateObject(object arg)
          {
              if (arg == null)
                  throw new ArgumentNullException(nameof(arg));
          
              return null;
          }
          
          static IEnumerable<object> CreateEnumerable(object arg)
          {
              if (arg == null)
                  throw new ArgumentNullException(nameof(arg));
          
              yield return null;
          }
          
          static void Test(object obj)
          {
              if (obj is IEnumerable enumerable)
              {
                  var enumerator = enumerable.GetEnumerator();
                  Console.WriteLine(enumerator.ToString());
                  Console.WriteLine(enumerator.GetType());
                  Console.WriteLine(enumerator.GetHashCode());
                  Console.WriteLine(enumerator.Equals(null));
                  Console.WriteLine(enumerator.Current ?? "NULL");
                  enumerator.MoveNext();
              }
          }

          Тогда для Test(CreateObject(null)) вывод будет:

          Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 'arg')
             at TryFinally.Program.CreateObject(Object arg) in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 19
             at TryFinally.Program.Main(String[] args) in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 11

          Вызов метода Test не произошел.

          А для Test(CreateEnumerable(null)):

          TryFinally.Program+<CreateEnumerable>d__2
          TryFinally.Program+<CreateEnumerable>d__2
          58225482
          False
          NULL
          Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 'arg')
             at TryFinally.Program.CreateEnumerable(Object arg)+MoveNext() in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 27
             at TryFinally.Program.Test(Object obj) in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 40
             at TryFinally.Program.Main(String[] args) in C:\Users\Admin\source\repos\TryFinally\TryFinally\Program.cs:line 11

          Метод Test не только был вызван, но и спокойно обращался к членам enumerator до вызова enumerator.MoveNext().


          1. fkafka
            24.12.2021 00:01

            Поэтому, кстати, рекомендуют отделять проверки аргументов от yield. Иначе можно получить исключение в несовсем ожидаемом месте.

            Да, с async/await это та же самая знаменитая засада.

            Простейший пример:

            try
            {
                Console.WriteLine("Calling SomeAsync.");
                var t = SomeAsync();
                Console.WriteLine("SomeAsync done.");
                await t;
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
            
            async Task SomeAsync()
            {
                throw new Exception("Hi, I'm an Exception!");
            }

            И вывод в консоль будет такой:

            Calling SomeAsync.
            SomeAsync done.
            Hi, I'm an Exception!

            Т.е. исключение кинулось уже после того, как метод кидающий это исключение "типа выполнился"


  1. vitrilo
    22.12.2021 14:16
    +4

    Также стоит упомянуть об сьедании/игнорировании средой исключений. Например, при выбросе исключения внутри finally после уже сработовшого catch. В таком случае оригинальное исключение будет потерено, и заменится на последнее (из finally).


    1. LaniChatah Автор
      22.12.2021 14:43

      Спасибо за ценное замечание.
      В целом это попадает в общую копилку про подмену исключения как в catch блоке (throw; VS throw new Exception(...)), но менее очевидный случай
      Так как я не затрагивал эту тему, то не стал писать и про подмену исключения при выбрасывании исключения из файнали


  1. fkafka
    22.12.2021 14:28
    +3

    BTW. Вызов Thread.Abort начиная с .NET 5 убрали (теперь он ничего не делает и просто кидает исключение "PlatformNotSupportedException" в вызвавшем его потоке. https://docs.microsoft.com/en-us/dotnet/api/system.threading.thread.abort?view=net-5.0

    А еще было бы познавательно написать про CSE (corrupted state exceptions).


    1. LaniChatah Автор
      22.12.2021 14:33
      +2

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

      Про исключение поврежденного состояния есть отдельная глава в статье. Или вы подразумевали что-то конкретное?


      1. fkafka
        22.12.2021 14:51

        Про исключение поврежденного состояния есть отдельная глава в статье.

        А, ну да, странно, но я это почему-то не заметил.


    1. KvanTTT
      23.12.2021 01:48

      Его раньше убрали, в более ранних core версиях его уже не было.


  1. 0x1000000
    22.12.2021 14:36
    +2

    Ещё блок finally в C# можно пропустить вообще без каких-либо исключений, достаточно намудрить с асинхронной стейт машиной. Когда-то приводил пример того, как это можно сделать.


  1. VEG
    22.12.2021 14:44

    stackalloc int[10000]
    Это 40 килобайт. Не стоит выделять так много памяти на стеке, он не резиновый. Максимум килобайт 10-20 на все необходимые буферы в функции, а если нужно больше, то добро пожаловать в кучу. Как пример, у Microsoft в VC++ по умолчанию ругается, если больше 16 килобайт. Не помешало бы такое же предупреждение и в C# добавить для stackalloc. Никогда не знаешь, сколько памяти на стеке понадобится функциям, которые вы вызываете из своей функции, поэтому лучше подходить к этому достаточно консервативно и максимально осторожно.


    1. LaniChatah Автор
      22.12.2021 15:10
      +3

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

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

      Именно поэтому при подобном аллоцировании имеет смысл подстраховаться с помощью RuntimeHelper , также при желании можно добавить в условие проверку на размер выделяемого масива.


    1. VolodjaT
      24.12.2021 14:34

      Так ведь стек в C# и C++ это не одно и то же?
      в С# лимит по умолчанию 4 MB на процес (в 64 бит)
      https://stackoverflow.com/questions/28656872/why-is-stack-size-in-c-sharp-exactly-1-mb


      1. VEG
        24.12.2021 14:53

        Это абсолютно одно и то же что в C++, что в C#. Когда ваш код или используемая вами библиотека вызывает какую-то системную функцию (которая почти наверняка написана на C или C++), эта функция будет использовать ровно тот же самый стек.

        Вы никогда не знаете, сколько памяти понадобится какой-нибудь функции, что вы вызываете. Если где-то там встретится небольшая рекурсия, так как разработчики посчитали, что в стеке будет достаточно свободно, а вы оставили совсем немного свободного пространства, ваша программа просто упадёт. И ради чего? Просто выделяйте любые достаточно большие буферы в куче. Тем более, что C# не про хардкорные оптимизации в стиле C++ вообще (хоть там и есть stackalloc и даже указатели в unsafe коде для очень специальных случаев).


  1. AlexanderplUs
    23.12.2021 16:57

    Небольшое замечание по фразе «достаточно места на стеке для средней функции» - average function лучше как "типичная функция" переводить. Тогда понятнее становится: «достаточно места на стеке для типичной функции»