How to reduce overhead when handling exceptions in C ++



Runtime error handling is very important in many situations that we encounter when developing software - from incorrect user input to damaged network packets. The application should not crash if the user suddenly downloaded PNG instead of PDF, or disconnected the network cable when updating the software. The user expects that the program will work no matter what happens and either handle emergency situations in the background or offer him to choose an option to solve the problem by means of a message sent through a friendly interface.

Exception handling can be a confusing, complex task, and, which is fundamentally important for many C ++ developers, it can greatly slow down the application. But, as in many other cases, there are several ways to solve this problem. Next, we will delve into the exception handling process in C ++, deal with its pitfalls and see how this can affect the speed of your application. In addition, we will look at alternatives that can be used to reduce overhead.

In this article, I will not urge you to stop using the exceptions completely. They should be applied, but applied precisely when it is impossible to avoid it: for example, how to report an error that occurred inside the constructor? We will mainly consider using exceptions to handle runtime errors. Using the alternatives that we will talk about will allow you to develop more reliable and easily maintainable applications.

Quick performance test


How much slower are exceptions in C ++ compared to the usual mechanisms for controlling the progress of a program?

Exceptions are obviously slower than simple break or return operations. But let's find out how much slower!

In the example below, we have written a simple function that randomly generates numbers and, on the basis of checking one generated number, gives / does not give an error message.

We tested several implementation options for error handling:

  1. Throw an exception with an integer argument. Although this is not particularly applied in practice, it is the easiest way to use exceptions in C ++. So we get rid of excessive complexity in the implementation of our test.
  2. Throw out std :: runtime_error, which can send a text message. This option, unlike the previous one, is much more often used in real projects. Let's see if the second option will give a tangible increase in overhead costs compared to the first.
  3. Empty return.
  4. Return C style int error code

To run the tests, we used the simple Google benchmark library . She repeatedly ran each test in a cycle. Next, I will describe how everything happened. Impatient readers can immediately jump to the results .

Test code

Our super complex random number generator:

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

Test functions:

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

That's it, now we can use the Google benchmark library :

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

For those who want to touch the beautiful, we have posted the full code here .

results


In the console, we see different test results, depending on the compilation options:

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

(Launched on 2015 MacBook Pro 2.5GHz i7)

The results are amazing! Look at the huge gap between code speed with and without exceptions (empty return and errorCode). And with compiler optimization, this gap is even bigger!

Undoubtedly, this is a great test. The compiler may be doing quite a bit of code optimization for tests 3 and 4, but the gap is big anyway. Thus, this allows us to estimate the overhead when using exceptions.

Thanks to the zero-cost model, in most implementations of try blocks in C ++, there is no additional overhead. But the catch-block works much slower. Using our simple example, we can see how slowly throwing and catching exceptions can work. Even on such a small call stack! And with increasing stack depth, overhead will increase linearly. That's why it is so important to try to catch the exception as close as possible to the code that threw it.

Well, we found out that exceptions work slowly. Then maybe stop it? But not everything is so simple.

Why does everyone keep using exceptions?


The benefits of exceptions are well documented in Technical Report on C ++ Performance (chapter 5.4) :

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

Once again: the main idea is the inability to ignore and forget about the exception. This makes exceptions a very powerful built-in C ++ tool, capable of replacing the difficult processing through error code, which we inherited from the C language.

Moreover, exceptions are very useful in situations that are not directly related to the program, for example, โ€œthe hard disk is full โ€Orโ€œ network cable is damaged. โ€ In such situations, exceptions are ideal.

But what is the best way to deal with error handling directly related to the program? In any case, we need a mechanism that would unambiguously indicate to the developer that he should check the error, provide him with sufficient information about it (if it arose), transmitting it in the form of a message or in some other format. It seems that we are again returning to the built-in exceptions, but just now we will talk about an alternative solution.

Expected


In addition to overhead, exceptions have one more drawback: they work sequentially: an exception must be caught and processed as soon as it is thrown, it is impossible to postpone it for later.

What can we do about it?

It turns out there is a way. Professor Andrei Alexandrescu came up with a special class for us called Expected. It allows you to create an object of class T (if everything goes fine) or an object of class Exception (if an error occurs). That is, either this or that. Or or.
In essence, this is a wrapper over the data structure, which in C ++ is called union.

Therefore, we illustrated his idea as follows:

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

A full implementation will certainly be more difficult. But the main idea of โ€‹โ€‹Expected is that in the same object (Expected & e) we can receive data for the regular operation of the program (which will have the type and format known to us: T & value) and error data ( Exception & ex). Therefore, it is very easy to check what came to us once again (for example, using the hasError method).

However, now no one will force us to handle the exception right this second. We will not have a throw () call or catch block. Instead, we can refer to our Exception object at our convenience.

Performance Test for Expected


We will write similar performance tests for our new class:

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

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

Not bad! For our std :: runtime_error without optimization, we were able to reduce the operating time from 1605 to 147 nanoseconds. With optimization, everything looks even better: a drop from 1261 to 57.5 nanoseconds. This is more than 10 times faster than with -O0 and more than 20 times faster than with -O2.

So compared to the built-in exceptions, Expected works many times faster, and also gives us a more flexible error handling mechanism. In addition, it has semantic purity and eliminates the need to sacrifice error messages and deprive users of quality feedback.

Conclusion


Exceptions are not absolute evil. And sometimes itโ€™s good at all, since they work extremely effectively for their intended purpose: in exceptional circumstances. We only begin to encounter problems when we use them where much more effective solutions are available.

Our tests, despite the fact that they are quite simple, showed: you can greatly reduce overhead if you do not catch exceptions (slow catch-block) in cases where it is enough to simply send data (using return).

In this article, we also briefly introduced the Expected class and how we can use it to speed up the error handling process. Expected makes it easy to track the progress of the program, and also allows us to be more flexible and send messages to users and developers.


All Articles