martes, 24 de junio de 2014

Sobre señales y volcado de datos

Creo que para la mayoría de los programadores, la depuración es la etapa que más tiempo consume en un desarrollo. A mi me gusta modificar un poco la regla del noventa-noventa para decir que el segundo 90% corresponde a la depuración. Depurar implica probar un programa para detectar errores, identificar sus causas, plantear posibles soluciones, y aplicar el mismo proceso recursivamente sobre estas soluciones, hasta que la recursión se corte porque ya [creemos que] no hay errores.

La parte de identificar la causa puede ser muy tediosa cuando llegar al punto en que se produce o manifiesta el error lleva bastante tiempo de ejecución. Porque el procesamiento previo es costoso, porque hay que cargar muchos datos, porque depende de acciones manuales del usuario, porque no sabemos bien cuándo va a manifestarse, por lo que sea. Y a veces hay que repetirlo y repetirlo para ir acotando el problema, porque cuando vemos el error, nos damos cuenta de que nos falta información, de que tenemos que modificar un poco el código para que genere y/o muestre más resultados intermedios y volver a probar. Hoy les comento un truco que, utilizando señales, permite generar en cualquier momento, desde cualquier punto de la ejecución, un volcado de datos ad-hoc y arbitrario hacia un archivo, para analizar el estado del programa.

Vamos entonces con los contenidos previos: señales. Las señales son un mecanismo muy básico de comunicación entre procesos que provee un sistema operativo. Se le envía una señal a un hilo o un proceso, y este se interrumpe. Si el hilo/proceso había registrado un "manejador" para la señal, se lo invoca. Sino, el sistema provee un comportamiento por defecto. La señal más común en GNU/Linux es SIGTERM, la que se envía con el comando "kill" para pedirle a un proceso que finalice ya (por las buenas, en caso de negarse se lo "obliga" con la señal SIGKILL). Otras señales se generan por combinaciones de teclas especiales. En una terminal, Ctrl+C envía SIGINT para detener un proceso. También tenemos en la terminal Ctrl+Z, que envía SIGTSTP, para suspender el proceso y enviarlo a segundo plano. Otra señal que vemos más de lo que quisieramos es SIGSEGV, la que envía el kernel cuando se da cuenta que un proceso quiere acceder a un lugar de la memoria al que no debe (cuando metemos la pata usando punteros).

Ejemplo: un código C++ que registra un manejador propio para SIGTSTP. 
En cualquier momento, con Ctrl+Z veo el valor de x.

En C/C++ tenemos una función "signal" para registrar los "manejadores" (código de ejemplo en la imagen anterior). En este caso, los manejadores serán funciones (callbacks). Entonces podemos interceptar estas señales, y actuar en consecuencia. ZinjaI, por ejemplo, detecta SIGSEGV para guardar lo que sea que hay abierto al reventar, así luego puede recuperar el trabajo cuando se vuelve a iniciar. Pero el uso que le doy más habitualmente a las señales es el de generar bajo demanda del usuario (o sea, mía) información sobre el estado del programa. Digamos que tengo un programa que realiza un procesamiento muy largo y lento. En un determinado momento quiero ver por donde va, o si está avanzando o está "colgado". Entonces le envío una señal, que lo interrumpe, llama a su manejador, y luego lo deja continuar como si nada. Programo el manejador para que muestre en pantalla qué está haciendo, y si es necesario escriba algunos archivos temporales con más detalles para analizar luego. Por ejemplo, en el generador de mallas de mi tesis doctoral, con Ctrl+Z (SIGTSTP es la señal que elegí) lo obligo a guardar la malla como esté en el momento (a medio generar). Para eso simplemente declaro un puntero global a una instancia de Malla (buuu global!) y hago que el manejador use ese puntero para invocar al método de malla que guarda la malla en un archivo. Así, cuando quiero, guardo el estado actual, y lo analizo con un visualizador de mallas externo para ver en qué anda ese mallador que tanto demora.

Pero hay más. Este mecanismo me viene como anillo al dedo para mejorar mis estrategias de depuración. Me pasaba que cuando depuraba un error del mallador, llegaba a cierta parte en donde veía que algo no andaba bien, y entonces necesitaba guardar el resultado intermedio. Lo normal era cortar la depuración, agregar una linea que invoque al método que guarda la malla, y luego volver a correr el programa en el depurador. Esto, como se imaginarán, lleva su tiempo y se torna tedioso, además de llenar el código de basura que después puedo olvidarme de sacar. Pero si tengo un manejador para una señal que guarde lo que yo quiero, le puedo pedir al depurador que le envíe la señal al proceso en cualquier momento. Cuando hago esto con gdb, gdb simula los datos de la señal y continúa con la ejecución del programa, de forma que inmediatamente la reciba y pase a esta función especial. Como luego de la misma seguirá ejecutando normalmente, solo tengo que tener cuidado de poner un punto de interrupción al final, o en la linea en la que estaba pausada la ejecución antes de la señal, y eso es todo.

Así entonces puedo ejecutar una función con código arbitrario desde cualquier lugar/momento de la depuración, sin tener que reiniciarla ni agregar basura en el código que realmente quiero depurar. Si ese código depende de unas pocas variables globales, puedo controlar fácilmente su comportamiento (elegir por ejemplo qué instancia de malla guardar si hay más de una, simplemente cambiandole el valor a una variable desde la tabla de inspecciones). Hago enfasis en el "arbitrario" de "código arbitrario", porque gdb permite ejecutar ciertos métodos o funciones como parte de la evaluación de una inspección, pero tiene limitaciones. Por ejemplo, para poder llamar a un método éste no debe alocan ni liberar memoria dinámicamente. Entonces se restringe mucho su uso (igual es útil para utilizar por ejemplo una sobrecarga de un operador [] para ver el contenido de una clase Vector sin meternos en sus entrañas, pero no es este el caso).

Este truco, además de servir para esas situaciones donde cuesta tiempo o paciencia llegar al punto en que se manifiesta el error, también sirve para volcar datos con el formato que más nos convenga cuando las estructuras son complejas y por ello difíciles o tediosas de explorar solo con el panel de inspecciones. Además, mientras la señal no se usa, esto no genera ninguna sobrecarga en la ejecución. Asi, siempre estará a mano para la depuración y no tendrá absolutamente ningún efecto colateral en la ejecución normal.

Los pasos 1 y 2 (código) se realizan solo una vez, luego se utiliza ese código 
desde el depurador cada vez que se quiera con los pasos 4 y 5.

Por todo esto, agregué en el menú Depuración de ZinjaI un par de ítems para pedirle a gdb que simule señales y para configurar cómo debe comportarse ante una señal generada desde afuera (si debe pausar la depuración al recibirla, o si debe dejarla llegar al proceso depurado o filtrarla antes). El botón para enviar la señal se puede colocar en la barra de herramientas de depuración. Entonces ahora todo esto que explicaba se resume a una linea de código en el main para decir cuál es la función especial (programada completamente a gusto y configurable desde el panel de inspecciones) y un botón en la barra de herramientas que durante cualquier pausa de la depuración la ejecuta.

Como todo, hay un pero, y es que en Windows todavía no logro que esto funcione bien. La variedad de señales en Windows es muchísimo menor que la de los sistemas POSIX (practicamente todos los demás), algunas tienen canales especiales (particularmente SIGINT, que ya me ha dado dolores de cabeza en otros casos), y no he logrado que gdb actúe correctamente cambiando la configuración de las mismas. Pero no somos pocos los que trabajamos día a día sobre el sistema del pingüino, así que espero que el truco les resulte útil.

1 comentario: