为了账号安全,请及时绑定邮箱和手机立即绑定

C++并发编程实战:如何为多线程性能设计数据结构?

标签:
ZBrush

在8.1节中我们看到了在线程间划分工作的一些方法,在8.2节中我们看到了影响代码性能的一些因素。当设计多线程性能的数据结构的时候如何使用这些信息呢?这是在第6章和第7章中处理的很困难的问题,是关于设计可以安全并行读取的数据结构。正如你在8.2节中看到的一样,即使没有别的线程共享此数据,单个线程使用的数据布局也会对它产生影响。

当为多线程性能设计你的数据结构时需要考虑的关键问题是竞争、假共享以及数据接近。这三个方面都会对性能产生很大影响,并且通常你可以通过改变数据布局或者改变分配给某线程的数据元素来提高性能。首先,我们来看一个简单的例子,在线程间划分数组元素。

8.3.1 为复杂操作划分数组元素

假设你正在做一些复杂的数学计算,你需要将两个大矩阵想乘。为了实现矩阵相乘,你将第一个矩阵的第一行每个元素与第二个矩阵的第一列相对应的每个元素相乘,并将结果相加得到结果矩阵左上角第一个元素。然后你继续将第二行与第一列相乘得到结果矩阵第一列的第二个元素,以此类推。正如图8.3所示,突出显示的部分表明了第一个矩阵的第二行与第二个矩阵的第三列配对,得到结果矩阵的第三列第二行的值。

为了值得使用多线程来优化该乘法运算,现在我们假设这些都有几千行和几千列的大矩阵。通常,非稀疏矩阵在内存中是用一个大数组表示的,第一行的所有元素后面是第二行的所有元素,以此类推。为了实现矩阵相乘,现在就有三个大数组了。为了获得更优的性能,你就需要注意数据存取部分,特别是第三个数组。

有很多在线程间划分工作的方法。假设你有比处理器更多的行例,那么你就可以让每个线程计算结果矩阵中某些列的值,或者让每个线程计算结果矩阵中某些行的值,或者甚至让每个线程计算结果矩阵中规则矩形子集的值。

回顾8.2.3节和8.2.4节,你就会发现读取数组中的相邻元素比到处读取数组中的值要好,因为这样减少了缓存使用以及假共享。如果你使每个线程处理一些列,那么就需要读取第一个矩阵中的所有元素以及第二个矩阵中相对应的列中元素,但是你只会得到列元素的值。假设矩阵是用行顺序存储的,这就意味着你从第一行中读取N个元素,从第二行中读取N个元素,以此类推(N的值是你处理的列的数目)。别的线程会读取每一行中别的元素,这就很清楚你应该读取相邻的的列,因此每行的N个元素就是相邻的,并且最小化了假共享。当然,如果这N个元素使用的空间与缓存线的数量相等的话,就不会有假共享,因为每个线程都会工作在独立的缓存线上。

另一方面,如果每个线程处理一些行元素,那么就需要读取第二个矩阵中的所有元素,以及第一个矩阵中相关的行元素,但是它只会得到行元素。因为矩阵是用行顺序存储的,因此你现在读取从N行开始的所有元素。如果你选择相邻的行,那么就意味着此线程是现在唯一对这N行写入的线程;它拥有内存中连续的块并且不会被别的线程访问。这就比让每个线程处理一些列元素更好,因为唯一可能产生假共享的地方就是一块的最后一些元素与下一个块的开始一些元素。但是值得花时间确认目标结构。

第三种选择一划分为矩形块如何呢?这可以被看做是先划分为列,然后划分为行。它与根据列元素划分一样存在假共享问题。如果你可以选择块的列数目来避免这种问题,那么从读这方面来说,划分为矩形块有这样的优点:你不需要读取任何一个完整的源矩阵。你只需要读取相关的目标矩阵的行与列的值。从具体方面来看,考虑两个1000行和1000列的矩阵相乘。就有一百万个元素。如果你有100个处理器,那么每个线程可以处理10行元素。尽管如此,为了计算这10000个元素,需要读取第二个矩阵的所有元素(一百万个元素)加上第一个矩阵相关行的10000个元素,总计1010000个元素;另一方面,如果每个线程处理100行100列的矩阵块(总计10000个元素) ,那么它们需要读取第一个矩阵的100行元素( 100 x 1000=100000元素)和第二个矩阵的100列元素(另一个100000个元素)。这就只有200000元素,将读取的元素数量降低到五分之一。如果你读取更少的元素,那么发生缓存未命中和更好性能的潜力的机会就更少了。

因此将结果矩阵划分为小的方块或者类似方块的矩阵比每个线程完全处理好几行更好。当然,你可以调整运行时每个块的大小,取决于矩阵的大小以及处理器的数量。如果性能很重要,基于目标结构分析各种选择是很重要的。

你也有可能不进行矩阵乘法,那么它是否适用呢?当你在线程间划分大块数据的时候,同样的原则也适用于这种情况。仔细观察数据读取方式,并且识别影响性能的潜在原因。在你遇到的问题也可能有相似的环境,就是只要改变工作划分方式可以提高性能而不需要改变基本算法。

好了,我们已经看到数组读取方式是如何影响性能的。其他数据结构类型呢?

8.3.2其他数据结构中的数据访问方式

从根本上说,当试图优化别的数据结构的数据访问模式时也是适用的。

1、在线程间改变数据分配,使得相邻的数据被同一个线程适用。

2、最小化任何给定线程需要的数据。

3、确保独立的线程访问的数据相隔足够远来避免假共享。

当然,运用到别的数据结构上是不容易的。例如,二叉树本来就很难用任何方式来再分,有用还是没用,取决于树是如何平衡的以及你需要将它划分为多少个部分。同样,树的本质意味着结点是动态分配的,并且最后在堆上不同地方。

现在,使数据最后在堆上不同地方本身不是一个特别的问题,但是这意味着处理器需要在缓存中保持更多东西。实际上这可以很有利。如果多个线程需要遍历树,那么它们都需要读取树的结点,但是如果树的结点至包含指向该结点持有数据的指针,那么当需要的时候,处理器就必须从内存中载入数据。如果线程正在修改需要的数据,这就可以避免结点数据与提供树结构的数据间的假共享带来的性能损失。

使用互斥元保护数据的时候也有同样的问题。假设你有一个简单的类,它包含一些数据项和一个互斥元来保护多线程读取。如果互斥元和数据项在内存中离得很近,对于使用此互斥元的线程来说就很好;它需要的数据已经在处理器缓存中了,因为为了修改互斥元已经将它载入了。但是它也有一个缺点:当第一个线程持有豆斥元的时候,如果别的线程试图锁住互斥元,它们就需要读取内存。互斥元的锁通常作为一个在互斥元内的存储单元上试图获取互斥元的读一修改一写原子操作来实现的,如果互斥元已经被锁的话,就接着调用操作系统内核。这个读一修改一写操作可能导致拥有互斥元的线程持有的缓存中的数据变得无效。只要使用互斥元,这就不是问题。尽管如此,如果互斥元和线程使用的数据共享同一个缓冲线,那么拥有此互斥元的线程的性能就会因为另一个线程试图锁住该互斥元而受到影响。

测试这种假共享是否是一个问题的方法就是在数据元素间增加可以被不同的线程并发读取的大块填充数据例如,你可以使用:

C++并发编程实战:如何为多线程性能设计数据结构?

来测试互斥元竞争问题或者使用:

C++并发编程实战:如何为多线程性能设计数据结构?

来测试数组数据是否假共享。如果这样做提高了性能,就可以得知假共享确实是一个问题,并且你可以保留填充数据或者通过重新安排数据读取的方式来消除假共享。

当然,当设计并发性的时候,不仅需要考虑数据读取模式,因此让我们来看看别的需要考虑的方面。

8.4 为并发设计时的额外考虑

本章我们看了一些在线程间划分工作的方法,影响性能的因素,以及这些因素是如何影响你选择哪种数据读取模式和数据结构的。但是,自考证书设计并发代码需要考虑更多。你需要考虑的事情例如异常安全以及可扩展性。如果当系统中处理核心增加时性能(无论是从减少执行时间还是从增加吞吐量方面来说)也增加的话,那么代码就是可扩展的。从理论上说,性能增加是线性的。因此一个有100个处理器的系统的性能比只有一个处理器的系统好100倍。

即使代码不是可扩展的,它也可以工作。例如,单线程应用不是可扩展的,异常安全是与正确性有关的。如果你的代码不是异常安全的,就可能会以破碎的不变量或者竞争条件结束,或者你的应用可能因为一个操作抛出异常而突然终止。考虑到这些,我们将首先考虑异常安全。

8.4.1 并行算法中的异常安全

异常安全是好的C++代码的一个基本方面,使用并发性的代码也不例外。实际上,并行算法通常比普通线性算法需要你考虑更多关于异常方面的问题。如果线性算法中的操作抛出异常,该算法只要考虑确保它能够处理好以避免资源泄漏和破碎的不变量。它可以允许扩大异常给调用者来处理。相比之下,在并行算法中,很多操作在不同的线程上运行。在这种情况下,就不允许扩大异常了,因为它在错误的调用栈中。如果一个函数大量产生以异常结束的新线程,那么该应用就会被终止。

作为一个具体的例子,我们来回顾清单2.8中的 parallel_accumulate函数,清单8.2中会做一些修改

清单8.2 sta::accumulate的并行版本(来自清单2.8)

C++并发编程实战:如何为多线程性能设计数据结构?

现在我们检查并且确定抛出异常的位置:总的说来,任何调用函数的地方或者在用户定义的类型上执行操作的地方都可能抛出异常。

首先,你调用distance 2,它在用户定义的迭代器类型上执行操作。因为你还没有进行任何工作,并且这是在调用线程上,所以这是没问题的。下一步,你分配了results选代器3和threads迭代器4。同样,这是在调用线程上,并且你没有做任何工作或者生产任何线程,因此这是没问题的。当然,如果threads构造函数抛出异常,那么就必须清楚为results分配的内存,析构函数将为你完成它。

跳过block_start 的初始化5因为这是安全的,就到了产生线程的循环中的操作6、7、8。一旦在7中创造了第一个线程,如果抛出异常的话就会很麻烦,你的新sta::thread 对象的析构函数会调用

std::terminate 然后中程序,

调用accumulate_block 9也可能会抛出异常,你的线程对象将被销毁并且调用std:terminate ;另一方面,最后调用std::accumulate 10的时候也可能抛出异常并且不导致任何困难,因为所有线程将在此处汇合。

这不是对于主线程来说的,但是也可能抛出异常,在新线程上调用 accumulate_block 可能抛出异常1。这里没有任何catch块,因此该异常将被稍后处理并且导致库调用sta::terminate()来中止程序。

即使不是显而易见的,这段代码也不是异常安全的。

1·增加异常安全性

好了,我们识别出了所有可能抛出异常的地方以及异常所造成的不好影响。那么如何处理它呢?我们先来解决在新线程上抛出异常的问题。

在第4章中介绍了完成此工作的工具。如果你仔细考虑在新线程中想获得什么,那么很明显当允许代码抛出异常的时候,你试图计算结果来返回。std: :packaged_task 和std:future 的组合设计是恰好的。如果你重新设计代码来使用 std::packaged_task ,就以清单8.3中的代码结束。

清单8.3使用std::packaged_task的std::accumulate的并行版本

C++并发编程实战:如何为多线程性能设计数据结构?

改变就是,函数调用accumulate_block操作直接返回结果,而不是返回存储地址的引用1。你使用std::packaged_task 和std::future来保证异常安全,因此你也可以使用它来转移结果。这就需要你调用std::accumulate 2明确使用默认构造函数T而不是重新使用提供的result值,不过这只是一个小小的改变。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消