jueves, 11 de diciembre de 2014

Cuando la ejecución en el depurador es mucho más leeenta

Saben que en ZinjaI (y en muchísimos otros IDEs) tenemos dos formas de ejecutar un proyecto: la "normal" y la "debug". La "normal" (Ejecución->Ejecutar o F9) guarda, compila y ejecuta el resultado como si hubiésemos hecho doble click en el exe desde el explorador de archivos. La ejecución "debug" (Depuración->Iniciar o F5), en cambio, ejecuta a través de gdb. Es decir, ejecuta gdb (el verdadero depurador que hay detrás de ZinjaI), y le pide a este que cargue y ejecute el exe. Para tener control sobre esta ejecución, gdb puede hacer internamente cosas raras, normalmente sin que lo notemos. Me dí cuenta hace poco que en Windows alguna de estas cosas raras puede hacer que el programa corra mucho más lento que en la ejecución normal. Y cuando un programa, en su ejecución normal revienta luego de unos pocos minutos de proceso, este "enlentecimiento" se torna un problema grave, porque para intentar depurarlo debemos esperar una eternidad hasta llegar al problema, cada vez que lo probamos. Ahora tengo un mecanismo interesante para resolver el problema de analizar el error con el depurador sin esperar tanto tiempo.

Mi (evidentemente insuficiente) conocimiento acerca de cómo funciona un depurador no me dice nada útil acerca de por qué en Windows un programa puede andar tanto más lento. Para poner unos números de ejemplo, un caso que tengo a mano tarda 8 segundos en finalizar en la ejecución normal, y 140 (17.5 veces) en el depurador. Algunos depuradores instrumentan de forma transparente funciones estándar, y esto es lo que los hace lentos. Por ejemplo, el de Visual Studio agrega cosas al reservar y liberar memoria dinámicamente (aunque no se si el debugger o el compilador), que luego le permiten detectar errores y hacer cierto profiling. Pero gdb y gcc, hasta donde conozco, no hacen nada de esto. Gdb simplemente debería poner el programa a correr con normalidad, interceptando solo las señales que este recibe, que es lo que usa para detener la ejecución.

Por ejemplo, al poner un punto de interrupción en una linea, va a ese lugar del ejecutable y cambia la instrucción que sea que hay por otro que genera una señal ("int3"). Entonces, hasta que no lleguemos a ese punto, no debería haber nada diferente a la ejecución normal. En GNU/Linux esto se verifica, no recuerdo haber notado nunca diferencias en la velocidad para uno y otro caso, a menos que haga cosas raras. Una cosa rara puede ser un watchpoint, o un breakpoint condicional, cosas que pueden obligar al depurador a detener el proceso frecuentemente para inspeccionar la memoria. Pero en Windows, aunque no haga nada ni defina nada, igual corre más lento. Es más, aunque lo que ejecute no tenga información de depuración, igual a veces va mucho más lento. Todavía no puedo explicar por qué, pero hoy voy a hablar de un workaround (un parche-truco) para poder depurar igual sin perder la paciencia.

En GNU/Linux hay una forma fácil de resolver esto, que consiste en activar los core-dumps. Esto hace que cuando un programa reviente (en ejecución normal, sin depurador de por medio) el sistema escupa todo lo que el programa tenía en la memoria en un archivo (llamado "core", en el directorio de ejecución), que luego puede ser cargado con gdb para analizar el estado que tenía el programa y sus variables al momento de reventar, y tratar así de descubrir qué fue lo que falló. En ZinjaI ya se puede analizar un core (Depuración->Más->Cargar vocado de memoria), y en la próxima versión tendrán una opción en las preferencias para que ZinjaI active la generación de cores automáticamente.

En Windows, la idea que propongo es la siguiente: hacer que el proceso corra fuera del depurador (es decir, ejecución "normal") hasta el punto en que revienta, y justo ahí carguemos el depurador para ver qué pasó. Entonces hay que lograr dos cosas: (primero) hacer que el proceso no se cierre cuando se produce el error, para (segundo) conectarle el depurador de alguna manera.


Esto de "conectar" el depurador a un proceso "vivo" que se lanzó por fuera, se llama "adjuntar", y está disponible en ZinjaI para GNU/Linux desde hace rato (Depuración->Más->Adjuntar...). La próxima versión lo tendrá disponible también para Windows. No lo tenía anteriormente porque lo había probado y no me había funcionado. Ahora tengo una versión actualizada de gdb, y además noté una diferencia en la salida del mismo a la hora de adjuntar un proceso entre sus versiones para Windows y GNU/Linux. No se si el "no andaba" era porque en las versiones viejas realmente gdb tenía problemas, o porque yo desde ZinjaI no había considerado las diferencias, pero en cualquier caso ahora me anda, así que será una funcionalidad usable para la próxima versión.

Lo que falta decir es cómo hacerlo en el momento justo. Tenemos que lograr "pausar" el proceso cuando se produce el error. La forma de lograrlo es instalándole un manejador de señales para las señales de error. Un manejador de señales es una función que se ejecuta cuando el proceso recibe ciertas señales (tipo callback), y se hace desde el código mediante la función signal. La señal que nos interesa es usualmente SIGSEGV (la "violación de segmento" en GNU/Linux, el "acceso ilegal" en Windows). Y es en esa función donde ponemos un código que espere:

   void my_handler(int sig_num) {
       cout << "Signal: " << sig_num << endl;
       cout << "PID: " << GetCurrentProcessId() << endl;
       cin.get();
   }
   int main() {
      signal( SIGSEGV, my_handler );
      int *p = 0; cout << *p; // KBOOM!!!

Este manejador informa de la señal, muestra el id del proceso con una función de la api de Windows (porque es lo que vamos a necesitar para decirle a gdb a quién adjuntarse), y espera una tecla. Este "esperar" es el que detiene todo justo en medio de la hecatombe para darle lugar al depurador. Entonces, podemos ejecutar el programa normalmente, y al ver el mensaje adjuntar el depurador a ese proceso. Pero hay un detalle importante a tener en cuenta: el backtrace que vamos a ver no va a decir nada útil, va a estar lleno de "??". Al adjuntar el depurador en ese momento pasan dos cosas: por un lado el proceso tiene en ese punto dos hilos, y el depurador aparece en un hilo que no es el del segfault. Si mostramos el panel de hilos (Depuración->Hilos de ejecución) y cambiamos al otro, veremos en el backtrace la llamada al manejador de la señal. Pero seguiremos sin ver cómo se llegó hasta allí. Es decir, el resto del backtrace (arriba y abajo del manejador) seguirá siendo "??". Otra vez no sé porqué ocurre esto (en GNU/Linux no me pasa), pero también es fácil de solucionar. Simplemente tratamos de continuar la ejecución (F5 otra vez). Como la función manejadora no termina el programa (debería terminar invocando a abort o exit), retorna y se vuelve al punto del error, pero como el error no está corregido, vuelve a explotar, y ahora ya tenemos a gdb listo para interceptar esta segunda explosión.

En conclusión, les presenté un atajo para depurar un segfault al que se tarda mucho en llegar por culpa del depurador, y de paso, algunas cositas mejoradas en ZinjaI para la próxima versión, que ya está muy cerca de ser publicada. Si alguien tiene alguna idea de porqué en gdb un proceso puede correr taaanto más lento puede contarnos en los comentarios, si yo lo averiguo antes les cuento en otro post.

No hay comentarios:

Publicar un comentario