Como reduzir a sobrecarga ao lidar com exceções em C ++



O processamento de erros de tempo de execução é muito importante em muitas situações que encontramos no desenvolvimento de software - desde a entrada incorreta do usuário até pacotes de rede danificados. O aplicativo não deve travar se o usuário baixou repentinamente PNG em vez de PDF ou desconectou o cabo de rede ao atualizar o software. O usuário espera que o programa funcione independentemente do que aconteça e lide com situações de emergência em segundo plano ou ofereça a ele uma opção para resolver o problema por meio de uma mensagem enviada por uma interface amigável.

O tratamento de exceções pode ser uma tarefa complexa e confusa e, que é de fundamental importância para muitos desenvolvedores de C ++, pode atrasar bastante o aplicativo. Mas, como em muitos outros casos, existem várias maneiras de resolver esse problema. Em seguida, vamos nos aprofundar no processo de tratamento de exceções em C ++, lidar com suas armadilhas e ver como isso pode afetar a velocidade do seu aplicativo. Além disso, veremos alternativas que podem ser usadas para reduzir as despesas gerais.

Neste artigo, não solicitarei que você pare de usar as exceções completamente. Eles devem ser aplicados, mas aplicados com precisão quando é impossível evitá-lo: por exemplo, como relatar um erro que ocorreu dentro do construtor? Consideraremos principalmente o uso de exceções para lidar com erros de tempo de execução. Usar as alternativas sobre as quais falaremos permitirá que você desenvolva aplicativos mais confiáveis ​​e de fácil manutenção.

Teste rápido de desempenho


Quão mais lentas são as exceções no C ++ em comparação com os mecanismos usuais para controlar o progresso de um programa?

As exceções são obviamente mais lentas que as simples operações de quebra ou retorno. Mas vamos descobrir o quanto mais lento!

No exemplo abaixo, escrevemos uma função simples que gera números aleatoriamente e, com base na verificação de um número gerado, fornece / não fornece uma mensagem de erro.

Testamos várias opções de implementação para tratamento de erros:

  1. Lance uma exceção com um argumento inteiro. Embora isso não seja particularmente aplicado na prática, é a maneira mais fácil de usar exceções no C ++. Portanto, nos livramos da complexidade excessiva na implementação de nosso teste.
  2. Jogue fora std :: runtime_error, que pode enviar uma mensagem de texto. Essa opção, diferente da anterior, é muito mais usada em projetos reais. Vamos ver se a segunda opção fornecerá um aumento tangível nos custos indiretos em comparação com a primeira.
  3. Retorno vazio.
  4. Código de erro int de retorno do estilo C

Para executar os testes, usamos a biblioteca simples de benchmark do Google . Ela repetidamente executou cada teste em um ciclo. A seguir, descreverei como tudo aconteceu. Os leitores impacientes podem pular imediatamente para os resultados .

Código de teste

Nosso super complexo gerador de números aleatórios:

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

Funções de teste:

// 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;
}

É isso, agora podemos usar a biblioteca de benchmark do 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 quem quer tocar no belo, postamos o código completo aqui .

resultados


No console, vemos diferentes resultados de teste, dependendo das opções de compilação:

Debug -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

Release -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

(Lançado em 2015, MacBook Pro 2.5GHz i7)

Os resultados são surpreendentes! Observe a enorme diferença entre a velocidade do código com e sem exceções (retorno vazio e errorCode). E com a otimização do compilador, essa lacuna é ainda maior!

Sem dúvida, este é um ótimo teste. O compilador pode estar otimizando bastante os testes 3 e 4, mas a diferença é grande de qualquer maneira. Assim, isso nos permite estimar a sobrecarga ao usar exceções.

Graças ao modelo de custo zero, na maioria das implementações de blocos de tentativa em C ++, não há sobrecarga adicional. Mas o bloqueio de captura funciona muito mais devagar. Usando nosso exemplo simples, podemos ver como as exceções de lançamento e captura podem funcionar lentamente. Mesmo em uma pilha de chamadas tão pequena! E com o aumento da profundidade da pilha, a sobrecarga aumentará linearmente. É por isso que é tão importante tentar capturar a exceção o mais próximo possível do código que a lançou.

Bem, descobrimos que as exceções funcionam lentamente. Então talvez pare com isso? Mas nem tudo é tão simples.

Por que todo mundo continua usando exceções?


Os benefícios das exceções estão bem documentados no Relatório Técnico sobre Desempenho do C ++ (capítulo 5.4) :

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

Mais uma vez: a idéia principal é a incapacidade de ignorar e esquecer a exceção. Isso faz das exceções uma ferramenta C ++ interna muito poderosa, capaz de substituir o difícil processamento por código de erro que herdamos da linguagem C.

Além disso, as exceções são muito úteis em situações que não estão diretamente relacionadas ao programa, por exemplo, “o disco rígido está cheio ”Ou“ o cabo de rede está danificado ”. Em tais situações, as exceções são ideais.

Mas qual é a melhor maneira de lidar com o tratamento de erros diretamente relacionado ao programa? De qualquer forma, precisamos de um mecanismo que indique inequivocamente ao desenvolvedor que ele deve verificar o erro, fornecer informações suficientes sobre ele (se houver), transmitindo-o na forma de uma mensagem ou em outro formato. Parece que estamos voltando novamente às exceções internas, mas agora falaremos sobre uma solução alternativa.

Esperado


Além da sobrecarga, as exceções têm outra desvantagem: elas funcionam sequencialmente: uma exceção precisa ser capturada e processada assim que é lançada, é impossível adiá-la para mais tarde.

O que podemos fazer sobre isso?

Acontece que há um caminho. O professor Andrei Alexandrescu criou uma aula especial para nós chamada Expected. Permite criar um objeto da classe T (se tudo der certo) ou um objeto da classe Exception (se ocorrer um erro). Ou seja, isso ou aquilo. Ou ou.
Em essência, esse é um invólucro da estrutura de dados, que em C ++ é chamado de união.

Portanto, ilustramos sua ideia da seguinte maneira:

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() ...
};

Uma implementação completa certamente será mais difícil. Mas a idéia principal de Esperado é que, no mesmo objeto (Esperado e), possamos receber dados para a operação regular do programa (que terá o tipo e o formato conhecidos por nós: T e valor) e dados de erro ( Exceção e ex). Portanto, é muito fácil verificar o que nos ocorreu mais uma vez (por exemplo, usando o método hasError).

No entanto, agora ninguém nos forçará a lidar com a exceção neste segundo. Não teremos uma chamada throw () ou um bloco catch. Em vez disso, podemos nos referir ao nosso objeto Exception quando for conveniente.

Teste de desempenho para o esperado


Escreveremos testes de desempenho semelhantes para nossa nova classe:

// 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 !!!

Depuração -O0:

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

Release -O2:

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

Não é ruim! No nosso std :: runtime_error sem otimização, conseguimos reduzir o tempo de operação de 1605 para 147 nanossegundos. Com a otimização, tudo fica ainda melhor: uma queda de 1261 para 57,5 ​​nanossegundos. Isso é mais de 10 vezes mais rápido que com -O0 e mais de 20 vezes mais rápido que com -O2.

Portanto, comparado às exceções internas, o Expected funciona muitas vezes mais rápido e também nos fornece um mecanismo de tratamento de erros mais flexível. Além disso, possui pureza semântica e elimina a necessidade de sacrificar mensagens de erro e privar os usuários de feedback de qualidade.

Conclusão


Exceções não são um mal absoluto. E, às vezes, é bom, uma vez que eles funcionam de maneira extremamente eficaz para o objetivo a que se destinam: em circunstâncias excepcionais. Só começamos a encontrar problemas quando os usamos, onde soluções muito mais eficazes estão disponíveis.

Nossos testes, apesar de serem bastante simples, mostraram: você pode reduzir bastante a sobrecarga se não capturar exceções (bloco de captura lento) nos casos em que é suficiente simplesmente enviar dados (usando retorno).

Neste artigo, também apresentamos brevemente a classe Expected e como podemos usá-la para acelerar o processo de tratamento de erros. O esperado facilita o rastreamento do progresso do programa e também nos permite ser mais flexíveis e enviar mensagens para usuários e desenvolvedores.


All Articles