Cómo reducir la sobrecarga al manejar excepciones en C ++



El manejo de errores en tiempo de ejecución es muy importante en muchas situaciones que encontramos al desarrollar software, desde la entrada incorrecta del usuario hasta los paquetes de red dañados. La aplicación no debería bloquearse si el usuario descarga repentinamente PNG en lugar de PDF o desconecta el cable de red al actualizar el software. El usuario espera que el programa funcione sin importar lo que ocurra y que maneje situaciones de emergencia en segundo plano u ofrezca que elija una opción para resolver el problema mediante un mensaje enviado a través de una interfaz amigable.

El manejo de excepciones puede ser una tarea compleja y confusa y, lo cual es fundamentalmente importante para muchos desarrolladores de C ++, puede ralentizar en gran medida la aplicación. Pero, como en muchos otros casos, hay varias formas de resolver este problema. A continuación, profundizaremos en el proceso de manejo de excepciones en C ++, abordaremos sus dificultades y veremos cómo esto puede afectar la velocidad de su aplicación. Además, analizaremos alternativas que pueden usarse para reducir los gastos generales.

En este artículo, no lo instaré a que deje de usar las excepciones por completo. Deben aplicarse, pero se aplican precisamente cuando es imposible evitarlo: por ejemplo, ¿cómo informar un error que ocurrió dentro del constructor? Consideraremos principalmente el uso de excepciones para manejar errores de tiempo de ejecución. Usar las alternativas de las que hablaremos le permitirá desarrollar aplicaciones más confiables y fáciles de mantener.

Prueba de rendimiento rápido


¿Cuánto más lentas son las excepciones en C ++ en comparación con los mecanismos habituales para controlar el progreso de un programa?

Las excepciones son obviamente más lentas que las simples operaciones de interrupción o devolución. ¡Pero descubramos cuánto más lento!

En el siguiente ejemplo, hemos escrito una función simple que genera números aleatoriamente y, sobre la base de verificar un número generado, da / no da un mensaje de error.

Probamos varias opciones de implementación para el manejo de errores:

  1. Lanza una excepción con un argumento entero. Aunque esto no se aplica particularmente en la práctica, es la forma más fácil de usar excepciones en C ++. Así nos deshacemos de la excesiva complejidad en la implementación de nuestra prueba.
  2. Deseche std :: runtime_error, que puede enviar un mensaje de texto. Esta opción, a diferencia de la anterior, se usa mucho más a menudo en proyectos reales. Veamos si la segunda opción dará un aumento tangible en los costos generales en comparación con la primera.
  3. Retorno vacío
  4. Devuelve el código de error int del estilo C

Para ejecutar las pruebas, utilizamos la sencilla biblioteca de referencia de Google . Repetidamente ejecutó cada prueba en un ciclo. A continuación, describiré cómo sucedió todo. Los lectores impacientes pueden saltar inmediatamente a los resultados .

Código de prueba

Nuestro generador de números aleatorios súper complejo:

const int randomRange = 2;  //     0  2.
const int errorInt = 0; 	//    ,    0.
int getRandom() {
	return random() % randomRange;
}

Funciones de prueba:

// 1.
void exitWithBasicException() {
	if (getRandom() == errorInt) {
    	throw -2;
	}
}
// 2.
void exitWithMessageException() {
	if (getRandom() == errorInt) {
    	throw std::runtime_error("Halt! Who goes there?");
	}
}
// 3.
void exitWithReturn() {
	if (getRandom() == errorInt) {
    	return;
	}
}
// 4.
int exitWithErrorCode() {
	if (getRandom() == errorInt) {
    	return -1;
	}
	return 0;
}

Eso es todo, ahora podemos usar la biblioteca de referencia de Google :

// 1.
void BM_exitWithBasicException(benchmark::State& state) {
	for (auto _ : state) {
    	try {
        	exitWithBasicException();
    	} catch (int ex) {
        	//  ,    .
    	}
	}
}
// 2.
void BM_exitWithMessageException(benchmark::State& state) {
	for (auto _ : state) {
    	try {
        	exitWithMessageException();
    	} catch (const std::runtime_error &ex) {
        	//  ,    
    	}
	}
}
// 3.
void BM_exitWithReturn(benchmark::State& state) {
	for (auto _ : state) {
    	exitWithReturn();
	}
}
// 4.
void BM_exitWithErrorCode(benchmark::State& state) {
	for (auto _ : state) {
    	auto err = exitWithErrorCode();
    	if (err < 0) {
        	// `handle_error()` …  - 
    	}
	}
}

//  
BENCHMARK(BM_exitWithBasicException);
BENCHMARK(BM_exitWithMessageException);
BENCHMARK(BM_exitWithReturn);
BENCHMARK(BM_exitWithErrorCode);

//  !
BENCHMARK_MAIN();

Para aquellos que quieren tocar lo bello, hemos publicado el código completo aquí .

resultados


En la consola, vemos diferentes resultados de prueba, dependiendo de las opciones de compilación:

Depuración -O0:

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
BM_exitWithBasicException     1407 ns         1407 ns       491232
BM_exitWithMessageException   1605 ns         1605 ns       431393
BM_exitWithReturn              142 ns          142 ns      5172121
BM_exitWithErrorCode           144 ns          143 ns      5069378

Lanzamiento -O2:

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
BM_exitWithBasicException     1092 ns         1092 ns       630165
BM_exitWithMessageException   1261 ns         1261 ns       547761
BM_exitWithReturn             10.7 ns         10.7 ns     64519697
BM_exitWithErrorCode          11.5 ns         11.5 ns     62180216

(Lanzado en 2015 MacBook Pro 2.5GHz i7) ¡

Los resultados son increíbles! Mire la gran brecha entre la velocidad del código con y sin excepciones (retorno vacío y código de error). Y con la optimización del compilador, ¡esta brecha es aún mayor!

Sin lugar a dudas, esta es una gran prueba. El compilador puede estar haciendo bastante optimización de código para las pruebas 3 y 4, pero la brecha es grande de todos modos. Por lo tanto, esto nos permite estimar los gastos generales cuando se usan excepciones.

Gracias al modelo de costo cero, en la mayoría de las implementaciones de bloques de prueba en C ++, no hay sobrecarga adicional. Pero el catch-block funciona mucho más lento. Usando nuestro sencillo ejemplo, podemos ver cuán lentamente puede funcionar lanzar y atrapar excepciones. ¡Incluso en una pila de llamadas tan pequeña! Y al aumentar la profundidad de la pila, la sobrecarga aumentará linealmente. Por eso es tan importante tratar de detectar la excepción lo más cerca posible del código que la arrojó.

Bueno, descubrimos que las excepciones funcionan lentamente. ¿Entonces tal vez lo detengas? Pero no todo es tan simple.

¿Por qué todos siguen usando excepciones?


Los beneficios de las excepciones están bien documentados en el Informe técnico sobre el rendimiento de C ++ (capítulo 5.4) :

, , errorCode- [ ], . , . , .

Una vez más: la idea principal es la incapacidad de ignorar y olvidar la excepción. Esto hace de las excepciones una herramienta C ++ incorporada muy poderosa, capaz de reemplazar el procesamiento difícil a través del código de error, que heredamos del lenguaje C.

Además, las excepciones son muy útiles en situaciones que no están directamente relacionadas con el programa, por ejemplo, "el disco duro está lleno "O" el cable de red está dañado ". En tales situaciones, las excepciones son ideales.

Pero, ¿cuál es la mejor manera de manejar el manejo de errores directamente relacionado con el programa? En cualquier caso, necesitamos un mecanismo que indique inequívocamente al desarrollador que debe verificar el error, proporcionarle información suficiente al respecto (si surgió), transmitirlo en forma de mensaje o en algún otro formato. Parece que volvemos a las excepciones integradas, pero ahora hablaremos de una solución alternativa.

Esperado


Además de los gastos generales, las excepciones tienen un inconveniente más: funcionan secuencialmente: una excepción debe detectarse y procesarse tan pronto como se produce, es imposible posponerla para más adelante.

¿Qué podemos hacer al respecto?

Resulta que hay un camino. El profesor Andrei Alexandrescu propuso una clase especial para nosotros llamada Esperada. Le permite crear un objeto de clase T (si todo va bien) o un objeto de clase Excepción (si ocurre un error). Es decir, esto o aquello. O o.
En esencia, este es un contenedor sobre la estructura de datos, que en C ++ se llama unión.

Por lo tanto, ilustramos su idea de la siguiente manera:

template <class T>
class Expected {
private:
	//  union:  ,   .     
	union {
    	T value;
    	Exception exception;
	};

public:
	//    `Expected`   T,   .
	Expected(const T& value) ...

	//    `Expected`   Exception,  -   
	Expected(const Exception& ex) ...

	//    :    
	bool hasError() ...

	//   T
	T value() ...

	//        (  Exception)
	Exception error() ...
};

Una implementación completa ciertamente será más difícil. Pero la idea principal de Expected es que en el mismo objeto (Expected & e) podemos recibir datos para el funcionamiento regular del programa (que tendrá el tipo y el formato que conocemos: T & value) y datos de error ( Excepción y ex). Por lo tanto, es muy fácil verificar lo que nos llegó una vez más (por ejemplo, usando el método hasError).

Sin embargo, ahora nadie nos obligará a manejar la excepción en este momento. No tendremos una llamada throw () o bloque de captura. En cambio, podemos referirnos a nuestro objeto Exception a nuestra conveniencia.

Prueba de rendimiento para lo esperado


Escribiremos pruebas de rendimiento similares para nuestra nueva clase:

// 5. Expected! Testcase 5 

Expected<int> exitWithExpected() {
	if (getRandom() == errorInt) {
    	return std::runtime_error("Halt! If you want...");  //  : return,   throw!
	}
	return 0;
}

// Benchmark.


void BM_exitWithExpected(benchmark::State& state) {
	for (auto _ : state) {
    	auto expected = exitWithExpected();

    	if (expected.hasError()){
        	// Handle in our own time.
    	}
    	// Or we can use the value...
    	// else {
    	// 	doSomethingInteresting(expected.value());
    	// }
	}
}

//  
BENCHMARK(BM_exitWithExpected);

// 
BENCHMARK_MAIN();

Drumroll !!!

Depurar -O0:

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
BM_exitWithExpected            147 ns          147 ns      4685942

Lanzamiento -O2:

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
BM_exitWithExpected           57.5 ns         57.5 ns     11873261

¡No está mal! Para nuestro std :: runtime_error sin optimización, pudimos reducir el tiempo de funcionamiento de 1605 a 147 nanosegundos. Con la optimización, todo se ve aún mejor: una caída de 1261 a 57.5 nanosegundos. Esto es más de 10 veces más rápido que con -O0 y más de 20 veces más rápido que con -O2.

Entonces, en comparación con las excepciones integradas, Expected funciona muchas veces más rápido y también nos brinda un mecanismo de manejo de errores más flexible. Además, tiene una pureza semántica y elimina la necesidad de sacrificar mensajes de error y privar a los usuarios de comentarios de calidad.

Conclusión


Las excepciones no son el mal absoluto. Y a veces es bueno en absoluto, ya que trabajan de manera extremadamente efectiva para el propósito previsto: en circunstancias excepcionales. Solo comenzamos a encontrar problemas cuando los usamos donde hay soluciones mucho más efectivas disponibles.

Nuestras pruebas, a pesar del hecho de que son bastante simples, mostraron: puede reducir en gran medida la sobrecarga si no detecta excepciones (bloqueo de captura lento) en los casos en que es suficiente simplemente enviar datos (mediante el retorno).

En este artículo, también presentamos brevemente la clase Esperada y cómo podemos usarla para acelerar el proceso de manejo de errores. Esperado facilita el seguimiento del progreso del programa y también nos permite ser más flexibles y enviar mensajes a los usuarios y desarrolladores.


All Articles