Inicio > Apunte, C#, C++ > El problema con try-catch: stack unwinding y problemas de rendimiento

El problema con try-catch: stack unwinding y problemas de rendimiento


Todos conocemos las ventajas de usar bloques try-catch (o try-catch-finally, para los lenguajes que lo soportan). Pero en muchas ocasiones surgen preguntas sobre el rendimiento que este sistema puede tener sobre nuestra aplicación. Y estas dudas puede que nos hagan recular al momento de encerrar un bloque de código potencialmente peligroso en un bloque try-catch. En otras palabras, ¿los bloques try-catch nos alentan la aplicación? ¿Debo, por ende, colocar estos bloques en todos lados? ¿Qué hago cuando atrapo una excepción?

Hay que comenzar a hacer este análisis desde la base misma: las excepciones. En primera instancia, hay que entender que, a nivel conceptual, las excepciones son eso: casos excepcionales. Situaciones que no deberían presentarse en un escenario normal. Esto, por supuesto, implica que uno tiene que hacer las validaciones en código correspondientes para evitar que las excepciones ocurran. Sin embargo, en algún momento de la vida van a ocurrir: se acabó la memoria de tu máquina, o se cayó la red a la mitad de una transacción de base de datos, etc. Por lo tanto, una solución robusta debe considerarlas como posibilidad. Y en consecuencia, deben ser atrapadas y manejadas convenientemente, de tal suerte que poner bloques try-catch en todos lados donde pueda surgir una excepción parece ser el camino correcto. Pero esto tiene sus matices, como veremos a en un momento.

Esto, sin embargo, puede crear preocupación sobre el hecho de que poner muchos manejadores ocasione una pérdida de rendimiento en la aplicación. Esto, afortunadamente, no es de alarmar. El poner un try-catch no supone problema alguno de sobrecarga ni rendimiento. Se pueden anidar tantos bloques como se requieran, y la pérdida de  memoria y rendimiento sería mínimo, casi nulo. El problema es cuando ocurre una excepción: ahí sí se tiene problemas de rendimiento. Me explico.

En lenguajes estructurados, procedimentales, orientados a objetos y prácticamente cualquiera que no sea ‘espagueti’ se cuenta con funciones que mandan llamar otras funciones. Cuando una función es llamada por otra, el motor de ejecución/máquina virtual/sistema operativo/lo que sea (dependiendo del lenguaje y plataforma) tiene que ubicar memoria suficiente para la función misma (una dirección en memoria), sus parámetros, las variables locales y finalmente, una variable que guarde el valor deretorno (siempre que éste no sea void -o en el caso de VB, que no sea una subrutina en lugar de una función). Al terminar la función, bien porque se llega al final o bien porque alcanza la cláusula return, la memoria asignada se destruye, liberándola. Incluso, en el caso de C++, se llaman a los destructores de aquellas variables locales que hayan sido creadas. Luego entonces, cuando llamas funciones anidadas, tienes que bloques de memoria se van asignado sobre sí: a esto se le llama pila de memoria –el último bloque en ser asignado será el primero en desasignarse. Este diseño hace que el manejo de memoria sea eficiente: no hay que mantener una tabla de referencias de las ubicaciones de memoria ni nada. Simplemente, cuando llega el momento de desasignar, de va por el último bloque de memoria y listo. A todo dar.

El problema es que la definición de excepción, o mejor dicho, de lanzamiento de una excepción, le da al traste a esto. Consideremos estos escenarios (para C++ y C#, respectivamente).

// C++

void boo() {
  try {
     foo();
  } catch (std::exception& e) {
    std::cout << e.what() << endl;
  }
}
 
void foo() {
   goo();
}
 
void goo() {
   hoo();
}
 
void hoo() {
  throw std::logic_error("Hoo!")
}
// C#

void boo() {
  try {
     foo();
  } catch (Exception e) {
    Console.WriteLine(e.Message)
  }
}
 
void foo() {
   goo();
}
 
void goo() {
   hoo();
}
 
void hoo() {
  throw new Exception("Hoo!")
}

Esto es lo que ocurre con este pedazo de código.

  1. Cuando boo se manda llamar, se crea el bloque de memoria para boo.
  2. Boo manda llamar a foo y se crea el bloque de memoria para foo.
  3. Foo manda llamar a goo y se crea el bloque de memoria para goo.
  4. Goo manda llamar a hoo y se crea el bloque de memoria para hoo.
  5. Hoo lanza una excepción, regresa a goo el control en busca de una cláusula catch.
  6. Goo regresa a foo el control en busca de una cláusula catch.
  7. Foo regresa a boo el control en busca de una cláusula catch.
  8. Boo captura la excepción pero no regresa el control, y termina la ejecución de su método en consecuencia.

Como se puede apreciar, el meollo del asunto es que se tiene que revisar la pila de llamadas para ver quién es el afortunado que tiene un bloque catch, y así regresarle el control. Esto equivale a quitar la memoria mientras se busca por el catch, en forma secuencial. Además, como no se conoce qué catch capturará la excepción, no hay mucho espacio para la optimización. A todo este relajo se le conoce como “Stack Unwinding” (ver [1]). Ahora, imaginemos este escenario modificado:

// C++

void boo() {
  try {
    foo();
  } catch (std::exception& e) {
    std::cout << e.what() << endl;
  }
}
 
void foo() {
  try {
    goo();
  } catch (std::exception& e) {
    throw logic_error("Error en goo")
  }
}
 
void goo() {
  try {
    hoo();
  } catch (std::exception& e) {
    throw logic_error("Error en hoo")
  }
}
 
void hoo() {
  throw std::logic_error("A hoo le dio patatús")
}

// C#

void boo() {
  try {
    foo();
  } catch (Exception e) {
    Console.WriteLine(e.Message);
  }
}
 
void foo() {
  try {
    goo();
  } catch (Exception e) {
    throw new Exception("Error en goo", e)
  }
}
 
void goo() {
  try {
    hoo();
  } catch (Exception e) {
    throw new Exception("Error en hoo", e)
  }
}
 
void hoo() {
  throw new Exception("A hoo le dio patatús")
}

En estos ejemplos, podemos apreciar que ocurren tres stack unwindings: hoo para goo, goo para foo, y foo para boo). Se puede ver fácilmente que tener muchos try-catch anidados que lancen excepciones suponen un costo grande de rendimiento, causado por el stack unwinding. Pero esto es así sólo cuando se lanza la excepción y por ende, se provoca el stack unwinding.

Ahora bien, dado que se supone que las excepciones son casos excepcionales, es preferible tener esa pérdida de rendimiento que solo ocurrirá en raras ocasiones, a que el programa truene miserablemente como una patata demasiado cocida. Pero si uno abusa de los try-catch, puede uno terminar con el agua hasta al coronilla: problemas serios de rendimiento.

Para contrarrestar estos efectos sin perder la fortaleza de un try-catch, Microsoft ha recomendado una serie de medidas destinadas a mejorar la práctica del manejo de excepciones (sobre todo, enfocado a su plataforma .NET).

Adicionalmente a éstas, mi modesta experiencia me hace considerar un conjunto de reglas a seguir, sencillas todas, a través de la cual podemos mejorar y robustecer nuestra aplicación. Estas reglas las expongo a continuación.

1. Nunca utilices excepciones para controlar el flujo. Úsalas solo para indicar casos excepcionales que no deberían ocurrir de forma normal. Prefiere hacer validaciones (i.e. prueba si la variable es nula, si el índice del array está dentro de rango, si las precondiciones para un objeto predeterminado se cumplen, etc.) y controla el flujo utilizando if, else, while, return… incluso el goto es preferible.

2. Cuando escribas una propiedad o método, realiza validación de las precondiciones (i. e. que los parámetros no sean nulos, que el estado interno de la clase sea válido, que el objeto no haya sido marcado como "disposable", etc.) y en dado caso que no se cumplan, lanza una excepción adecuada (ArgumentNullException, InvalidOperationException y ObjectDisposedException, respectivamente en C#; o std::invalid_argument, std::logic_error o std::runtime_error respectivamente en C++). Si una de estas excepciones se lanza en algún momento de la vida, tienes un bug (ya que por eso son precondiciones) y tienes que arreglar tu código.

3. Atrapa las excepciones sólo donde lo necesites. Si una función tiene que reaccionar ante una excepción (como cerrar un archivo, abortar una transacción, cancelar una sesión, etc.) entonces usa el catch. Si solo necesitas asegurarte que ciertas tareas se cumplan, no pongas catch y pon nada más el finally (en el caso de C#, C++ no lo soporta). Si a tu función le da igual, en el sentido de que no puede arreglar el desperfecto, no pongas un catch y déjala burbujear hasta que haya una función a la que sí le importe.

4. Coloca manejadores de errores genéricos. Si al final no hubo función alguna que pudiera tratar el error, entonces si no lo controlas tu aplicación tronará miserablemente. Y no hay nada menos profesional que el mensajito de "una excepción no controlada ha sucedido, el programa abortará su ejecución". Si estás con aplicación Windows, pon el try catch en las rutinas de alto nivel (digamos, en los manejadores de los eventos, que son los que suelen iniciar las acciones, o en los manejadores de mensajes, si estás con C++). Si estás con aplicación ASP.NET, asegúrate de contar con una página de errores personalizada y redirigir los errores a ésta a través del web.config (o sobre-escribiendo el Page.OnError). Pon un mensaje de error amigable al usuario, y guarda en algún log la información de la excepción, incluido el stack trace: puede salvarte horas al momento de hacer una depuración.

5. Relanza excepciones solo cuando sea estrictamente necesario. Si un método necesita reaccionar ante una excepción, pero no puede manejarla (i. e. no puede restaurar el estado del sistema por sí misma) pon el catch correspondiente, y después de tu código, relanza la excepción:

// C++
void foo()
{
  try {
    goo();
  } catch (std::exception& e) {
    // hacer algo con excepción
    throw;
  }
}
// C#
void foo()
{
  try {
    goo();
  } catch (Exception e) {
    // hacer algo con excepción
    throw e;
  }
}

En estos casos, causarás otro stack unwinding, pero ni modo, no queda de otra. Pero si no lo necesitas, por favor no lo pongas, o te puedes ver metido en líos de rendimiento, por lo ya explicado.

6. Nunca de los nuncas atrapes una excepción y la dejes sin atender. Es decir, nunca hagas esto:

// C++
void foo() 
{
  try {
    goo(); 
  } catch (std::exception& e) { }
}
// C#
void foo() 
{
  try {
    goo(); 
  } catch (Exception e) { }
}

Cada vez que alguien escribe código como ese un gatito muere. En verdad. Es como si al barrer guardaras el polvo debajo de la alfombra. No te deshaces del problema, solo lo ocultas. Nada más que a veces lo que uno guarda sin darse cuenta puede que no sea polvito, sino el contenido de uno de los canales de aguas negras de Chalco, llenas de pestilencia, enfermedades, gusanos y podredumbre. En serio. Por lo menos guarda el error en el Event Viewer o en un log. Creo que incluso es preferible que truene la aplicación a que no te enteres que hay algo mal.

Bueno, pues espero no haber causado más confusión. Todo lo anterior lo podemos resumir como sigue.

Concuerdo con que muchos try-catch puedan ser señal de falta de validación, pero mientras no lancen excepciones a cada rato y mientras no causen stack unwindings, no deberías tener problemas. Aunque personalmente, pienso que es mejor dejarlas burbujear siempre que sea posible, dado que no hacerlo implica que o bien estás generando muchos stack unwinds (porque si no puedes manejar el error, lo estás relanzando) o bien atrapas la excepción pero no haces nada al respecto (con lo cuál nomás te haces güey metiendo polvo debajo de la alfombra), y ambas acciones son causantes de que recibas un duro y sonoro sape.

Bueno, hasta aquí llega mi perorata. Te sugiero que leas más sobre excepciones, es un tema interesante al que se le puede sacar provecho. Busca en MSDN y en CodeProject, hay muy buenas fuentes.

Sillón leiter.

Categorías:Apunte, C#, C++ Etiquetas: , ,
  1. noviembre 4, 2010 a las 1:52 pm

    Excelente artículo, ya lo apunto para que algunos de mis compañeros refresquen conceptos :D

    Salu2 @ Madrid

  2. Ariel
    septiembre 1, 2011 a las 2:13 pm

    Excelente artículo.
    Gracias por compartirlo!!

  3. octubre 6, 2012 a las 4:36 am

    “Es como si al barrer guardaras el polvo debajo de la alfombra. No te deshaces del problema, solo lo ocultas.”

    Al igual que la diferencia entre el protocolo TCP y UDP es que el primero valida que lleguen todos los paquetes y el
    segundo no, porque solo le interesa que “siga el flujo aunque de pierda algún que otro paquete” Para ciertos tipos de aplicaciones ese catch te quita que tu programa deje de funcionar y continúe trabajando. Esto es muy necesario en casos en los que hay rutinas que se ejecutan cada X segundos para verificar datos en archivos. Si falla por alguna excepción no merece la pena hacer nada puesto que en unos segundos volverá a intentarlo. Mientras se esta depurando si activamos el log de excepciones, en producción no.

  1. No trackbacks yet.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s