如何在C ++中处理异常时减少开销



在开发软件时遇到的许多情况下,处理运行时错误非常重要-从错误的用户输入到损坏的网络数据包。如果用户突然下载PNG而不是PDF,或者在更新软件时断开了网络电缆的连接,则应用程序不会崩溃。用户期望该程序无论发生什么都会工作,并且可以在后台处理紧急情况,或者通过友好界面发送的消息让他选择解决问题的选项。

异常处理可能是一个令人困惑,复杂的任务,并且对于许多C ++开发人员而言,这从根本上来说很重要,它会大大降低应用程序的速度。但是,与许多其他情况一样,有几种方法可以解决此问题。接下来,我们将深入研究C ++中的异常处理过程,处理其陷阱,并观察其如何影响应用程序的速度。另外,我们将研究可用于减少开销的替代方法。

在本文中,我不会敦促您完全停止使用这些异常。应该应用它们,但是要在无法避免的情况下精确地应用它们:例如,如何报告在构造函数内部发生的错误?我们将主要考虑使用异常来处理运行时错误。使用我们将要讨论的替代方法,可以使您开发更可靠和易于维护的应用程序。

快速性能测试


与控制程序进度的常规机制相比,C ++中的异常要慢多少?

异常显然比简单的break或return操作慢。但是,让我们找出速度慢了多少!

在下面的示例中,我们编写了一个简单的函数,该函数随机生成数字,并在检查一个生成的数字的基础上给出/不给出错误消息。

我们测试了几种用于错误处理的实现选项:

  1. 引发带有整数参数的异常。尽管这在实践中并未特别适用,但这是在C ++中使用异常的最简单方法。因此,我们消除了测试实施中的过度复杂性。
  2. 丢弃std :: runtime_error,它可以发送文本消息。与前一个选项不同,此选项在实际项目中使用更多。让我们来看看第二种选择是否会比第一种明显增加间接费用成本。
  3. 空收益。
  4. 返回C样式int错误代码

为了运行测试,我们使用了简单的Google基准测试库她在一个周期内反复运行了每个测试。接下来,我将描述一切如何发生。不耐烦的读者可以立即跳转到结果

测试代码

我们的超复杂随机数生成器:

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

测试功能:

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

就是这样,现在我们可以使用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();

对于那些想要触摸美丽的人,我们在这里发布了完整的代码

结果


在控制台中,根据编译选项,我们看到不同的测试结果:

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

发行-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

(于2015年MacBook Pro 2.5GHz i7推出)

结果令人惊叹!看看有无异常(空返回和errorCode)之间的代码速度之间的巨大差距。借助编译器优化,这个差距更大!

无疑,这是一个伟大的考验。编译器可能会对测试3和4进行了大量的代码优化,但无论如何差距还是很大的。因此,这使我们能够估计使用异常时的开销。

多亏了零成本模型,在大多数C ++中的try块实现中,都没有额外的开销。但是捕获块的工作要慢得多。通过简单的示例,我们可以看到如何缓慢地抛出和捕获异常。即使在这么小的调用堆栈上!随着堆栈深度的增加,开销将线性增加。这就是为什么设法捕获尽可能接近引发异常的代码如此重要的原因。

好吧,我们发现异常工作缓慢。然后也许停止它?但是,并非一切都那么简单。

为什么每个人都继续使用异常?


C ++性能技术报告(第5.4章) 中充分记录了异常的好处

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

再一次:主要思想是无法忽略和忘记异常。这使异常成为非常强大的内置C ++工具,能够通过我们从C继承的错误代码来代替困难的处理。

此外,异常在与程序不直接相关的情况下非常有用,例如,“硬盘已满”。 “或”网络电缆已损坏。在这种情况下,例外是理想的。

但是,处理与程序直接相关的错误处理的最佳方法是什么?无论如何,我们都需要一种机制来明确地指示开发人员,他应该检查错误,为他提供有关该错误的足够信息(如果出现了此错误),以消息的形式或其他某种格式进行传输。似乎我们再次返回到内置异常,但是刚才我们将讨论替代解决方案。

预期


除了开销之外,异常还有一个缺点:它们按顺序工作:异常一旦抛出就需要捕获并处理,不可能将其推迟到以后。

我们对于它可以做些什么呢?

事实证明有办法。 Andrei Alexandrescu教授为我们开设了一个特别课程,称为Expected。它允许您创建T类的对象(如果一切正常)或Exception类的对象(如果发生错误)。就是这个或那个。或或。
本质上,这是对数据结构的包装,在C ++中将其称为联合。

因此,我们举例说明他的想法:

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

全面实施肯定会更加困难。但是Expected的主要思想是,在同一个对象(Expected&e)中,我们可以接收程序正常运行的数据(它将具有我们已知的类型和格式:T&值)和错误数据(例外和例外)。因此,很容易再次检查一下我们遇到的情况(例如,使用hasError方法)。

但是,现在没有人会强迫我们在这一秒内处理异常。我们将没有throw()调用或catch块。相反,我们可以在方便时引用Exception对象。

预期性能测试


我们将为新类编写类似的性能测试:

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

击鼓!!!

调试-O0:

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

发行-O2:

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

不错!对于未经优化的std :: runtime_error,我们可以将操作时间从1605减少到147纳秒。通过优化,一切看起来都更好:从1261纳秒降至57.5纳秒。这比使用-O0快10倍以上,比使用-O2快20倍以上。

因此,与内置异常相比,Expected的工作速度快了很多倍,并且还为我们提供了更灵活的错误处理机制。此外,它具有语义纯洁性,并且无需牺牲错误消息和剥夺用户的质量反馈。

结论


例外并非绝对邪恶。有时这一点都是好事,因为它们在达到预期目的时会非常有效地工作:在特殊情况下。我们只有在使用更有效的解决方案的情况下,才开始遇到问题。

尽管我们的测试非常简单,但我们的测试显示:如果在仅发送数据(使用return)就足够的情况下不捕获异常(缓慢的catch块),则可以大大减少开销。

在本文中,我们还简要介绍了Expected类以及如何使用它来加快错误处理过程。Expected使跟踪程序的进度变得容易,也使我们更加灵活,可以将消息发送给用户和开发人员。


All Articles