《C++并发实例》 8.5 实践中设计并发代码
在为特定任务设计并发代码时,前面描述的每个问题需要考虑的程度取决于该任务。为了演示它们如何使用,我们将看看 C++ 标准库中三个函数的并行版实现。这将为您提供熟悉的构建基础,同时提供一个查看问题的平台。作为奖励,我们可以使用这些函数的实现,帮助完成更大的并行任务。
我选择这些实现主要是为了演示特定的技术,而不是为了成为最先进的实现;可以在有关并行算法的学术文献或专业多线程库(例如英特尔的线程构建模块 http://threadingbuildingblocks.org/ )中找到更好利用硬件并发性的高级实现。
从概念上讲,最简单的并行算法是 std::for_each 的并行版本,因此我们将从它开始。
8.5.1 std::for_each 的并行实现
std::for_each 概念很简单;它依次为队伍中的每个元素提供用户调用函数。并行实现和顺序 std::for_each 之间的最大区别是函数调用的顺序。 std::for_each 按顺序使用第一个元素调用函数,然后是第二个元素,依此类推,而并行实现时,无法保证处理元素的顺序,并且它们可能(实际上我们希望)被同时处理。
要实现此功能的并行版本,您只需将队伍划分为要在每个线程上处理的元素集。您提前知道元素的数量,因此可以在处理开始之前划分数据(第 8.1.1 节)。我们假设这是唯一运行的并行任务,因此您可以使用 std:🧵: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);
std::vector<std::thread> threads(num_threads-1); - [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 相同,这并不奇怪。关键区别在于因工作线程不返回值而使用 futures 容器存储 std::future
正如您可以使用 std::async 简化 std::accumulate 的并行实现一样, 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= - [2]
std::async(¶llel_for_each<Iterator,Func>,
first,mid_point,f);
parallel_for_each(mid_point,last,f); - [3]
first_half.get(); - [4]
}
}
与列表8.5中基于 std::async 的 parallel_accumulate 一样,您以递归方式分割数据而不是在执行前分割,因为您不知道该库将使用多少个线程。和以前一样,您在每个阶段将数据分成两半,一半异步运行 [2],另一半直接运行 [3],直到剩余数据太小而不值得分割,这种情况下您将使用 std::for_each [1]。再次说明, std::async 和 std::future 的 get() 成员函数 [4] 提供了异常传播语义。
让我们从必须对每个元素执行相同操作的算法(其中有几个;首先想到的是 std::count 和 std::replace)转向一个稍微复杂一点的形式:std::find 。
8.5.2 std::find 的并行实现
std::find 是接下来要考虑的一个有用的算法,因为它是无需处理每个元素即可完成的几种算法之一。例如,如果范围中的第一个元素与搜索条件匹配,则无需检查任何其他元素。正如您很快就会看到的,这是性能的一个重要属性,并且它对并行实现的设计有直接影响。这是数据访问模式如何影响代码设计的一个特定示例(第 8.3.2 节)。此类中的其他算法包括 std::equal 和 std::any_of。
如果你和你的妻子或伴侣在阁楼的纪念品盒中寻找一张旧照片,如果你找到了这张照片,你不会让他们继续寻找。相反,你应该让他们知道你找到了照片(也许可以大喊“找到了!”),这样他们就可以停止搜索并转向其他事情。许多算法的本质要求它们处理每个元素,因此它们没有相当于大喊“找到了!”的效果。对于像 std::find 这样的算法来说,“提前”完成的能力是一个重要的属性,并且不能轻视。因此,您需要设计代码来利用它——在知道结果时以某种方式中断其他任务,以便代码不必等待其他工作线程处理剩余的元素。
如果您不中断其他线程,串行版本可能会优于并行实现,因为串行算法可以在找到匹配项后停止搜索并返回。例如,如果系统可以支持四个并发线程,那么每个线程将必须检查队列中四分之一的元素,因此我们简单的并行实现将花费大约单个线程所需时间的四分之一。检查每个元素。如果匹配元素位于范围的前四分之一,则顺序算法将首先返回,因为它不需要检查其余元素。
中断其他线程的一种方法是使用原子变量作为标志,并在处理每个元素后检查该标志。如果设置了该标志,则其他线程之一已找到匹配项,因此您可以停止处理并返回。通过以这种方式中断线程,您可以保留不必处理每个值的属性,从而在更多情况下比串行版本提高性能。这样做的缺点是原子加载可能是缓慢的操作,因此这可能会阻碍每个线程的进度。
现在,关于如何返回值以及如何传播任何异常您有两种选择。您可以使用 futures 数组,使用 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(), - [11]
block_start,block_end,match,
&result,&done_flag);
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]
}
列表 8.9 的主体与前面的示例类似。这次,工作是在本地 find_element 类的调用函数 operator 中完成的 [1]。它循环遍历所给块中的元素,在一个步中检查标志 [2]。如果找到匹配,它会在promise 中设置最终结果值 [3],然后在返回之前设置done_flag [4]。
如果抛出异常,则会被 catch 捕获 [5],并且在设置 did_flag 之前尝试将异常存储在 promise 中 [6]。如果 promise 已经设置,则再设置 promise 的值可能会引发异常,因此您捕获并丢弃这里发生的所有异常 [7]。
这意味着,如果调用 find_element 的线程找到匹配项或引发异常,则所有其他线程将看到 donne_flag 设置并停止。如果多个线程同时找到匹配或抛出,它们将竞相在 promise 中设置结果。但这是一种良性的竞争条件;因此,无论哪个成功,名义上都是“第一”,因此是可接受的结果。
回到主parallel_find 函数本身,您有用于停止搜索的 promise [9] 和 flag [9],两者都与搜索队列一起传递到新线程 [11]。主线程还使用 find_element 来搜索剩余元素 [12]。正如已经提到的,您需要等待所有线程完成才能检查结果,因为可能没有任何匹配的元素。该操作可以通过将线程启动和加入代码封装在块中来实现 [10],因此当您检查标志以查看是否有匹配项时,所有线程已经加入 [13]。如果找到匹配项,您可以从 promise 中获得 std::future
同样,此实现假设您将使用所有可用的硬件线程,或者您有一些其他机制来确定用于线程之间预先划分工作的线程数。与以前一样,您可以使用 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)
{
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]
}
希望您尽早明白:如果你找到了一个匹配项,意味着你需要引入一个所有线程共享的 flag 来表示已找到。因此需要将其传递给所有递归调用。最简单的实现方法是委托给传入参数的实现函数 [1] —— 参数使用主入口传入的 flag 引用 [12]。
然后核心实现沿着熟悉的路线进行。与此处的许多实现一样,您设置要在单个线程最少处理元素数 [2];如果你不能清晰地分割出至少两块,你就在当前线程上运行所有内容 [3]。实际的算法是通过指定队列的简单循环,循环直到队列的末尾或设置完成标志 (done flag) [4]。如果确实找到匹配项,则在返回之前设置完成标志 [5]。如果您因为到达队列末尾或因为另一个线程设置了完成标志而停止搜索,则返回 last 以表示未找到匹配项 [6]。
如果队列可以划分,则首先找到中点 [7],然后使用 std::async 在队列的后半部分执行搜索 [8],并小心使用 std::ref 传递完成标志的引用。同时,您可以通过直接递归调用执行前半部分的搜索 [9]。如果原始队列足够大,异步调用和直接递归都可能需要进一步细分。
如果直接搜索返回的是mid_point,那么就没有找到匹配项,所以需要获取异步搜索的结果。如果在那一半中没有找到结果,则结果将是 last,这是表示未找到正确返回值 [10]。如果“异步”调用被延迟 (deferred) 而不是真正异步,那么它实际上会在 get() 调用中运行;在这种情况下,如果下半部分的搜索成功,则将跳过范围上半部分的搜索。如果异步搜索确实在另一个线程上运行,那么 async_result 变量的析构函数将等待线程完成,因此不会有任何线程泄漏。
和以前一样,std::async 的使用为您提供了异常安全和异常传播功能。如果直接递归抛出异常,future 的析构函数将确保运行异步调用的线程在函数返回之前已终止,如果异步调用抛出异常,则异常将通过 get() 调用传播 [10]。在整个过程中使用 try/catch 代码只是为了在异常上设置完成标志,并确保在抛出异常时所有线程快速终止 [11]。如果没有它,实现仍然是正确的,但会继续检查元素,直到每个线程完成。
该算法的两种实现与您见过的其他并行算法共同的一个关键点是,不再保证按照从 std::find 获取的顺序处理节点。如果您要并行化算法,这一点至关重要。如果顺序很重要,则无法同时处理元素。如果元素是独立的,那么对于像parallel_for_each这样的东西来说并不重要,但这意味着你的 parallel_find 可能会返回一个靠近队列末尾的元素,即使有靠近开头的匹配,如果你没有预期这样的结果,会感到很惊讶。
好的,您已经成功并行化 std::find。正如我在本节开头所述,还有其他类似的算法可以在不处理每个数据元素的情况下完成,并且可以使用相同的技术。我们还将在第 9 章中进一步讨论中断线程的问题。
为了完成我们的三个示例,我们将换个方向并查看 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 并且你将其分成三个块, 在第一个实例中你会得到 {1, 3, 6}, {4, 9, 15}, {7, 15, 24}。如果您随后将 6(第一个块中最后一个元素的总和)添加到第二个块中的元素上,则会得到 {1, 3, 6}, {10, 15, 21}, {7, 15, 24} 。然后,将第二个块的最后一个元素 (21) 添加到第三个也是最后一个块中的元素上,以获得最终结果:{1, 3, 6}、{10, 15, 21}、{28, 36, 55 }。
除了原始的块划分之外,前一个块的部分和的相加也可以并行化。如果首先更新每个块的最后一个元素,则一个线程可以更新块中的其余元素,同时第二个线程更新下一个块,依此类推。当列表中的元素比处理核心多时,这种方法很有效,因为每个核心在每个阶段都有合理数量的要处理的元素。
如果您有很多处理核心(与元素数量一样多或更多),则效果不太好。如果您将工作分配给处理器,那么您最终会在第一步中的元素格式进行工作。在这些条件下,这种结果的前向传播意味着许多处理器处于等待状态,因此您需要为它们找到一些要执行的工作。然后您可以采取不同的方法来解决问题。您不需要将总和从一个块到下一个块进行完全前向传播,而是进行部分传播:首先像以前一样对相邻元素求和,然后将这些总和加到间隔为二的元素上,然后将下一组结果添加到间隔为四的元素上,依此类推。如果您从相同的九个初始元素开始,则第一轮后您将得到 1, 3, 5, 7, 9, 11, 13, 15, 17,您得到了前两个元素的最终结果。第二轮之后,您将得到 1、3、6、10、14、18、22、26、30,这对于前四个元素是正确的。在第三轮之后,您有 1, 3, 6, 10, 15, 21, 28, 36, 44,这对于前八个元素是正确的,最后在第四轮之后,您有 1, 3, 6, 10, 15, 21 、28、36、45,这是最终答案。尽管总步骤比第一种方法更多,但如果您有许多处理器,则并行性的范围更大;每个处理器可以在每个步骤中更新一个条目。
总体而言,第二种方法对于N 个操作(每个处理器一个)需要 log2(N) 步骤,其中 N 是列表中的元素数量。与第一个算法相比,第一个算法中每个线程必须对分配给它的块的初始部分和执行 N/k 操作,然后进一步执行 N/k 操作来进行前向传播,其中 k 是线程数。因此,就操作总数而言,第一种方法是 O(N),而第二种方法是 O(N log(N))。然而,如果你有与列表元素一样多的处理器,第二种方法只需要每个处理器执行 log(N) 次操作,而第一种方法本质上是在 k 变大时执行序列化操作,因为前向传播。因此,对于少量处理单元,第一种方法将更快地完成,而对于大规模并行系统,第二种方法将更快地完成。这是第 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] 之外,还有一个 promise 容器 [14],用于存储块中最后一个元素的值,以及一个 futures 容器 [15],用于检索前一个块中最后一个值。您可以为 futures [16] 保留空间,以避免在生成线程时重新分配,因为您知道将拥有多少个线程。
主循环与之前相同,只是这次您实际上想要指向每个块中最后一个元素的迭代器,而不是普通的传递列尾 [17],这样您就可以对每一个队列的最后一个元素进行前向传播。实际处理是在 process_chunk 函数对象中完成的,我们很快就会看到;该块的开始和结束迭代器作为参数与前一个队列的最终值(如果有)的 future 以及保存该队列最终值的 promise 一起传入 [18]。
生成线程后,您可以更新 block_start ,记住在进到最后一个元素之前 [19],并将当前块中最后一个值的 future 存储到 future 容器中,以便下个循环再次取出 [10]。
在处理最后一个块之前,您需要获取最后一个元素的迭代器 [21],您可以将其传递给 process_chunk [22]。 std::partial_sum 不返回值,因此在处理完最终块后您无需执行任何操作。一旦所有线程都完成,操作就完成了。
好的,现在是时候查看实际上完成所有工作的 process_chunk 函数对象了 [1]。您首先为整个块(包括最终元素 [2])调用 std::partial_sum ,但随后您需要知道您是否是是第一个块 [3].如果你不是第一个块,那么前一个块有一个 previous_end_value ,所以你需要等待那它 [4]。为了最大化算法的并行性,您首先更新最后一个元素 [5],这样您就可以将该值传递给下一个块(如果有)[5]。完成此操作后,您可以使用 std::for_each 和一个简单的 lambda 函数 [7] 来更新队列内的所有剩余元素。
如果没有 previous_end_value,则您是第一个块,因此您可以只更新下一个块的 end_value(同样,如果有的话,您可能是唯一的块)[8]。
最后,如果任何操作抛出异常,您将捕获它 [9] 并将其存储在 promise [10] 中,这样在尝试获取前一个最终值 [4] 时,它将传播到下一个块。这会将所有异常传播到最终块中,然后仅重新抛出 [11],因为您知道您正在主线程上运行。
由于线程之间的同步,该代码不容易用 std::async 重写。这些任务等待其他任务执行过程中可用的结果,因此所有任务必须同时运行。
介绍完基于块的前向传播方法后,让我们看看第二种计算队列部分和的方法。
实现部分和的增量成对算法
通过添加越来越远的元素来计算部分和的第二种方法在您的处理器可以同步执行加法的情况下效果最好。在这种情况下,不需要进一步同步,因为所有中间结果都可以直接传播到需要它们的下一个处理器。但实际上很少有系统可以这样使用,除了单个处理器可以同时对少量数据元素执行相同的指令,这被称为单指令/多数据(Single-Instruction/Multiple-Data 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_): - [1]
count(count_),spaces(count),generation(0)
{}
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 变量中。最初,屏障处的空间数量等于该计数。当每个线程等待时,空格数会递减 [3]。当它达到零时,空格数将重置为 count [4],并且增加 generation 以向其他线程发出信号通知它们继续 [5]。如果可用空间数量未达到零,则必须等待。此实现使用一个简单的自旋锁 [6],比较 generation 和 wait() 开头 [2] 获取的值。因为只有当所有线程都到达屏障时才会更新生成 [5],等待时使用yield() [7],所以等待线程就不会在忙等待中占用 CPU。
当我说这个实现很简单时,我的意思是:它使用自旋等待,因此对于线程可能等待很长时间的情况来说它并不理想,并且如果有超过 count 个线程可能随时调用 wait(),那么它就不起作用。如果您需要处理其中任何一种情况,则必须使用更健壮(但更复杂)的实现。我还坚持对原子变量进行顺序一致的操作,因为这使得一切都更容易推理,但您可能会放宽一些排序约束。这种全局同步在大规模并行架构上是昂贵的,因为保持屏障状态的缓存行必须在所有涉及的处理器之间穿梭(参见第 8.2.2 节中缓存乒乓的讨论),因此您必须非常小心以确保这确实是这里最好的选择。
无论如何,这正是您所需要的;您有固定数量的线程需要在循环中同步运行。嗯,它几乎是固定数量的线程。您可能还记得,列表开头的元素会在几个步骤后获得最终值。这意味着要么必须让这些线程保持循环,直到处理完整个范围,要么需要允许屏障处理线程丢失,从而减少 count。我选择了后一个选项,因为它避免了线程执行不必要的工作,只是循环直到最后一步完成。
这意味着您必须将 count 更改为原子变量,以便您可以从多个线程更新它,而无需外部同步:
std::atomic<unsigned> count;
初始化保持不变,但现在在重置空格数时必须从 count 中显式 load() :
spaces=count.load();
这些是您需要在 wait() 前进行的所有更改;现在您需要一个新的成员函数来减少统计。我们称其为done_waiting(),因为线程声明它已等待完成:
void done_waiting()
{
--count; - [1]
if(!--spaces) - [2]
{
spaces=count.load(); - [3]
++generation;
}
}
您要做的第一件事是递减 count [1],以便重置下一次 space 重新设置它以表示更少的等待线程数。然后你需要减少可用 spece 的数量 [2]。如果你不这样做,其他线程将永远等待,因为 space 被初始化为旧的、更大的值。如果您是该批次中的最后一个线程,则需要重置计数器并增加 generation [3],就像在 wait() 中所做的那样。这里的关键区别在于,如果您是批次中的最后一个线程,则不必等待。等待这么久你终于完成了!
现在您已准备好编写部分求和的第二个实现。在每个步骤中,每个线程都会在屏障上调用 wait() 以确保线程一起单步执行,并且一旦每个线程完成,它就会在屏障上调用 did_waiting() 来减少 count。如果您在初始队列旁边使用第二个缓冲区,则屏障将提供您所需的所有同步。在每个步骤中,线程从初始队列或缓冲区中读取数据,并将新值写入另一个的相关元素。如果线程在一个步骤中从初始队列读取,则它们在下一步从缓冲区中读取,反之亦然。这确保了单独线程的读取和写入之间不存在竞争条件。一旦线程完成循环,它必须确保正确的最终值已写入初始队列。下面的列表将所有这些整合在一起。
Listing 8.13 A parallel implementation of partial_sum by pairwise updates
struct barrier
{
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)? - [2]
buffer[i]:ith_element;
value_type& dest=(step%2)?
ith_element:buffer[i];
value_type const& addend=(step%2)? - [3]
buffer[i-stride]:*(first+i-stride);
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;
for(unsigned long i=0;i<(length-1);++i)
{
threads[i]=std::thread(process_element(),first,last, - [9]
std::ref(buffer),i,std::ref(b));
}
process_element()(first,last,buffer,length-1,b); - [10]
}
现在这段代码的整体结构可能已经变得熟悉了。您有一个带有函数调用运算符 (process_element) 的类来执行工作 [1],从主线程上 [10 ]调用存储在容器 [8] 中的一组线程 [9] 来执行操作。这次的关键区别在于,线程数取决于列表中的项目数,而不是 std:: thread::hardware_concurrency。正如我已经说过的,除非您使用的是线程成本低廉的大规模并行计算机,否则这可能是一个坏主意,但它显示了整体结构。可以有更少的线程,每个线程处理源队列中的多个值,但是会出现这样的情况:线程过少导致比前向传播算法的效率要低。
不管怎样,关键的工作是在process_element的函数调用操作符中完成的。在每个步骤中,您要么从初始队列中获取第 i 个元素,要么从缓冲区中获取第 i 个元素 [2],并加上向前 stride 步长的元素值 [3],然后将值保存在缓冲区中,如果从缓冲区中取的值则保存在初始队列中 [4]。然后,您在开始下一步之前等待障碍 [5]。在 stride 超出队列范围时结束,在这种情况下,如果您的最终结果存储在缓冲区中,则需要更新原始范围中的元素 [6]。最后,你告诉屏障你已经 down_waiting() [7]。
请注意,该解决方案不是异常安全的。如果工作线程之一的 process_element 中抛出异常,它将终止应用程序。你可以通过使用 std::promise 来存储异常来处理这个问题,就像列表 8.9 中的 parallel_find 实现的那样,或者甚至只使用由互斥体保护的 std::exception_ ptr 。
我们的三个例子到此结束。希望它们能够帮助明确第 8.1、8.2、8.3 和 8.4 节中强调的一些设计注意事项,并演示如何在实际代码中应用这些技术。
8.6 总结
我们在本章中涵盖了相当多的内容。我们从各种在线程之间划分工作的技术开始,例如预先划分数据或使用多个线程形成管道。然后,我们从底层角度研究了围绕多线程代码性能的问题,首先研究了错误共享和数据争用,然后再讨论数据访问模式如何影响代码的性能。然后,我们研究了并发代码设计中的其他注意事项,例如异常安全性和可扩展性。最后,我们提供了许多并行算法实现的示例,每个示例都强调了设计多线程代码时可能出现的特定问题。
本章中多次出现的一个项目是线程池的概念——一组预配置的线程,运行分配给该池的任务。设计一个好的线程池需要花费很多心思,因此我们将在下一章中讨论一些问题,以及高级线程管理的其他方面。