Главная » Хабрахабр » Особые исключения в .NET и как их готовить

Особые исключения в .NET и как их готовить

У разных исключений в .NET есть свои особенности, и знать их бывает очень полезно. Как обмануть CLR? Как остаться в живых в рантайме, поймав StackOverflowException? Какие исключения перехватить вроде бы нельзя, но если очень хочется, то можно?

Под катом расшифровка доклада Евгения (epeshk) Пешкова с нашей конференции DotNext 2018 Piter, где он рассказал про эти и другие особенности исключений.

Меня зовут Евгений. Привет! Суть в том, что у нас есть много продуктовых команд, которые пишут собственные сервисы и хостят их у нас. Я работаю в компании СКБ Контур и занимаюсь разработкой системы хостинга и деплоя приложений под Windows. Например, проследить за потреблением системных ресурсов или докинуть реплик к сервису. Мы предоставляем им легкое и простое решение разнообразных инфраструктурных задач.

Мы видели очень много способов, как приложение может упасть в рантайме. Иногда получается, что приложения, которые хостятся в нашей системе, разваливаются. Один из таких способов — это выкинуть какой-нибудь неожиданный и фееричный exception.

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

План

  1. Поведение исключений в .NET
  2. Обработка исключений в Windows и хаки

Всё рассмотренное далее верно для Windows. Все примеры тестировались на последней версии полного фреймворка .NET 4.7.1. Также будет немного упоминаний .NET Core.

Access Violation

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

Для этого запишем байт 42 по адресу 1000 (будем считать, что 1000 — это достаточно случайный адрес и у нашего приложения, скорее всего, доступа к нему нет). Попробуем получить это исключение, используя C#.

try { Marshal.WriteByte((IntPtr) 1000, 42);
}
catch (AccessViolationException) { ...
}

WriteByte делает как раз то, что нам нужно: записывает байт по заданному адресу. Мы ожидаем, что этот вызов выбросит AccessViolationException. Этот код действительно выбросит это исключение, его удастся обработать и приложение продолжит работать. Теперь немного изменим код:

try ; Marshal.Copy(bytes, 0, (IntPtr) 1000, bytes.Length);
}
catch (AccessViolationException) { ...
}

Если вместо WriteByte использовать метод Copy и скопировать байт 42 по адресу 1000, то, используя try-catch, AccessViolation поймать не получится. При этом на консоль будет выведено сообщение о том, что приложение завершено из-за необработанного AccessViolationException.

Marshal.Copy(bytes, 0, (IntPtr) 1000, bytes.Length);
Marshal.WriteByte((IntPtr) 1000, 42);

Получается, что у нас есть две строчки кода, при этом первая крашит все приложение с AccessViolation, а вторая выбрасывает обрабатываемое исключение того же типа. Чтобы понять, почему так происходит, мы посмотрим на то, как устроены эти методы изнутри.

Начнем с метода Copy.

static void Copy(...) { Marshal.CopyToNative((object) source, startIndex, destination, length);
}
[MethodImpl(MethodImplOptions.InternalCall)] static extern void CopyToNative(object source, int startIndex, IntPtr destination, int length);

Единственное, что делает метод Copy — вызывает метод CopyToNative, реализованный внутри .NET. Если наше приложение все-таки падает и исключение где-то происходит, то это может происходить только внутри CopyToNative. Отсюда можно сделать первое наблюдение: если .NET-код вызвал нативный код и внутри него произошел AccessViolation, то .NET-код это исключение по какой-то причине обработать не может.

Посмотрим на код этого метода: Теперь поймём, почему удалось обработать AccessViolation при использовании метода WriteByte.

unsafe static void WriteByte(IntPtr ptr, byte val) { try { *(byte*) ptr = val; } catch (NullReferenceException) { // this method is documented to throw AccessViolationException on any AV
throw new AccessViolationException(); }
}

Этот метод реализован полностью в managed-коде. Здесь используется C#-pointer, чтобы писать данные по нужному адресу, а также перехватывается NullReferenceException. Если перехватили NRE — выбрасывается AccessViolationException. Так нужно из-за спецификации. При этом все исключения, выброшенные конструкцией throw —  обрабатываемые. Соответственно, если при выполнении кода внутри WriteByte произойдёт NullReferenceException — мы сможем поймать AccessViolation. Мог ли произойти NRE, в нашем случае, при обращении не к нулевому адресу, а к адресу 1000?

Перепишем код с использованием C# pointers напрямую, и увидим, что при обращении к ненулевому адресу действительно выбрасывается NullReferenceException:

*(byte*) 1000 = 42;

Чтобы понять, почему так происходит, нам нужно вспомнить как устроена память процесса. В памяти процесса все адреса – виртуальные. Это значит, что у приложения есть большое адресное пространство и лишь некоторые страницы из него отображаются в реальной физической памяти. Но есть особенность: первые 64 КБ адресов никогда не отображаются в физическую память и не отдаются приложению. Рантайм .NET об этом знает и использует это. Если AccessViolation произошел в managed-коде, то рантайм проверяет, по какому именно адресу в памяти происходило обращение, и генерирует соответствующее исключение. Для адресов от 0 до 2^16 — NullReference, для всех остальных – AccessViolation.

Представьте, что вы обращаетесь к полю объекта ссылочного типа, и ссылка на этот объект нулевая: Давайте разберемся, почему NullReference выбрасывается не только при обращении по нулевому адресу.

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

Но что же происходит, если мы обращаемся к полю объекта, а сам этот объект занимает больше, чем 64 КБ?

Проведем эксперимент. Можем ли мы в этом случае получить AccessViolation? Одно поле – в начале объекта, второе – в конце: Создадим очень большой объект и будем обращаться к его полям.

Никакого AccessViolationException не произойдет.
Посмотрим на инструкции, которые будут сгенерированы для этих методов. Оба метода выбросят NullReferenceException. Во втором случае JIT-компилятор добавил дополнительную инструкцию cmp, которая обращается к адресу самого объекта, тем самым вызывая AccessViolation с нулевым адресом, который будет преобразован рантаймом в NullReferenceException.

Почему? Стоит отметить, что для этого эксперимента недостаточно использовать в качестве большого объекта массив. Оставим этот вопрос читателю, пишите идеи в комментариях 🙂

Подведем краткий итог экспериментов с AccessViolation.

Кроме того, если исключение произошло в managed-коде, то будет проверяться адрес объекта. AccessViolationException ведёт себя по-разному в зависимости от того, где исключение произошло (в managed-коде или в нативном).

Это иногда полезная возможность, особенно при работе с unsafe-кодом. Возникает вопрос: можем ли мы обработать AccessViolationException, который произошел в нативном коде или в управляемом, но не преобразованный в NullReference и не выброшенный с использованием throw? NET. Ответ на этот вопрос зависит от версии .

NET 1. В . Все ссылки считались либо валидными, либо нулевыми. 0 вообще не было никакого AccessViolationException. NET 2. Ко времени . В 4. 0 стало понятно, что без прямой работы с памятью  – никак, и AccessViolation появился, при этом был обрабатываемым. Для перехвата этого исключения теперь нужно пометить метод, в котором находится блок catch атрибутом HandleProcessCorruptedStateException. 0 и выше он по-прежнему остался обрабатываемым, но обработать его уже не так просто. Видимо, разработчики так сделали, потому что посчитали, что AccessViolationException — это не то исключение, которое надо ловить в обычном приложении.
Кроме того, для обратной совместимости есть возможность использовать настройки рантайма:

  • legacyNullReferenceExceptionPolicy возвращает поведение .NET 1.0 – все AV превращаются в NRE
  • legacyCorruptedStateExceptionsPolicy возвращает поведение .NET 2.0 – все AV перехватываемы

В .NET Core AccessViolation не обрабатывается вообще.

В нашем продакшене была вот такая ситуация:

NET 4. Приложение, собранное под . 1 использовало библиотеку с общим кодом, собранную под . 7. 5. NET 3. В этой библиотеке был хелпер для запуска периодического действия:

while (isRunning) { try { action(); } catch (Exception e) { log.Error(e); } WaitForNextExecution(... );
}

В этот хелпер мы передавали action из нашего приложения. Так получилось, что он падал с AccessViolation. В результате наше приложение постоянно логгировало AccessViolation, вместо того, чтобы упасть, т.к. код в библиотеке под 3.5 мог его поймать. Нужно обратить внимание, что перехватываемость зависит не от версии рантайма, на котором запущено приложение, а от TargetFramework, под который было собрано приложение, и его зависимости.

Обработка AccessVilolation зависит от того, где он произошел — в нативном или управляемом коде — а также от TargetFramework и настроек рантайма. Подводим итог.

Thread Abort

Иногда в коде нужно остановить выполнение одного из потоков. Для этого можно использовать метод thread.Abort();

var thread = new Thread(() => { try { ... } catch (ThreadAbortException e) { ... Thread.ResetAbort(); }
});
...
thread.Abort();

При вызове метода Abort в останавливаемом потоке выбрасывается исключение ThreadAbortException. Разберём его особенности. Например, такой код:

var thread = new Thread(() => { try { … } catch (ThreadAbortException e) { … }
});
...
thread.Abort();

Абсолютно эквивалентен такому:

var thread = new Thread(() => { try { ... } catch (ThreadAbortException e) { ... throw; }
});
...
thread.Abort();

Если всё-таки нужно обработать ThreadAbort и выполнить еще какие-то действия в останавливаемом потоке, то можно использовать метод Thread.ResetAbort(); Он прекращает процесс остановки потока и исключение перестаёт прокидываться выше по стеку. Важно понимать, что метод thread.Abort() сам по себе ничего не гарантирует — код в останавливаемом потоке может препятствовать остановке.

Abort() заключается в том, что он не сможет прервать код в том случае, если он находится в блоках catch и finally. Еще одна особенность thread.

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

Abort() дожидается выброса ThreadAbortException. Также вызов метода thread. Abort() может заблокировать вызывающий поток. Объединим эти два факта и получим, что метод thread.

var thread = new Thread(() =>
{ try { } catch { } // <-- No ThreadAbortException in catch finally { // <-- No ThreadAbortException in finally Thread.Sleep(- 1);
}
});
thread.Start();
...
thread.Abort(); // Never returns

В реальности с этим можно столкнуться при использовании конструкции using. Она разворачивается в try/finally, внутри finally вызывается метод Dispose. Он может быть сколь угодно сложным, содержать вызовы обработчики событий, использовать блокировки. И если thread.Abort был вызван во время выполнения Dispose — thread.Abort() будет его ждать. Так мы получаем блокировку почти на пустом месте.

NET Core метод thread. В . И я считаю, что это очень хорошо, потому что мотивирует пользоваться не thread. Abort() выбрасывает PlatformNotSupportedException. Abort(), а неинвазивными методами остановки выполнения кода, например с помощью CancellationToken.

OUT OF MEMORY

Это исключение можно получить, если памяти на машине оказалось меньше, чем требуется. Или когда мы уперлись в ограничения 32-битного процесса. Но получить его можно, даже если на компьютере много свободной памяти, а процесс — 64-битный.

var arr4gb = new int[int.MaxValue/2];

Код выше выкинет OutOfMemory. Все дело в том, что в дотнете по умолчанию не разрешены объекты более 2 ГБ. Это можно исправить настройкой gcAllowVeryLargeObjects в App.config. В этом случае массив размером 4 ГБ создастся.

А теперь попробуем создать массив ещё больше.

var largeArr = new int[int.MaxValue];

Теперь даже gcAllowVeryLargeObjects не поможет. Все из-за того, что в .NET есть ограничение на максимальный индекс в массиве. Это ограничение меньше, чем int.MaxValue.

Max array index:

  • byte arrays – 0x7FFFFFC7
  • other arrays – 0X7FEFFFFF

В этом случае произойдёт OutOfMemoryException, хотя на самом деле мы уперлись в ограничение типа данных, а не в недостаток памяти.

NET фреймворка: Иногда OutOfMemory явно выбрасывается управляемым кодом внутри .

Concat.
Это реализация метода string. MaxValue, то сразу выбрасывается OutOfMemoryException. Если длина строки-результата будет больше, чем int.

Перейдем к ситуации, когда OutOfMemory возникает по делу, когда реально заканчивается память.

LimitMemory(64.Mb());
try { while (true) list.Add(new byte[size]);
} catch (OutOfMemoryException e) { Console.WriteLine(e);
}

Сначала мы ограничиваем память нашего процесса в 64 мБ. Далее внутри цикла выделяем новые массивы байтов, сохраняем их в какой-то лист, чтобы GC их не собирал, и пытаемся поймать OutOfMemory.

В этом случае может произойти все что угодно:

  • Исключение обработается
  • Процесс упадёт
  • Зайдём в catch, но исключение вылетит снова
  • Зайдём в catch, но вылетит StackOverflow

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

  1. Исключение может обработаться. Внутри .NET ничто не мешает обрабатывать OutOfMemoryException.
  2. Процесс может упасть. Не нужно забывать, что у нас managed-приложение. Это означает, что внутри него выполняется не только наш код, но и код рантайма. Например, GC. Таким образом, может случиться ситуация, когда рантайм захочет себе выделить память, но не сможет это сделать, тогда мы не сможем перехватить исключение.
  3. Зайдем в catch, но исключение вылетит снова. Внутри catch мы тоже выполняем работу, при которой нам понадобится память (печатаем исключение на консоль), а это может вызвать новое исключение.
  4. Зайдем в catch, но вылетит StackOverflow. Сам StackOverflow происходит при вызове метода WriteLine, но переполнения стека здесь нет, а происходит другая ситуация. Разберём её подробнее.

Если страница зарезервирована, то приложение отметило, что собирается её использовать. В виртуальной памяти страницы могут быть не только отображены в физическую память, но и могут быть зарезервированными (reserved). Стек использует такую возможность разделять память на зарезервированную и закомиченную. Если страница уже отображена в реальную память или своп, то она называется «закоммиченной» (committed). Выглядит это примерно так:

Так получается, что уже вся закоммиченная память закончилась, значит операционная система в этот момент должна взять еще одну зарезервированную страницу стека и отобразить ее в реальную физическую память, которая уже заполнена массивами байтов. Получается, что мы вызываем метод WriteLine, который занимает какое-то место на стеке. Это и приводит к исключению StackOverflow.

Следующий код позволит на старте потока закоммитить всю память под стек сразу.

new Thread(() => F(), 4*1024*1024).Start();

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

Stack Overflow

Разберёмся подробнее со StackOverflowException. Посмотрим на два примера кода. В одном из них мы запускаем бесконечную рекурсию, которая приводит к переполнению стека, во втором мы просто выбрасываем это исключение с помощью throw.

try { InfiniteRecursion();
}
catch (Exception) { ...
}

try { throw new StackOverflowException();
}
catch (Exception) { ...
}

Так как все исключения, выброшенные с помощью throw, обрабатываемы, то во втором случае мы поймаем исключение. А с первым случаем все интереснее. Обратимся к MSDN:

«You cannot catch stack overflow exceptions, because the exception-handling code may require the stack.»
MSDN

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

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

EnsureSufficientExecutionStack(); RuntimeHelpers.

  • «Ensures that the remaining stack space is large enough to execute the average .NET Framework function.» — MSDN
  • InsufficientExecutionStackException
  • 512 KB – x86, AnyCPU, 2 MB – x64 (half of stack size)
  • 64/128 KB — .NET Core
  • Check only stack address space

В документации по этому методу сказано, что он проверяет, что на стеке достаточно места для выполнения средней функции .NET. Но что же такое средняя функция? На самом деле в .NET Framework этот метод проверяет, что на стеке свободна хотя бы половина от его размера. В .NET Core он проверяет чтобы было свободно 64 КБ.

NET Core появился аналог: RuntimeHelpers. Также в . TryEnsureSufficientExecutionStack() возвращающий bool, а не бросающий исключение.

2 появилась возможность использовать Span и stackallock вместе без использования unsafe-кода. В C# 7. В качестве такого способа предложены метод, проверяющий возможность аллокации на стеке и конструкция trystackalloc. Возможно, благодаря этому stackalloc станет использоваться в коде чаще и будет полезно иметь способ защититься от StackOverflow при его использовании, выбирая, где именно выделить память.

Span<byte> span;
if (CanAllocateOnStack(size)) span = stackalloc byte[size];
else span = new byte[size];

Вернёмся к документации по StackOverflow на MSDN

Instead, when a stack overflow occurs in a normal application, the Common Language Runtime (CLR) terminates the process.»
MSDN

Если есть «normal» application, которые падают при StackOverflow, значит есть и не-«normal» application, которые не падают? Для того, чтобы ответить на этот вопрос придется спуститься на уровень ниже с уровня управляемого приложения на уровень CLR.

«An application that hosts the CLR can change the default behavior and specify that the CLR unload the application domain where the exception occurs, but lets the process continue.» — MSDN
StackOverflowException -> AppDomainUnloadedException

Приложение, которое хостит CLR может переопределить поведение при переполнении стека так, чтобы вместо завершения всего процесса выгружался Application Domain, в потоке котором это переполнение произошло. Таким образом, мы можем превратить StackOverflowException в AppDomainUnloadedException.

NET. При запуске managed-приложение, автоматически запускается рантайм . Например, написать unmanaged-приложение (на С++ или другом языке), которое будет использовать специальное API для того, чтобы поднять CLR и запустить наше приложение. Но можно пойти по другому пути. Написав его, мы можем сконфигурировать многие вещи в рантайме. Приложение, которое запускает внутри себя CLR будем называть CLR-host. Мы в продакшене используем CLR-host для того, чтобы избежать попадания страниц памяти в своп. Например, подменить менеджер памяти и менеджер потоков.

Следующий код конфигурирует CLR-host так, чтобы при StackOverflow выгружался AppDomain (C++):

ICLRPolicyManager *policyMgr;
pCLRControl->GetCLRManager(IID_ICLRPolicyManager, (void**) (&policyMgr));
policyMgr->SetActionOnFailure(FAIL_StackOverflow, eRudeUnloadAppDomain);

Хороший ли это способ спастись от StackOverflow? Наверно, не очень. Во-первых, нам пришлось написать код на C++, чего делать не хотелось бы. Во-вторых, мы должны поменять свой C#-код так, чтобы та функция, которая может выбросить StackOverflowException выполнялась в отдельном AppDomain'е и в отдельном потоке. Наш код сразу превратится вот в такую лапшу:

try { var appDomain = AppDomain.CreateDomain("..."); appDomain.DoCallBack(() => { var thread = new Thread(() => InfiniteRecursion()); thread.Start(); thread.Join(); }); AppDomain.Unload(appDomain);
}
catch (AppDomainUnloadedException) { }

Ради того, чтобы вызвать метод InfiniteRecursion, мы написали кучу строк. В-третьих, мы начали использовать AppDomain. А это почти гарантирует кучу новых проблем. В том числе, с исключениями. Рассмотри пример:

public class CustomException : Exception {}
var appDomain = AppDomain.CreateDomain( "...");
appDomain.DoCallBack(() => throw new CustomException()); System.Runtime.Serialization.SerializationException:
Type 'CustomException' is not marked as serializable.
at System.AppDomain.DoCallBack(CrossAppDomainDelegate callBackDelegate)

Так как наше исключение не помечено как сериализуемое, то наш код упадет с исключением SerializationException. И чтобы исправить эту проблему, нам недостаточно пометить наше исключение атрибутом Serializable, еще потребуется реализовать дополнительный конструктор для сериализации.

[Serializable] public class CustomException : Exception
{
public CustomException(){}
public CustomException(SerializationInfo info, StreamingContext ctx) : base(info, context){}
} var appDomain = AppDomain.CreateDomain("...");
appDomain.DoCallBack(() => throw new CustomException());

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

SEH/VEH

Обратите внимание, что если между Managed и CLR летали Managed-exceptions, то между CLR и Windows летают SEH-exceptions.

SEH – Structured Exception Handling

  • Механизм обработки исключений в Windows
  • Единообразная обработка software и hardware исключений
  • C# исключения реализованы поверх SEH

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

NET знает о SEH-исключениях и умеет их конвертировать в managed-исключения: Рантайм .

  • EXCEPTION_STACK_OVERFLOW -> Crash
  • EXCEPTION_ACCESS_VIOLATION -> AccessViolationException
  • EXCEPTION_ACCESS_VIOLATION -> NullReferenceException
  • EXCEPTION_INT_DIVIDE_BY_ZERO -> DivideByZeroException
  • Unknown SEH exceptions -> SEHException

Взаимодействовать с SEH мы можем через WinApi.

[DllImport("kernel32.dll")] static extern void RaiseException(uint dwExceptionCode, uint dwExceptionFlags, uint nNumberOfArguments,IntPtr lpArguments); // DivideByZeroException
RaiseException(0xc0000094, 0, 0, IntPtr.Zero);
// Stack overflow
RaiseException(0xc00000fd, 0, 0, IntPtr.Zero);

На самом деле, конструкция throw тоже работает через SEH.

throw -> RaiseException(0xe0434f4d, ...)

Здесь стоит отметить, что код у CLR-exception всегда один и тот же, поэтому какой бы тип исключения мы не выбрасывали, оно всегда будет обрабатываемым.

Если SEH по семантике схож с try-catch, то VEH по семантике схож с обработчиком прерываний. VEH — это векторная обработка исключений, расширение SEH, но работающее на уровне процесса, а не на уровне одного потока. Интересная возможность VEH — это то, что он позволяет изменить SEH-исключение до того, как оно попадет в обработчик. Мы просто задаем свой обработчик и можем получать информацию обо всех исключениях, которые происходят в нашем процессе.

NET не крэшил процесс. Мы можем поставить между операционной системой и рантаймом собственный векторный обработчик, который будет обрабатывать SEH-исключения и при встрече с EXCEPTION_STACK_OVERFLOW изменять его так, чтобы рантайм .

С VEH можно взаимодействовать через WinApi:

[DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr AddVectoredExceptionHandler(IntPtr FirstHandler, VECTORED_EXCEPTION_HANDLER VectoredHandler); delegate VEH PVECTORED_EXCEPTION_HANDLER(ref EXCEPTION_POINTERS exceptionPointers);
public enum VEH : long
{ EXCEPTION_CONTINUE_SEARCH = 0, EXCEPTION_EXECUTE_HANDLER = 1, EXCEPTION_CONTINUE_EXECUTION = -1
} delegate VEH PVECTORED_EXCEPTION_HANDLER(ref EXCEPTION_POINTERS exceptionPointers); [StructLayout(LayoutKind.Sequential)] unsafe struct EXCEPTION_POINTERS { public EXCEPTION_RECORD* ExceptionRecord; public IntPtr Context;
} delegate VEH PVECTORED_EXCEPTION_HANDLER(ref EXCEPTION_POINTERS exceptionPointers);
[StructLayout(LayoutKind.Sequential)] unsafe struct EXCEPTION_RECORD { public uint ExceptionCode; ...
}

В Context находится информация о состоянии всех регистров процессора в момент исключения. Нас же будет интересовать EXCEPTION_RECORD и поле ExceptionCode в нем. Мы можем подменить его на собственный код исключения, о котором CLR вообще ничего не знает. Векторный обработчик выглядит так:

static unsafe VEH Handler(ref EXCEPTION_POINTERS e) { if (e.ExceptionRecord == null) return VEH. EXCEPTION_CONTINUE_SEARCH; var record = e. ExceptionRecord; if (record->ExceptionCode != ExceptionStackOverflow) return VEH. EXCEPTION_CONTINUE_SEARCH; record->ExceptionCode = 0x01234567; return VEH. EXCEPTION_EXECUTE_HANDLER;
}

Теперь сделаем обёртку, устанавливающую векторный обработчик в виде метода HandleSO, который принимает в себя делегат, который потенциально может упасть со StackOverflowException (для наглядности в коде нет обработки ошибок функций WinApi и удаления векторного обработчика).

HandleSO(() => InfiniteRecursion()) ; static T HandleSO<T>(Func<T> action) { Kernel32. AddVectoredExceptionHandler(IntPtr.Zero, Handler); Kernel32.SetThreadStackGuarantee(ref size); try { return action(); } catch (Exception e) when ((uint) Marshal. GetExceptionCode() == 0x01234567) {} return default(T);
} HandleSO(() => InfiniteRecursion());

Внутри него также используется метод SetThreadStackGuarantee. Этот метод резервирует место на стеке под обработку StackOverflow.

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

Но, что произойдет, если вызвать HandleSO дважды в одном потоке?

HandleSO(() => InfiniteRecursion());
HandleSO(() => InfiniteRecursion());

А произойдёт AccessViolationException. Вернемся к устройству стека.

В самом верху стека лежит специальная страница, помеченная флагом Guard page.
Операционная система умеет детектировать переполнение стека. Если просто перехватить это переполнение, то этой страницы на стеке больше не будет – при следующем переполнении операционная система не сможет этого понять и stack-pointer выйдет за границы памяти, выделенной под стек. При первом обращении к этой странице произойдет другое исключение – STATUS_GUARD_PAGE_VIOLATION, а флаг Guard page со страницы снимается. Значит нужно восстанавливать флаги страниц после обработки StackOverflow – cамый простой способ это сделать – использовать метод _resetstkoflw из библиотеки рантайма C (msvcrt.dll). Как итог — произойдет AccessViolationException.

[DllImport("msvcrt.dll")] static extern int _resetstkoflw();

Аналогичным способом можно перехватить AccessViolationException в .NET Core под Windows, который приводит к падению процесса. При этом понадобиться учесть порядок вызова векторных обработчиков и установить свой обработчик в начало цепочки, так как .NET Core также использует VEH при обработке AccessViolation. За порядок вызова обработчиков отвечает первый параметр функции AddVectoredExceptionHandler:

Kernel32.AddVectoredExceptionHandler(FirstHandler: (IntPtr) 1, handler);

Изучив практические вопросы, подведем общие итоги:

  • Исключения не так просты, как кажутся;
  • Не все исключения обрабатываются одинаково;
  • Обработка исключений происходит на разных уровнях абстракции;
  • Можно вмешаться в процесс обработки исключений и заставить рантайм .NET работать не так, как было задумано изначально.

Ссылки

→ Репозиторий с примерами из доклада
→ Dotnext 2016 Moscow — Adam Sitnik — Exceptional Exceptions in .NET
→ DotNetBook: Exceptions
→ .NET Inside Out Part 8 — Handling Stack Overflow Exception in C# with VEH — другой способ перехвата StackOverflow.

А еще в Москву приедут Джеффри Рихтер, Грег Янг, Павел Йосифович и другие не менее интересные спикеры. 22-23 ноября Евгений выступит на DotNext 2018 Moscow с докладом «Системные метрики: собираем подводные камни». Присоединяйтесь! Темы докладов можно посмотреть здесь, а купить билеты — здесь.


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

9 кругов автоматизации склада Lamoda

Наш склад размером с две Красные площади и высотой в 5 этажей работает круглый год и никогда не спит — 24/7 364 дня в году (единственный выходной — 1 января). У нас хранится и обслуживается более 8 000 000 товаров, ...

[Перевод] Каскадные SFU: улучшаем масштабируемость и качество медиа в WebRTC-приложениях

В развертывании медиасерверов для WebRTC есть две сложности: масштабирование, т.е. выход за рамки использования одного сервера и оптимизация задержек для всех пользователей конференции. В то время как простой шардинг в духе «отправить всех юзеров конференции X на сервер Y» легко ...