C++性能优化--读书笔记

145 阅读1小时+

C++性能优化

优化是没有问题的,只有能显著地提升性能时才值得修改代码;我们需要学习高效的编程惯用法并在程序中实践,而不是过度的优化已有的代码;

吾尝终日而思矣,不如须臾之所学也;吾尝跂而望矣,不如登高之博见也。

第一章

  • 优化概述

    这儿一纳秒,那儿一纳秒

    1. 用好的编译器并用好编译器

      • 编译器优化

        打开编译器优化选项,在gcc/g++下使用 -O1 -O2 -O3 -Os -Ofast -Og 操作;

        -O 优化(大写,小写为生成指定文件),选项控制特定的优化

        1. (-O0) 不优化。 这是默认设置。
        2. (-O,-O1) 这两个命令的效果是一样的,目的都是在不影响编译速度的前提下,尽量采用一些优化算法降低代码大小和可执行代码的运行速度**。**-O 还会在机器上打开 -fomit-frame-pointer ,这样做不会干扰调试。
        3. (-O2) 该优化选项会牺牲部分编译速度,除了执行-O1所执行的所有优化之外,还会采用几乎所有的目标配置支持的优化算法,用以提高目标代码的运行速度
        4. (-O3) 该选项除了执行-O2所有的优化选项之外,一般都是采取很多向量化算法,提高代码的并行执行程度,利用现代CPU中的流水线,Cache等
        5. ( -Os) 这是在-O2的基础之上,尽量的降低目标代码的大小,这对于存储容量很小的设备来说非常重要。
        6. (-Ofast) 除了启用所有的-O3优化选项之外,也会针对某些语言启用部分优化。如:-ffast-math ,对于Fortran语言。
        7. (-Og) 优化调试体验。-Og应该是标准edit-compile-debug周期的优化级别选择,在保持快速编译和良好调试体验的同时,提供合理的优化级别。像-O0 -Og完全禁用了许多优化过程,因此控制它们的单个选项无效。除此以外-Og 使所有 -O1 优化标志,但那些可能会干扰调试的标志除外;

        注意:用GDB调试的时候需要关闭优化选项

    2. 使用最优算法

      选择一个最优算法对性能优化的效果最大。各种优化手段都能改善程序的性能。它们可以压缩以前看似低效的代码的执行时间,就像通过升级PC能让程序运行得更快一样。

    3. 使用更好的库并用好库

      优秀的函数库的API所提供的函数反映了这些API的惯用法,绝大部分情况下均可以满足最优性能。

    4. 减少内存分配

    5. 减少复制

    6. 移除计算

    7. 使用最优数据结构

    8. 提高并发

    9. 优化内存管理

第二章

  • 影响优化的行为

    1. 在处理器中,访问内存的性能开销远比其他操作的性能开销大。
    2. 非对齐访问所需的时间是所有字节都在同一个字中时的两倍。
    3. 访问频繁使用的内存地址的速度比访问非频繁使用的内存地址的速度快。
    4. 访问相邻地址的内存的速度比访问互相远隔的地址的内存快。
    5. 由于高速缓存的存在,一个函数运行于整个程序的上下文中时的执行速度可能比运行于测试套件中时更慢。
    6. 访问线程间共享的数据比访问非共享的数据要慢很多。
    7. 计算比做决定快。
    8. 每个程序都会与其他程序竞争计算机资源。
    9. 如果一个程序必须在启动时执行或是在负载高峰期时执行,那么在测量性能时必须加载负载。
    10. 每一次赋值、函数参数的初始化和函数返回值都会调用一次构造函数,这个函数可能隐藏了大量的未知代码。
    11. 有些语句隐藏了大量的计算。从语句的外表上看不出语句的性能开销会有多大。
    12. 当并发线程共享数据时,同步代码降低了并发量。(锁和上下文切换)

第三章

  • 测量性能

    在Linux下查看命令执行时间 time xxx

    Win下高效的计时函数:GetSystemTimeAsFileTime(相减结果存在问题,还没弄清楚)

    #include <windows.h>
    #include <iostream>
    using namespace std;
    
    // 用于计算两个FILETIME之间的差异并转换为微秒
    ULONGLONG FileTimeDiffToMicroseconds(const FILETIME& ft1, const FILETIME& ft2) {
        ULARGE_INTEGER t1, t2, diff;
        t1.HighPart = ft1.dwHighDateTime;
        t1.LowPart = ft1.dwLowDateTime;
        t2.HighPart = ft2.dwHighDateTime;
        t2.LowPart = ft2.dwLowDateTime;
        diff.QuadPart = t2.QuadPart - t1.QuadPart;
        cout << t1.QuadPart << endl;
        cout << t2.QuadPart << endl;
    
        return diff.QuadPart * 10; // 100ns to 1us
    }
    
    int main() {
        FILETIME start_time, end_time;
        GetSystemTimeAsFileTime(&start_time);
    
        // 你的代码段
        {
            
        }
    
        GetSystemTimeAsFileTime(&end_time);
    
        ULONGLONG elapsed_microseconds = FileTimeDiffToMicroseconds(end_time, start_time);
    
        // 输出结果
        std::cout << "Execution time: " << elapsed_microseconds << " microseconds" << std::endl;
    
        return 0;
    }
    

第四章

  • 优化字符串的使用

    原函数:

    std::string remove_ctrl(std::string s) {
    	 std::string result;
    	 for (int i=0; i<s.length(); ++i) {
    		 if(s[i] >= 0x20)
    		 result = result + s[i];
    	 }
    	 return result;
    }
    
    • 使用复合赋值操作避免临时字符串

      result += s[i]; 替代 result = result + s[i];

      result = result + s[i]; 会先构建一个临时字符串存放等号右端的值,再赋值给左边;

      字符串连接运算符的开销是很大的。它会调用内存管理器去构建一个新的临时字符串对象来保存连接后的字符串。

    • 通过预留存储空间减少内存的重新分配

      result.reserve(s.length()); 替代 std::string result;

      .capacity() 返回存储容量。.size() 返回字符串的长度大小。.length()和.size()等同。

      .max_size() 返回string对象中可存放的最大字符串的长度。

      字符串的字符缓冲区发生溢出时,std::string会申请两倍的内存空间。

      // 为容器预留足够的空间,避免不必要的重复分配,影响capacity。
      void reserve( size_type n) ;
      // 重新规划大小,变小,原来数据多余的截掉。变大,用该函数第二个参数填充;影响size。
      constexpr void resize(size_type n);
      constexpr void resize(size_type n, CharT c);
      
    • 消除对参数字符串的复制

      std::string const& s 替代 std::string s 使用引用传递参数,移除实参复制;

    • 使用迭代器

      for (int i=0; i<s.length(); ++i)

      替换

      for (auto it=s.begin(),end=s.end(); it != end; ++it)

    • 消除对返回的字符串的复制

      入参用引用来替换返回值

      void remove_ctrl_ref_result_it ( std::string& result, std::string const& s)

    • 用字符数组代替字符串

      当程序有极其严格的性能需求时,不使用 C++ 标准库,而是利用 C 风格的字符串函数来手动编写函数。C 风格的字符串函数更难以使用,但是它们却能带来显著的性能提升。要想使用 C 风格的字符串,程序员必须手动分配和释放字符缓冲区,或者使用静态数组并将其大小设置为可能发生的最差情况。

      void remove_ctrl_cstrings(char* destp, char const* srcp, size_t size) {
      	 for (size_t i=0; i<size; ++i) {
      		 if (srcp[i] >= 0x20)
      			 *destp++ = srcp[i];
      		  }
      	 *destp = 0;
      }
      

    也可以直接在初始参数中进行处理使用 string& erase (size_t pos, size_t len); 删除指定位置信息;

    1. 由于字符串是动态分配内存的,因此它们的性能开销非常大。它们在表达式中的行为与值类似,它们的实现方式中需要大量的复制。
    2. 将字符串作为对象而非值可以降低内存分配和复制的频率
    3. 为字符串预留内存空间可以减少内存分配的开销。
    4. 将指向字符串的常量引用传递给函数与传递值的结果几乎一样,但是更加高效。
    5. 将函数的结果通过输出参数作为引用返回给调用方会复用实参的存储空间,这可能比分配新的存储空间更高效。
    6. 即使只是有时候会减少内存分配的开销,仍然是一种优化。
    7. 有时候,换一种不同的算法会更容易优化或是本身就更高效。
    8. 标准库中的类是为通用用途而实现的,它们很简单。它们并不需要特别高效,也没有为某些特殊用途而进行优化。

第五章

  • 优化算法

    当一个程序需要在数秒内执行完毕,实际上却要花费数小时时,唯一可以成功的优化方法可能就是选择一种更高效的算法了。(这里并不会介绍什么算法,因为不同的设计有着不同的最优解)

    O(1),即常量时间

    它们的开销是固定的,完全不取决于输入数据的规模。

    O(n),即线性时间

    算法需要花费的时间与输入数据的规模成正比。这种算法称为线性时间算法。

    O(log2n)

    时间开销比线性小。例如,二分查找算法,其时间开销是O(log2n)。

    O(n log2n)

    算法可能具有超线性时间开销。例如,快速排序的平均时间。

    O(n^2)、O(n^3) 等

    例如,双层n步循环;

    在算法中需要知道:最优情况平均情况最差情况的时间开销;

    • 查找算法的时间开销

      • **线性查找算法:**时间开销为O(n),它的开销虽然大,却极其常用。它可以用于无序表。只要能够比较关键字是否相等,即可使用它。
      • **二分查找算法:**时间开销是O(log2n),效率更高,二分查找算法要求表已经按照查找关键字完成排序,不仅需要可以比较查找关键字是否相等,还需要可以比较它们之间的大小关系。二分查找是最常用的算法。
      • **插补查找:**与二分查找类似,也是将有序表分为两部分,不过它用到了查找关键字的一些其他特性来改善分块性能。当查找关键字均匀分布时,插补查找的性能可以达到非常高效的O(log log n) 。
      • **散列法(hash):**可以以平均O(1)的时间找出一条记录的。 散列法无法工作于键值对的链表上,它需要一种特殊结构的表。例如:unordered_map — 哈希map

      当n很小时,所有算法的时间开销都一样;比如在redis中当数据量较小时,会采用不同的存储结构;

    • 排序算法的时间开销

      排序算法不记录了!十大经典排序算法 | 菜鸟教程 (runoob.com)

    • 优化模式

      • 预计算

        通过在程序执行至热点代码之前,提前计算来达到从热点代码中移除计算的目的。

        例如:(将需要计算的常量提前处理)

        int sec_per_day = 60 * 60 * 24;

        int sec_per_day = 86400;

      • 延迟处理

        通过在真正需要执行计算时才执行计算,可以将计算从某些代码路径上移除。

        两段构建:在构建对象时,我们并不是一气呵成,而是仅在构造函数中编写建立空对象的最低限度的代码。稍后,程序再调用该对象的初始化成员函数来完成构建。

        写时复制:写时复制是指当一个对象被复制时,并不复制它的动态成员变量,而是让两个实例共享动态变量。只在其中某个实例要修改该变量时,才会真正进行复制。

      • 批量处理

        每次对多个元素一起进行计算,而不是一次只对一个元素进行计算。

      • 缓存

        通过保存和复用昂贵计算的结果来减少计算量,而不是重复进行计算。

        **动态规划:**通过计算子问题并缓存结果来提高具有递归关系的计算的速度。

        **线程池:**缓存了那些创建开销很大的线程。

      • 特化

        特化与泛化相对。特化的目的在于移除在某种情况下不需要执行的昂贵的计算。

      • 提高处理量

        通过一次处理一大组数据来减少循环处理的开销。

      • 散列法

        计算可变长度字符串等大型数据结构的压缩数值映射(散列值)。在进行比较时,用散列值代替数据结构可以提高性能。

第六章

  • 优化动态分配内存的变量

    动态分配内存的变量就是 C++ 程序中最大的“性能杀手”

    • 变量的存储期

      • 静态存储期

        静态变量被分配在编译器预留的内存空间中。在程序编译时,编译器会为每个静态变量分配一个固定位置和固定大小的内存空间。静态变量的内存空间在程序的整个生命周期内都会被一直保留。所有的全局静态变量都会在程序执行进入 main() 前被构建,在退出 main() 之后被销毁。

      • 线程局部存储期

        线程局部变量在进入线程时被构建,在退出线程时被析构。它们的生命周期与线程的生命周期一样。每个线程都包含一份这类变量的独立的副本。

        11开始,用 thread_local 存储类型指示符关键字声明的变量具有线程局部存储期。

      • 自动存储期

        变量被分配在编译器在函数调用栈上预留的内存空间中。在编译时,编译器会计算出距离栈指针的偏移量,自动变量会以该偏移量为起点,占用一段固定大小的内存,但是自动变量的绝对地址直到程序执行进入变量的作用域内才会确定下来。

      • 动态存储期

        变量被保存在程序请求的内存中。程序会在 new中显式地为动态变量请求存储空间并构建动态变量,稍后,程序在 delete中显式地析构动态变量,并将变量所占用的内存返回给内存管理器。

    • 使用智能指针实现动态变量所有权的自动化

      • 动态变量所有权的自动化

        智能指针会通过耦合动态变量的生命周期与拥有该动态变量的智能指针的生命周期,来实现动态变量所有权的自动化。

        • 当程序执行超出智能指针实例所属的作用域时,具有自动存储期的智能指针实例会删除它所拥有的动态变量
        • 声明为类的成员函数的智能指针实例在类被销毁时会删除它所拥有的动态变量。
        • 当线程正常终止时,具有线程局部存储期的智能指针实例会删除它所拥有的动态变量。
        • 当程序结束时,具有静态存储期的智能指针实例会删除它所拥有的动态变量。
      • 共享动态变量的所有权的开销更大

        C++ 允许多个指针和引用指向同一个动态变量。

        std::shared_ptr在所有权被共享时管理被共享的所有权的。当一个 shared_ptr 被赋值给另一个 shared_ptr 时,引用计数会增加。当 shared_ptr 被销毁后,析构函数会减小引用计数;如果此时引用计数变为了 0,还会删除动态变量。

        由于在引用计数上会发生性能开销昂贵的原子性的加减运算,因此 shared_ptr 可以工作于多线程程序中。std::shared_ptr 也因此比 C 风格指针和std::unique_ptr 的开销更大。

    • 动态变量有运行时开销

      动态变量分配内存的开销是数千次内存访问。平均来看,这种开销太大了。我们已经在第 4 章中反复看到,只是移除一次对内存管理器的调用就可以带来显著的性能提升。

      果函数找到了一块正好符合大小的内存,它会将这块内存从集合中移除并返回这块内存或择拆分内存块然后只返回其中一部分。

      如果没有可用的内存块来满足请求,那么分配函数会调用操作系统内核,从系统的可用内存池中请求额外的大块内存,这样调用的开销非常大,可能会导致初次访问时发生更大的延迟。

      未使用内存块的集合是由程序中的所有线程所共享的资源。对未使用内存块的集合所进行的改变都必须是线程安全的。如果若干个线程频繁地调用内存管理器分配内存或是释放内存,那么它们会将内存管理器视为一种资源进行竞争,导致除了一个线程外,所有线程都必须等待。

    • 使用静态数据结构

      当向容器中添加新的元素时,有时这种开销非常昂贵。

      std::string 和 std::vector 超出会重新分配它们的存储空间。

      std::map 和std::listt 会为每个新添加的元素分配一个新节点。

      • 用std::array替代std::vector

        数组大小固定且不会调用内存管理器的 std::array,从性能优化的角度看,std::array 几乎与 C 风格的数组不分伯仲;从编程的角度看,std::array 与标准库容器具有相似性。并非所有情况下都进行替代,而是建议在属于固定数组大小是,采用array;

      • 在栈上创建大块缓冲区

        随着字符串的增长,需要重新分配内存空间,因此估算出比较合理的长度进行创建,尽管在栈上可以声明的总存储空间是有限的;

      • 静态地创建链式数据结构

        可以使用静态初始化的方式构建具有链式数据结构的数据。

      • 用环形缓冲区替代双端队列

      • 在数组中创建二叉树

      最后两项并不建议尝试;

    • 使用std::make_shared替代new表达式

      下面方式调用两次内存管理器:第一次用于创建 MyClass 的实例,第二次用于创建被隐藏起来的引用计数对象。

      std::shared_ptr<MyClass> p(new MyClass("hello", 123));

      使用 make_shared 函数可以分配一块内存来同时保存引用计数和 MyClass 的一个实例。

      std::shared_ptr<MyClass> p = std::make_shared<MyClass>("hello", 123);

    • 不要无谓地共享所有权

      当各个指针的生命周期会不可预测地发生重叠时,shared_ptr 非常有用。但它的开销也很昂贵。增加和减少 shared_ptr中的引用计数并不是执行一个简单的增量指令,而是使用完整的内存屏障进行一次非常昂贵的原子性增加操作。但是在这些引用中,没有哪个的寿命比“主引用”长。

    • 使用“主指针”拥有动态变量

      std::shared_ptr 很简单,但开销是昂贵的;

      如果存在主引用,那么我们可以使用 std::unique_ptr 高效地实现它。然后,我们可以在函数调用过程中,用指针或是引用来引用该对象,达到shared_ptr的效果。那么普通指针和引用就会被记录为“无主”指针。当不确定指针的拥有者时,可以使用 std::shared_ptr。

      并不建议,当使用 .get() 获取原始指针,指针可能提前释放,导致宕机

    • 预分配动态变量以防止重新分配

      在 std::string 和 std::vector 使用reserve()提前分配空间;

    • 在循环外创建动态变量

      for (auto& filename : namelist) { 
          std::string config; 
          ReadFileXML(filename, config); 
          ProcessXML(config); 
      }
      // 将config放在外层
      std::string config; 
      for (auto& filename : namelist) { 
          config.clear(); 
          ReadFileXML(filename, config); 
          ProcessXML(config); 
      }
      
    • 移除无谓的复制

      如果a和b都是BigClass 类的实例,那么赋值语句a = b;会调用BigClass的赋值运算符成员函数。赋值运算符可以只是简单地将b的字段全部复制到a中去。如果BigClass中有一个保存有数百万元素的std::map 或是一个保存有数百万字符的字符数组,那么赋值语句的开销会非常大。

    • 在类定义中禁止不希望发生的复制

      使用delete

      // 在C++11之前禁止复制的方法 
      class BigClass { 
      private: 
          BigClass(BigClass const&); 
          BigClass& operator=(BigClass const&); 
      public: 
          ... 
      };
      // 在C++11中禁止复制的方法 
      class BigClass { 
      public: 
          BigClass(BigClass const&) = delete; 
          BigClass& operator=(BigClass const&) = delete; 
          ... 
      };
      

      任何企图对以这种方式声明的类的实例赋值——或通过传值方式传递给函数,或通过传值方式返回,或是将它用作标准库容器的值时——都会导致发生编译错误。

    • 移除函数参数上的复制

      采用引用传参

      int Sum(std::list<int> v)int Sum(std::list<int>& v);

    • 实现写时复制惯用法

      写时复制(copy on write,COW)是一项编程惯用法;

      一个带有动态变量的对象被复制时,也必须复制该动态变量。这种复制被称为深复制

      复制指针地址,而不是复制指针指向的变量,这种复制被称为浅复制

      写时复制的核心思想是:在其中一个副本被修改之前,一个对象的两个副本一直都是相同的。直到其中一个实例或另外一个实例被修改,两个实例能够共享那些指向复制开销昂贵的字段的指针。先进行一次“浅复制”,然后将深复制推迟至对象的某个元素发生改变时。

    • 实现移动语义

      将一个对象赋值给一个变量时,会导致其内部的内容被复制。这个运行时开销非常大。而在这之后,原来的对象立即被销毁了。复制的努力也随之化为乌有,因为本来可以复用原来对象的内容的。

      class vector
      {
      public:
          void push_back(const MyClass& value)  // const MyClass& 左值引用
          {
              // 执行拷贝操作
          }
      
          void push_back(MyClass&& value)  // MyClass&& 右值引用
          {
              // 执行移动操作
          }
      };
      
    • std::swap():“穷人”的移动语义

      互换两个变量所保存的内容

      // 在移动语义出现之前
      template <typename T> void std::swap(T& a, T& b) { 
          T tmp = a; // 为a创建一份新的临时副本 
          a = b;     // 将b复制到a中,放弃a以前的值 
          b = tmp;   // 将临时副本复制到b中,放弃b以前的值 
      }
      
      // std::swap() 出现
      std::vector<int> a(1000000,0); 
      std::vector<int> b; // b是空的 
      std::swap(a,b);     // 现在b有100万个元素
      // 或采用容器中的swap成员函数
      a.swap(b);
      
    • 移动语义的微妙之处 move

      我们需要知道什么是左值和右值:

      左值:可以出现在 operator= 左侧的值;

      右值:可以出现在 operator= 右侧的值;

      当然这并不是全部正确的,但百分之90多都是这种情况,但有例外:

      std::string();类似这种一个类加括号也就是临时变量都是右值;

      函数的形参永远是左值;

      移动语义:

      原来指针A指向的内存交给了指针B,指针A不指向任何内存。

      std::move并不能移动任何东西,他唯一的功能是将左值转化为右值,继而我们可以用右值引用引用这个值,以用于移动语义,真正的移动是移动构造函数

    • 扁平数据结构

      一个数据结构中的元素被存储在连续存储空间中时,称这个数据结构为扁平的。相比于通过指针链接在一起的数据结构,扁平数据结构具有显著的性能优势。

      • 相比于通过指针链接在一起的数据结构,创建扁平数据结构实例时调用内存管理器的 开销更小。如:vector 存储就为一片连续的空间,当新增数据超出容量时,会开辟一段原来2倍的空间,将原来的数据拷贝过去;
      • 紧凑的数据结构仍然有助于改善缓存局部性。扁平数据结构在局部缓存性上的优势使得它们更加高效。

第七章

  • 优化热点语句

    语句级别的优化可以被模式化,为从执行流中移除指令的过程;

    优化的热点代码的因素:

    • 循环

      循环中的语句开销是语句各自的开销乘以它们被重复执行的次数。热点循环必须由开发人员自己找出来。

    • 频繁被调用的函数

      函数的开销是函数自身的开销乘以它被执行的次数。分析器可以直接指出热点函数。

    • 贯穿整个程序的惯用法

      这是一个与C++语句和惯用法有关的总类别。如果在程序中广泛地使用了这些惯用法,那么将它替换为性能开销更小的惯用法可以提升程序的整体性能。

    语句级别的优化带来的回报比优化内存分配和复制要小。

    从循环中移除代码

    一个循环是由两部分组成的:一段被重复执行的控制语句 和 一个确定需要进行多少次循环的控制分支;

    • 缓存循环结束条件值

      在进入循环时预计算并缓存循环结束条件值,即缓存开销昂贵的 strlen() 的返回值,来提高程序性能。

      for (size_t i = 0; i < strlen(s); ++i){}

      ⇒ 使用变量缓存结果

      for (size_t i = 0, len = strlen(s); i < len; ++i){}

    • 使用更高效的循环语句

      将一个 for 循环简化为 do 循环通常可以提高循环处理的速度。(但是在不同的编译器上可能有不同的结果,作者在vs2010上,性能是提高了,但是在vs2015上新能降低了)

    • 用递减替代递增

      对循环进行递减优化

      for (int i = (int)strlen(s)-1; i >= 0; --i)

      请注意循环的接受条件件是 i >= 0。如果 i 是无符号的,从定义上说,它总是大于或等于 0,那么循环就永远无法结束。在采用递减方式时,这是一个非常典型的错误。

      并不建议使用,在日常中操作中,容易遗忘结束判断,导致一直循环

    • 从循环中移除不变性代码

      现代编译器非常善于找出在循环中被重复计算的具有循环不变性的代码,然后将计算移动至循环外部来改善程序性能。当调用的函数很复杂,开发人员必须自己找出具有循环不变性的函数调用并将它们从循环中移除。

    • 从循环中移除无谓的函数调用

      一次函数调用可能会执行大量的指令。如果函数具有循环不变性,那么将它移除到循环外有助于改善性能。在循环中不会改变它的参数,那么这个函数就具有循环不变性,可以将其移动到循环外。

    • 从循环中移除隐含的函数调用

      C++ 代码还可能会隐式地调用函数,而没有这种很明显的调用语句。

      • 声明一个类实例(调用构造函数) • 初始化一个类实例(调用构造函数) • 赋值给一个类实例(调用赋值运算符) • 涉及类实例的计算表达式(调用运算符成员函数) • 退出作用域(调用在作用域中声明的类实例的析构函数) • 函数参数(每个参数表达式都会被复制构造到它的形参中) • 函数返回一个类的实例(调用复制构造函数,可能是两次)

      • 向标准库容器中插入元素(元素会被移动构造或复制构造) • 向矢量中插入元素(如果矢量重新分配了内存,那么所有的元素都需要被移动构造或是 复制构造

      这些函数调用被隐藏起来了。你从表面上看不出带有名字和参数列表的函数调用。它们看起来更像赋值和声明。我们很容易误以为这里没有发生函数调用。

      如果将函数签名从通过值传递实参修改为传递指向类的引用或指针,有时候可以在进行隐式函数调用时移除形参构建。

      案例:

      for (...) {
       std::string s("a");
       ...
       s += "b";
      }
      

      在 for 循环中声明 s 的开销是昂贵的。在每次循环都会调用 s 的析构函数,而析构函数会释放为 s 动态分配的内存,因此当下一次进入循环时,一定会重新分配内存

      std::string s;
      for (...) {
       s.clear();
       s += "a";
       ...
       s += "b";
      }
      

      现在,不会再在每次循环中都调用 s 的析构函数了。这不仅仅是在每次循环中都节省了一次函数调用,同时还带来了其他效果——由于 s 内部的动态数组会被复用,因此当向 s 中添加字符时,可能会移除一次对内存管理器的调用。

    • 从循环中移除昂贵的、缓慢改变的调用

      有些函数调用虽然并不具有循环不变性,但是也可能变得具有循环不变性。

      比如:在日志应用程序中调用获取当前时间的函数。它只需要几条指令即可从操作系统获取当前时间,但是却需要花费些时间来格式化显示时间。

    • 将循环放入函数以减少调用开销

      如果程序要遍历字符串、数组或是其他数据结构,并会在每次迭代中都调用一个函数,那么可以通过一种称为循环倒置的技巧来提高程序性能。

      void test() {
          ...
      }
      for (int i = 0; i < 10; ++i) {
          test();
      }
      

      转化 ⇒ 在函数中进行循环,减少函数调用;

      void test() {
          for (int i = 0; i < 10; ++i) {
              
          }
      }
      
      test();
      
    • 不要频繁地进行操作

      在一个程序的主循环中每秒处理约 1000 个事务,那么它应当每隔多长时间检测一次是否有终止命令,这取决于两件事情:程序需要以多快的速度响应终止请求,以及程序检查终止命令的开销。

      当配置可能变化时,一局游戏需要拉取最新的配置,在策划接受延时的情况下,可以开启一个新线程,用于定时刷新配置;

    从函数中移除代码

    与循环一样,函数也包含两部分:一部分是由一段代码组成的函数体,另一部分是由参数列表和返回值类型组成的函数头。与优化循环一样,这两部分也可以独立优化。

    • 函数调用的开销

      每次调用函数时,计算机都会在执行代码中保存它的位置,将控制权交给函数体,接着会返回到函数调用后的下一条语句,高效地将函数体插入到指令执行流中。

      每次程序调用一个函数时,都会发生类似下面这样的处理:

      依赖于处理器体系结构和优化器设置

      (1) 执行代码将一个栈帧推入到调用栈中来保存函数的参数和局部变量。 (2) 计算每个参数表达式并复制到栈帧中。 (3) 执行地址被复制到栈帧中并生成返回地址。 (4) 执行代码将执行地址更新为函数体的第一条语句(而不是函数调用后的下一条语句)。 (5) 执行函数体中的指令。 (6) 返回地址被从栈帧中复制到指令地址中,将控制权交给函数调用后的语句。 (7) 栈帧被从栈中弹出。

      • 函数调用的基本开销

        • 函数参数

          除了计算参数表达式的开销外,复制每个参数的值到栈中也会发生开销。如果只有几个小型的参数,那么可能可以很高效地将它们传递到寄存器中;但是如果有很多参数,那么至少其中一部分需要通过栈传递。

        • 成员函数调用(与函数调用)

          每个成员函数都有一个额外的隐藏参数:一个指向 this 类实例的指针,而成员函数正是通过它被调用的。这个指针必须被写入到调用栈上的内存中或是保存在寄存器中。

        • 调用和返回

          调用和返回对程序的功能没有任何影响。当函数很小且在函数被调用之前已经定义了函数时,许多编译器都会试图内联函数体。如果不能内联函数,调用和返回就会产生开销。

      • 虚函数的开销

        在 C++ 中可以将任何成员函数定义为虚函数。每个带有虚成员函数的实例都有一个无名指针指向一张称为**虚函数表。**这张表指向类中可见的每个虚函数签名所关联的函数体。

        调用虚函数的代码会解引指向类实例的指针,来获得指向虚函数表的指针。这段代码会为虚函数表加上索引来得到函数的执行地址。

        因此,实际上这里会为所有的虚函数调用额外地加载两次非连续的内存,每次都会增加高速缓存未命中的几率和发生流水线停顿的几率。

      • 继承中的成员函数调用

        当一个类继承另一个类时,继承类的成员函数可能需要进行一些额外的工作。

        • 继承类中定义的虚成员函数

          代码要给 this 类实例指针加上一个偏移量,来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。且这些指令通常都比较慢,因为它们会进行额外的计 算。

        • 多重继承的继承类中定义的成员函数调用

          代码必须向 this 类实例指针中加上一个偏移量来组成指向多重继承类实例的指针

        • 多重继承的继承类中定义的虚成员函数调用

          对于继承类中的虚成员函数调用,如果继承关系最顶端的基类没有虚成员函数,那么代码必须要给 this 类实例指针加上一个偏移量来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。

        • 虚多重继承

          为了组成虚多重继承类的实例的指针,代码必须解引类实例中的表,来确定要得到指向虚多重继承类的实例的指针时需要加在类实例指针上的偏移量

      • 函数指针的开销

        C++ 提供了函数指针,这样当通过函数指针调用函数时,代码可以在运行时选择要执行的函数体。除了基本的函数调用和返回开销外,这种机制还会产生其他额外的开销

        • 函数指针(指向非成员函数和静态成员函数的指针

          当函数指针被解引后,这个函数将会在运行时会被调用。通过将一个函数赋值给函数指针,程序可以显式地通过函数指针选择要调用的函数。

          代码必须解引指针来获取函数的执行地址。编译器也不太可能会内联这些函数。

        • 成员函数指针

          成员函数指针声明同时指定了函数签名和解释函数调用的上下文中的类

          程序通过将函数赋值给函数指针,显式地选择通过成员函数指针调用哪个函数。

          成员函数指针有多种表现形式,一个成员函数只能有一种表现形式。

          我们有理由认为一个成员函数指针会出现最差情况的性能。

      • 函数调用开销总结

        因此,C 风格的不带参数的 void 函数的调用开销是最小的。如果能够内联它的话,就没有开销;即使不能内联,开销也仅仅是两次内存读取加上两次程序执行的非局部转移 。

        如果基类没有虚函数,而虚函数在多重虚拟继承的继承类中,那么这是最坏的情况。在这种情况下,代码必须解引类实例中的函数表来确定加到类实例指针上的偏移量,构成虚拟多重继承函数的实例的指针,接着解引该实例来获取虚函数表,最后索引虚函数表得到函数执行地址

    • 移除未使用的多态性和不使用的接口

      C++ 中,虚成员函数多用来实现运行时多态性。通过对象实例的地址得到虚函数表的地址,然后通过**遍历其中函数指针,并调用相应的函数,**未使用多态可能会带来不必要的性能开销。

    • 使用模板在编译时选择实现

      抽象基类中定义的接口是非常严格的,继承类必须实现在抽象基类中定义的所有函数。而通过模板定义的接口就没有这么严格了。只有参数中那些实际会被模板的某种特化所调用的函数才需要被定义。模板编程提供了一种强力的优化手段,需要学习如何高效地使用 C++ 的这个特性。

    • 移除对动态链接库的调用

      当动态链接库被按需加载后在程序中显式地设置函数指针,或是在程序启动时自动地加载动态链接库时隐式地设置函数指针,然后通过这个函数指针调用动态链接库;

      能显改善性能,但是并非每个项目都适用,有些动态链接库调用是必需的。例如,应用程序可能需要实现第三方插件库。

    • 使用静态成员函数取代成员函数

      每次对成员函数的调用都有一个this 指针,通过对 this 指针加上偏移量可以获取类成员数据。虚成员函数必须解引 this 指针来获得虚函数表指针。

      将成员函数声明为静态函数。静态成员函数没有隐式 this 指针,可以通过普通函数指针,而不是开销更加昂贵的成员函数指针找到它们

    • 将虚析构函数移至基类中

      任何有继承类的类的析构函数都应当被声明为虚函数(这是C++基础)。这样 delete 表达式将会引用一个指向基类的指针,继承类和基类的析构函数都会被调用。

    优化表达式

    在语句级别下面是涉及基本数据类型(整数、浮点类型和指针)的数学计算。这也是最后的优化机会。如果一个热点函数中只有一条表达式,那么它可能是唯一的优化机会。

    • 简化表达式

      C++ 会严格地以运算符的优先级和可结合性的顺序来计算表达式。

      y = a*x*x*x + b*x*x + c*x + d; 这条语句将会执行 6 次乘法运算和 3 次加法运算。

      ⇒ 转化

      y = (((a*x + b)*x) + c)*x + d; 优化后的语句只会执行 3 次乘法运算和 3 次加法运算。

      C++ 之所以不会重排序算术表达式是因为这非常危险。

      如果表达式中都是整数类型,那么 相除 的结果将不精确,当进行重新排列后,会导致得出了一个错得离谱的结果。

    • 将常量组合在一起

      编译器可以帮们做的一件事是计算常量表达式。

      seconds = 24 * 60 * 60 * days;

      编译器会计算表达式中的常量部分,产生类似下面的表达式:seconds = 86400 * days;

      但是,如果程序员这样写:

      seconds = 24 * days * 60 * 60;

      编译器只能在运行时进行乘法计算了。

      建议将常量计算好后再进行处理

    • 使用更高效的运算符

      例如,整数表达式 x*4 可以被重编码为更高效的 x<<2

      例如,整数表达式 x*9 *可以被重写为 x**8+x*1,进而可以重写为 (x<<3)+x

    • 使用整数计算替代浮点型计算

      浮点型计算的开销是昂贵的。浮点数值内部的表现比较复杂,它带有一个整数型尾数、一个独立的指数以及两个符号。

    • 双精度类型可能会比浮点型更快

      这并没有意义,在实际项目中不需要过多的纠结这微小的差异

    优化控制流程惯用法

    由于当指令指针必须被更新为非连续地址时在处理器中会发生流水线停顿,因此计算比控制流程更快。C++ 编译器会努力地减少指令指针更新的次数。了解这些知识有助于我们编写更快的代码。

    • 用switch替代if-else

      if-else 语句中的流程控制是线性的:首先测试 if 条件,如果结果为真,执行第一个代码块;否则,接着测试 else if 条件;如果测试一个变量的值 n 次,那么需要 n 个 if-then-else if 语句块。如果这段代码执行得非常频繁,那么开销将会显著地增加。

    • 用虚函数替代switch或if

      虚函数调用会通过索引虚函数表得到虚函数体的地址,这个操作的开销总是常量时间。

      Animal::move() {
       if (this->animalType == TIGER) {
      	 pounce();
       }
       else if (this->animalType == RABBIT) {
      	 hop();
       }
       else if (...)
       ...
      }
      

      虚函数调用会通过索引虚函数表得到虚函数体的地址。这个操作的开销总是常量时间。因此,基类中的虚成员函数 move() 会被继承类中表示各种动物的 pounce、hop 或 swim 等函数重写。

    • 使用无开销的异常处理

      异常处理会使程序变得更加庞大和更加慢,因此关闭编译器的异常处理开关是一项优化,但是使用异常处理可以使程序在通常运行时更加快速,在出错时表现得更加优秀。

      C++11 中引入了一种新的异常规范,称为 noexcept。声明一个函数为 noexcept 会告诉编译器这个函数不可能抛出任何异常。如果这个函数抛出了异常,那么如同在 throw() 规范中一样,terminate() 将会被调用。

    小结

    • 除非有一些因素放大语句的性能开销,否则不值得对语句的性能优化,因为性能提升不大。 • 循环中的语句的性能开销被放大的倍数是循环的次数。 • 函数中的语句的性能开销被放大的倍数是其在函数中被调用的次数。 • 被频繁地调用的编程惯用法的性能开销被放大的倍数是其被调用的次数。 • 有些 C++ 语句(赋值、初始化、函数参数计算)中包含了隐藏的函数调用。 • 调用操作系统的函数的开销是昂贵的。 • 一种有效的移除函数调用开销的方法是内联函数。 • double 计算可能会比 float 计算更快。

第八章

  • 使用更好的库

    在性能优化阶段,库是一个需要特别注意的地方。库提供了组装程序的基础。库函数和类常常被用在嵌套循环的最底层,因此通常它们都是热点代码。编译器或是操作系统提供的库在使用时非常高效。

    优化标准库的使用

    C++ 标准库中的许多部分都包含了可以产生极其高效的代码的模板类和函数。未经测试的新特性都会在 Boost 库(www.boost.org)中孕育多年后,才会被标准委员会所采纳。

    • C++标准库的哲学

      C++ 标准库之所以提供这些函数和类,是因为要么无法以其他方式提供这些函数和类,要么这些函数和类会被广泛地用于多种操作系统上。

    • 使用C++标准库的注意事项

      尽管下面的讨论是针对 C++ 标准库的,但它同样适用于标准 Linux 库、POSIX 库或是其他任何被广泛使用的跨平台库。

      在使用中可能会出现问题

      1. 标准库的实现中有 bug

      2. 标准库的实现可能不符合 C++ 标准

        库的一部分也可能会领先或是落后于另一部分。

      3. 对标准库开发人员来说,性能并非最重要的事情

        对于标准库的开发人员来说,特性的覆盖率、简单性、可维护性可移植性很重要,因为库会被长期使用。性能有时会排在这些更加重要的因素之后。

      4. 部分库的实现可能会让一些优化手段无效

      5. 并非 C++ 标准库中的所有部分都同样有用

        有些 C++ 特征,加入到标准中多年以后才被开发人员所使用。这些特性实际上使得编码变得更加困难,而不是简单。

      6. 标准库不如最好的原生函数高效

        标准库没有为某些操作系统提供异步文件 I/O 等特性。性能优化开发人员只能通过调用原生函数,牺牲可移植性来换取运行速度。

    • 优化现有库

      优化现有库就如同扫雷一样。这是可能的,也是有必要的。但是这是一项需要耐心和对细节极度专注的工作,否则就会引起爆炸!

      除非有极致性能要求,非专业请勿修改

      • 改动越少越好

        不要向类或函数中添加或移除功能,也不要改变函数签名。这类改动几乎肯定会破坏修改后的库与使用库的程序之间的兼容性,改动越少越好。

      • 添加函数,不要改动功能

        是在现有库中加入新函数和类是相对安全的。

        安全地修改现有库方法:

        • 向现有库中添加函数,将循环处理移动到这个新函数内,在代码中使用编程惯用法。
        • 通过向现有库中添加接收右值引用作为参数的新函数,重载现有库中的旧函数来在老版本的库中实现移动语义。
    • 设计优化库

      面对设计糟糕的库,性能优化开发人员几乎无能为力。但是面对一个空白屏幕时,性能优化开发人员则有更大的使用最佳实践以及避免性能陷阱的余地。

      • 草率编码后悔多

        接口的稳定性是设计可持续交付的库的核心。匆匆忙忙地设计库或是在库中揉入一堆强耦合的函数,都会导致无法定义出优秀的调用规则和返回规则,无法实现优秀的内存分配行为以及效率。设计优化库与设计其他 C++ 代码是一样的,不过风险更高。项目前期完善规范和设计、文档以及模块化测试,都会在开发库这样的关键代码时派上用场。

        测试用例很关键

        测试用例可以帮助我们在设计库的过程中识别出依赖性关系和耦合性。

        测试用例可以帮助设计人员了解如何使用库。

        测试用例可以帮助我们测量库的性能。

      • 在库的设计上,简约是一种美德

        简约的库都是简单的。它们是由非成员函数或是简单的类组成的。

        简约是持续地适用单一职责原则以及接口隔离原则等优秀 C++ 开发原则的终极结果。

        单一职责原则、开闭原则、里氏替换原则、 接口隔离原则 和 依赖倒置原则;

      • 不要在库内分配内存

        由于内存分配非常昂贵,如果可能的话,请在库外部进行内存分配。时尽可能地重用内存,而不是分配新的存储空间。

        将内存分配移动到库外部,还可以减少在函数之间传递数据时保存数据的存储空间被复制的次数。

      • 函数比框架更容易优化

        库可以分为两种:函数库框架

        函数的优势在于我们可以独立地测量和优化它们的性能。调用一个框架会牵扯到它内部的所有类和函数,使得修改变得难以隔离和测试。这使得它们难以优化。

      • 扁平继承层次关系

        多数抽象都不会有超过三层类继承层次:一个具有通用函数的基类,一个或多个实现多态的继承类,以及一个在非常复杂的情况下可能会引入的多重继承混合层。

        一旦继承层次超过了三层,这就是一个信号,表明类的层次结构不够清晰,从性能优化的角度看,继承层次越深,在成员函数被调用时引入额外计算的风险就越高。

      • 扁平调用链

        绝大多数抽象的实现都不会超过三层嵌套函数调用:一种非成员函数或是成员函数实现策略,调用某个类的成员函数,调用某个实现了抽象或是访问数据的公有或私有的成员函数。

      • 扁平分层设计

      • 避免动态查找

        动态查找天生低效。时间开销与待查找的文件大小成正比。

      • 留意“上帝函数”

        “上帝函数”是指实现了高级策略的函数。如果在程序中使用这种函数,会导致链接器向可执行文件中添加许多库函数。在嵌入式系统中,可执行文件的增大会耗尽物理内存;在桌面级计算机上,可执行文件的增大则会增加虚拟内存分页。

    小结

    • C++ 标准库之所以提供这些函数和类,是因为要么无法以其他方式提供这些函数和类,要么这些函数和类会被广泛地用于多种操作系统上。 • 在标准库实现中也存在 bug。 • 没有一种“完全符合标准的实现”。 • 标准库不如最好的原生函数高效。 • 当要升级库时,尽量只进行最小的改动。 • 接口的稳定性是可交付的库的核心。 • 在对库进行性能优化时,测试用例非常关键。 • 设计库与设计其他 C++ 代码是一样的,只是风险更高。 • 多数抽象都不需要超过三层类继承层次。 • 多数抽象的实现都不需要超过三层嵌套函数调用。

第九章

  • 优化查找和排序

    在C++标准库中的头文件中包含了几种基于迭代器的查找序列容器的算法。即使在最优情况下,这些算法也并不都具有相同的性能。

    使用std::map和std::string的键值对表

    (这是不介绍map和string的使用方法)

    改善查找性能的方法

    • 测量当前的实现方式的性能来得到比较基准。 • 识别出待优化的抽象活动。 • 将待优化的活动分解为组件算法和数据结构。 • 修改或是替换那些并非最优的算法和数据结构,然后进行性能测试以确定修改是否有效果。

    • 进行一次基准测量

      对未优化的代码进行性能测量非常重要,这样能够得到基准测量值来帮助我们确定优化是否有效果。

    • 识别出待优化的活动

      识别出待优化的活动,这样就可以将活动细分,便于找出需要优化的组件。

    • 分解待优化的活动

      将待优化的活动分解为组件算法数据结构

    • 修改或替换算法和数据结构

      开发人员需要寻找基准解决方案中的非最优算法,寻找数据结构提供的开销昂贵的、非被优化活动所必需的,因此可以被移除或是简化的行为。

      接着,开发人员进行性能测量,确认性能是否有所提高。

      在待优化的活动中有如下优化机会:

      • 换一种表数据结构或是提高它的性能。某些表数据结构的选择制约了查找和插入算法的选择。如果在数据结构中包含需要调用内存管理器的动态变量,那么表数据结构的 选择还会对性能有影响。
      • 替换一种键数据结构提高它的性能。
      • 替换一种比较算法提高它的性能。
      • 替换一种查找算法提高它的性能。
      • 替换一种插入算法提高它的性能。

    优化std::map的查找

    性能优化开发人员可以通过保持表数据结构不变,但改变键的数据结构,改善程序性能。

    • 以固定长度的字符数组作为std::map的键

      使用std::string作为键所带来的开销,因为内存分配占据了绝大部分创建表的开销。如果开发人员可以使用一种不会动态分配存储空间的数据结构作为键类型,就能够将这个开销减半。

    • 以C风格的字符串组作为键使用std::map

      有时,程序会访问那些存储期很长的、C风格的、以空字符结尾的字符串,那么我们就可以用这些字符串的char*指针作为std::map的键。

    • 当键就是值的时候,std::set

      std::set是一个关联容器,它存储的元素都是键值一体的。并且也会根据键自动排序。 和map一样也可以通过提供一个自定义的比较函数或对象来**改变排序方式。**std::set 的元素通过其键或迭代器进行访问,并且在这个容器中,键也是值(key is value),也就是说set相当于键值一体的map。

    使用头文件优化算法

    改变比较键的算法来提高性能,改变查找算法和表数据结构的方法。C++标准库还提供了一些算法,其中就包括查找和排序算法。标准库算法接收迭代器作为参数。迭代器抽象了指针的行为,从包含这些值的数据结构中分离出值的遍历。

    • 以序列容器作为被查找的键值对表

      序列容器消耗的内存比map少,它们的启动开销也更小。标准库算法的一个非常有用的特性是它们能够遍历任意类型的普通数组,因此,它们能够高效地查找静态初始化的结构体的数组。这样可以移除所有启动表的开销和销毁表的开销。

    • std::find():功能如其名,O(n)时间开销

      find() 是一个简单的线性查找算法。线性查找是最通用的查找方式。它不需要待查找的数据已经排序完成,只需要能够比较两个键是否相等即可。C++允许为各种类型的一对值重载等号运算符bool operator==(v1,v2)。

      因此在有序容器中,使用二分查找可以提高性能

    • std::binary_search():返回bool值

      二分查找一种常用的分而治之的策略,binary_search() 返回一个bool值,表示键是否存在于有序表中。

      #include <algorithm> 
      //查找 [first, last) 区域内是否包含 val
      bool binary_search (ForwardIterator first, ForwardIterator last,
                            const T& val);
      //根据 comp 指定的规则,查找 [first, last) 区域内是否包含 val
      bool binary_search (ForwardIterator first, ForwardIterator last,
                            const T& val, Compare comp);
      

      使用案例:

      #include <iostream>     // std::cout
      #include <algorithm>    // std::binary_search
      #include <vector>       // std::vector
      using namespace std;
      //以普通函数的方式定义查找规则
      bool mycomp(int i, int j) { return i > j; }
      //以函数对象的形式定义查找规则
      class mycomp2 {
      public:
          bool operator()(const int& i, const int& j) {
              return i > j;
          }
      };
      int main() {
          string out;
          int a[7] = { 1,2,3,4,5,6,7 };
          //从 a 数组中查找元素 4
          bool haselem = binary_search(a, a + 9, 4);
          out = haselem ? "T" : "F";
          cout << "haselem:" << out << endl;
      
          vector<int>myvector{ 4,5,3,1,2 };
          //从 myvector 容器查找元素 3
          bool haselem2 = binary_search(myvector.begin(), myvector.end(), 3, mycomp2());
          out = haselem ? "T" : "F";
          cout << "haselem2:" << haselem2;
          return 0;
      }
      
    • 使用std::equal_range()的二分查找

      equal_range() 会返回一对迭代器,如果没有找到元素,equal_range()会返回一对指向相等值的迭代器,这表示这个范围是空的。

      //找到 [first, last) 范围中所有等于 val 的元素
      pair<ForwardIterator,ForwardIterator> equal_range (ForwardIterator first, ForwardIterator last, const T& val);
      //根据 comp 指定的规则,找到 [first, last) 范围内所有等于 val 的元素
      pair<ForwardIterator,ForwardIterator> equal_range (ForwardIterator first, ForwardIterator last, const T& val, Compare comp);
      

      案例:

      #include <iostream>     // std::cout
      #include <algorithm>    // std::equal_range
      #include <vector>       // std::vector
      using namespace std;
      //以普通函数的方式定义查找规则
      bool mycomp(int i, int j) { return i > j; }
      //以函数对象的形式定义查找规则
      class mycomp2 {
      public:
          bool operator()(const int& i, const int& j) {
              return i > j;
          }
      };
      int main() {
          int a[9] = { 1,2,3,4,4,4,5,6,7};
          //从 a 数组中找到所有的元素 4
          pair<int*, int*> range = equal_range(a, a + 9, 4);
          cout << "a[9]:";
          for (int *p = range.first; p < range.second; ++p) {
              cout << *p << " ";
          }
      
          vector<int>myvector{ 7,8,5,4,3,3,3,3,2,1 };
          pair<vector<int>::iterator, vector<int>::iterator> range2;
          //在 myvector 容器中找到所有的元素 3
          range2 = equal_range(myvector.begin(), myvector.end(), 3,mycomp2());
          cout << "\nmyvector:";
          for (auto it = range2.first; it != range2.second; ++it) {
              cout << *it << " ";
          }
          
          // 当没有找到元素
          auto range3 = equal_range(myvector.begin(), myvector.end(), 9, mycomp2());
      		if (range3.first == range3.second) {
      		    cout << "\nnull" << endl;
      		}
          return 0;
      }
      

      注意equal_range返回值,当没有找到元素 ,first == second

    • 使用std::lower_bound()的二分查找

      equal_range() 所承诺的时间开销是O(log2n),但它返回了所有的相等的元素

      std::lower_bound() 返回一个指向表中键大于等于key的第一个元素的迭代器

      如果表中所有元素的键都小于key,那么它会返回一个指向表末尾的迭代器

      //在 [first, last) 区域内查找不小于 val 的元素
      ForwardIterator lower_bound (ForwardIterator first, ForwardIterator last,
                                   const T& val);
      //在 [first, last) 区域内查找第一个不符合 comp 规则的元素
      ForwardIterator lower_bound (ForwardIterator first, ForwardIterator last, const T& val, Compare comp);
      

      案例:

      #include <iostream>     // std::cout
      #include <algorithm>    // std::lower_bound
      #include <vector>       // std::vector
      using namespace std;
      //以普通函数的方式定义查找规则
      bool mycomp(int i,int j) { return i>j; }
      
      //以函数对象的形式定义查找规则
      class mycomp2 {
      public:
          bool operator()(const int& i, const int& j) {
              return i>j;
          }
      };
      
      int main() {
          int a[5] = { 1,2,3,4,5 };
          //从 a 数组中找到第一个不小于 3 的元素
          int *p = lower_bound(a, a + 5, 3);
          cout << "*p = " << *p << endl;
      
          vector<int> myvector{ 4,5,3,1,2 };
          //根据 mycomp2 规则,从 myvector 容器中找到第一个违背 mycomp2 规则的元素
          vector<int>::iterator iter = lower_bound(myvector.begin(), myvector.end(),3,mycomp2());
          cout << "*iter = " << *iter;
          return 0;
      }
      

      注意返回值为迭代器

      文章中还进行了自己写二分查找,并且使用strcmp()替代<,有兴趣的可以查看原书

      kv* find_binary_3(kv* start, kv* end, char const* key) {
       auto stop = end;
       while (start < stop) {
      	 auto mid = start + (stop-start)/2;
      	 auto rc = strcmp(mid->key, key);
      	 if (rc > 0) {
      		 stop = mid;
      	 }
      	 else if (rc < 0) {
      		 start = mid + 1;
      	 }
      	 else {
      		 return mid;
      	 }
       }
       return end;
      }
      

    优化键值对散列表中的查找

    散列表这个想法大致是这样的:无论键是什么类型,它都可以被一个散列函数归约为一个整数散列值。接着我们使用这个散列值作为数组索引,让它直接指向表中的元素。

    转换为散列表后,需要解决一个问题”hash冲突”,根据key(键)即经过一个函数f(key)得到的结果的作为地址去存放当前的key value键值对**,但是却发现算出来的地址上已经被占用了**。这就是所谓的hash冲突。

    解决方案:(p为首次hash后冲突的值,)

    1. 开放定址法

      1. 当出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p1为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi 。
      2. 我们就往后面一直加1并对m取模直到存在一个空余的地址供我们存放值,取模是为了保证找到的位置在0~m-1的有效空间之中。
    2. 再Hash法

      同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。

    3. 链地址法

      将所有哈希地址相同的记录都链接在同一链表中。

              引用CSDN图片

           引用CSDN图片
      
    4. 建立公共溢出区

      将哈希表分为基本表溢出表将发生冲突的都存放在溢出表中

    • 使用std::unordered_map进行散列

      哈希map的使用就不赘述了;

    • 对固定长度字符数组的键进行散列

      简单的固定长度字符数组模板类 charbuf 也可以与散列表一起使用。

      template <unsigned N=10, typename T=char> struct charbuf {
       charbuf();
       charbuf(charbuf const& cb);
       charbuf(T const* p);
       charbuf& operator=(charbuf const& rhs);
       charbuf& operator=(T const* rhs);
       operator size_t() const;
       bool operator==(charbuf const& that) const;
       bool operator<(charbuf const& that) const;
      private:
       T data_[N];
      };
      
      std::unordered_map<charbuf<>, unsigned> table;
      

      作者进行了性能测试,结果是令人失望的

    • 用自定义的散列表进行散列

      对于给定的一组键不会产生冲突的散列称为完美散列

      能够创建出无多余空间的表的散列称为最小散列

      26 条有效元素的首字母各不相同,而且它们是有序的,因此基于首字母的散列就是一个完美的最小散列。基于示例表的完美最小散列表:

      unsigned hash(char const* key) {
       if (key[0] < 'a' || key[0] > 'z')
       return 0;
       return (key[0]-'a');
      }
      kv* find_hash(kv* first, kv* last, char const* key) {
       unsigned i = hash(key);
       return strcmp(first[i].key, key) ? last : first + i;
      }
      

    使用C++标准库优化排序

    C++ 标准库 头文件包含各种排序算法,我们可以使用这些算法为那些具有额外特殊属性的输入数据定制更加复杂的排序。

    • std::heap_sort 将一个具有堆属性的范围转换为一个有序范围。heap_sort 不是稳定排序。 • std::partition 会执行快速排序的基本操作。 • std::merge 会执行归并排序的基本操作。 • 各种序列容器的 insert 成员函数会执行插入排序的基本操作。

第十章

  • 优化数据结构

    在STL 之前,每个项目都会定义自己的链表和二分查找树实现,可能也会改写其他人的代码。

    理解标准库容器

    C++ 标准库中的各种容器尽管在实现上明显不同,但是它们看起来都非常相似,但其实这只是错觉。开发人员只有详细地掌握各个容器类才能理解如何最优地使用它们。

    • 序列容器

      序列容器 std::string、std::vector、std::deque、std::list 和 std::forward_list 中元素的顺序与它们被插入的顺序相同。除了 std::forward_list 外,所有的序列容器都有一个具有常量时间性能开销的成员函数能够将元素推入至序列容器的末尾。不过,只有 std::deque、std::list 和std::forward_list 能够高效地将元素推入至序列容器的头部。

      std::string、std::vector 和 std::deque 都是基于一个类似数组的内部骨架构建而成的。在非末尾处插入元素的时间开销是 O(n),这个内部数组可能会被重新分配,导致所有的迭代器和指针失效。而在 std::list 和std::forward_list 的中间插入元素的时间开销是常量时间。

    • 关联容器

      所有的关联容器都会按照元素的某种属性上的顺序关系,而不是按照插入的顺序来保存元素。一共有四种有序关联容器:std::map、std::multimap、std::set 和std::multiset。遍历它们时会按照排序关系的顺序访问它们中的元素。插入或是移除元素的分摊开销是 O(log2n)

      了四种无序关联容器:std::unordered_map、std::unordered_multimap、std::unordered_set 和 std::unordered_multiset。

    std::vector

    这两种数据结构的“产品手册”如下。 • 序列容器 • 插入时间:在末尾插入元素的时间开销为 O(1),在其他位置插入元素的时间开销为 O(n) • 索引时间:根据位置进行索引,时间开销为 O(1) • 排序时间:O(n log2n) • 如果已排序,查找时间开销为 O(log2n),否则为 O(n) • 当内部数组被重新分配时,迭代器和引用失效 • 迭代器从前向后或是从后向前生成元素 • 合理控制分配容量,与大小无关

    • 重新分配的性能影响

      当 size == capacity 时,任何插入操作都会触发一次性能开销昂贵的存储空间扩展:重新分配内部存储空间,将 vector 中的元素复制到新的存储空间中,并使所有指向旧存储空间的迭代器和引用失效

    • std::vector中的插入与删除

      push_back(ele);		                        // 尾部插入元素ele
      pop_back();		                            // 删除最后一个元素
      insert(const_iterator pos, ele);		      // 迭代器指向位置pos插入元素ele
      insert(const_iterator pos, int count, ele);	// 迭代器指向位置pos插入count个元素ele
      emplace (const_iterator pos, ele);          // 定位置之前插入一个新的元素。
      emplace_back (const_iterator pos, ele);     // 尾部插入一个新的元素。
      erase(const_iterator pos);		              // 删除迭代器指向的元素
      erase(const_itrator start, const_iterator end);	// 删除迭代器从start到end之间的元素
      clear();		                                    //删除容器中所有的元素
      

      emplace() 、 insert() 和 push_bask()都能完成向 vector 容器中插入新元素。emplace()运行效率更高。

      push_back() 在向 vector 尾部添加一个元素时,首先会创建一个临时对象,然后再将这个临时对象移动或拷贝到 vector 中; emplace_back() 在实现时,则是直接在 vector 尾部创建这个元素,省去了移动或者拷贝元素的过程。

    • 遍历std::vector

      遍历 vector 和访问其元素的开销并不大,不同方法的性能开销差异显著。

      有三种方法可以遍历一个 vector():使用迭代器、使用 at() 成员函数和使用下标

      遍历的开销所花费的时间微不足道但与插入操作一样,下标版本更加高效

    • 对std::vector排序

      C++ 标准库有两种排序算法——std::sort() 和 std::stable_sort()。容器的迭代器是随机访问迭代器,那么两种算法的时间开销都是 O(n log2n)

    • 查找std::vector

      std::find是C++标准库中的一个通用查找算法,用于在给定范围内查找指定元素。它接受两个迭代器作为参数,分别表示搜索范围的起始和结束位置。

      template <class InputIt, class T>
      InputIt find(InputIt first, InputIt last, const T& value);
      

    std::deque

    deque 的“产品手册”如下

    • 序列容器 • 插入时间:在末尾插入元素的时间开销为 O(1),在其他位置插入元素的时间开销为 O(n) • 索引时间:根据位置进行索引,时间开销为 O(1) • 排序时间:O(n log2n) • 如果已排序,查找时间开销为 O(log2n),否则为 O(n) • 当内部数组被重新分配时,迭代器和引用会失效 • 迭代器可以从后向前或是从前向后遍历元素

    std::deque 是一种专门用于创建“先进先出”(FIFO)队列的容器。在队列两端插入和删除元素的开销都是常量时间,下标操作也是常量时间。

    • std::deque使用

      对比一下 std::vector 和 std::deque 的性能。对于相同数量的元素,vector的赋值操作的性能是 deque 的 13 倍,删除操作的性能是 deque 的 22 倍,基于迭代器的插入操作的性能是 deque 的 9 倍,push_back() 操作的性能是 deque 的两倍,使用 insert()在末尾插入元素的性能则是 deque 的 3 倍。

      push_back()在容器现有元素的尾部添加一个元素,和 emplace_back() 不同,该函数添加新元素的过程是,先构造元素,然后再将该元素移动或复制到容器的尾部。
      pop_back()移除容器尾部的一个元素。
      push_front()在容器现有元素的头部添加一个元素,和 emplace_back() 不同,该函数添加新元素的过程是,先构造元素,然后再将该元素移动或复制到容器的头部。
      pop_front()移除容器尾部的一个元素。
      emplace_back()C++ 11 新添加的成员函数,其功能是在容器尾部生成一个元素。和 push_back() 不同,该函数直接在容器头部构造元素,省去了复制或移动元素的过程。
      emplace_front()C++ 11 新添加的成员函数,其功能是在容器头部生成一个元素。和 push_front() 不同,该函数直接在容器头部构造元素,省去了复制或移动元素的过程。
      insert()在指定的位置直接生成一个元素。和 emplace() 不同的是,该函数添加新元素的过程是,先构造元素,然后再将该元素移动或复制到容器的指定位置。
      emplace()C++ 11 新添加的成员函数,其功能是 insert() 相同,即在指定的位置直接生成一个元素。和 insert() 不同的是,emplace() 直接在容器指定位置构造元素,省去了复制或移动元素的过程。
      erase()移除一个元素或某一区域内的多个元素。
      clear()删除容器中所有的元素。

    之后还有list、map、等…容器,这里不进行介绍;

    小结

    • 各容器类的大 O 标记性能并不能反映真实情况。有些容器比其他容器快许多倍。 • 在进行插入、删除、遍历和排序操作时 std::vector 都是最快的容器。 • 使用std::lower_bound查找有序std::vector的速度可以与查找std::map的速度相匹敌。 • std::deque 只比 std::list 稍快一点。 • std::forward_list 并不比 std::list 更快。 • 散列表 std::unordered_map 比 std::map 更快,但是没有比 std::map 快上一个数量级。

    • 互联网上有丰富的类似标注库容器的容器资源

第十一章

  • 优化I/O

    读取文件的秘诀

    初始版本的 file_reader() 函数

    std::string file_reader(char const* fname) {
    	 std::ifstream f;
    	 f.open(fname);
    	 if (!f) {
    		 std::cout << "Can't open " << fname
    		 << " for reading" << std::endl;
    		 return "";
    	 }
    	 std::stringstream s;
    	 std::copy(std::istreambuf_iterator<char>(f.rdbuf()),
    	 std::istreambuf_iterator<char>(),
    	 std::ostreambuf_iterator<char>(s) );
    	 return s.str();
    }
    

    fname 是文件名。如果打不开文件,file_reader() 会打印一条错误信息到标准输出中并返回空字符串。否则,std::copy() 会将 f 的流缓冲区复制到 std::stringstream s 的流缓冲区中。

    • 创建一个吝啬的函数签名

      file_reader()函数做了几件不同的事情:打开文件;进行错误处理;读取已打开且有效的流到字符串中。

      可以将打开文件和错误处理进行移除,将函数改为tream_read_streambuf_stringstream()

      void stream_read_streambuf_stringstream(
       std::istream& f,
       std::string& result) {
       std::stringstream s;
       std::copy(std::istreambuf_iterator<char>(f.rdbuf()),
       std::istreambuf_iterator<char>(),
       std::ostreambuf_iterator<char>(s) );
       std::swap(result, s.str());
      }
      

      这个函数可以与std::stringstream 等其他类型的流一起工作,而且它更短,更易读,只有一个概念上的运算而已。

    • 缩短调用链

      std::istream 有一个 << 运算符,它接收流缓冲区作为参数。<< 运算符可能会绕过 istream API 直接调用流缓冲区。添加 stream 到 stringstream 中,一次一个字符;

      void stream_read_streambuf(std::istream& f, std::string& result) {
       std::stringstream s;
       s << f.rdbuf();
       std::swap(result, s.str());
      }
      
    • 减少重新分配

      我们可以调用 reserve() 为存储文件内容的std::string 预先分配内存,来防止随着字符串逐字符的增长发生重新分配。 result 预先分配存储空间;

      void stream_read_string_reserve(std::istream& f, std::string& result)
      {
      	 f.seekg(0,std::istream::end);
      	 std::streamoff len = f.tellg();
      	 f.seekg(0);
      	 if (len > 0)
      	 result.reserve(static_cast<std::string::size_type>(len));
      	 result.assign(std::istreambuf_iterator<char>(f.rdbuf()),
      	 std::istreambuf_iterator<char>());
      }
      

      stream_read_string_reserve() 通过将它的流指针移动到流尾部,读取偏移量后再将流指针复位到流头部来计算流长度。istream::tellg() 实际上返回一个代表流指针位置的小型结构体,其中包含一个部分读取 UTF-8 多字节字符的偏移量

      计算流长度并预先分配存储空间的技巧很实用。优秀的库设计总是会在它们自己的函数中复用这些工具。计算流长度;

      stream_size():计算流长度

      std::streamoff stream_size(std::istream& f) {
      	 std::istream::pos_type current_pos = f.tellg();
      	 if (-1 == current_pos)
      		 return -1;
      	 f.seekg(0,std::istream::end);
      	 std::istream::pos_type end_pos = f.tellg();
      	 f.seekg(current_pos);
      	 return end_pos - current_pos;
      }
      

      通用版本的 stream_read_string()

      void stream_read_string_2(std::istream& f,
       std::string& result,
       std::streamoff len = 0)
      {
      	 if (len > 0)
      		 result.reserve(static_cast<std::string::size_type>(len));
      	 result.assign(std::istreambuf_iterator<char>(f.rdbuf()), std::istreambuf_iterator<char>());
      }
      
    • 更大的吞吐量——使用更大的输入缓冲区

      C++ 流包含一个继承自 std::streambuf 的类,用于改善从操作系统底层以更大块的数据单位读取文件时的性能。

      增大 std::streambuf 内部缓冲区的大小

      std::ifstream in8k;
      in8k.open(filename);
      char buf[8192];
      in8k.rdbuf()->pubsetbuf(buf, sizeof(buf));
      
    • 更大的吞吐量——一次读取一行

      对于一个含有多行文字的文件,在标准库中就有一个叫作 getline() 的函数。

      一次读取一行的 stream_read_getline()

      void stream_read_getline(std::istream& f, std::string& result) {
      	 std::string line;
      	 result.clear();
      	 while (getline(f, line))
      		 (result += line) += "\n";
      }
      
    • 再次缩短函数调用链

      std::istream 提供了一个 read() 成员函数,它能够将字符直接复制到缓冲区中。绕过缓冲区和 C++ 流 I/O 的其他“负担”,它应当能够更加高效。如果能够一次读取整个文件,那么函数调用也会非常高效。

      stream_read_string() 使用 read() 读取文件至字符串中

      bool stream_read_string(std::istream& f, std::string& result) {
      	 std::streamoff len = stream_size(f);
      	 if (len == -1)
      		 return false;
      	 result.resize (static_cast<std::string::size_type>(len));
      	 f.read(&result[0], result.length());
      	 return true;
      }
      

    小结

    • 不论你是在哪个网站上看到的,互联网上的“快速”文件 I/O 代码不一定快。 • 增大 rdbuf 的大小可以让读取文件的性能提高几个百分点。 • 我测试到的最快的读取文件的方法是预先为字符串分配与文件大小相同的缓冲区,然后调用 std::streambuf::sgetn() 函数填充字符串缓冲区。 • std::endl 会刷新输出。如果你并不打算在控制台上输出,那么它的开销是昂贵的。 • std::cout 是与 std::cin 和 stdout 捆绑在一起的。打破这种连接能够改善性能。

第十二章

  • 优化并发

    多核微处理器问世,它们提供了真正(并非时间切割)的并发;

    并发概述

    并发是多线程控制的同步(或近似同步)执行。并发的目标并不是减少指令执行的次数或是每秒访问数据的次数。它是通过提高计算资源的使用率来减少程序运行的时间的。计算机硬件、操作系统、函数库以及 C++ 自身的特性都能够为程序提供并发支持。

    几种并发形式:

    1. 时间分割(time slicing)

      **将CPU的执行时间分割成若干个小的时间片段,**每个时间片段分配给一个线程或进程来执行。它会使用计时器和周期性的中断来调整处理器的调度。C++ 并不知道它被时间分割了。

    2. 虚拟化(Virtualization)

      一种常见的虚拟化技术是让一个称为“hypervisor”的轻量级操作系统将处理器的时间块分配给客户虚拟机。包含一个文件系统镜像和一个内存镜像,通常这都是一个正在运行一个或多个程序的操作系统。

    3. 容器化(containerization)

      容器化与虚拟化的相似之处在于,容器中也有一个包含了程序在检查点的状态的文件系统镜像和内存镜像;容器化具有与虚拟化相同的优点,对于运行于容器中的 C++ 程序,容器化是不可见的。

    4. 对称式多处理(symmetric multiprocessing)

      是一种包含若干执行相同机器代码并访问相同物理内存的执行单元的计算机。多核处理器都是对称式多处理器,对称式多处理器使用真正的硬件并发执行多线程控制

    5. 同步多线程(simultaneous multithreading)

      有些处理器的硬件核心有两个(或多个)寄存器集,可以相应地执行两条或多条指令流。当一条指令流停顿时(如需要访问主内存),处理器核心能够执行另外一条指令流上的指令。这种特性的处理器核心的行为就像是有两个(或多个)核心一样;

    6. 多进程

      进程是并发的执行流,这些执行流有它们自己的受保护的虚拟内存空间。进程之间通过管道、队列、网络 I/O 或是其他不共享的机制进行通信。进程的主要优点是操作系统会隔离各个进程。如果一个进程崩溃了,其他进程依然活着。

    7. 分布式处理(distributed processing)

      如RPC;组通过 TCP/IP 协议进行通信的云服务器的实例就是一种分布式处理。在一台单独的 PC 上也存在着分布式处理;

    8. 线程

      线程是进程中的并发执行流,它们之间共享内存。线程使用同步原语进行同步,使用共享的内存地址进行通信。与进程相比,线程的优点在于消耗的资源更少,创建和切换也更快。

    9. 任务

      任务是在一个独立线程的上下文中能够被异步调用的执行单元。在基于任务的并发中,任务和线程是独立地和显式地被管理的,这样可以将一个任务分配给一个线程去执行。

    • 交叉执行

      并发程序能够大致被抽象为加载(load)、存储(store)和 分支(branch);

      两个线程的并发执行的控制可以被建模为两个线程的简单的加载和存储语句的交叉。

    • 顺序一致性

      程序具有顺序一致性,程序表现得看起来像是语句的执行顺序与语句的编写顺序是一致的,遵守 C++ 流程控制语句的控制。

    • 竞争

      并发给 C++ 带来了一个问题——没有任何方法能够知道什么时候两个函数会并发执行以及哪些变量被共享了。在 C++ 的标准内存模型中,只要程序中不会发生竞争,那么它的行为看起来像是具有顺序一致性;如果程序中会发生竞争,就可能会违背顺序一致性。

    • 同步

      同步是多线程中语句交互的强制顺序。同步允许开发人员讨论多线程程序语句的执行顺序。没有同步,语句执行的顺序是不可预测的,线程之间的协同工作将会变得非常困难。同步原语是一种编程结构,其目的是通过强制并发程序的交叉来实现同步。所有的同步原语的工作原理都是让一个线程等待另外一个线程或是挂起线程。通过强制指定特定的执行顺序,同步原语避免了竞争的发生

    • 原子性

      原子性是指一个操作或一组操作要么全部执行成功,要么全部不执行,不会出现部分执行的情况。原子性是一种非常重要的概念,用于确保多个线程或进程对共享数据的访问不会导致数据不一致或不确定的结果。获取和释放互斥量之间的程序部分被称为临界区

      • 互斥实现原子性 原子性是通过互斥实现的。每个线程在访问共享变量前都必须获得一个**互斥量,**并在完成操作后释放这个互斥量。

      • 原子性硬件操作

        通过互斥实现的原子性会带来性能开销;

        由于只有一个线程能够拥有互斥量,共享变量上的操作无法并发执行。在共享变量上执行操作的线程越多,临界区从并发执行中夺走的时间也就越多。

        如果许多线程都挂起了,有些线程可能永远无法得到互斥量;这些线程上的计算无法继续向前进行。这种情况被称为资源饥饿

        如果一个线程已经获得了一个互斥量,然后需要获取第二个互斥量,那么当另外一个线程已经获得了第二个互斥量并需要获取第一个互斥量时,就会发生线程永远无法继续往下执行的情况。我们称这种情况为死锁

    C++并发方式

    • 线程

      头文件提供了std::thread 模板类,它允许程序创建线程对象作为操作系统自身的线程工具的包装器。std::thread的构造函数接收一个可调用对象作为参数,并会在新的软件线程上下文中执行这个对象。

      (具体的线程使用方式不进行介绍)

      • promise和future

        C++ 头文件中 std::promise对象提供了一种方法,可以在一个线程中设置一个值或异常,并在另一个线程中获取该值或异常。你可以把std::promise看作是一个写入端,而std::future是相应的读取端。promise和future允许线程异步地计算值和抛出异常。 future是一个同步原语,接收线程会在对future的get()成员函数的调用中挂起阻塞,直到相应的promise设置了共享状态的值或是异常,变为就绪状态为止。

        在一个线程中设置值或异常:

        // 设置值
        promise.set_value(42);
        
        // 或者设置异常
        promise.set_exception(std::make_exception_ptr(std::runtime_error("An error occurred")));
        

        具体代码:

        #include <iostream>
        #include <thread>
        #include <future>
        #include <exception>
        
        // 线程函数,使用promise设置异常
        void threadFunction(std::promise<int>&& promise) {
            try {
                // 模拟某种可能抛出异常的操作
                throw std::runtime_error("An error occurred in the thread");
                promise.set_value(42); // 正常情况下设置值
            } catch (const std::exception& e) {
                promise.set_exception(std::current_exception()); // 捕获并设置异常
            }
        }
        
        int main() {
        		// 创建一个std::promise对象
            std::promise<int> promise;
            // 获取与该promise关联的std::future对象
            std::future<int> future = promise.get_future();
            
            // 启动线程
            std::thread t(threadFunction, std::move(promise));
            
            // 在主线程中等待并获取结果
            try {
                int result = future.get();
                std::cout << "Result: " << result << std::endl;
            } catch (const std::exception& e) {
                std::cerr << "Exception caught in main: " << e.what() << std::endl;
            }
        
            t.join(); // 等待线程完成
            return 0;
        }
        

        这种方法确保了异常能在不同的线程之间正确传递和处理,使得多线程程序更加健壮和可维护。

    • 异步 async

      async()它隐藏了线程池和任务队列的许多细节。std::packaged_task模板类能够包装任意的可调用对象,使其能够被异步调用。

      #include <iostream> // std::cout
      #include <future>   // std::async, std::future
      #include <chrono> // std::chrono::milliseconds
      
      using namespace std;
      
      bool is_prime(int x)
      {
          for (int i = 2; i < x; ++i)
              if (x % i == 0)
                  return false;
          return true;
      }
      int main()
      {
          // 异步调用函数
          future<bool> fut = async(is_prime, 444444443);
          cout << "checking, please wait";
          // 等待异步
          chrono::milliseconds span (100);
          while (fut.wait_for(span)==future_status::timeout)
              cout << '.' << flush;
          // 获取结果
          bool x = fut.get(); 
          cout << "\n444444443 " << (x ? "is" : "is not") << " prime.\n";
          return 0;
      }
      
    • 互斥量

      头文件包含了四种互斥量模板。

      1. std::mutex

        一种简单且相对高效的互斥量,互斥量是一个可以处于两态之一的变量:解锁和加锁。只需要一个二进制位表示它,不过实际上,常常使用一个整型量,0表示解锁,而其他所有的值则表示加锁。

        
        #include <mutex>
        
        std::mutex my_mutex;
        
        my_mutex.lock();
        // 互斥空间
        my_mutex.unlock();
        

        注意:我们在使用mutex时,要时刻注意lock()与unlock()的加锁临界区的范围,不能太大也不能太小,太大了会导致程序运行效率低下,大小了则不能满足我们对程序的控制。并且我们在加锁之后要及时解锁,否则会造成死锁,lock()与unlock()应该是成对出现。

      2. std::recursive_mutex

        一种线程能够递归获取的互斥量(递归锁),就像函数的嵌套调用一样。由于该类需要对它被获取的次数计数,因此可能稍微低效。允许同一个线程对同一个锁对象多次上锁,获得多层所有权。

        recursive_mutex对象已经被该调用线程上锁,调用线程再次调用该函数,会获得对该recursive_mutex对象新的所有权级。而完全解锁该recursive_mutex对象需要调用相同次数的unlock函数。

        recursive_mutex::lock 上锁(不成功阻塞)

        被其他线程上锁,则调用线程将阻塞,直到该对象被解锁。

        已经被该调用线程上锁,获得对该对象新的所有权级

        bool try_lock() noexcept; 尝试上锁(不会造成线程阻塞)

        若已被其他线程上锁,则返回false,但不阻塞调用线程

        已经被该调用线程上锁,获得对该对象新的所有权级

        void unlock(); 解锁,并释放一个所有权级。

        若调用线程对该对象只有一个所有权级,则锁被完成释放;

        若该对象未被调用线程上锁,调用该函数会导致undefine behavior

        native_handle_type native_handle(); 获得原始句柄。

        该函数需要库函数支持时才存在于recursive_mutex对象中。该函数会返回于对象相关的可用于访问具体应用信息的值。

        try_lock 也是mutex类的成员函数;

        #include <mutex>
        
        // 使用mutex或recursive_mutex定义均可
        std::recursive_mutex mtx;
        // std::mutex mtx;
        
        void example_function() {
            if (mtx.try_lock()) {
                // 成功获得锁
                // 执行一些操作
                mtx.unlock();  // 记得在操作结束后释放锁
            }
            else {
                // 未能获得锁
            }
        }
        
      3. std::timed_mutex

        允许在一定时间内尝试获取互斥量。要想在一定时间内尝试获取互斥量,通常需要操作系统的介入,导致与std::mutex相比,这类互斥量的延迟显著地增大了

        使用和mutex类似,但是增加了时间功能

        timed_mutex::try_lock_for

        bool try_lock_for (const chrono::duration<Rep,Period>& rel_time);

        若构造的对象已经被其他线程锁住,则调用线程将被阻塞至多rel_time的时间;若在rel_time时间内,对象被解锁,则调用线程将解除阻塞状态,并继续运行;否则,调用线程将被阻塞rel_time时间。该函数返回false

        timed_mutex::try_lock_until

        bool try_lock_until (const chrono::time_point<Clock,Duration>& abs_time);

        功能与try_lock_for一样;try_lock_until 为指定时间点;

        #include <mutex>
        #include <chrono>
        
        std::timed_mutex mtx;
        
        void example_function() {
            if (mtx.try_lock_for(std::chrono::milliseconds(100))) {
                // 成功获得锁
                // 执行一些操作
                mtx.unlock();  // 记得在操作结束后释放锁
            }
            else {
                // 未能在100毫秒内获得锁
            }
        }
        
      4. std::recursive_timed_mutex

        一种能够在一定时间递归地获取的互斥量

      5. std::shared_mutex

        共享互斥量 C++ 17

        lock() 锁定互斥体,若互斥体不可用则阻塞

        try_lock() 尝试锁定互斥体,若互斥体不可用则返回

        unlock() 解锁互斥体

        共享锁定

        lock_shared() 为共享所有权锁定互斥体,若互斥体不可用则阻塞

        try_lock_shared() 尝试为共享所有权锁定互斥体,若互斥体不可用则返回

        unlock_shared() 解锁互斥体(共享所有权)

        (建议使用锁方式进行使用)

        #include <iostream>
        #include <mutex>  // 对于 std::unique_lock
        #include <shared_mutex>
        #include <thread>
         
        // C++17 使用 shared_mutex 为读写锁
        
        class ThreadSafeCounter {
         public:
          ThreadSafeCounter() = default;
         
          // 多个线程/读者能同时读计数器的值。
          unsigned int get() const {
            std::shared_lock<std::shared_mutex> lock(mutex_);
            return value_;
          }
         
          // 只有一个线程/写者能增加/写线程的值。
          void increment() {
            std::unique_lock<std::shared_mutex> lock(mutex_);
            value_++;
          }
         
          // 只有一个线程/写者能重置/写线程的值。
          void reset() {
            // std::unique_lock<std::shared_mutex> lock(mutex_); // 使用锁的方式
            mutex_.lock_shared();
            value_ = 0;
            mutex_.unlock_shared();
          }
         
         private:
          mutable std::shared_mutex mutex_;
          unsigned int value_ = 0;
        };
         
        int main() {
          ThreadSafeCounter counter;
         
          auto increment_and_print = [&counter]() {
            for (int i = 0; i < 3; i++) {
              counter.increment();
              std::cout << std::this_thread::get_id() << ' ' << counter.get() << '\n';
         
              // 注意:写入 std::cout 实际上也要由另一互斥同步。省略它以保持示例简洁。
            }
          };
         
          std::thread thread1(increment_and_print);
          std::thread thread2(increment_and_print);
        
          thread1.join();
          thread2.join();
        }
         
        
      6. std::shared_timed_mutex

        一种同时支持定时和非定时获取互斥量的共享互斥量。

    • 获取互斥量也被称为锁住互斥量,释放互斥量也被称为解锁互斥量。

      1. std::lock_guard

        一种简单的RAII锁。在这个类构造后,在作用域中程序会等待直到获得锁;当离开作用域而在析构过程中则会释放锁

        
        #include <mutex>
        
        std::mutex g_i_mutex; 
        // 作用域
        {
            std::lock_guard<std::mutex> lock(g_i_mutex);
            // 作用域结束后,释放锁
        }
        
      2. std::unique_lock

        当添加 std::defer_lock 参数后 unique_lock在构造后不立即加锁需要手动上锁和解锁,lock_guard在构造后自动上锁

        lock_guard锁的持有只能在lock_guard对象的作用域范围内,作用域范围之外锁被释放,而unique_lock支持移动操作,可以将unique_lock对象通过函数返回值返回,这样锁就转移到外部unique_lock对象中,延长锁的持有时间。

        #include <iostream>
        #include <mutex>    //unique_lock
        #include <shared_mutex> //shared_mutex shared_lock
        #include <thread>
        using namespace std;
        
        int n;
        std::mutex some_mutex;
        
        void prepare_data()
        {
            cout << n++ << endl;
        }
        
        void do_something()
        {
            cout << n++ << endl;
        }
        
        std::unique_lock<std::mutex> get_lock()
        {
            std::unique_lock<std::mutex> lk(some_mutex);//与lock_guard相同,构造时获取锁
            cout << "owns_lock? " << lk.owns_lock() << endl;//1
            prepare_data();
            return lk;
        }
        
        int main()
        {
            //unique_lock基本使用
            std::mutex mutex2;
            std::unique_lock<std::mutex> lock2(mutex2, **std::defer_lock**);//告诉构造函数暂不获取锁
            cout << "owns_lock? " << lock2.owns_lock() << endl;//0 没有上锁
            lock2.lock();//手动获取锁
            std::cout << "owns_lock? " << lock2.owns_lock() << endl;//1 上锁
            lock2.unlock();//手动解锁
            cout << "owns_lock? " << lock2.owns_lock() << endl;//0
            //锁所有权转移到函数外部
            std::unique_lock<std::mutex> lk(get_lock());// 将函数中的锁通过构造函数转移给lk
            do_something(); // 被lk上锁
        }
        //析构
        //lock2未获取锁mutex2,因此不会调用unlock
        //lk对象持有锁some_mutex,调用unlock,进行解锁
        

        注意!!在添加 std::defer_lock 参数后,不自动上锁,需要手动上锁;

      3. std::shared_lock

        以共享模式锁定关联互斥。等效于调用 mutex()->lock_shared();

        需要和unique_lock配合使用(不使用 std::defer_lock 参数)

        #include <iostream>
        #include <mutex>    //unique_lock
        #include <shared_mutex> //shared_mutex shared_lock
        #include <thread>
        
        std::mutex mtx;
        
        class ThreadSaferCounter
        {
        private:
            mutable std::shared_mutex mutex_;
            unsigned int value_ = 0;
        public:
            ThreadSaferCounter(/* args */) {};
            ~ThreadSaferCounter() {};
            
            unsigned int get() const {
                //读者, 获取共享锁, 使用shared_lock
                std::shared_lock<std::shared_mutex> lck(mutex_);//执行mutex_.lock_shared();
                return value_;  //lck 析构, 执行mutex_.unlock_shared();
            }
        
            unsigned int increment() {
                //写者, 获取独占锁, 使用unique_lock
                std::unique_lock<std::shared_mutex> lck(mutex_);//执行mutex_.lock();
                value_++;   //lck 析构, 执行mutex_.unlock();
                return value_;
            }
        
            void reset() {
                //写者, 获取独占锁, 使用unique_lock
                std::unique_lock<std::shared_mutex> lck(mutex_);//执行mutex_.lock();
                value_ = 0;   //lck 析构, 执行mutex_.unlock();
            }
        };
        
    • 条件变量

      condition_variable是C++11引入的一个同步原语,用于实现线程之间的等待和唤醒机制。在线程池中用于唤醒线程;它是一种条件变量,可以与mutex(互斥锁)结合使用,实现复杂的线程同步和通信。

      成员函数

      1. 等待函数

        有三个等待函数:wait()、wait_for() 和 wait_util()。

        wait():

        void wait(unique_lock<mutex>& lock);

        wait函数接受一个unique_lock类型的引用作为参数。在调用wait函数之前,需要先获取该互斥锁。wait函数会将当前线程阻塞,并且会自动释放这个互斥锁

        wait_for():

        template <class Rep, class Period>
        cv_status wait_for(std::unique_lock<std::mutex>& lock, const std::chrono::duration<Rep, Period>& rel_time);
        
        template <class Rep, class Period, class Predicate>
        bool wait_for (unique_lock<mutex>& lck, const chrono::duration<Rep,Period>& rel_time, Predicate pred);
        

        wait_for() 函数用于阻塞线程并等待唤醒,它可以设置一个超时时间。如果超时时间到期且仍未收到唤醒通知,wait_for() 返回 cv_status::timeout,线程继续执行。

        wait_until()

        template <class Clock, class Duration>
        cv_status wait_until(std::unique_lock<std::mutex>& lock, const std::chrono::time_point<Clock, Duration>& abs_time);
        
        template <class Clock, class Duration, class Predicate>
        bool wait_until(unique_lock<mutex>& lck, const chrono::time_point<Clock,Duration>& abs_time, Predicate pred);
        

        wait_until 接受一个绝对时间点作为参数。

        如果到达指定时间点仍未收到唤醒通知,wait_until 返回 cv_status::timeout,线程继续执行。

      2. 通知函数

        void notify_one() noexcept;

        notify_one() 用于唤醒等待在条件变量上的单个线程。

        抢占式,具体是哪个线程 C++ 标准并未明确,所以是不确定的。

        void notify_all() noexcept;

        notify_all() 用于唤醒等待在条件变量上的所有线程。

        唤醒的线程将竞争获取与条件变量关联的互斥锁,然后可以继续执行。

      注意:虚假唤醒唤醒丢失

      虚假唤醒(spurious wakeup)指一个或多个线程被唤醒,但没有实际的条件变化或通知发生。这些线程被认为是"虚假唤醒"。虚假唤醒通常由操作系统或 C++ 标准库的实现引发,这是多线程的一种正常行为。

      唤醒丢失(wakeup loss)指发送方在接收方进入等待状态之前发送通知,结果就是导致通知消失。

      解决方案:使用一个变量(通常是 bool 类型的变量)来表示等待的条件,线程在等待前和等待后检查该条件是否满足

      线程池案例:

      #include <iostream>			  // std::cout
      #include <thread>			  // std::thread
      #include <mutex>			  // std::mutex, std::unique_lock
      #include <condition_variable> // std::condition_variable
      #include <functional>
      #include <queue>
      #include <vector>
      #include <unistd.h>
      #include <shared_mutex>			// 读写锁使用 C++17
      
      using namespace std;
      
      #define _POOL_02
      
      #ifdef _POOL_02
      class CThreadPool {
      private:
      	vector<thread> threads ;
      	queue <function<void()>> tasks;
      	std::condition_variable cv;
      	bool ready = false;
      	std::mutex mtx;
      	
      public:
      	void AddTask(function<void()> task);
      	CThreadPool(int size);
      	~CThreadPool();
      };
      
      CThreadPool::CThreadPool(int size) {
      	for(int i =0 ;i<size;i++) {
      		threads.push_back(thread ([&](){
      			for(;;){
      				function<void()> task;
      				{
      					unique_lock<mutex> lkx(mtx);
      					while(tasks.empty() && !ready)	// tasks.empty()防止虚假唤醒没有接收内容,!ready线程池结束跳出等待;
      						cv.wait(lkx);	// 柱塞,自动释放这个互斥锁
      					if(ready) break;	// 停止线程,进行break
      					task = tasks.front();
      					tasks.pop();
      				}
      				task();
      			}
      		}));
      	}
      
      }
      
      CThreadPool::~CThreadPool() {
      	{
      		unique_lock<mutex> lck(mtx);
      		ready = true;
      	}
      	cv.notify_all();
      	for(auto &thread : threads){
      		thread.join();
      	}
      }
      
      void CThreadPool::AddTask(function<void()> task) {
      	unique_lock<mutex> lck(mtx);
      	tasks.push(task);
      	cv.notify_one();
      }
      
      int main(){
          CThreadPool pool(4);
          for(int i=0; i<20; ++i){
              pool.AddTask([&](){
      			{
      				cout << this_thread::get_id() << " begin:" << i << endl;
      				// this_thread::sleep_for(1s);
      				cout << this_thread::get_id() << " end:" << i << endl;
      			}
              });
          }
      	// 主线程执行完毕释放线程池
          this_thread::sleep_for(2s);  // C11的休眠函数,表示当前线程休眠一段时间,休眠期间不与其他线程竞争CPU; 类似sleep()
                                        
      }
      #endif
      
      /*
      	唤醒丢失:
      		线程需要先挂起等待cv.wait(lock),后进行信号的通知cv.notify_all()
      		如果程序先进行了通知,线程在挂起就会出现 “唤醒丢失”,线程会一直阻塞在wait中
      	虚假唤醒:
      		情况 1:notify_one 但是多线程争抢
      		情况 2: 系统原因	有些操作系统为了在处理内部的错误条件和竞争时具有灵活性,即使没有发出信号,也可以允许条件变量从等待中返回。(在某些操作系统中会存在问题)
      			(linux 系统提供的 pthread 保证不会发生这种情况的虚假唤醒)
      				方式一:使用 while 来判断条件	
      				while (vec.empty()) { 	// vec为需要执行的内容,当不为空才结束循环,用来防止虚假唤醒
      					cv.wait(lock);
      				}
      				方式二:与上面等效 while 
      				cv.wait(lock, [](){ return !vec.empty();} );	// 注意lambda表达式为 真 唤醒
      */
      
    • 原子变量

      头文件#include <atomic>

      是C++中用于多线程编程的强大工具之一。它们提供了一种线程安全的方式来访问和修改共享数据,而无需使用显式的互斥锁。std::atomic支持各种数据类型,如整数、布尔值、指针等。您可以创建std::atomic对象,并使用原子操作来读取和修改它们的值。

      创建原子变量

      std::atomic<int> atomicInt(0);  // 或 sdt::atomic_int 
      std::atomic<bool> atomicBool(true);
      

      读取值

      int value = atomicInt.load();
      bool flag = atomicBool.load();
      

      原子操作

      store: 向变量中存储一个数值

      load: 取出变量的值

      exchange: 以原子方式交换变量的值,返回操作之前的值。

      compare_exchange_weak: 用于比较和交换两个数值。

      compare_exchange_strong: 与上功能一样;

      区别:

      1. 阻塞:如果比较和交换操作失败,函数通常会忙等待(busy-wait)直到条件满足为止。这意味着它会持续检查条件,直到可以安全地执行比较和交换为止。这种忙等待行为可能会导致 CPU 资源的浪费,因此在高并发场景中可能需要谨慎使用。
      2. 一致性保证:与 compare_exchange_weak() 相比,compare_exchange_strong() 提供更强的内存一致性保证。具体来说,它确保了在比较和交换操作成功执行后,其他线程将立即看到更新后的值。这是通过强制使用特定的内存排序规则来实现的,这些规则确保了在多线程环境中的可见性和顺序性。

      fetch_add: 原子的增减变量的值,返回操作之前的值。

      fetch_sub: 原子的减少变量的值,返回操作之前的值。

      fetch_and: 原子的进行按位与,返回操作之前的值。

      fetch_or: 原子的进行按位或,返回操作之前的值。

      fetch_xor: 原子的进行按位异或,返回操作之前的值。

      #include <iostream>
      #include <atomic>
      
      using namespace std;
      atomic<int> g_countAtomic;
      
      void fun() {
          if (!g_countAtomic.load()) {
              g_countAtomic.store(2);
              cout << "加载atomic的值:" << g_countAtomic << endl;
          }
          g_countAtomic.store(3);
          cout << "再次加载atomic的值:" << g_countAtomic << endl;
          int nOld = g_countAtomic.exchange(5);
          cout << "以前的值:" << nOld << " 再次加载atomic的值:" << g_countAtomic.load() << endl;
          int expired = 5;
          int newValue = 6;
          if (g_countAtomic.compare_exchange_weak(expired, newValue)) {//true当前值与期望值相等,设置为新值
              cout << "当前值与期望值相等,更新新值成功,g_countAtomic = " << g_countAtomic << endl;
          }
          else {//当前值与期望值不想等,还是原来的值
              cout << "当前值与期望值不相等,没有更新新值,g_countAtomic = " << g_countAtomic << endl;
          }
          int oldResult = g_countAtomic.fetch_add(7);
          cout << "以前的值:" << oldResult << " 增加7后atomic的值:" << g_countAtomic.load() << endl;
          int oldFet = g_countAtomic.fetch_sub(4);
          cout << "以前的值:" << oldFet << " 减去4后atomic的值:" << g_countAtomic.load() << endl;
          int oldAndValue = g_countAtomic.fetch_and(10);//1010
          cout << "按位与之前的值:" << oldAndValue << " 按位与之后的值:" << g_countAtomic.load() << endl;
          int oldOrValue = g_countAtomic.fetch_or(10);
          cout << "按位或之前的值:" << oldOrValue << " 按位或之后的值:" << g_countAtomic.load() << endl;
          int oldXorValue = g_countAtomic.fetch_xor(13);
          cout << "按位异或之前的值:" << oldXorValue << " 按位异或之后的值:" << g_countAtomic.load() << endl;
      }
      
      int main()
      {
          fun();
          cout << "----------------------------------" << endl;
      
          return 0;
      }
      

    优化多线程C++程序

    程序中线程的行为可以深入到其结构中。因此优化线程行为会比优化内存分配或是函数调用更加复杂。

    • 用std::async替代std::thread

      std::thread 有一个非常严重的问题,那就是每次调用都会启动一个新的线程。启动线程时,直接开销和间接开销都会使得这个操作非常昂贵。(可以采用线程池的方式避免)

      1. 直接开销包括调用操作系统为线程在操作系统的表中分配空间的开销、为线程的栈分配内存的开销、初始化线程寄存器组的开销和调度线程运行的开销。
      2. 创建线程的间接开销是增加了所使用的内存总量。每个线程都必须为它自己的函数调用栈预留存储空间。频繁地启动和停止大量线程,那么在计算机上执行的线程会竞争 访问有限的高速缓存资源,导致高速缓存发生抖动。
      3. 线程的数量比硬件线程的数量多时会带来另外一种间接开销。由于需要操作系统进行调度,因此所有线程的速度都会变慢。

      模板函数 std::async() 会运行线程上下文中的可调用对象,但是它的实现方式允许复用线程,类似线程池的方式实现的。

      作者进行了性能测试,在 Windows 上,std::async() 明显快得多。

    • 创建与核心数量一样多的可执行线程

      C++ 提供了一个 std::thread::hardware_concurrency() 函数,它可以返回可用核心的数量。

      设置了过多的线程,每个线程都想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,而由于线程数量过多会导致性能下降

      • 线程的平均工作时间所占比例越高,就需要越少的线程;
      • 线程的平均等待时间所占比例越高,就需要越多的线程;
      • 针对不同的程序,进行对应的实际测试就可以得到最合适的选择。
    • 实现任务队列和线程池

      在上面的文章中已经有一份线程池的方案;

    • 在单独的线程中执行I/O

      程序在写数据之前或是读数据之后必须对它进行转换。我们可以考虑使用一个单独的进行去处理I/O 问题。

    • 同步和互斥

      同步和互斥会降低多线程程序的速度。摆脱或减少同步可以提升程序性能。

    • 移除启动和停止代码

      程序中有部分代码难以并发执行,那就是在 main() 得到控制权前执行的代码以及在main() 退出后执行的代码。

      在 main() 开始执行前,所有具有静态存储期的变量都会被初始化。对于基本数据类型,初始化的性能开销是 0。

    让同步更加高效

    同步是共享内存的并发的间接开销。减小这种间接开销对优化性能非常重要。

    • 减小临界区的范围

      临界区是指获取互斥量释放互斥量之间所包围的区域。(锁的范围)

      如果在临界区中并没有访问共享变量而是只做其他事情,那么其他线程就会浪费等待时间。

    • 限制并发线程的数量

      我们应当使可运行线程的数量少于或等于处理器核心的数量,这样能够移除切换上下文的间接开销。过多的线程会采用时间片等其他形式运行,导致性能下降;

    • 避免惊群

      当发生某事件时,所有的线程都会变为可运行状态,但由于只有几个核心,因此只有几个线程能够立即运行。

    • 避免锁护送

      当大量线程同步,柱塞在某个资源或是临界区上时会发生锁护送。这会导致额外的阻塞,因为它们都会试图立即继续进行处理,但是每次却只有一个线程能够继续处理,仿佛是在护送锁一样。大量线程会同时阻塞在一个位置;

    • 减少竞争

      任何时候,两个或多个线程需要相同的资源时,互斥都会导致线程挂起,无法并发。

      解决竞争问题

      1. 注意内存和 I/O 都是资源

        当大量线程都试图分配动态变量时,程序的性能可能会随着线程数量的增加出现断崖式下降。文件 I/O 也是一种资源,磁盘驱动器一次只能读取一个地址,试图同时在多个文件上执行 I/O 操作会导致性能突然下降。

      2. 复制资源

        有时候,我们可以复制表,让每个线程都有一份非共享的副本,来移除多线程对于共享的 map 或是散列表等资源的竞。尽管维护一个数据结构的两份副本会带来更多的工作,但与使用一种共享数据结构相比,它可能还会减少程序运行时间。

      3. 分割资源

        有时候我们可以分割数据结构,让每个线程只访问它们所需的那部分数据,来避免多线程竞争同一个数据结构。

      4. 细粒度锁

        我们可以使用多个互斥量,用来锁住小块区域,而不是一个锁住整个数据结构。

      5. 无锁数据结构

        我们使用无锁散列表等无锁数据结构来摆脱对互斥的依赖。是细粒度锁的终极形态。

      6. 资源的调度

        有些资源——例如磁盘驱动器——是无法被复制或分割的。但是我们可以调度磁盘活动,让它们不要同时发生,或是让访问磁盘相邻部分的活动同时发生。

    • 不要在单核系统上繁忙等待

      在单核处理器上,同步线程的唯一方法是调用操作系统的同步原语。繁忙等待会导致线程浪费整个时间片,因为除非在等待的线程放弃使用处理器,否则持有互斥量的线程是无法运行出临界区的。

    • 自己设计互斥量可能会低效

      要想设计出健壮的互斥,必须先熟悉它们所运行的基础——操作系统——的设计。自己设计互斥量不是性能优化的康庄大道

    • 限制生产者输出队列的长度

      任何时候只要生产者比消费者快,数据就会在生产者和消费者之间累积。限制队列长度并在列队满员后阻塞生产者

    并发库

    作者推荐的C++标准还未采纳的并发库:

    • Boost.Thread 和 Boost.Coroutine

      Boost 的线程库是对 C++17 标准库线程库的展望。其中有些部分现在仍然处于实验状态。

      www.boost.org/doc/libs/1_…

      www.boost.org/doc/libs/1_…

    • POSIX 线程

      一个跨平台的线程和同步原语库,它可能是最古老和使用最广泛的并发库了。

      sourceware.org/pthreads-wi…

    • 线程构建模块

      TBB 是一个有雄心壮志的、有良好文档记录的、具有模板特性的 C++ 线程 API。它提供了并行 for 循环,任务和线程池,并发容器,数据流消息传递类以及同步原语。

      (TBB,www.threadingbuildingblocks.org/

    • OpenMP

      使用 C/C++ 和 Fortran 语言进行多平台共享内存并行编程

      openmp.org

    • C++ AMP

      C++ AMP 是一份关于设计 C++ 库在 GPU 设备上进行并行数据计算的开源规范。

      msdn.microsoft.com/en-us/libra…

    小结

    • 如果没有竞争,那么一个多线程 C++ 程序具有顺序一致性。 • 一个畅所欲言的大型设计社区认为显式同步和共享变量是一个糟糕的主意。 • 在临界区中执行 I/O 操作无法优化性能。 • 可运行线程的数量应当少于或等于处理器核心的数量。 • 理想的竞争一块短临界区的核心数量是两个。

第十三章

  • 优化内存管理

    内存管理器是 C++ 运行时系统中监视动态变量的内存分配情况的一组函数和数据结构。内存管理器需要满足许多需求。如何高效地满足这些需求是一项开放的研究挑战。

    C++内存管理器API

    • 动态变量的生命周期

      最常见的 new 表达式的各种重载形式执行分配和放置生命阶段。在使用阶段后,delete 表达式会执行销毁和释放阶段。

      • 分配

        程序要求内存管理器返回一个指向至少包含指定数量未类型化的内存字节的连续内存地址的指针。如果没有足够的可用内存,那么分配将会失败。

      • 放置

        程序创建动态变量的初始值,将值放置到被分配的内存中。new 表达式参与这个阶段。

      • 使用

        程序从动态变量中读取值,调用动态变量的成员函数并将值写入到动态变量中。

      • 销毁

        如果变量是一个类实例,那么程序会调用它的析构函数对动态变量执行最后的操作。delete 表达式管理这个阶段。

      • 释放

        程序将属于被销毁的动态变量的存储空间返回给内存管理器。C 语言的库函数 free()和 C++ 语言的 delete() 运算符的各种重载版本参与释放阶段。

    • 内存管理函数分配和释放内存

      重载 new 运算符能够为任意类型的数组分配空间。

      1. new()运算符实现分配

        new 表达式会调用 new() 运算符的若干版本之一来获得动态变量的内存,或是调用 new运算符获得动态数组的内存。new() 运算符对于性能优化非常重要,因为默认内存管理器的开销是昂贵的。

        new() 运算符的几种重载形式

        void* ::operator new(size_t)

        void* ::operator new[](size_t)

        void* ::operator new(size_t, const std::nothrow_tag&)

        void* ::operator new[](size_t, const std::nothrow_tag&)

        void* ::operator new(size_t, void*)

        void* ::operator new[](size_t, void*)

    • new表达式构造动态变量

      new 表达式返回一个指向动态变量或是动态数组的第一个元素的右值指针。如果构造函数抛出了异常,那么它的成员和基类都会被销毁,被分配的内存也会通过调用 delete() 运算符返回给内存管理器。如果没有匹配的 delete() 运算符,那么内存就不会被返回给内存管理器导致发生内存泄漏

      1. 不抛出异常的new表达式

        如果 placement-params 中包含有关键字 std::nothrow,那么 new 表达式不会抛出 std::bad_alloc。它不会尝试构造对象,而是直接返回 nullptr。通常认为异常处理会降低效率,因此不抛出异常的 new 表达式应该会更快。不过,C++ 编译器实现的异常处理仅在异常被抛出后才会发生非常小的运行时开销;

      2. 定位放置new表达式执行定位放置处理而不进行分配

        如果 placement-params 是一个指向已经存在的有效存储空间的指针,那么 new 表达式不会调用内存管理器,而只是简单地将 type 放置在指针所指向的内存地址,而且这块内存必须能够容下 type。

      3. 自定义定位放置new表达式——内存分配的腹地

        如果 placement-params 是 std::nothrow 或单个指针以外的其他东西,那么这个 new 表达式就被称为自定义定位放置 new 表达式。指定的内存中创建新对象。

      4. 类专用new()运算符允许我们精准掌握内存分配

        new() 会精准地掌握自己的内存分配。类专用 new() 运算符是高效的,因为它为大小固定的对象分配内存。因此,第一个未使用的内存块总是可用的。如果类没有被用在多线程中,类专用 new() 运算符就可以免去确保类的内部数据结构是线程安全的这项开销。

    • delete表达式处置动态变量

      程序使用 delete 表达式将动态变量所使用的内存返回给内存管理器。

    • 显式析构函数调用销毁动态变量

      通过显式地调用析构函数,而不是使用 delete 表达式,能够只执行动态变量的析构,但不释放它的存储空间。

      foo_p->~Foo(); 直接调用析构函数,内存空间没被释放,会导致内存泄漏;

    高性能内存管理器

    C++默认情况下,所有申请存储空间的请求都会经过 ::operator new(),释放存储空间的请求都会经过 ::operator delete()。(除原生malloc() free())

    大多数 C++ 编译器所提供的 ::operator new() 都是 C 语言的 malloc() 函数的简单包装器。

    如果一个程序使用了包括字符串和标准容器在内的很多动态变量,那么用这些malloc() 替代内存管理器非常有效;

    提供类专用内存管理器

    如果一个类实现了 new() 运算符,那么当为该类申请内存时就不会调用全局 new() 运算符,而是调用这个 new() 运算符。我们可以利用对对象的了解在类专用内存管理器中编写更多有利于提升性能的处理。

    编写高效地处理分配相同大小内存的请求的内存管理器

    1. 分配固定大小内存的内存管理器能够高效地复用被返回的内存。它们不必担心碎片,因为所有的请求都申请相同大小的内存。

    2. 能够以很少甚至零内存间接开销的方式实现分配固定大小内存的内存管理器。

    3. 分配固定大小内存的内存管理器能够确保所消耗内存总量的上限。

    4. 在分配固定大小内存的内存管理器中,分配和释放内存的函数都非常简单,因此它们会被高效地内联,而默认 C++ 内存分配器中的函数则无法被内联。

    5. 分配固定大小内存的内存管理器具有优秀的高速缓存行为。最后一个被释放的节点可以是下一个被分配的节点。

    • 分配固定大小内存的内存管理器

      代码清单定义了一个简单的分配固定大小内存块的内存管理器,它会从一个名为“分 配区”(arena)的单独的、静态声明的存储空间块中分配内存块。

      template <class Arena> struct fixed_block_memory_manager {
       template <int N>
       fixed_block_memory_manager(char(&a)[N]);
       fixed_block_memory_manager(fixed_block_memory_manager&) = delete;
       ~fixed_block_memory_manager() = default;
       void operator=(fixed_block_memory_manager&) = delete;
       void* allocate(size_t);
       size_t block_size() const;
       size_t capacity() const;
       void clear();
       void deallocate(void*);
       bool empty() const;
      private:
       struct free_block {
      	 free_block* next;
       };
      	 free_block* free_ptr_;
      	 size_t block_size_;
      	 Arena arena_;
      };
      // 构造函数接收一个 C 风格的字符数组作为它的参数。
      
    • 内存块分配区

      分配固定大小内存的内存管理器所使用的内存块分配区

      struct fixed_arena_controller {
      	 template <int N>
      		 fixed_arena_controller(char(&a)[N]);
      	 fixed_arena_controller(fixed_arena_controller&) = delete;
      	 ~fixed_arena_controller() = default;
      	 void operator=(fixed_arena_controller&) = delete;
      	 void* allocate(size_t);
      	 size_t block_size() const;
      	 size_t capacity() const;
      	 void clear();
      	 bool empty() const;
      private:
      	 void* arena_;
      	 size_t arena_size_;
      	 size_t block_size_;
      };
      

      fixed_arena_controller 类的目的是创建一个内存块链表,其中所有的内存块的大小都是相同的。这个大小是在第一次调用 allocate() 时设置的。

      fixed_arena_controller 中的分配和释放代码很简单:在提供给构造函数的存储空间上分配未使用节点的链表,返回一个指向链表第一个元素的指针。

      inline void* fixed_arena_controller
       ::allocate(size_t size) {
       if (!empty())
       return nullptr; // arena已经被分配了
       block_size_ = std::max(size, sizeof(void*));
       size_t count = capacity();
       if (count == 0)
       return nullptr; // arena太小了,甚至容不下一个元素
       char* p;
       for (p = (char*)arena_; count > 1; --count, p += size) {
       *reinterpret_cast<char**>(p) = p + size;
       }
       *reinterpret_cast<char**>(p) = nullptr;
       return arena_;
      
      inline size_t fixed_arena_controller::block_size() const {
       return block_size_;
      }
      inline size_t fixed_arena_controller::capacity() const {
       return block_size_ ? (arena_size_ / block_size_) : 0;
      }
      inline void fixed_arena_controller::clear() {
       block_size_ = 0;
      }
      inline bool fixed_arena_controller::empty() const {
       return block_size_ == 0;
      }
      }
      
    • 添加一个类专用new()运算符

      具有类专用 new() 运算符的类

      class MemMgrTester {
      	 int contents_;
      public:
      	 MemMgrTester(int c) : contents_(c) {}
      	 static void* operator new(size_t s) {
      		 return mgr_.allocate(s);
      	 }
      	 static void operator delete(void* p) {
      		 mgr_.deallocate(p);
      	 }
      	 static fixed_block_memory_manager<fixed_arena_controller> mgr_;
      };
      

      能够像这样被重置的内存管理器被称作内存池管理器;它所控制的分配区则被称为内存池。

    • 分配固定大小内存块的内存管理器的性能

      分配固定大小内存块的内存管理器是非常高效的。分配和释放内存块的函数的开销是固定的,而且代码可以内联。在内存分配之间进行的计算越多,那么提高内存分配性能所能带来的收益就越小。

    • 分配固定大小内存块的内存管理器的变化形式

      • 当未使用内存块的链表是空的时,不是分配一个新的固定大小内存块的分配区,而是使用 malloc() 分配内存。被释放的内存块会被缓存在未使用内存块的链表中供快速复用。
      • 可以通过调用 malloc() 或是 ::new 创建分配区,而不使用固定分配区。分配固定大小内存块的内存管理器能够保持它速度快和内存碎片少的优势。
      • 如果类的实例在使用一段时间后会被全部销毁,那么可以将分配固定大小内存块的内存管理器作为内存池使用。我们可以设计一种通用的内存管理器来满足来自另外一个分配区的申请不同大小内存块的请求,并返回不同大小的内存块到另外一个未使用内存块的链表中。

      Boost 有一个叫作“Pool”(www.boost.org/doc/libs/re…的分配固定大小内存块的内存管理器。

    • 非线程安全的内存管理器是高效的

      如果不用顾忌线程安全,那么分配固定大小内存块的内存管理器还可以更加高效。

      1. 同步的开销是昂贵的,它们不需要同步机制来序列化临界区
      2. 非线程安全的内存管理器之所以高效是因为它们不会柱塞在同步原语上。

      如果一个类实现了类专用内存管理器,即使程序作为一个整体是多线程的,只要某个类只在一个线程中使用,那么它就无需等待。尽量使临界区最小,让内存管理器运行得更高效。

    小结

    • 相比于内存管理器,在其他地方看看有没有可能会带来更好性能改善效果的优化机会。 • 对几个大型开源程序的研究表明,替换默认内存管理器对程序整体运行速度的性能提升最多只有 30%。 • 为申请相同大小内存块的请求分配内存的内存管理器是很容易编写的,它的运行效率也很高。 • 同一个类的实例的分配内存的请求所申请的内存的大小是一样的。 • 可以在类级别重写 new() 运算符。 • 标准库容器类 std::list、std::map、std::multimap、std::set 和 std::multiset 都从许多同等的节点中创建数据结构。 • 标准库容器接收一个 Allocator 作为参数,与类专用 new() 运算符一样,允许自定义内存管理。 • 编写一个自定义的内存管理器或分配器可以提高程序性能,但相比于移除对内存管理器的调用等其他优化方法,它的效果没有那么明显。