《C++ Concurrency in Action》笔记29 设计并行代码(1)

来源:互联网 发布:金方 绿方 知乎 编辑:程序博客网 时间:2024/06/05 17:09

8 设计并行代码

在设计并行代码时要比设计顺序代码时考更过,不仅包括封装、耦合、聚合,而且需要考虑哪些数据需要共享,怎样同步访问数据,哪些线程需要等待其他线程完成工作等等。

在这一章,我们将聚焦于这些问题,着眼于高层级(也是基本的)考虑多线程用法,哪些代码执行在哪些线程上,它们对代码清晰度的影响,以及探寻作为共享数据的数据结构的细节以提升性能。

首先来看看如何在线程间划分工作。

8.1 如何在线程间划分工作

想象一下你此刻增打算盖一座房子。你需要打地基,砌墙,架设管道,添加线路,等等。理论上你可以通过足够的训练自己完成这所有的工作,但是那可能要花上很久,因为你必须不断的切换工作内容。

或者,你可以雇一些人帮你完成。那么你现在需要考虑需要多少人,并且这些人都需要什么技能。你可以聘请一些具有一般技能的人,并让每个人都融入一切。你仍然需要给他们分配任务,但是现在所有事情都可以很快完成,因为有了许多个你来一起做事。

或者,你可以雇佣一批专家,一个瓦匠,一个木匠,一个电工,一个管道工等等。这些人只做自己擅长的事情,所以如果某一时候不需要做鱼管道相关的事情,那么管道工就可以坐下来喝点茶或者咖啡。事情还是比以前做的更快,因为还有多个你在工作,管道工可以在电工处理厨房的时候去处理卫生间的事情,只是在没有自己的事情时需要等待。虽然有闲置时间,你仍然会发现,请专家做事比请杂务工要快得多。专家不需要切换手中的工具,而且每个专业的专家都要比普通工人要快得多。这是否依赖于特殊情况还需要你去尝试和观察。

尽管你雇佣了专家,但是你仍然需要确定需要不同种类专家的个数。比如,也许瓦工比电工需要的更多一些。另外,如果需要建造更多的房子,你的团队的组成和整体效率可能会改变。对于管道工来说虽然一个房子的工作量不大,但是如果你想盖很多房子的话,那么仍然会有许多工作会保持他处于忙碌状态。而且,如果当专家没事做的时候你不必付薪水,那么你就可以承担得起一整个专家团队,尽管每个时刻只有相同数量的人在工作。

好了,盖房子的事暂告一段落,那么这些道理在多线程中都意味着什么呢?其实,在多线程中存在同样的问题。你需要决定使用多少线程,以及每个线程都要做什么。你需要决定使用“通用”类型的线程在每个时刻都工作,还是使用“专家”类型的线程每类线程只做一件事,亦或是二者一定程度的组合。不论出于什么多线程使用的原因你都需要对此作出决定,而且它将影响代码的并行效率和代码的清晰度。

这一章主要简述如何在多线程间划分任务。

8.2 在开始处理数据前,先在线程之间划分数据

最简单的并行算法形如std::for_each,为数据集合中的每一项执行一个操作。为了使算法并行化,你可以将每一个数据项分配给一个处理线程。如何最好的划分数据以优化性能依赖于数据结构的很多细节。

最简单的划分模式就是,将N个数据分给第一个线程,接下来的N个数据分给另一个线程,以此类推,就像下面的图8.1所示,但也可以使用其他的划分模式。无论怎么划分,每个线程只管在不与其他线程通讯的情况下处理划分给自己的数据项,直到完成处理。


这种结构类似一种使用了Message Passing Interface(MPI)或者Open MP-frameworks的程序:一个任务被划分成多个并行任务,工作线程无依赖的执行这个任务,得到的这些结果在最后的收拢步骤中合并。它适合被用于2.4章节中的accumulate示例;这种情况下,并行任务和最后的收拢都是累加操作。对于一个简单的for_each操作,最后的步骤是空操作,因为没有需要合并的结果。

作为收拢操作的最后一步是重要的;类似于程序2.8的单纯的实现将收拢操作作为最后的一个串行步骤。但是,这个步骤也总是可以被并行化的;实际上累加本身也是一种收拢操作,所以,例如2.8程序中,当线程数大于每个线程中的最小数据项数时,可以改为递归调用(不清楚什么意思!)。或者,工作线程在完成任务后可以做一些收拢步骤,而不是每次都创建新线程。

尽管这种机制很管用,但也不是对所有情况都适用。有时候事先无法对数据进行划分,因为数据只有到处理时才可以明确划分。这一点在像快速排序(Quicksort)这样的递归算法中尤为明显,这就需要不同的方法了。

8.1.2 递归划分数据

快速排序有两个基本步骤:选择一个轴心数据项,将数据分为两步,然后递归的对轴心两侧的数据项进行排序。你不能通过对这些数据事先简单的划分来使其并行化,因为直到处理数据时才会知道数据属于那一侧。如果你要对这种算法并行化,那么你需要利用递归的特性。在每一层的收拢操作上都需要调用大量的quik_sort函数,因为你需要对轴心两侧的数据排序。由于递归操作是对独立的数据进行排序,因此它们之间是无依赖的,这是一种使用并行机制的极佳的情况。图8.2显示了这种数据的递归划分:


在第4章,你看到了这样的实现。它并不是直接在不同层级直接进行递归调用,而是在每一阶段使用了 std::async()来产生异步任务。通过使用 std::async(),你让C++标准库来决定什么时候在新线程中执行任务,什么时候同步执行任务

这是很重要的:如果你正在对一个很大的数据集合进行排序,让每次递归都产生新线程将会生成大量的线程。当我们来分析它的性能时你将看到,如果有太多的线程,你可能实际上降低了应用程序的执行效率。如果数据量过大,可能将线程资源耗尽。这种在像这样的递归算法中对整个任务的划分方法是很好的选择之一;你只需要保持线程数量处于最大状态。简单情况下,std::async()可以做到这点,但这不是唯一的选择。

另一种做法是使用std::thread::hardware_concurrency()函数去选择线程数量,就像程序2.8中那样的并行版本。并不开启一个新线程来执行递归调用,而是把将要进行排序的数据放在一个线程安全的stack中,就像第6、7章中那样的stack。如果线程没事可做,要么是因为它完成了所有的操作,要么就是在等待从stack中取出数据。

下面程序显示了这种方式的一个样本:

Listing 8.1 Parallel Quicksort using a stack of pending chunks to sort

template<typename T>struct sorter//(1){struct chunk_to_sort{std::list<T> data;std::promise<std::list<T> > promise;};thread_safe_stack<chunk_to_sort> chunks;//(2)std::vector<std::thread> threads;//(3)unsigned const max_thread_count;std::atomic<bool> end_of_data;sorter() :max_thread_count(std::thread::hardware_concurrency() - 1),end_of_data(false){}~sorter()//(4){end_of_data = true;//(5)for (unsigned i = 0; i<threads.size(); ++i){threads[i].join();//(6)}}void try_sort_chunk(){boost::shared_ptr<chunk_to_sort > chunk = chunks.pop();//(7)if (chunk){sort_chunk(chunk);//(8)}}std::list<T> do_sort(std::list<T>& chunk_data)//(9){if (chunk_data.empty()){return chunk_data;}std::list<T> result;result.splice(result.begin(), chunk_data, chunk_data.begin());T const& partition_val = *result.begin();typename std::list<T>::iterator divide_point = std::partition(chunk_data.begin(), chunk_data.end(),//(10)[&](T const& val) {return val<partition_val; });chunk_to_sort new_lower_chunk;new_lower_chunk.data.splice(new_lower_chunk.data.end(),chunk_data, chunk_data.begin(),divide_point);std::future<std::list<T> > new_lower = new_lower_chunk.promise.get_future();chunks.push(std::move(new_lower_chunk));//(11)if (threads.size()<max_thread_count)//(12){threads.push_back(std::thread(&sorter<T>::sort_thread, this));}std::list<T> new_higher(do_sort(chunk_data));result.splice(result.end(), new_higher);while (new_lower.wait_for(std::chrono::seconds(0)) != std::future_status::ready)//(13){try_sort_chunk();//(14)}result.splice(result.begin(), new_lower.get());return result;}void sort_chunk(boost::shared_ptr<chunk_to_sort > const& chunk){chunk->promise.set_value(do_sort(chunk->data));//(15)}void sort_thread(){while (!end_of_data)//(16){try_sort_chunk();//(17)std::this_thread::yield();//(18)}}};template<typename T>std::list<T> parallel_quick_sort(std::list<T> input)//(19){if (input.empty()){return input;}sorter<T> s;return s.do_sort(input);//(20)}

在这里,函数parallel_quick_sort()将绝大部分的排序功能委托给类模板sorter<>(1),它提供了一种简单的方式来对未排序的数据分组(2),以及一个线程集合(3)。主要工作由 do_sort()完成,它对数据做通常的分割(10)。这回,不是为每个数据块创建一个线程,它把数据块放到stck中(11),如果有剩余的处理器就新建一个线程(12)。因为较小一组的数据块可能比别的线程排序,所以你需要等待它完成(13)。为了让处理能够持续进行(这种情况下,当前线程是唯一的线程,或者所有的线程都处于忙碌状态),那么当你处于等待状态时,就尝试从对stack中的数据块进行排序(14)。try_sort_chunk只是从stack中pop出一个数据块(7)然后对其排序(8),将结果存放在promise中,以待将这些数据放入stack中的线程来取走(15)。

当 end_of_data未false时,新近生成的线程一直在循环中对stack中的数据进行排序(17)。在检查 end_of_data的空隙阶段,它放弃当前cpu时间片以交给其他线程,让它们有机会将新的数据块放入stack中(18)。这个类依靠析构函数来清理所有开启的线程(4)。当所有的数据都被排完序,do_sort()就会返回(尽管工作线程还在运行),所以主线程会从parallel_quick_sort()函数返回(20)并销毁sorter对象。这回导致 end_of_data被设置为true,然后等待所有线程完成(6)。标志位被设置为true后,会终止线程函数(16)。

使用这个方案,你再也不必担心使用spawn_task产生新线程会导致无限制的创建线程,也不用依靠C++标准库来选择线程的数量(就像使用std::async()那样)。相反,你将线程数量限制在 std::thread::hardware_concurrency()以下,以阻止过多的事务切换。但是这样做也有另外一个潜在的问题:管理这些线程以及在线程间的通讯会增加许多复杂的代码。而且,尽管这些线程处理的数据块都是相互独立的,但是它们都向同一个stack执行pop和push。这种频繁的竞争或导致性能下降,尽管使用了无锁stack(因此是无阻塞的),你马上就能看到原因。

这种方法是线程池的一个特殊版本——多个线程分别从list中取出待操作的数据,完成操作,然后再从list中取出数据。一些关于线程池的问题(包括任务列表竞争)以及相应的解决方案将在第9章中阐述。将应用程序投放到多处理器中出现的问题将在8.2.1中讨论。

不管是在处理之前划分数据还是递归划分都是出于这种假设,数据是预先定下来的,你只需要寻找划分的办法。情况不总是这样,如果数据是动态创建的或者是外部输入的,那么这些方案就无法工作了。在这种情况下,也许针对任务的类型来划分比针对数据来划分更有意义。

8.1.3 通过任务类型来划分工作

通过为不同的线程分配数据块来为线程间划分工作(不论是事先划分还是递归划分),仍然基于一种假设:本质上每个线程针对数据所做的是相同的操作。另一种方案是令线程成为“专家”,不同的线程从事不同的工作,就像盖房子中的管道工和电工那样。这些线程可能针对相同的或不同的数据操作,但是做的都是不同的工作。

这种工作划分,取决于在并行操作中的不同关注点;每个线程都有一个不同的任务,这些任务的展开不需要彼此依赖。偶尔其他线程可能会给当前一些数据,或者一些待处理的事件,但是通常每个线程都只关注一件事。这本身是个好的设计,每一块代码都有自己独立的职责。

根据不同的关注点划分任务

一个单线程应用不得不处理单基于职责而带来的冲突,冲突来自于在单个执行周期中要处理许多不同的任务,或者尽管其他任务还在执行中应用也需要及时处理输入事件(例如键盘消息或者网络传入的数据)。在单线程世界里,你最终需要手动编写这样的代码:先让A任务执行一点,再让B任务执行一点,检查键盘输入,检查网络输入的数据包,然后再循环回去继续处理A任务的其他部分,等等。这就意味着,对于A任务,这样的代码最终会由于需要保存状态以及周期的向主循环返回控制而变得复杂。如果你在循环中加入太多的任务,任务的执行速度可能会变得非常慢,而且用户会发现程序对按键的响应时间太长。我确定你曾经在某些应用程序中见到过这种情况:你让它做一件事,那么它的交互界面会被冻结,直到事情做完。

这就是使用多线程的原因。如果你让每个任务都运行在单独的线程中,那么操作系统会帮你处理它们。这种情况下,对于任务A来说,你将把注意力集中在任务自身的实现上,而不用担心保存状态或者向主循环返回控制或者考虑做这些事情消耗了多少时间。操作系统会保存状态,并在适当的时机在任务B和任务C之间切换,如果系统具有多核或者多个处理器,那么这些任务将会真正的并行运行。现在键盘消息以及网络数据包会被及时的处理,所有事情都很成功:用户得到及时的响应,你作为开发者也获得了更简单的代码,因为每个线程只需要关心它负责的事情,而不用将任务和控制流以及用户交互混合在一起了。

这听起来很美好,但事实是这样的吗?对于每一个具体的事情来说,要看他的细节。如果每件事都是各自独立的,而且线程不需要相互通讯,那就真的很简单了。不幸的是,那样简单的事情并不总会出现。这些运行于后台的任务经常要处理一些用户的请求,当任务完成时需要通过某种方式更新用户交互以通知用户何时任务完成。或者,用户想取消任务,那就用户会通过某种方式用户交互接口发送一个消息以告知后台程序。这些情况都要求小心思考,适当同步,但还要保持每个线程的关注点不同。用户接口线程依然只是处理用户交互,但是当别的线程要求时它可能需要更新自己。同样,后台运行的任务线程也只需要关心与任务需求相关的操作,有时会收到其他线程要求停止任务的消息。无论哪种情况,线程都需要关心来其他线程的请求,这些请求也只是与线程本身的职责直接相关的。

在线程间区分关注点时有两件危险的事情。第一件事就是使用错误的关注点来划分线程。这样划分后的症状就是线程之间存在太多的共享数据,或者线程最终需要彼此等待;这些现象的原因都是因为线程之间的通讯过多。一旦产生这种后果,那么就应该好好分析一下线程之间通讯的原因是什么。如果所有的通讯都与相同的问题相关,那也许应该从所有线程中提取出一个专门负责这项事物的线程。或者如果两个线程存在很多与其他线程无关的通讯,那也许应该将两个线程合并为一个线程。

当使用任务类型划分工作时,没必要将线程限制成完全孤立状态。如果多组输入数据请求需要使用相同操作序列去处理,那么完全可以让每个线程来处理操作序列中的一步。

在线程之间划分任务序列

如果你的任务都是由处理相互独立的数据项,而且操作顺序相同,那么你可以使用管道(pipeline)来拓展系统的并行性。这好比一个物理管道:数据从一端流入经过一系列操作(管道),然后从另一端流出。

使用者方式划分工作需要在管道中为每一步段创建一个独立的线程,每个线程负责序列操作中的一个操作。当操作完成后就把数据放入队列中,以便下一个线程取出继续操作。这样就允许当负责第二个操作的线程还在处理第一个数据项时,负责第一个操作的线程已经开始处理第二个数据项了。

这就是8.1.1节中描述的另一种划分方式,它适合于输入数据在被处理时,程序还不能知道数据的全部。例如,数据可能来自网络,或者负责第一个操作的线程需要扫描文件以获取待处理的数据。

在每个操作很耗时的情况下,管道系统依然能能够表现良好,通过在线程中划分任务而不是划分数据,从侧面改变了程序的效率。

假设你有20个数据项需要处理,4核处理器,每个数据项需要4步操作,每步需要3秒。如果你在线程间划分数据,那么每个线程需要处理5次。假设此时系统中没有其他事情会影响到当前处理,那么12秒后处理完4个数据项,24秒后处理完8个数据项,等等。所有数据都被处理完需要1分钟。

如果使用管道系统,事情就不一样了。可以将4个步骤可以分别分给4个内核。现在第一个数据项必须被每个内核处理,所以它仍然需要12秒来完成。进一步来看,12秒以后只有一个数据被处理完,没比使用数据划分策略好哪去。但是,一旦管道系统准备待续,事情就不一样了;当第一个内核处理完第一个数据项它就会转去处理第二个数据,当最后一个内核处理完第一个数据项后就能够继续处理第二个数据项。现在是每3秒处理一个数据项,而不是原来的每12秒处理出4个数据项。

处理整个数据的时间会更长一些,因为最后一个内核必须等待9秒之后才能开始处理第一个数据项。但是更平滑、定期的处理策略在某些情况下更有利。考虑这种情况,比如一个播放刚清晰数字视频的系统。为了能让视频达到可观看的地步,需要每秒至少25帧的刷新频率。而且,观众需要均匀持续的播放效果;如果应用程序中断1秒再播放1秒,即使能够达到每秒100帧的刷新频率也是没用的。另一方面,观众能够接受在开始播放之前有几秒的等待时间。这种情况下使用管道并行系统来处理播放,会使播放效果更流畅。

看完了关于在线程中划分工作的技术,让我们再来看看影响多线程系统性能的因素,以及它们如何影响你选择不同的策略。

8.2 影响并行代码效率的因素

如果你在多处理器系统上使用并行来改善代码的性能,你需要知道影响性能的因素。即使你使用多线程来划分关注点,但是你也要确保那不会降低性能。如果你的应用程序在一个光鲜亮丽的16核系统上比单核处理器还慢的话,我猜用户一定不会感谢你。

你马上就会看到,许多影响多线程的因素——即使是很简单的事情,例如将改变由哪个线程来处理哪个数据项(保证其他的事情不变),可能会对性能产生戏剧性的影响。先来看看最明显的一个因素:你的操作系统究竟有多少处理器?

8.2.1 多少个处理器?

处理器的数量(以及结构)是影响多线程性能的第一要素,也是起到决定作用的一个因素。某些情况下,你能够准确的了解目标系统并且针对它进行设计,并能够在目标系统或者一个复制品上做精确的测试。如果是那样的话,你无疑是幸运的;通常情况你没那么走运。你可能在一个相似的系统上开发,但是系统的不同之处可能起到关键作用。比如你在一个双核或者四核系统上开发,但是用户的系统可能是多核单处理器,或者单核多处理器,或者多核多处理器。在这些不同的系统下,并行程序的行为和性能可能大不相同。所以,你需要思考以及测试(如果可能的话)。

首先估算,一个16核单处理器,等同于一个4核4处理器,或者1核16处理器:每种情况下,系统都能并行运行16个线程。如果你想利用这个优势,那么你的程序至少要拥有16个线程,当线程数量少于16个时,会有处理器处于空闲状态(除非系统同时需要运行其他应用,不过我们暂时忽略这种可能性)。令一方面,如果你有多于16个线程处于待运行状态(非阻塞状态,等待某些事情),你的应用程序将会因线程切换而消耗处理器的时间,就像第一章总说的那样。如果发生这种情况,我们叫它超额申请(over subscription)

为了允许应用程序根据硬件可以同时运行的线程数量来调整线程数,C++标准委员会提供了std::thread::hardware_concurrency()函数。

直接使用std::thread::hardware_concurrency()函数时要小心,因为你的代码不会考虑系统上运行的任何其他线程,除非你明确的共享该信息(your code doesn’t take into account any of the other threads that are running on the system unless you explicitly share that information.)。在最坏的情况下,如果多个线程在同一时刻调用函数 std::thread::hardware_concurrency(),将会产生严重的超额申请。std::async()函数可以避免这个问题,因为系统知道所有的调用,并且适当的安排时序。小心使用线程池也能够避免这个问题。

然而,尽管你能够考虑所有运行在应用上的线程,你仍然受到此刻运行的其他应用程序的影响。虽然在单用户系统上同时使用多个CPU密集型的应用是很罕见的,但是在某些领域中还是很常见的。对于这种情况,系统能够提供机制来让应用程序选择合适的线程数,但是这种机制已经超出了C++标准的范围。一种选择是,当选择线程数时,使用类似std::async()的设施来考虑运行在所有应用上的异步任务的数量。另一种选择就是限制给定应用的处理器内核访问数量。我希望这个限制能够反映在函数std::thread::hardware_concurrency()的返回值中,尽管他没有保证。如果你想处理这种情况,请参考操作系统手册。

另一个影响因素是,理想的算法需要依赖于问题的规模与处理器的单元数量之间的比值。处理单元越多,操作完成得越快。

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

8.2.2 数据争夺与ping-pong缓存

如果运行在不同处理器上的两个线程读取相同的数据,那通常不会出问题;数据会被拷贝至各自的缓存中,两个处理器都可以处理。但是,如果其中一个线程修改数据,那么数据改变必须传递到另一个处理器的缓存上,这需要时间。这依赖于两个处理器上的操作性质,还有用于指定操作的内存序列,这样一个修改操作可能会导致另一个处理器暂停以等待内存硬件将数据改变通传递到它的缓存中。就CPU指令而言,这是一种非常慢的操作,相当于执行了数千个独立的指令,精确的时间主要依赖于硬件物理结构。

考虑下面一段代码:

std::atomic<unsigned long> counter(0);void processing_loop(){while (counter.fetch_add(1, std::memory_order_relaxed)<100000000){do_something();}}

counter是一个全局变量,所以任何调用processing_loop()函数的线程都在修改同一个变量。因此,对于每个增加的处理器来说,都必须确保它的缓存中保有一份counter变量的最新拷贝,修改数值,然后再通知其他处理器。虽然使用了std::memory_order_relaxed,以至于编译器不必同步其他数据,但是fetch_add是一个读-改-写操作,因此需要检测变量的最新值。如果另一个处理器中的线程也在做同样的操作,那么counter的数值将在两个处理器以及两个缓存中来回传递,当执行递增操作时,两个处理器都拥有了counter的最新值。如果do_something()的执行时间足够短,或者有太多的处理器运行这段代码,那么实际上这些处理器可能发现它们都在互相等待;一个处理器正准备要更新数值,但是另一个处理器正在更新,所以它只能等另一个处理器完成更新而且数据改变被传播过来。这种情况叫做高争夺(high contention)。如果处理器之间极少互相等待,则叫做低争夺(low contention)。

像这样的一个循环,counter会在缓存之间传递很多次。这叫做缓存乒乓(ping-pong),它会极大的影响效率。如果处理器因为它而等待缓存转换,那这个处理器此刻什么都做不了,这对整个应用来说是一个坏消息。

你可能认为这样的事情不会放生在你身上,毕竟你没有像上面那样的循环。你确定吗?mutex锁呢?如果你在循环中请求一个mutex,那么从数据访问的角度来看你的代码就和上面的代码类似。为了锁定mutex,另一个线程必须将组成互斥体的数据传输到其处理器并对其进行修改。当它完成后,再次修改mutex以解锁,然后mutex的数据还要传递给下一个请求mutex的线程。这个传递时间就是第二个线程等待第一个线程释放mutex的附加时间:

std::mutex m;my_data data;void processing_loop_with_mutex(){while (true){std::lock_guard<std::mutex> lk(m);if (done_processing(data)) break;}}

这就是最坏之处:如果数据和mutex被多个线程访问,系统的处理器和内核越多,将存在更多的争夺,一个处理器不得不等待另一个处理器。如果你想使用多线程来快速完成任务,那么访问相同的mutex和数据的线程越多,那么它们同时访问同一个mutex(或同一个原子变量)的几率就越大。

mutex争夺和原子变量争夺还是不同的,原因很简单,mutex的使用在操作系统层面将线程串行化而不是在处理器层面。如果你有足够多的线程准备运行,操作系统会在一个线程等待mutex时安排另一个线程运行,而处理器(processor stall)则会阻止任何其他的线程在此处理器上运行。尽管如此,使用mutex的情况仍然会降低性能,毕竟同一时刻只能运行一个线程。

回想第3章,3.3.2节,一个很少被更新的数据结构可也使用一个单写多读的mutex来保护。如果进展不顺利的话,乒乓缓存依然会使这样的mutex带来的益处变得无影无踪,因为所有访问数据的线程(尽管是读操作线程)都需要修改mutex自身。随着访问数据的处理器数量的增加,对mutex的争夺越发激烈,持有mutex的缓存行必须在所有内核之间来回传递,潜在的请求锁定和解锁带来的时间消耗将达到不可接受的地步。有多种技术可以改善这种问题,本质上就是通过扩展mutex,令其跨越多个缓存行,但如果你自己没能实现mutex,就只能受限于系统提供的mutex了。

如何表面乒乓缓存呢?在这章后面你将看到,答案很好地与提高并发潜力的一般准则相一致:减少多个线程对同一块内存地址的争夺。

那并不简单。即使一块指定的内存位置始终由一个线程来访问,你仍然会因为另一个已知的伪共享(false sharing)效应而导致乒乓缓存。

8.2.3 伪共享

处理器缓存通常不处理单个的内存位置,相反,它们处理叫做缓存行(cache lines)的内存块。这些内存块一般为32字节或者64字节大小,但是确切的细节依赖于使用的具体处理器模型。由于缓存硬件只处理缓存行大小的内存块,相邻的小的数据项将归属于同一个缓存行。有时候这很有利:如果被同一个线程访问的一组数据都处于一个缓存行中,将比被换分到多个缓存行中的效率更高。然而,如果位于同一个缓存行上的多个不想关的数据项需要被不同的线程访问,这将是导致性能问题的主要原因。

假设你有一个int数组,有一组线程频繁访问各自的数组元素,包括更新数据。由于一个int的大小明显比缓存行要小,所以有一些数组元素被归属到相同的缓存行上。因此,即使内个线程只是访问输入自己的数组元素,缓存硬件仍然会导致乒乓缓存。每次访问0号元素的线程需要更新数值时,缓存行的所有权就会被切换到运行这个线程的处理器上,那么,只有当访问1号元素的线程需要更新数值时缓存行的所有权才会被传递到运行访问1号元素的线程的处理器上。尽管没有数据是共享的,缓存行是共享的,因此导致了伪共享。解决的方案就是构建数据,使被同一个线程访问的各个数据项在内存上紧靠在一起(这就很可能被归为同一个缓存行中),而使那些被不同线程访问的数据项在内存上相互远离,使其更可能被分配到不同的缓存行上。在这一章的后面你见看到这是怎样影响数据和代码的设计的。

如果说不同的线程访问同一个缓存行是个坏主意,那么同一个线程访问的数据的内存布局是如何起作用的呢?

8.2.4 你的数据有多紧凑?

鉴于伪共享是由于不同线程访问的数据在内存上挨得太近造成的,另一个与数据布局相关的缺陷则直接影响单个线程本身的性能。这个问题就是数据接近:如果单个线程访问的数据在内存上比较分散,那就很可能被分配到不同的缓存行上。反过来说,如果单个线程访问的数据在内存上彼此紧靠,那就很可能被分配到相同的缓存行上。因此,如果数据分散,相比于内存布局紧凑型,则会有更多的缓存行必须从内存加载到处理器缓存上,这将增加内存访问延迟,降低性能。

如果数据的内存布局分散,那么一个给定的缓存行除了为当前线程包含数据外还去包含其他的数据的几率就会增加。极端情况下,缓存中的绝大部分数据都不是当前线程所关心的。这将浪费宝贵的缓存空间,增加了缓存丢失的几率,即使缓存中已经获得了数据,但是为了及时给别的线程提供缓存空间而不得不将其移除,当真正用到这个数据时就不得不从主内存中取数据。

这些事情对单线程应用来说很重要,那么我为什么要在这里将其引出呢?原因就是任务切换。如果系统中存在多于内核数量的线程,每个内核都将运行多个线程。这就加大了缓存的压力,你试图确保不同的线程访问不同的缓存行以防止伪共享。因此,当处理器切换线程时,如果不同线程使用的数据不是来自于同一个缓存行而是来自不同的缓存行,那就很可能需要重新加载缓存行

如果线程数大于处理器数或者内核数,那么操作系统可能会安排一个线程在一个内核上执行一个时间片,然后在另一个内核上再执行一个时间片。因此这将要求缓存行从第一个内核的缓存中转移到第二个内核缓存中;需要转移的缓存行越多,时间消耗越大。虽然操作系统会阻止这种情况的发生,但这确实会发生,而且一旦发生就会影响效率。

当大量线程准备运行(而不是等待)时,任务切换会变得非常普遍。这是一个我们已经接触过的问题:超额申请。

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

在多线程系统中,线程数操过处理器个数的情况是典型存在的,除非你运行在一个超级并行硬件上。尽管如此,线程常常需要花费时间去等待外部I/O完成操作,或者因mutex而阻塞,或者等待一个条件变量,等等,所以,这本身并不是问题所在。相比于在线程等待时令处理器闲置,留有额外的线程来做有用的事显然更好一些。

但是那不总是可行。如果拥有太多的附加线程,那么同一时刻正准备运行的线程数将远远超过有效的处理器个数,操作系统将不得不启动繁重的任务切换以保证所有线程都获得公平的时间片。就像你在第一章中看到的那样,这将增加任务切换的开销以及因为缺乏内存临近(lack of proximity)而导致的缓存问题。当你拥有一个无限制生成线程的任务时,超额申请问题将越发严重,就像第4章中快速排序使用的递归那样,或者当使用任务类型划分的线程数大于处理器个数时,则工作受限于CPU,而不是I/O。

如果因为使用数据划分而导致线程数过多,那么你可以像8.1.2节中那样限制线程的数量。如果超额申请是因为使用任务类型划分造成的,那么除了选择不同的划分方式外你也没有什么可做的了。在这种情况下,需要选择一个合适的划分方案,可能需要对目标平台有着更加详细的了解,不过这也只限于性能已经无法接受,而且能够证明改变划分方式的确可以提高性能的情况下。

其他的因素也可以影响多线程代码的效率。即使同样的CPU型号、相同的时钟频率,在双核单处理器上和两个单核处理器系统上,乒乓缓存造成的消耗仍有很大区别,不过这只是主要的显而易见的影响因素。让我们来看一下,它是如何影响代码和数据结构的设计的。

8.3 为提升多线程性能而设计数据结构

在8.1节中,我们看了在线程间划分工作的不同方式,在8.2节中我们讨论了影响代码性能的影响因素。我们如何利用这些信息来设计数据结构呢?这是和第6、7章截然不同的问题,它们所述的都是关于如何设计数据结构以安全的并行访问数据。你在第8.2节中看到的是,单个线程的数据内存分布造成的影响,尽管那些数据并没有分享给其他线程。

为提高多线程性能而设计数据结构的关键点是:争夺、伪共享、数据距离(data proximity)。这三点对性能的影响都很大,你常常可以通过改变数据布局或者改变哪个数据项被分配给哪个线程来改善性能。首先让我们来看一个简单的想法:在线程间划分数组元素。

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

假设你正在 做一些复杂的数学计算,你需要将两个大的方矩阵相乘。为了让两个矩阵相乘,你将第一个矩阵的第一行中每个元素乘以第二个矩阵的第一列中对应的元素得到结果矩阵的左上角的元素,然后重复操作将第一个矩阵中的第二行和第二个矩阵中的第一列对应元素相乘得到结果矩阵中第一列第二个元素,第一个矩阵中的第一行和第二个矩阵中的第一列对应元素相乘得到结果矩阵中第一行的第二个元素,等等。这些步骤显示在下图8.3中:


现在想象这是个很大的矩阵,拥有数千行和列,以便于使用多线程来优化性能。

这里介绍一个概念。矩阵中非零元素的个数远远小于矩阵元素的总数,并且非零元素的分布没有规律,通常认为矩阵中非零元素的总数比上矩阵所有元素总数的值小于等于0.05时,则称该矩阵为稀疏矩阵(sparse matrix),该比值称为这个矩阵的稠密度;与之相区别的是,如果非零元素的分布存在规律(如上三角矩阵、下三角矩阵、对角矩阵),则称该矩阵为特殊矩阵。

显然,一个非稀疏矩阵在内存上表现为一个大的数组,第一行的末尾连接着第二行的开头,以此类推。为了实现矩阵相乘,你现在就拥有了3个大的数组。为了优化性能,你需要仔细考虑数据访问的模式,特别是向第三个数组中写入的方式。

现在有很多方式在线程间划分工作。假设你的行/列数远超可用的处理器个数,可以让每个线程计算多列结果,或者让每个线程计算多行结果,或者甚至让每个线程计算一个子矩阵结果。

回想8.2.3和8.2.4,访问连续的数组元素要好于访问内存分散的数据,因为这样可以减少缓存行的使用以及降低伪共享的发生几率。如果让每个线程负责结果矩阵中的多列计算,那就需要读取第一个矩阵中的每一个元素以及第二个矩阵中的对应元素,但最终只能在结果矩阵上逐列写数据。我们知道,在内存上,数组是以行连续的形式存储的,这就意味着,如果每个线程需要为结果矩阵上的N列作计算,那么每个线程都需要访问结果矩阵的第1行中的N个元素、第二行中的N个元素,等等。由于每行中的相邻的N个元素在内存上是连续排列的,因此你将伪共享风险降至最低。当然,如果N个元素占有的空间正好等于缓存行的大小,那么线程将工作的完全分开的缓存行上,伪共享将不复存在。

另一面,如果你让每个线程负责结果矩阵中的多行计算,那么需要读取第二个矩阵中的每一个元素,以及第一个矩阵中的对应元素,最终需要在结果矩阵上逐行写数据。因为在内存上,数组是以行连续的形式存储的,你现在同时访问了N行的所有元素。如果这N行都是相邻的,那就意味着当前线程是唯一访问这N行元素的线程,这些连续的内存块不被其他线程所访问。这似乎是对之前的令每个线程处理结果矩阵中多列计算的一种改进,因为唯一可能出现伪共享的地方就是前N行的末尾几个元素可能和下N行头几个元素处于同一个缓存行中,但在给定的体系中付出一点时间代价是值得的。

那么第三种方案——让每一个线程负责结果矩形中的一个矩形块区域的计算,它的效果如何呢?它可以被看成是先对列划分然后对行划分。这样,它存在和使用列划分策略同样的伪共享风险。如果你能选择合适的列数避免伪共享的可能,那么从读数据这方面来看是有利的:你无需整个读取任何一个源矩阵。你只需要在2个源矩阵上读取与目标矩形区域对应的行数和列数。

举个具体的数目,考虑将两个具有1000行和1000列的矩阵相乘。那就是100万个元素。假设你有100个处理器,如果使用行划分的策略,它们在一个完美时钟周期可以计算出10行结果,也就是10000个结果。但是为了计算这些数据,需要读取第二个矩阵的所有元素(100万个)加上第一个矩阵中对应的10行元素(1万个),总数为1010000个数元素。

如果让这些处理器每次计算出100*100的矩形区域的结果(仍然是10000个),那么它每次需要从第一个矩阵中读取100行元素(100*1000=100000),从第二个矩阵中读取100列元素(100*1000=100000)。加一起才读取了20万个元素,是使用行划分策略的1/5。如果每次都读取更少的元素,那么就会降低缓存行丢失的概率,潜在的提升了效率。

因此将结果矩阵划分为矩形区域或正方形区域要好于将其划分为若干行。当然,你可以在运行时调整矩形块的尺寸,这要依赖于矩阵的尺寸和有效处理器的个数。与以往一样,如果性能很重要,那么在目标架构上配置各种选项至关重要。

如果你碰到的问题不是矩形相乘问题,那么这些知识对你有什么用呢?同样的原则适用于任何在线程间划分大的内存块数据的情况;仔细查看数据访问模式的各个方面方面,并确定影响性能的潜在原因。在你涉及的领域可能存在类似的情况,只需改变工作划分方式而无需改变基本的算法就可以提升性能。

OK,我们已经看完了数组的的访问模式是如何影响性能的。那么其他的数据类型呢?

8.3.2 其他数据结构的数据访问模式

从根本上来说,优化其他数据结构的数据访问模式和优化数组的数据访问模式一样:

 ■ 调整数据分布,使被同一个线程访问的数据在内存上紧靠在一起

 ■ 最小化线程的数据访问量

 ■ 确保被不同线程访问的数据在内存分布上的距离足够远,以防止伪共享

当然,对于其他数据结构来说不太容易做到这点。例如,二叉树难以在除子树之外的任何单位上细分,而且划分是否有用,完全取决于树的平衡度以及需要将它分成多少部分。而且,树的特性意味着节点很可能是动态创建的,节点最终归属于堆上的不同内存位置。

数据最终放在堆上的不同位置本身并不是一个特别的问题,但是那因为着处理器必须在缓存中持有更多的东西。这实际上是有好处的。如果多个线程需要遍历树,那么它们都需要访问树的节点,但是如果树中的节点只是保存了一个数据指针,那么处理器只是必须从内存中读取了它们需要的数据。如果数据被需要的线程修改,则可以避免节点数据本身与提供树结构的数据之间的伪共享造成的性能影响。(If the data is being modified by the threads that need it, this can avoid the performance hit of false sharing between the node data itself and the data that provides the tree structure.)

这里有一个与使用mutex保护数据类似的问题。假设你有一个简单的类,包含了一些数据项以及一个用于保护数据的mutex。如果mutex和数据项在内存上紧靠在一起,这对于一个线程访问mutex来说十分理想;在访问mutex时引发的缓存行读取可能顺便把数据项也一同载入了。但是同时也存在一个缺点:如果别的线程想访问此时被第一个线程锁定的mutex,那它们都需要访问同一块内存。mutex锁定的典型实现是内部在一个内存位置作为一个读-写-改的原子操作试图acquire这个mutex,如果一个mutex已经被锁定,随后会跟着一个操作系统内核调用(Mutex locks are typically implemented as a readmodify-write atomic operation on a memory location within the mutex to try to acquire the mutex, followed by a call to the operating system kernel if the mutex is already locked.)。持有mutex的线程的缓存中保持的数据可能因为这个读 - 改 - 写操作而变得无效(This read-modify-write operation may well cause the data held in the cache by the thread that owns the mutex to be invalidated. )。就mutex本身的操作而言,这不是问题;这个线程在解锁这个mutex之前一直不会接触这个mutex。然而,如果mutex所在的缓存行被这个线程所访问的数据所共享,那么持有mutex的线程就会引发一个性能下降,因为其他的线程在试图锁定这个mutex!

测试这种类型的伪共享是否成为一个问题的一种方法是,在被并行访问的数据项之间填充一个大块内存,例如你可以使用下面的方式来测试mutex的争夺问题:

struct protected_data{std::mutex m;char padding[65536];my_data data_to_protect;};

或者使用下面的方式来测试数组伪共享问题:

struct my_data{data_item1 d1;data_item2 d2;char padding[65536];};my_data some_array[256];

如果这样能够提高性能,你就会知道伪共享确实是个问题,那么你就可以留着填充区域,或者使用其他方式重排数据访问方式来避免伪共享。

8.4 设计并发时的其他注意事项

本章到目前为止,我们看到了在线程间划分工作的多种方式,影响性能的各种因素,以及这些因素是如何影响选择数据访问模式以及数据结构的。尽管如此,还有更多的关于并发代码的设计。你需要考虑像异常安全、可伸缩性这些事情。如果代码的性能(无论是降低执行速度(reduced speed of execution)还是增加吞吐量)会随着处理器内核的数量增加而提升,那么这样的代码就叫做可伸缩的(scalable)。理想情况下,性能增长是线性的,所以一个具有100个处理器的系统的性能时一个具有1个处理器的性能的100倍。

虽然一个不可伸缩的代码也可以运行——一个单线程应用显然是不可伸缩的,例如——异常安全是一个正确的问题(Although code can work even if it isn’t scalable—a single-threaded application is certainly not scalable, for example—exception safety is a matter of correctness.)。如果你的代码不是异常安全的,可能会随时出问题,因为数据一致性被破坏,或者数据竞争,或者因为一个操作抛出异常而意外终止。基于这些因素,我们首先来看看异常安全。

8.4.1 并行算法中的异常安全

异常安全是一个好的C++代码应该具备的基本能力,并发代码也不例外。事实上,相比于普通的串行算法,编写并行算法常常需要在异常安全方面更加小心。如果串行算法中的一个操作抛出了一个异常,只需担心确保自身的整理,以避免资源泄漏和破坏数据一致性;可以放心的将异常传递给调用者,让调用者来处理。作为对比,在并行算法中,许多操作都运行在不同的线程中。这种情况下,异常不允许被传递,因为它们处于错误的调用栈。如果在新线程上产生的函数异常退出,则应用程序将被终止。

作为一个具体的例子,让我们重新查看Listing2.8程序,下面将程序重新列出:

Listing 8.2 A naïve parallel version of  std::accumulate (from listing 2.8)

template<typename Iterator, typename T>struct accumulate_block{void operator()(Iterator first, Iterator last, T& result){result = std::accumulate(first, last, result);//(1)}};template<typename Iterator, typename T>T parallel_accumulate(Iterator first, Iterator last, T init){unsigned long const length = std::distance(first, last);//(2)if (!length)return init;unsigned long const min_per_thread = 25;unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread;unsigned long const hardware_threads = std::thread::hardware_concurrency();unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);unsigned long const block_size = length / num_threads;std::vector<T> results(num_threads);//(3)std::vector<std::thread> threads(num_threads - 1);//(4)Iterator block_start = first;//(5)for (unsigned long i = 0; i<(num_threads - 1); ++i){Iterator block_end = block_start;//(6)std::advance(block_end, block_size);threads[i] = std::thread(accumulate_block<Iterator, T>(),block_start, block_end, std::ref(results[i]));//(7)block_start = block_end;//(8)}accumulate_block()(block_start, last, results[num_threads - 1]);//(9)std::for_each(threads.begin(), threads.end(),std::mem_fn(&std::thread::join));return std::accumulate(results.begin(), results.end(), init);//(10)}

现在让我们标出哪里可能会抛出异常:基本上调用一个你知道可能会抛出异常的函数,或者调用了基于用户定义的操作,都可能抛出异常。

首先调用了distance()(2),这个操作基于用户提供的迭代器类型。由于你还没有做任何事情,而且这个操作发生在调用线程中,所以没什么问题。接着,你分配了一个results数组(3),以及一个thread数组(4)。同样,这些发生在调用线程,而且还没有做任何事,也没有创建线程。当然,在构造threads时可能会抛出异常,但是析构函数会保证需要释放的资源得以正确释放。

略过block_start的初始化(5),因为它同样安全,下面来看循环创建线程(6)、(7)、(8)。一旦经过了第一个线程的创建(7),那么如果它抛出了异常,麻烦就来了;std::thread对象的析构函数会调用std::terminate()函数终止应用程序。这不是件好事。

调用accumulate_block()(9)可能会抛出异常,会导致同样的后果;线程对象会被销毁,析构函数会调用std::terminate()函数。而另一边,在最后调用的 std::accumulate()(10)如果抛出异常则不会产生不利影响,因为在这一时刻所有的线程对象都执行了join()操作。

这些都是针对主线程来说的,但是还有未考虑到的情况:如果在新线程中调用了 accumulate_block()函数(1),这个调用如果抛出异常了,但是这里没有catch语句块,结果就是抛出的异常没有地方处理,导致系统执行std::terminate()函数

虽然不明显,但这段代码的确不是异常安全的。

添加异常安全

OK,现在我们已经找出所有可能抛出异常的位置以及对应的后果。现在该如何做呢?先来解决新线程中抛出异常的问题。

你在第4章中曾遇到过这种功能的设施。如果你仔细想想,你想从新线程中得到什么,你就会发现你试图允许一个可能抛出异常的代码来计算,并且返回结果。这恰恰是设计std::packaged_task和std::future的目的所在。如果使用std::packaged_task重新整理代码,那么将得到下面的代码:

Listing 8.3 A parallel version of  std::accumulate using  std::packaged_task

template<typename Iterator, typename T>struct accumulate_block{T operator()(Iterator first, Iterator last)//(1){return std::accumulate(first, last, T());//(2)}};template<typename Iterator, typename T>T parallel_accumulate(Iterator first, Iterator last, T init){unsigned long const length = std::distance(first, last);if (!length)return init;unsigned long const min_per_thread = 25;unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread;unsigned long const hardware_threads = std::thread::hardware_concurrency();unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);unsigned long const block_size = length / num_threads;std::vector<std::future<T> > futures(num_threads - 1);//(3)std::vector<std::thread> threads(num_threads - 1);Iterator block_start = first;for (unsigned long i = 0; i<(num_threads - 1); ++i){Iterator block_end = block_start;std::advance(block_end, block_size);std::packaged_task<T(Iterator, Iterator)> task(accumulate_block<Iterator, T>());//(4)futures[i] = task.get_future();//(5)threads[i] = std::thread(std::move(task), block_start, block_end);//(6)block_start = block_end;}T last_result = accumulate_block()(block_start, last);//(7)std::for_each(threads.begin(), threads.end(), std::mem_fn(&std::thread::join));T result = init;//(8)for (unsigned long i = 0; i<(num_threads - 1); ++i){result += futures[i].get();//(9)}result += last_result;//(10)return result;}

第一处改变就是accumulate_block()函数不再使用引用参数作为返回,而是直接返回结果(1)。你将使用std::packaged_task和std::future来实现异常安全,所以你可以使用它来传递结果。这要求在调用 std::accumulate()时传递一个类型T的缺省构造对象(2),而不是之前那样重用result的值,但这不是主要的改变。

第二个改变就是不再将结果定义成T类型的vector而是定义成 std::future<T>类型的vector(3),以便新创建的线程调用std::packaged_task。在创建新线程循环中,首先创建了一个accumulate_block的task(4),然后从task获取到future(5),然后让其在新线程中运行,把参数传递给线程(6)。当task被执行,那么执行结果或者是抛出的异常将被future捕获。

因为不得不从future中取出计算结果,所以不再使用 std::accumulate,而是直接使用一个for循环,由初始值init(8)开始,然后分别加上从future中取出的结果(9)。如果对应的task执行时抛出异常,异常将会被future捕获,当调用get()时会被再次抛出。

所以,现在排除掉了一个隐藏的问题:在工作线程中抛出的异常会被重新抛出到主线程中。如果多个工作线程抛出异常,只有一个会被转发,但那无需大惊小怪。如果你很在意这件事的话,可以使用 std::nested_exception去捕获所有异常。

剩下的问题就是线程泄漏:如果在创建第一个线程,以及对所有线程执行join()之间抛出了异常。最简单的办法就是catch任何异常,然后对 joinable()的线程执行join(),然后抛出异常:

try{for (unsigned long i = 0; i<(num_threads - 1); ++i){// ... as before}T last_result = accumulate_block()(block_start, last);std::for_each(threads.begin(), threads.end(),std::mem_fn(&std::thread::join));}catch (...){for (unsigned long i = 0; i<(num_thread - 1); ++i){if (threads[i].joinable())thread[i].join();}throw;}

现在可以正常工作了。所有的线程都被join()了,无论代码怎样跳出当前块。但是,try/catch块很难看,而且存在重复代码,在常规控制流中以及catch块中都存在对线程的join操作。重复代码基本不是一件好事,那意味着要做更多的改动(如果事后要改变逻辑的话)。换一种方式,把那些事情(join)放到对象的析构函数中执行。那毕竟是C++惯用的清理资源的方式。这个就是需要的类:

class join_threads{std::vector<std::thread>& threads;public:explicit join_threads(std::vector<std::thread>& threads_) : threads(threads_){}~join_threads(){for (unsigned long i = 0; i<threads.size(); ++i){if (threads[i].joinable())threads[i].join();}}};

它类似于程序2.3中的thread_guard,现在可以将代码简化为这样了:

Listing 8.4 An exception-safe parallel version of  std::accumulate

template<typename Iterator, typename T>T parallel_accumulate(Iterator first, Iterator last, T init){unsigned long const length = std::distance(first, last);if (!length)return init;unsigned long const min_per_thread = 25;unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread;unsigned long const hardware_threads = std::thread::hardware_concurrency();unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);unsigned long const block_size = length / num_threads;std::vector<std::future<T> > futures(num_threads - 1);std::vector<std::thread> threads(num_threads - 1);join_threads joiner(threads);//(1)Iterator block_start = first;for (unsigned long i = 0; i<(num_threads - 1); ++i){Iterator block_end = block_start;std::advance(block_end, block_size);std::packaged_task<T(Iterator, Iterator)> task(accumulate_block<Iterator, T>());futures[i] = task.get_future();threads[i] = std::thread(std::move(task), block_start, block_end);block_start = block_end;}T last_result = accumulate_block()(block_start, last);T result = init;for (unsigned long i = 0; i<(num_threads - 1); ++i){result += futures[i].get();//(2)}result += last_result;return result;}

一旦创建了线程容器threads,就立刻创建一个新类join_threads的对象(1),用于在对象销毁时join所有进程。可以去掉原来使用循环显示join线程的代码,因为你清楚的知道,无论代码怎样运行,函数如何退出,这些线程都能保证被join。注意 futures[i].get()调用(2),现在它需要阻塞等待计算结果,所以在这点上你不需要再显示的join线程。这与原始的程序8.2不同,8.2程序必须join所有线程以确保结果被放入到vector中,才能继续执行最后一步。现在,你不仅解决了异常安全问题,而且你的代码变得短了许多,因为你使用了新类(这个类还是可以被别的程序复用的类)。

STD :: ASYNC ()之异常安全

现在你知道了显示管理线程时如何做到异常安全,让我们来看看使用std::async()来做同样的事情。你已经知道,这种情况下,标准库会小心帮你管理线程,当future状态变为ready时,任何生成的线程都完成了(any threads spawned are completed when the future is ready)。异常安全的关键就在于,如果你没有等待future就销毁它,析构函数将等待线程完成。这就能巧妙的避免仍在运行的并且持有数据引用的线程发生线程泄漏。下面的程序显示了使用了std::async()的版本,并且是异常安全的:

Listing 8.5 An exception-safe parallel version of  std::accumulate using  std::async

template<typename Iterator, typename T>T parallel_accumulate(Iterator first, Iterator last, T init){unsigned long const length = std::distance(first, last);//(1)unsigned long const max_chunk_size = 25;if (length <= max_chunk_size){return std::accumulate(first, last, init);//(2)}else{Iterator mid_point = first;std::advance(mid_point, length / 2);//(3)std::future<T> first_half_result = std::async(parallel_accumulate<Iterator, T>,first, mid_point, init);//(4)T second_half_result = parallel_accumulate(mid_point, last, T());//(5)return first_half_result.get() + second_half_result;//(6)}}

这个版本使用了递归划分数据的方式,而不是之前的逐个计算分块的数据划分方式,但是这个版本却比上一个版本简单了许多,而是仍然是异常安全的。一如既往,首先得出序列的长度(1),如果小于最大块尺寸,直接调用std::accumulate函数(2)。否则,找到轴心点(3),然后启动一个异步任务来处理前一半数据(4)。另一半数据则直接使用递归调用(5),然后两部分得到的结构再相加(6)。标准库能够确保std::async适当的使用硬件线程,不会创建过多的线程。一些“异步”调用实际上会在执行get()函数(6)时同步执行。

这个版本的优雅之处在于,利用硬件并行的优势的同时还能保证异常安全。如果递归调用时抛出了一个异常(5),因调用std::async而创建的future(4)将随着异常的传递而销毁。它将依次等待异步任务完成,那就会组织悬挂线程的产生。另一方面,如果异步调用抛出异常,那么异常将被future捕获,当调用get()时(6)就会重新抛出。

在设计并行代码时还有没有其他需要注意的事情?我们来看看可伸缩性(scalability)。如果你的代码放到具有更多处理器上的系统时,性能会提高多少?
















阅读全文
0 0