Поделиться через


Profiler Stack Walking in the платформа .NET Framework 2.0: Basics and Beyond

 

Сентябрь 2006 г.

Дэвид Броман
Microsoft Corporation

Область применения:
   Microsoft .NET Framework 2.0
   Среда CLR

Сводка: Описывается, как запрограммировать профилировщик для обхода управляемых стеков в среде CLR платформа .NET Framework. (14 печатных страниц)

Содержимое

Введение
Синхронные и асинхронные вызовы
Перемешивание
Будьте на своем лучшем поведении
Достаточно достаточно
Кредит, где кредит начисляется
Об авторе

Введение

Эта статья предназначена для всех, кто заинтересован в создании профилировщика для изучения управляемых приложений. Я опишу, как можно запрограммировать профилировщик для обхода управляемых стеков в среде CLR платформа .NET Framework. Я постараюсь сохранить настроение легким, потому что сама тема может быть тяжелой время от времени.

API профилирования в среде CLR версии 2.0 содержит новый метод с именем DoStackSnapshot , который позволяет профилировщику выполнять стек вызовов приложения, которое вы профилируете. В версии 1.1 среды CLR аналогичные функции предоставлялись через интерфейс внутрипроцессной отладки. Но с помощью DoStackSnapshot ходить по стеку вызовов проще, точнее и стабильнее. Метод DoStackSnapshot использует тот же ходок стека, который используется сборщиком мусора, системой безопасности, системой исключений и т. д. Значит, ты знаешь , что это должно быть правильно.

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

Я сосредоточусь на получении трассировок стека с помощью API DoStackSnapshot . Другим способом получения трассировок стека является создание теневых стеков. Вы можете подключить FunctionEnter и FunctionLeave , чтобы сохранить копию управляемого стека вызовов для текущего потока. Создание теневого стека полезно, если вам все время требуются сведения о стеке во время выполнения приложения, а также если вы не возражаете против затрат на производительность, связанную с выполнением кода профилировщика при каждом управляемом вызове и возврате. Метод DoStackSnapshot лучше всего подходит, если требуется несколько разреженный отчет о стеках, например в ответ на события. Даже профилировщик выборки, который создает моментальные снимки стека каждые несколько миллисекундах, намного разрежен, чем создание теневых стеков. Поэтому DoStackSnapshot хорошо подходит для профилировщиков выборки.

Прогуляться по стеку на дикой стороне

Очень полезно получать стеки вызовов в любое время. Но с властью приходит ответственность. Пользователь профилировщика не хочет, чтобы обход стека приводил к нарушению доступа (AV) или взаимоблокировке в среде выполнения. Как профилировщик писатель, вы должны владеть вашей властью с осторожностью. Я расскажу о том, как использовать DoStackSnapshot, и как сделать это осторожно. Как вы увидите, чем больше вы хотите сделать с помощью этого метода, тем труднее получить его правильно.

Давайте рассмотрим нашу тему. Вот что вызывает профилировщик (его можно найти в интерфейсе ICorProfilerInfo2 в Corprof.idl):

HRESULT DoStackSnapshot( 
  [in] ThreadID thread, 
  [in] StackSnapshotCallback *callback, 
  [in] ULONG32 infoFlags, 
  [in] void *clientData, 
  [in, size_is(contextSize), length_is(contextSize)] BYTE context[], 
  [in] ULONG32 contextSize); 

Следующий код является тем, что среда CLR вызывает в профилировщике. (Вы также можете найти его в Corprof.idl.) Вы передаете указатель на реализацию этой функции в параметре обратного вызова из предыдущего примера.

typedef HRESULT __stdcall StackSnapshotCallback( 
  FunctionID funcId, 
  UINT_PTR ip, 
  COR_PRF_FRAME_INFO frameInfo, 
  ULONG32 contextSize, 
  BYTE context[], 
  void *clientData); 

Это как бутерброд. Когда профилировщик хочет пройти по стеку, вы вызываете DoStackSnapshot. Прежде чем среда CLR возвращается из этого вызова, она вызывает функцию StackSnapshotCallback несколько раз, по одному разу для каждого управляемого кадра или для каждого запуска неуправляемых кадров в стеке. На рисунке 1 показан этот сэндвич.

Рис. 1. "Бутерброд" вызовов во время профилирования

Как видно из моих нотации, среда CLR уведомляет вас о кадрах в обратном порядке от того, как они были отправлены в стек: сначала конечный кадр (отправленный последним), main кадр последний (сначала был отправлен).

Что означают все параметры для этих функций? Я не готов обсудить их все еще, но я буду обсуждать некоторые из них, начиная с DoStackSnapshot. (Я доберусь до остальных через несколько секунд.) Значение infoFlags поступает из перечисления COR_PRF_SNAPSHOT_INFO в Corprof.idl и позволяет управлять тем, будет ли среда CLR зарегистрировать контексты для кадров, которые она сообщает. Вы можете указать любое значение для clientData , и среда CLR вернет его вам в вызове StackSnapshotCallback .

В StackSnapshotCallback среда CLR использует параметр funcId для передачи значения FunctionID текущего шагаемого кадра. Это значение равно 0, если текущий кадр является выполнением неуправляемых кадров, о которых я расскажу позже. Если значение funcId не равно нулю, вы можете передать funcId и frameInfo другим методам, таким как GetFunctionInfo2 и GetCodeInfo2, чтобы получить дополнительные сведения о функции. Вы можете получить эти сведения о функции сразу же, во время стека или сохранить значения funcId и позже получить сведения о функции, что снижает влияние на работающее приложение. Если вы получите сведения о функции позже, помните, что значение frameInfo допустимо только внутри обратного вызова, который предоставляет его вам. Хотя значения funcId можно сохранить для последующего использования, не сохраняйте frameInfo для последующего использования.

При возвращении из StackSnapshotCallback обычно возвращается S_OK и среда CLR продолжит ходить по стеку. При желании вы можете вернуть S_FALSE, что останавливает ход стека. Затем вызов DoStackSnapshot вернет CORPROF_E_STACKSNAPSHOT_ABORTED.

Синхронные и асинхронные вызовы

DoStackSnapshot можно вызывать двумя способами: синхронно и асинхронно. Проще всего получить правильный синхронный вызов. Вы выполняете синхронный вызов, когда среда CLR вызывает один из методов ICorProfilerCallback(2) вашего профилировщика, и в ответ вызываете DoStackSnapshot для обхода стека текущего потока. Это полезно, если вы хотите увидеть, как выглядит стек в интересной точке уведомления, например ObjectAllocated. Чтобы выполнить синхронный вызов, вызовите DoStackSnapshot из метода ICorProfilerCallback(2), передавая ноль или null для параметров, о которые я не говорил.

Асинхронное пошаговое руководство по стеку происходит при проходе по стеку другого потока или принудительном прерывании потока для выполнения пошагового выполнения стека (для себя или в другом потоке). Прерывание потока включает в себя перехват указателя инструкции потока, чтобы заставить его выполнять собственный код в произвольное время. Это безумно опасно по слишком многим причинам, чтобы перечислить здесь. Пожалуйста, просто не делайте этого. Я ограничу описание асинхронных прогулок стека только использованием DoStackSnapshot без перехвата для обхода отдельного целевого потока. Я называю это "асинхронным", так как целевой поток выполнялся в произвольной точке во время начала шага стека. Этот метод обычно используется профилировщиками выборки.

Хождение по всему кому-то другому

Давайте немного разберем перекрестный поток, т. е. асинхронный стек. У вас есть два потока: текущий и целевой поток. Текущий поток — это поток, выполняющий DoStackSnapshot. Целевой поток — это поток, стек которого выполняется с помощью DoStackSnapshot. Целевой поток указывается путем передачи его идентификатора в параметре потока в DoStackSnapshot. То, что происходит дальше, не для слабонервных. Помните, что целевой поток выполнял произвольный код, когда вы попросили пройти его стек. Таким образом, среда CLR приостанавливает целевой поток и остается приостановленной на все время его обхода. Можно ли это сделать безопасно?

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

Перемешивание

Управляемое приложение вряд ли будет тратить все свое время на управляемый код. Вызовы PInvoke и COM-взаимодействие позволяют управляемому коду вызывать неуправляемый код, а иногда и снова с делегатами. А управляемый код вызывает непосредственно в неуправляемую среду выполнения (CLR) для выполнения JIT-компиляции, обработки исключений, выполнения сборки мусора и т. д. Поэтому при выполнении стека вы, вероятно, столкнетесь со смешанным стеком: некоторые кадры являются управляемыми функциями, а другие — неуправляемыми.

Повзрослейте, уже!

Прежде чем продолжить, краткое интерлюдия. Всем известно, что стеки на наших современных компьютерах растут (то есть "толкают") на меньшие адреса. Но когда мы визуализируем эти адреса в уме или на досках, мы не согласны с тем, как сортировать их по вертикали. Некоторые из нас представляют, что стек растет (маленькие адреса на вершине); некоторые видят его растет вниз (маленькие адреса внизу). Мы разделились по этому вопросу и в нашей команде. Я выбираю сторону любого отладчика, который я когда-либо использовал, — трассировки стека вызовов и дампы памяти говорят мне, что маленькие адреса "выше" больших адресов. Так стеки растут; main находится внизу, конечный вызываемый объект находится вверху. Если вы не согласны, вам придется сделать некоторые психические перестановки, чтобы пройти через эту часть статьи.

Официант, есть отверстия в моем стеке

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

Рис. 2. Стек с управляемыми и неуправляемыми кадрами

Отступая немного назад, стоит понять, почему DoStackSnapshot существует в первую очередь. Он поможет вам ходить по управляемым кадрам в стеке. Если вы попытаетесь самостоятельно пройти управляемые кадры, вы получите ненадежные результаты, особенно в 32-разрядных системах, из-за некоторых дурацких соглашений о вызовах, используемых в управляемом коде. Среда CLR понимает эти соглашения о вызовах, поэтому DoStackSnapshot может помочь вам расшифровать их. Однако DoStackSnapshot не является полным решением, если вы хотите иметь возможность пройти весь стек, включая неуправляемые кадры.

Вот где у вас есть выбор:

Вариант 1. Ничего не делать и сообщать пользователям о стеках с "неуправляемыми отверстиями" или ...

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

Когда DoStackSnapshot встречает блок неуправляемых кадров, он вызывает функцию StackSnapshotCallback с значением funcId , равным 0, как упоминалось ранее. Если вы используете вариант 1, просто не делайте никаких действий в обратном вызове, если значение funcId равно 0. Среда CLR снова вызовет вас для следующего управляемого кадра, и в этот момент вы сможете проснуться.

Если неуправляемый блок состоит из нескольких неуправляемых кадров, среда CLR по-прежнему вызывает StackSnapshotCallback только один раз. Помните, что среда CLR не предпринимает никаких усилий для декодирования неуправляемого блока— у нее есть специальные внутренние сведения, которые помогают переходить по блоку к следующему управляемому кадру. Среда CLR не обязательно знает, что находится внутри неуправляемого блока. Это для вас, чтобы выяснить, следовательно, вариант 2.

Этот первый шаг является Doozy

Независимо от того, какой вариант вы выберете, заполнение неуправляемых отверстий не является единственной трудной частью. Только начать прогулку может быть проблемой. Взгляните на стек выше. Вверху находится неуправляемый код. Иногда вам повезет, и неуправляемый код будет COM или PInvoke . Если это так, среда CLR достаточно умна, чтобы узнать, как ее пропустить, и начинается с первого управляемого кадра (D в примере). Тем не менее, может потребоваться пройти самый верхний неуправляемый блок, чтобы создать как можно более полный стек.

Даже если вы не хотите идти по самому верхнему блоку, вы можете быть вынуждены в любом случае. Если вам не повезет, неуправляемый код является не COM или PInvoke , а вспомогательным кодом в самой среде CLR, например кодом для JIT-компиляции или сборки мусора. В этом случае среда CLR не сможет найти кадр D без вашей помощи. Поэтому незапечатанный вызов DoStackSnapshot приведет к ошибке CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX или CORPROF_E_STACKSNAPSHOT_UNSAFE. (Кстати, это действительно стоит посетить corerror.h.)

Обратите внимание, что я использовал слово "unseededed". DoStackSnapshot принимает начальный контекст с помощью параметров context и contextSize . Слово "контекст" перегружено множеством значений. В данном случае я говорю о контексте регистра. Если вы используете зависимые от архитектуры заголовки windows (например, nti386.h), вы найдете структуру с именем CONTEXT. Он содержит значения для регистров ЦП и представляет состояние ЦП в определенный момент времени. Это тип контекста, о котором я говорю.

Если передать значение NULL для параметра контекста , пошаговое руководство по стеку будет незамеченным, а среда CLR начинается в верхней части. Однако если вы передаете значение, отличное от NULL, для параметра контекста , представляющего состояние ЦП в некотором месте ниже в стеке (например, при указании на кадр D), среда CLR выполнит пошаговое руководство по стеку, заполненное контекстом. Он игнорирует реальную верхнюю часть стека и начинается, где бы вы ни указывали.

Хорошо, не совсем верно. Контекст, который вы передаете в DoStackSnapshot , скорее подсказка, чем прямая директива. Если среда CLR уверена, что она сможет найти первый управляемый кадр (так как самый верхний неуправляемый блок — PInvoke или COM-код), она выполнит это и проигнорирует начальное значение. Не берите это лично, однако. Среда CLR пытается помочь вам, предоставляя наиболее точные сведения о стеке. Начальное значение полезно только в том случае, если самый верхний неуправляемый блок является вспомогательным кодом в самой среде CLR, так как у нас нет сведений, которые помогут нам пропустить его. Таким образом, начальное значение используется только в том случае, если среда CLR не может самостоятельно определить, с чего начать прогулку.

Вы можете задаться вопросом, как вы можете предоставить нам семена в первую очередь. Если целевой поток еще не приостановлен, вы не можете просто пройти по стеку целевого потока, чтобы найти кадр D и таким образом вычислить начальный контекст. И все же я говорю вам, чтобы вычислить начальный контекст путем выполнения неуправляемой прогулки перед вызовом DoStackSnapshot и таким образом до DoStackSnapshot позаботится о приостановке целевого потока для вас. Нужно ли приостанавливать целевой поток вами и средой CLR? На самом деле, да.

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

Все вместе сейчас

Для действительно adventuresome профилировщик, который делает асинхронный, перекрестный, заполненный стек прогулки при заполнении неуправляемых отверстий, вот как будет выглядеть стек ходьбы. Предположим, что показанный здесь стек — это тот же стек, который вы видели на рис. 2, только что немного разбитый.

Содержимое стека Действия профилировщика и среды CLR

1. Вы приостанавливаете целевой поток. (Число приостановок целевого потока теперь равно 1.)

2. Вы получите текущий контекст регистра целевого потока.

3. Вы определяете, указывает ли контекст регистра на неуправляемый код, то есть вызываете ICorProfilerInfo2::GetFunctionFromIP и проверка, возвращается ли значение FunctionID, равное 0.

4. Так как в этом примере контекст регистра указывает на неуправляемый код, вы выполняете неуправляемый обход стека, пока не найдете самый верхний управляемый кадр (функция D).

5. Вы вызываете DoStackSnapshot с начальным контекстом, и среда CLR снова приостанавливает целевой поток. (Теперь число приостановок равно 2.) Бутерброд начинается.
а. Среда CLR вызывает функцию StackSnapshotCallback с functionID для D.
b. Среда CLR вызывает функцию StackSnapshotCallback с FunctionID , равным 0. Вы должны ходить по этому блоку самостоятельно. Вы можете остановиться, когда достигнете первого управляемого кадра. Кроме того, вы можете обмануть и отложить неуправляемую прогулку до некоторого времени после следующего обратного вызова, так как следующий обратный вызов сообщит вам, где именно начинается следующий управляемый кадр и, следовательно, где должна завершиться неуправляемая прогулка.
c. Среда CLR вызывает функцию StackSnapshotCallback с functionID для C.
d. Среда CLR вызывает функцию StackSnapshotCallback с functionID для B.
д) Среда CLR вызывает функцию StackSnapshotCallback с FunctionID , равным 0. Опять же, вы должны ходить по этому блоку самостоятельно.
е) Среда CLR вызывает функцию StackSnapshotCallback с functionID для A.
ж. Среда CLR вызывает функцию StackSnapshotCallback с functionID для Main.

h. DoStackSnapshot "возобновляет" целевой поток, вызывая API Win32 ResumeThread(), который уменьшает количество приостановки потока (его число приостановки теперь равно 1) и возвращает значение . Бутерброд готов.
6. Вы возобновляете целевой поток. Теперь число приостановок равно 0, поэтому поток физически возобновляется.

Будьте на своем лучшем поведении

Хорошо, это слишком много энергии без какой-то серьезной осторожности. В самом сложном случае вы реагируете на прерывания таймера и произвольно приостанавливаете потоки приложения для обхода их стеков. Yikes!

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

Плохое начальное значение

Начнем с простого правила: не используйте плохое начальное значение. Если профилировщик предоставляет недопустимое начальное значение (отличное от NULL) при вызове DoStackSnapshot, среда CLR даст неправильные результаты. Он будет смотреть на стек, где вы указываете, и делать предположения о том, что значения в стеке должны представлять. Это приведет к тому, что среда CLR разыменует адреса в стеке. При неправильном начальном значении среда CLR разыменует значения в неизвестное место в памяти. Среда CLR делает все возможное, чтобы избежать появления всех возможностей второго шанса av, что приведет к удалению процесса профилирования. Но вы действительно должны сделать усилия, чтобы получить ваше семя право.

Беды подвески

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

Если вы брали курсы информатики, вы, вероятно, помните о проблеме "столовой философов". Группа философов сидит за столом, каждый с одной вилкой справа и одной слева. Согласно проблеме, каждому из них нужно две вилки, чтобы съесть. Каждый философ поднимает свою правую вилку, но тогда никто не может забрать левую вилку, потому что каждый философ ждет философа слева, чтобы положить необходимую вилку. И если философы сидят за круглым столом, у вас есть цикл ожидания и много пустых желудок. Причина, по которой все они голодают, заключается в том, что они нарушают простое правило предотвращения взаимоблокировки: если вам нужно несколько блокировок, всегда принимать их в одном порядке. Следуя этому правилу, можно избежать цикла, в котором A ожидает на B, B — на C, а C — на A.

Предположим, что приложение следует правилу и всегда принимает блокировки в том же порядке. Теперь приходит компонент (например, профилировщик) и начинает произвольно приостанавливать потоки. Сложность значительно возросла. Что делать, если приостановка теперь должна принять блокировку, удерживаемую приостановленной? Или что делать, если приостановке требуется блокировка, удерживаемая потоком, который ожидает блокировки, удерживаемой другим потоком, который ожидает блокировки, удерживаемой участником приостановки? Приостановка добавляет новое ребро к граф зависимостей потока, что может привести к циклам. Рассмотрим некоторые конкретные проблемы.

Проблема 1. Получатель приостановки владеет блокировками, необходимыми для приостановки или потоками, от которых зависит приостановка.

Проблема 1a. Блокировки являются блокировками СРЕДЫ CLR.

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

Проблема 1b. Блокировки — это блокировки вашего профилировщика.

Эта проблема на самом деле является скорее вопросом здравого смысла. Возможно, у вас есть собственная синхронизация потоков для выполнения здесь и там. Представьте, что поток приложения (поток A) сталкивается с обратным вызовом профилировщика и выполняет часть кода профилировщика, который принимает одну из блокировок профилировщика. Затем поток B должен пройти по потоку A, что означает, что поток B приостановит поток A. Необходимо помнить, что пока поток A приостановлен, поток B не должен пытаться принять какие-либо из собственных блокировок профилировщика, которые может принадлежать потоку A. Например, поток B будет выполнять StackSnapshotCallback во время стека, поэтому не следует принимать никаких блокировок во время обратного вызова, которые могут принадлежать потоку A.

Проблема 2. Пока вы приостанавливаете целевой поток, целевой поток пытается приостановить вас.

Вы можете сказать: "Это не может произойти!" Верьте или нет, он может, если:

  • Приложение выполняется в многопроцессорном поле и
  • Поток A выполняется на одном процессоре, а поток B — на другом, и
  • Поток A пытается приостановить поток B, а поток B пытается приостановить поток A.

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

Эта проблема вызывает больше затруднений, чем проблема 1, так как вы не можете полагаться на среду CLR для обнаружения перед вызовом DoStackSnapshot , что потоки будут приостанавливать друг друга. И после того, как вы выполнили подвеску, уже слишком поздно!

Почему целевой поток пытается приостановить профилировщик? В гипотетическом, плохо написанном профилировщике код стека вместе с кодом приостановки может выполняться любым количеством потоков в произвольное время. Представьте, что поток A пытается пройти поток B в то же время, что поток B пытается пройти поток A. Они оба пытаются приостановить друг друга одновременно, так как они оба выполняют часть SuspendThread процедуры стека профилировщика. Оба выигрывают, и профилирование приложения находится в тупике. Правило здесь очевидно: не разрешайте профилировщику выполнять код стека (и, следовательно, код приостановки) в двух потоках одновременно.

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

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

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

Это наше первое нетривиальное правило, поэтому для акцента позвольте мне повторить его:

Правило 1. Приостанавливать другой поток должен только поток, который никогда не выполнял управляемый код.

Никто не любит ходить труп

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

К счастью, среда CLR уведомляет профилировщиков о том, что поток будет уничтожен, используя обратный вызов с метким именем ThreadDeifed, определенный с помощью интерфейса ICorProfilerCallback(2). Вы несете ответственность за реализацию ThreadDeстроение и заставить его дождаться завершения любого процесса, который выполняет этот поток. Это достаточно интересно, чтобы квалифицироваться как наше следующее правило:

Правило 2. Переопределите обратный вызов ThreadDe destroyed и заставьте реализацию подождать, пока вы не закончите обход стека потока, который будет уничтожен.

Следуя правилу 2, среда CLR не может уничтожить поток до тех пор, пока вы не закончите обход стека потока.

Сборка мусора помогает создать цикл

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

Правило 3. Не удерживайте блокировку во время вызова профилировщика, который может активировать сборку мусора.

Я упоминал ранее, что это плохая идея для вашего профилировщика, если его собственные блокировки, если поток-владение может быть приостановлено, и если поток может пройти другой поток, которому требуется та же блокировка. Правило 3 помогает избежать более тонкой проблемы. Здесь я говорю, что вы не должны удерживать какие-либо собственные блокировки, если владеющий поток должен вызвать метод ICorProfilerInfo(2), который может активировать сборку мусора.

Несколько примеров должны помочь. В первом примере предположим, что поток B выполняет сборку мусора. Последовательность:

  1. Поток A принимает и теперь владеет одной из блокировок профилировщика.
  2. Поток B вызывает обратный вызов GarbageCollectionStarted профилировщика.
  3. Поток B блокирует блокировку профилировщика на шаге 1.
  4. Поток A выполняет функцию GetClassFromTokenAndTypeArgs .
  5. Вызов GetClassFromTokenAndTypeArgs пытается активировать сборку мусора, но обнаруживает, что сборка мусора уже выполняется.
  6. Поток A блокирует, ожидая завершения сборки мусора (поток B). Однако поток B ожидает потока A из-за блокировки профилировщика.

На рисунке 3 показан сценарий в этом примере:

Рис. 3. Взаимоблокировка между профилировщиком и сборщиком мусора

Второй пример немного отличается от сценария. Последовательность:

  1. Поток A принимает и теперь владеет одной из блокировок профилировщика.
  2. Поток B вызывает обратный вызов ModuleLoadStarted профилировщика.
  3. Поток B блокирует блокировку профилировщика на шаге 1.
  4. Поток A выполняет функцию GetClassFromTokenAndTypeArgs .
  5. Вызов GetClassFromTokenAndTypeArgs активирует сборку мусора.
  6. Поток A (который в настоящее время выполняет сборку мусора) ожидает готовности потока B к сбору. Но поток B ожидает потока A из-за блокировки профилировщика.
  7. На рисунке 4 показан второй пример.

Рис. Взаимоблокировка между профилировщиком и ожидающей сборкой мусора

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

Результат во втором примере происходит потому, что поток, выполняющий сборку мусора, должен дождаться, пока другие потоки приложения будут готовы к сбору. Проблема возникает, когда вы вводите один из собственных замков в смеси, таким образом образуя цикл. В обоих случаях правило 3 нарушается, разрешив потоку A владеть одной из блокировок профилировщика, а затем вызвать Метод GetClassFromTokenAndTypeArgs. (На самом деле вызова любого метода, который может активировать сборку мусора, достаточно для завершения процесса.)

У вас, вероятно, есть несколько вопросов.

У. Как узнать, какие методы ICorProfilerInfo(2) могут активировать сборку мусора?

A. Мы планируем задокументировать это на сайте MSDN или, по крайней мере, в моем блоге или блоге Джонатана Келджо.

У. Что это связано со стеком ходьбы? Нет упоминание DoStackSnapshot.

A. Верно. И DoStackSnapshot даже не является одним из тех методов ICorProfilerInfo(2), которые активируют сборку мусора. Причина, по которой я обсуждаю правило 3, заключается в том, что именно те приключенческие программисты асинхронно ходить стеки из произвольных выборок, которые будут с наибольшей вероятностью реализовать свои собственные блокировки профилировщика, и, таким образом, быть склонны попасть в эту ловушку. Правило 2 фактически предписывает добавить синхронизацию в профилировщик. Вполне вероятно, что профилировщик выборки также будет иметь другие механизмы синхронизации, возможно, для координации чтения и записи общих структур данных в произвольное время. Конечно, профилировщик, который никогда не касается DoStackSnapshot , все еще может столкнуться с этой проблемой.

Достаточно достаточно

Я собираюсь закончить с кратким обзором основных моментов. Вот важные моменты, которые следует помнить:

  • Синхронные обходы стека включают обход текущего потока в ответ на обратный вызов профилировщика. Для этого не требуется заполнения, приостановки или каких-либо специальных правил.
  • Для асинхронных прогулок требуется начальное значение, если верхняя часть стека является неуправляемым кодом и не является частью вызова PInvoke или COM. Вы предоставляете начальное значение, непосредственно приостанавливая целевой поток и продвигая его самостоятельно, пока не найдете самый верхний управляемый кадр. Если в этом случае не указать начальное значение, DoStackSnapshot может вернуть код сбоя или пропустить некоторые кадры в верхней части стека.
  • Если необходимо приостановить потоки, помните, что только поток, который никогда не выполнял управляемый код, должен приостанавливать другой поток.
  • При выполнении асинхронных обходов всегда переопределяйте обратный вызов ThreadDe destroyed , чтобы блокировать уничтожение потока средой CLR до тех пор, пока не завершится обход стека этого потока.
  • Не удерживайте блокировку, пока профилировщик вызывает функцию CLR, которая может активировать сборку мусора.

Дополнительные сведения об API профилирования см. в разделе Профилирование (неуправляемый) на веб-сайте MSDN.

Кредит, где кредит начисляется

Я хотел бы добавить примечание о благодарности к остальной части команды API профилирования CLR, потому что написание этих правил действительно было командной работой. Отдельная благодарность Шону Селитренникову, который предоставил более раннюю инкарнацию большей части этого содержания.

 

Сведения об авторе

Дэвид был разработчиком в Корпорации Майкрософт дольше, чем вы думаете, учитывая его ограниченные знания и зрелость. Хотя больше не разрешено проверка в коде, он по-прежнему предлагает идеи для новых имен переменных. Дэвид является заядлым поклонником графа Чокулы и владеет своей собственной машиной.