So reduzieren Sie den Overhead beim Behandeln von Ausnahmen in C ++



Die Verarbeitung von Laufzeitfehlern ist in vielen Situationen sehr wichtig, die bei der Entwicklung von Software auftreten - von falschen Benutzereingaben bis hin zu beschädigten Netzwerkpaketen. Die Anwendung sollte nicht abstürzen, wenn der Benutzer plötzlich PNG anstelle von PDF heruntergeladen oder das Netzwerkkabel beim Aktualisieren der Software abgezogen hat. Der Benutzer erwartet, dass das Programm unabhängig von den Ereignissen funktioniert und entweder Notfallsituationen im Hintergrund behandelt oder ihm anbietet, eine Option zur Lösung des Problems mithilfe einer Nachricht auszuwählen, die über eine benutzerfreundliche Oberfläche gesendet wird.

Die Behandlung von Ausnahmen kann eine verwirrende, komplexe Aufgabe sein und, die für viele C ++ - Entwickler von grundlegender Bedeutung ist, die Anwendung erheblich verlangsamen. Wie in vielen anderen Fällen gibt es jedoch verschiedene Möglichkeiten, dieses Problem zu lösen. Als Nächstes werden wir uns mit dem Ausnahmebehandlungsprozess in C ++ befassen, die Fallstricke behandeln und herausfinden, wie sich dies auf die Geschwindigkeit Ihrer Anwendung auswirken kann. Darüber hinaus werden Alternativen untersucht, mit denen der Overhead reduziert werden kann.

In diesem Artikel werde ich Sie nicht dringend bitten, die Ausnahmen nicht mehr vollständig zu verwenden. Sie sollten angewendet werden, aber genau dann, wenn es unmöglich ist, sie zu vermeiden: Wie kann beispielsweise ein Fehler gemeldet werden, der im Konstruktor aufgetreten ist? Wir werden hauptsächlich Ausnahmen in Betracht ziehen, um Laufzeitfehler zu behandeln. Wenn Sie die Alternativen verwenden, über die wir sprechen werden, können Sie zuverlässigere und einfach zu wartende Anwendungen entwickeln.

Schneller Leistungstest


Wie viel langsamer sind Ausnahmen in C ++ im Vergleich zu den üblichen Mechanismen zur Steuerung des Programmfortschritts?

Ausnahmen sind offensichtlich langsamer als einfache Unterbrechungs- oder Rückgabevorgänge. Aber lassen Sie uns herausfinden, wie viel langsamer!

Im folgenden Beispiel haben wir eine einfache Funktion geschrieben, die zufällig Zahlen generiert und auf der Grundlage der Überprüfung einer generierten Zahl eine Fehlermeldung ausgibt / nicht ausgibt.

Wir haben verschiedene Implementierungsoptionen für die Fehlerbehandlung getestet:

  1. Wirf eine Ausnahme mit einem ganzzahligen Argument aus. Obwohl dies in der Praxis nicht besonders angewendet wird, ist es der einfachste Weg, Ausnahmen in C ++ zu verwenden. So werden wir übermäßige Komplexität bei der Implementierung unseres Tests los.
  2. Wirf std :: runtime_error aus, das eine Textnachricht senden kann. Diese Option wird im Gegensatz zur vorherigen Option in realen Projekten viel häufiger verwendet. Mal sehen, ob die zweite Option die Gemeinkosten im Vergleich zur ersten spürbar erhöht.
  3. Leere Rückgabe.
  4. Rückgabe des C-Style-Int-Fehlercodes

Um die Tests durchzuführen, haben wir die einfache Google-Benchmark-Bibliothek verwendet . Sie führte jeden Test wiederholt in einem Zyklus durch. Als nächstes werde ich beschreiben, wie alles passiert ist. Ungeduldige Leser können sofort zu den Ergebnissen springen .

Testcode

Unser superkomplexer Zufallszahlengenerator:

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

Testfunktionen:

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

Jetzt können wir die Google-Benchmark-Bibliothek verwenden :

// 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();

Für diejenigen, die das Schöne berühren möchten, haben wir den vollständigen Code hier veröffentlicht .

Ergebnisse


In der Konsole werden je nach Kompilierungsoptionen unterschiedliche Testergebnisse

angezeigt: 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

(Auf dem MacBook Pro 2.5GHz i7 2015 gestartet)

Die Ergebnisse sind erstaunlich! Schauen Sie sich die große Lücke zwischen der Codegeschwindigkeit mit und ohne Ausnahmen an (leere Rückgabe und Fehlercode). Und mit der Compiler-Optimierung ist diese Lücke noch größer!

Zweifellos ist dies ein großartiger Test. Der Compiler führt möglicherweise einige Codeoptimierungen für die Tests 3 und 4 durch, aber die Lücke ist trotzdem groß. Auf diese Weise können wir den Overhead bei Verwendung von Ausnahmen abschätzen.

Dank des Null-Kosten-Modells entsteht bei den meisten Implementierungen von Try-Blöcken in C ++ kein zusätzlicher Overhead. Der Catch-Block arbeitet jedoch viel langsamer. Anhand unseres einfachen Beispiels können wir sehen, wie langsam das Auslösen und Abfangen von Ausnahmen funktionieren kann. Selbst auf einem so kleinen Call Stack! Und mit zunehmender Stapeltiefe steigt der Overhead linear an. Deshalb ist es so wichtig zu versuchen, die Ausnahme so nah wie möglich an dem Code zu fangen, der sie ausgelöst hat.

Nun, wir haben herausgefunden, dass Ausnahmen langsam funktionieren. Dann vielleicht aufhören? Aber nicht alles ist so einfach.

Warum verwenden alle weiterhin Ausnahmen?


Die Vorteile von Ausnahmen sind im Technischen Bericht zur C ++ - Leistung (Kapitel 5.4) gut dokumentiert :

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

Noch einmal: Die Hauptidee ist die Unfähigkeit, die Ausnahme zu ignorieren und zu vergessen. Dies macht Ausnahmen zu einem sehr leistungsfähigen integrierten C ++ - Tool, das die schwierige Verarbeitung durch Fehlercode ersetzen kann, den wir von der C-Sprache geerbt haben.

Darüber hinaus sind Ausnahmen in Situationen sehr nützlich, die nicht direkt mit dem Programm zusammenhängen, z. B. „Die Festplatte ist voll "Oder" Netzwerkkabel ist beschädigt. " In solchen Situationen sind Ausnahmen ideal.

Aber wie geht man am besten mit der Fehlerbehandlung um, die direkt mit dem Programm zusammenhängt? In jedem Fall benötigen wir einen Mechanismus, der dem Entwickler eindeutig anzeigt, dass er den Fehler überprüfen, ihm ausreichende Informationen darüber (falls er aufgetreten ist) zur Verfügung stellen und ihn in Form einer Nachricht oder in einem anderen Format übertragen soll. Es scheint, dass wir wieder zu den eingebauten Ausnahmen zurückkehren, aber gerade jetzt werden wir über eine alternative Lösung sprechen.

Erwartet


Ausnahmen haben neben dem Overhead einen weiteren Nachteil: Sie arbeiten nacheinander: Eine Ausnahme muss abgefangen und verarbeitet werden, sobald sie ausgelöst wird. Es ist unmöglich, sie auf später zu verschieben.

Was können wir dagegen tun?

Es stellt sich heraus, dass es einen Weg gibt. Professor Andrei Alexandrescu hat für uns eine spezielle Klasse namens Expected entwickelt. Sie können damit ein Objekt der Klasse T (wenn alles in Ordnung ist) oder ein Objekt der Klasse Exception (wenn ein Fehler auftritt) erstellen. Das heißt, entweder dies oder das. Oder oder.
Im Wesentlichen ist dies ein Wrapper über die Datenstruktur, die in C ++ als Union bezeichnet wird.

Deshalb haben wir seine Idee wie folgt illustriert:

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

Eine vollständige Umsetzung wird sicherlich schwieriger. Die Hauptidee von Expected ist jedoch, dass wir im selben Objekt (Expected & e) Daten für den regulären Betrieb des Programms (mit dem uns bekannten Typ und Format: T & value) und Fehlerdaten (empfangen können) empfangen können. Ausnahme & ex). Daher ist es sehr einfach zu überprüfen, was noch einmal zu uns gekommen ist (z. B. mit der hasError-Methode).

Jetzt wird uns jedoch niemand mehr zwingen, die Ausnahme in dieser Sekunde richtig zu behandeln. Wir werden keinen throw () -Aufruf oder Catch-Block haben. Stattdessen können wir nach Belieben auf unser Exception-Objekt verweisen.

Leistungstest für erwartet


Wir werden ähnliche Leistungstests für unsere neue Klasse schreiben:

// 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();

Trommelwirbel!!!

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

Nicht schlecht! Für unseren std :: runtime_error ohne Optimierung konnten wir die Betriebszeit von 1605 auf 147 Nanosekunden reduzieren. Mit der Optimierung sieht alles noch besser aus: ein Rückgang von 1261 auf 57,5 ​​Nanosekunden. Dies ist mehr als 10-mal schneller als mit -O0 und mehr als 20-mal schneller als mit -O2.

Im Vergleich zu den integrierten Ausnahmen arbeitet Expected um ein Vielfaches schneller und bietet uns außerdem einen flexibleren Fehlerbehandlungsmechanismus. Darüber hinaus weist es eine semantische Reinheit auf und macht Fehlermeldungen überflüssig und beraubt Benutzer des Qualitätsfeedbacks.

Fazit


Ausnahmen sind nicht absolut böse. Und manchmal ist es überhaupt gut, da sie für ihren beabsichtigten Zweck äußerst effektiv arbeiten: unter außergewöhnlichen Umständen. Wir stoßen erst dann auf Probleme, wenn wir sie dort einsetzen, wo viel effektivere Lösungen verfügbar sind.

Unsere Tests haben trotz der Tatsache, dass sie recht einfach sind, gezeigt: Sie können den Overhead erheblich reduzieren, wenn Sie keine Ausnahmen abfangen (langsamer Fangblock), wenn es ausreicht, einfach Daten zu senden (mit return).

In diesem Artikel haben wir auch kurz die Expected-Klasse vorgestellt und wie wir sie verwenden können, um den Fehlerbehandlungsprozess zu beschleunigen. Expected macht es einfach, den Fortschritt des Programms zu verfolgen, und ermöglicht es uns, flexibler zu sein und Nachrichten an Benutzer und Entwickler zu senden.


All Articles