[DotNetBook] Время занимательных историй: исключительно исключительные ситуации

в 11:04, , рубрики: .net, C#, clrbook, DotNetBook, exception

[DotNetBook] Время занимательных историй: исключительно исключительные ситуации - 1

Существует ряд исключительных ситуаций, которые скажем так… Несколько более исключительны чем другие. Причем если попытаться их классифицировать, то как и было сказано в самом начале главы, есть исключения родом из самого .NET приложения, а есть исключения родом из unsafe мира. Их в свою очередь можно разделить на две подкатегории: иcключительные ситуации ядра CLR (которое по своей сути — unsafe) и любой unsafe код внешних библиотек.

Давайте поговорим про эти особые исключительные ситуации.

ThreadAbortException

Вообще, это может показаться не очевидным, но существует четыре типа Thread Abort.

Данная статья — третья из четырех в цикле статей про исключения. Полный цикл:
Архитектура системы типов
Cобытия об исключительных ситуациях
Виды исключительных ситуаций (эта статья)
— Сериализация и блоки обработки

  • Грубый вариант ThreadAbort, который, отрабатывая не может быть никак остановлен и который не запускает обработчиков исключительных ситуаций вообще включая секции finally
  • Вызов метода Thread.Abort() на текущем потоке
  • Асинхронное исключение ThreadAbortException, вызванное из другого потока
  • Если во время выгрузки AppDomain существуют потоки, в рамках которых запущены методы, скомпилированные для этого домена, будет произведен ThreadAbort тех потоков, в которых эти методы запущены

Стоит заметить что ThreadAbortException довольно часто используется в большом .NET Framework, однако его не существует на CoreCLR или же под Windows 8 "Modern app profile". Попробуем узнать, почему.

Итак, если мы имеем дело с не принципиальным типом обрыва потока, когда мы еще можем с ним что-то сделать (т.е. второй, третий и четвертый вариант), виртуальная машина при возникновении такого исключения начинает идти по всем обработчикам исключительных ситуаций и искать как обычно те, тип исключения которых является тем, что было выброшено либо более базовым. В нашем случае это три типа: ThreadAbortException, Exception и object (помним что Exception — это по своей сути — хранилище данных и тип исключения может быть любым. Даже int). Отрабатывая все подходящие catch блоки виртуальная машина пробрасыват ThreadAbortException дальше по цепочке обработки исключений попутно входя во все finally блоки. В целом, ситуации в двух примерах, описанных ниже абсолютно одинаковые:

var thread = new Thread(() =>
{
    try {
        // ...
    } catch (Exception ex)
    {
        // ...
    }
});
thread.Start();
//...
thread.Abort();

var thread = new Thread(() =>
{
    try {
        // ...
    } catch (Exception ex)
    {
        // ...
        if(ex is ThreadAbortException)
        {
            throw;
        }
    }
});
thread.Start();
//...
thread.Abort();

Конечно же, всегда возникнет ситуация, когда возникающий ThreadAbort может быть нами вполне ожидаем. Тогда может возникнуть понятное желание его все-таки обработать. Как раз для таких случаев был разработан и открыт метод Thread.ResetAbort(), который делает именно то, что нам нужно: останавливает сквозной проброс исключения через всю цепочку обработчиков, делая его обработанным:

void Main()
{
    var barrier = new Barrier(2);

    var thread = new Thread(() =>
    {
        try {
            barrier.SignalAndWait();  // Breakpoint #1
            Thread.Sleep(TimeSpan.FromSeconds(30));
        }
        catch (ThreadAbortException exception)
        {
            "Resetting abort".Dump();
            Thread.ResetAbort();
        }

        "Catched successfully".Dump();
        barrier.SignalAndWait();     // Breakpoint #2
    });

    thread.Start();
    barrier.SignalAndWait();         // Breakpoint #1

    thread.Abort();
    barrier.SignalAndWait();         // Breakpoint #2
}

Output:
Resetting abort
Catched successfully

Однако реально ли стоит этим пользоваться? И стоит ли обижаться на разработчиков CoreCLR что там этот код попросту выпилен? Представьте что вы — пользователь кода, который по вашему мнению "повис" и у вас возникло непреодолимое желание вызвать для него ThreadAbortException. Когда вы хотите оборвать жизнь потока все чего вы хотите — чтобы он действительно завершил свою работу. Мало того, редкий алгоритм просто обрывает поток и бросает его, уходя к своим делам. Обычно внешний алгоритм решает дождаться корректного завершения операций. Или же наоборот: может решить что поток более уже ничего делать не будет, декрементирует некие внутренние счетчики и более не будет завязываться на то что есть какая-то многопоточная обработка какого-либо кода. Тут в общем не скажешь, что хуже. Я даже так вам скажу: отработав много лет программистом я до сих пор не могу вам дать прекрасный способ его вызова и обработки. Сами посудите: вы бросаете ThreadAbort не прямо сейчас а в любом случае спустя некоторое время после осознания безвыходности ситуации. Т.е. вы можете как попасть по обработчику ThreadAbortException так и промахнуться мимо него: "зависший код" мог оказаться вовсе не зависшим, а попросту долго работающим. И как раз в тот момент, когда вы хотели оборвать его жизнь, он мог вырваться из ожидания и корректно продолжить работу. Т.е. без лишней лирики выйти из блока try-catch(ThreadAbortException) { Thread.ResetAbort(); }. Что мы получим? Оборванный поток, который ни в чем не виноват. Шла уборщица, выдернула провод, сеть пропала.

Метод ожидал таймаута, уборщица вернула провод, все заработало, но ваш контролирующий код не дождался и убил поток. Хорошо? Нет. Как-то можно защититься? Нет. Но вернемся к навящивой идее легализации Thread.Abort(): мы кинули кувалдой в поток и ожидаем что он с вероятностью 100% оборвется, но этого может не произойти. Во-первых становится не понятно как его оборвать в таком случае. Ведь тут все может быть намного сложнее: в подвисшем потоке может быть такая логика, которая перехватывает ThreadAbortException, останавливает его при помощи ResetAbort, однако продолжает висеть из-за сломанной логики. Что тогда? Делать безусловный thread.Interrupt()? Попахивает попыткой обойти ошибку в логике программы грубыми методами. Плюс, я вам гарантирую что у вас поплывут утечки: thread.Interrupt() не будет заниматься вызовом catch и finally, а это значит что при всем опыте и сноровке очистить ресурсы вы не сможете: ваш поток просто исчезнет, а находясь в соседнем потоке вы можете не знать ссылок на все ресурсы, которые были заняты умирающим потоком. Также прошу заметить что в случае промаха ThreadAbortException мимо catch(ThreadAbortException) { Thread.ResetAbort(); } у вас точно также потекут ресурсы.

После того что вы прочитали чуть выше я надеюсь, вы остались в некотором состоянии запутанности и желания перечитать абзац. И это будет совершенно правильная мысль: это будет доказательством того что пользоваться Thread.Abort() попросту нельзя. Как и нельзя пользоваться thread.Interrupt();. Оба метода приводят к неконтролируемому поведению вашего приложения. По своей сути они нарушают принцип целостности: основной принцип .NET Framework.

Однако, чтобы понять для каких целей этот метод введен в эксплуатацию достаточно посмотреть исходные коды .NET Framework и найти места использования Thread.ResetAbort(). Ведь именно его наличие по сути легализует thread.Abort().

Класс ISAPIRuntime ISAPIRuntime.cs

try {

    // ...

}
catch(Exception e) {
    try {
        WebBaseEvent.RaiseRuntimeError(e, this);
    } catch {}

    // Have we called HSE_REQ_DONE_WITH_SESSION?  If so, don't re-throw.
    if (wr != null && wr.Ecb == IntPtr.Zero) {
        if (pHttpCompletion != IntPtr.Zero) {
            UnsafeNativeMethods.SetDoneWithSessionCalled(pHttpCompletion);
        }
        // if this is a thread abort exception, cancel the abort
        if (e is ThreadAbortException) {
            Thread.ResetAbort();
        }
        // IMPORTANT: if this thread is being aborted because of an AppDomain.Unload,
        // the CLR will still throw an AppDomainUnloadedException. The native caller
        // must special case COR_E_APPDOMAINUNLOADED(0x80131014) and not
        // call HSE_REQ_DONE_WITH_SESSION more than once.
        return 0;
    }

    // re-throw if we have not called HSE_REQ_DONE_WITH_SESSION
    throw;
}

В данном примере происходит вызов некоторого внешнего кода и если тот был завершен не корректно: с ThreadAbortException, то при определенных условиях помечаем поток как более не прерываемый. Т.е. по сути обрабатываем ThreadAbort. Почему в данном конкретно случае мы обрываем Thread.Abort? Потому что в данном случае мы имеем дело с серверным кодом, а он в свою очередь вне зависимости от наших ошибок вернуть корректные коды ошибок вызывающей стороне. Обрыв потока привел бы к тому что сервер не смог бы вернуть нужный код ошибки пользователю, а это совершенно не правильно. Также оставлен комментарий о Thread.Abort() во время AppDomin.Unload(), что является экстримальной ситуацией для ThreadAbort поскольку такой процесс не остановить и даже если вы сделаете Thread.ResetAbort. Это хоть и остановит сам Abortion, но не остановит выгрузку потока с доменом, в котором он находится: поток же не может исполнять инструкции кода, загруженного в домен, который отгружен.

Класс HttpContext HttpContext.cs

internal void InvokeCancellableCallback(WaitCallback callback, Object state) {
    // ...

    try {
        BeginCancellablePeriod();  // request can be cancelled from this point
        try {
            callback(state);
        }
        finally {
            EndCancellablePeriod();  // request can be cancelled until this point
        }
        WaitForExceptionIfCancelled();  // wait outside of finally
    }
    catch (ThreadAbortException e) {
        if (e.ExceptionState != null &&
            e.ExceptionState is HttpApplication.CancelModuleException &&
            ((HttpApplication.CancelModuleException)e.ExceptionState).Timeout) {

            Thread.ResetAbort();
            PerfCounters.IncrementCounter(AppPerfCounter.REQUESTS_TIMED_OUT);

            throw new HttpException(SR.GetString(SR.Request_timed_out),
                                null, WebEventCodes.RuntimeErrorRequestAbort);
        }
    }
}

Здесь приведен прекрасный пример перехода от неуправляемого асинхронного исключения ThreadAbortException к управляемому HttpException с логгированием ситуации в журнал счетчиков производительности.

Класс HttpApplication HttpApplication.cs

 internal Exception ExecuteStep(IExecutionStep step, ref bool completedSynchronously) 
 {
    Exception error = null;

    try {
        try {

        // ...

        }
        catch (Exception e) {
            error = e;

            // ...

            // This might force ThreadAbortException to be thrown
            // automatically, because we consumed an exception that was
            // hiding ThreadAbortException behind it

            if (e is ThreadAbortException &&
                ((Thread.CurrentThread.ThreadState & ThreadState.AbortRequested) == 0))  {
                // Response.End from a COM+ component that re-throws ThreadAbortException
                // It is not a real ThreadAbort
                // VSWhidbey 178556
                error = null;
                _stepManager.CompleteRequest();
            }
        }
        catch {
            // ignore non-Exception objects that could be thrown
        }
    }
    catch (ThreadAbortException e) {
        // ThreadAbortException could be masked as another one
        // the try-catch above consumes all exceptions, only
        // ThreadAbortException can filter up here because it gets
        // auto rethrown if no other exception is thrown on catch
        if (e.ExceptionState != null && e.ExceptionState is CancelModuleException) {
            // one of ours (Response.End or timeout) -- cancel abort

            // ...

            Thread.ResetAbort();
        }
    }
}

Здесь описывается очень интересный случай: когда мы ждем не настоящий ThreadAbort (мне вот в некотором смысле жалко команду CLR и .NET Framework. Сколько не стандартных ситуаций им приходится обрабатывать, подумать страшно). Обработка ситуации идет в два этапа: внутренним обработчиком мы ловим ThreadAbortException но при этом проверяем наш поток на флаг реальной прерываемости. Если поток не помечен как прерывающийся, то на самом деле это не настоящий ThreadAbortException. Такие ситуации мы должны обработать соответствующим образом: спокойно поймать исключение и обработать его. Если же мы получаем настоящий ThreadAbort, то он уйдет во внешний catch поскольку ThreadAbortException должен войти во все подходящие обработчики. Если он удовлетворяет необходимым условиям, он также будет обработан путем очистки флага ThreadState.AbortRequested методом Thread.ResetAbort().

Если говорить про примеры самого вызова Thread.Abort(), то все примеры кода в .NET Framework написаны так что могут быть переписаны без его использования. Для наглядности приведу только один:

Класс QueuePathDialog QueuePathDialog.cs

protected override void OnHandleCreated(EventArgs e)
{
    if (!populateThreadRan)
    {
        populateThreadRan = true;
        populateThread = new Thread(new ThreadStart(this.PopulateThread));
        populateThread.Start();
    }

    base.OnHandleCreated(e);
}

protected override void OnFormClosing(FormClosingEventArgs e)
{
    this.closed = true;

    if (populateThread != null)
    {
        populateThread.Abort();
    }

    base.OnFormClosing(e);
}

private void PopulateThread()
{
    try
    {
        IEnumerator messageQueues = MessageQueue.GetMessageQueueEnumerator();
        bool locate = true;
        while (locate)
        {
            // ...
            this.BeginInvoke(new FinishPopulateDelegate(this.OnPopulateTreeview), new object[] { queues });
        }
    }
    catch
    {
        if (!this.closed)
            this.BeginInvoke(new ShowErrorDelegate(this.OnShowError), null);
    }

    if (!this.closed)
        this.BeginInvoke(new SelectQueueDelegate(this.OnSelectQueue), new object[] { this.selectedQueue, 0 });
}

TheradAbortException во время AppDomain.Unload

Попробуем отгрузить AppDomain во время исполнения кода, который в него загружен. Для этого искусственно создадим не вполне нормальную ситуацию, но достаточно интересную с точки зрения исполнения кода. В данном примере у нас два потока: один создан для того чтобы получить в нем ThreadAbortException, а другой — основной. В основном мы создаем новый домен, в котором запускаем новый поток. Задача этого потока — уйти в основной домен. Чтобы методы дочернего домена осталиь бы только в Stack Trace. После этого основной домен отгружает дочерний:

class Program : MarshalByRefObject
{
    static void Main()
    {
        try
        {
            var domain = ApplicationLogger.Go(new Program());
            Thread.Sleep(300);
            AppDomain.Unload(domain);

        } catch (ThreadAbortException exception)
        {
            Console.WriteLine("Main AppDomain aborted too, {0}", exception.Message);
        }
    }

    public void InsideMainAppDomain()
    {
        try
        {
            Console.WriteLine($"InsideMainAppDomain() called inside {AppDomain.CurrentDomain.FriendlyName} domain");

            // AppDomain.Unload will be called while this Sleep
            Thread.Sleep(-1);
        }
        catch (ThreadAbortException exception)
        {
            Console.WriteLine("Subdomain aborted, {0}", exception.Message);

            // This sleep to allow user to see console contents
            Thread.Sleep(-1);
        }
    }

    public class ApplicationLogger : MarshalByRefObject
    {
        private void StartThread(Program pro)
        {
            var thread = new Thread(() =>
            {
                pro.InsideMainAppDomain();
            });
            thread.Start();
        }

        public static AppDomain Go(Program pro)
        {
            var dom = AppDomain.CreateDomain("ApplicationLogger", null, new AppDomainSetup
            {
                ApplicationBase = AppDomain.CurrentDomain.BaseDirectory,
            });

            var proxy = (ApplicationLogger)dom.CreateInstanceAndUnwrap(typeof(ApplicationLogger).Assembly.FullName, typeof(ApplicationLogger).FullName);
            proxy.StartThread(pro);

            return dom;
        }
    }

}

Происходит крайне интересная вещь. Код выгрузки домена помимо самой выгрузки ищет вызванные в этом домене методы, которые еще не завершили работу в том числе в глубине стека вызова методов и вызывает ThreadAbortException в этих потоках. Это важно, хоть и не очевидно: если домен отгружен, нельзя осуществить возврат в метод, из которого был вызван метод основного домена, но который находится в отгружаемом. Т.е. другими словами AppDomain.Unload может выбрасывать потоки, исполняющие в данный момент код из других доменов. Прервать Thread.Abort в таком случае не получится: исполнять код выгруженного домена вы не сможете, а значит Thread.Abort завершит свое дело, даже если вы вызовите Thread.ResetAbort.

Выводы по ThreadAbortException

  • Это — асинхронное исключение, а значит оно может возникнуть в любой точке вашего кода (но, стоит отметить, что для этого надо постараться);
  • Обычно код обрабатывает только те ошибки, которые ждет: нет доступа к файлу, ошибка парсинга строки и прочие подобные. Наличие асинхронного (в плане возникновения в любом месте кода) исключения создает ситуацию, когда try-catch могут быть не обработаны: вы же не можете быть готовым к ThreadAbort в любом месте приложения. И получается, что это исключение в любом случае породит утечки;
  • Обрыв потока может также происходить из-за выгрузки какого-либо домена. Если в Stack Trace потока существуют вызовы методов отгружаемого домена, поток получит ThreadAbortException без возможности ResetAbort;
  • В общем случае не должно возникать ситуаций, когда вам нужно вызвать Thread.Abort(), поскольку результат практически всегда — не предсказуем.
  • CoreCLR более не содержит ручной вызов Thread.Abort(): он просто удален из класса. Но это не означает что его нет возможности получить.

ExecutionEngineException

В комментарии к этому исключению стоит атрибут Obsolete с комментарием:

This type previously indicated an unspecified fatal error in the runtime. The runtime no longer raises this exception so this type is obsolete

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

void Main()
{
    var counter = 0;

    AppDomain.CurrentDomain.FirstChanceException += (_, args) => {
        Console.WriteLine(args.Exception.Message);
        if(++counter == 1) {
            throw new ArgumentOutOfRangeException();
        }
    };

    throw new Exception("Hello!");
}

Результатом данного кода будет ExecutionEngineException, хотя ожидаемое мной поведение Unhandled Exception ArgumentOutOfRangeException из инструкции throw new Exception("Hello!"). Возможно это показалось страным разработчикам ядра и они посчитали что корректнее выбросить ExecutionEngineException.

Второй вполне простой путь получить ExecutionEngineException — это не корректно настроить маршаллинг в мир unsafe. Если вы напутаете с размерами типов, передадите больше чем надо, чем испортите, например, стек потока, ждите ExecutionEngineException. И это будет логичный, правильный результат: ведь в данной ситуации CLR вошла в состояние, которое она нашла не консистентным. Не понятным, как его восстанавливать. И как результат, ExecutionEngineException.

Отдельно стоит поговорить про диагностику ExecutionEngineException. Каковы могут быть причины его возникновения? Если исключение вдруг возникло в вашем коде, необходимо ответить на несколько вопросов:

  • Используются ли в вашем приложении unsafe библиотеки? Вами или же может сторонними библиотеками. Попробуйте для начала выяснить, где конкретно приложение получает данную ошибку. Если код уходит в unsafe мир и получает ExecutionEngineException там, тогда необходимо тщательно проверить сходимость сигнатур методов: в нашем коде и в импортируемом. Помните, что если импортируются модули написанные на Delphi и прочих вариациях языка Pascal, то аргументы должны идти в обратном порядке (настройка производится в DllImport: CallingConvention.StdCall);
  • Подписаны ли вы на FirstChanceException? Возможно его код вызвал исключение. В таком случае просто оберните обработчик в try-catch(Exception) и обязательно сохраните в журнал ошибок происходящее;
  • Может быть ваше приложение частично собрано под одну платформу, а частично — под другую. Попробуйте очистить кэш nuget пакетов, полностью пересобрать приложение с нуля: с очищенными вручную папками obj/bin
  • Проблема иногда бывает в самом фреймворке. Например, в ранних версиях .NET Framework 4.0. В этом случае стоит протестировать отдельный участок кода, который вызывает ошибку — на более новой версии фреймворка;

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

Corrupted State Exceptions

После становления платформы и ее популяризации, после того как огромная масса програмистов начала мигрировать с C/C++ и MFC (Microsoft Foundation Classes) на более приятные мозгу среды разработки: сюда помимо .NET Framework можно отнести в первую очередь Qt, Java и С++ Builder, нам был дан вектор на виртуализацию исполнения кода приложения от окружающей среды. Со временем, удачно сверстанный архитектурно, .NET Framework начал занимать свою нишу. С годами, окрепнув и набирая массу, можно начинать рассуждать не с точки зрения приспособленца, а с точки зрения силы. И если раньше мы в большинстве случаев были вынуждены работать с феерическим пластом компонентов, написанных на COM/ATL/ActiveX (вы помните перетаскивание на формочки иконок COM/ActiveX компонент в Borland C++ Builder?), то теперь всем нам стало сильно легче жить. Ведь теперь соответствующие технологии достаточно редки чтобы волноваться и появилась возможность сделать их несколько неудобными чтобы от них начали отказываться полностью, переходя на современный и ладный .NET Framework. Старые технологии, которые не то что существовали 5-10 лет назад, а на самом деле существуют и здравствуют и ныне, кажется нам чем-то архаичным, забытым, "кривым" и допотопным. А потому можно сделать еще один шаг к закрытию песочницы: сделать её ещё более непробиваемой, сделать её ещё более managed.

И один из этих шагов — введение понятия Corrupted State Exceptions, что по сути ставит ряд исключительных ситуаций вне закона. Давайте разберемся, что это за исключительные ситуации, а саму историю еще раз проследим на одной из них — AccessViolationException:

Файл util.cpp util.cpp

BOOL IsProcessCorruptedStateException(DWORD dwExceptionCode, BOOL fCheckForSO /*=TRUE*/)
{
    // ...

    // If we have been asked not to include SO in the CSE check
    // and the code represent SO, then exit now.
    if ((fCheckForSO == FALSE) && (dwExceptionCode == STATUS_STACK_OVERFLOW))
    {
        return fIsCorruptedStateException;
    }

    switch(dwExceptionCode)
    {
        case STATUS_ACCESS_VIOLATION:
        case STATUS_STACK_OVERFLOW:
        case EXCEPTION_ILLEGAL_INSTRUCTION:
        case EXCEPTION_IN_PAGE_ERROR:
        case EXCEPTION_INVALID_DISPOSITION:
        case EXCEPTION_NONCONTINUABLE_EXCEPTION:
        case EXCEPTION_PRIV_INSTRUCTION:
        case STATUS_UNWIND_CONSOLIDATE:
            fIsCorruptedStateException = TRUE;
            break;
    }

    return fIsCorruptedStateException;
}

Рассмотрим описания наших исключительных ситуаций:

Код ошибки Описание
STATUS_ACCESS_VIOLATION Достаточно частая ошибка попытки работы с участком памяти, на который нет прав. Память с точки зрения процесса линейная, но работать можно далеко не со всем диапазоном: только с теми «островками», которые были выделены операционной системой, а также с теми, на которые хватает прав (например, есть диапазоны, которыми владеет только операционная система или же можно только исполнять код но не читать как данные)
STATUS_STACK_OVERFLOW Эта ошибка известна всем: ошибка нехватки памяти в стеке потока под вызов очередного метода
EXCEPTION_ILLEGAL_INSTRUCTION Очередной код, считанный процессором из тела метода не был распознан как инструкция
EXCEPTION_IN_PAGE_ERROR Поток предпринял попытку работы со страницей памяти, которой не существует
EXCEPTION_INVALID_DISPOSITION Механизм обработки исключений вернул не правильный обработчик. Такое исключение никогда не должно возникать в программах, написанных на высокоуровневых языках (например, С++)
EXCEPTION_NONCONTINUABLE_EXCEPTION Поток сделал попытку продолжить исполнение программы после возникновения исключения, продолжить исполнение кода после которого не возможно. Тут имеется ввиду не блоки catch/fault/finally, а подобие фильтров исключений, которые позволяют исправить ошибку, которая привела к исключению и предпринять еще одну попытку выполнить код, приведший к ошибке
EXCEPTION_PRIV_INSTRUCTION Попытка выполнить привилегированную инструкцию процессора
STATUS_UNWIND_CONSOLIDATE Исключение, относящееся к размотке стека и не являющееся предметом наших обсуждений

Прошу заметить, что по своей сути только два из них достойны перехвата: это STATUS_ACCESS_VIOLATION и STATUS_STACK_OVERFLOW. Остальные ошибки исключительны даже для исключительных ситуаций. Они скорее относятся к классу фатальных ошибок и нами рассматриваться не могут. А потому, давайте остановимся на этих двух более подробно:

AccessViolationException

Получение этого исключения — одна из тех новостей, которые не хотелось бы никому получить. А когда получаешь, становится совсем не ясно что с этим делать. AccessViolationException — это исключение "промаха" мимо выделенного для приложения участка памяти и по своей сути выбрасывается при попытке чтения или записи в защищенную область памяти. Здесь под словом "защита" лучше понимать именно попытку работы с еще не выделенным участком памяти или же уже освобожденным. Тут, заметьте не имеется ввиду процесс выделения и освобождения памяти сборщиком мусора. Тот просто размечает уже выделенную память под свои и ваши нужды. Память — она имеет в некоторой степени слоистую структуру. Когда после слоя управления памятью сборщиком мусора идет слой управления выделением памяти библиотеками ядра CLR, а за ними — операционной системой — из пула доступных фрагментов линейного адресного пространства. Так вот когда приложение промахивается мимо своей памяти и пытается работать с невыделенным участком либо с участком, приложению не предназначенному, тогда и возникает это исключение. Когда оно возникает, вам доступно не так много вариантов для анализа:

  • Если StackTrace уходит в недра CLR, вам сильно не повезло: это скорее всего ошибка ядра. Однако этот случай почти никогда не срабатывает. Из вариантов обхода — либо действовать как-то иначе либо обновить версию ядра если возможно;
  • Если же StackTrace уходит в unsafe код некоторой библиотеки, тут доступны такие варианты: либо вы напутали с настройкой маршаллинга либо же в unsafe библиотеке закралась серьезная ошибка. Тщательно проверьте аргументы метода: возможно аргументы нативного метода имеют другую разрядность или же другой порядок или попросту размер. Проверьте что структуры передаются там где надо — по ссылке, а там, где надо — по значению

Чтобы перехватить такое исключение на данный момент необходимо показать JIT компилятору что это реально необходимо. В противном случае оно перехвачено никак не будет и вы получите упавшее приложение. Однако, конечно же, его стоит перехватывать только тогда, когда вы понимаете что вы сможете его правильно обработать: его наличие может свидетельствовать о произошедшей утечке памяти если она была выделена unsafe методом между точкой его вызова и точкой выброса AccessViolationException и тогда хоть приложение и не будет "завалено", но его работа возможно станет не корректной: ведь перехватив поломку вызова метода вы наверняка попробуете вызвать этот метод еще раз, в будущем. А в этом случае что может пойти не так не известно никому: вы не можете знать каким образом было нарушено состояние приложения в прошлый раз. Однако, если желание перехватить такое исключение у вас сохранилось, прошу посмотреть на таблицу возможности перехвата этого исключения в различных версиях .NET Framework:

Версия .NET Framework AccessViolationExeception
1.0 NullReferenceException
2.0, 3.5 AccessViolation перехватить можно
4.0+ AccessViolation перехватить можно, но необходима настройка
.NET Core AccessViolation перехватить нельзя

Т.е. другими словами, если вам попалоось очень старое приложение на .NET Framework 1.0, покажите его мне вы получите NRE, что будет в некоторой степени обманом: вы отдали указатель со значением больше нуля, а получили NullReferenceException. Однако, на мой взгляд, такое поведение обосновано: находясь в мире управляемого кода вам меньше всего должно хотеться изучать типы ошибок неуправляемого кода и NRE — что по сути и есть "плохой указатель на объект" в мире .NET — тут вполне подходит. Однако, мир был бы прекрасен если бы все так было просто. В реальных ситуациях пользователям решительно не хватало этого типа исключений и потому — достаточно скоро — его ввели в версии 2.0. Просуществовав несколько лет в перехватываемом варианте, исключение перестало быть перехватываемым, однако появилась специальная настройка, которая позволяет включить перехват. Такой порядок выбора в команде CLR в целом на каждом этапе выглядит достаточно обоснованным. Посудите сами:

  • 1.0 Ошибка промаха мимо выделенных участков памяти должна быть именно исключительной ситуацией потому как если приложение работает с каким-либо адресом, оно его откуда-то получило. В managed мире этим местом является оператор new. В unmanaged мире — в целом любой участок кода может выступать точкой для возникновения такой ошибки. И хотя с точки зрения философии смысл обоих исключений диаметрально противоположен (NRE — работа с не проинициализированным указателем, AVE — работа с некорректно проинициализированным указателем), с точки зрения идеологии .NET некорректно проинициализированных указателей быть не может. Оба случая можно свести к одному и придать философский смысл: не корректно заданный указатель. А потому давайте так и сделаем: в обоих случаях будем выбрасывать NullReferenceException.
  • 2.0 На ранних этапах существования .NET Framework оказалось что кода, который наследуется через COM библиотеки больше собственного: существует огромная кодовая база коммерческих компонент для взаимодействия с сетью, UI, БД и прочими подсистемами. А значит, вопрос получения именно AccessViolationException все-таки стоит: неверная диагностика проблем может сделать процесс поимки проблемы более дорогим. В .NET Framework введено исключение AccessViolationException.
  • 4.0 .NET Framework укоренился, потеснив традиционную разработку на низкоуровневых языках программирования. Резко сокращено количество COM компонент: практически все основные задачи уже решаются в рамках самого фреймворка, а работа в unsafe кодом начинает считаться чем-то странным, неправильным. В этих условиях можно вернуться к идеологии, введенной в фреймворк с самого начала: .NET — он только для .NET. Unsafe код — это не норма, а вынужденное состояние, а потому идеологичность наличия перехвата AccessViolationException идет вразрез с идеологией понятия фреймворк — как платформа (т.е. имитация полной песочницы со своими законами). Однако мы все еще находимся в реалиях платформы, на которой работаем и во многих ситуациях перехватывать это исключение все еще необходимо: вводим специальный режим перехвата: только если введена соответствующая конфигурация;
  • .NET Core Наконец, сбылась мечта команды CLR: .NET более не предполагает законности работы с unsafe кодом, а потому существование AccessViolationException теперь вне закона даже на уровне конфигурации. .NET вырос настолько чтобы самостоятельно устанавливать правила. Теперь существование этого исключения в приложении приведет его к гибели, а потому любой unsafe код (т.е. сам CLR) обязан быть безопасным с точки зрения этого исключения. Если оно появляется в unsafe библиотеке, с ней просто не будут работать, а значит разработчики сторонних компонент на unsafe языках будут более аккуратными и обрабатывать его — у себя.

Вот так, на примере одного исключения можно проследить историю становления .NET Framework как платформы: от неуверенного подчинения внешним правилам до самостоятельного установления правил самой платформой.

После всего сказанного осталось раскрыть последнюю тему: как включить обработку данного исключения в 4.0+. Итак, чтобы включить обработку исключения данного типа в конкретном методе, необходимо:

  • Добавить в секцию configuration/runtime следующий код: <legacyCorruptedStateExceptionsPolicy enabled="true|false"/>
  • Для каждого метода, где необходимо обработать AccessViolationException, надо добавить два атрибута: HandleProcessCorruptedStateExceptions и SecurityCritical. Эти атрибуты позволяют включить обработку Corrupted State Exceptions, для конкретных методов, а не для всех вообще. Эта схема очень правильная, поскольку вы должны быть точно уверены что хотите их обрабатывать и знать, где: иногда более правильный вариант — просто завалить приложение на бок.

Для примера включения обработчика CSE и их примитивной обработки рассмотрим следующий код:

[HandleProcessCorruptedStateExceptions, SecurityCritical]
public bool TryCallNativeApi()
{
    try
    {
        // Запуск метода, который может выбросить AccessViolationException
    }
    catch (Exception e)
    {
        // Журналирование, выход
        System.Console.WriteLine(e.Message);
        return false;
    }

  return true;
}

StackOverflowException

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

Итак, когда наступает нехватка памяти под стек потока (либо уперлись в следующий занятый участок памяти и нет возможности выделить следующую страницу виртуальной памяти) или же поток уперся в разрешенный диапазон памяти, происходит попытка доступа к адресному пространству, которое называется Guard page. По сути этот диапазон — ловушка и не занимает никакой физической памяти. Вместо реального чтения или записи процессор вызывает специализированное прерывание, которое должно запросить у операционной системы новый участок памяти под рост стека потока. В случае достижения максимально-разрешенных значений операционная система вместо выделения нового участка генерирует исключительную ситуацию с кодом STATUS_STACK_OVERFLOW, которая будучи проброшенной через Structured Exception Handling механизм в .NET обрушивает текущий поток исполнения как более не корректный.

Прошу заметить что хоть данное исключение и является Corrupted State Exception, перехватить его при помощи HandleProcessCorruptedStateExceptions не представляется возможным. Т.е. следующий код не отработает:

// Main.cs
[HandleProcessCorruptedStateExceptions, SecurityCritical]
static void Main()
{
    try
    {
        Recursive();
    } catch (Exception exception)
    {
        Console.WriteLine("Catched Stack Overflow!");
    }
}

static void Recursive()
{
    Recursive();
}

// app.config:
<configuration>
  <runtime>
    <legacyCorruptedStateExceptionsPolicy enabled="true"/>
  </runtime>
</configuration>

А не представляется возможным потому что переполнение стека может быть вызвано двумя путями: первый — это намеренный вызов рекурсивного метода, который не слишком аккуратен чтобы контролировать собственную глубину. Тут может возникнуть желание исправить ситуацию, перехватив выброс исключения. Однако, если подумать, мы тем самым легализуем данную ситуацию, позволяя ей случиться вновь, что выглядит скорее не дальновидным чем случаем проявления заботы. И вторая — случайность — получение StackOverflowException при вполне себе обычном вызове. Просто в данный момент глубина стека вызовов оказалась слишком критичной. В данном примере перехватывать исключение выглядит как что-то совсем дикое: приложение работало штатно, все шло хорошо как вдруг легальный вызов метода при корректно работающих алгоритмах вызвал выброс исключения с дальнейшей разверткой стека до ожидающего такого поведения участка кода. Хм… Еще раз: мы ждем, что на следующем участке ничего не отработает потому что в стеке кончится память. Как по мне, это полный абсурд.

Ссылка на всю книгу

Автор: Станислав Сидристый

Источник

Поделиться

* - обязательные к заполнению поля