线程间划分工作的技术
决定使用多少个线程,并且这些线程应该去做什么。还需要决定是使用“全能”的线程去完成所有的任务,还是使用“专业”线程只去完成一件事情,或将两种方法混合。使用并发的时候,需要作出诸多选择来驱动并发,这里的选择会决定代码的性能和清晰度。因此,这里的选择至关重要,所以在你设计应用程序的结构时,再作出适当的决定。
在线程处理前对数据进行划分
使用过MPI(Message Passing Interface)和OpenMP的人对这个结构一定很熟悉:一项任务被分割成多个,放入一个并行任务集中,执行线程独立的执行这些任务,结果在会有主线程中合并。一种方式是使用递归算法(递减操作)。
递归划分
使用栈的并行快速排序算法——等待数据块排序:
#include "threadsafe_stack.h"//省略部分头文件
template<typename T>
struct sorter // 1
{
struct chunk_to_sort
{
std::list<T> data;
std::promise<std::list<T> > promise;
};
threadsafe_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()
{
// 7 从栈上弹出一个数据块
std::shared_ptr<chunk_to_sort > chunk = chunks.pop();
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 = // 10 对数据进行划分
std::partition(chunk_data.begin(), chunk_data.end(),
[&](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);
// 13 因为小于部分的数据块可能由其他线程进行处理,那么就得等待这个线程完成
while (new_lower.wait_for(std::chrono::seconds(0)) !=
std::future_status::ready) //等待低值完成
{
// 14 当线程处于等待状态时,就让当前线程尝试处理栈上的数据
try_sort_chunk();
}
result.splice(result.begin(), new_lower.get());
//从期望中得到数据list<T>
return result;
}
void sort_chunk(std::shared_ptr<chunk_to_sort> const& chunk)
{
// 15 结果存在promise中,让线程对已经存在于栈上的数据块进行提取
chunk->promise.set_value(do_sort(chunk->data));
}
void sort_thread()
{
while (!end_of_data) // 16 未被设置时
{
try_sort_chunk();
// 17 新生成的线程还在尝试从栈上获取需要排序的数据块
std::this_thread::yield();
/*18 在循环检查中,也要给其他线程机会,
*可以从栈上取下数据块进行更多的操作。
*Provides a hint to the implementation to
*reschedule the execution of threads,
*allowing other threads to run.
*/
}
}
};
template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input) // 19
{
if (input.empty())
{
return input;
}
sorter<T> s;
// 20 当所有数据都已经排序完成,do_sort将会返回
return s.do_sort(input);
}
int main()
{
std::list<int> l{ 35,3,4,44,66,22,11,222,333,55,1,0,9,6,
35,3,4,44,66,22,11,222,333,55,1,0,9,6 };
auto r = parallel_quick_sort(l);//18ms
for (auto &im : r)
{
std::cout << im << std::endl;
}
system("pause");
return 0;
}
上面程序使用的线程安全栈(无锁栈的使用还在摸索中):
#ifndef _THREADSAFE_STACK_H
#define _THREADSAFE_STACK_H
template<typename T>
class threadsafe_stack//读书笔记5
{
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack() {}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data = other.data;
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(std::move(new_value)); // 1
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if (data.empty()) return std::make_shared<T>();
// 2 修改了异常处理做法,返回空指针代替抛出异常
std::shared_ptr<T> const res(
std::make_shared<T>(std::move(data.top()))); // 3
data.pop(); // 4
return res;
}
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if (data.empty()) throw empty_stack();
value = std::move(data.top()); // 5
data.pop(); // 6
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
#endif // !_THREADSAFE_STACK_H
该方案制约线程数量的值就是std::thread::hardware_concurrency()
的值,这样就能避免任务过于频繁的切换。不过,这里还有两个问题:线程管理和线程通讯。要解决这两个问题就要增加代码的复杂程度。虽然,线程对数据项是分开处理的,不过所有对栈的访问,都可以向栈添加新的数据块,并且移出数据块以作处理。这里重度的竞争会降低性能(即使使用无锁(无阻塞)栈)。
这个方案使用到了一个特殊的线程池——所有线程的任务都来源于一个等待链表,然后线程会去完成任务,完成任务后会再来链表提取任务。这个线程池很有问题(包括对工作链表的竞争)。几种划分方法:1,处理前划分;2,递归划分(都需要事先知道数据的长度固定),还有上面的那种划分方式。事情并非总是这样好解决;当数据是动态生成,或是通过外部输入,那么这里的办法就不适用了。在这种情况下,基于任务类型的划分方式,就要好于基于数据的划分方式。
通过任务类型划分工作
每个线程只需要关注自己所要做的事情即可。其本身就是基本良好的设计,每一段代码只对自己的部分负责。
分离关注:多线程下有两个危险需要分离关注。一个是对错误担忧的分离,主要表现为线程间共享着很多的数据,或者是不同的线程要相互等待。这两种情况都是因为线程间很密切的交互。当这种情况发生,就需要看一下为什么需要这么多交互。当所有交互都有关于同样的问题,就应该使用单线程来解决,并将引用同一原因的线程提取出来。或者,当有两个线程需要频繁的交流,且没有其他线程时,那么就可以将这两个线程合为一个线程。当通过任务类型对线程间的任务进行划分时,不应该让线程处于完全隔离的状态。当多个输入数据集需要使用同样的操作序列,可以将序列中的操作分成多个阶段,来让每个线程执行。
划分任务序列:当任务会应用到相同操作序列,去处理独立的数据项时,就可以使用流水线(pipeline)系统进行并发。这样可以为流水线中的每一阶段操作创建一个独立线程。
影响并发代码性能的因素
在多线程代码中有很多因素会影响性能——对线程处理的数据做一些简单的改动(其他不变),都可能对性能产生戏剧性的效果。
有多少个处理器?
处理器个数是影响多线程应用的首要因素。当多于机器支持线程数在运行的时候(都没有阻塞或等待),应用将会浪费处理器的运算时间在线程间进行切换。这种情况发生时,我们称其为超额认购(oversubscription)。根据前面,我们可以知道使用std::thread::hardware_concurrency()
函数就能知道在给定硬件上可以扩展的线程数量了。但是要谨慎使用,因为它不会考虑其它运行在系统上的线程(除非已经将系统信息进行共享)。最坏的情况就是,多线程同时调用 std::thread::hardware_concurrency()
函数来对线程数量进行扩展,这样将导致庞大的超额认购。std::async()
就能避免这个问题,因为标准库会对所有的调用进行适当的安排。同样,谨慎的使用线程池也可以避免这个问题。
不过,即使你已经考虑到所有在应用中运行的线程,程序还要被同时运行的其他程序所影响。这里的一种选择是使用与std::async()
类似的工具,来为所有执行异步任务的线程的数量做考虑;另一种选择就是,限制每个应用使用的处理芯个数。理想算法可能会取决于问题规模与处理单元的比值。大规模并行系统中有很多的处理单元,算法可能就会同时执行很多操作,让应用更快的结束;这就要快于执行较少操作的平台,因为该平台上的每一个处理器只能执行很少的操作。随着处理器数量的增加,另一个问题就会来影响性能:多个处理器尝试访问同一个数据。
数据争用与乒乓缓存
当两个线程并发的在不同处理器上执行,并且对同一数据进行读取,通常不会出现问题;因为数据将会拷贝到每个线程的缓存中,并且可以让两个处理器同时进行处理。不过,当有线程对数据进行修改的时候,这个修改需要更新到其他核芯的缓存中去,就要耗费一定的时间。根据线程的操作性质,以及使用到的内存序,这样的修改可能会让第二个处理器停下来,等待硬件内存更新缓存中的数据。即便是精确的时间取决于硬件的物理结构,不过根据CPU指令,这是一个特别特别慢的操作,相当于执行成百上千个独立指令。如果处理器很少需要互相等待,那么这种情况就是低竞争(low contention)。反之称为高竞争(high contention)。
数据在每个缓存中传递若干次叫做乒乓缓存(cache pingpong),当一个处理器因为等待缓存转移而停止运行时,这个处理器就不能做任何事情,所以对于整个应用来说,这就是一个坏消息。
随着处理器对数据的访问次数增加,对于互斥量的竞争就会增加,并且持有互斥量的缓存行将会在核芯中进行转移,因此会增加不良的锁获取和释放次数。有一些方法可以改善这个问题,其本质就是让互斥量对多行缓存进行保护,不过这样的互斥量需要自己去实现。如果乒乓缓存是一个糟糕的现象,那么该怎么避免它呢?在后面,答案会与提高并发潜能的指导意见相结合:减少两个线程对同一个内存位置的竞争。虽然,要实现起来并不简单。即使给定内存位置被一个线程所访问,可能还是会有乒乓缓存的存在,是因为另一种叫做伪共享(false sharing)的效应。
伪共享
处理器缓存通常不会用来处理在单个存储位置,但其会用来处理称为缓存行(cache lines)的内存块。假设你有一个int类型的数组,并且有一组线程可以访问数组中的元素,且对数组的访问很频繁(包括更新)。通常int类型的大小要小于一个缓存行,同一个缓存行中可以存储多个数据项。因此,即使每个线程都能对数据中的成员进行访问,硬件缓存还是会产生乒乓缓存。每当线程访问0号数据项,并对其值进行更新时,缓存行的所有权就需要转移给执行该线程的处理器,这仅是为了让更新1号数据项的线程获取1号线程的所有权。缓存行是共享的(即使没有数据存在),因此使用伪共享来称呼这种方式。这个问题的解决办法就是对数据进行构造,让同一线程访问的数据项存在临近的内存中(就像是放在同一缓存行中),这样那些能被独立线程访问的数据将分布在相距很远的地方,并且可能是存储在不同的缓存行中。在本章接下来的内容中看到,这种思路对代码和数据设计的影响。
如何让数据紧凑?
伪共享发生的原因:某个线程所要访问的数据过于接近另一线程的数据,另一个是与数据布局相关的陷阱会直接影响单线程的性能。
任务切换(task switching)。如果系统中的线程数量要比核芯多,每个核上都要运行多个线程。这就会增加缓存的压力,为了避免伪共享,努力让不同线程访问不同缓存行。因此,当处理器切换线程的时候,就要对不同内存行上的数据进行重新加载(当不同线程使用的数据跨越了多个缓存行时),而非对缓存中的数据保持原样(当线程中的数据都在同一缓存行时)。
当有超级多的线程准备运行时(非等待状态),任务切换问题就会频繁发生。这个问题我们之前也接触过:超额认购(oversubscription)。
超额认购和频繁的任务切换
如果有很多额外线程,就会有很多线程准备执行,而且数量远远大于可用处理器的数量,不过操作系统就会忙于在任务间切换,以确保每个任务都有时间运行。
为多线程性能设计数据结构
当为多线程性能而设计数据结构的时候,需要考虑竞争(contention),伪共享(false sharing)和数据距离(data proximity)。这三个因素对于性能都有着重大的影响,并且你通常可以改善的是数据布局,或者将赋予其他线程的数据元素进行修改。
为复杂操作划分数组元素
对于一个数组来说,访问连续的元素是最好的方式,因为这将会减少缓存的使用,并且降低伪共享的概率。当然,如果空间已经被N个元素所占有,且N个元素也就是每个缓存行上具体的存储元素数量,就会让伪共享的情况消失,因为线程将会对独立缓存行上的数据进行操作。
另一方面,因为矩阵是以行连续的方式存储,那么现在可以以N行的方式访问所有的元素。如果再次选择相邻行,这就意味着线程现在只能写入N行,这里就有不能被其他线程所访问的连续内存块。那么让线程对每组列进行处理就是一个改进,因为伪共享只可能有在一个内存块的最后几个元素和下一个元素的开始几个上发生,
第三个选择——将矩阵分成小矩阵块?这可以看作先对列进行划分,再对行进行划分。因此,划分列的时候,同样有伪共享的问题存在。如果你可以选择内存块所拥有行的数量,就可以有效的避免伪共享;将大矩阵划分为小块,对于读取来说是有好处的:就不再需要读取整个源矩阵了。
根据文中计算分析,将矩阵分成小块或正方形的块,要比使用单线程来处理少量的列好的多。当然,可以根据源矩阵的大小和处理器的数量,在运行时对块的大小进行调整。和之前一样,当性能是很重要的指标,就需要对目标架构上的各项指标进行测量。
最后,同样的原理可以应用于任何情况,这种情况就是有很大的数据块需要在线程间进行划分;仔细观察所有数据访问的各个方面,以及确定性能问题产生的原因。各种领域中,出现问题的情况都很相似:改变划分方式就能够提高性能,而不需要对基本算法进行任何修改。
其他数据结构中的数据访问模式
根据上面对数组的探讨,优化方式总结如下:
- 尝试调整数据在线程间的分布,就能让同一线程中的数据紧密联系在一起。
- 尝试减少线程上所需的数据量。
- 尝试让不同线程访问不同的存储位置,以避免伪共享。
文中阐述了互斥量相关的问题(由探讨二叉树访问模式延伸):互斥锁是当做一个“读-改-写”原子操作实现的,对于相同位置的操作都需要先获取互斥量,如果互斥量已锁,那就会调用系统内核。这种“读-改-写”操作,可能会让数据存储在缓存中,让线程获取的互斥量变得毫无作用。从目前互斥量的发展来看,这并不是个问题;线程不会直到互斥量解锁,才接触互斥量。不过,当互斥量共享同一缓存行时,其中存储的是线程已使用的数据,这时拥有互斥量的线程将会遭受到性能打击,因为其他线程也在尝试锁住互斥量。
一种测试伪共享问题的方法是:对大量的数据块填充数据,让不同线程并发的进行访问。如下几种方式(测试互斥量竞争或用来测试数组数据中的伪共享):
如果这样能够提高性能,你就能知道伪共享在这里的确存在。//1 struct protected_data { std::mutex m; char padding[65536]; // 65536字节已经超过一个缓存行的数量级 my_data data_to_protect; }; //2 struct my_data { data_item1 d1; data_item2 d2; char padding[65536]; }; my_data some_array[256];
设计并发代码的注意事项
虽然,已经有了很多设计并发代码的内容。你还需要考虑很多事情,比如异常安全和可扩展性。随着系统中核数的增加,性能越来越高(无论是在减少执行时间,还是增加吞吐率),这样的代码称为“可扩展”代码。理想状态下,性能随着核数的增加线性增长,也就是当系统有100个处理器时,其性能是系统只有1核时的100倍。
虽然,非扩展性代码依旧可以正常工作——单线程应用就无法扩展——例如,异常安全是一个正确性问题。如果你的代码不是异常安全的,最终会破坏不变量,或是造成条件竞争,亦或是你的应用意外终止,因为某个操作会抛出异常。并行算法中的异常安全
异常安全是衡量C++代码一个很重要的指标,并发代码也不例外。在串行算法中,异常可以传给调用者解决或是根据其本身进行处理。在并行算法中很多操作要运行在独立的线程上。在这种情况下,异常就不再允许被传播,因为这将会使调用堆栈出现问题。如果一个函数在创建一个新线程后带着异常退出,那么这个应用将会终止。
此次,作为一个具体的例子,书中对parallel_accumulate
函数进行了剖析。
std::accumulate
的原始并行版本(读书笔记1):
当在(7)处创建了第一个线程,如果再抛出异常,就会出问题的;对于新的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( // 7 异常可能发生点 accumulate_block<Iterator, T>(), block_start, block_end, std::ref(results[i])); 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 }
std::thread
对象将会销毁,程序将调用std::terminate
来中断程序的运行。使用std::terminate
的地方,可不是什么好地方。accumulate_block(9)的调用就可能抛出异常,就会产生和上面类似的结果;线程对象将会被销毁,并且调用std::terminate
。另一方面,最终调用std::accumulate
(10)可能会抛出异常,不过处理起来没什么难度,因为所有的线程在这里已经汇聚回主线程了。上面只是对于主线程来说的,不过还有很多地方会抛出异常:对于调用accumulate_block的新线程来说就会抛出异常(1)。没有任何catch块,所以这个异常不会被处理,并且当异常发生的时候会调用std::terminater()
来终止应用的运行。也许这里的异常问题并不明显,不过这段代码是非异常安全的。
添加异常安全。在读书笔记3中已经做过如下工作:如果你仔细的了解过新线程用来完成什么样的工作,要返回一个计算的结果的同时,允许代码产生异常。这可以将std::packaged_task
和std::future
相结合,来解决这个问题。如果使用std::packaged_task
重新构造代码,代码可能会是如下模样。
使用std::packaged_task
的并行std::accumulate
:
这样,一个问题就已经解决:在工作线程上抛出的异常,可以在主线程上抛出。如果不止一个工作线程抛出异常,那么只有一个能在主线程中抛出,不过这不会有产生太大的问题。如果这个问题很重要,你可以使用类似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 使用futures向量为每个新生线程存储 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<> 对一个函数或可调用对象,绑定一个期望。 当 std::packaged_task<>对象被调用,它就会调用相关函数或可调用对象, 将期望状态置为就绪,返回值也会被存储为相关数据。*/ std::packaged_task<T(Iterator, Iterator)> task( // 4 accumulate_block<Iterator, T>()); //<>填写传入传出参数,task初始化为一个函数 futures[i] = task.get_future(); // 5 从任务中获取future threads[i] = std::thread(std::move(task), block_start, block_end); // 6 将需要处理的数据块的开始和结束信息传入,让新线程去执行这个任务 block_start = block_end; } /*当任务执行时,future将会获取对应的结果,以及任何抛出的异常。 如果相关任务抛出一个异常,那么异常就会被future捕捉到, 并且使用get()的时候获取数据时,这个异常会再次抛出。*/ 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 累加每个future上的值 } result += last_result; // 10 加上最后一块数据 return result; }
std::nested_exception
(嵌套异常) 来对所有抛出的异常进行捕捉。剩下的问题就是,当生成第一个新线程和当所有线程都汇入主线程时,抛出异常;这样会让线程产生泄露。最简单的方法就是捕获所有抛出的线程,汇入的线程依旧是joinable()
的,并且会再次抛出异常:
现在好了,无论线程如何离开这段代码,所有线程都可以被汇入。出现重复代码: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;//再次抛出,解决异常,确保正确填充result }
std::for_each(threads.begin(),threads.end(),std::mem_fn(&std::thread::join));
现在让我们来提取一个对象的析构函数;毕竟,析构函数是C++中处理资源的惯用方式。看一下你的类:
异常安全版 std::accumulate: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(); } } };
不再需要显式的将线程进汇入。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; }
std::async()
的异常安全。当需要显式管理线程的时候,需要代码是异常安全的。
异常安全并行版std::accumulate
——使用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>, // 4 异步任务 first, mid_point, init); T second_half_result = parallel_accumulate(mid_point, last, T()); // 5 第二半的数据是通过直接的递归调用来处理 return first_half_result.get() + second_half_result; // 6 和相加 } }
std::async
的调用能够充分的利用硬件线程,并且不会产生线程的超额认购,一些“异步”调用是在调用get()
(6)后同步执行的。优雅的地方,不仅在于利用硬件并发的优势,并且还能保证异常安全。如果有异常在递归调用(5)中抛出,通过调用std::async
(4)所产生的“期望”,将会在异常传播时被销毁。这就需要依次等待异步任务的完成,因此也能避免悬空线程的出现。另外,当异步任务抛出异常,且被future所捕获,在对get()
(6)调用的时候,future中存储的异常,会再次抛出。可扩展性和Amdahl定律
扩展性(scalability)代表了应用利用系统中处理器执行任务的能力。一种简化的方式就是就是将程序划分成“串行”部分和“并行”部分。串行部分:只能由单线程执行一些工作的地方。并行部分:可以让所有可用的处理器一起工作的部分。当在多处理系统上运行你的应用时,“并行”部分理论上会完成的相当快,因为其工作被划分为多份,放在不同的处理器上执行。“串行”部分则不同,还是只能一个处理器执行所有工作。这样(简化)假设下,就可以对随着处理数量的增加,估计一下性能的增益:当程序“串行”部分的时间用fs来表示,那么性能增益(P)就可以通过处理器数量(N)进行估计:
这就是Amdahl定律,在讨论并发程序性能的时候都会引用到的公式。该定律明确了,对代码最大化并发可以保证所有处理器都能用来做有用的工作。如果将“串行”部分的减小,或者减少线程的等待,就可以在多处理器的系统中获取更多的性能收益。或者,当能提供更多的数据让系统进行处理,并且让并行部分做最重要的工作,就可以减少“串行”部分,以获取更高的性能增益。
扩展性:当有更多的处理器加入时,减少一个动作的执行时间,或在给定时间内做更多工作。有时这两个指标是等价的(如果处理器的速度相当快,那么就可以处理更多的数据),有时不是。选择线程间的工作划分的技术前,辨别哪些方面是能否扩展的就十分的重要。使用多线程隐藏延迟
在实际应用中,线程会经常因为等待某些事情而阻塞。当知道一些线程需要像这样耗费相当一段时间进行等待时,可以利用CPU的空闲时间去运行一个或多个线程。同之前一样,这也是一种优化,对修改(线程数量)前后性能的测量很重要;优化的线程数量高度依赖要完成工作的先天属性,以及等待时间所占的百分比。应用可能不用额外的线程,而使用CPU的空闲时间。在其他情况下,当一个线程等待其他线程去执行一个操作时,比起阻塞,不如让阻塞线程自己来完成这个操作。在一个极端的例子中,当一个线程等待一个任务完成,并且这个任务还没有被其他任何线程所执行时,等待线程就可以执行这个任务,或执行另一个不完整的任务。比起添加线程数量让其对处理器进行充分利用,有时也要在增加线程的同时,确保外部事件被及时的处理,以提高系统的响应能力。使用并发提高响应能力
事件驱动型(event driven)的GUI,API可能编写如下:
为了确保用户输入被及时的处理,无论应时在做些什么,while (true) { event_data event = get_event(); if (event.type == quit) break; process(event); }
get_event()
和process()
必须以合理的频率调用。这就意味着任务要被周期性的悬挂,并且返回到事件循环中,或get_event()
/process()
必须在一个合适地方进行调用。每个选项的复杂程度取决于任务的实现方式。下面,使用并发分离关注(将GUI线程和任务线程进行分离):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: //... } }
在实践中设计并发代码
当为一个特殊的任务设计并发代码时,需要根据任务本身来考虑之前所提到的问题。作者用C++标准库的三个标准函数作为例子进行并发实现。并行实现:
下面的实现与并行的std::for_each
std::accumulate
很相似。
使用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 void表示无返回值 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 传入lambda函数,执行for_each() [=]() { 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 提供检索工作线程异常的方法 //如果不想把异常传递出去,就可以省略这一步。 } }
std::async
实现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; if (length<(2 * min_per_thread)) { std::for_each(first, last, f); // 1 } else { Iterator const mid_point = first + length / 2; // 2 将每一级的数据分成两部分,异步执行另外一部分 std::future<void> first_half = std::async(¶llel_for_each<Iterator, Func>, first, mid_point, f); parallel_for_each(mid_point, last, f); // 3 剩余部分递归 first_half.get(); // 4 异常传播 } }
并行实现:
当第一个元素就满足查找标准,那就没有必要对其他元素进行搜索了。一种办法,中断其他线程的一个办法就是使用一个原子变量作为一个标识,在处理过每一个元素后就对这个标识进行检查。如果标识被设置,那么就有线程找到了匹配元素,所以算法就可以停止并返回了。用这种方式来中断线程,就可以将那些没有处理的数据保持原样,并且在更多的情况下,相较于串行方式,性能能提升很多。缺点就是,加载原子变量是一个很慢的操作,会阻碍每个线程的运行。std::find
返回值和传播异常:现在你有两个选择。你可以使用一个future数组,使用std::packaged_task
来转移值和异常,在主线程上对返回值和异常进行处理;或者使用std::promise
对工作线程上的最终结果直接进行设置。这完全依赖于你想怎么样处理工作线程上的异常。如果想停止第一个异常(即使还没有对所有元素进行处理),就可以使用std::promise
对异常和最终值进行设置。另外,如果想要让其他工作线程继续查找,可以使用std::packaged_task
来存储所有的异常,当线程没有找到匹配元素时,异常将再次抛出。这种情况下,我会选择std::promise
,因为其行为和std::find
更为接近。这里需要注意一下搜索的元素是不是在提供的搜索范围内。因此,在所有线程结束前,获取future上的结果。如果被future阻塞住,所要查找的值不在范围内,就会持续的等待下去。实现代码如下:
使用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 匹配元素找到,设置promise 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 “启动-汇入”代码放在一个块中,所有线程都会在找到匹配元素时13进行汇入 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 }//joiner析构,线程全部汇入 if (!done_flag.load()) //13 { return last; } return result.get_future().get(); // 14 获取返回值或异常 }
std::async
实现的并行find算法:
stackoverflow:A thread leak is a resource leak where the resource being leaked is a thread. A thread dump is a way to dump all the information of threads for analysis (such as finding thread leaks).template<typename Iterator, typename MatchType> Iterator parallel_find_impl(Iterator first, Iterator last, MatchType match, std::atomic<bool>& done)// 1 递归传入标识,引用方式 { 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 返回结果确定或异常传播 /*相等,返回第二部分结果(通过此结果确定是否找到); 否则,返回第一部分(已经找到)。 如果异步查找真实的运行在其他线程上, 那么async_result变量的析构函数将会等待该线程完成, 所以这里不会有线程泄露(线程没有完成就被销毁)。*/ } } catch (...)//如果不使用try/catch就需要等待线程任务完成 { done = true; // 11 只能捕捉在done发生的异常,并且当有异常抛出。 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 主入口点 }
std::async
可以用来提供“异常-安全”和“异常-传播”特性。实现中一个重要的特性就是,不能保证所有数据都能被 std::find 串行处理。其他并行算法可以借鉴这个特性,因为要让一个算法并行起来这是必须具有的特性。如果有顺序问题,元素就不能并发的处理了。并行实现:
std::partial_sum
std::partial_sum
会计算给定范围中的每个元素,并用计算后的结果将原始序列中的值替换掉。比如,有一个序列[1,2,3,4,5],在执行该算法后会成为:[1,3(1+2),6(1+2+3),
10(1+2+3+4),15(1+2+3+4+5)]。确定某个范围部分和的一种的方式,就是在独立块中计算部分和,然后将第一块中最后的元素的值,与下一块中的所有元素进行相加,依次类推。比如,[1,2,3,4,5]分成3块,{1,2},{3,4},{5}。计算后{1,3},{3,7},{5}。加入前一块尾值得到{1,3},{6,10},{15}。合并{1,3,6,10,15}。
将原始数据分割成块,加上之前块的部分和就能够并行了。如果每个块中的末尾元素都是第一个被更新的,那么块中其他的元素就能被其他线程所更新,同时另一个线程对下一块进行更新,等等。当处理的元素比处理核心的个数多的时候,这样完成工作没问题,因为每一个核芯在每一个阶段都有合适的数据可以进行处理。
使用划分的方式来并行的计算部分和:
因为线程间需要同步,这里的代码就不容易使用 std::async 重写。任务等待会让线程中途去执行其他的任务,所以所有的任务必须同时执行。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 传入lambda { 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); //最后块,抛出动作一定会在主线程上进行。 }
第二种方式:对部分结果进行传播:第一次与相邻的元素(距离为1)相加和(和之前一样),之后和距离为2的元素相加,在后来和距离为4的元素相加,以此类推。比如,初始序列为[1,2,3,4,5],第一次后为[1,3,5,7,9],第二次后为[1,3,6,10,14],下一次就要隔4个元素了。第三次后[1, 3, 6, 10, 15],这就是最终的结果。
实现以2的幂级数为距离部分和算法。代码必须能处理通用情况,并且需要在每步上对线程进行显式同步。完成这种功能的一种方式是使用栅栏(barrier)——一种同步机制:只有所有线程都到达栅栏处,才能进行之后的操作;先到达的线程必须等待未到达的线程。
简单的栅栏类:
使用自旋等待的情况下,如果让线程等待很长时间就不会很理想,并且如果超过count数量的线程对wait()进行调用,这个实现就没有办法工作了。如果想要很好的处理这样的情况,必须使用一个更加健壮(更加复杂)的实现。不论怎么样,这些都需要你考虑到;需要有固定数量的线程执行同步循环。好吧,大多数情况下线程数量都是固定的。无论是让所有线程循环处理范围内的所有元素,还是让栅栏来同步线程,都会递减count的值。我会选择后者,因为其能避免线程做不必要的工作,仅仅是等待最终步骤完成。这需要对程序进行修改: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减至0时 spaces = count; // 4 重置 ++generation; // 5 增加,向等待线程发出信号 } else { while (generation == my_generation) // 6 自旋锁 std::this_thread::yield(); // 7 避免cpu忙等待 } } };
此处需要合理设置count值,并在合适的地方调用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;//1 递减,下次重置spaces将会变小 if (!--spaces)//2 递减 { spaces = count.load(); //3 一组当中最后一个线程需要对计数器进行重置,并且递增generation的值 ++generation; } //最后一个线程不需要等待 } };
wait()
和done_waiting()
。
通过两两更新对的方式实现partial_sum:
在一开始,这段程序我看的并不是很懂,包括栅栏的写法。后来,在一步一步的调试后,发现这段代码写的很是优雅。每个线程处理一轮部分加的操作,配合栅栏,将操作划分为几个阶段,达到最终目的。每一轮完成,栅栏count减一,下一阶段开始时候spaces也就少了一个。class join_threads;//参考前面定义 struct barrier; 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) { // 2 每一步,都会从原始数据或缓存中获取第i个元素 value_type const& source = (step % 2) ? buffer[i] : ith_element; value_type& dest = (step % 2) ? ith_element : buffer[i]; value_type const& addend = (step % 2) ? // 3 获取到的元素加到指定stride的元素中去 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);//栅栏,count=5 std::vector<std::thread> threads(length - 1); // 8 线程容器 //线程的数量是根据列表中的数据量来定的,并非硬件支持数(api获取) 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 主线程最后的调用 } int main() { std::vector<int> v{ 1,2,3,4,5 }; parallel_partial_sum(v.begin(), v.end()); std::for_each(v.begin(), v.end(), [](const int &i) {std::cout << i << std::endl;});//lambda参数是元素类型 return 0; }
总体来说,当有N个操作时(每步使用一个处理器)第二种方法需要log(N)[底为2]步;在本节中,N就相当于数据链表的长度。比起第一种,每个线程对分配块做N/k个操作,然后在做N/k次结果传递(这里的k是线程的数量)。因此,第一种方法的时间复杂度为O(N),不过第二种方法的时间复杂度为Q(Nlog(N))。当数据量和处理器数量相近时,第二种方法需要每个处理器上log(N)个操作,第一种方法中每个处理器上执行的操作数会随着k的增加而增多,因为需要对结果进行传递。对于处理单元较少的情况,第一种方法会比较合适;对于大规模并行系统,第二种方法比较合适。
注意这个解决方案并不是异常安全的。如果某个线程在process_element
执行时抛出一个异常,其就会终止整个应用。这里可以使用一个std::promise
来存储异常,或仅使用一个被互斥量保护的std::exception_ptr
即可。
说明:此书的读书笔记中文字内容大多摘自译文,访问译文请于读书笔记1查看链接。