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

来源:互联网 发布:java计算时间差 毫秒 编辑:程序博客网 时间:2024/06/05 06:28

8.4.2 可伸缩性与阿姆达尔定律(Amdahl’s law)

可伸缩性是关于确保你的代码可以利用所运行系统上的附新增的处理器。极端情况是,一个单线程应用完全是不可伸缩的;即使为系统增加100个处理器,它的效率也不会改变。另一个极端情况是,你有一个类似于SETI@Home工程的应用,它专门设计用来利用数千处理器(以附加网络上单个计算机的形式)。

对于任何给定的多线程程序,执行有用工作的线程数将随程序运行而变化。尽管每个线程在它的存在期间都在做有用的工作,但应用程序在初始时仍有可能只有一个线程,然后才生成其他的线程。即使这样也是非常不可能的。线程经常互相等待,或者等待I/O完成操作。

每一次线程等待(不论等待什么),除非另一个线程准备在处理器中腾出空间,都会存在一个处理器闲置,那本可以用于做有用的工作(you have a processor sitting idle that could be doing useful work.)。

查看它一个简单的方法是将程序划分为“串行”部分,只有一个线程正在做有用的工作,以及“并行”部分,所有可用处理器正在进行有用的工作。如果将你的应用放到具有更多处理器上的系统上运行,理论上并行部分将完成的更快,因为工作可以被分派到更多的处理器上,而串行部分仍然是串行执行。在这样的假设下,你可以估算通过增加处理器数量得到的性能提升:如果串行部分占整个程序的比例为Fs([0~1]区间的一个小数),那么使用N个处理器获得的性能提升P(加速比)就可以被估算为:

                    1
P =  ———————
                    1-Fs
          Fs + ———
                      N

这就是阿姆达尔定律,当谈论并行代码的性能时经常被引用。如果所有事情都可以被并行化,那么串行比例为0,加速比就等于N。或者,如果串行比例为1/3,那么无论增加多少处理器,你的加速比也超不过3。

然而,这只是画了一个天真的画面而已,因为任务很少能够像方程中那样要求的被无线划分,而且也不是所有的事情都可以假定受限于CPU。你将会看到,在运行时线程可能会因为很多事情而处于等待状态。

阿姆达尔定律可以清晰的告诉我们一件事,当你使用并行来提高效率时,值得仔细思索整个应用,使并行最大化,并且确保处理器总是保持做有用的事。如果能够减少串行部分,或者减少线程等待,那么在更多的处理器上将获得更多的性能提升。或者,你能提供更多的数据给系统处理,那就能保持并行部分时刻准备工作,就可以减少串行比例并提高并行加速比P。

本质上说,可伸缩性就是当增加处理器时减少执行一个动作的时间或者增加单位时间内可以处理的数据量。有时这两者是等价的(如果每个数据项处理得更快,那就可以处理更多的数据),但不总是这样。在选择不同的方式来划分工作前,找出可伸缩性的哪个方面对你来时更重要往往很关键。

在这章的开始,我曾经说过,线程并不总是在做有用的事。有时它们不得不等待其他线程,或者等待I/O完成,或者其他事情。在这期间,如果你能给它提供一些有用的事情来做,就能够有效的“隐藏”等待。

8.4.3 隐藏多线程的延迟

对于大量的关于多线程代码的讨论,我们都是基于这样一种假设,当线程运行在处理器上时,它总是全速运行,而且总是在做有用的事情。这当然不是事实,应用程序代码中的线程总是因为等待其他事情而陷入等待。例如,它们可能需要等待I/O完成,等待获取一个mutex,等待另一个线程完成一个操作或通知一个条件变量或者位一个future赋值,更甚至是sleep一个时间周期。

不论什么原因导致等待,如果你只有与系统中的物理处理器单元一样多的线程,那么阻塞线程就意味着你在浪费CPU时间。运行一个阻塞线程的那个处理器什么也没做。因此,如果你能预见某个线程可能会浪费大量时间去等待,那就可以将这些剩余CPU时间用于运行额外的一个或更多的线程。

考虑一个病毒扫描应用程序,它使用管道系统来为线程划分工作。第一个线程负责搜索文件系统,将文件名放到一个队列中。同时,另一个线程从队列中取出文件名,载入文件,然后扫描病毒。你知道在文件系统中搜索要扫描的文件的线程肯定是I / O绑定的,所以将CPU的剩余时间用来运行附加的扫描线程。那么你拥有一个用于搜索文件的线程,以及和物理处理器单元数目相同的文件扫描线程。由于扫描线程也可能必须从磁盘读取文件的重要部分以扫描它们,也许应该创建更多的扫描线程。但是在某些时候会存在太多线程,系统就会因为频繁切换任务而导致性能变慢,就像8.2.5节中说的那样。

和往常一样,这是一种优化,所以在修改线程数的前后进行测试是很重要的;最佳线程数非常依赖于工作的性质以及线程等待所占的时间比例。

依靠应用程序本身,有可能无序附加线程也能利用CPU的剩余时间。例如,如果一个线程因为等待I/O操作完成而陷入阻塞,如果可以的话就使用异步I/O,当异步I/O在后台运作时线程就可以做其他有用的事情了。另外,如果线程在等待其他线程完成一个操作,那么不要等待,也许那个操作自己是否可以去完成,就像第7章中的无锁队列那样。极端情况下,如果线程等待的那个任务还没有任何线程开始做,那么应该完全自己去完成那个任务,或者去做别的未完成的任务。8.1中就有这样的例子,只要它需要的块还没有完成排序,那么排序函数就会反复尝试为那些没有排序的块做排序。

除了增加线程以确保所有可用的处理器单元都被使用,有时候可以增加一些线程以及时的处理一些外部事件,以提高系统的响应能力。

8.4.4 提升并行系统的响应能力

大多数现代图形用户界面框架都是事件驱动的(event driven);用户的操作都是通过点击按键和移动鼠标来实现的,那会生成一串事件或者消息,然后由应用来处理。操作系统本身也会产生消息或事件。为了确保消息或事件被正确处理,应用程序一般会拥有这样的事件循环:

while (true){event_data event = get_event();if (event.type == quit)break;process(event);}

明显,API的细节是多样化的,但是结构通常一样:等待一个事件,处理必要的事件,然后等待下一个。如果你有一个单线程应用,那将是一个难写的长时间运行的任务,就像8.1.3中所说的那样。为了确保用户输入被及时处理,get_event()和process()函数必须以合理的频率被调用,而无论应用在做什么。这就意味着,要么任务必须周期的暂停自己并将控制返回给事件循环,要么get_event()/process()必须在内部一个合适的地方被调用。哪种选择都将使应用代码变得复杂。

通过在并行中划分关注点,可以将耗时的任务放入一个新线程中,再让一个专门的GUI线程负责事件处理。线程间可以通过简单的机制通讯,而不是在执行任务的代码中混合着事件处理代码。下面的代码显示了一个大概的轮廓:

Listing 8.6 Separating GUI thread from task thread

std::thread task_thread;std::atomic<bool> task_cancelled(false);void gui_thread(){while (true){event_data event = get_event();if (event.type == quit)break;process(event);}}void task(){while (!task_complete() && !task_cancelled){do_next_operation();}if (task_cancelled){perform_cleanup();}else{post_gui_event(task_complete);}}void process(event_data const& event){switch (event.type){case start_task:task_cancelled = false;task_thread = std::thread(task);break;case stop_task:task_cancelled = true;task_thread.join();break;case task_complete:task_thread.join();display_results();break;default://...}}

使用这种方法来划分关注点,用户线程总是能够及时响应事件,尽管任务可能需要很久才能执行完。对一个应用来说,响应能力是用户体验的关键;如果应用一旦执行某个操作就完全卡死,那就不方便使用了。通过提供一个专门的事件处理线程,GUI可以处理GUI专有的消息(例如改变尺寸或重绘窗口)而不用打断耗时的操作,否则传递相关消息就会影响耗时任务。

到目前为止,在本章中,你已经全面了解了设计并发代码时需要考虑的问题。总的来说,这些知识都是非常重要的,但是随着多线程编程的不断使用,它们中的大部分都将成为你的第二灵感。如果这些考虑因素对你来说是还比较陌生,那么希望在你查看如何影响多线程代码的具体示例时,它们将变得更加清晰。

8.5 在实践中涉及并行代码

当为一个特定的任务设计并行代码时,你需要多大程度来考虑之前所述的问题,依赖于任务。为了证明它们的应用,我们来看看3个C++标准库函数的并行版本。这将为您提供熟悉的基础,同时提供一个查看问题的平台。作为奖励,我们也实现可用的函数,可用于帮助并行化更大的任务。我主要选择这些实现来演示特定的技术,它们不是最先进的实现;更好的利用可用的硬件并行性的实现可以在关于并行算法的学术论文中找到,或者是在专业多线程库(例如Intel’s Threading Building Blocks)中找到。

最简单的并行算法概念就是std::for_each()的并行版本,因此我们先来看看它的实现。

8.5.1 std::for_each的一种并行实现

std::for_each在概念上是简单的,它在一个范围内依次对数据项执行用户定义的函数。串行版本与并行版本之间的最大不同就是函数执行的顺序。std::for_each调用函数的顺序是,第一个元素、第二个元素,等等;而并行版本则不能保证这些元素的调用顺序,实际上的调用可能是并行的(也正是我们期望的)。

为了实现并行版本,需要为每个线程分配一组数据项。由于你预先知道了数据项的个数,所以你可以在处理数据之前划分数据(8.1.1节)。我们假设这个算法是当前唯一在运行着的线程,所以可以使用 std::thread::hardware_concurrency()来确定线程数。你也知道这些数据项可以被独立的处理,相互之间没有依赖,所以你可以使用连续的内存块来避免伪共享(8.2.3节)。

这个算法类似于8.4.1节中std::accumulate的并行版本,但不需要计算元素的和,你只需要对每一个元素执行函数。你可能已经想象到这将是个简单的代码,因为它们不需要返回结果,如果你希望将抛出的异常传递给调用者,那就需要使用std::packaged_task和std::future机制以在线程间传递异常。下面是一个简单的实现:

Listing 8.7 A parallel version of  std::for_each

template<typename Iterator, typename Func>void parallel_for_each(Iterator first, Iterator last, Func f){unsigned long const length = std::distance(first, last);if (!length)return;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<void> > futures(num_threads - 1);//(1)std::vector<std::thread> threads(num_threads - 1);join_threads joiner(threads);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<void(void)> task(//(2)[=](){std::for_each(block_start, block_end, f);});futures[i] = task.get_future();threads[i] = std::thread(std::move(task));//(3)block_start = block_end;}std::for_each(block_start, last, f);for (unsigned long i = 0; i<(num_threads - 1); ++i){futures[i].get();//(4)}}

代码的基本结构和程序8.4一样。关键的不同之处就是用于保存std::future<void>的数组futures(1),因为这个工作没有返回值,一个简单的lambad表达式在block_start和 block_end之间的元素上调用了用户传进来的f函数(2)。这就避免在thread的构造函数中传参数(3)。由于工作线程无需返回值,所以 futures[i].get()调用(4)只是提供了一种接收工作线程传递来的异常的手段;如果你不想获取异常,可以忽略它。

就像std::accumulate可以使用std::async简单的改成并行版本,对于parallel_for_each也一样。下面就是这种实现:

Listing 8.8 A parallel version of  std::for_each using  std::async

template<typename Iterator, typename Func>void parallel_for_each(Iterator first, Iterator last, Func f){unsigned long const length = std::distance(first, last);if (!length)return;unsigned long const min_per_thread = 25;if (length<(2 * min_per_thread)){std::for_each(first, last, f);//(1)}else{Iterator const mid_point = first + length / 2;std::future<void> first_half = std::async(&parallel_for_each<Iterator, Func>,first, mid_point, f);//(2)parallel_for_each(mid_point, last, f);//(3)first_half.get();//(4)}}

就像程序8.5中基于std::async()的parallel_accumulate那样,使用递归划分数据,因为你不知道标准库会使用多少线程。像往常一样,将数据分为两半,一般异步执行(1),另一半直接执行(2),直到剩下的数据太少了无法再划分,那就使用std::for_each(1)。再次对 std::async返回的future(4)执行get()以获取传递的异常。

让我们从对每个数据项执行相同操作的算法(还有一些,例如std::count以及std::replace)转向稍微复杂一些的算法,例如std::find。

8.5.2 std::find的一种并行实现

std::find是我们下一个要讨论的常用算法,因为那是几个完全不需要访问每个元素的算法之一。例如,如果第一个元素符合查询条件,那就不需要再测试其他的元素了。这对性能来说是一种重要的属性,它直接影响并行实现的设计。这是一个典型的数据访问模式能够影响代码设计的例子(8.3.2节)。这种类型的其他算法包括std::equal以及std::any_of

如果你正在和你的妻子或伴侣通过阁楼的纪念品箱子寻找一张旧照片,如果找到了就不必再找下去了。你会通知他们,你已经找到了(或许通过大喊:我找到了!),所以他们就会停止翻找,去干别的。许多算法的性质要求访问每一个元素,它们没有机会喊停。像std::find这样的算法,具有提早结束的内力是一种重要的属性,防止浪费时间。所以应该利用这个属性——当知道答案时就以某种方式中断其他任务,以使得代码无需等待其他线程处理完剩下的元素。

如果你不中断其他线程,那么串行版本可能会好于并行版本,因为串行版本一旦找到对应元素就会立刻停止。例如,如果系统可以支持同时运行4个线程,那么每个线程就会处理1/4的元素,并行版本会花费相当于单线程访问所有元素的1/4的时间,如果那个要找的元素恰好再第一个1/4元素范围内,那么单线程算法就会提前结束。

一种中断其他线程的方法是使用一个原子变量作为标志位,每处理一个元素之后都检查一下标志位。只要标志位被设置了,说明其他线程之一已经找到了那个元素,那么就可以停止并返回了。使用这种方式来中断线程,使得你可以不用遍历所有元素,在大多数情况下的效率都会胜过串行版本的算法。缺点就是,读取原子变量是一种缓慢的操作,因此会妨碍每个线程的执行。

现在你有两个选择来返回结果以及传递异常。你可以使用一个future数组,使用std::packaged_task传递结果和异常,然后将结果返回给主线程;或者可以在工作线程中使用std::promise直接设置最终结果。这完全依赖于你希望如何处理来自工作线程中的异常。如果你希望再碰到第一个异常时就停止操作(即使没有任何元素被处理),你可以使用std::promise来设置结果和异常。另一边,如果你希望允许其他线程保持搜索,就可以使用std::packaged_task存储所有的异常,然后如果没有找到匹配元素就重新抛出其中的一个异常。

这种情况下,我选择使用std::promise,因为它的行为与std::find更为匹配。有一件事需要注意,如果指定的范围内没有要找的元素,那么你在从future中得到结果之前就要等待所有线程完成操作。如果你只是在future处阻塞,如果没有值那你就会永远等待。结果就像下面这样:

Listing 8.9 An implementation of a parallel find algorithm

template<typename Iterator, typename MatchType>Iterator parallel_find(Iterator first, Iterator last, MatchType match){struct find_element//(1){void operator()(Iterator begin, Iterator end, MatchType match, std::promise<Iterator>* result,std::atomic<bool>* done_flag){try{for (; (begin != end) && !done_flag->load(); ++begin)//(2){if (*begin == match){result->set_value(begin);//(3)done_flag->store(true);//(4)return;}}}catch (...)//(5){try{result->set_exception(std::current_exception());//(6)done_flag->store(true);}catch (...)//(7){}}}};unsigned long const length = std::distance(first, last);if (!length)return last;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::promise<Iterator> result;//(8)std::atomic<bool> done_flag(false);//(9)std::vector<std::thread> threads(num_threads - 1);{//(10)join_threads joiner(threads);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);threads[i] = std::thread(find_element(),block_start, block_end, match, &result, &done_flag);//(11)block_start = block_end;}find_element()(block_start, last, match, &result, &done_flag);//(12)}if (!done_flag.load())//(13){return last;}return result.get_future().get();//(14)}

这个程序的主体与之前的例子很相似。这次,工作由类find_element的调用操作符函数来完成(1)。它循环检查给定范围内的所有元素,并且检查标志位(2)。如果找到了,就将值设置到promise中(3),然后在返回前设置一下标志位(4)。

如果抛出了一个异常,就会被通用处理捕获(5),然后在设置标志位前尝试将异常保存到promise中(6)。如果promise已经被设置了,那么再次设置会导致抛出一个异常,所以再次捕获这里的异常,并抛弃(7)。

这就意味着,如果一个线程调用了find_element函数,要么找到了要找的元素要么抛出一个异常,所有其他的线程如果检测到了标志位就会停止。如果多个线程同时找到了匹配的元素,或者同时抛出异常,它们将竞相去设置promise。但这是一个良性竞争条件;无论哪个成功都是名义上“第一”,因此是可以接受的结果。

来看parallel_find主要功能,你有promise(8)和标志位(9)用于停止搜索,它们随着元素范围一起被传给用于搜索的新线程(11)。主线程本身也使用find_element来搜索剩下的元素(12)。之前已经说过,在检查结果之前必须等待所有线程完成,因为可能没有匹配的元素。你将启动-join线程的代码封装在一个代码块中(10),所以当你检查标志位时所有线程已经被join了(13)。如果找到了一个元素,你就可以通过future的get函数取出结果或者异常(14)。

同样,这个实现假设你能够使用所有有效的硬件线程,或者你可以使用某种机制确定线程数以便预先为线程分配工作。一如既往,这里仍然可以使用std::async及递归数据划分方法来简化实现,同时使用C++标准库提供的自动缩放设施。一种使用std::async的parallel_find()的实现如下:

Listing 8.10 An implementation of a parallel find algorithm using  std::async

template<typename Iterator, typename MatchType>//(1)Iterator parallel_find_impl(Iterator first, Iterator last, MatchType match,std::atomic<bool>& done){try{unsigned long const length = std::distance(first, last);unsigned long const min_per_thread = 25;//(2)if (length<(2 * min_per_thread))//(3){for (; (first != last) && !done.load(); ++first)//(4){if (*first == match){done = true;//(5)return first;}}return last;//(6)}else{Iterator const mid_point = first + (length / 2);//(7)std::future<Iterator> async_result = std::async(¶llel_find_impl<Iterator, MatchType>,//(8)mid_point, last, match, std::ref(done));Iterator const direct_result = parallel_find_impl(first, mid_point, match, done);//(9)return (direct_result == mid_point) ? async_result.get() : direct_result;//(10)}}catch (...){done = true;//(11)throw;}}template<typename Iterator, typename MatchType>Iterator parallel_find(Iterator first, Iterator last, MatchType match){    std::atomic<bool> done(false);    return parallel_find_impl(first, last, match, done);//(12)}

如果希望一旦找到匹配项那就尽早完成,那就意味着需要有一个标志位用来确定已经找到那个元素,这个标志位被所有线程共享。因此也需要被传入所有的递归调用中。最简单的办法就是授权一个实现函数(1),它带着一个附加参数——一个原子变量的引用,这个标志位是从主入口点传出的(12)。

(此处对程序的解释和之前的例子几乎一字不差,故省略)

如果没有找到就返回last迭代器(6)。小心使用 std::ref()传递标志位的引用。如果async任务是以同步方式执行的,那么最终将到调用get()时才会去执行(10)。如果下一半的搜索成功了,那么上一半的搜索会被忽略,get()函数不会被调用。如果async任务确实运行在新线程上,那么async_result对象(它是个future)析构时会去等待线程完成,所以不必担心线程泄漏。

跟以前一样,std::async为你提供了异常安全以及异常传递特性。如果直接调用的递归函数抛出了异常,future的析构函数会保证在函数返回前结束其他线程,如果异步调用的递归函数抛出了异常,那么调用get()时会传递异常(10)。涵盖了所有代码的try / catch块的唯一用处是,当一个异常被抛出时设置标志位,以快速的终止所有的线程(11)。即使没有这个try/catch检查,这个实现也能正确工作,只是即使出现了一个异常,所有的线程还是会继续工作,直到完成。

这个算法以及其他你已经见过的并行算法的一个重要的特征就是,不能保证像std::find那样的按序访问元素。这是算法并行化的本质。如果保证顺序就不能并行处理了。如果元素相互之间是独立的,那么使用像parallel_for_each这样的算法,即使不能保证处理顺序也无关紧要。但是那意味着parallel_find可能返回了一个范围末尾的元素位置,即使第一个元素就可以匹配,如果你不希望那样的话,结果就有些令人惊讶了。

OK,那么你已经完成了std::find的并行化。就像我在这章开始时所说的那样,还有其他类似的算饭不需要访问每一个元素就可以完成操作,相同的技术也可以被应用到这些算法上。我们在第9章还会看到关于中断线程的问题。

为了完成我们的3个例子,我们换个方向,来看一下std::partial_sum。实现这个算法的压力并不大,但这是一个有趣的并行算法,并且突出显示了额外的一些设计选择。

8.5.3 一个std::partial_sum的并行实现

std::partial_sum算法,输入范围内的元素经过累加放到目标容器中,目标容器中每个元素都等于本身及其之前所有位置的元素的和。序列1, 2, 3, 4, 5 变成1, (1+2)=3,(1+2+3)=6,(1+2+3+4)=10,(1+2+3+4+5)=15。它的并行化非常有趣,因为你不能只是将元素换分为不同块,然后每一块分别计算。例如,第一个元素的初始值需要被加到其他所有元素上。

一种求部分和的方法是,先划分数据为很多块,然后分别求部分和。然后将第一块的最后一个结果分别加上第二块的每个结果,等等。如果初始集合是1, 2, 3, 4, 5, 6, 7, 8, 9,将它们分为3块并求部分和,第一次得到了{1, 3, 6}, {4, 9, 15}, {7, 15, 24} 。如果将第一块中的最后一个元素6分别加到第二块上,就得到{1, 3, 6}, {10, 15, 21},{7, 15, 24}。让后再将第二块的最后一个元素21分别加到第3块上,于是就得到了最后的结果{1, 3, 6}, {10, 15, 21}, {28, 36, 55}。

就像原始数据可以被分成块那样,来自前一块的部分和的加法也可以并行化。如果每一块的最后一个元素最先被更新,那么块中剩下的元素可以被一个线程更新,同时另一个线程去更新下一个快,等等。当元素个数远超处理器核个数时这种方法很有效,因为每一阶段每个内核都有合理数量的元素去处理。

如果系统拥有大量的处理器核(甚至多余元素个数),那么这种方法就不太好了。如果你在处理器中分派任务,你最终在第一步处理了一对元素,在这种情况下,结果的向前传递意味着,许多处理器处于等待状态,所以你应该为它们找到一些工作去做。可以换个思路去解决问题。现在不将上一块的和整个传给下一个块,而是部分传递:首先还是像之前那样先令相邻的元素相加,1,1+2,2+3,3+4,4+5,5+6,6+7,7+8,8+9,第一轮之后就变为1, 3, 5, 7, 9, 11, 13, 15, 17,前两个元素已经计算出来。第二轮开始,每个数将自身添加到向后第2位上,1,3,1+5,3+7,5+9,7+11,9+13,11+15,13+17,第二轮之后就变为1, 3, 6, 10, 14,18, 22, 26, 30,前4个元素已经计算出来。第三轮开始每个数将自身添加到向后的第4位上,1,3,6,10,1+14,3+18,6+22,10+26,14+30,第三轮之后就变为1, 3, 6, 10, 15, 21, 28, 36, 44,前8个已经计算出来。第4轮之后就变为1, 3, 6, 10, 15, 21, 28, 36, 45,所有的元素都计算出来了,得到最后的结果。尽管比第一种方式多出了很多步骤,但是如果有更多的处理器,那么并行执行的空间将更大;每个处理器在一步中都可以更新一个元素。

总的来说,如果有N个元素,第二种方法需要log₂ (N)个步骤(每个处理器处理一个元素)(the second approach takes log 2 (N) steps of around N operations (one per
processor), where N is the number of elements in the list)。如果K是线程数,那么第一种方法,初始求部分和时每个线程需要执行N/K次此操作,然后进一步执行N/K次操作来向前传递。因此第一种方法的时间复杂度为O(N),而第二种的是时间复杂度为O(Nlog(N)),按照总的操作数来看。但是,如果处理器的数量与元素数量一样多的话,那么第二种方式只要求每个处理器执行log(N)次操作,而第一种操作当K变大时本质上成为了串行化操作,原因是向前传递结果。对于少量的处理器单元系统来说,第一种方式将会完成得更快,而在高并行系统中第2种方式完成得更快。这是8.2.1中描述的问题的一种极端体现。

无论如何,先放下效率问题,先来看看第一种方式的实现:

Listing 8.11 Calculating partial sums in parallel by dividing the problem

template<typename Iterator>void parallel_partial_sum(Iterator first, Iterator last){typedef typename Iterator::value_type value_type;struct process_chunk//(1){void operator()(Iterator begin, Iterator last, std::future<value_type>* previous_end_value,std::promise<value_type>* end_value){try{Iterator end = last;++end;std::partial_sum(begin, end, begin);//(2)if (previous_end_value)//(3){value_type& addend = previous_end_value->get();//(4)*last += addend;//(5)if (end_value){end_value->set_value(*last);//(6)}std::for_each(begin, last, [addend](value_type& item)//(7){item += addend;});}else if (end_value){end_value->set_value(*last);//(8)}}catch (...)//(9){if (end_value){end_value->set_exception(std::current_exception());//(10)}else{throw;//(11)}}}};unsigned long const length = std::distance(first, last);if (!length)return last;unsigned long const min_per_thread = 25;//(12)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;typedef typename Iterator::value_type value_type;std::vector<std::thread> threads(num_threads - 1);//(13)std::vector<std::promise<value_type> > end_values(num_threads - 1);//(14)std::vector<std::future<value_type> > previous_end_values;//(15)previous_end_values.reserve(num_threads - 1);//(16)join_threads joiner(threads);Iterator block_start = first;for (unsigned long i = 0; i<(num_threads - 1); ++i){Iterator block_last = block_start;std::advance(block_last, block_size - 1);//(17)threads[i] = std::thread(process_chunk(),//(18)block_start, block_last, (i != 0) ? &previous_end_values[i - 1] : 0, &end_values[i]);block_start = block_last;++block_start;//(19)previous_end_values.push_back(end_values[i].get_future());//(20)}Iterator final_element = block_start;std::advance(final_element, std::distance(block_start, last) - 1);//(21)process_chunk()(block_start, final_element,//(22)(num_threads>1) ? &previous_end_values.back() : 0, 0);}

这个实现的结构和上一个算法是一样的,将问题分为多块,每个线程分配最小的元素数量(12)。这种情况下,与线程数组(13)一样,还要定义一个相同大小的promises数组(14),用于保存块中最后一个元素的值,还有一个future数组(15),用于获取上一个块中的最后一个值。可以对future数组执行reserve,因为你知道有多少元素,防止它连续分配内存。

主循环和以前一样,除了一点,这次你不想让迭代器指向每一个块中的最后一个元素之后的元素,而是指向每一块中的最后一个(17),所以你可以将每一块中的最后一个元素向前传递。实际的处理位于process_chunk函数对象中,稍后将看到;起始和末尾迭代器随着上一块末尾元素的future(如果有的话)以及保持本块最后一个元素值的promise参数一起传递进去的(18)。

每次创建完线程,就可以更新block_start迭代器了,记着要递增它(19),还要将当前块中的最后一个元素的数组保存到future中,并放到数组里,这样它就可以被下一个循环取出(20)。

在处理最后一个块之前,需要获取最后一个迭代器(21),以便于传给 process_chunk(22)。std::partial_sum()没有返回值,所以一旦最后一块被处理后,你无需做任何事。一旦所有线程完成,操作就完成了。

现在来看process_chunk函数对象(1)。开始时对整块调用std::partial_sum()函数,包括最后一个元素(2),但是你需要知道你是否是第一块(3)。如果你不是第一块,就需要等待previous_end_value(4)。为了最大化并行算法,你首先更新最后一个元素(5),就可以将其传给下一个块(如果有的话)(6)。然后,就可以使用std::for_each以及lambad表达式(7)更新块内所有剩余元素。

如果previous_end_value指针为空,代表你是第1块,你只需要为下一块更新end_value(如果有的话)(8)。

最后,如果任何操作抛出异常,你捕获了它(9),并把它存在promise中(10),当下一个块试图取得上一块末尾元素值的时候就会得到这个异常(4)。这将会让所有的异常都被传递给最后一个块,最后一个块捕获到异常后只是重新抛出(11),因为你知道你运行在主线程(这里的主线程不是绝对的主线程,是指最初执行这个算法的线程)。

由于这个算法需要在线程间同步,因此它不适合用std::async重写。任务等待的结果需要其他线程的执行才能变得有效,因此所有任务必须并行运行。

由于是基于阻塞的,向前传递的方式已经偏离了并行的主要思想,让我们来看看下一种方式。

并行SUM的递增配对算法

第二种方式是使用递增的方式向前增加部分和,这样能够利用附加处理器进行步伐一致的运算处理。这种情况下,不需要进一步同步,因为所有的中间结果可以直接传递给下一个处理器。但是实际上,很少有这样的系统可以使用,除了单个处理器可以通过所谓的单指令/多数据(SIMD)指令同时在少量数据元素上执行相同指令的情况下。所以需要考虑通常情况,在每一步显示的同步线程。

为了做到这点,一种方法是使用栅栏(barrier)——一种同步机制,要求线程一直等待,直到足够多的线程到达了栅栏。一旦所有的线程都到达栅栏,它们就都变为非阻塞状态,继而开始处理。C++11线程库没有直接提供这种工具,因此需要自己编写。

想象一个游乐场中的过山车。如果有适量的游客在等待,那么游乐场的员工就要确保过山车座无虚席才会启动它。栅栏的工作方式一样:你指定了座位的数量,线程必须等待,直到座位已经被填满。一旦拥有足够多的等待线程,它们就都可以执行了;然后栅栏就被重置,并开始等待下一波线程。这种结构经常被用于循环中,同一个线程会循环等待下一次。现在的想法是,保持多个线程步伐一致,所以一个线程不会脱离步骤而跑到其他线程前面。否则,对于像这次实现的算法来说,那就是一场灾难,因为脱离步骤的线程会潜在的修改还被别的线程使用着的数据,或者使用了还没有被正确更新的数据。

下面的代码简单的实现了一个栅栏:

Listing 8.12 A simple barrier class

class barrier{unsigned const count;std::atomic<unsigned> spaces;std::atomic<unsigned> generation;public:explicit barrier(unsigned count_) : count(count_), spaces(count), generation(0)//(1){}void wait(){unsigned const my_generation = generation;//(2)if (!--spaces)//(3){spaces = count;//(4)++generation;//(5)}else{while (generation == my_generation)//(6)std::this_thread::yield();//(7)}}};

鉴于这个实现,你在构造了多个“座位”(1),它被保存在count变量中。初始时 spaces等于count。随着每个线程进入等待,spaces逐次递减(3)。当它达到0,spaces就被重新设置为count(4),递增generation以通知其他线程它们可以结束等待(5)。如果spaces递减后的数值没有到0,那么必须等待。这个实现使用了简单的旋转锁(6),检查generation的数值是否与开始执行wait()函数时得到的值(2)相同。只有当所有的线程都到达栅栏,generation才会被更新(5),而在等待时执行了yield()(7),所以线程不会在忙碌中等待CPU。

当我说这个实现简单是,我的意思是:它用了一个旋转等待,所以它对于线程可能等待很长时间的情况并不理想,而且如果在同一时刻有超过count个线程调用wait()那它就不能工作了。如果你想处理这两种情况,你必须使用更强壮的(也是更复杂的)实现来代替。而且,出于简单的目的,我还在原子变量上使用了顺序一致序列,但你可以释放一些顺序限制。在大规模并行结构中这样的全局同步的开销是很昂贵的,因为持有栅栏状态的缓存行必须在不同的处理器之间来回穿梭(请查看8.2.2节中的乒乓缓存),所以你必须十分谨慎,确保在这里它确实是最好的选择。

无论怎样,这里你需要一个栅栏;在步骤一致循环中的线程数是固定的。好吧,几乎是固定数目的线程。你可能会记得,前面的元素在几部之后就能得到最终结果。那就意味着,你要么保持所有线程一直循环知道整个范围都被处理完,要么需要栅栏来处理线程以及时抛弃多余线程,然后递减count。我选择第二种,因为那可以阻止先完成计算的线程做没必要的空循环,直到最后的步骤完成。

这就意味着你需要将count改为原子变量,这样不同线程都可以更新它而不用额外的同步手段:

std::atomic<unsigned> count;

初始化依然相同,但当你重新设置spaces时必须显示的对count使用load()(因为在std::atomic<>的定义中,原子变量的拷贝赋值运算符函数是显示删除的):

spaces=count.load();

现在你需要一个新的成员函数来递减count。起名为 done_waiting(),因为线程表明自己已经结束等待:

void done_waiting(){--count;//(1)if (!--spaces)//(2){spaces = count.load();//(3)++generation;}}

第一件事就是递减count,这样下次重置splaces时就能反映出新的更少的需要等待的线程数。然后需要递减splaces(2),否则,其他的线程就会永远等下去,因为spaces被初始化为旧值——更大的值。如果你是这一波线程中最后一个到达栅栏的线程,那么你需要重置spaces并且递增generation(3),就像wait()中做的那样。这里的关键的不同之处在于,如果你是这一波中最后一个到达栅栏的线程,那么你无需等待,你结束了所有的等待!

现在开始准备第二种方式的部分和实现。每一步,每个线程都在栅栏上调用wait(),保证整体逐步前进,一旦某个线程完成任务,就调用done_waiting()以递减count。如果你为原始的范围数据增加一个buffer,那么栅栏就可以为你提供所有的同步。在每一步,线程从原始范围或者buffer中读取数据,然后写到对应的其他元素上。如果在某一步,线程从原始数据中读取,那么下一步就从buffer中读取,反之亦然。这就确保了不同的线程在读和写之间不存在竞争条件。一旦一个线程完成了循环,它必须确保最后的值被正确的写到原始范围中。下面的代码列出了最终的实现:

Listing 8.13 A parallel implementation of  partial_sum by pairwise updates

struct barrier//这个栅栏可以动态调整count数量{std::atomic<unsigned> count;std::atomic<unsigned> spaces;std::atomic<unsigned> generation;barrier(unsigned count_) :count(count_), spaces(count_), generation(0){}void wait(){unsigned const gen = generation.load();if (!--spaces){spaces = count.load();++generation;}else{while (generation.load() == gen){std::this_thread::yield();}}}void done_waiting(){--count;if (!--spaces){spaces = count.load();++generation;}}};template<typename Iterator>void parallel_partial_sum(Iterator first, Iterator last){typedef typename Iterator::value_type value_type;struct process_element//(1){void operator()(Iterator first, Iterator last, std::vector<value_type>& buffer, unsigned i, barrier& b){value_type& ith_element = *(first + i);bool update_source = false;for (unsigned step = 0, stride = 1; stride <= i; ++step, stride *= 2){value_type const& source = (step % 2) ? buffer[i] : ith_element;//(2)value_type& dest = (step % 2) ? ith_element : buffer[i];value_type const& addend = (step % 2) ? buffer[i - stride] : *(first + i - stride);//(3)dest = source + addend;//(4)update_source = !(step % 2);b.wait();//(5)}if (update_source)//(6){ith_element = buffer[i];}b.done_waiting();//(7)}};unsigned long const length = std::distance(first, last);if (length <= 1)return;std::vector<value_type> buffer(length);barrier b(length);std::vector<std::thread> threads(length - 1);//(8)join_threads joiner(threads);Iterator block_start = first;//这个block_start并没有用到for (unsigned long i = 0; i<(length - 1); ++i){threads[i] = std::thread(process_element(), first, last, std::ref(buffer), i, std::ref(b));//(9)}process_element()(first, last, buffer, length - 1, b);//(10)}

工作由process_element函数对象来完成(1)。这次的不同之处关键在于,线程数依赖于元素个数而不是td::thread::hardware_concurrency 。我曾经说过,除非你有一个强大的并行系统,创建线程变的廉价,否则这也许是个糟糕的实现,但是它显示出了整体结构。它可能减少线程使用量,每个线程处理多个元素,但是当线程减少到一个点时,它的效率将不如前向传递算法。

在每一步,你都从原始数据或buffer中取出第i个元素(2),然后加上后退stride个位置的元素值(3),要么保存到原始数据中要么保存到buffer中(4)。然后在开始下一步之前在栅栏处等待(5)。如果stride >= i,那么就完成了操作,此时如果最终值保存在了buffer中,那就将其更新到原始值上(6)。最后你通知栅栏,你已经不再需要等待了,通过调用 done_waiting()(7)。

注意,这个方案并非异常安全。如果其中一个线程在执行process_element时抛出了一个异常,那将导致应用直接中断退出。你可以通过使用 std::promise来保存异常,就像在程序8.9中parallel_find算法所做的那样,或者直接使用一个mutex来保护一个std::exception_ptr

阅读全文
0 0
原创粉丝点击