8.2 并发代码的性能

多处理系统中使用并发来提高代码的效率时,需要了解影响并发的效率的因素。即使使用多线程对关注点进行分离,还需要确定是否会对性能造成负面影响。在16核机器上应用的速度与单核机器相当时,用户一定会抱怨。

多线程代码中有很多因素会影响性能——对线程处理的数据做一些简单的改动,都可能对性能产生戏剧性的效果。所以,多言无益,让我们来看一下这些因素吧。

8.2.1 处理器数量

处理器数量是影响多线程应用的首要因素。当对目标硬件很熟悉,并且能针对硬件进行软件设计,并在目标系统或副本上进行性能测试。那很幸运,可以在类似的平台上进行开发。不过,当所使用的平台与目标平台的差异很大,比如:可能会在一个双芯或四芯的系统上做开发,而用户系统可能就只有单个处理器(可能有很多芯),或多个单芯处理器,亦或是多核多芯的处理器。并发程序在不同平台上的行为和性能特点可能完全不同,需要在不同平台上进行测试。

一个单核16芯的处理器和四核双芯或十六核单芯的处理器相同:任何系统上,都能开启16个并发线程。线程数量少于16个时,会有处理器处于空闲状态(除非系统同时需要运行其他应用,不过我们暂时忽略这种可能性)。另一方面,当多于16个线程在运行时(都没有阻塞或等待),应用将会浪费处理器的运算时间在线程间进行切换,称其为超额申请(oversubscription)。

为了扩展线程的数量,且与硬件所支持的并发线程数一致,C++标准线程库提供std::thread::hardware_concurrency(),使用这个函数就能知道在给定硬件上可以扩展的线程数量了。

使用std::thread::hardware_concurrency()需要谨慎,因为不会考虑其他应用已使用的线程数量(除非已经将系统信息进行共享)。std::async()可以避免这个问题,标准库会对所有调用进行安排。同样,谨慎的使用线程池也可以避免这个问题。

不过,即使考虑到在应用中运行的线程,还是会受其他运行程序的影响。虽然,单用户系统中使用多个CPU密集型应用很罕见,但在某些领域就很常见了。系统能提供选择线程数量的机制,这种机制超出了C++标准的范围。一种选择是使用与std::async()类似的工具,为所有执行异步任务线程的数量做考虑,另一种选择是限制每个应用使用的处理芯数量。我是希望这种限制可以反映到std::thread::hardware_concurrency()上(不能保证)。如果需要处理这种情况,可以看一下系统说明,了解一下是否有相关选项可供使用。

理想算法可能会取决于问题规模与处理单元的比值。大规模并行系统中有很多的处理单元,为了让应用尽快结束,算法可能就会同时执行很多操作。

随着处理器数量的增加,另一个问题就会来影响性能:多个处理器尝试访问同一个数据。

8.2.2 数据争用与乒乓缓存

当两个线程在不同处理器上时,对同一数据进行读取,通常不会出现问题。因为数据会拷贝到每个线程的缓存中,并让两个处理器同时进行处理。当有线程对数据进行修改,并且需要更新到其他核芯的缓存中去,就要耗费一定的时间。这样的修改可能会让第二个处理器停下来,等待硬件内存更新缓存中的数据。根据CPU指令,这是一个特别特别慢的操作。

思考下面的代码段:

  1. std::atomic<unsigned long> counter(0);
  2. void processing_loop()
  3. {
  4. while(counter.fetch_add(1,std::memory_order_relaxed)<100000000)
  5. {
  6. do_something();
  7. }
  8. }

counter变量是全局的,任何线程都能调用processing_loop()。因此,每次对counter进行增量操作时,处理器必须确保缓存中的counter是最新值,然后进行修改,再告知其他处理器。编译器不会为任何数据做同步操作,fetch_add是一个“读-改-写”操作,因此要对最新的值进行检索。如果另一个线程在另一个处理器上执行同样的代码,counter的数据需要在两个处理器之间进行传递,这两个处理器的缓存中间就存有counter的最新值(当counter的值增加时)。如果do_something()足够短,或有很多处理器来对这段代码进行处理时,处理器会互相等待。一个处理器准备更新这个值,另一个处理器在修改这个值,所以该处理器就需要等待第二个处理器更新完成,并且完成更新传递时才能执行更新,这种情况被称为高竞争(high contention)。如果处理器很少需要互相等待就是低竞争(low contention)。

循环中counter的数据将在每个缓存中传递若干次,这就是乒乓缓存(cache ping-pong),这会对应用的性能有着重大的影响。当处理器因为等待缓存转移而停止运行时,这个处理器就不能做任何事情,所以对于整个应用来说这是一个坏消息。

你可能会想,这种情况不会发生在你身上,因为你没有使用任何循环。那么互斥锁呢?如果在循环中放置一个互斥量,就和之前从数据访问的方式差不多了。为了锁住互斥量,另一个线程必须将数据进行转移,并且对数据进行修改。当这个过程完成时,会再次对互斥量进行解锁,之后互斥数据将会传递到下一个需要互斥量的线程上去。转移耗费的时间,就是第二个线程等待第一个线程释放互斥量的时间:

  1. std::mutex m;
  2. my_data data;
  3. void processing_loop_with_mutex()
  4. {
  5. while(true)
  6. {
  7. std::lock_guard<std::mutex> lk(m);
  8. if(done_processing(data)) break;
  9. }
  10. }

接下来看看最糟糕的部分:数据和互斥量已经准备好让多个线程进访问后,当系统中的核心数和处理器数量增加时,很可能看到高竞争,以及处理器互相等待的情况。如果在多线程情况下,能更快的对同样级别的数据进行处理,线程就会对数据和互斥量进行竞争。这样的情况有很多,很多线程会同时尝试对互斥量进行获取,或者同时访问变量等等。

互斥量的竞争通常不同于原子操作,互斥量通常使用操作系统级别的序列化线程。如果有足够的线程去执行任务,有线程在等待互斥量时,操作系统会安排其他线程来执行任务,而处理器只会在其他线程运行在目标处理器上时,让该处理器停止工作。对互斥量的竞争,将会影响线程的性能。

回顾第3章,一个很少更新的数据结构可以使用一个“单作者,多读者”互斥量(详见3.3.2)保护。乒乓缓存可以抵消互斥所带来的性能收益,因为所有线程(即使是读者线程)都会对互斥量进行修改。随着处理器对数据访问次数的增加,对于互斥量的竞争就会增加,并且持有互斥量的缓存行会在核芯中进行转移,因此会增加不良的锁获取和释放次数。有些方法可以改善这个问题,就是让互斥量对多行缓存进行保护,不过这样的互斥量需要自己去实现。

如何避免乒乓缓存呢?答案就是:减少两个线程对同一个内存位置的竞争。

虽然,要实现起来并不简单。即使给定内存位置,因为伪共享(false sharing)可能还是会有乒乓缓存。

8.2.3 伪共享

处理器缓存通常不会用来处理单个存储点,而是会用来处理称为缓存行(cache lines)的内存块。内存块通常大小为32或64字节,实际大小需要由处理器来决定。因为硬件缓存可确定处理缓存行的大小,较小的数据项就在同一内存行的相邻位置上。有时这样的设定还不错:当线程访问的一组数据是在同一数据行中,对于应用的性能来说就要好于对多个缓存行进行传播。不过,同一缓存行存储的是无关数据时,且需要被不同线程访问,这就会造成性能问题。

假设一个int类型的数组,并且有一组线程可以访问数组中的元素,且对数组的访问很频繁。通常int的大小要小于一个缓存行,同一个缓存行中可以存储多个数据项。因此,即使每个线程都能对数据中的成员进行访问,硬件还是会产生乒乓缓存。每当线程访问0号数据项,并对其值进行更新时,缓存行的所有权就需要转移给执行该线程的处理器,这仅是为了更新1号数据项的线程获取1号线程的所有权。缓存行是共享的(即使没有数据存在),因此使用伪共享来描述这种方式。这个问题的解决办法就是对数据进行构造,让同一线程访问的数据项存在临近的地址中(就像是放在同一缓存行中),这样那些能被独立线程访问的数据将分布在相距很远的地方,并且可能是存储在不同的缓存行中。本章接下来的内容中可以看到,这种思路对代码和数据设计的影响。C++17标准在头文件<new>中定义了std::hardware_destructive_interference_size它指定了当前编译目标可能共享的连续字节的最大数目。如果确保数据间隔大于等于这个字节数,就不会有错误的共享存在了。

如果多线程访问同一内存行是一种糟糕的情况,那么单线程下的内存布局会有哪些影响呢?

8.2.4 让数据紧凑

伪共享发生的原因:某个线程所要访问的数据过于接近另一线程的数据,另一个是与数据布局相关的陷阱会直接影响单线程的性能。问题在于数据过于接近:单线程访问数据时,数据就已在内存中展开,分布在不同的缓存行上。另一方面,当内存中有紧凑数据时,数据就分布在同一缓存行上。因此,当数据已传播,将会有更多的缓存行从处理器的缓存上加载数据,这会增加访问内存的延迟,以及降低数据的性能(与紧凑的数据存储地址相比较)。

同样的,如果数据已传播,给定缓存行上就包含与当前线程有关和无关的数据。极端情况下,有更多的数据存在于缓存中,就会对数据以更多的关注,而非这些数据去做了什么。这就会浪费宝贵的缓存空间,增加处理器缓存缺失的情况,从而因为其他数据已经占有缓存中的位置,所以需要再从主存中添加对应数据项到缓存中。

现在,对于单线程代码来说任务切换(task switching)就很关键了。如果系统中的线程数量要比核芯多,每个核上都要运行多个线程。这就会增加缓存的压力,为了避免伪共享,努力让不同线程访问不同缓存行。当处理器切换线程时,要对不同内存行上的数据进行加载(当不同线程使用的数据跨越了多个缓存行时),而非对缓存中的数据保持原样(当线程中的数据都在同一缓存行时)。C++17在头文件<new>中指定了一个常数std::hardware_constructive_interference_size,这是同一高速缓存行上的连续字节的最大数目(需要对齐)。将所需的数据大小控制在这个字节数内,就能提高缓存命中率。

如果线程数量多于内核或处理器数量,操作系统可能会选择将线程安排给这个核芯一段时间,之后再安排给另一个核芯一段时间。就需要将缓存行从一个内核上,转移到另一个内核上,也意味着要耗费很多时间。虽然,操作系统通常会避免这样的情况发生,不过当其发生的时候,对性能会有很大影响。

当有超级多的线程准备运行时(非等待状态),任务切换就会频繁发生。这个问题我们之前也接触过:超额申请。

8.2.5 超额申请和频繁的任务切换

多线程系统中,通常线程的数量要多于处理器的数量,除非在大规模并行(massively parallel)硬件上运行。不过,线程会花费时间来等待外部I/O完成,或被互斥量阻塞,或等待条件变量等等,所以等待不是问题。使用额外的线程来完成有用的工作,而非让线程在处理器处以闲置状态时持续等待。

不过,这也并非长久之计,如果有很多额外线程,就会有很多线程准备执行。不过,当线程数远远大于可用处理器数量时,操作系统就会忙于切换任务,以确保每个任务都有时间运行,这将增加切换任务的时间开销,和缓存问题造成同一结果。当无限制的产生新线程,超额申请就会加剧,或者在通过任务类型对任务进行划分的时候,线程数量大于处理器数量。这时,对性能影响的因素是CPU的能力,而非I/O。

如果只是简单的通过数据划分生成多个线程,可以限定工作线程的数量,如8.1.2节中那样。如果超额申请是对工作的划分而产生,那么不同的划分方式对性能就没有太多益处了。

其他因素也会影响多线程代码的性能,即使CPU类型和时钟周期相同,乒乓缓存的开销也可以让程序在两个单核处理器和在一个双核处理器上,产生不明显的性能差。接下来,让我们看一下这些因素是如何影响代码与数据结构设计的。