
En mi última publicación, cubrí un error de corrección en la API fundamental de generación de perfiles de Java AsyncGetCallTrace que encontré por casualidad. Ahora la pregunta es: ¿podríamos encontrar esos errores automáticamente? Potencialmente, descubrir más errores o tener más confianza en ausencia de errores. Ya escribí código para probar la estabilidad de las API de creación de perfiles, probando la falta de errores fatales, en mi Probador de perfiles jdk proyecto. Dichas herramientas son invaluables cuando se modifica la implementación de la API o se agrega una nueva API. Esta publicación cubrirá una nueva herramienta prototípica llamada seguimiento_validación y sus conceptos fundamentales. Me concentro aquí en AsyncGetCallTrace y GetStackTrace API, pero debido a la similitud en el código, JFR debería tener propiedades de corrección similares.
La herramienta tardó mucho más en llegar a un estado utilizable; Es por eso que no escribí una publicación en el blog la semana pasada. Espero volver a estar en horario la próxima semana.
Un breve resumen de mi serie de blogs “Escribiendo un perfilador desde cero”: Ambas API devuelven el seguimiento de la pila para un subproceso determinado en un momento determinado (A llamó a B, que a su vez llamó a C, …):
La única diferencia es que AsyncGetCallTrace (ASGCT) devuelve el seguimiento de la pila en cualquier punto de la ejecución del programa y GetStackTrace (GST) solo en puntos seguros específicos, donde se define el estado de la JVM. GetStackTrace es la única API oficial para obtener seguimientos de pila, pero tiene problemas de precisión. Ambos no tienen más que unas pocas pruebas básicas en OpenJDK.
Pero, ¿cuándo se considera correcto el resultado de una API de creación de perfiles? Si coincide con la ejecución del programa.
Esto es difícil de comprobar si no modificamos la JVM. Pero es relativamente simple verificar casos de prueba pequeños donde la mayor parte del tiempo de ejecución se gasta en un solo método. Luego podemos verificar directamente en el código fuente si el seguimiento de la pila tiene sentido. Volveremos a esta respuesta pronto.
La idea básica para la automatización es comparar automáticamente las devoluciones de la API de creación de perfiles con las devoluciones de un oráculo. Sin embargo, lamentablemente aún no tenemos un oráculo para el AsyncGetCallTrace asíncrono, pero podemos crear uno al debilitar nuestra definición de corrección y desarrollar nuestro oráculo en varias etapas.
En la práctica, no necesitamos las API de creación de perfiles para devolver el resultado correcto en el 100 % de los casos y para todos los fotogramas del seguimiento. Los perfiladores típicos son perfiladores de muestreo y, por lo tanto, aproximan el resultado de todos modos. Esto hace que la definición de corrección sea más fácil de probar, ya que nos permite hacer el equilibrio entre factibilidad y precisión.
La idea ahora es construir nuestro oráculo en diferentes capas. Estamos comenzando con suposiciones básicas y escribiendo pruebas para verificar que la capa anterior probablemente también sea correcta. Esto nos lleva a nuestra prueba combinada de AsyncGetCallTrace asíncrono. Esto tiene la ventaja de que cada verificación es relativamente simple, lo cual es esencial porque todo el oráculo depende de cuánto confiemos en los supuestos básicos y las pruebas que verifican que una capa es correcta. Describo las capas y controles a continuación:
Comenzamos con la suposición más básica como nuestra capa base: se puede obtener una aproximación de los seguimientos de la pila instrumentando el código de bytes en tiempo de ejecución. La idea es insertar en cada entrada de un método el método y su clase (el marco) en una pila y abrirlo en cada salida:
class A { void methodB() { // ... } }
… se transforma en …
class A { void methodB() { trace.push("A", "methodB"); // ... trace.pop(); } }
El agente de instrumentación modifica el código de bytes en tiempo de ejecución, por lo que se registra cada salida del método. Usé el gran Asistente de Java biblioteca para el trabajo pesado. Registramos toda esta información en pilas de subprocesos locales.
Esto no captura todos los métodos, porque no podemos modificar los métodos nativos implementados en C++, pero cubre la mayoría de los métodos. Esto es lo que quise decir antes con una aproximación. Un problema con esto es el costo de la instrumentación. Podemos hacer un compromiso entre precisión y utilidad instrumentando solo algunos de los métodos.
Podemos pedirle a la estructura de datos de la pila que se aproxime al seguimiento de la pila actual en medio de cada método. Estos rastros son correctos por construcción, especialmente cuando implementamos la estructura de datos de la pila en código nativo, solo exponiendo el Trace::push
y Trace::pop
métodos. Esto limita el reordenamiento del código por parte de la JVM.
Como describí anteriormente, esta API es la API oficial para obtener los seguimientos de la pila y no se limita al recorrido básico de la pila, ya que camina solo cuando se define el estado de JVM. Por lo tanto, se podría suponer que devuelve los fotogramas correctos. Esto es lo que hice en mi publicación anterior del blog. Pero deberíamos probar esta suposición: podemos crear un nativo Trace::check
que llama a GetStackTrace y verifica que todos los marcos de Trace
están presentes y en el orden correcto. Las llamadas a este método se insertan después de la llamada a Trace::push
al comienzo de los métodos.
Por lo general, hay más marcos presentes en el retorno de GetStackTrace, pero es seguro asumir que los atributos de corrección también se mantienen aproximadamente para todo el GetStackTrace. Por supuesto, se podría verificar la corrección de GetStackTrace en diferentes partes de los métodos. Esto probablemente sea innecesario, ya que los programas comunes de Java llaman a los métodos cada pocas instrucciones de código de bytes.
Esta capa ahora nos permite obtener los marcos que consisten en la identificación del método y la ubicación en puntos seguros.
Ahora podemos usar la capa anterior y el hecho de que el resultado de ambas API tiene casi el mismo formato para comprobar que AsyncGetCallTrace devuelve el resultado correcto en puntos seguros. Ambas API deberían producir los mismos resultados allí. La verificación aquí es tan simple como llamar a ambas API en el método Trace::check y comparar sus resultados (omitiendo la información de ubicación ya que es menos estable). Esto tiene, por supuesto, las mismas advertencias que en la capa anterior, pero esto es aceptable, en mi opinión.
Si tiene curiosidad: la principal diferencia entre los marcos de ambas API es el número mágico que usan ASGCT y GST para indicar métodos nativos en el campo de ubicación.
Nuestro objetivo es convencernos de que AsyncGetCallTrace es seguro en puntos no seguros, asumiendo que AsyncGetCallTrace es seguro en puntos seguros (aquí el comienzo de los métodos). La solución consta de dos partes: la pila de seguimiento, que contiene el seguimiento de la pila actual y el bucle de muestra, que llama a AsyncGetCallTrace de forma asíncrona y compara los resultados con la pila de seguimiento.
La estructura de datos de la pila de seguimiento permite empujar y sacar los seguimientos de la pila en la entrada y salida del método. Consiste en una matriz de fotogramas grandes que contiene los fotogramas actuales: el índice 0 tiene el fotograma inferior y el índice superior contiene el fotograma superior (el orden inverso en comparación con AsyncGetCallTrace). La matriz es lo suficientemente grande, aquí 1024 entradas, para almacenar seguimientos de pila de todos los tamaños relevantes. Se aumenta con un previous
matriz que contiene el índice del marco superior del marco emisor transitivo más reciente del marco superior actual.
Suponemos aquí que el rastro de la persona que llama es un sub-rastreo del rastro actual, con solo el marco de la persona que llama que difiere en la ubicación (lineno
aquí). Esto se debe a que la ubicación del marco de la persona que llama es el comienzo del método donde obtuvimos el seguimiento. Las llamadas a otros métodos tienen ubicaciones diferentes. Por lo tanto, marcamos la ubicación del cuadro superior con un número mágico para indicar que esta información cambia durante la ejecución del método.
Esto nos permite almacenar la pila de trazas de pila de forma compacta. Creamos una estructura de datos de este tipo por subproceso en el almacenamiento local de subprocesos. Esto nos permite obtener una subtraza posiblemente completa en cada punto de la ejecución, con solo la ubicación del marco superior de la subtraza diferente. Podemos usar esto para verificar la corrección de AsyncGetCallTrace en puntos arbitrarios en el tiempo:
Creamos un bucle en un subproceso separado que envía una señal a un subproceso Java en ejecución elegido al azar y usamos el controlador de señales para llamar a AsyncGetCallTrace para el subproceso Java y obtener una copia de la pila de seguimiento actual. Luego comprobamos que el resultado es el esperado. Tenga en cuenta la sincronización.
Con esto, podemos estar razonablemente seguros de que AsyncGetCallTrace es lo suficientemente correcto cuando todas las pruebas de capa se ejecutan correctamente en un punto de referencia representativo como Renacimiento. Una implementación prototípica de todo esto es mi proyecto trace_validation: se ejecuta con el encabezado actual de OpenJDK sin ningún problema, excepto por una tasa de error del 0,003 % en la última verificación (dependiendo de la configuración, pero también con dos advertencias: la última verificación todavía tiene el problema de colgarse a veces, pero espero solucionarlo en las próximas semanas, y solo lo probé en Linux x86.
Hay otra forma posible de implementar la última verificación, que no implementé (todavía) pero que aún es interesante explorar:
También podemos basar esta capa en la parte superior de la capa GetStackTrace aprovechando el hecho de que GetStackTrace bloquea en puntos no seguros hasta que se alcanza un punto seguro y luego obtener el seguimiento de la pila (ver JBS). Al igual que con la otra variante de verificación, creamos un ciclo de muestra en un subproceso separado, elegimos un subproceso Java aleatorio, le enviamos una señal y luego llamamos a AsyncGetCallTrace en el controlador de señal. Pero inmediatamente después de enviar la señal, llamamos a GetStackTrace para obtener un seguimiento de la pila en el siguiente punto seguro. El seguimiento de la pila debe ser aproximadamente el mismo que el seguimiento de AsyncGetCallTrace, ya que el tiempo de demora entre sus llamadas es mínimo. Podemos comparar ambas trazas y así hacer una comprobación aproximada.
La ventaja es que no hacemos ninguna instrumentación con este enfoque y solo registramos los seguimientos de pila que realmente necesitamos. La principal desventaja es que es más aproximado, ya que la sincronización de AsyncGetCallTrace y GetStackTrace no es evidente y, de hecho, es específica de la implementación y la carga. Todavía no lo probé, pero podría hacerlo en el futuro porque la configuración debería ser lo suficientemente simple como para agregarlo a OpenJDK como un caso de prueba.
Le mostré en este artículo cómo podemos probar la corrección de AsyncGetCallTrace automáticamente usando un oráculo de varios niveles. La implementación difiere ligeramente y es más complicada de lo esperado debido a las peculiaridades de escribir un agente de instrumentación con un agente nativo y una biblioteca nativa.
Ahora estoy bastante seguro de que AsyncGetCallTrace es lo suficientemente correcto y espero que usted también. Por favor, pruebe el subyacente proyecto y presentar cualquier problema o sugerencia.
Esta entrada de blog es parte de mi trabajo en el máquina savia equipo en SAVIAhaciendo que la creación de perfiles sea más fácil para todos.
Calle Eloy Gonzalo, 27
Madrid, Madrid.
Código Postal 28010
Paseo de la Reforma 26
Colonia Juárez, Cuauhtémoc
Ciudad de México 06600
Real Cariari
Autopista General Cañas,
San José, SJ 40104
Av. Jorge Basadre 349
San Isidro
Lima, LIM 15073