Статьи

Прерывание потока и критические регионы (в .Net)

Мне не нужно видеть исходный код API, чтобы код против. На самом деле, я активно противодействую зависимости (даже психологически) от внутренних деталей реализации. Контракт должен быть достаточным. Конечно, я предполагаю хорошо разработанный API с хорошей (по крайней мере приличной) документацией. Но

иногда
 часто реальность сложнее, чем идеальный мир.

Пустая попытка {}

Во время работы с  System.Diagnostics.Process контекстом Parallel.ForEachвсе стало слишком сложно. (Я оставлю кровавые подробности в другом посте.) Этот пост вызвал странный паттерн, который я заметил, просматривая Process.cs , исходный код Processкласса (чтобы распутать сложный сценарий).

Это из StartWithCreateProcess()частного метода Process. Поэтому нет ничего удивительного в том, что он выполняет множество вызовов API Win32. Что выделяется, так это try{}конструкция. Но также обратите внимание на RuntimeHelpers.PrepareConstrainedRegions()звонок.

Думая о возможных причинах этого, я подозревал, что это связано с гарантиями времени выполнения. RuntimeHelpers.PrepareConstrainedRegions()Вызов является членом КВЖД. Так зачем использовать пустую попытку, если у нас есть вызов PrepareConstrainedRegions? К сожалению, я перепутал это с пустым tryпредложением. В действительности, пустая конструкция try не имеет ничего общего с CER и всем, что связано с прерыванием выполнения посредством ThreadAbortException .

Быстрый поиск поразил Сиддхарта Уппала « Тайна пустого блока попыток», где он объясняет, что Thread.Abort()никогда не прерывает код в finallyпредложении. Ну, не совсем.

Thread.Abort и наконец

Обычный вопрос собеседования после прохождения семантики try/ catch/ finallyсостоит в том, чтобы спросить кандидата, исполняется ли finallyон всегда (так как обычно это формулировка, которую он использует). Нет ли сценариев, когда можно было бы ожидать, что их код  finallyникогда не будет выполнен? Творческий ответ обычно возникает, когда у нас есть «бесконечный цикл» в tryили catch(если он вызывается). Начинающие кандидаты легко путаются, когда считают преждевременное завершение процесса (или потока). В конце концов, можно было бы ожидать некоторой последовательности в поведении кода. Так почему бы не finallyвсегда выполняться, даже в сценарии завершения процесса ?

Нетрудно увидеть, что между стороной прекращения и рассматриваемым кодом / процессом идет борьба полномочий. Если finallyвсегда выполняется, не будет никаких гарантий для прекращения. Да, мы не можем гарантировать, что оба finallyбудут выполняться всегда, гарантируя, что прекращение всегда будет успешным. Один или оба должны иметь слабые гарантии (или, по крайней мере, более слабые гарантии, чем другие). Когда дело доходит до толчка, мы (как пользователи или администраторы) хотим иметь полный контроль над нашими машинами, поэтому мы выбираем самую лучшую волшебную палочку, чтобы убить и прекратить любой неправильный (или просто нежелательный) процесс.

Однако история немного отличается, когда дело доходит до прерывания отдельных потоков. Там, где на уровне процесса операционная система может завершить его широким жестом, в управляемом мире все более контролируемо. CLR может следить за тем, чтобы любой поток, который собирается завершиться (посредством вызова Thread.Abort()), был выполнен чисто и с соблюдением всех правил языка и времени выполнения. Это включает выполнение блоков finally и финализаторов .

ThreadAbortException странность

При прерывании потока очевидное поведение является одним из исключений, генерируемых изнутри рассматриваемого потока. Когда другой поток запускается Thread.Abort()в нашем потоке, ThreadAbortExceptionиз нашего кода возбуждается a . Это называется асинхронным прерыванием потока, в отличие от синхронного прерывания, когда поток запускается Thread.CurrentThread.Abort()(инвариантно для самого себя). Другие асинхронные исключения включают OutOfMemoryException и StackOverflowException.

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

Источником этого поведения  ThreadAbortExceptionявляется запрошенный флаг прерывания, который устанавливается, когда  Thread.Abort()вызывается (но не когда он генерируется напрямую). Затем CLR проверяет этот флаг в определенных контрольных точках и перебирает исключение, которое обычно возникает между любыми двумя машинными инструкциями. Это гарантирует, что исключение не будет сгенерировано при выполнении блока finally или при выполнении неуправляемого кода.

Так что ожидание начинающего собеседника (и мистера Уппала) было в конце концов правильным. За исключением того, что это не было. Мы вернулись на круги своя к проблеме между целью прерывания потока и возможностью того, что код с плохим поведением никогда не сдается вообще. Я слишком щедр, когда помечаю код, который не поддается запросу на прерывание, как «плохо себя ведущий». Поскольку ThreadAbortExceptionавтоматически перебрасывается по catchпричинам, единственный способ подавить его — это явный вызов, Thread.ResetAbort()который очищает флаг запроса отмены. Это сделано намеренно, так как разработчики часто пишут общие предложения.

AppDomain хостинг

До сих пор мы предполагали, что нам просто может потребоваться прервать процесс, не задавая вопросов. Но зачем нужно прерывать отдельные потоки внутри процесса? Ответ лежит на хостинге . В таких средах, как IIS или SQL-серверы, сервер должен быть быстрым и надежным. Это привело к разработке процессов разделения за пределы потоков. AppDomainгруппирует процессоры в рамках одного процесса, так что создание новых экземпляров происходит быстро (быстрее, чем создание совершенно нового процесса), но в то же время оно группируется так, что они могут быть выгружены по требованию. Когда AppDomainэкземпляр занимает больше времени, чем настроенное время (или потребляет некоторый ресурс больше, чем должен), он считается плохо себя ведет, и сервер захочет его выгрузить. Выгрузка включает в себя прерывание каждого потока вAppDomain обсуждаемый.

Проблема опять в конфликте между гарантиями. На этот раз, однако, логика завершения должна подыгрывать или иначе. При завершении процесса освобождается вся память процесса вместе со всеми его системными ресурсами. Если управляемый код или CLR этого не делают, операционная система будет. В размещенной среде исполнения хост хочет иметь полный контроль над временем жизни AppDomain(со всеми его потоками) все время, когда он решает очистить его, он не хочет дестабилизировать процесс или, что еще хуже , сама или система в целом. При разгрузкеAppDomainСервер хочет дать ему возможность очистить и освободить любые общие ресурсы, включая файлы и сокеты и объекты синхронизации (то есть блокировки), и это лишь некоторые из них. Это потому, что процесс будет продолжаться, надеюсь, очень долго. Отсюда и поведение, ThreadAbortExceptionкоторое вызывает каждый catchи finallyкак следует.

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

Rude Abort / Unload / Exit

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

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

В грубом событии прерывания потока CLR воздерживается от вызова любых блоков finally (кроме тех, которые помечены как Области ограниченного выполнения или сокращенно CER), а также любого обычного финализатора. Но в отличие от простых блоков finally, финализаторы — гораздо более серьезная группа. Это функции с последствиями. Напомним, что финализация служит для освобождения системных ресурсов. В полностью управляемой среде с сборкой и очисткой мусора единственными ресурсами, требующими особого внимания, являются те, которые неудалось. В идеальном случае не нужно реализовывать финализаторы вообще. Другой случай, когда нам нужно обернуть системный ресурс, который не управляется (это включает вызов собственной DLL). Все системные ресурсы, представленные платформой, высвобождаются с использованием финализатора.

Конечно, если мы разрабатываем автономные приложения, а не размещаем, нам не нужно беспокоиться о возможности эскалации и грубого прерывания или выгрузки. Но опять же, зачем нам вообще беспокоиться Thread.Abort()в таком случае? Либо наш код может выдать такой резкий запрос, который мы должны избегать, например, чумы, и выбрать более гражданские методы отмены, такие как возбуждение событий или установка общих флагов (с особой тщательностью), либо наш код находится в библиотеке, которая может вызываться либо из отдельного приложения, либо из размещенного приложения. Только в последнем случае мы должны беспокоиться и готовиться к возможности грубой отмены / выгрузки.

Критическое завершение и CER

Dispose()вызывается в finallyблоках, либо вручную, используя usingпредложение. Таким образом, единственный правильный способ избавиться от объектов во время таких потрясений — иметь на этих объектах финализаторы. И не просто финализатор, а Критический финализатор . Это тот же метод, который используется в SafeHandle для обеспечения правильного освобождения собственных дескрипторов в случае катастрофического сбоя.

Когда все становится серьезно, вызываются только финализаторы, помеченные как безопасные . Неудивительно, что атрибуты используются для обозначения методов как безопасных. Контракт между CLR и кодексом является строгим. Во-первых, мы сообщаем, насколько важна функция перед лицом асинхронных исключений, отмечая их надежность. Затем мы аннулируем наши права на выделение памяти, что не является тривиальным, так как это делается прозрачно в некоторых случаях, таких как маршалинг P / Invoke, блокировка и упаковка. Кроме того, единственные методы, которые мы можем вызвать изнутри блока CER, — это методы с надежными гарантиями надежности. Виртуальные методы, которые не подготовлены заранее, также не могут быть вызваны.

Это приносит нам полный круг  RuntimeHelpers.PrepareConstrainedRegions(). Этот вызов говорит CLR полностью подготовить исходящий код, выделяя всю необходимую память, обеспечивая достаточное пространство стека, JITing код, который полностью загружает любые сборки, которые могут нам понадобиться.

Вот пример кода, который демонстрирует, как это работает на практике. Когда атрибут ReliabilityContract закомментирован, блок try выполняется перед блоком finally, который завершается ошибкой. Однако с атрибутом ReliabilityContract PrepareConstrainedRegions()вызов не может выделить всю необходимую память заранее и поэтому даже не пытается выполнить ни предложение try, ни finally, вместо этого исключение выдается немедленно.

Есть три формы для выполнения кода в ограниченных областях выполнения (из блога команды BCL ):

  • ExecuteCodeWithGuaranteedCleanup, безопасная форма переполнения стека для try / finally.
  • Блок try / finally сразу же предшествует вызову RuntimeHelpers.PrepareConstrainedRegions. Блок try не ограничен, но все блоки catch, finally и fault для этой попытки являются.
  • В качестве критического финализатора — любой подкласс CriticalFinalizerObject имеет финализатор, который охотно готовится до того, как будет выделен экземпляр объекта.

    • Особый случай — это метод ReleaseHandle SafeHandle, виртуальный метод, который охотно готовится до выделения подкласса и вызывается из критического финализатора SafeHandle.

Вывод

Обычно CLR гарантирует чистое выполнение и очистку (с помощью кода finally и финализации) даже в условиях асинхронных исключений и прерывания потока. Тем не менее, он сохраняет за собой право принимать более жесткие меры, если хост усиливает ситуацию. Написание кода в блоках finally, чтобы избежать возможности асинхронных исключений, хотя и не является наилучшей практикой , будет работать. Когда мы злоупотребляем этим, переустанавливая запросы на прерывание и тратя слишком много времени в блоках finally, хост увеличивает объем операций до грубой выгрузки и будет агрессивно разрывать AppDomain со всеми его потоками, минуя блоки finally , если не в блоке CER, как в приведенном выше коде. сделал.

Таким образом, блоки наконец не выполняются, когда выполняется грубое прерывание / выгрузка (если не в CER), когда процесс завершается (операционной системой), когда возникает необработанное исключение (обычно в неуправляемом коде или в CLR) или в фоновых потоках (IsBackground == true), когда все потоки переднего плана вышли.