Paseo por la pila del generador de perfiles en .NET Framework 2.0: Aspectos básicos y posteriores
Septiembre de 2006
David Broman
Microsoft Corporation
Se aplica a:
Microsoft .NET Framework 2.0
Common Language Runtime (CLR)
Resumen: Describe cómo puede programar el generador de perfiles para recorrer las pilas administradas en Common Language Runtime (CLR) de .NET Framework. (14 páginas impresas)
Contenido
Introducción
Llamadas sincrónicas y asincrónicas
Mezclarlo
Estar en su mejor comportamiento
Ya basta
Crédito en el que el crédito vencida
Acerca del autor
Introducción
Este artículo está dirigido a cualquier persona interesada en compilar un generador de perfiles para examinar las aplicaciones administradas. Describiré cómo puede programar el generador de perfiles para recorrer las pilas administradas en Common Language Runtime (CLR) de .NET Framework. Trataré de mantener el estado de ánimo claro, porque el tema en sí puede ser pesado en ocasiones.
La API de generación de perfiles de la versión 2.0 de CLR tiene un nuevo método denominado DoStackSnapshot que permite al generador de perfiles recorrer la pila de llamadas de la aplicación que está generando perfiles. La versión 1.1 de CLR expone una funcionalidad similar a través de la interfaz de depuración en proceso. Pero caminar la pila de llamadas es más fácil, más preciso y más estable con DoStackSnapshot. El método DoStackSnapshot usa el mismo caminador de pila usado por el recolector de elementos no utilizados, el sistema de seguridad, el sistema de excepciones, etc. Así que sabes que tiene que ser correcto.
El acceso a un seguimiento completo de la pila proporciona a los usuarios del generador de perfiles la capacidad de obtener el panorama general de lo que sucede en una aplicación cuando sucede algo interesante. Dependiendo de la aplicación y de lo que un usuario quiere generar perfiles, puede imaginar a un usuario que quiera una pila de llamadas cuando se asigna un objeto, cuando se carga una clase, cuando se produce una excepción, etc. Incluso obtener una pila de llamadas para algo distinto de un evento de aplicación (por ejemplo, un evento de temporizador) sería interesante para un generador de perfiles de muestreo. Examinar los puntos calientes en el código se vuelve más iluminado cuando puede ver quién llamó a la función que llamó a la función que llamó a la función que contiene el punto de acceso frecuente.
Me centraré en obtener seguimientos de pila con la API DoStackSnapshot . Otra manera de obtener seguimientos de pila es crear pilas de sombras: puede enlazar FunctionEnter y FunctionLeave para mantener una copia de la pila de llamadas administradas para el subproceso actual. La creación de la pila de sombras es útil si necesita información de pila en todo momento durante la ejecución de la aplicación y, si no le importa el costo de rendimiento de que el código del generador de perfiles se ejecute en cada llamada administrada y devuelva. El método DoStackSnapshot es mejor si necesita informes ligeramente dispersos de pilas, como en respuesta a eventos. Incluso un generador de perfiles de muestreo que toma instantáneas de pila cada pocos milisegundos es mucho disperso que la creación de pilas de sombras. Por lo tanto , DoStackSnapshot es adecuado para los generadores de perfiles de muestreo.
Tomar un paseo de pila en el lado salvaje
Resulta muy útil poder obtener pilas de llamadas siempre que quieras. Pero con el poder viene la responsabilidad. Un usuario del generador de perfiles no querrá que la pila pase por un resultado en una infracción de acceso (AV) o un interbloqueo en tiempo de ejecución. Como escritor del generador de perfiles, debe ejercer su poder con cuidado. Hablaré sobre cómo usar DoStackSnapshot y cómo hacerlo con cuidado. Como verá, cuanto más quiera hacer con este método, más difícil es conseguirlo.
Echemos un vistazo a nuestro tema. Esto es lo que llama el generador de perfiles (puede encontrarlo en la interfaz ICorProfilerInfo2 en 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);
El código siguiente es el que llama a CLR en el generador de perfiles. (También puede encontrar esto en Corprof.idl). Se pasa un puntero a la implementación de esta función en el parámetro de devolución de llamada del ejemplo anterior.
typedef HRESULT __stdcall StackSnapshotCallback(
FunctionID funcId,
UINT_PTR ip,
COR_PRF_FRAME_INFO frameInfo,
ULONG32 contextSize,
BYTE context[],
void *clientData);
Es como un sándwich. Cuando el generador de perfiles quiere recorrer la pila, llama a DoStackSnapshot. Antes de que CLR vuelva desde esa llamada, llama a la función StackSnapshotCallback varias veces, una vez para cada fotograma administrado o para cada ejecución de fotogramas no administrados en la pila. En la figura 1 se muestra este sándwich.
Figura 1. Un "sándwich" de llamadas durante la generación de perfiles
Como puede ver en mis notaciones, CLR le notifica los fotogramas en el orden inverso de cómo se insertaron en la pila: marco hoja primero (insertado por última vez), marco principal último (insertado primero).
¿Qué significan todos los parámetros de estas funciones? Aún no estoy listo para discutirlos, pero hablaré de algunos de ellos, empezando por DoStackSnapshot. (Llegaré al resto en unos instantes). El valor infoFlags procede de la enumeración COR_PRF_SNAPSHOT_INFO en Corprof.idl, y le permite controlar si CLR le proporcionará contextos de registro para los fotogramas que notifica. Puede especificar cualquier valor que desee para clientData y CLR lo devolverá en la llamada a StackSnapshotCallback .
En StackSnapshotCallback, CLR usa el parámetro funcId para pasar el valor functionID del marco que se ha caminado actualmente. Este valor es 0 si el marco actual es una ejecución de fotogramas no administrados, que hablaré más adelante. Si funcId no es cero, puede pasar funcId y frameInfo a otros métodos, como GetFunctionInfo2 y GetCodeInfo2, para obtener más información sobre la función. Puede obtener esta información de función inmediatamente, durante el recorrido de la pila o, como alternativa, guardar los valores de funcId y obtener la información de la función más adelante, lo que reduce el impacto en la aplicación en ejecución. Si más adelante obtiene la información de la función, recuerde que un valor frameInfo solo es válido dentro de la devolución de llamada que le proporciona. Aunque está bien guardar los valores de funcId para su uso posterior, no guarde frameInfo para su uso posterior.
Cuando vuelva de StackSnapshotCallback, normalmente devolverá S_OK y CLR seguirá caminando la pila. Si lo desea, puede devolver S_FALSE, lo que detiene el recorrido de la pila. Su llamada a DoStackSnapshot devolverá CORPROF_E_STACKSNAPSHOT_ABORTED.
Llamadas sincrónicas y asincrónicas
Puede llamar a DoStackSnapshot de dos maneras, de forma sincrónica y asincrónica. Una llamada sincrónica es la más fácil de conseguir. Realiza una llamada sincrónica cuando CLR llama a uno de los métodos ICorProfilerCallback(2) del generador de perfiles y, en respuesta, llama a DoStackSnapshot para recorrer la pila del subproceso actual. Esto es útil cuando desea ver el aspecto de la pila en un punto de notificación interesante como ObjectAllocated. Para realizar una llamada sincrónica, llame a DoStackSnapshot desde el método ICorProfilerCallback(2), pasando cero o null para los parámetros que no le he dicho.
Un recorrido de pila asincrónica se produce cuando se recorre la pila de un subproceso diferente o se interrumpe un subproceso forzado para realizar un recorrido de pila (en sí mismo o en otro subproceso). Interrumpir un subproceso implica secuestrar el puntero de instrucción del subproceso para forzar que ejecute su propio código en momentos arbitrarios. Esto es muy peligroso por demasiadas razones para enumerar aquí. Por favor, no lo hagas. Restringiré mi descripción de los recorridos de pila asincrónica a usos no secuestrados de DoStackSnapshot para caminar un subproceso de destino independiente. Lo llamo "asincrónico" porque el subproceso de destino se estaba ejecutando en un momento arbitrario en el momento en que comienza el recorrido de la pila. Esta técnica se usa normalmente por los generadores de perfiles de muestreo.
Caminar por encima de otra persona
Vamos a desglosar el subproceso cruzado( es decir, la pila asincrónica) anda un poco. Tiene dos subprocesos: el subproceso actual y el subproceso de destino. El subproceso actual es el subproceso que ejecuta DoStackSnapshot. El subproceso de destino es el subproceso cuya pila va a recorrer DoStackSnapshot. Para especificar el subproceso de destino, pase su identificador de subproceso en el parámetro thread a DoStackSnapshot. Lo que sucede a continuación no es para el desmayo del corazón. Recuerde que el subproceso de destino estaba ejecutando código arbitrario cuando se le pidió que recorrera su pila. Por lo tanto, CLR suspende el subproceso de destino y permanece suspendido todo el tiempo que se está caminando. ¿Esto se puede hacer de forma segura?
Me alegro que me lo preguntes. Esto es realmente peligroso, y hablaré más adelante sobre cómo hacerlo de forma segura. Pero en primer lugar, voy a entrar en pilas de modo mixto.
Mezclarlo
Es probable que una aplicación administrada pase todo su tiempo en código administrado. Las llamadas de PInvoke y la interoperabilidad COM permiten que el código administrado llame a código no administrado y, a veces, vuelva a usar delegados. Y el código administrado llama directamente al entorno de ejecución no administrado (CLR) para realizar la compilación JIT, controlar excepciones, realizar recolección de elementos no utilizados, etc. Por lo tanto, cuando realice un recorrido por la pila, probablemente encuentre una pila en modo mixto: algunos marcos son funciones administradas y otras son funciones no administradas.
¡Crece, ya!
Antes de continuar, un breve interludio. Todo el mundo sabe que las pilas de nuestros equipos modernos crecen (es decir, "insertar") en direcciones más pequeñas. Pero cuando visualizamos estas direcciones en nuestras mentes o en pizarras, no estamos de acuerdo con cómo ordenarlas verticalmente. Algunos de nosotros imaginan que la pila crece ( pequeñas direcciones en la parte superior); algunos ven crecer (pequeñas direcciones en la parte inferior). También estamos divididos en este problema en nuestro equipo. Opto por asociarme con cualquier depurador que haya usado: los seguimientos de pila de llamadas y los volcados de memoria me dicen que las pequeñas direcciones están "por encima" de las grandes direcciones. Así que las pilas crecen; main está en la parte inferior, el destinatario hoja está en la parte superior. Si no está de acuerdo, tendrá que hacer alguna reorganización mental para pasar por esta parte del artículo.
El camarero, hay agujeros en mi pila
Ahora que hablamos el mismo lenguaje, echemos un vistazo a una pila de modo mixto. En la figura 2 se muestra una pila de modo mixto de ejemplo.
Ilustración 2. Una pila con marcos administrados y no administrados
Retroceder un poco, vale la pena entender por qué DoStackSnapshot existe en primer lugar. Está ahí para ayudarle a recorrer marcos administrados en la pila. Si intentó recorrer los marcos administrados usted mismo, obtendría resultados poco confiables, especialmente en sistemas de 32 bits, debido a algunas convenciones de llamada wacky usadas en código administrado. CLR entiende estas convenciones de llamada y DoStackSnapshot puede ayudarle a descodificarlas. Sin embargo, DoStackSnapshot no es una solución completa si desea poder recorrer toda la pila, incluidos los fotogramas no administrados.
Aquí tiene una opción:
Opción 1: No hacer nada y pilas de informes con "agujeros no administrados" a los usuarios, o ...
Opción 2: Escriba su propio caminante de pila no administrado para rellenar esos agujeros.
Cuando DoStackSnapshot se encuentra en un bloque de fotogramas no administrados, llama a la función StackSnapshotCallback con funcId establecido
en 0, como he mencionado antes. Si va a usar la opción 1, simplemente no haga nada en la devolución de llamada cuando funcId sea 0. CLR le llamará de nuevo para el siguiente marco administrado y podrá reactivarse en ese momento.
Si el bloque no administrado consta de más de un fotograma no administrado, CLR todavía llama a StackSnapshotCallback solo una vez. Recuerde que CLR no hace ningún esfuerzo para descodificar el bloque no administrado; tiene información interna especial que le ayuda a omitir el bloque al siguiente marco administrado y así progresa. CLR no sabe necesariamente qué hay dentro del bloque no administrado. Eso es para que descubras, por lo tanto, la opción 2.
Ese primer paso es un Doozy
Independientemente de la opción que elija, rellenar los agujeros no administrados no es la única parte dura. Comenzar el paseo puede ser un desafío. Eche un vistazo a la pila anterior. Hay código no administrado en la parte superior. A veces tendrá suerte y el código no administrado será COM o PInvoke . Si es así, CLR es lo suficientemente inteligente como para saber cómo omitirlo y comienza el recorrido en el primer marco administrado (D en el ejemplo). Sin embargo, es posible que todavía quiera recorrer el bloque no administrado más alto para informar de la forma más completa posible de una pila.
Incluso si no desea recorrer el bloque superior, es posible que de todos modos se vea obligado a hacerlo, si no tiene suerte, ese código no administrado no es COM o PInvoke , pero el código auxiliar en clR en sí, como el código para compilar JIT o la recolección de elementos no utilizados. Si ese es el caso, CLR no podrá encontrar el marco D sin su ayuda. Por lo tanto, una llamada no desactivada a DoStackSnapshot producirá el error CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX o CORPROF_E_STACKSNAPSHOT_UNSAFE. (Por cierto, vale la pena visitar corerror.h).)
Observe que he usado la palabra "sin secar". DoStackSnapshot toma un contexto de inicialización mediante el contexto
y los parámetros contextSize
. La palabra "context" se sobrecarga con muchos significados. En este caso, estoy hablando de un contexto de registro. Si examina los encabezados de windows dependientes de la arquitectura (por ejemplo, nti386.h), encontrará una estructura denominada CONTEXT. Contiene valores para los registros de CPU y representa el estado de la CPU en un momento determinado en el tiempo. Ese es el tipo de contexto del que hablo.
Si pasa null para el parámetro de contexto , el recorrido de la pila se anula y clR se inicia en la parte superior. Sin embargo, si pasa un valor distinto de NULL para el parámetro de contexto , que representa el estado de CPU en algún punto inferior en la pila (como apuntar al marco D), CLR realiza una propagación de la pila con el contexto. Omite la parte superior real de la pila y comienza dondequiera que apunte.
Bien, no es cierto. El contexto que se pasa a DoStackSnapshot es más de una sugerencia que una directiva directa. Si CLR está seguro de que puede encontrar el primer fotograma administrado (porque el bloque no administrado superior es PInvoke o código COM), lo hará e ignorará la inicialización. No lo tomes personalmente, sin embargo. CLR está intentando ayudarle proporcionando el paseo de pila más preciso que puede. La inicialización solo es útil si el bloque no administrado superior es código auxiliar en el propio CLR, ya que no tenemos información para ayudarnos a omitirlo. Por lo tanto, la inicialización solo se usa cuando CLR no puede determinar por sí mismo dónde iniciar el recorrido.
Es posible que se pregunte cómo puede proporcionarnos la semilla en primer lugar. Si el subproceso de destino aún no está suspendido, no solo puede recorrer la pila del subproceso de destino para encontrar el marco D y, por tanto, calcular el contexto de inicialización. Y, sin embargo, le digo que calcule su contexto de inicialización haciendo su paseo no administrado antes de llamar a DoStackSnapshot y, por tanto, antes de que DoStackSnapshot se ocupe de suspender el subproceso de destino por usted. ¿Es necesario suspender el subproceso de destino usted y CLR? En realidad, sí.
Creo que es hora de coreografíar este ballet. Pero antes de profundizar demasiado, tenga en cuenta que el problema de si y cómo inicializar un paseo de pila solo se aplica a los paseos asincrónicos . Si está haciendo un paseo sincrónico, DoStackSnapshot siempre podrá encontrar su camino al marco más administrado sin su ayuda, sin necesidad de inicializar.
Todos juntos ahora
Para el generador de perfiles verdaderamente aventurado que está haciendo un recorrido de pila asincrónica, entre subprocesos y de inicialización mientras rellena los agujeros no administrados, este es el aspecto que tendría un paseo por la pila. Supongamos que la pila que se muestra aquí es la misma pila que vio en la figura 2, acaba de dividir un poco.
Contenido de la pila | Acciones de Profiler y CLR |
---|---|
1. Suspende el subproceso de destino. (El recuento de suspensiones del subproceso de destino ahora es 1). 2. Obtiene el contexto de registro actual del subproceso de destino. 3. Determina si el contexto de registro apunta al código no administrado, es decir, llama a ICorProfilerInfo2::GetFunctionFromIP y comprueba si devuelve un valor FunctionID de 0. 4. Dado que en este ejemplo el contexto de registro apunta al código no administrado, realiza un recorrido de pila no administrado hasta que encuentre el marco más administrado (Función D). |
|
5. Llama a DoStackSnapshot con el contexto de inicialización y CLR suspende de nuevo el subproceso de destino. (Su recuento de suspensiones ahora es 2). El sándwich comienza.
a. CLR llama a la función StackSnapshotCallback con functionID for D. |
|
b. CLR llama a la función StackSnapshotCallback con FunctionID igual a 0. Debe caminar este bloque usted mismo. Puede detenerse cuando llegue al primer marco administrado. Como alternativa, puede hacer trampas y retrasar la caminata no administrada hasta algún momento después de la siguiente devolución de llamada, ya que la siguiente devolución de llamada le indicará exactamente dónde comienza el siguiente marco administrado y, por tanto, dónde debe terminar el paseo no administrado. |
|
c. CLR llama a la función StackSnapshotCallback con functionID para C. |
|
d. CLR llama a la función StackSnapshotCallback con functionID para B. |
|
e. CLR llama a la función StackSnapshotCallback con FunctionID igual a 0. De nuevo, debe caminar este bloque usted mismo. |
|
f. CLR llama a la función StackSnapshotCallback con functionID para A. |
|
g. CLR llama a la función StackSnapshotCallback con functionID for Main. |
|
6. Reanuda el subproceso de destino. Su recuento de suspensiones ahora es 0, por lo que el subproceso se reanuda físicamente. |
Estar en su mejor comportamiento
Ok, esto es demasiado poder sin precaución seria. En el caso más avanzado, responde a interrupciones del temporizador y suspende los subprocesos de aplicación arbitrariamente para recorrer sus pilas. ¡Yikes!
Ser bueno es difícil e implica reglas que no son obvias al principio. Así que vamos a profundizar.
La inicialización incorrecta
Comencemos con una regla sencilla: no use una inicialización incorrecta. Si el generador de perfiles proporciona una inicialización no válida (no nula) al llamar a DoStackSnapshot, CLR le dará resultados incorrectos. Observará la pila donde se apunta y realizará suposiciones sobre lo que se supone que representan los valores de la pila. Esto hará que CLR desreferenciar lo que se supone que son direcciones en la pila. Dado un valor de inicialización incorrecto, CLR desreferenciará los valores en algún lugar desconocido en la memoria. CLR hace todo lo posible para evitar un AV de segunda oportunidad, lo que anularía el proceso de generación de perfiles. Pero realmente deberías hacer un esfuerzo para conseguir tu inicialización correcta.
Problemas de suspensión
Otros aspectos de la suspensión de subprocesos son lo suficientemente complicados como para requerir varias reglas. Cuando decide realizar la caminata entre subprocesos, ha decidido como mínimo pedir al CLR que suspenda los subprocesos en su nombre. Además, si desea recorrer el bloque no administrado en la parte superior de la pila, ha decidido suspender los subprocesos por sí mismo sin invocar la sabiduría del CLR sobre si es una buena idea en este momento.
Si tomó clases de informática, probablemente recuerde el problema de los "filósofos comedores". Un grupo de filósofos está sentado en una mesa, cada uno con una bifurcación a la derecha y otra a la izquierda. Según el problema, cada uno necesita dos bifurcaciones para comer. Cada filósofo recoge su bifurcación derecha, pero entonces nadie puede recoger su bifurcación izquierda porque cada filósofo está esperando a que el filósofo a su izquierda ponga la bifurcación necesaria. Y si los filósofos están sentados en una mesa circular, tienes un ciclo de espera y muchos estómagos vacíos. La razón por la que todos aparecen es que rompen una regla simple de prevención de interbloqueos: si necesita varios bloqueos, siempre los tome en el mismo orden. Siguiendo esta regla, se evitaría el ciclo en el que A espera en B, B espera en C y C espera en A.
Supongamos que una aplicación sigue la regla y siempre toma bloqueos en el mismo orden. Ahora se incluye un componente (por ejemplo, el generador de perfiles) y se inicia la suspensión arbitraria de subprocesos. La complejidad ha aumentado sustancialmente. ¿Qué ocurre si el suspender ahora necesita tomar un bloqueo retenido por el suspendido? O bien, ¿qué ocurre si el suspender necesita un bloqueo mantenido por un subproceso que espera un bloqueo mantenido por otro subproceso que espera un bloqueo mantenido por el suspendido? La suspensión agrega un nuevo borde a nuestro gráfico de dependencias de subprocesos, que puede introducir ciclos. Echemos un vistazo a algunos problemas específicos.
Problema 1: el suspendido posee los bloqueos que necesita el suspender o que necesitan los subprocesos de los que depende el suspender.
Problema 1a: Los bloqueos son bloqueos CLR.
Como puede imaginar, CLR realiza una gran cantidad de sincronización de subprocesos y, por tanto, tiene varios bloqueos que se usan internamente. Al llamar a DoStackSnapshot, CLR detecta que el subproceso de destino posee un bloqueo CLR que necesita el subproceso actual (el subproceso que llama a DoStackSnapshot) para realizar el recorrido de la pila. Cuando surge esa condición, CLR se niega a realizar la suspensión y DoStackSnapshot vuelve inmediatamente con el error CORPROF_E_STACKSNAPSHOT_UNSAFE. En este momento, si ha suspendido el subproceso usted mismo antes de la llamada a DoStackSnapshot, reanudará el subproceso usted mismo y ha evitado un problema.
Problema 1b: los bloqueos son los bloqueos de su propio generador de perfiles.
Este problema es realmente más un problema de sentido común. Es posible que tenga su propia sincronización de subprocesos para hacerlo aquí y allí. Imagine que un subproceso de aplicación (subproceso A) encuentra una devolución de llamada del generador de perfiles y ejecuta parte del código del generador de perfiles que toma uno de los bloqueos del generador de perfiles. A continuación, el subproceso B debe recorrer el subproceso A, lo que significa que el subproceso B suspenderá el subproceso A. Debe recordar que, mientras se suspende el subproceso A, no debe tener el subproceso B intentando tomar ninguno de los bloqueos propios del generador de perfiles que el subproceso A podría poseer. Por ejemplo, el subproceso B ejecutará StackSnapshotCallback durante el recorrido de la pila, por lo que no debe tomar ningún bloqueo durante esa devolución de llamada que podría ser propiedad del subproceso A.
Problema 2: mientras suspende el subproceso de destino, el subproceso de destino intenta suspenderlo.
Podrías decir: "¡Eso no puede suceder!" Créelo o no, puede, si:
- La aplicación se ejecuta en un cuadro de varios procesadores y
- El subproceso A se ejecuta en un procesador y el subproceso B se ejecuta en otro, y
- El subproceso A intenta suspender el subproceso B mientras el subproceso B intenta suspender el subproceso A.
En ese caso, es posible que ambas suspensiones ganen y ambos subprocesos terminen suspendidos. Dado que cada subproceso está esperando a que el otro lo despierte, permanecen suspendidos para siempre.
Este problema es más desconcertante que el problema 1, ya que no puede confiar en CLR para detectar antes de llamar a DoStackSnapshot que los subprocesos se suspenderán entre sí. ¡Y después de haber realizado la suspensión, es demasiado tarde!
¿Por qué el subproceso de destino intenta suspender el generador de perfiles? En un generador de perfiles hipotético y mal escrito, el código de paseo por la pila, junto con el código de suspensión, podría ejecutarse por cualquier número de subprocesos en momentos arbitrarios. Imagine que el subproceso A está intentando recorrer el subproceso B al mismo tiempo que el subproceso B está intentando recorrer el subproceso A. Ambos intentan suspenderse simultáneamente, ya que ambos ejecutan la parte SuspendThread de la rutina apilada del generador de perfiles. Tanto win como la aplicación que se está generando el perfil se interbloquean. La regla aquí es obvia: no permita que el generador de perfiles ejecute código de paseo por la pila (y, por tanto, código de suspensión) en dos subprocesos simultáneamente.
Una razón menos obvia por la que el subproceso de destino podría intentar suspender el subproceso andando se debe al funcionamiento interno de CLR. CLR suspende los subprocesos de aplicación para ayudar con tareas como la recolección de elementos no utilizados. Si el caminante intenta caminar (y, por tanto, suspender) el subproceso que realiza la recolección de elementos no utilizados al mismo tiempo que el subproceso del recolector de elementos no utilizados intenta suspender el caminante, los procesos se interbloquearán.
Pero es fácil evitar el problema. CLR suspende solo los subprocesos que necesita suspender para realizar su trabajo. Imagine que hay dos subprocesos implicados en el recorrido de la pila. El subproceso W es el subproceso actual (el subproceso que realiza el recorrido). El subproceso T es el subproceso de destino (el subproceso cuya pila se recorre). Siempre que el subproceso W nunca haya ejecutado código administrado y, por tanto, no esté sujeto a la recolección de elementos no utilizados de CLR, CLR nunca intentará suspender el subproceso W. Esto significa que es seguro que el generador de perfiles tenga thread W suspend thread T.
Si está escribiendo un generador de perfiles de muestreo, es bastante natural asegurarse de todo esto. Normalmente, tendrá un subproceso independiente de su propia creación que responde a interrupciones del temporizador y que recorre las pilas de otros subprocesos. Llame a este subproceso del sampler. Dado que crea el subproceso de sampler usted mismo y tiene control sobre lo que ejecuta (y, por lo tanto, nunca ejecuta código administrado), CLR no tendrá ningún motivo para suspenderlo. Diseñar el generador de perfiles para que cree su propio subproceso de muestreo para realizar todo el recorrido de la pila también evita el problema del "generador de perfiles mal escrito" descrito anteriormente. El subproceso de sampler es el único subproceso del generador de perfiles que intenta recorrer o suspender otros subprocesos, por lo que el generador de perfiles nunca intentará suspender directamente el subproceso del sampler.
Esta es nuestra primera regla notrivial, por lo que, por énfasis, déjeme repetirla:
Regla 1: solo un subproceso que nunca ha ejecutado código administrado debe suspender otro subproceso.
A nadie le gusta caminar un cadáver
Si va a realizar un recorrido de pila entre subprocesos, debe asegurarse de que el subproceso de destino permanece activo mientras dure el recorrido. Solo porque pasa el subproceso de destino como un parámetro a la llamada DoStackSnapshot no significa que haya agregado implícitamente cualquier tipo de referencia de duración a él. La aplicación puede hacer que el subproceso desaparezca en cualquier momento. Si esto sucede mientras intenta recorrer el subproceso, podría provocar fácilmente una infracción de acceso.
Afortunadamente, CLR notifica a los generadores de perfiles cuando un subproceso está a punto de destruirse, usando la devolución de llamada subproceso denominada ThreadDestroyed definida con la interfaz ICorProfilerCallback(2). Es su responsabilidad implementar ThreadDestroyed y hacer que espere hasta que finalice cualquier proceso que pase por ese subproceso. Esto es lo suficientemente interesante como para calificar como nuestra regla siguiente:
Regla 2: Invalide la devolución de llamada ThreadDestroyed y haga que la implementación espere hasta que haya terminado de recorrer la pila del subproceso que se va a destruir.
La siguiente regla 2 impide que CLR destruya el subproceso hasta que haya terminado de recorrer la pila de ese subproceso.
La recolección de elementos no utilizados le ayuda a crear un ciclo
Las cosas pueden resultar un poco confusas en este momento. Comencemos con el texto de la siguiente regla y lo desciframos desde allí:
Regla 3: No mantenga un bloqueo durante una llamada del generador de perfiles que pueda desencadenar la recolección de elementos no utilizados.
Mencioné anteriormente que es una mala idea para que el generador de perfiles contenga uno si sus propios bloqueos si el subproceso propietario podría suspenderse y si el subproceso podría ser guiado por otro subproceso que necesita el mismo bloqueo. La regla 3 le ayuda a evitar un problema más sutil. Aquí, digo que no debe contener ninguno de sus propios bloqueos si el subproceso propietario está a punto de llamar a un método ICorProfilerInfo(2) que podría desencadenar una recolección de elementos no utilizados.
Un par de ejemplos deben ayudar. En el primer ejemplo, supongamos que el subproceso B está realizando la recolección de elementos no utilizados. La secuencia es:
- El subproceso A toma y ahora posee uno de los bloqueos del generador de perfiles.
- El subproceso B llama a la devolución de llamada GarbageCollectionStarted del generador de perfiles.
- El subproceso B se bloquea en el bloqueo del generador de perfiles del paso 1.
- El subproceso A ejecuta la función GetClassFromTokenAndTypeArgs .
- La llamada GetClassFromTokenAndTypeArgs intenta desencadenar una recolección de elementos no utilizados, pero detecta que una recolección de elementos no utilizados ya está en curso.
- El subproceso A se bloquea, esperando a que se complete la recolección de elementos no utilizados actualmente en curso (subproceso B). Sin embargo, el subproceso B está esperando el subproceso A debido al bloqueo del generador de perfiles.
En la figura 3 se muestra el escenario de este ejemplo:
Figura 3. Un interbloqueo entre el generador de perfiles y el recolector de elementos no utilizados
El segundo ejemplo es un escenario ligeramente diferente. La secuencia es:
- El subproceso A toma y ahora posee uno de los bloqueos del generador de perfiles.
- El subproceso B llama a la devolución de llamada ModuleLoadStarted del generador de perfiles.
- El subproceso B se bloquea en el bloqueo del generador de perfiles del paso 1.
- El subproceso A ejecuta la función GetClassFromTokenAndTypeArgs .
- La llamada GetClassFromTokenAndTypeArgs desencadena una recolección de elementos no utilizados.
- El subproceso A (que ahora realiza la recolección de elementos no utilizados) espera a que el subproceso B esté listo para recopilarse. Pero el subproceso B está esperando el subproceso A debido al bloqueo del generador de perfiles.
- En la figura 4 se muestra el segundo ejemplo.
Figura 4. Un interbloqueo entre el generador de perfiles y una recolección de elementos no utilizados pendiente
¿Has digerido la locura? El problema es que la recolección de elementos no utilizados tiene sus propios mecanismos de sincronización. El resultado del primer ejemplo se produce porque solo se puede producir una recolección de elementos no utilizados a la vez. Esto es ciertamente un caso de fleco, porque las recolecciones de elementos no utilizados generalmente no se producen tan a menudo que uno tiene que esperar a otro, a menos que esté trabajando bajo condiciones estresantes. Incluso así, si perfila lo suficiente para este escenario y debe estar preparado para él.
El resultado en el segundo ejemplo se produce porque el subproceso que realiza la recolección de elementos no utilizados debe esperar a que los demás subprocesos de aplicación estén listos para la recolección. El problema surge cuando introduce uno de sus propios bloqueos en la mezcla, formando así un ciclo. En ambos casos, la regla 3 se interrumpe al permitir que el subproceso A posea uno de los bloqueos del generador de perfiles y, a continuación, llame a GetClassFromTokenAndTypeArgs. (En realidad, llamar a cualquier método que pueda desencadenar una recolección de elementos no utilizados es suficiente para acabar con el proceso).
Probablemente tengas varias preguntas por ahora.
Q. ¿Cómo sabe qué métodos ICorProfilerInfo(2) podrían desencadenar una recolección de elementos no utilizados?
A. Tenemos previsto documentarlo en MSDN, o al menos en mi blog o en el blog de Jonathan Keljo.
Q. ¿Qué tiene que ver esto con la pila caminando? No hay ninguna mención de DoStackSnapshot.
A. Verdadero. Y DoStackSnapshot no es ni uno de esos métodos ICorProfilerInfo(2) que desencadenan una recolección de elementos no utilizados. La razón por la que estoy discutiendo la Regla 3 aquí es que precisamente esos programadores aventureros caminan asincrónicamente pilas de muestras arbitrarias que probablemente implementarán sus propios bloqueos de generador de perfiles y, por lo tanto, ser propensos a caer en esta trampa. De hecho, la regla 2 básicamente le indica que agregue sincronización al generador de perfiles. Es muy probable que un generador de perfiles de muestreo también tenga otros mecanismos de sincronización, quizás coordinar la lectura y escritura de estructuras de datos compartidas en momentos arbitrarios. Por supuesto, todavía es posible que un generador de perfiles nunca toque DoStackSnapshot para encontrar este problema.
Ya basta
Voy a terminar con un resumen rápido de los aspectos destacados. Estos son los puntos importantes que hay que recordar:
- Los recorridos sincrónicos de la pila implican caminar el subproceso actual en respuesta a una devolución de llamada del generador de perfiles. No requieren propagación, suspensión ni reglas especiales.
- Los recorridos asincrónicos requieren un valor de inicialización si la parte superior de la pila es código no administrado y no forma parte de una llamada PInvoke o COM. Proporcione un valor de inicialización suspendiendo directamente el subproceso de destino y caminando usted mismo hasta que encuentre el marco más administrado de la parte superior. Si no proporciona un valor de inicialización en este caso, DoStackSnapshot puede devolver un código de error o omitir algunos fotogramas en la parte superior de la pila.
- Si necesita suspender subprocesos, recuerde que solo un subproceso que nunca ha ejecutado código administrado debe suspender otro subproceso.
- Al realizar recorridos asincrónicos, invalide siempre la devolución de llamada ThreadDestroyed para impedir que CLR destruya un subproceso hasta que se complete el recorrido de pila de ese subproceso.
- No mantenga un bloqueo mientras el generador de perfiles llame a una función CLR que pueda desencadenar una recolección de elementos no utilizados.
Para obtener más información sobre la API de generación de perfiles, vea Generación de perfiles (no administrada) en el sitio web de MSDN.
Crédito en el que el crédito se debe
Me gustaría incluir una nota de gracias al resto del equipo de la API de generación de perfiles de CLR, ya que escribir estas reglas ha sido realmente un esfuerzo de equipo. Gracias especial a Sean Selitrennikoff, quien proporcionó una encarnación anterior de gran parte de este contenido.
Acerca del autor
David ha sido desarrollador en Microsoft por más tiempo de lo que pensaría, dado su conocimiento y madurez limitados. Aunque ya no se permite proteger el código, todavía ofrece ideas para nuevos nombres de variables. David es un vidiente fan de Count Chocula y posee su propio auto.