手把手写C++服务器:C++编译常见问题编译优化方法C++库发布方式
Posted 沉迷单车的追风少年
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手把手写C++服务器:C++编译常见问题编译优化方法C++库发布方式相关的知识,希望对你有一定的参考价值。
前言:前文( 手把手写C++服务器(2):C/C++编译链接模型、函数重载隐患、头文件使用规范)研究了一些C++编译链接的基本原理,这篇文章继续探索优化方法,以及C++库的三种发布方式(动态库、静态库、源码库)。
目录
(2)仅包含必要头文件,并尽量使用及提供前向声明版本的头文件
C++编译遇到的常见问题
虽然C++运行效率很高,但是编译效率却让人大为苦恼。当项目代码一旦变多之后,编译带来的时间问题极大程度上影响了开发效率。如果不进行任何编译优化,十万余行的代码都能编译几个小时!C++编译常见的问题如下:
(1)每个源文件独立编译,跨编译单元优化困难
C/C++的编译系统和其他高级语言存在很大的差异,其他高级语言中,编译单元是整个Module,即Module下所有源码,会在同一个编译任务中执行。而在C/C++中,编译单元是以文件为单位。每个.c/.cc/.cxx/.cpp源文件是一个独立的编译单元,导致编译优化时只能基于本文件内容进行优化,很难跨编译单元提供代码优化。
(2)每个编译单元,都需要独立解析所有包含的头文件
如果N个源文件引用到了同一个头文件,则这个头文件需要解析N次。如果头文件中有模板(STL/Boost),则该模板在每个cpp文件中使用时都会做一次实例化,N个源文件中的std::vector会实例化N次。
(3)虚函数对编译带来的负担
编译器处理虚函数的方法是:给每个对象添加一个指针,存放了指向虚函数表的地址,虚函数表存储了该类(包括继承自基类)的虚函数地址。如果派生类重写了虚函数的新定义,该虚函数表将保存新函数的地址,如果派生类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址将被添加到虚函数表中。
调用虚函数时,程序将查看存储在对象中的虚函数表地址,转向相应的虚函数表,使用类声明中定义的第几个虚函数,程序就使用数组的第几个函数地址,并执行该函数。
使用虚函数后的变化:
- 对象将增加一个存储地址的空间(32位系统为4字节,64位为8字节)。
- 每个类编译器都创建一个虚函数地址表。
- 对每个函数调用都需要增加在表中查找地址的操作。
如何减少代码编译时间?
(1)云编译、编译cache、并行编译等技术
虽然make、gcc等工具已经非常好用了,但是几乎每个大厂都有自己的编译工具,以我实习过的华为/百度为例,都是编译依赖全部上云,在云端进行cache、并行编译等优化,极大程度上提升了编译的速度。
(2)仅包含必要头文件,并尽量使用及提供前向声明版本的头文件
头文件使用规范建议:
- 将文件间的编译依赖降至最小。
- 将定义式之间的依赖关系降至最小,避免循环依赖。用#ifdef、#define、#endif等控制编译依赖。
- 让class名字、头文件名字、源文件名字直接相关,方面源码定位。
- 在头文件内写内部#include guard,不要在源文件写外部护套。
关于前向声明可参考:https://xduwq.blog.csdn.net/article/details/117291506
(3)gcc提供了不同等级的编译优化
GCC提供了为了满足用户不同程度的的优化需要,提供了近百种优化选项,用来对编译时间,目标文件长度,执行效率这个三维模型进行不同的取舍和平衡。优化的方法总体上将有以下几类:
- 精简操作指令。
- 尽量满足CPU的流水操作。
- 通过对程序行为地猜测,重新调整代码的执行顺序。
- 充分使用寄存器。
- 对简单的调用进行展开等等。
如果全部了解这些编译选项,对代码针对性的优化还是一项复杂的工作,幸运的是GCC提供了从O0-O3以及Os这几种不同的优化级别供大家选择,在这些选项中,包含了大部分有效的编译优化选项,并且可以在这个基础上,对某些选项进行屏蔽或添加,从而大大降低了使用的难度。
- O0:不做任何优化,这是默认的编译选项。
- O和O1:对程序做部分编译优化,编译器会尝试减小生成代码的尺寸,以及缩短执行时间,但并不执行需要占用大量编译时间的优化。
- O2:是比O1更高级的选项,进行更多的优化。GCC将执行几乎所有的不包含时间和空间折中的优化。当设置O2选项时,编译器并不进行循环展开以及函数内联优化。与O1比较而言,O2优化增加了编译时间的基础上,提高了生成代码的执行效率。
- O3:在O2的基础上进行更多的优化,例如使用伪寄存器网络,普通函数的内联,以及针对循环的更多优化。
- Os:主要是对代码大小的优化, 通常各种优化都会打乱程序的结构,让调试工作变得无从着手。并且会打乱执行顺序,依赖内存操作顺序的程序需要做相关处理才能确保程序的正确性。
(4)使用Pimpl,在公共接口里封装私有数据和方法
Pimpl(pointer to implementation, 指向实现的指针)是一种常用的,用来对“类的接口与实现”进行解耦的方法。这个技巧可以避免在头文件中暴露私有细节,因此是促进API接口与实现保持完全分离的重要机制。但是Pimpl并不是严格意义上的设计模式(它是受制于C++特定限制的变通方案),这种惯用法可以看作桥接设计模式的一种特例。
- 隐藏了更多的类实现
- 向私有结构添加新的数据成员不会影响二进制兼容性
- 包含类声明的头文件只需要包含类接口(而不是其实现)所需的那些文件
/* PublicClass.h */
#include <memory>
class PublicClass {
public:
PublicClass(); // Constructor
PublicClass(const PublicClass&); // Copy constructor
PublicClass(PublicClass&&); // Move constructor
PublicClass& operator=(const PublicClass&); // Copy assignment operator
PublicClass& operator=(PublicClass&&); // Move assignment operator
~PublicClass(); // Destructor
// Other operations...
private:
struct CheshireCat; // Not defined here
std::unique_ptr<CheshireCat> d_ptr_; // Opaque pointer
};
/* PublicClass.cpp */
#include "PublicClass.h"
struct PublicClass::CheshireCat {
int a;
int b;
};
PublicClass::PublicClass()
: d_ptr_(std::make_unique<CheshireCat>()) {
// Do nothing.
}
PublicClass::PublicClass(const PublicClass& other)
: d_ptr_(std::make_unique<CheshireCat>(*other.d_ptr_)) {
// Do nothing.
}
PublicClass::PublicClass(PublicClass&& other) = default;
PublicClass& PublicClass::operator=(const PublicClass &other) {
*d_ptr_ = *other.d_ptr_;
return *this;
}
PublicClass& PublicClass::operator=(PublicClass&&) = default;
PublicClass::~PublicClass() = default;
https://en.wikipedia.org/wiki/Opaque_pointer
| 使用impl 实现类 | 不使用impl实现类 |
优点 | 类型定义与客户端隔离, 减少#include 的次数,提高编译速度,库端的类随意修改,客户端不需要重新编译 | 直接,简单明了,不需要考虑堆分配,释放,内存泄漏问题 |
缺点 | 对于impl的指针必须使用堆分配,堆释放,时间长了会产生内存碎片,最终影响程序运行速度, 每次调用一个成员函数都要经过impl->xxx()的一次转发 | 库端任意头文件发生变化,客户端都必须重新编译 |
(5)C/C++ 跨编译单元的优化只能交给链接器
第一点说到C++的基本编译单元是单独的文件,编译优化只能针对单独的文件进行。因此跨单元的优化只能交给链接器解决。
链接器的工作流程:首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址,最后把所有的目标文件的内容写在各自的位置上,就生成一个可执行文件。链接的细节比较复杂,链接阶段是单进程,无法并行加速,导致大项目链接极慢。
(6)优化不必要类之间的继承
通过final关键字禁止继承,因为每一次继承,编译器都会产生额外的开销!
C++库的三种发布方式:静态库、动态库和源码库
三种库的基本特性
动态库 | 静态库 | 源码库 | |
发布方式 | 头文件和.so文件 | 头文件和.a文件 | 头文件和.cc文件 |
编译时间 | 短 | 短 | 长 |
查询依赖 | ldd查询 | 编译期信息 | 编译期信息 |
部署方式 | 可执行文件+动态库 | 单一可执行文件 | 单一可执行文件 |
主要时间差 | 编译期——运行期 | 编译库——编译应用程序 | 无 |
debug build or release build?
Debug通常称为调试版本,通过一系列编译选项的配合,编译的结果通常包含调试信息。 现代的编译器非常先进,在编译的过程会对编译进行非常多的优化,但是debug模式中将不会保存任何优化,为开发人员提供强大的应用程序调试能力。而Release通常称为发布版本,是为用户使用的,一般客户不允许在发布版本上进行调试。所以不保存调试信息,同时,它往往进行了各种优化,以期达到代码最小和速度最优。为用户的使用提供便利。
debug跟release在初始化变量时所做的操作是不同的,debug是将每个字节位都赋成0xcc, 而release的赋值近似于随机。如果你的程序中的某个变量没被初始化就被引用,就很有可能出现异常:用作控制变量将导致流程导向不一致;用作数组下标将会使程序崩溃;更加可能是造成其他变量的不准确而引起其他的错误。所以在声明变量后马上对其初始化一个默认的值是最简单有效的办法,否则项目大了你找都没地方找。代码存在错误在debug方式下可能会忽略而不被察觉到。debug方式下数组越界也大多不会出错,在release中就暴露出来了,这个找起来就比较难了。
只有debug版的程序才能设置断点、单步执行、使用 TRACE/ASSERT等调试输出语句。release不包含任何调试信息,所以体积小、运行速度快。
参考
- https://www.cnblogs.com/misserwell/p/4343927.html
- https://tech.meituan.com/2020/12/10/apache-kylin-practice-in-meituan.html
- https://xduwq.blog.csdn.net/article/details/117291506
- https://blog.csdn.net/zhangxiao93/article/details/74518204
- https://www.cnblogs.com/misserwell/p/4343927.html
以上是关于手把手写C++服务器:C++编译常见问题编译优化方法C++库发布方式的主要内容,如果未能解决你的问题,请参考以下文章
手把手写C++服务器:C/C++编译链接模型函数重载隐患头文件使用规范