优化C++软件

Posted wuhui_gdnt

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了优化C++软件相关的知识,希望对你有一定的参考价值。

7.15. 函数参数

在大多数情形里,函数参数通过值传递。这意味着参数的值被拷贝到一个局部变量。对简单类型,比如int,float,double,bool,enum以及指针与引用,这是高效的。

数组总是以指针传递,除非它们被封装在一个类或结构体里。

如果参数是复合类型,比如结构体或类,情形更复杂。如果满足以下条件,复合类型参数的传递更高效:

  • 对象很小能放入一个寄存器
  • 对象没有拷贝构造函数与析构函数
  • 对象没有虚成员
  • 对象不使用运行时类型识别(RTTI)

如果任何这些条件不满足,通常传递指向该对象的指针或引用更快。如果对象大,拷贝整个对象显然要花时间。在该对象被拷贝到参数时,必须调用拷贝构造函数,在该函数返回前,如果有,必须调用析构函数。

向函数传递复合对象首选的方法是通过const引用。一个const引用确保原始对象不被修改。不像指针或非const引用,const引用允许函数实参是一个表达式或匿名对象。如果函数被内联,编译器可以很容易地优化掉一个const引用。

一个替代的解决方案是使函数成为对象的类或结构体的一个成员。这同样高效。

在32位模式中,简单的函数参数在栈上传递,但在64位系统则在寄存器中。后者更高效。64位Windows允许最多4个参数在寄存器中传递。64位Unix系统允许最多14个参数在寄存器中传递(8个浮点或双精度加上6个整数、指针或引用参数)、在成员函数中的this指针算作一个参数。更多细节在手册5《不同C++编译器与操作系统的调用惯例》中给出。

7.16. ​​​​​​​函数返回类型

函数的返回类型最好是一个简单类型、指针、引用或void。返回复合类型的对象更复杂且通常更低效。

仅在最简单的情形里,复合类型对象可以在寄存器中返回。对象何时可以在寄存器中返回的细节,参考手册5《不同C++编译器与操作系统的调用惯例》。

除了最简单的情形,通过拷贝到调用者通过一个掩藏指针指示的位置,返回复合对象。拷贝构造函数,如果有,通常在拷贝过程中调用,在原始对象被摧毁时,调用析构函数。在简单的情形里,编译器也许能通过在最终目的地构造该对象,避免调用拷贝构造函数以及析构函数,但不能指望它。

除了返回一个复合对象,你可以考虑以下替换方案:

  • 使函数成为对象的一个构造函数。
  • 使函数修改现存对象,而不是制作一个新的。可以通过指针或引用,或者使函数成为对象类的一个成员,使函数可以得到现存对象。
  • 使函数返回定义在函数内的一个静态对象的指针或引用。这高效,但危险。直到函数下一次被调用,且可能在另一个线程中改写这个局部对象,返回的指针或引用才是有效的。如果你忘记使这个局部对象成为静态,那么只要函数返回,它就无效。
  • 使函数通过new构造一个对象,并返回它的一个指针。因为动态内存分配的代价,这是低效的。如果你忘记删除对象,这个方法还有内存泄露的风险。

7.17. 函数尾调用

尾调用是优化函数调用的一种方式。如果函数最后的语句是调用另一个函数,那么编译器可以使用到第二个函数的跳转替换这个调用。优化编译器将自动完成这。第二个函数将不会返回到第一个函数,而是直接返回到调用第一个函数的地方。因为消除了一个返回,这更高效。例子:

// Example 7.35. Tail call

void function2(int x);

void function1(int y)

     ...

     function2(y+1);

这里,通过直接跳转到function2,消除从function1的返回。即使存在一个返回值,这也奏效:

// Example 7.36. Tail call with return value

int function2(int x);

int function1(int y)

     ...

     return function2(y+1);

仅在两个函数有相同的返回类型时,尾调用优化才能工作。如果函数在栈上有参数(在32位模式中,这是最常见的情形),那么这两个函数参数使用的栈空间必须一样大。

7.18. ​​​​​​​递归函数

递归函数是调用自身的函数。递归函数调用对处理递归的数据结构是有用的。递归函数的代价是每次迭代获取新实例的所有参数与局部变量,这占据了栈空间。深度递归还使得返回地址的预测效率下降。通常这个问题出现在递归深度超过16时(参考手册3《Intel,AMD与VIA CPU微架构》中返回栈缓冲的解释)。

对处理一个分叉数据树结构,递归函数调用仍然是最高效的解决方案。如果该树结构宽度超过深度,递归更高效。总是应该使用更高效的循环来替换非分叉递归。通常教材的递归函数例子是阶乘函数:

// Example 7.37. Factorial as recursive function

unsigned long int factorial(unsigned int n)

     if (n < 2) return 1;

     return n * factorial(n-1);

这个实现非常低效,因为所有n的实例与所有返回地址占据储存栈上空间。使用循环更高效:

// Example 7.38. Factorial function as loop

unsigned long int factorial(unsigned int n)

     unsigned long int product = 1;

     while (n > 1)

          product *= n;

          n--;

    

     return product;

递归尾调用比其他递归调用效率更高,但仍然比不过循环。

经验不够的程序员有时会调用main来重启他们的程序。这是一个坏主意,因为栈被main每次递归调用产生的新实例的所有局部变量填满了。重启程序的正确方式是在main里做一个循环。

7.19. ​​​​​​​结构体与类

时至今日,编程教材推荐面向对象编程,作为使软件更清晰及模块化的一种手段。所谓的对象是结构体及类的实例。面向对象编程形式对程序性能有积极与消极的影响。积极的影响有:

  • 一起使用的变量也保存在一起,如果它们是结构体或类的成员。这使得数据缓存更高效。
  • 作为类成员的变量无需作为参数传递给类成员函数。这些变量避免了参数传递的开销。

面向对象编程的消极影响有:

  • 非静态成员函数有作为一个隐含参数传递的this指针。所有非静态成员函数都有this参数传递的开销。
  • This指针占据一个寄存器。在所有32位系统中,寄存器是稀缺资源。
  • 虚拟成员函数效率较低(参考第55页)。

面向对象编程的积极影响还是消极影响占优,不能一概而论。至少,可以这样说,类与成员函数的使用代价不高。如果对程序的逻辑结构与清晰性有益,你可以使用面向对象编程风格,只要在程序最关键部分避免过多的函数调用。使用结构体(没有成员函数)对性能没有消极影响。

7.20. ​​​​​​​类数据成员(变量实例)

在创建一个类或结构体的实例时,类或结构体的数据成员以它们声明的次序连续储存。把数据组织成类或结构体没有性能上的损失。访问类或结构体数据成员所需的时间与访问一个简单变量一样。

大多数编译器将对齐数据成员来取整地址,以优化访问,如下表所示。

类型

大小,字节

对齐,字节

bool

1

1

char, signed or unsigned

1

1

short int, signed or unsigned

2

2

int, signed or unsigned

4

4

64-bit integer, signed or unsigned

8

8

pointer or reference, 32-bit mode

4

4

pointer or reference, 64-bit mode

8

8

float

4

4

double

8

8

long double

8, 10, 12 or 16

8 or 16

表7.2. 数据成员的对齐

在具有不同大小成员的结构体或类中,这个对齐会导致未使用字节的空洞。例如:

// Example 7.39a

struct S1

     short int a;  // 2 bytes. first byte at 0, last byte at 1

                           // 6 unused bytes

     double b;     // 8 bytes. first byte at 8, last byte at 15

     int d;            // 4 bytes. first byte at 16, last byte at 19

                          // 4 unused bytes

;

S1 ArrayOfStructures[100];

这里,在a与b之间有6个未使用字节,因为b必须从被8整除的地址开始。在末尾也有4个未使用字节。原因是数组中S1的下一个实例必须从被8整除的地址开始,以将其b成员对齐到8。将最小的成员放到最后,未使用字节数可以减少到2:

// Example 7.39b

struct S1

     double b;      // 8 bytes. first byte at 0, last byte at 7

     int d;             // 4 bytes. first byte at 8, last byte at 11

     short int a;   // 2 bytes. first byte at 12, last byte at 13

                            // 2 unused bytes

;

S1 ArrayOfStructures[100];

这个重排使结构体减小了8字节,数组减小了800字节。

结构体与类对象通常可以通过重排数据成员变得更小。如果类有至少一个虚成员函数。在第一个数据成员前或最后一个数据成员后有一个虚表指针。这个指针在32位系统中为4字节,在64位系统中为8字节。如果你对结构体或其每个成员的多大有疑问,那么你可以使用sizeof操作符进行一些实验。由sizeof操作符返回的值包括在对象末尾的所有未使用字节。

如果数据成员相对于结构体或类开头的偏移小于128,访问它的代码会更紧凑,因为这个偏移可以表示为一个8位有符号数。如果相对于结构体或类开头的偏移是128字节或更大,那么偏移必须被表示为一个32位数(指令集没有8位与32位之间的偏移)。例如:

// Example 7.40

class S2

     public:

     int a[100];  // 400 bytes. first byte at 0, last byte at 399

     int b;           // 4 bytes. first byte at 400, last byte at 403

     int ReadB() return b;

;

这里b的偏移是400。任何通过指针或成员函数,比如ReadB,访问b的代码需要将这个偏移编码为一个32位数。如果交换a与b,那么可以通过编码为一个8位有符号数的偏移,或完全不使用偏移,访问两者。这使得代码更紧凑,因而代码缓存的使用更有效率。因此,建议在结构体或类声明中,大数组与其他大对象最后出现,最常使用的数据成员最先出现。如果不可能在前128个字节里包含所有的数据成员,将最常用的成员放在前128个字节。

7.21. ​​​​​​​类成员函数(方法)

每次声明或创建一个类的新对象时,将产生数据成员的新实例。不过每个成员函数仅有一个实例。不拷贝函数代码,因为相同的代码适用于该类的所有实例。

调用成员函数与调用带有结构体指针或引用的简单函数一样快。例如:

// Example 7.41

class S3

     public:

     int a;

     int b;

     int Sum1() return a + b;

;

int Sum2(S3 * p) return p->a + p->b;

int Sum3(S3 & r) return r.a + r.b;

三个函数Sum1,Sum2与Sum3都做相同的事情,它们效率相同。如果你查看编译器产生的代码,你将注意到某些编译器对这三个函数产生完全相同的代码。Sum1有一个隐含的this,它与Sum2及Sum3中的p与r作用相同。你是希望把函数做成该类的一个成员,还是给它该类或结构体的指针或引用,纯粹是程序风格的问题。某些编译器,通过在寄存器中传递this,而不是在栈上,使在32位Windows中,Sum1比Sum2与Sum3的效率稍高。

Static成员函数不能访问任何非静态数据成员或非静态成员函数。静态成员函数比非静态函数更快,因为它不需要this指针。如果成员函数不需要任何非静态访问,你可以通过使它们成为静态,使它们更快。

7.22. ​​​​​​​虚成员函数

虚函数用于实现多态类。多态类的每个实例有一个指针,指向不同版本虚函数的指针表。这个所谓的虚表用于在运行时找出虚函数的正确版本,多态是为什么面向对象编程效率不如非面向对象编程的主要原因之一。如果可以避免虚函数,那么就可以获得面向对象编程的大多数好处,而无需付出性能代价。调用虚成员函数的时间比调用非虚成员函数要多几个时钟周期,假设函数调用语句总是调用该虚函数的同一个版本。如果版本改变,你可能会得到10 ~ 20时钟周期的误预测惩罚。虚函数调用的延迟与误预测的规则与switch语句相同,如第37页所解释。

在一个已知类型的对象上调用虚函数时,可以绕过分派机制,不过你不能总是依赖编译器绕过这个分派机制,即使在这样做是显而易见的。参考第74页。

仅在编译时刻不知道调用多态成员函数的哪个版本时,才需要运行时多态。如果在程序的关键部分使用虚函数,那么可以考虑不使用多态或通过编译时多态,是否有可能获得期望的功能。

有时,通过模板而不是虚函数,获得期望的动态效果是可能的。模板参数应该是一个包含有多个版本函数的类。这个方法更快,因为模板参数总是在编译时解析的,而不是在运行时。第59页的例子7.47展示了如何做的一个例子。不幸的是,语法是如此杂乱,它不一定值得。

7.23. ​​​​​​​运行时类型识别(RTTI)

运行时类型识别对所有的类对象添加额外的信息,是低效的。如果编译器有用于RTTI的一个选项,关闭它,使用替代实现。

7.24. ​​​​​​​继承

派生类的一个对象实现的方式与包含父类与子类成员的简单类的一个对象相同。父类与子类成员的访问一样快。通常,你可以假定使用继承几乎没有性能损失。出于以下原因,代码缓存可能有少许退化:

  • 父类数据成员的大小添加到子类成员的偏移。访问总偏移超过127字节的数据成员的代码紧凑性下降。参考第47页。
  • 父类与子类的成员函数通常保存在不同的模块中。这会导致许多跳转以及效率下降的代码缓存。可以通过确保在附近调用的函数,也保存在彼此附近,来解决这个问题。细节参考第89页。

从多个父类继承,或在通过其中一个基类指针访问派生类对象时,会导致伴随成员指针及虚函数的复杂性。通过在派生类中构造对象,可以避免多继承:

// Example 7.42a. Multiple inheritance

class B1; class B2;

class D : public B1, public B2

     public:

     int c;

;

替换为:

// Example 7.42b. Alternative to multiple inheritance

class B1; class B2;

class D : public B1

     public:

     B2 b2;

     int c;

;

7.25. ​​​​​​​构造函数与析构函数

构造函数在内部被实现为一个返回该对象引用的成员函数。新对象的内存分配不一定由构造函数本身来完成。因此,构造函数与其他成员函数一样高效。这适用于缺省构造函数、拷贝构造函数,以及其他构造函数。

类不需要构造函数。如果对象不需要初始化,不需要缺省构造函数。如果对象拷贝可以通过拷贝所有数据成员完成,不需要拷贝构造函数。简单的构造函数可以被内联,以提升性能。

一旦对象通过赋值、作为函数参数、或作为函数返回值被拷贝时,一个拷贝构造函数被调用。如果涉及内存或其他资源的分配,拷贝构造函数会很耗时。有各种方法避免内存块的浪费性拷贝,例如:

  • 使用对象的引用或指针,而不是拷贝它。
  • 使用“移动构造函数”转移内存块的所有权、这要求编译器支持C++0X。
  • 制作将内存块所有权从一个对象转移到另一个的成员函数或友元函数或操作符。丢失内存块所有权的对象应该把其指针置为NULL。当然,应该有一个摧毁该对象拥有内存块的析构函数。

析构函数与成员函数一样高效。如果不必要,不要制作析构函数。虚析构函数与虚成员函数效率相同。参考第48页。

7.26. ​​​​​​​联合

联合是一个结构体,其中数据成员共享相同的内存空间。通过允许不会同时使用的数据成员共享相同的内存片段,联合可以节省内存空间。例子参考第91页。

联合也可以用于以不同的方式访问同一个数据。例如:

// Example 7.43

union

     float f;

     int i;

x;

x.f = 2.0f;

x.i |= 0x80000000;       // set sign bit of f

cout << x.f;                     // will give -2.0

在这个例子里,使用仅能用于整数的按位OR操作符,设置f的符号位。

7.27. ​​​​​​​位域(bitfield)

位域有助于使数据更紧凑。访问位域的成员比访问结构体的成员效率要低。在大数组情形里,如果它可以节省缓存空间或使文件更小,这额外的时间是合理的。

使用<<与|操作来组成位域比分别写成员更快。例如:

// Example 7.44a

struct Bitfield

     int a:4;

     int b:2;

     int c:2;

;

Bitfield x;

int A, B, C;

x.a = A;

x.b = B;

x.c = C;

假定A,B与C的值很小不会导致溢出,这个代码可以下面的方式改进:

// Example 7.44b

union Bitfield

     struct

          int a:4;

          int b:2;

          int c:2;

     ;

     char abc;

;

Bitfield x;

int A, B, C;

x.abc = A | (B << 4) | (C << 6);

或者,如果需要防止溢出:

// Example 7.44c

x.abc = (A & 0x0F) | ((B & 3) << 4) | ((C & 3) <<6 );

7.28. ​​​​​​​重载函数

重载函数的不同版本被处理为不同的函数。使用重载函数没有性能损失。

7.29. ​​​​​​​重载操作符

重载操作符等价于函数。使用重载操作符与使用完成同样事情的函数一样高效。

带有多个重载操作符的表达式会导致不期望的、用于中间结果的临时对象。例如:

// Example 7.45a

class vector                                                             // 2-dimensional vector

      public:

      float x, y;                                                             // x,y coordinates

      vector()                                                           // default constructor

      vector(float a, float b) x = a; y = b;              // constructor

      vector operator + (vector const & a)          // sum operator

      return vector(x + a.x, y + a.y);                       // add elements

;

vector a, b, c, d;

a = b + c + d;                                                            // makes intermediate object for (b + c)

通过连接操作,可以避免创建用于中间结果(b+c)的临时对象:

// Example 7.45b

a.x = b.x + c.x + d.x;

a.y = b.y + c.y + d.y;

幸好,大多数编译器在简单的情形里,将自动进行这个优化。

7.30. ​​​​​​​模板

就在编译前模板参数被它们的值替代而言,模板类似于宏。下面的例子展示了函数参数与模板参数间的差别:

// Example 7.46

int Multiply (int x, int m)

     return x * m;

template <int m>

int MultiplyBy (int x)

     return x * m;

int a, b;

a = Multiply(10,8);

b = MultiplyBy<8>(10);

a与b都将给出值10 * 8 = 80。差别在于m传递给函数的方式。在简单函数中,m在运行时从调用者传递给被调用函数。而在模板函数中,m在编译时被其值替代,因此编译器看到常量8,而不是变量m。使用模板参数而不是函数参数的好处是,避免了参数传递的开销。坏处是,对模板参数每个不同的值,编译器需要制作该模板函数的新实例。在这个例子里,如果使用许多不同的系数作为模板参数调用MuliplyBy,代码会变得非常大。

在上面的例子里,模板函数比简单的函数更快,因为编译器知道它可以使用偏移操作来乘以2的指数。X*8被更快的x << 3替代。在这个简单函数的情形中,编译器不知道m的值,因此不能进行优化,除非函数可以被内联。(在上面的例子里,编译器能够内联且优化这两个函数,将80放入a与b。但在更复杂的情形里,它可能不能这样做)。

模板参数也是一个类型。第27页的例子向你展示,如何使用同一个模板制作不同类型的数组。

模板是高效的,因为模板参数总是在编译时解析。模板使得源代码更复杂,但编译后代码不会。一般而言,使用模板没有执行速度方面的代价。

如果模板参数实际上是相同的,两个或更多的模板实例将被结合为一个。如果模板参数不同,那么从每组模板参数,你将得到一个实例。具有许多实例的模板使编译后代码大,并使用更多缓存空间,

过度使用模板使代码难以阅读。如果模板仅有一个实例,你也可以使用#define,const或typedef,而不是模板参数。

模板可用于元编程(metaprogramming),如第154页所述。

将模板用于多态

模板类可用于实现一个编译时多态,它比使用虚成员函数获得的运行时多态更高效。下面的例子首先展示了运行时多态:

// Example 7.47a. Runtime polymorphism with virtual functions

class CHello

     public:

     void NotPolymorphic();       // Non-polymorphic functions go here

     virtual void Disp();                // Virtual function

     void Hello()

          cout << "Hello ";

          Disp();                               // Call to virtual function

     

;

class C1 : public CHello

     public:

     virtual void Disp()

          cout << 1;

    

;

class C2 : public CHello

     public:

     virtual void Disp()

          cout << 2;

    

;

void test ()

     C1 Object1; C2 Object2;

     CHello * p;

     p = &Object1;

     p->NotPolymorphic();           // Called directly

     p->Hello();                              // Writes "Hello 1"

     p = &Object2;

     p->Hello();                              // Writes "Hello 2"

这里,对C1::Disp()或C2::Disp()的分发在运行时完成,如果编译器不知道对象p指向哪个类(参考第74页)。目前编译器不擅长优化掉p,并把调用内联到Object1.Hello(),将来的编译器也许能这么做。

如果在编译时已知对象属于类C1还是C2,那么我们可以避免低效的虚函数分派过程。这通过在Active Template Library(ATL)及Windows Template Library(WTL)中使用的特殊技巧来完成:

// Example 7.47b. Compile-time polymorphism with templates

// Place non-polymorphic functions in the grandparent class:

class CGrandParent

     public:

     void NotPolymorphic();

;

// Any function that needs to call a polymorphic function goes in the

// parent class. The child class is given as a template parameter:

template <typename MyChild>

class CParent : public CGrandParent

     public:

     void Hello()

     cout << "Hello ";

     // call polymorphic child function:

      (static_cast<MyChild*>(this))->Disp();

    

;

// The child classes implement the functions that have multiple

// versions:

class CChild1 : public CParent<CChild1>

     public:

     void Disp()

          cout << 1;

    

;

class CChild2 : public CParent<CChild2>

     public:

     void Disp()

          cout << 2;

    

;

void test ()

     CChild1 Object1; CChild2 Object2;

     CChild1 * p1;

     p1 = &Object1;

 

     p1->Hello();                                    // Writes "Hello 1"

     CChild2 * p2;

     p2 = &Object2;

     p2->Hello();                                    // Writes "Hello 2"

 

这里,CParent是通过模板参数获取关于其子类信息的一个模板类。通过将this指针类型转换为其子类的一个指针,它可以调用其子类的多态成员。仅在有正确的子类每作为模板参数时,这才是安全的。换而言之,你必须确保声明

class CChild1 : public CParent<CChild1>

对子类名与模板参数,有相同的名字。现在继承的次序如下。第一代类(CGrandParent)包含任何非多态成员函数。第二代类(Cparent<>)包含任何需要调用多态函数的成员函数。第三代类包含多态函数的不同版本。第二代类通过一个模板参数得到关于第三代类的信息。

如果对象已知,运行时没有浪费时间分发虚成员函数。这个信息包含在具有不同类型的p1与p2中。坏处是Cparent::Hello()有多个占据缓存空间的实例。

例子7.47b中的语法无可否认地非常复杂(kludgy)。通过避免虚函数分发机制所节省的几个时钟周期,很难证明这样一个难以理解因而难以维护的复杂代码的合理性。如果编译器能够自动进行去虚拟化(参考第74页),依赖编译器优化肯定比使用这个复杂的模板方法更方便。

7.31. ​​​​​​​线程

线程用于同时或者看起来同时执行两个或更多任务。如果计算机仅有一个CPU核,不可能同时执行两个任务。对前台任务,每个线程通常将得到30ms的时间片,对后台任务则是10ms。每个时间片之后,上下文切换的代价相当高,因为所有缓存必须适应新的上下文。通过更长的时间片减少上下文切换是可能的。这将以用户输入更长响应时间为代价,使得应用运行得更快。(在Windows中,通过在先进系统性能选项下,选择优化后台服务性能,你可以将时间片增加到120ms。我不知道在Linux中这是否可能)。

不同任务有不同优先级时,线程是有用的。例如,在字处理器中,用户期望按下键或移动鼠标有立即的响应。这个任务必须有高优先级。其他任务,比如拼写检查与重编页码,在其他具有低优先级的线程中运行。如果不同的任务不能分解为具有不同优先级的线程,在程序忙于进行拼写检查时,对键盘与鼠标输入,用户可能经历不可接受的的长响应时间。

如果应用有图形化的用户接口,任何需要长时间的线程,比如高负荷的数学计算,应该在一个独立线程中调度。否则,程序将不能快速响应键盘或鼠标输入。

在应用程序里制作一个类似线程的调度,而不招致操作系统线程调度器的开销是可能的。这可以通过在一个在图形化用户接口消息循环中调用的函数里逐步完成重负荷后台计算来完成(Windows MFC中OnIdle)。这个方法可能比在仅有一个CPU核的系统里制作一个独立线程更快,但它要求后台任务可以被分解为有合适时间的小片。

完全利用多CPU核系统的最好方法是把任务分解为线程。每个线程运行在自己的CPU核上。

在优化多线程应用时,我们必须考虑多线程的4种代价:

  • 启动与停止线程的代价。如果与启动及停止线程所需时间相比,持续时间短,不要把任务放入独立的线程。
  • 任务切换的代价。如果基于相同优先级的线程数少于CPU核数,这个代价最小。
  • 线程间同步与通讯的代价。信号量、互斥等的开销是可观的。如果两个线程通常彼此等待,以获得对相同资源的访问,那么把它们合并为一个线程更好。多个线程间共享的变量必须声明为volatile。这防止编译器优化这个变量。
  • 不同的线程需要独立的储存。多个线程使用的函数或类不能依赖静态或全局变量。(参考第19页线程局部储存)线程有自己的栈。如果线程共享相同的缓存,这会导致缓存竞争。

多线程程序必须使用线程安全函数。线程安全函数不应该使用静态变量。

关于多线程技术的进一步讨论,参考第10章,102页。

7.32. 异常与错误处理

异常处理的目的是检测很少出现的错误,并以一个优雅的方式从错误条件恢复。你可能认为只要错误不发生,异常处理就不需要额外时间,但不幸的是,这不总是成立的。程序必须进行许多簿记以便知道如何从异常事件中恢复。这个簿记的代价很大程度上依赖于编译器。某些编译器有高效的、极小或没有开销的基于表的方法,而其他编译器有低效的基于代码的方法,或者要求运行时类型识别(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里会怎么样?我们不经退出就脱离F1。F1的清理被阻止,因为它被粗鲁地打断了。现在,调用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 <excpt.h>

#include <float.h>

#include <math.h>

#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(不是一个数)。程序可以检测最终结果,看它是否是有效值(比如使用_finite()),在错误时以安全的方式重新进行计算。在某些微处理器上,当一个操作数是无穷或NAN时,这些计算比正常需要更多时间。

避免异常处理的代价

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

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

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

制作异常安全代码

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

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

C++方式的清理任务是制作一个析构函数。读写一个文件的函数可以封装到一个带有确保关闭该文件的析构函数的类中。相同的方法可用于任何其他资源,比如动态分配内存,窗口,互斥量,数据块连接等。

C++异常处理系统确保调用局部对象的所有析构函数。如果有具有负责所有已分配资源清理的析构函数封装类,程序是异常安全的。如果析构函数引致另一个异常,系统很可能失败。

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

7.33. ​​​​​​​栈回滚的其他情形

前面的章节描述了由异常处理句柄使用的、称为栈回滚的机制,在异常出现时,不使用正常返回途径跳出函数后,由异常处理句柄用来清理及调用任何必须的析构函数。这个机制也用在其他两个情形里:

在线程终止时,可以使用栈回滚机制。目的是检测线程中声明的对象是否有需要调用的析构函数。建议在终止线程前,从要求清理的函数返回。你不能确定_endthread()的调用会清理栈。这个行为是依赖实现的。

在使用函数longjmp跳出一个函数时,也使用栈回滚机制。尽可能避免使用longjmp。在时间关键代码中,不要依赖longjmp。

7.34. ​​​​​​​预处理器指示

就程序性能而言,预处理指示(任何以#开头的东西)是没有代价的,因为它们在程序编译前被解决了。

#if指示用于支持多平台或同一个源代码的多种配置。#if比if更高效,因为#if在编译时解决,而if在运行时解决。

在用于定义常量时,#define指示等价于const定义。例如,#define ABC 123与const int ABC = 123;一样高效,因为在大多数情形里,优化的编译器会用值替换一个整数常量。不过,在某些情形里,const int声明需要内存空间,而#define指示不需要内存空间。浮点常量总是需要内存空间,即使没有对它给出名字。在用作宏时,#define指示有时比函数更高效。参考第36页的讨论。

7.35. ​​​​​​​名字空间

就执行速度而言,使用名字空间没有代价。

以上是关于优化C++软件的主要内容,如果未能解决你的问题,请参考以下文章

优化C++软件——目录

优化C++软件——目录

Spark SQL CBO 基于代价的优化

优化C++软件

优化C++软件

优化C++软件