• C++ 性能优化指南 KurtGuntheroth 第12章 优化并发 摘录


    C++标准库直接支持线程共享内存的并发模型;

    12.1 复习并发

    并发的目标不是减少减少指令执行次数或是每秒访问数据的次数。相反,它是通过提高计算资源的使用率来减少程序运行的时间的。

    开发执行的程序活动越多,那么所使用的资源以及等待事件发生和资源变为可用状态的程序活动也越多。如此良性循环下去,计算资源和IO资源的总的使用时间将会达到某个饱和度。

    从性能优化的角度看,并发的挑战是找到足够多的独立任务来充分使用所有可用的计算资源,即使有些任务必须等待外部事件的发生或是资源变为可用状态。

    本节将会讲解C++内存模型和在多线程程序中使用共享内存技术的基本工具。

    12.1.1 并发概述

    计算机硬件、操作系统、函数库以及C++自身的特性都能够为程序提供并发支持。

    最著名的几种并发形式如下:

    1. 时间分割(time slice):

            这是操作系统中的一个调度函数。在时间分割中,操作系统会维护一份当前正在执行的程序和系统任务的列表,并为每个程序分配时间块。任何时候,当一个程序等待事件或是资源时,操作系统会将程序从可运行程序列表中移除,并将它所使用的处理器资源共享给其他程序。

    2. 虚拟化virtualization:

            一种常见的虚拟化技术是让一个称为hypervisor的轻量级操作系统将处理器的时间块分配给客户虚拟机。客户虚拟机包含一个文件系统镜像和一个内存镜像,通常这都是一个正在运行一个或多个程序的操作系统。当hypervisor运行客户虚拟机后,某些处理器指令和对内存区域的某些访问会产生Trap,并将它下传给hypervisor,这将允许hypervisor竞争IO设备和其他硬件资源。另外一种虚拟化技术是使用传统操作系统作为客户虚拟机的主机。如果主机和客户虚拟机上运行的操作系统相同,那么就能够使用操作系统IO工具更加高效地竞争IO资源。

            虚拟化技术的优点如下:

            客户虚拟机是在运行时被打包为磁盘文件的,因此我们能够对客户虚拟机设置检查带你checkpoint,保存客户虚拟机,加载和继续执行客户虚拟机,以及在多台主机上运行客户虚拟机。

            只要资源允许,我们能够并发地执行运行多台客户虚拟机。hypervisor会与计算机虚拟内存保护硬件共同协作,隔离这些客户虚拟机。这使得硬件能够被当做商品租借出去并计时收费。

            我们能够配置客户虚拟机使用主机的一部分资源(物理内存,处理器核心)。计算资源能够根据每台客户虚拟机上正在运行的程序的需求“量体裁衣”,确保并发地在同一硬件上运行的多台虚拟机保持性能稳定,并防止它们之间意外地发生交互。

            与传统的时间分割一样,C++程序同样不知道它运行在hyhpervisor下的一台客户虚拟机中。C++程序也许会间接地注意到它们所使用的资源受到了限制。虚拟化技术与C++程序设计是有关的,因为它既能够限制程序所消耗的计算资源,也需要让程序知道哪些资源才是真正可用的。

    3. 容器化containerization:

            容器化和虚拟化的相似之处在于,容器中也有一个包含了程序在检查点的状态的文件系统镜像和内存镜像:不同之处在于容器主机是一个操作系统,这样能够直接地提供IO和系统资源,而不必通过hypervisor去低效地竞争资源。

    容器化与虚拟机相同的优点(打包,配置和隔离性),同时它在某种程度上更加高效。对于运行于容器化的C++程序,容器化是不可见的。

    4. 对称式多处理器symmetric multiprocessing

            对称式多处理器是一种包含若干执行相同机器代码并访问相同物理内存的执行单元的计算机。现代多核处理器都是对称式多处理器。当前正在执行的程序和系统任务能够运行于任何可用的执行单元上,尽管选择执行单元可能会给性能带来影响。

            对称式多处理器使用真正的硬件并发执行多线程控制。如果对称式多处理器有n个执行单元,那么一个计算密集型程序的执行时间最多可以被缩短为1/n。软件线程可能会,也可以不会运行于各自的硬件线程上,因此可能会也可能不会减少程序运行的总时间,而硬件线程则与此形成了鲜明对比。

    5. 超线程simultaneous multithreading

       有些处理器的硬件核心有两个或多个寄存器集,可以相应执行两条或多条指令流。当一条指令流停顿时(如需要访问主内存),处理器核心能够执行另外一条指令流上的指令。不过最高效地使用软件线程的方法是让软件线程数量与硬件线程数量匹配。

    6. 多进程:

            进程是并发的执行流,这些执行流有它们自己受保护的虚拟内存空间。进程之间通过管道、队列、网络IO或是其他不共享的机制进行通信。线程使用同步原语或是通过等待输入(即发生阻塞直至输入变为可用状态)来进行同步。

            进程的主要优点是操作系统会隔离各个进程。如果一个进程奔溃了,其他进程依然活着,尽管它们可能什么也不做。

            进程最大的缺点是它们有太多的状态:虚内存表,多执行单元上下文,所有暂停线程的上下文。进程的启动、停止以及互相之间的切换都比线程慢。

            C+=无法直接操作进程。通常,一个C++程序的表现形式就是操作系统的一个进程。C++中没有任何工具可以操作进程,因为并非所有的操作系统都有进程的概念。有些小型处理器会为程序分隔时间,但不会隔离程序,所以这些程序看起来更像是线程。

    7. 分布式处理distributed processing

            分布式处理是指程序活动分布在一组处理器上。这些处理器可以不同。相比于处理器的处理速度,它们之间的通信速度非常慢。一组通过TCP/IP协议进行通信的云服务器的实例就是一种分布式处理。另外一种就是将图形任务分流给图形处理单元中的多种专用处理器。 

            在典型的分布式处理结构中,数据通过管道或网络流向进程,进程在对输入数据进行处理后的数据放入到下一段管道中。

            进程之间不会共享内存或是同步,因此它们能够全速进行。

    8. 线程:

            线程是进程中的并发执行流,它们之间共享内存。线程使用同步原语进行同步,使用共享内存地址进行通信。

            与进程相比,线程的优点在于消耗的资源更少,创建和切换也更快。

            不过,线程也有几个缺点:由于进程中的所有线程都共享相同的内存空间,一个线程写入无效的内存地址可能会覆盖掉其他线程的数据结构,导致线程崩溃或出现不可预测的行为。此外,访问共享内存远比访问不共享的内存慢,并且内存中保存的内容必须在线程之间同步,否则线程将会难以解释这些内容。

    9. 任务:

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

            基于任务的并发构建于线程之上,因此任务也具有线程的优点和缺点。

            基于任务的并发的的另一个优势是,处于活动状态的软件线程的数量能够与硬件线程的数量匹配起来。

            任务的灵活性的代价比应用程序的复杂性更大。程序必须实现对任务设置优先级或是排序任务的方法。

    12.1.2 交叉执行

    在单核处理器时代,并发是通过在操作系统中进行时间分割来实现的。竞争条件race condition相当稀少,因为一个线程在操作系统将控制权交给另外一个线程之前会执行许多语句。

    在如今的多核处理器时代,单独语句的交叉成为了可能,这样会更加频繁地观察到竞争条件。

    12.1.3 顺序一致性

    C++认为计算机模型是简单和直观的,这个模型有一个要求,那就是程序具有顺序一致性sequential consistency。也就是说,程序表现得看起来像是语句的执行顺序与语句的编写顺序是一致的,遵守C++流程控制语句的控制。

    1. int x = 0, y = 0;
    2. x = 1;
    3. y = 1;
    4. if (y == 1)
    5. assert(x == 1);

    编译器生成最优代码的“黑暗魔法”,会对代码进行重新排序,现代微处理器也会对加载和存储重新排序,编译器优化,改变执行顺序,告诉缓存和写缓冲去的综合影响可以使用加载和存储通用隐喻来建模--摆脱它们在程序中原本位置,将它们移动到原来位置之前或之后。

    重要的是,当一条使用变量的语句被移动到相关语句之前或之后时,只要不是将它移动到更新该语句之后,程序就仍然具有顺序一致性;同样,在改变更新变量的语句的执行顺序时,只要不是将它移动到使用该变量之后,程序就仍然具有一致性。

    12.1.4 竞争

    并发给了C++带来一个问题--没有任何方法能够知道什么时候两个函数会并发执行以及那些变量被共享了。

    在C++的标准内存模型中,主要程序不会发生竞争,那么它的行为看起来像是具有顺序一致性,如果程序中发生竞争,就可能会违背顺序一致性。

    12.1.5 同步

    同步是多线程中语句交互的强制顺序。同步允许开发人员讨论多线程程序语句的执行顺序。没有同步,语句执行的顺序是不可预测的,线程之间的协同工作将会变得非常困难。

    同步原语是一种编程结构,其目的是通过强制并发程序的交叉来实现同步。所有同步原语的工作原理都是让一个线程等待另一个线程或是挂起线程。通过强制指定特定的执行顺序,同步原语避免了竞争的发生。

    包括挂起线程的事件event,互斥量mutex,信号量semaphore。

    理解同步原语只是一种概念山的存在非常重要。同样的同步原语在不同的操作系统上会有不同的实现。

    经典的同步原语会与操作系统进行交互,切换线程的活动状态和挂起状态。这种实现适合哪种只有一个相对较慢的执行单元的计算机。不过,通过操作系统启动和停止线程的延迟会非常明显。如果计算机有多个处理器以一种真正的并发方式执行多个指令流,通过在共享变量上采用繁忙等待策略进行同步可用大幅缩短等待时间。

    12.1.6 原子性

    如果没有线程能够在另外一个线程对共享变量计算到一半的时候看到该变量被更新了,那么在共享变量上执行这个操作就具有原子性atomicity。

    1. 互斥实现原子性

    传统上,原子性是通过互斥实现的。每个线程在访问共享变量前都必须获得一个互斥量mutex,并在完成操作后释放这个互斥量。获取和释放互斥量之间的程序部分被称为临界区critical section。如果一个线程得到了互斥量,那么其他所有线程在试图获得互斥量时都会被挂起。因此,在一个时间点只有一个线程能够对共享数据进行操作。我们称这个线程持有互斥量。互斥量会序列化线程,让它们一个接一个地访问临界区。

    有一种称为内存栅栏memory fence的机制可以防止共享变量的加载和存储泄露至临界区外。在处理器中,有些特殊的指令可以告诉处理器不要移动加载语句和存储语句穿越内存栅栏。

    位于临界区顶部的内存栅栏必须防止共享变量的加载被泄露至临界区外。我们称这个内存栅栏具有获得语义acquire semantics,因为它在线程获得互斥量时才存在。类似地,位于临界区底部的内存栅栏必须防止共享变量的存储被泄露至临界区外。我们称这个内存栅栏具有释放语义release semantics,因为它在线程释放互斥量时才存在。

    使用C++标准库提供的同步原语或是操作系统的原生同步库的开发人员无需担心内存栅栏,但是自己实现同步原语或是无锁数据结构的程序员则必须保持警惕。

    2. 原子性硬件操作

    通过互斥实现的原子性会带来性能开销,使得开发人员在使用它时会遇到麻烦。

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

    当一个线程释放互斥量时,在该互斥量上挂起的线程就能够获取它。但是当多个线程被挂起时,是无法确保其中那个线程一定能够获得互斥量,因为提供这样一种保证的性能开销非常昂贵。如果许多线程被挂起了,有些线程可能永远无法得到互斥量;这些线程的计算无法继续向前进行。这种情况就称为资源饥饿。

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

    对于整数和指针这样的简单类型变量,在某些计算机执行的某些操作是具有原子性的,因为这些操作是都通过一条单独的机器指令执行时。这些特殊的原子性指令带有内存栅栏,能够确保指令在执行过程中不会被其他的指令中断。

    原子性指令形成了实现互斥量和其他同步原语的基础。使用硬件的原子性操作就可以实现巧妙得令人惊叹的线程安全的数据结构。我们称其为无锁编程lock-free。

    无锁编程可以增加并发线程的数量,但是它们并非万灵药。原子操作仍然会序列化线程,哪怕这些操作只执行一条指令。

    12.4 让同步更加高效

    同步是共享内存的并发的间接开销。当线程必须在星号上挂起时,开销是以毫秒计算的。互斥量重要开销是等待其他线程释放它的开销。

    多线程程序最容易遇见的一个问题是有许多线程争相获取同一个互斥量,如果这种竞争不激烈,那么持有互斥量通常不会降低其他线程的性能。

    12.4.1 减小临界区的范围

    临界区是指获取互斥量和释放互斥量之间所包围的区域。在临界区的执行过程中,没有其他线程能够访问该互斥量所控制的共享变量。

    第一点是难以高效地使用监视器概念,第二点是在临界区中执行IO处理无法提高性能。

    12.4.2 限制并发线程的数量

    可运行线程的数量少于或等于处理器核心的数量,这样能够移除切换上下文的间接开销。

    如果线程数量比核心数量多,则只有一部分线程会被分配给核心,在某个时间点也只有一部分线程会执行实际运行。其他线程会在操作系统的“可运行”队列中等待被分配时间片。操作系统会因周期性的中断而醒来,决定运行那个线程。

    “可运行”队列中的线程可能会在操作系统为它分配核心之前等待许多毫秒。

    如果持有互斥量但没有实际去执行,而是在操作系统的可运行队列中等待,那么它就无法释放互斥量。

    除非线程在事件上挂起了,否则操作系统就让当前分配给核心的线程运行完整的时间片。知道有些其他线程耗尽了它们的时间片,操作系统才会分配核心给新的可运行线程,而这可能会耗时许多毫秒。

    这就是开发人员要试图通过限制活动线程的数量来解决的导致程序性能下降的问题。

    12.4.3 避免惊群

    当有许多线程挂起在一个事件上时就会发生所谓的惊群thundering herd。当发生这个事件时,所有的线程都会变为可运行状态,但如果只有几个核心,因此只有几个线程能够立即运行。其中只有一个线程能够拿到互斥量继续进行工作,操作系统会将其他线程移动到可运行队列中,并最终逐个运行线程,每个线程都会发现发出的事件已经被其他某个线程服务了,只得继续挂在这个事件上,虽然消耗了很多时间但线程处理却没有任何进展。

    避免惊群问题的方法就是限制创建出服务事件的数量。

    12.4.4 避免锁护送

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

    当大量线程竞争一个互斥量时,当互斥量被释放时,大量线程线程变为可运行状态,但是只有第一个被分配到处理器的线程会再次锁住互斥量,其余线程被挂起。这对程序的整体影响是操作系统虽然花费了很多时间重启线程,但大多数线程都无法继续处理。更糟糕的是,所有的线程都仍然是同步的。当下个线程释放互斥量时它们会立即醒来,然后如此往复循环。

    当系统通常都运行良好,但偶尔看起来失去响应几秒,那么就可以看出发生了锁护送。

    尽管仍然总是有在另外一个地方出现锁护送的风险,但减少线程数量或是调度线程在不同的时间启动能够缓解出现锁护送的情况。有时,最好能够简单地确认一下,某个组的任务是否因为共享了某个硬件设备或是其他性能瓶颈资源而无法并发执行。

    12.4.5 减少竞争

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

    1. 注意内存和IO都是资源:

            并非所有的开发人员都注意到了内存管理器是一种资源。在多线程系统中,内存管理器必须序列化对它的访问,否则它的数据结构会被破坏。当大量线程都试图分配动态变量时,程序的性能可能会随着线程数量的增加而出现断崖式下跌。

    2. 文件IO也是资源;

            磁盘驱动器一次只能读取一个地址。试图同时在多个文件上执行IO操作会导致性能下降。

    3. 网络IO也是资源:

            相对于数据传输,以太网连接器是一条相对窄的管道。

    4. 复制资源

            有时候,我们可以复制表,让每个线程都有一份非共享的副本,来移除多线程对于共享的map或是散列表等资源的竞争。

    5. 分隔资源:

            分隔数据结构,让每个线程只访问它们所需的那部分数据,来避免多线程竞争同一个数据结构。

    6. 细粒度锁:

            使用多个互斥量,而不是一个互斥量来锁住整个数据结构。读写锁是一个不错的选择,要访问散列表的一条元素时,线程可以使用读锁锁住骨干数组,然后用一个读锁或写锁锁住元素。

    7. 无锁数据结构:

            使用无锁散列表等数据结构来拜托对互斥的依赖,这是细粒度锁的终极形态。

    8. 资源的调度:

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

            尽管操作系统会在细粒度级别调度读写操作,但是程序能够通过序列化读取配置文件等操作,避免它们同时发生。

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

    繁忙等待会导致线程浪费整个时间片,因为除非在等待的线程放弃使用处理器,否则持有互斥量的线程是无法运行出临界区。

    12.4.7 不要永远等待

            一直等待是错误恢复的天敌。

    12.4.8 自主设计的互斥量可能会低效

            操作系统提供的互斥量更加了解操作系统的奥妙,以及它调度任务以改善性能或是在该操作系统上避免优先级反转问题。

    12.4.9 限制生产者输出队列

            在生产者/消费者程序中,任何时候只要生产者比消费者快,数据就会在生产者和消费者之间累积。导致许多问题:

            生产者竞争处理器。内存分配器和其他资源;

            生产者会最终消费所有的系统内存资源,导致整个程序异常终止。

            如果程序能够从异常恢复过来,在重启之前它可能会需要在处理队列中累积的所有数据,这将会增加程序的恢复时间。

    解决办法就是限制队列长度并在队列满员后阻塞生产者。

    12.5 并发库

    Boost.Thread;

    POSIX线程;

    线程构建木块TBB;

    MPI;

    OpenMP;

    C++ AMP(GPU);

    12.6 小结

    如果没有竞争,那么一个多线程C++程序具有顺序一致性;

    显式同步和共享变量是一个糟糕注意;

    在临界区执行IO操作无法优化性能;

    可运行线程的数量应当少于或等于处理器核心的数量;

    理想的竞争一块短临界区的核心数量是两个;

            

  • 相关阅读:
    Taro模拟table表格搭配react实现方式
    【距离注意残差网络:超分】
    Linux搭建DNS服务
    实战Docker未授权访问提权
    【Zookeeper客户端常用的命令&&Zookeeper的核心功能之节点数据存储】
    Python青少年等级考试实操题(二级)
    【RTOS训练营】GPIO知识和预习安排 + 晚课提问
    【Git】Git基础命令操作速记
    2022.6.30-----leetcode.1175
    从0到1写一款自动为Markdown标题添加序号的Jetbrains插件
  • 原文地址:https://blog.csdn.net/weixin_47955824/article/details/126130977