并行编程是未来,但是您如何才能有效利用多核CPU的高性能并行编程呢? 当然,也可以选择使用诸如POSIX线程之类的线程库,但是最初是出于C语言引入POSIX线程框架的。 这也是一种太底层的方法,例如,您无权访问任何并发容器,也没有任何可使用的并发算法。 在这一点上,英特尔推出了英特尔®线程构建块(Intel TBB),这是一种基于C++的并行编程框架,具有许多有趣的功能,并且比线程具有更高的抽象水平。
下载和安装Intel TBB并不需要什么特别的事情:提取的目录层次结构让人联想到带有include,bin,lib和doc文件夹的UNIX®系统。 出于本文的目的,我选择了tbb30_20110427oss稳定版本。
英特尔TBB有很多工作要做。 以下是一些有趣的入门知识:
您可以在任务中拥有更高级别的抽象,而不是线程。 英特尔声称,在Linux®系统上,启动和终止任务比启动和停止线程快18倍。 英特尔TBB带有任务计划程序,该任务计划程序可以有效处理多个逻辑和物理内核之间的负载平衡。 Intel TBB中的默认任务调度策略与大多数线程调度程序具有的循环策略不同。 英特尔TBB提供现成的货架线程安全的容器,如可用性concurrent_vector和concurrent_queue 。 可以使用诸如parallel_for和parallel_reduce类的通用并行算法。 模板类atomic提供了无锁 (也称为无互斥 )并发编程支持。 这种支持使Intel TBB适用于高性能应用程序,因为Intel TBB可以处理互斥锁。 全部都是C++ ! 由于没有花哨的扩展名或宏,英特尔®TBB仍停留在该语言之内,从而大量使用了模板。英特尔TBB确实有很多先决条件。 在开始之前,您应该具备:
C++模板以及对标准模板库(STL)的一些理解。 线程知识-POSIX线程或Windows®线程。尽管不是必需的,但C++0x lambda函数在Intel TBB中找到了相当的用法。
英特尔TBB的讨论始于创建和处理任务和同步原语(mutex),然后使用并发容器和并行算法。 它以使用原子模板的无锁编程结束。
英特尔TBB基于任务的概念。 您定义自己的任务,这些任务派生自tbb / task.h中声明的tbb::task 。 要求用户在其代码中覆盖纯虚拟方法task* task::execute ( ) 。 以下是每个Intel TBB任务的一些属性:
当Intel TBB任务计划程序选择运行某些任务时,将调用任务的execute方法。 那是切入点。 execute方法可以返回task* ,它告诉调度程序下一个要运行的任务。 如果返回NULL,则调度程序可以自由选择下一个任务。 task::~task( )是虚拟的,并且用户任务分配的任何资源都必须在此析构函数中释放。 通过调用task::allocate_root( ) 。 主任务通过调用task::spawn_root_and_wait(task)来运行任务以完成task::spawn_root_and_wait(task) 。下面的清单1显示了第一个任务及其调用方式:
要运行Intel TBB程序,必须正确初始化任务计划程序。 清单1中调度程序的参数是自动的,它使调度程序可以自己决定线程数。 当然,如果要控制产生的最大线程数,则可以覆盖此行为。 但是在生产代码中,除非您真的知道自己在做什么,否则最好将确定最佳线程数的工作留给调度程序。
现在您已经创建了第一个任务,让清单1中的first_task产生一些子任务。 下面的清单2引入了一些新概念:
英特尔TBB提供了一个名为task_list的容器,该容器旨在用作任务的集合。 每个父任务都使用allocate_child函数调用创建一个子任务。 在任务产生任何子任务之前,它必须调用set_ref_count 。 否则会导致未定义的行为。 如果要生成子任务,然后等待它们完成,则count必须等于子任务的数量+ 1; 否则, count应等于子任务数。 不久之后会更多。 对spawn_and_wait_for_all的调用如其名称所示:产生子任务并等待所有操作完成。这是代码:
那么,为什么英特尔TBB要求显式设置set_ref_count ? 该文档说,这主要是出于性能方面的考虑。 生成子代之前,必须始终为任务设置引用计数。 请参阅相关信息的链接,更多的细节。
您也可以创建任务组。 以下代码创建一个任务组,该任务组产生两个任务并等待它们完成。 task_group的run方法具有以下签名:
template<typename Func> void run( const Func& f )run方法产生一个可计算f( )但不会阻塞调用任务的任务,因此控件会立即返回。 为了等待子任务完成,调用任务调用wait (请参见下面的清单3 )。
请注意task_group的语法简单性-直接处理任务时不需要进行内存分配等调用,并且您无需对ref计数做任何事情。 这就是任务。 英特尔TBB任务可以完成数百种事情。 请确保深入了解Intel TBB文档以获取更多详细信息。 让我们继续并发容器。
现在,让我们集中讨论Intel TBB的并发容器之一: concurrent_vector 。 此容器在标头tbb / concurrent_vector.h中声明,并且基本接口类似于STL向量:
template<typename T, class A = cache_aligned_allocator<T> > class concurrent_vector;可以将多个控制线程安全地添加到向量中,而无需任何显式锁定。 从英特尔TBB手动意译, concurrent_vector具有以下特性:
它提供对元素的随机访问; 索引从位置0开始。 安全并发增加大小是可能的,并且可以同时添加多个线程。 添加新元素不会使现有索引或迭代器无效。但是,并发是有代价的。 与STL不同,在STL中,添加新元素涉及数据的移动,而concurrent_vector数据不移动。 而是,容器维护一系列连续的内存段。 显然,这增加了容器开销。
对于同时添加向量,可以使用三种方法:
push_back在向量的末尾附加一个元素。 grow_by(N) -append N型的连续元素T到concurrent_vector和迭代器返回到第一所附元件。 每个元素都用T ( )初始化。 grow_to_at_least(N) -Grow载体以大小为N,如果向量的当前大小小于N。您可以按如下所示将字符串附加到concurrent_vector :
void append( concurrent_vector<char>& cv, const char* str1 ) { size_t count = strlen(str1)+1; std::copy( str1, str1+count, cv.grow_by(count) ); }关于Intel TBB的最好的事情之一是,它使您可以自动并行化部分源代码,而不必费心创建和维护线程。 最常见的并行算法是parallel_for 。 考虑以下示例:
void serial_only (int* array, int size) { for (int count = 0; count < size; ++count) apply_transformation (array [count]); }现在,如果上一apply_transformation中的apply_transformation例程没有做任何奇怪的事情,例如仅对单个数组元素进行了一些转换,那么您就无法阻止将负载分配给多个CPU内核。 您需要英特尔TBB库中的两个类才能入门: blocked_range (来自tbb / blocked_range.h)和parallel_for (来自tbb / parallel_for.h)。
blocked_range类旨在创建一个向迭代器提供parallel_for的对象,因此您需要创建诸如blocked_range (0, size) ,并将其作为输入传递给parallel_for 。 parallel_for需要的第二个也是最后一个参数是具有清单4中的要求的类(从parallel_for.h标头粘贴)。
该代码告诉您,您需要使用operator ( )创建自己的类,并使用blocked_range作为参数,并在operator ( )的方法定义内对您先前创建的serial for循环进行编码。 复制构造函数和析构函数应该是公共的,并且您让编译器为您提供默认值。 下面的清单5显示了代码。
现在您已经成功创建了第二个对象,您只需调用parallel_for ,如清单6所示。
英特尔TBB提供了很多并行算法,例如parallel_reduce (在tbb / parallel_reduce.h中声明)。 假设您要汇总所有元素,而不是对每个单独的数组元素应用转换。 这是序列号:
void serial_only (int* array, int size) { int sum = 0; for (int count = 0; count < size; ++count) sum += array [count]; return sum; }从概念上讲,在并行上下文中运行此代码将意味着每个控制线程都应汇总数组的某些部分,并且必须在某处存在join方法来汇总部分求和。 下面的清单7显示了Intel TBB代码。
在将每个线程的数组拆分为子数组时,您需要保持一定的粒度(例如,每个线程负责对N个元素求和,其中N既不太大也不不太小)。 那是blocked_range的第三个参数。 英特尔TBB要求summation_helper类满足两个条件:它必须具有一个名为join的方法以添加部分和和以及一个带有特殊参数的构造函数(称为splitting构造函数 )。 清单8提供了代码:
这就是将会发生的事情。 Intel TBB调用splitting构造函数(称为split的第二个参数是Intel TBB所需的虚拟参数),并且部分数组由一定数量的元素填充(该数量是blocked_range定义的粒度的函数)。 当子数组上的求和完成时, join方法将添加部分结果。 有点复杂? 乍看起来也许; 只需记住您需要三个方法: operator( )添加数组范围, join添加以添加部分结果,以及split构造函数以启动新的工作线程。
英特尔TBB还有其他几种有用的算法, parallel_sort是最有用的算法之一。 请参阅英特尔TBB参考手册(见相关信息 )了解详情。
在多线程编程期间经常出现的一个问题是,互斥锁的锁定和解锁浪费了CPU周期数。 如果您来自POSIX线程背景,英特尔TBB的atomic模板将使您感到惊讶。 它是互斥锁的替代方法,速度要快得多,并且您可以放心地取消对锁定和解锁代码的需求。 atomic是所有编码难题的灵丹妙药吗? 否。它的使用受到严格限制。 但是,如果您要创建高性能代码,这将非常有效。 声明整数为atomic类型的方法如下:
#include "tbb/atomic.h" using namespace tbb; atomic<int> count; atomic<float* > pointer_to_float;现在,假设多个控制线程正在访问较早版本的变量计数。 通常,您希望在写入过程中使用互斥量来保护计数; 但是, atomic<int>不再需atomic<int> 。 看一下清单9 。
代替+= ,可以使用atomic<T>类的fetch_and_add方法。 不,它在内部不使用任何互斥锁作为fetch_and_add方法的一部分。 当执行fetch_and_add ,它的作用是立即加1000以立即count -所有线程一次都可以看到count的更新值,或者所有线程都可以继续看到旧值。 这就是为什么count被声明为atomic变量:对操作count是原子,并且不能按照进程或线程调度的反复无常而中断。 无论如何调度线程,都无法在不同线程中count不同的值。 有关无锁编程的深入讨论,请参阅参考资料 。
atomic<T>类具有以下五个基本操作:
y = x; // atomic read x = b; // atomic write x.fetch_and_store(y); // y = x and return the old value of x x.fetch_and_add(y); // x += y and return the old value of x x.compare_and_swap(y, z); // if (x == z) x = y; in either case, return old value of x另外,为方便起见,支持运算符+= , -= , ++和-- ,但它们都在fetch_and_add之上fetch_and_add 。 如tbb / atomic.h所示,这是定义运算符的方式( 清单10 )。
请注意, atomic<T>中的类型T只能是整数类型,枚举类型或指针类型。
不可能在一篇文章中公正地描述一个具有英特尔TBB规模的图书馆。 确实,英特尔网站上有数十篇文章重点介绍了英特尔TBB的多个方面。 取而代之的是,本文试图深入了解Intel TBB随附的一些引人注目的功能-任务,并发容器,算法以及创建无锁代码的方法。 希望本文的介绍激发了您的兴趣,英特尔TBB将获得另一个热情的用户—就像作者本人一样。
翻译自: https://www.ibm.com/developerworks/aix/library/au-intelthreadbuilding/index.html