Compartilhar via


Profiler Stack Walking in the .NET Framework 2.0: Basics and Beyond

 

Setembro de 2006

David Broman
Microsoft Corporation

Aplica-se a:
   Microsoft .NET Framework 2.0
   CLR (Common Language Runtime)

Resumo: Descreve como você pode programar seu criador de perfil para percorrer pilhas gerenciadas no CLR (Common Language Runtime) do .NET Framework. (14 páginas impressas)

Sumário

Introdução
Chamadas síncronas e assíncronas
Misturando-o
Esteja em seu melhor comportamento
Tudo tem um limite
Crédito em que o crédito é devido
Sobre o autor

Introdução

Este artigo é direcionado para qualquer pessoa interessada em criar um criador de perfil para examinar aplicativos gerenciados. Descreverei como você pode programar seu criador de perfil para percorrer pilhas gerenciadas no CLR (Common Language Runtime) do .NET Framework. Vou tentar manter o humor leve, porque o tópico em si pode ser pesado às vezes.

A API de criação de perfil na versão 2.0 do CLR tem um novo método chamado DoStackSnapshot que permite que o criador de perfil ande na pilha de chamadas do aplicativo que você está criando a criação de perfil. A versão 1.1 do CLR expôs funcionalidade semelhante por meio da interface de depuração em processo. Mas andar na pilha de chamadas é mais fácil, mais preciso e mais estável com DoStackSnapshot. O método DoStackSnapshot usa o mesmo andador de pilha usado pelo coletor de lixo, sistema de segurança, sistema de exceção e assim por diante. Então você sabe que tem que estar certo.

O acesso a um rastreamento de pilha completo dá aos usuários do criador de perfil a capacidade de obter a visão geral do que está acontecendo em um aplicativo quando algo interessante acontece. Dependendo do aplicativo e do que um usuário deseja criar o perfil, você pode imaginar um usuário querendo uma pilha de chamadas quando um objeto é alocado, quando uma classe é carregada, quando uma exceção é lançada e assim por diante. Mesmo obter uma pilha de chamadas para algo diferente de um evento de aplicativo, por exemplo, um evento de temporizador, seria interessante para um criador de perfil de amostragem. Olhar para pontos de acesso no código torna-se mais esclarecedor quando você pode ver quem chamou a função que chamou a função que chamou a função que contém o ponto de acesso.

Vou me concentrar em obter rastreamentos de pilha com a API DoStackSnapshot . Outra maneira de obter rastreamentos de pilha é criando pilhas de sombra: você pode conectar FunctionEnter e FunctionLeave para manter uma cópia da pilha de chamadas gerenciadas para o thread atual. A criação de pilha de sombras será útil se você precisar de informações de pilha o tempo todo durante a execução do aplicativo e se não se importar com o custo de desempenho de executar o código do criador de perfil em cada chamada gerenciada e retornar. O método DoStackSnapshot é melhor se você precisar de relatórios ligeiramente mais esparsos de pilhas, como em resposta a eventos. Mesmo um criador de perfil de amostragem tirando instantâneos de pilha a cada poucos milissegundos é muito mais esparso do que criar pilhas de sombra. Portanto , DoStackSnapshot é adequado para perfis de amostragem.

Faça um stack walk no lado selvagem

É muito útil conseguir obter pilhas de chamadas sempre que quiser. Mas com o poder vem a responsabilidade. Um usuário do criador de perfil não desejará que o stack walking resulte em uma AV (violação de acesso) ou um deadlock no runtime. Como escritor de criador de perfil, você deve exercer seu poder com cuidado. Falarei sobre como usar DoStackSnapshot e como fazer isso com cuidado. Como você verá, quanto mais você deseja fazer com esse método, mais difícil é acertar.

Vamos dar uma olhada em nosso assunto. Veja o que o criador de perfil chama (você pode encontrar isso na interface ICorProfilerInfo2 em 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); 

O código a seguir é o que o CLR chama no criador de perfil. (Você também pode encontrar isso em Corprof.idl.) Você passa um ponteiro para a implementação dessa função no parâmetro de retorno de chamada do exemplo anterior.

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

É como um sanduíche. Quando o criador de perfil quiser andar na pilha, você chama DoStackSnapshot. Antes que o CLR retorne dessa chamada, ele chama a função StackSnapshotCallback várias vezes, uma vez para cada quadro gerenciado ou para cada execução de quadros não gerenciados na pilha. A Figura 1 mostra este sanduíche.

Figura 1. Um "sanduíche" de chamadas durante a criação de perfil

Como você pode ver nas minhas notações, o CLR notifica você sobre os quadros na ordem inversa de como eles foram enviados para a pilha — quadro folha primeiro (por último), main quadro último (enviado por push primeiro).

O que todos os parâmetros para essas funções significam? Ainda não estou pronto para discutir todos eles, mas discutirei alguns deles, começando com DoStackSnapshot. (Eu vou chegar ao resto em alguns momentos.) O valor infoFlags vem da enumeração COR_PRF_SNAPSHOT_INFO em Corprof.idl e permite que você controle se o CLR fornecerá contextos de registro para os quadros que ele relata. Você pode especificar qualquer valor desejado para clientData e o CLR o devolverá em sua chamada StackSnapshotCallback .

Em StackSnapshotCallback, o CLR usa o parâmetro funcId para passar o valor FunctionID do quadro andado no momento. Esse valor será 0 se o quadro atual for uma execução de quadros não gerenciados, sobre os quais falarei mais tarde. Se funcId não for zero, você poderá passar funcId e frameInfo para outros métodos, como GetFunctionInfo2 e GetCodeInfo2, para obter mais informações sobre a função. Você pode obter essas informações de função imediatamente, durante a caminhada da pilha ou, como alternativa, salvar os valores funcId e obter as informações da função mais tarde, o que reduz o impacto no aplicativo em execução. Se você receber as informações da função mais tarde, lembre-se de que um valor frameInfo é válido somente dentro do retorno de chamada que o fornece a você. Embora não haja problema em salvar os valores funcId para uso posterior, não salve o frameInfo para uso posterior.

Quando você retornar de StackSnapshotCallback, normalmente retornará S_OK e o CLR continuará andando na pilha. Se desejar, você pode retornar S_FALSE, o que interrompe a caminhada da pilha. Sua chamada do DoStackSnapshot retornará CORPROF_E_STACKSNAPSHOT_ABORTED.

Chamadas síncronas e assíncronas

Você pode chamar DoStackSnapshot de duas maneiras, de forma síncrona e assíncrona. Uma chamada síncrona é a mais fácil de acertar. Você faz uma chamada síncrona quando o CLR chama um dos métodos ICorProfilerCallback(2) do criador de perfil e, em resposta, chama DoStackSnapshot para percorrer a pilha do thread atual. Isso é útil quando você deseja ver como é a pilha em um ponto de notificação interessante, como ObjectAllocated. Para executar uma chamada síncrona, chame DoStackSnapshot de dentro de seu método ICorProfilerCallback(2), passando zero ou nulo para os parâmetros sobre os quais não falei.

Uma caminhada de pilha assíncrona ocorre quando você anda na pilha de um thread diferente ou interrompe com força um thread para executar uma caminhada de pilha (em si mesmo ou em outro thread). Interromper um thread envolve o sequestro do ponteiro de instrução do thread para forçá-lo a executar seu próprio código em horários arbitrários. Isso é insanamente perigoso por muitas razões para listar aqui. Por favor, não faça isso. Restringirei minha descrição de caminhadas de pilha assíncronas a usos não seqüestrados de DoStackSnapshot para percorrer um thread de destino separado. Eu chamo isso de "assíncrono" porque o thread de destino estava sendo executado em um ponto arbitrário no momento em que a caminhada da pilha começa. Essa técnica é comumente usada por perfis de amostragem.

Andando por toda a outra pessoa

Vamos dividir o cross-thread, ou seja, a pilha assíncrona, ande um pouco. Você tem dois threads: o thread atual e o thread de destino. O thread atual é o thread que executa DoStackSnapshot. O thread de destino é o thread cuja pilha está sendo andada por DoStackSnapshot. Especifique o thread de destino passando sua ID de thread no parâmetro thread para DoStackSnapshot. O que acontece a seguir não é para os fracos de coração. Lembre-se de que o thread de destino estava executando código arbitrário quando você pediu para percorrer sua pilha. Portanto, o CLR suspende o thread de destino e permanece suspenso o tempo todo em que está sendo andado. Isso pode ser feito com segurança?

Que bom que você perguntou. Isso é realmente perigoso, e eu vou falar um pouco mais tarde sobre como fazer isso com segurança. Mas primeiro, vou entrar em pilhas de modo misto.

Misturando-o

É provável que um aplicativo gerenciado passe todo o tempo em código gerenciado. As chamadas PInvoke e a interoperabilidade COM permitem que o código gerenciado chame um código não gerenciado e, às vezes, volte novamente com delegados. E chamadas de código gerenciado diretamente no CLR (runtime não gerenciado) para fazer compilação JIT, manipular exceções, executar coleta de lixo e assim por diante. Portanto, quando você faz uma caminhada de pilha, provavelmente encontrará uma pilha de modo misto— alguns quadros são funções gerenciadas e outros são funções não gerenciadas.

Cresça, já!

Antes de continuar, um breve interlúdio. Todos sabem que as pilhas em nossos computadores modernos crescem (ou seja, "push") para endereços menores. Mas quando visualizamos esses endereços em nossas mentes ou em quadros de comunicações, discordamos de como classificá-los verticalmente. Alguns de nós imaginam a pilha crescendo (pequenos endereços por cima); alguns o veem crescendo ( pequenos endereços na parte inferior). Estamos divididos sobre esse problema em nossa equipe também. Eu escolho ficar do lado de qualquer depurador que eu já usei — rastreamentos de pilha de chamadas e despejos de memória me dizem que os endereços pequenos estão "acima" dos endereços grandes. Então as pilhas crescem; main estiver na parte inferior, o receptor folha está na parte superior. Se você discordar, você terá que fazer alguma reorganização mental para passar por esta parte do artigo.

Garçom, há buracos na minha pilha

Agora que estamos falando a mesma linguagem, vamos examinar uma pilha de modo misto. A Figura 2 ilustra um exemplo de pilha de modo misto.

Figura 2. Uma pilha com quadros gerenciados e não gerenciados

Recuando um pouco, vale a pena entender por que DoStackSnapshot existe em primeiro lugar. Ele está lá para ajudá-lo a percorrer quadros gerenciados na pilha. Se você mesmo tentasse percorrer quadros gerenciados, obteria resultados não confiáveis, especialmente em sistemas de 32 bits, devido a algumas convenções de chamada malucas usadas no código gerenciado. O CLR entende essas convenções de chamada e DoStackSnapshot pode, portanto, ajudá-lo a decodificá-las. No entanto, DoStackSnapshot não será uma solução completa se você quiser poder percorrer toda a pilha, incluindo quadros não gerenciados.

Aqui é onde você tem uma escolha:

Opção 1: não fazer nada e relatar pilhas com "buracos não gerenciados" para seus usuários ou ...

Opção 2: escreva seu próprio andador de pilha não gerenciado para preencher esses buracos.

Quando DoStackSnapshot se depara com um bloco de quadros não gerenciados, ele chama a função StackSnapshotCallback com funcId definido como 0, como mencionei antes. Se você estiver indo com a Opção 1, simplesmente não faça nada no retorno de chamada quando funcId for 0. O CLR chamará você novamente para o próximo quadro gerenciado e você poderá ativar nesse ponto.

Se o bloco não gerenciado consistir em mais de um quadro não gerenciado, o CLR ainda chamará StackSnapshotCallback apenas uma vez. Lembre-se de que o CLR não está fazendo nenhum esforço para decodificar o bloco não gerenciado– ele tem informações internas especiais que o ajudam a pular o bloco para o próximo quadro gerenciado e é assim que ele progride. O CLR não sabe necessariamente o que está dentro do bloco não gerenciado. Isso é para você descobrir, daí a Opção 2.

Essa primeira etapa é um Doozy

Não importa qual opção você escolher, preencher os buracos não gerenciados não é a única parte difícil. Apenas começar a caminhada pode ser um desafio. Dê uma olhada na pilha acima. Há código não gerenciado na parte superior. Às vezes, você terá sorte e o código não gerenciado será código COM ou PInvoke . Nesse caso, o CLR é inteligente o suficiente para saber como ignorá-lo e inicia a caminhada no primeiro quadro gerenciado (D no exemplo). No entanto, talvez você ainda queira percorrer o bloco mais alto não gerenciado para relatar a pilha o mais completa possível.

Mesmo que você não queira percorrer o bloco mais alto, talvez seja forçado a fazer isso de qualquer maneira— se você não tiver sorte, esse código não gerenciado não é código COM ou PInvoke , mas código auxiliar no próprio CLR, como código para fazer compilação JIT ou coleta de lixo. Se esse for o caso, o CLR não poderá encontrar o quadro D sem sua ajuda. Portanto, uma chamada semeada para DoStackSnapshot resultará no erro CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX ou CORPROF_E_STACKSNAPSHOT_UNSAFE. (A propósito, vale a pena visitar corerror.h.)

Observe que usei a palavra "semeado". DoStackSnapshot usa um contexto de semente usando os parâmetros context e contextSize . A palavra "contexto" está sobrecarregada com muitos significados. Nesse caso, estou falando de um contexto de registro. Se você perutilizar os cabeçalhos de janelas dependentes da arquitetura (por exemplo, nti386.h), encontrará uma estrutura chamada CONTEXT. Ele contém valores para os registros de CPU e representa o estado da CPU em um momento específico. Esse é o tipo de contexto que estou falando.

Se você passar nulo para o parâmetro de contexto , o passo a passo da pilha será semeado e o CLR começará na parte superior. No entanto, se você passar um valor não nulo para o parâmetro de contexto , representando o estado da CPU em algum ponto inferior na pilha (como apontar para o quadro D), o CLR executará uma propagação de caminhada de pilha com seu contexto. Ele ignora a parte superior real da pilha e começa onde quer que você aponte.

Ok, não é bem verdade. O contexto que você passa para DoStackSnapshot é mais uma dica do que uma diretiva total. Se o CLR tiver certeza de que pode encontrar o primeiro quadro gerenciado (porque o bloco mais não gerenciado é o código PInvoke ou COM), ele fará isso e ignorará sua semente. Mas não leve para o lado pessoal. O CLR está tentando ajudá-lo fornecendo o passo a passo de pilha mais preciso possível. Sua semente só será útil se o bloco mais não gerenciado for o código auxiliar no próprio CLR, pois não temos informações para nos ajudar a ignorá-la. Portanto, sua semente é usada somente quando o CLR não pode determinar por si só onde iniciar a caminhada.

Você pode se perguntar como você pode fornecer a semente para nós em primeiro lugar. Se o thread de destino ainda não estiver suspenso, você não poderá simplesmente percorrer a pilha do thread de destino para localizar o quadro D e, portanto, calcular o contexto de semente. E ainda estou dizendo para calcular seu contexto de semente fazendo sua caminhada não gerenciada antes de chamar DoStackSnapshot e, portanto, antes de DoStackSnapshot cuidar da suspensão do thread de destino para você. O thread de destino precisa ser suspenso por você e pelo CLR? Na verdade, sim.

Acho que é hora de coreografar esse balé. Mas antes de eu ficar muito profundo, observe que a questão de se e como propagar uma caminhada de pilha se aplica apenas a caminhadas assíncronas . Se você estiver fazendo uma caminhada síncrona, o DoStackSnapshot sempre poderá encontrar seu caminho para o quadro mais gerenciado sem sua ajuda— nenhuma semente necessária.

Todos Juntos Agora

Para o criador de perfil verdadeiramente aventureiro que está fazendo uma caminhada assíncrona, transversal, pilha semeada enquanto preenche os buracos não gerenciados, aqui está como seria uma caminhada de pilha. Suponha que a pilha ilustrada aqui seja a mesma pilha que você viu na Figura 2, apenas dividida um pouco.

Conteúdo da pilha Ações do Profiler e do CLR

1. Você suspende o thread de destino. (A contagem de suspensão do thread de destino agora é 1.)

2. Você obtém o contexto de registro atual do thread de destino.

3. Você determina se o contexto de registro aponta para código não gerenciado, ou seja, você chama ICorProfilerInfo2::GetFunctionFromIP e marcar se você recebe de volta um valor functionID de 0.

4. Como neste exemplo o contexto de registro aponta para código não gerenciado, você executa uma caminhada de pilha não gerenciada até encontrar o quadro mais gerenciado (Função D).

5. Você chama DoStackSnapshot com o contexto de semente e o CLR suspende o thread de destino novamente. (Sua contagem de suspensão agora é 2.) O sanduíche começa.
a. O CLR chama a função StackSnapshotCallback com o FunctionID para D.
b. O CLR chama a função StackSnapshotCallback com FunctionID igual a 0. Você deve andar por este bloco por conta própria. Você pode parar quando chegar ao primeiro quadro gerenciado. Como alternativa, você pode trapacear e atrasar sua caminhada não gerenciada até algum momento após o próximo retorno de chamada, pois o próximo retorno de chamada lhe dirá exatamente onde o próximo quadro gerenciado começa e, portanto, onde sua caminhada não gerenciada deve terminar.
c. O CLR chama a função StackSnapshotCallback com o FunctionID para C.
d. O CLR chama a função StackSnapshotCallback com o FunctionID para B.
e. O CLR chama a função StackSnapshotCallback com FunctionID igual a 0. Mais uma vez, você deve andar neste bloco por conta própria.
f. O CLR chama a função StackSnapshotCallback com o FunctionID para A.
g. O CLR chama a função StackSnapshotCallback com o FunctionID para Main.

h. Dostacksnapshot "retoma" o thread de destino chamando a API Win32 ResumeThread(), que diminui a contagem de suspensão do thread (sua contagem de suspensão agora é 1) e retorna. O sanduíche está completo.
6. Você retoma o thread de destino. Sua contagem de suspensão agora é 0, portanto, o thread é retomado fisicamente.

Esteja em seu melhor comportamento

Ok, isso é muito poder sem alguma cautela séria. No caso mais avançado, você está respondendo a interrupções de temporizador e suspendendo threads de aplicativo arbitrariamente para percorrer suas pilhas. Caramba!

Ser bom é difícil e envolve regras que não são óbvias no início. Então vamos nos aprofundar.

A Semente Ruim

Vamos começar com uma regra fácil: não use uma semente incorreta. Se o criador de perfil fornecer uma semente inválida (não nula) quando você chamar DoStackSnapshot, o CLR fornecerá resultados inválidos. Ele examinará a pilha onde você a aponta e fará suposições sobre quais valores na pilha devem representar. Isso fará com que o CLR desreferencia o que são considerados endereços na pilha. Dada uma semente inválida, o CLR desreferenciará os valores em algum lugar desconhecido na memória. O CLR faz tudo o que pode para evitar um AV de segunda chance total, o que derrubaria o processo que você está criando perfil. Mas você realmente deve fazer um esforço para acertar sua semente.

Aflições da suspensão

Outros aspectos da suspensão de threads são complicados o suficiente para exigir várias regras. Ao decidir fazer a caminhada entre threads, você decidiu, no mínimo, pedir ao CLR para suspender threads em seu nome. Além disso, se você quiser percorrer o bloco não gerenciado no topo da pilha, decidiu suspender threads sozinho sem invocar a sabedoria do CLR sobre se essa é uma boa ideia no momento.

Se você teve aulas de ciência da computação, você provavelmente se lembra do problema dos "filósofos gastronômicos". Um grupo de filósofos está sentado em uma mesa, cada um com um garfo à direita e um à esquerda. De acordo com o problema, cada um deles precisa de dois garfos para comer. Cada filósofo pega sua bifurcação direita, mas então ninguém pode pegar seu garfo esquerdo porque cada filósofo está esperando o filósofo à sua esquerda para derrubar o garfo necessário. E se os filósofos estão sentados em uma mesa circular, você tem um ciclo de espera e um monte de estômagos vazios. A razão pela qual todos eles passam fome é que eles quebram uma regra simples de prevenção de deadlock: se você precisar de vários bloqueios, sempre os tome na mesma ordem. Seguir essa regra evitaria o ciclo em que A espera em B, B espera em C e C espera em A.

Suponha que um aplicativo siga a regra e sempre use bloqueios na mesma ordem. Agora, um componente aparece (seu criador de perfil, por exemplo) e começa a suspender arbitrariamente os threads. A complexidade aumentou substancialmente. E se o suspensório agora precisar tomar um bloqueio mantido pelo suspendee? Ou e se o suspensório precisar de um bloqueio mantido por um thread que esteja aguardando um bloqueio mantido por outro thread que esteja aguardando um bloqueio mantido pelo suspensoe? A suspensão adiciona uma nova borda ao nosso thread-grafo de dependência, que pode introduzir ciclos. Vamos dar uma olhada em alguns problemas específicos.

Problema 1: o suspensoe possui bloqueios que são necessários para o suspensório ou que são necessários por threads dos quais o suspensório depende.

Problema 1a: os bloqueios são bloqueios CLR.

Como você pode imaginar, o CLR executa muita sincronização de thread e, portanto, tem vários bloqueios que são usados internamente. Quando você chama DoStackSnapshot, o CLR detecta que o thread de destino possui um bloqueio CLR que o thread atual (o thread que está chamando DoStackSnapshot) precisa para executar o passo a passo da pilha. Quando essa condição surge, o CLR se recusa a executar a suspensão e DoStackSnapshot retorna imediatamente com o erro CORPROF_E_STACKSNAPSHOT_UNSAFE. Neste ponto, se você mesmo suspendeu o thread antes da chamada para DoStackSnapshot, você mesmo retomará o thread e evitou um problema.

Problema 1b: os bloqueios são bloqueios do seu próprio criador de perfil.

Esse problema é realmente mais uma questão de senso comum. Você pode ter sua própria sincronização de threads para fazer aqui e ali. Imagine que um thread de aplicativo (Thread A) encontre um retorno de chamada do criador de perfil e execute parte do código do criador de perfil que usa um dos bloqueios do criador de perfil. Em seguida, o Thread B precisa percorrer o Thread A, o que significa que o Thread B suspenderá o Thread A. Você precisa lembrar que, enquanto o Thread A está suspenso, você não deve fazer com que o Thread B tente usar nenhum dos próprios bloqueios do criador de perfil que o Thread A possa possuir. Por exemplo, o Thread B executará StackSnapshotCallback durante a caminhada da pilha, portanto, você não deve fazer nenhum bloqueio durante esse retorno de chamada que possa pertencer ao Thread A.

Problema 2: enquanto você suspende o thread de destino, o thread de destino tenta suspendê-lo.

Você pode dizer: "Isso não pode acontecer!" Acredite ou não, ele pode, se:

  • Seu aplicativo é executado em uma caixa de vários processadores e
  • O Thread A é executado em um processador e o Thread B é executado em outro e
  • O Thread A tenta suspender o Thread B enquanto o Thread B tenta suspender o Thread A.

Nesse caso, é possível que ambas as suspensões ganhem e ambos os threads acabem suspensos. Como cada thread está esperando o outro acordá-lo, eles permanecem suspensos para sempre.

Esse problema é mais desconcertante do que o Problema 1, porque você não pode confiar no CLR para detectar antes de chamar DoStackSnapshot de que os threads serão suspensos uns aos outros. E depois de executar a suspensão, é tarde demais!

Por que o thread de destino está tentando suspender o criador de perfil? Em um criador de perfil hipotético e mal escrito, o código de movimentação de pilha, juntamente com o código de suspensão, pode ser executado por qualquer número de threads em momentos arbitrários. Imagine que o Thread A está tentando percorrer o Thread B ao mesmo tempo em que o Thread B está tentando percorrer o Thread A. Ambos tentam suspender um ao outro simultaneamente, pois ambos estão executando a parte SuspendThread da rotina de andar em pilha do criador de perfil. Tanto win quanto o aplicativo que está sendo criado com o perfil estão em deadlock. A regra aqui é óbvia: não permita que o criador de perfil execute o código de andar na pilha (e, portanto, o código de suspensão) em dois threads simultaneamente!

Um motivo menos óbvio pelo qual o thread de destino pode tentar suspender o thread de caminhada é devido ao funcionamento interno do CLR. O CLR suspende threads de aplicativo para ajudar com tarefas como coleta de lixo. Se o andador tentar andar (e, portanto, suspender) o thread que executa a coleta de lixo ao mesmo tempo em que o thread do coletor de lixo tentar suspender o andador, os processos serão travados.

Mas é fácil evitar o problema. O CLR suspende apenas os threads que precisa suspender para fazer seu trabalho. Imagine que há dois threads envolvidos em seu passo a passo da pilha. Thread W é o thread atual (o thread que executa a caminhada). Thread T é o thread de destino (o thread cuja pilha é andada). Desde que o Thread W nunca tenha executado o código gerenciado e, portanto, não esteja sujeito à coleta de lixo CLR, o CLR nunca tentará suspender o Thread W. Isso significa que é seguro para o criador de perfil fazer com que o Thread W suspenda o Thread T.

Se você estiver escrevendo um criador de perfil de amostragem, é bastante natural garantir tudo isso. Normalmente, você terá um thread separado de sua própria criação que responde a interrupções de temporizador e que percorre as pilhas de outros threads. Chame isso de thread do sampler. Como você cria o thread do sampler por conta própria e tem controle sobre o que ele executa (e, portanto, nunca executa o código gerenciado), o CLR não terá nenhum motivo para suspendê-lo. Projetar seu criador de perfil para que ele crie seu próprio thread de amostragem para fazer toda a movimentação de pilha também evita o problema do "criador de perfil mal escrito" descrito anteriormente. O thread do sampler é o único thread do criador de perfil tentando percorrer ou suspender outros threads, de modo que o criador de perfil nunca tentará suspender diretamente o thread do sampler.

Esta é nossa primeira regra nãotrivial, portanto, para dar ênfase, deixe-me repeti-la:

Regra 1: somente um thread que nunca executou o código gerenciado deve suspender outro thread.

Ninguém gosta de andar com um cadáver

Se você estiver executando uma caminhada de pilha entre threads, deverá garantir que o thread de destino permaneça ativo durante a caminhada. Só porque você passa o thread de destino como um parâmetro para a chamada DoStackSnapshot não significa que você adicionou implicitamente qualquer tipo de referência de tempo de vida a ele. O aplicativo pode fazer o thread desaparecer a qualquer momento. Se isso acontecer enquanto você estiver tentando percorrer o thread, poderá facilmente causar uma violação de acesso.

Felizmente, o CLR notifica os profilers quando um thread está prestes a ser destruído, usando o retorno de chamada ThreadDestroyed apropriadamente nomeado definido com a interface ICorProfilerCallback(2). É sua responsabilidade implementar o ThreadDestroyed e fazer com que ele aguarde até que qualquer processo que passe por esse thread seja concluído. Isso é interessante o suficiente para se qualificar como nossa próxima regra:

Regra 2: substitua o retorno de chamada ThreadDestroyed e faça com que sua implementação aguarde até terminar de percorrer a pilha do thread a ser destruído.

A Regra 2 a seguir impede que o CLR destrua o thread até que você termine de percorrer a pilha desse thread.

A coleta de lixo ajuda você a criar um ciclo

As coisas podem ficar um pouco confusas neste momento. Vamos começar com o texto da próxima regra e decifrar a partir daí:

Regra 3: não mantenha um bloqueio durante uma chamada do criador de perfil que possa disparar a coleta de lixo.

Mencionei anteriormente que é uma má ideia para o criador de perfil manter um se seus próprios bloqueios se o thread proprietário puder ser suspenso e se o thread puder ser andado por outro thread que precise do mesmo bloqueio. A regra 3 ajuda você a evitar um problema mais sutil. Aqui, estou dizendo que você não deve manter nenhum de seus próprios bloqueios se o thread proprietário estiver prestes a chamar um método ICorProfilerInfo(2) que possa disparar uma coleta de lixo.

Alguns exemplos devem ajudar. Para o primeiro exemplo, suponha que o Thread B esteja fazendo a coleta de lixo. A sequência é:

  1. O Thread A usa e agora possui um dos bloqueios do criador de perfil.
  2. O Thread B chama o retorno de chamada GarbageCollectionStarted do criador de perfil.
  3. Blocos do Thread B no bloqueio do criador de perfil da Etapa 1.
  4. O Thread A executa a função GetClassFromTokenAndTypeArgs .
  5. A chamada GetClassFromTokenAndTypeArgs tenta disparar uma coleta de lixo, mas detecta que uma coleta de lixo já está em andamento.
  6. Thread A bloqueia, aguardando a conclusão da coleta de lixo em andamento (Thread B). No entanto, o Thread B está aguardando o Thread A devido ao bloqueio do criador de perfil.

A Figura 3 ilustra o cenário neste exemplo:

Figura 3. Um deadlock entre o criador de perfil e o coletor de lixo

O segundo exemplo é um cenário ligeiramente diferente. A sequência é:

  1. O Thread A usa e agora possui um dos bloqueios do criador de perfil.
  2. O Thread B chama o retorno de chamada ModuleLoadStarted do criador de perfil.
  3. Blocos do Thread B no bloqueio do criador de perfil da Etapa 1.
  4. O Thread A executa a função GetClassFromTokenAndTypeArgs .
  5. A chamada GetClassFromTokenAndTypeArgs dispara uma coleta de lixo.
  6. O thread A (que agora está fazendo a coleta de lixo) aguarda que o Thread B esteja pronto para ser coletado. Mas o Thread B está aguardando o Thread A devido ao bloqueio do criador de perfil.
  7. A Figura 4 ilustra o segundo exemplo.

Figura 4. Um deadlock entre o criador de perfil e uma coleta de lixo pendente

Você já digeriu a loucura? O ponto crucial do problema é que a coleta de lixo tem seus próprios mecanismos de sincronização. O resultado no primeiro exemplo ocorre porque apenas uma coleta de lixo pode ocorrer por vez. Isso é reconhecidamente um caso de franja, porque as coletas de lixo geralmente não ocorrem com tanta frequência que alguém tem que esperar por outro, a menos que você esteja operando sob condições estressantes. Mesmo assim, se você criar um perfil longo o suficiente, esse cenário ocorrerá e você precisará estar preparado para isso.

O resultado no segundo exemplo ocorre porque o thread que executa a coleta de lixo deve aguardar até que os outros threads de aplicativo estejam prontos para coleta. O problema surge quando você introduz um de seus próprios bloqueios na mistura, formando assim um ciclo. Em ambos os casos, a Regra 3 é interrompida, permitindo que o Thread A possua um dos bloqueios do criador de perfil e chame GetClassFromTokenAndTypeArgs. (Na verdade, chamar qualquer método que possa disparar uma coleta de lixo é suficiente para condenar o processo.)

Você provavelmente já tem várias perguntas.

Q. Como você sabe quais métodos ICorProfilerInfo(2) podem disparar uma coleta de lixo?

a. Planejamos documentar isso no MSDN, ou pelo menos no meu blog ou no blog de Jonathan Keljo.

Q. O que isso tem a ver com andar na pilha? Não há menção de DoStackSnapshot.

a. Verdadeiro. E DoStackSnapshot nem é um desses métodos ICorProfilerInfo(2) que disparam uma coleta de lixo. A razão pela qual estou discutindo a Regra 3 aqui é que são precisamente aqueles programadores aventureiros andando de forma assíncrona de amostras arbitrárias que provavelmente implementarão seus próprios bloqueios de criador de perfil e, portanto, estarão propensos a cair nessa armadilha. De fato, a Regra 2 essencialmente informa a você para adicionar sincronização ao criador de perfil. É bem provável que um criador de perfil de amostragem também tenha outros mecanismos de sincronização, talvez para coordenar a leitura e a gravação de estruturas de dados compartilhadas em horários arbitrários. É claro que ainda é possível que um criador de perfil que nunca toque em DoStackSnapshot encontre esse problema.

Tudo tem um limite

Vou terminar com um resumo rápido dos destaques. Aqui estão os pontos importantes a serem lembrados:

  • As caminhadas de pilha síncrona envolvem a movimentação do thread atual em resposta a um retorno de chamada do criador de perfil. Elas não exigem propagação, suspensão ou regras especiais.
  • As caminhadas assíncronas exigem uma semente se a parte superior da pilha for um código não gerenciado e não parte de uma chamada PInvoke ou COM. Você fornece uma semente suspendendo diretamente o thread de destino e andando por conta própria até encontrar o quadro mais gerenciado. Se você não fornecer uma semente nesse caso, DoStackSnapshot poderá retornar um código de falha ou ignorar alguns quadros na parte superior da pilha.
  • Se você precisar suspender threads, lembre-se de que apenas um thread que nunca executou o código gerenciado deve suspender outro thread.
  • Ao executar caminhadas assíncronas, sempre substitua o retorno de chamada ThreadDestroyed para impedir que o CLR destrua um thread até que a caminhada de pilha desse thread seja concluída.
  • Não mantenha um bloqueio enquanto o criador de perfil chama uma função CLR que possa disparar uma coleta de lixo.

Para obter mais informações sobre a API de criação de perfil, consulte Criação de perfil (não gerenciada) no site do MSDN.

Crédito em que o crédito é devido

Gostaria de incluir uma nota de agradecimento ao restante da equipe da API de Criação de Perfil clr, porque escrever essas regras tem sido realmente um esforço de equipe. Agradecimentos especiais a Sean Selitrennikoff, que forneceu uma encarnação anterior de grande parte deste conteúdo.

 

Sobre o autor

David é desenvolvedor da Microsoft há mais tempo do que você imagina, dado seu conhecimento e maturidade limitados. Embora não tenha mais permissão para marcar no código, ele ainda oferece ideias para novos nomes de variáveis. David é um ávido fã do Conde Chocula e possui seu próprio carro.