Comment réduire la surcharge lors de la gestion des exceptions en C ++



La gestion des erreurs d'exécution est très importante dans de nombreuses situations que nous rencontrons lors du développement de logiciels - d'une entrée utilisateur incorrecte à des paquets réseau endommagés. L'application ne doit pas se bloquer si l'utilisateur a soudainement téléchargé PNG au lieu de PDF, ou déconnecté le câble réseau lors de la mise à jour du logiciel. L'utilisateur s'attend à ce que le programme fonctionne quoi qu'il arrive et soit gère les situations d'urgence en arrière-plan soit lui propose de choisir une option pour résoudre le problème au moyen d'un message envoyé via une interface conviviale.

La gestion des exceptions peut être une tâche complexe et déroutante et, ce qui est fondamentalement important pour de nombreux développeurs C ++, elle peut considérablement ralentir l'application. Mais, comme dans de nombreux autres cas, il existe plusieurs façons de résoudre ce problème. Ensuite, nous allons explorer le processus de gestion des exceptions en C ++, traiter ses pièges et voir comment cela peut affecter la vitesse de votre application. De plus, nous examinerons les alternatives pouvant être utilisées pour réduire les frais généraux.

Dans cet article, je ne vous exhorte pas à cesser d'utiliser complètement les exceptions. Ils doivent être appliqués, mais appliqués précisément lorsqu'il est impossible de l'éviter: par exemple, comment signaler une erreur survenue à l'intérieur du constructeur? Nous considérerons principalement l'utilisation d'exceptions pour gérer les erreurs d'exécution. L'utilisation des alternatives dont nous parlerons vous permettra de développer des applications plus fiables et facilement maintenables.

Test de performance rapide


Dans quelle mesure les exceptions en C ++ sont-elles plus lentes par rapport aux mécanismes habituels de contrôle de la progression d'un programme?

Les exceptions sont évidemment plus lentes que les simples opérations d'interruption ou de retour. Mais découvrons combien plus lentement!

Dans l'exemple ci-dessous, nous avons écrit une fonction simple qui génère aléatoirement des nombres et, sur la base de la vérification d'un nombre généré, donne / ne donne pas de message d'erreur.

Nous avons testé plusieurs options de mise en œuvre pour la gestion des erreurs:

  1. Lancez une exception avec un argument entier. Bien que cela ne soit pas particulièrement appliqué dans la pratique, c'est le moyen le plus simple d'utiliser les exceptions en C ++. On se débarrasse donc d'une complexité excessive dans la mise en œuvre de notre test.
  2. Jetez std :: runtime_error, qui peut envoyer un message texte. Cette option, contrairement à la précédente, est beaucoup plus souvent utilisée dans les projets réels. Voyons si la deuxième option entraînera une augmentation tangible des frais généraux par rapport à la première.
  3. Retour vide.
  4. Retourne le code d'erreur int de style C

Pour exécuter les tests, nous avons utilisé la bibliothèque de référence Google simple . Elle a effectué à plusieurs reprises chaque test dans un cycle. Ensuite, je vais décrire comment tout s'est passé. Les lecteurs impatients peuvent immédiatement accéder aux résultats .

Code de test

Notre générateur de nombres aléatoires super complexe:

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

Fonctions de test:

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

Voilà, maintenant nous pouvons utiliser la bibliothèque de référence 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();

Pour ceux qui veulent toucher le beau, nous avons affiché le code complet ici .

résultats


Dans la console, nous voyons différents résultats de test, selon les options de compilation:

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

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

(Lancé sur MacBook Pro 2015 2,5 GHz i7)

Les résultats sont incroyables! Regardez l'énorme écart entre la vitesse du code avec et sans exceptions (retour vide et errorCode). Et avec l'optimisation du compilateur, cet écart est encore plus grand!

Sans aucun doute, c'est un excellent test. Le compilateur fait peut-être un peu d'optimisation de code pour les tests 3 et 4, mais l'écart est quand même important. Ainsi, cela nous permet d'estimer les frais généraux lors de l'utilisation d'exceptions.

Grâce au modèle à coût nul, dans la plupart des implémentations de blocs try en C ++, il n'y a pas de surcharge supplémentaire. Mais le bloc d'arrêt fonctionne beaucoup plus lentement. En utilisant notre exemple simple, nous pouvons voir à quel point lancer et intercepter des exceptions peut fonctionner lentement. Même sur une si petite pile d'appels! Et avec l'augmentation de la profondeur de la pile, les frais généraux augmenteront linéairement. C'est pourquoi il est si important d'essayer d'attraper l'exception le plus près possible du code qui l'a déclenchée.

Eh bien, nous avons découvert que les exceptions fonctionnent lentement. Alors peut-être l'arrêter? Mais tout n'est pas si simple.

Pourquoi tout le monde utilise-t-il des exceptions?


Les avantages des exceptions sont bien documentés dans le rapport technique sur les performances C ++ (chapitre 5.4) :

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

Encore une fois: l'idée principale est l'incapacité d'ignorer et d'oublier l'exception. Cela fait des exceptions un outil C ++ intégré très puissant, capable de remplacer le traitement difficile par le code d'erreur, que nous avons hérité du langage C.

De plus, les exceptions sont très utiles dans des situations qui ne sont pas directement liées au programme, par exemple, «le disque dur est plein "Ou" le câble réseau est endommagé. " Dans de telles situations, les exceptions sont idéales.

Mais quelle est la meilleure façon de traiter la gestion des erreurs directement liée au programme? Dans tous les cas, nous avons besoin d'un mécanisme qui indiquerait sans ambiguïté au développeur qu'il devrait vérifier l'erreur, lui fournir suffisamment d'informations à ce sujet (le cas échéant), la transmettre sous forme de message ou dans un autre format. Il semble que nous revenions à nouveau aux exceptions intégrées, mais tout à l'heure, nous parlerons d'une solution alternative.

Attendu


En plus des frais généraux, les exceptions ont un autre inconvénient: elles fonctionnent séquentiellement: une exception doit être interceptée et traitée dès qu'elle est levée, il est impossible de la reporter pour plus tard.

Que pouvons-nous y faire?

Il s'avère qu'il existe un moyen. Le professeur Andrei Alexandrescu a créé pour nous une classe spéciale appelée Expected. Il vous permet de créer un objet de classe T (si tout se passe bien) ou un objet de classe Exception (si une erreur se produit). C'est-à-dire ceci ou cela. Ou ou.
En substance, il s'agit d'un wrapper sur la structure de données, qui en C ++ est appelée union.

Par conséquent, nous avons illustré son idée comme suit:

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

Une mise en œuvre complète sera certainement plus difficile. Mais l'idée principale d'Expected est que dans le même objet (Expected & e), nous pouvons recevoir des données pour le fonctionnement normal du programme (qui auront le type et le format connus de nous: T & value) et des données d'erreur ( Exception & ex). Par conséquent, il est très facile de vérifier à nouveau ce qui nous est arrivé (par exemple, en utilisant la méthode hasError).

Cependant, maintenant personne ne nous forcera à gérer l'exception dès cette seconde. Nous n'aurons pas d'appel throw () ni de bloc catch. Au lieu de cela, nous pouvons nous référer à notre objet Exception à notre convenance.

Test de performance pour prévu


Nous écrirons des tests de performances similaires pour notre nouvelle 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();

Roulement de tambour!!!

Débogage -O0:

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

Version -O2:

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

Pas mal! Pour notre std :: runtime_error sans optimisation, nous avons pu réduire le temps de fonctionnement de 1605 à 147 nanosecondes. Avec l'optimisation, tout semble encore mieux: une baisse de 1261 à 57,5 ​​nanosecondes. C'est plus de 10 fois plus rapide qu'avec -O0 et plus de 20 fois plus rapide qu'avec -O2.

Par conséquent, par rapport aux exceptions intégrées, Expected fonctionne beaucoup plus rapidement et nous offre également un mécanisme de gestion des erreurs plus flexible. De plus, il a une pureté sémantique et élimine la nécessité de sacrifier les messages d'erreur et de priver les utilisateurs de commentaires de qualité.

Conclusion


Les exceptions ne sont pas un mal absolu. Et parfois, c'est bon du tout, car ils fonctionnent extrêmement efficacement pour leur destination: dans des circonstances exceptionnelles. Nous ne commençons à rencontrer des problèmes que lorsque nous les utilisons lorsque des solutions beaucoup plus efficaces sont disponibles.

Nos tests, bien qu'ils soient assez simples, ont montré: vous pouvez réduire considérablement les frais généraux si vous n'attrapez pas d'exceptions (catch-block lent) dans les cas où il suffit d'envoyer simplement des données (en utilisant return).

Dans cet article, nous avons également présenté brièvement la classe attendue et comment nous pouvons l'utiliser pour accélérer le processus de gestion des erreurs. Expected facilite le suivi de la progression du programme et nous permet également d'être plus flexibles et d'envoyer des messages aux utilisateurs et aux développeurs.


All Articles