У разных исключений в .NET есть свои особенности, и знать их бывает очень полезно. Как обмануть CLR? Как остаться в живых в рантайме, поймав StackOverflowException? Какие исключения перехватить вроде бы нельзя, но если очень хочется, то можно?
Под катом расшифровка доклада Евгения (epeshk) Пешкова с нашей конференции DotNext 2018 Piter, где он рассказал про эти и другие особенности исключений.
Привет! Меня зовут Евгений. Я работаю в компании СКБ Контур и занимаюсь разработкой системы хостинга и деплоя приложений под Windows. Суть в том, что у нас есть много продуктовых команд, которые пишут собственные сервисы и хостят их у нас. Мы предоставляем им легкое и простое решение разнообразных инфраструктурных задач. Например, проследить за потреблением системных ресурсов или докинуть реплик к сервису.
Иногда получается, что приложения, которые хостятся в нашей системе, разваливаются. Мы видели очень много способов, как приложение может упасть в рантайме. Один из таких способов — это выкинуть какой-нибудь неожиданный и фееричный exception.
Сегодня я расскажу об особенностях исключений в .NET. С некоторыми из этих особенностей мы столкнулись в продакшене, а с некоторыми — в ходе экспериментов.
Всё рассмотренное далее верно для Windows. Все примеры тестировались на последней версии полного фреймворка .NET 4.7.1. Также будет немного упоминаний .NET Core.
Это исключение случается при некорректных операциях с памятью. Например, если приложение пробует обратиться к области памяти, к которой у него нет доступа. Исключение низкоуровневое, и обычно, если оно случилось, предстоит очень долгая отладка.
Попробуем получить это исключение, используя C#. Для этого запишем байт 42 по адресу 1000 (будем считать, что 1000 — это достаточно случайный адрес и у нашего приложения, скорее всего, доступа к нему нет).
WriteByte делает как раз то, что нам нужно: записывает байт по заданному адресу. Мы ожидаем, что этот вызов выбросит AccessViolationException. Этот код действительно выбросит это исключение, его удастся обработать и приложение продолжит работать. Теперь немного изменим код:
Если вместо WriteByte использовать метод Copy и скопировать байт 42 по адресу 1000, то, используя try-catch, AccessViolation поймать не получится. При этом на консоль будет выведено сообщение о том, что приложение завершено из-за необработанного AccessViolationException.
Получается, что у нас есть две строчки кода, при этом первая крашит все приложение с AccessViolation, а вторая выбрасывает обрабатываемое исключение того же типа. Чтобы понять, почему так происходит, мы посмотрим на то, как устроены эти методы изнутри.
Начнем с метода Copy.
Единственное, что делает метод Copy — вызывает метод CopyToNative, реализованный внутри .NET. Если наше приложение все-таки падает и исключение где-то происходит, то это может происходить только внутри CopyToNative. Отсюда можно сделать первое наблюдение: если .NET-код вызвал нативный код и внутри него произошел AccessViolation, то .NET-код это исключение по какой-то причине обработать не может.
Теперь поймём, почему удалось обработать AccessViolation при использовании метода WriteByte. Посмотрим на код этого метода:
Этот метод реализован полностью в managed-коде. Здесь используется C#-pointer, чтобы писать данные по нужному адресу, а также перехватывается NullReferenceException. Если перехватили NRE — выбрасывается AccessViolationException. Так нужно из-за спецификации. При этом все исключения, выброшенные конструкцией throw — обрабатываемые. Соответственно, если при выполнении кода внутри WriteByte произойдёт NullReferenceException — мы сможем поймать AccessViolation. Мог ли произойти NRE, в нашем случае, при обращении не к нулевому адресу, а к адресу 1000?
Перепишем код с использованием C# pointers напрямую, и увидим, что при обращении к ненулевому адресу действительно выбрасывается NullReferenceException:
Чтобы понять, почему так происходит, нам нужно вспомнить как устроена память процесса. В памяти процесса все адреса – виртуальные. Это значит, что у приложения есть большое адресное пространство и лишь некоторые страницы из него отображаются в реальной физической памяти. Но есть особенность: первые 64 КБ адресов никогда не отображаются в физическую память и не отдаются приложению. Рантайм .NET об этом знает и использует это. Если AccessViolation произошел в managed-коде, то рантайм проверяет, по какому именно адресу в памяти происходило обращение, и генерирует соответствующее исключение. Для адресов от 0 до 2^16 — NullReference, для всех остальных – AccessViolation.
Давайте разберемся, почему NullReference выбрасывается не только при обращении по нулевому адресу. Представьте, что вы обращаетесь к полю объекта ссылочного типа, и ссылка на этот объект нулевая:
В этой ситуации мы ожидаем получить NullReferenceException. Обращение к полю объекта происходит по смещению относительно адреса этого объекта. Получится, что мы обратимся к адресу, достаточно близкому к нулю (вспомним, что ссылка на наш исходный объект — нулевая). С таким поведением рантайма мы получим ожидаемое исключение без дополнительной проверки адреса самого объекта.
Но что же происходит, если мы обращаемся к полю объекта, а сам этот объект занимает больше, чем 64 КБ?
Можем ли мы в этом случае получить AccessViolation? Проведем эксперимент. Создадим очень большой объект и будем обращаться к его полям. Одно поле – в начале объекта, второе – в конце:
Оба метода выбросят NullReferenceException. Никакого AccessViolationException не произойдет.
Посмотрим на инструкции, которые будут сгенерированы для этих методов. Во втором случае JIT-компилятор добавил дополнительную инструкцию cmp, которая обращается к адресу самого объекта, тем самым вызывая AccessViolation с нулевым адресом, который будет преобразован рантаймом в NullReferenceException.
Стоит отметить, что для этого эксперимента недостаточно использовать в качестве большого объекта массив. Почему? Оставим этот вопрос читателю, пишите идеи в комментариях :)
Подведем краткий итог экспериментов с AccessViolation.
AccessViolationException ведёт себя по-разному в зависимости от того, где исключение произошло (в managed-коде или в нативном). Кроме того, если исключение произошло в managed-коде, то будет проверяться адрес объекта.
Возникает вопрос: можем ли мы обработать AccessViolationException, который произошел в нативном коде или в управляемом, но не преобразованный в NullReference и не выброшенный с использованием throw? Это иногда полезная возможность, особенно при работе с unsafe-кодом. Ответ на этот вопрос зависит от версии .NET.
В .NET 1.0 вообще не было никакого AccessViolationException. Все ссылки считались либо валидными, либо нулевыми. Ко времени .NET 2.0 стало понятно, что без прямой работы с памятью – никак, и AccessViolation появился, при этом был обрабатываемым. В 4.0 и выше он по-прежнему остался обрабатываемым, но обработать его уже не так просто. Для перехвата этого исключения теперь нужно пометить метод, в котором находится блок catch атрибутом HandleProcessCorruptedStateException. Видимо, разработчики так сделали, потому что посчитали, что AccessViolationException — это не то исключение, которое надо ловить в обычном приложении.
Кроме того, для обратной совместимости есть возможность использовать настройки рантайма:
В .NET Core AccessViolation не обрабатывается вообще.
В нашем продакшене была вот такая ситуация:
Приложение, собранное под .NET 4.7.1 использовало библиотеку с общим кодом, собранную под .NET 3.5. В этой библиотеке был хелпер для запуска периодического действия:
В этот хелпер мы передавали action из нашего приложения. Так получилось, что он падал с AccessViolation. В результате наше приложение постоянно логгировало AccessViolation, вместо того, чтобы упасть, т.к. код в библиотеке под 3.5 мог его поймать. Нужно обратить внимание, что перехватываемость зависит не от версии рантайма, на котором запущено приложение, а от TargetFramework, под который было собрано приложение, и его зависимости.
Подводим итог. Обработка AccessVilolation зависит от того, где он произошел — в нативном или управляемом коде — а также от TargetFramework и настроек рантайма.
Иногда в коде нужно остановить выполнение одного из потоков. Для этого можно использовать метод thread.Abort();
При вызове метода Abort в останавливаемом потоке выбрасывается исключение ThreadAbortException. Разберём его особенности. Например, такой код:
Абсолютно эквивалентен такому:
Если всё-таки нужно обработать ThreadAbort и выполнить еще какие-то действия в останавливаемом потоке, то можно использовать метод Thread.ResetAbort(); Он прекращает процесс остановки потока и исключение перестаёт прокидываться выше по стеку. Важно понимать, что метод thread.Abort() сам по себе ничего не гарантирует — код в останавливаемом потоке может препятствовать остановке.
Еще одна особенность thread.Abort() заключается в том, что он не сможет прервать код в том случае, если он находится в блоках catch и finally.
Внутри кода фреймворка часто можно встретить методы, у которых блок try пустой, а вся логика находится внутри finally. Это делается как раз с той целью, чтобы этот код не могла быть прерван ThreadAbortException.
Также вызов метода thread.Abort() дожидается выброса ThreadAbortException. Объединим эти два факта и получим, что метод thread.Abort() может заблокировать вызывающий поток.
В реальности с этим можно столкнуться при использовании конструкции using. Она разворачивается в try/finally, внутри finally вызывается метод Dispose. Он может быть сколь угодно сложным, содержать вызовы обработчики событий, использовать блокировки. И если thread.Abort был вызван во время выполнения Dispose — thread.Abort() будет его ждать. Так мы получаем блокировку почти на пустом месте.
В .NET Core метод thread.Abort() выбрасывает PlatformNotSupportedException. И я считаю, что это очень хорошо, потому что мотивирует пользоваться не thread.Abort(), а неинвазивными методами остановки выполнения кода, например с помощью CancellationToken.
Это исключение можно получить, если памяти на машине оказалось меньше, чем требуется. Или когда мы уперлись в ограничения 32-битного процесса. Но получить его можно, даже если на компьютере много свободной памяти, а процесс — 64-битный.
Код выше выкинет OutOfMemory. Все дело в том, что в дотнете по умолчанию не разрешены объекты более 2 ГБ. Это можно исправить настройкой gcAllowVeryLargeObjects в App.config. В этом случае массив размером 4 ГБ создастся.
А теперь попробуем создать массив ещё больше.
Теперь даже gcAllowVeryLargeObjects не поможет. Все из-за того, что в .NET есть ограничение на максимальный индекс в массиве. Это ограничение меньше, чем int.MaxValue.
Max array index:
В этом случае произойдёт OutOfMemoryException, хотя на самом деле мы уперлись в ограничение типа данных, а не в недостаток памяти.
Иногда OutOfMemory явно выбрасывается управляемым кодом внутри .NET фреймворка:
Это реализация метода string.Concat. Если длина строки-результата будет больше, чем int.MaxValue, то сразу выбрасывается OutOfMemoryException.
Перейдем к ситуации, когда OutOfMemory возникает по делу, когда реально заканчивается память.
Сначала мы ограничиваем память нашего процесса в 64 мБ. Далее внутри цикла выделяем новые массивы байтов, сохраняем их в какой-то лист, чтобы GC их не собирал, и пытаемся поймать OutOfMemory.
В этом случае может произойти все что угодно:
В этом случае программа получится абсолютно недетерминированной. Разберем все варианты:
В виртуальной памяти страницы могут быть не только отображены в физическую память, но и могут быть зарезервированными (reserved). Если страница зарезервирована, то приложение отметило, что собирается её использовать. Если страница уже отображена в реальную память или своп, то она называется «закоммиченной» (committed). Стек использует такую возможность разделять память на зарезервированную и закомиченную. Выглядит это примерно так:
Получается, что мы вызываем метод WriteLine, который занимает какое-то место на стеке. Так получается, что уже вся закоммиченная память закончилась, значит операционная система в этот момент должна взять еще одну зарезервированную страницу стека и отобразить ее в реальную физическую память, которая уже заполнена массивами байтов. Это и приводит к исключению StackOverflow.
Следующий код позволит на старте потока закоммитить всю память под стек сразу.
Кроме того, можно использовать настройку рантайма disableCommitThreadStack. Её нужно отключить, чтобы стек потока коммитился заранее. Стоит отметить, что поведение по умолчанию описанное в документации и наблюдаемое в реальности — различно.
Разберёмся подробнее со StackOverflowException. Посмотрим на два примера кода. В одном из них мы запускаем бесконечную рекурсию, которая приводит к переполнению стека, во втором мы просто выбрасываем это исключение с помощью throw.
Так как все исключения, выброшенные с помощью throw, обрабатываемы, то во втором случае мы поймаем исключение. А с первым случаем все интереснее. Обратимся к MSDN:
Здесь сказано, что мы не сможем перехватить StackOverflowException, так как сам перехват может потребовать дополнительного места в стеке, который уже закончился.
Чтобы как-нибудь защититься от этого исключения, можно поступить следующим образом. Во-первых, можно ограничить глубину рекурсии. Во-вторых, можно использовать методы класса RuntimeHelpers:
В документации по этому методу сказано, что он проверяет, что на стеке достаточно места для выполнения средней функции .NET. Но что же такое средняя функция? На самом деле в .NET Framework этот метод проверяет, что на стеке свободна хотя бы половина от его размера. В .NET Core он проверяет чтобы было свободно 64 КБ.
Также в .NET Core появился аналог: RuntimeHelpers.TryEnsureSufficientExecutionStack() возвращающий bool, а не бросающий исключение.
В C# 7.2 появилась возможность использовать Span и stackallock вместе без использования unsafe-кода. Возможно, благодаря этому stackalloc станет использоваться в коде чаще и будет полезно иметь способ защититься от StackOverflow при его использовании, выбирая, где именно выделить память. В качестве такого способа предложены метод, проверяющий возможность аллокации на стеке и конструкция trystackalloc.
Вернёмся к документации по StackOverflow на MSDN
Если есть «normal» application, которые падают при StackOverflow, значит есть и не-«normal» application, которые не падают? Для того, чтобы ответить на этот вопрос придется спуститься на уровень ниже с уровня управляемого приложения на уровень CLR.
Приложение, которое хостит CLR может переопределить поведение при переполнении стека так, чтобы вместо завершения всего процесса выгружался Application Domain, в потоке котором это переполнение произошло. Таким образом, мы можем превратить StackOverflowException в AppDomainUnloadedException.
При запуске managed-приложение, автоматически запускается рантайм .NET. Но можно пойти по другому пути. Например, написать unmanaged-приложение (на С++ или другом языке), которое будет использовать специальное API для того, чтобы поднять CLR и запустить наше приложение. Приложение, которое запускает внутри себя CLR будем называть CLR-host. Написав его, мы можем сконфигурировать многие вещи в рантайме. Например, подменить менеджер памяти и менеджер потоков. Мы в продакшене используем CLR-host для того, чтобы избежать попадания страниц памяти в своп.
Следующий код конфигурирует CLR-host так, чтобы при StackOverflow выгружался AppDomain (C++):
Хороший ли это способ спастись от StackOverflow? Наверно, не очень. Во-первых, нам пришлось написать код на C++, чего делать не хотелось бы. Во-вторых, мы должны поменять свой C#-код так, чтобы та функция, которая может выбросить StackOverflowException выполнялась в отдельном AppDomain'е и в отдельном потоке. Наш код сразу превратится вот в такую лапшу:
Ради того, чтобы вызвать метод InfiniteRecursion, мы написали кучу строк. В-третьих, мы начали использовать AppDomain. А это почти гарантирует кучу новых проблем. В том числе, с исключениями. Рассмотри пример:
Так как наше исключение не помечено как сериализуемое, то наш код упадет с исключением SerializationException. И чтобы исправить эту проблему, нам недостаточно пометить наше исключение атрибутом Serializable, еще потребуется реализовать дополнительный конструктор для сериализации.
Это все получается не очень красиво, поэтому идём дальше — на уровень операционной системы и хаков, которые не стоит использовать в продакшене.
Обратите внимание, что если между Managed и CLR летали Managed-exceptions, то между CLR и Windows летают SEH-exceptions.
SEH – Structured Exception Handling
SEH — это механизм обработки исключений в Windows, он позволяет одинаково единообразно обрабатывать любые исключения, которые пришли, например, с уровня процессора, или были связаны с логикой самого приложения.
Рантайм .NET знает о SEH-исключениях и умеет их конвертировать в managed-исключения:
Взаимодействовать с SEH мы можем через WinApi.
На самом деле, конструкция throw тоже работает через SEH.
Здесь стоит отметить, что код у CLR-exception всегда один и тот же, поэтому какой бы тип исключения мы не выбрасывали, оно всегда будет обрабатываемым.
VEH — это векторная обработка исключений, расширение SEH, но работающее на уровне процесса, а не на уровне одного потока. Если SEH по семантике схож с try-catch, то VEH по семантике схож с обработчиком прерываний. Мы просто задаем свой обработчик и можем получать информацию обо всех исключениях, которые происходят в нашем процессе. Интересная возможность VEH — это то, что он позволяет изменить SEH-исключение до того, как оно попадет в обработчик.
Мы можем поставить между операционной системой и рантаймом собственный векторный обработчик, который будет обрабатывать SEH-исключения и при встрече с EXCEPTION_STACK_OVERFLOW изменять его так, чтобы рантайм .NET не крэшил процесс.
С VEH можно взаимодействовать через WinApi:
В Context находится информация о состоянии всех регистров процессора в момент исключения. Нас же будет интересовать EXCEPTION_RECORD и поле ExceptionCode в нем. Мы можем подменить его на собственный код исключения, о котором CLR вообще ничего не знает. Векторный обработчик выглядит так:
Теперь сделаем обёртку, устанавливающую векторный обработчик в виде метода HandleSO, который принимает в себя делегат, который потенциально может упасть со StackOverflowException (для наглядности в коде нет обработки ошибок функций WinApi и удаления векторного обработчика).
Внутри него также используется метод SetThreadStackGuarantee. Этот метод резервирует место на стеке под обработку StackOverflow.
Таким образом мы можем пережить вызов метода с бесконечной рекурсией. Наш поток продолжит работать как ни в чем не бывало, как будто никакого переполнения не происходило.
Но, что произойдет, если вызвать HandleSO дважды в одном потоке?
А произойдёт AccessViolationException. Вернемся к устройству стека.
Операционная система умеет детектировать переполнение стека. В самом верху стека лежит специальная страница, помеченная флагом Guard page. При первом обращении к этой странице произойдет другое исключение – STATUS_GUARD_PAGE_VIOLATION, а флаг Guard page со страницы снимается. Если просто перехватить это переполнение, то этой страницы на стеке больше не будет – при следующем переполнении операционная система не сможет этого понять и stack-pointer выйдет за границы памяти, выделенной под стек. Как итог — произойдет AccessViolationException. Значит нужно восстанавливать флаги страниц после обработки StackOverflow – cамый простой способ это сделать – использовать метод _resetstkoflw из библиотеки рантайма C (msvcrt.dll).
Аналогичным способом можно перехватить AccessViolationException в .NET Core под Windows, который приводит к падению процесса. При этом понадобиться учесть порядок вызова векторных обработчиков и установить свой обработчик в начало цепочки, так как .NET Core также использует VEH при обработке AccessViolation. За порядок вызова обработчиков отвечает первый параметр функции AddVectoredExceptionHandler:
Изучив практические вопросы, подведем общие итоги:
→ Репозиторий с примерами из доклада
→ 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.
Под катом расшифровка доклада Евгения (epeshk) Пешкова с нашей конференции DotNext 2018 Piter, где он рассказал про эти и другие особенности исключений.
Привет! Меня зовут Евгений. Я работаю в компании СКБ Контур и занимаюсь разработкой системы хостинга и деплоя приложений под Windows. Суть в том, что у нас есть много продуктовых команд, которые пишут собственные сервисы и хостят их у нас. Мы предоставляем им легкое и простое решение разнообразных инфраструктурных задач. Например, проследить за потреблением системных ресурсов или докинуть реплик к сервису.
Иногда получается, что приложения, которые хостятся в нашей системе, разваливаются. Мы видели очень много способов, как приложение может упасть в рантайме. Один из таких способов — это выкинуть какой-нибудь неожиданный и фееричный exception.
Сегодня я расскажу об особенностях исключений в .NET. С некоторыми из этих особенностей мы столкнулись в продакшене, а с некоторыми — в ходе экспериментов.
План
- Поведение исключений в .NET
- Обработка исключений в Windows и хаки
Всё рассмотренное далее верно для Windows. Все примеры тестировались на последней версии полного фреймворка .NET 4.7.1. Также будет немного упоминаний .NET Core.
Access Violation
Это исключение случается при некорректных операциях с памятью. Например, если приложение пробует обратиться к области памяти, к которой у него нет доступа. Исключение низкоуровневое, и обычно, если оно случилось, предстоит очень долгая отладка.
Попробуем получить это исключение, используя C#. Для этого запишем байт 42 по адресу 1000 (будем считать, что 1000 — это достаточно случайный адрес и у нашего приложения, скорее всего, доступа к нему нет).
try {
Marshal.WriteByte((IntPtr) 1000, 42);
}
catch (AccessViolationException) {
...
}
WriteByte делает как раз то, что нам нужно: записывает байт по заданному адресу. Мы ожидаем, что этот вызов выбросит AccessViolationException. Этот код действительно выбросит это исключение, его удастся обработать и приложение продолжит работать. Теперь немного изменим код:
try {
var bytes = new byte[] {42};
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? Проведем эксперимент. Создадим очень большой объект и будем обращаться к его полям. Одно поле – в начале объекта, второе – в конце:
Оба метода выбросят NullReferenceException. Никакого AccessViolationException не произойдет.
Посмотрим на инструкции, которые будут сгенерированы для этих методов. Во втором случае JIT-компилятор добавил дополнительную инструкцию cmp, которая обращается к адресу самого объекта, тем самым вызывая AccessViolation с нулевым адресом, который будет преобразован рантаймом в NullReferenceException.
Стоит отметить, что для этого эксперимента недостаточно использовать в качестве большого объекта массив. Почему? Оставим этот вопрос читателю, пишите идеи в комментариях :)
Подведем краткий итог экспериментов с AccessViolation.
AccessViolationException ведёт себя по-разному в зависимости от того, где исключение произошло (в managed-коде или в нативном). Кроме того, если исключение произошло в managed-коде, то будет проверяться адрес объекта.
Возникает вопрос: можем ли мы обработать AccessViolationException, который произошел в нативном коде или в управляемом, но не преобразованный в NullReference и не выброшенный с использованием throw? Это иногда полезная возможность, особенно при работе с unsafe-кодом. Ответ на этот вопрос зависит от версии .NET.
В .NET 1.0 вообще не было никакого AccessViolationException. Все ссылки считались либо валидными, либо нулевыми. Ко времени .NET 2.0 стало понятно, что без прямой работы с памятью – никак, и AccessViolation появился, при этом был обрабатываемым. В 4.0 и выше он по-прежнему остался обрабатываемым, но обработать его уже не так просто. Для перехвата этого исключения теперь нужно пометить метод, в котором находится блок catch атрибутом HandleProcessCorruptedStateException. Видимо, разработчики так сделали, потому что посчитали, что AccessViolationException — это не то исключение, которое надо ловить в обычном приложении.
Кроме того, для обратной совместимости есть возможность использовать настройки рантайма:
- legacyNullReferenceExceptionPolicy возвращает поведение .NET 1.0 – все AV превращаются в NRE
- legacyCorruptedStateExceptionsPolicy возвращает поведение .NET 2.0 – все AV перехватываемы
В .NET Core AccessViolation не обрабатывается вообще.
В нашем продакшене была вот такая ситуация:
Приложение, собранное под .NET 4.7.1 использовало библиотеку с общим кодом, собранную под .NET 3.5. В этой библиотеке был хелпер для запуска периодического действия:
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() сам по себе ничего не гарантирует — код в останавливаемом потоке может препятствовать остановке.
Еще одна особенность thread.Abort() заключается в том, что он не сможет прервать код в том случае, если он находится в блоках catch и finally.
Внутри кода фреймворка часто можно встретить методы, у которых блок try пустой, а вся логика находится внутри finally. Это делается как раз с той целью, чтобы этот код не могла быть прерван ThreadAbortException.
Также вызов метода thread.Abort() дожидается выброса ThreadAbortException. Объединим эти два факта и получим, что метод thread.Abort() может заблокировать вызывающий поток.
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.Abort() выбрасывает PlatformNotSupportedException. И я считаю, что это очень хорошо, потому что мотивирует пользоваться не thread.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, хотя на самом деле мы уперлись в ограничение типа данных, а не в недостаток памяти.
Иногда OutOfMemory явно выбрасывается управляемым кодом внутри .NET фреймворка:
Это реализация метода string.Concat. Если длина строки-результата будет больше, чем int.MaxValue, то сразу выбрасывается OutOfMemoryException.
Перейдем к ситуации, когда OutOfMemory возникает по делу, когда реально заканчивается память.
LimitMemory(64.Mb());
try {
while (true)
list.Add(new byte[size]);
} catch (OutOfMemoryException e) {
Console.WriteLine(e);
}
Сначала мы ограничиваем память нашего процесса в 64 мБ. Далее внутри цикла выделяем новые массивы байтов, сохраняем их в какой-то лист, чтобы GC их не собирал, и пытаемся поймать OutOfMemory.
В этом случае может произойти все что угодно:
- Исключение обработается
- Процесс упадёт
- Зайдём в catch, но исключение вылетит снова
- Зайдём в catch, но вылетит StackOverflow
В этом случае программа получится абсолютно недетерминированной. Разберем все варианты:
- Исключение может обработаться. Внутри .NET ничто не мешает обрабатывать OutOfMemoryException.
- Процесс может упасть. Не нужно забывать, что у нас managed-приложение. Это означает, что внутри него выполняется не только наш код, но и код рантайма. Например, GC. Таким образом, может случиться ситуация, когда рантайм захочет себе выделить память, но не сможет это сделать, тогда мы не сможем перехватить исключение.
- Зайдем в catch, но исключение вылетит снова. Внутри catch мы тоже выполняем работу, при которой нам понадобится память (печатаем исключение на консоль), а это может вызвать новое исключение.
- Зайдем в 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:
RuntimeHelpers.EnsureSufficientExecutionStack();
- «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, а не бросающий исключение.
В C# 7.2 появилась возможность использовать Span и stackallock вместе без использования unsafe-кода. Возможно, благодаря этому stackalloc станет использоваться в коде чаще и будет полезно иметь способ защититься от StackOverflow при его использовании, выбирая, где именно выделить память. В качестве такого способа предложены метод, проверяющий возможность аллокации на стеке и конструкция trystackalloc.
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.
При запуске managed-приложение, автоматически запускается рантайм .NET. Но можно пойти по другому пути. Например, написать 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 всегда один и тот же, поэтому какой бы тип исключения мы не выбрасывали, оно всегда будет обрабатываемым.
VEH — это векторная обработка исключений, расширение SEH, но работающее на уровне процесса, а не на уровне одного потока. Если SEH по семантике схож с try-catch, то VEH по семантике схож с обработчиком прерываний. Мы просто задаем свой обработчик и можем получать информацию обо всех исключениях, которые происходят в нашем процессе. Интересная возможность VEH — это то, что он позволяет изменить SEH-исключение до того, как оно попадет в обработчик.
Мы можем поставить между операционной системой и рантаймом собственный векторный обработчик, который будет обрабатывать SEH-исключения и при встрече с EXCEPTION_STACK_OVERFLOW изменять его так, чтобы рантайм .NET не крэшил процесс.
С 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. При первом обращении к этой странице произойдет другое исключение – STATUS_GUARD_PAGE_VIOLATION, а флаг Guard page со страницы снимается. Если просто перехватить это переполнение, то этой страницы на стеке больше не будет – при следующем переполнении операционная система не сможет этого понять и stack-pointer выйдет за границы памяти, выделенной под стек. Как итог — произойдет AccessViolationException. Значит нужно восстанавливать флаги страниц после обработки StackOverflow – cамый простой способ это сделать – использовать метод _resetstkoflw из библиотеки рантайма C (msvcrt.dll).
[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 с докладом «Системные метрики: собираем подводные камни». А еще в Москву приедут Джеффри Рихтер, Грег Янг, Павел Йосифович и другие не менее интересные спикеры. Темы докладов можно посмотреть здесь, а купить билеты — здесь. Присоединяйтесь!