C++性能优化笔记-6-C++元素的效率差异-16-异常和错误处理

C++元素的效率差异

  • 异常与错误处理
    • 异常与向量代码
    • 避免异常处理的代价
    • 开发异常安全代码

异常与错误处理

运行时错误已发异常,这些异常可以通过陷阱或软件中断的形式被检测到。这些异常可以通过 try-catch 块捕捉到。程序会崩溃并产生一个错误消息,如果异常处理被启用并且没有 try-catch 块。
异常处理的目的是检测很少出现的错误,并以一个优雅的方式从错误中恢复。你可能认为只要错误不发生,异常处理就不需要额外时间,但不幸的是,这不总是成立的。程序必须进行许多簿记以便知道如何从异常事件中恢复。这个簿记的代价很大程度上依赖于编译器。某些编译器有高效的、极小或没有开销的基于表的方法,而其他编译器有低效的基于代码的方法,或者要求运行时类型识别(RTTI),这影响代码的其他部分。进一步解释,参考ISO/IEC TR18015 Technical Report on C++ Performance。

下面的例子解释了为什么需要簿记:

// Example 7.48
class C1 {
public:
     ...
     ~C1();
};

void F1() {
     C1 x;
     ...
}

void F0() {
     try {
          F1();
     }
     catch (...) {
          ...
     }
}

假定函数F1在返回时调用对象x的析构函数。但如果异常出现在F1里会怎么样?我们不经return就跳出F1F1的清理被阻止,因为它被直接地打断了。现在,调用x的析构函数是异常处理的责任。如果F1保存了要调用析构函数的所有信息或任何其他可能需要的清理,这才可能。如果F1调用另一个函数,它进而调用另一个函数,以此类推,如果异常发生在最里层函数中,异常处理需要所有关于函数调用链的信息。它需要追踪记录,在函数调用中回溯,检查所有需要进行的必要清理。这称为栈回滚。

所有函数必须为异常处理保存某些信息,即使没有异常发生。这是为什么在某些编译器中,异常处理会是代价高昂的原因。如果异常处理对应用是不必要的,那么应该禁止它,以使代码更小、更高效。可以通过在编译器中关闭异常处理选项,禁止全程序的异常处理。可以通过向函数原型添加throw(),禁止单个函数的异常处理。

void F1() throw();

这允许编译器假定F1不会抛出任何异常,因此它无需保存函数F1的恢复信息。不过,如果F1调用另一个可能抛出异常的函数F2,那么F1必须检查F2抛出的异常。在F2确实抛出异常的情形下,调用std::unexpected()函数。因此,仅当所有被F1调用的函数也有一个throw()声明时,才向F1应用throw()声明。对库函数,空throw()说明是有用的。

编译器区分_叶子函数_与_框架函数_。框架函数至少调用其他一个函数。叶子函数不调用任何其他函数。叶子函数比框架函数更简单,因为如果可以排除异常,或者在异常出现时没有清理工作,就可以不考虑栈回滚信息。通过内联所有调用的函数,框架函数可以转换为叶子函数。如果程序关键的最里层循环不包含框架函数的调用,可得到最好的性能。

虽然在某些情形里,空throw()语句有益于优化,但没有理由添加像throw(A, B, C)的语句显式告知函数会抛出哪些类型的异常。实际上,编译器可能实际上会添加额外的代码来检查抛出的异常是否就是指定的类型(参考Sutter:A Pragmatic Look at Exception Specifications, Dr Dobbs Journal, 2002)。

在某些情形里,即使在程序最关键部分中使用异常处理也是最优的。如果替代实现效率较低,且希望能够从错误恢复,就是这样。下面的例子展示了这样一个情形:

// Example 7.49
// Portability note: This example is specific to Microsoft compilers.
// It will look different in other compilers.
#include 
#include 
#include 

#define EXCEPTION_FLT_OVERFLOW 0xC0000091L

void MathLoop() {
      const int arraysize = 1000; unsigned int dummy;
      double a[arraysize], b[arraysize], c[arraysize];
      // Enable exception for floating point overflow:
      _controlfp_s(&dummy, 0, _EM_OVERFLOW);
      // _controlfp(0, _EM_OVERFLOW); // if above line doesn't work

      int i = 0; // Initialize loop counter outside both loops
      // The purpose of the while loop is to resume after exceptions:
      while (i < arraysize) {
            // Catch exceptions in this block:
            __try {
                  // Main loop for calculations:
                  for ( ; i < arraysize; i++) {
                        // Overflow may occur in multiplication here:
                        a[i] = log (b[i] * c[i]);
                  }
            }
            // Catch floating point overflow but no other exceptions:
            __except (GetExceptionCode() == EXCEPTION_FLT_OVERFLOW ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
                  // Floating point overflow has occurred.
                  // Reset floating point status:
                  _fpreset();
                  _controlfp_s(&dummy, 0, _EM_OVERFLOW);
                  // _controlfp(0, _EM_OVERFLOW); // if above doesn't work
                  // Re-do the calculation in a way that avoids overflow:
                  a[i] = log(b[i]) + log(c[i]); // Increment loop counter and go back into the for-loop:
                  i++;
            }
      }
}

假定在b[i]c[i]中的数很大,在乘法b[i]*c[i]中会出现溢出,虽然它很少发生。上面的代码在溢出时会捕捉异常,并以需要更多时间但避免溢出的方式重新计算。对每个因子而不是积取对数,确保不会出现溢出,但计算时间加倍。
支持异常处理所需的时间是微不足道的,因为在关键的最里层循环内没有try块或函数调用(除了log)。log是一个我们假定被优化的库函数。我们不能改变其可能的异常处理支持。在异常发生时,代价是高的,但这不是一个问题,因为我们假定它很少出现。
这里,测试循环内溢出条件不耗费什么,因为在溢出时,我们依赖微处理器硬件唤起异常。如果有一个try块,把异常重定向到程序中的异常处理部分,异常会由操作系统捕获。
捕捉硬件异常有可移植性问题。该机制依赖于编译器、操作系统与CPU硬件中的非标准化细节。移植这样一个应用到不同的平台很可能要求修改代码。
让我们看一下在这个例子中异常处理的可能替代方案。在b[i]*c[i]之前,通过检查它们是否太大,可以检查溢出。这要求两个浮点比较操作,它们相对代价高,因为它们必须在最里层循环中。另一个可能的方法是使用安全的公式a[i] = log(b[i]) + log(c[i]);。这使对log调用加倍,对数需要长时间计算。如果存在循环外检查溢出、无需检查所有数组元素的方法,这可能是更好的解决方案。如果所有的元素从少数参数产生,在循环前进行这样一个检查是可能的。或者如果结果通过某个公式合并为单个结果,在循环后进行这个检查也是可能的。

异常与向量代码

向量指令对并行乘法有用。会在后续章节讨论。异常处理对于向量代码效果不好,因为一个向量中的元素可能引发异常,而其他的元素不会。一个无关的分支可能产生异常,如果使用向量代码实现分支。如果代码可以从向量指令中获益,那么最好禁用异常陷阱,用NAN传播和INF传播替代。进一步的讨论,参考nan_propagation。

避免异常处理的代价

在不尝试从错误恢复时,异常处理是不必要的。如果在出错时,只是希望程序发出一条错误消息,然后停止这个程序,没有理由使用trycatchthrow。自定义错误处理函数,只是打印一条合适错误消息,然后调用exit,效率更高。

如果存在需要被清理的已分配资源,调用exit可能是不安全的,如下面解释。有其他不使用异常处理错误的方式。检测错误的函数可以返回一个错误码,调用函数可用来恢复或发布一条错误消息。

建议使用一个系统及深思熟虑的错误处理方法。你必须区分可恢复与不可恢复错误;确保在出错时,已分配资源被清理;对用户提供合适的错误消息。

开发异常安全代码

假定一个函数以独占模式打开一个文件,在关闭这个文件之前,一个错误终止了这个程序。在程序终止后,文件将维持锁定,用户将不能访问该文件,直到计算机重启。为了防止这种问题,你必须使你的程序异常安全。换而言之,在异常或其他错误时,程序必须清理所有资源。需要清理的事情包括:

  • 使用new或malloc分配的内存。
  • 窗口,图形刷等的句柄。
  • 上锁的互斥量。
  • 打开的数据块连接。
  • 打开的文件与网络连接。
  • 需要被删除的临时文件。
  • 需要保存的用户工作。
  • 任何其他已分配的资源。

C++的清理任务是析构函数。读写一个文件的函数可以封装到一个带有确保关闭该文件的析构函数的类中。相同的方法可用于任何其他资源,比如动态分配内存,窗口,互斥量,数据块连接等。
C++异常处理系统确保调用所有局部对象的析构函数。如果有具有负责所有已分配资源清理的析构函数封装类,程序是异常安全的。如果析构函数引致另一个异常,系统很可能失败。

如果开发自己的错误处理系统,而不是使用异常处理,那么你不能确保所有的析构函数被调用,资源被清理。如果一个错误处理函数调用exit()abort()_endthread()等,那么不保证所有的析构函数被调用。不使用异常机制,处理一个不可恢复错误的安全方式是从函数返回。如果可能,函数可以返回一个错误代码,或者这个错误代码可以保存在一个全局对象里。调用函数必须检查错误代码。如果后面的函数也有某些东西要清理,它必须返回到其调用者,以此类推。

你可能感兴趣的:(程序优化,c++,架构与开发技巧,c++,性能优化,异常处理)