网站首页 > 基础教程 正文
如果要用并发来提高程序在多处理器环境下的性能,我们需要了解哪些因素会影响。哪怕你只是用多线程来进行关注点分离,你需要确保这不会对性能有负面影响。如果你的程序在16核的机器上跑得比在一台老的单核机器上还更慢,客户可是不会买账的。
接下来,我们会看到有非常多的因素影响多线程程序的性能——哪怕只是改变下每个线程处理的哪部分数据(其他都保持不变)都会对性能有巨大的影响。我们先不进一步展开,从看其中一些明显的因素看起,如你的目标机器有多少个处理器?
8.2.1有多少个处理器?
处理器的数量和结构是多线程程序的性能的首要和关键的因素。有时你在开发时是知道目标硬件,有目标硬件的规格甚至在一样的硬件上开发。有这种条件你算得上是幸运儿了,但是一般情况我们没这种待遇。也许你是在相似的硬件环境下开发,但是其中的差异会很致命。例如,你在2核或4核的系统下开发,而你的客户可能有多核处理器或者多个单核处理器,乃至多个多核处理器。并发程序的行为和性能在这样不同的环境下会有很大的差异。因此你需要仔细考量会有哪些影响并尽可能地进行测试。
简单近似的话,一个16核处理器等价于4个4核处理器或16个单核处理器,因为它们都可以并发执行16个线程。你的程序至少要有16个线程来利用好这些硬件。如果少于16,就会有处理器性能闲置(除非这个机器还在运行其他程序,我们现在忽略这个情形) ;另一方面,如果你有多于16个线程要运行(没有阻塞或等待) ,会浪费处理器的运算力在切换这些线程上(参见第1章)。这种情况一般被称为过度订阅
(oversubscription )。
为了让程序中的线程数量随着硬件能同时运行的线程数量扩展, C++11的标准库提供了std::thread:: hardware_concurrency() 。我们已经看过使用它的例子。
直接调用sta::thread: hardware_concurrency() 时需要注意,你的程序并没有考虑机器上运行的其他线程,除非你显式地共享这些信息。最坏的情况是,多个线程同时调用std::thread: :hardware_concurrency()会造成严重的过度订阅。而std::async() 会在被调用时,由标准库处理所有这些调用并适当地调度,从而避免这个问题。精心设计的线程池也能避免这个问题。
即使你已经考虑了程序中所有运行的线程,你仍然会被其他同时运行的程序影响。尽管在单用户环境下很少有多个CPU密集型任务同时运行,在某些场景下这种情况会更普遍。为这种应用场景设计的系统一般会提供某种机制来让程序选择合适的线程数,当然这已经不在C++标准内了。一种方法是提供类似std::async()的调用,在选择线程数量时考量所有程序异步执行的任务数量。另一种是限制给定程序使用的核的数量。我希望在这样的平台上可以用sta::thread::hardware_concurrency()来返回这个数量,不过这取决于具体的系统。如果你需要处理这样的情形,可以去查阅文档了解目标系统提供了哪种方案。
这种情况下随之而来的麻烦是:一个问题的理想算法取决于问题大小和处理单元的数量。如果你在有大量处理单元的大规模并行处理机上运行,耗费操作多的算法可能会比操作少的算法快得多,因为每个处理器只需要处理少量的操作。
随着处理器数量增加,另一个影响性能的问题也出现了,多个处理器访问相同的数据。
8.2.2数据竞争和乒乓缓存
如果两个线程同时在不同的处理器上运行,它们同时读取同样的数据通常不会有问题,数据会被复制到各自的缓存,两个处理器都可以继续执行。但是,如果其中一个线程修改了数据,这个修改需要花费时间传播到另一个处理器的缓存。取决于两个线程的操作和这些操作的内存顺序,这样的修改可能导致第二个处理器停下来等待内存硬件传播对数据的修改。从CPU指令来说,这是个相当于数百条指令的显著缓慢的操作,具体的时间主要与硬件的物理结构相关。
考虑下面这段简单代码。
这里的counter 是全局的,每个调用processing_loop()的线程都在修改同一个变量。因此,每次在增加时,处理器必须保证它的缓存中有counter 的最新拷贝、修改,然后发布到其他处理器。即使你用
std::memory_order_relaxed来让编译器不同步其他数据, fetch_add 是一个“读-修改-写"操作,因此需要获取变量最新的值。如果其他处理器上的其他线程在运行同样的代码, counter 的数据就必须在两个处理器之间来回传递来保证每个处理器在增加时都有最新的counter 值。如果do_something() 耗时很少,或者有太多的处理器在运行这段代码,处理器可能会处于互相等待的状态。一个处理器已经准备好更新这个值,但是另一个处理器已经在做了,这就要等待另一个处理器更新,并且这个改动已经传播完成,这种情况被称为高竞争( highcontention )。如果处理器很少需要互相等待,则称为低竞争(lowcontention ).
在这样的循环中, counter 的数据在各处理器的缓存间来回传递。这被称为乒E缓存(cacheping-
pong) ,而且会严重影响程序的性能。如果处理器因为需要等待缓存而被挂起,在这个时间里处理器无法进行任何工作,即使有其他线程等待被执行,这对整个程序来说不是个好消息。
也许你会觉得这不会在自己身上发生,因为不会写这样的循环。但是你能确定吗?如互斥锁,如果你在一个循环中获得一个互斥元,你的代码从数据访问的角度看和上面的代码会非常相像。为了锁住互斥元,另一个线程必须从它所在的处理器获得互斥元并修改。当操作完成后,它又修改互斥元来释放,相关的数据必须传递到下一个需要亘斥元的线程所在的处理器。这个传递所需的时间是第二个线程等待第一个线程释放互斥元的额外时间。
现在是最棘手的部分:如果数据和互斥元被不止一个线程访问,当你添加更多的核和处理器时,你就越可能面临高竞争,处理器需要等待另一个处理器。如果你更快地用多线程来处理同样的数据,这些线程会竞争这些数据,并竞争同一个互斥元。线程数量越多,就越可能同时试图获取互斥元或者访问某个原子变量。
竞争互斥元的影响通常和竞争原子操作不同,因为使用互斥元在操作系统层面将线程串行化,而不是在处理器层面。如果你有足够的线程等待运行,操作系统会在一个线程等待互斥元时调度另一个线程运行。与之相对的是,处理器的挂起会阻止其他线程在这个处理器上运行。但是,这仍然会影响其他竞争这个互斥元的线程的性能,因为它们每次只有一个会被运行。
在第3章,我们看过如何用一个单写入者,多读取者的互斥元保护很少更新的数据结构的例子(参见3.3.2节)。乒乓缓存会使得只用一个互斥元的好处不明显,特别是工作量大的时候。因为所有访问数据的线程(甚至是读取者仍然需要自己去修改互斥元。随着访问数据的处理器数量上升,互斥元本身的竞争也在增加,包含亘斥元的缓存线必须在各个核之间传递,导致获取和释放锁的时间不可接受。你可以用一些方法来改善,主要是通过将亘斥元分布在多个缓存线,但这就意味着你要自己去实现这样的互斥元,而不能使用系统本身提供的。
如果乒乓缓存效应有害,我们如何避免呢?本章稍后会揭示,解决方法依赖于提高并发度,尽可能地避免两个线程竞争从一个内存位置。不过这并不容易做到,即使一个特定内存区域只有一个线程会去访问,你仍然会遇到乒乓缓存,因为存在假共享(falsesharing )的问题。
8.2.3假共享
处理器缓存的最小单位通常不是一个内存地址,而是一小块称为缓存线(cacheline )的内存。这些内存块一般大小为32-64字节,取决于具体的处理器。缓存只能处理缓存线大小的内存块,相邻地址的数据会被载入同一个缓存线。有时这是好事,线程访问的数据在同一个缓存线比分布在多个缓存线更好。但是如果缓存线内有不相关但需要被别的线程访问的数据,会导致严重的性能问题,
假设你有一个int型的数组以及一组线程,每个线程都不停访问和改写数组中彼此正交的部分。因为整型的大小通常小于缓存线,数组中的多个元素会出现在同一个缓存线。这样即使线程只访问自己相关的数据仍然会有乒乓缓存。一个线程在更改其访问的数据时,缓存线的所有权需要转移到其所在的处理器,而另一个线程所需的数据可能也在这个缓存线上,当它访问时缓存线又要再次转移。这个缓存线是两者共享的,然而其中的数据并不共享,因此被称为假共享(falsesharing )。这里的解决方案是构造好数据的结构,使得被同一个线程访问的数据在内存中也是相邻的,这样就更可能出现在同一个缓存线,而不同线程访问的数据则分散在内存中,使之更可能地出现在不同的缓存线。本章稍后会介绍如何根据这个要求设计数据和代码。
如果说多个线程访问同一个缓存线有害,那么单个线程访问的数据的内存布局又有什么影响呢?
8.2.4数据应该多紧密
假共享是由于一个线程访问的数据与另一个线程的靠得太近,而另一个与数据布局直接相关的性能隐患则来自一个线程本身。根源是数据的相邻度。如果线程访问的数据分散在内存中,意味着这些数据分布在各个缓存线上。因此,更多的缓存线需要加载到处理器的缓存中,这会增加内存访问延迟,性能要低于数据分布紧密的情况。
同时,这也会增加线程需要的某个缓存线同时含有其他线程访问的数据的可能性。极端情况下,缓存中无关的数据会多于你关心的数据。这会浪费宝贵的缓存空间,迫使处理器将需要的数据移出缓存来腾出空间,这样更容易缓存未命中而不得不从内存中获取数据。
这对单线程代码的性能很重要,而我们在这里考虑它的原因是任务切换(taskswitching)。如果有多余CPU核数量的线程,每个核都将运行多个线程。这会增加缓存的压力,因为你要保证不同线程访问不同的缓存线以避免假共享。因此,当处理器切换线程时,数据分散在多个缓存线比每个线程的数据都紧靠在同一个缓存线,更可能需要重载这些缓存线。
如果线程数多于核或者处理器处理,操作系统可能也会选择在一个核上给某个线程分配一个时间片,之后又到另一个核上给这个线程分配时间片。这就需要将这个线程所需的缓存线从第一个核转移到第二个核。需要转移的缓存线越多,消耗的时间也越多。尽管操作系统通常会尽可能避免这种情况,这种现象仍然存在并且一旦发生就会严重影响性能。
任务切换导致的问题在大量线程处于就绪而不是等待状态时特别突出。这是我们已经接触过的问题:过度订阅。
8.2.5过度订阅和过多的任务切换
在多线程的系统中,线程数量通常会多于处理器数量,除非你使用的是大规模并行处理机。然而,线程经常花时间等待外部/0操作完成或者因为互斥元而阻塞,又或者在等待一个条件变量等,因此数量多于处理器并不会带来问题。多出的线程可以让程序进行有用的工作而不是使处理器空闲等待。
但是这不总是好事。当你有太多的线程时,你会有多余可用处理器的就绪线程,操作系统将会开始频繁的任务切换以保证所有线程享有适当的时间片。我们在第1章看到过,这会增加任务切换的额外开销,并且由于数据没有相邻导致的一系列缓存问题。过度订阅会在以下情况产生:你有任务无限制地生成新的线程,如第4章递归调用的快速排序;或者你根据任务类型分配的线程数量大于处理器的数量,而任务更依赖于CPU而不是I/0。
如果你只是因为划分数据产生了太多的线程,你可以简单的限制工作线程的数量,就像我们在8.1.2节见过的一样。如果过度订阅来自于对任务类型的划分,你就没有什么改进的余地了,这时选择合适的划分也许超出了你对目标平台的知识储备,除非性能无法接受而且能证明对划分的改变确实可以提高性能才值得去做。其他因素也能影响多线程代码的性能。乒乓缓存的代价在两个单核处理器和一个双核处理器上会有很大的差异,哪怕两个平台的CPU类型和时钟频率都一样。以上都是重要的因素,对性能有显著的影响。现在,让我们了解一下这会如何影响我们代码和数据结构的设计。
本文节选自《C++并发编程实战》
本书介绍如何编写或者设计、调试、维护、研究多线程C++程序,并提供了技术模式和工具,可以用来分析并发编程以及降低封装并发交互的复杂性。书中还提供了大量的实例、练习、可重用的代码以及用于网络通信程序的简化库。
猜你喜欢
- 2024-11-11 Linux下的C++ socket编程实例 linux c++ tcp
- 2024-11-11 C++11原子变量:线程安全、无锁操作的实例解析
- 2024-11-11 C++11的thread_local原理和应用范例
- 2024-11-11 知识重构-c++ : Lambda 知识重构拼音
- 2024-11-11 c++ 疑难杂症(4) std:vector c++ vector subscript out of range
- 2024-11-11 深入探索C++异步编程的奥秘 c++11异步编程
- 2024-11-11 C++ 开发中使用协程需要注意的问题
- 2024-11-11 golang极速嵌入式Linux应用开发(四)-协程与并发
- 2024-11-11 在计算机编程中,线程是指一个程序内部的执行流程
- 2024-11-11 C++ std:decay、std:bind、std:packaged_task 在模版编程的实践
- 最近发表
- 标签列表
-
- gitpush (61)
- pythonif (68)
- location.href (57)
- tail-f (57)
- pythonifelse (59)
- deletesql (62)
- c++模板 (62)
- css3动画 (57)
- c#event (59)
- linuxgzip (68)
- 字符串连接 (73)
- nginx配置文件详解 (61)
- html标签 (69)
- c++初始化列表 (64)
- exec命令 (59)
- canvasfilltext (58)
- mysqlinnodbmyisam区别 (63)
- arraylistadd (66)
- node教程 (59)
- console.table (62)
- c++time_t (58)
- phpcookie (58)
- mysqldatesub函数 (63)
- window10java环境变量设置 (66)
- c++虚函数和纯虚函数的区别 (66)