并行编程是未来,但是您如何才能有效利用多核CPU的高性能并行编程呢? 当然,也可以选择使用诸如POSIX线程之类的线程库,但是最初是出于C
语言引入POSIX线程框架的。 这也是一种太底层的方法,例如,您无权访问任何并发容器,也没有任何可使用的并发算法。 在这一点上,英特尔推出了英特尔®线程构建块(Intel TBB),这是一种基于C++
的并行编程框架,具有许多有趣的功能,并且比线程具有更高的抽象水平。
下载和安装Intel TBB并不需要什么特别的事情:提取的目录层次结构让人联想到带有include,bin,lib和doc文件夹的UNIX®系统。 出于本文的目的,我选择了tbb30_20110427oss稳定版本。
英特尔TBB入门
英特尔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),然后使用并发容器和并行算法。 它以使用原子模板的无锁编程结束。
您好,World with Intel TBB任务
英特尔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显示了第一个任务及其调用方式:
清单1.创建第一个英特尔TBB任务
#include "tbb/tbb.h"
#include <iostream>
using namespace tbb;
using namespace std;
class first_task : public task {
public:
task* execute( ) {
cout << "Hello World!\n";
return NULL;
}
};
int main( )
{
task_scheduler_init init(task_scheduler_init::automatic);
first_task& f1 = *new(tbb::task::allocate_root()) first_task( );
tbb::task::spawn_root_and_wait(f1);
}
要运行Intel TBB程序,必须正确初始化任务计划程序。 清单1中调度程序的参数是自动的,它使调度程序可以自己决定线程数。 当然,如果要控制产生的最大线程数,则可以覆盖此行为。 但是在生产代码中,除非您真的知道自己在做什么,否则最好将确定最佳线程数的工作留给调度程序。
现在您已经创建了第一个任务,让清单1中的first_task
产生一些子任务。 下面的清单2引入了一些新概念:
- 英特尔TBB提供了一个名为
task_list
的容器,该容器旨在用作任务的集合。 - 每个父任务都使用
allocate_child
函数调用创建一个子任务。 - 在任务产生任何子任务之前,它必须调用
set_ref_count
。 否则会导致未定义的行为。 如果要生成子任务,然后等待它们完成,则count
必须等于子任务的数量+ 1; 否则,count
应等于子任务数。 不久之后会更多。 - 对
spawn_and_wait_for_all
的调用如其名称所示:产生子任务并等待所有操作完成。
这是代码:
清单2.创建多个子任务
#include "tbb/tbb.h"
#include <iostream>
using namespace tbb;
using namespace std;
class first_task : public task {
public:
task* execute( ) {
cout << "Hello World!\n";
task_list list1;
list1.push_back( *new( allocate_child() ) first_task( ) );
list1. push_back( *new( allocate_child() ) first_task( ) );
set_ref_count(3); // 2 (1 per child task) + 1 (for the wait)
spawn_and_wait_for_all(list1);
return NULL;
}
};
int main( )
{
first_task& f1 = *new(tbb::task::allocate_root()) first_task( );
tbb::task::spawn_root_and_wait(f1);
}
那么,为什么英特尔TBB要求显式设置set_ref_count
? 该文档说,这主要是出于性能方面的考虑。 生成子代之前,必须始终为任务设置引用计数。 请参阅相关信息的链接,更多的细节。
您也可以创建任务组。 以下代码创建一个任务组,该任务组产生两个任务并等待它们完成。 task_group
的run
方法具有以下签名:
template<typename Func> void run( const Func& f )
run
方法产生一个可计算f( )
但不会阻塞调用任务的任务,因此控件会立即返回。 为了等待子任务完成,调用任务调用wait
(请参见下面的清单3 )。
清单3.创建一个task_group
#include "tbb/tbb.h"
#include <iostream>
using namespace tbb;
using namespace std;
class say_hello( ) {
const char* message;
public:
say_hello(const char* str) : message(str) { }
void operator( ) ( ) const {
cout << message << endl;
}
};
int main( )
{
task_group tg;
tg.run(say_hello("child 1")); // spawn task and return
tg.run(say_hello("child 2")); // spawn another task and return
tg.wait( ); // wait for tasks to complete
}
请注意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) );
}
借助英特尔®TBB立即使用并行算法
关于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标头粘贴)。
清单4. parallel_for的第二个参数的要求
/** \page parallel_for_body_req Requirements on parallel_for body
Class \c Body implementing the concept of parallel_for body must define:
- \code Body::Body( const Body& ); \endcode Copy constructor
- \code Body::~Body(); \endcode Destructor
- \code void Body::operator()( Range& r ) const; \endcode
Function call operator applying the body to range \c r.
**/
该代码告诉您,您需要使用operator ( )
创建自己的类,并使用blocked_range
作为参数,并在operator ( )
的方法定义内对您先前创建的serial for
循环进行编码。 复制构造函数和析构函数应该是公共的,并且您让编译器为您提供默认值。 下面的清单5显示了代码。
清单5.为parallel_for创建第二个参数
#include "tbb/blocked_range.h"
using namespace tbb;
class apply_transform{
int* array;
public:
apply_transform (int* a): array(a) {}
void operator()( const blocked_range& r ) const {
for (int i=r.begin(); i!=r.end(); i++ ){
apply_transformation(array[i]);
}
}
};
现在您已经成功创建了第二个对象,您只需调用parallel_for
,如清单6所示。
清单6.使用parallel_for并行化循环
#include "tbb/blocked_range.h"
#include "tbb/parallel_for.h"
using namespace tbb;
void do_parallel_the_tbb_way(int *array, int size) {
parallel_for (blocked_range(0, size), apply_transform(array));
}
英特尔TBB中的其他并行算法
英特尔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代码。
清单7.串行for循环求和数组元素
#include "tbb/blocked_range.h"
#include "tbb/parallel_reduce.h"
using namespace tbb;
float sum_with_parallel_reduce(int*array, int size) {
summation_helper helper (array);
parallel_reduce (blocked_range<int> (0, size, 5), helper);
return helper.sum;
}
在将每个线程的数组拆分为子数组时,您需要保持一定的粒度(例如,每个线程负责对N个元素求和,其中N既不太大也不不太小)。 那是blocked_range
的第三个参数。 英特尔TBB要求summation_helper
类满足两个条件:它必须具有一个名为join
的方法以添加部分和和以及一个带有特殊参数的构造函数(称为splitting构造函数 )。 清单8提供了代码:
清单8.使用join方法创建summation_helper类并拆分构造函数
class summation_helper {
int* partial_array;
public:
int sum;
void operator( )( const blocked_range<int>& r ) {
for( int count=r.begin(); count!=r.end( ); ++count)
sum += partial_array [count];
}
summation_helper (summation_helper & x, split):
partial_array (x. partial_array), sum (0)
{
}
summation_helper (int* array): partial_array (array), sum (0)
{
}
void join( const summation_helper & temp ) {
sum += temp.sum; // required method
}
};
这就是将会发生的事情。 Intel TBB调用splitting构造函数(称为split
的第二个参数是Intel TBB所需的虚拟参数),并且部分数组由一定数量的元素填充(该数量是blocked_range
定义的粒度的函数)。 当子数组上的求和完成时, join
方法将添加部分结果。 有点复杂? 乍看起来也许; 只需记住您需要三个方法: operator( )
添加数组范围, join
添加以添加部分结果,以及split构造函数以启动新的工作线程。
英特尔TBB还有其他几种有用的算法, parallel_sort
是最有用的算法之一。 请参阅英特尔TBB参考手册(见相关信息 )了解详情。
使用Intel 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 。
清单9.原子fetch_and_add不需要锁定
// writing with mutex, count is declared as int count;
{
// … code
pthread_mutex_lock (&lock);
count += 1000;
pthread_mutex_unlock (&lock);
// … code continues
}
// writing without mutex, count declared as atomic<int> count;
{
// … code
count.fetch_and_add (1000); // no explicit locking/unlocking
// … code continues
}
代替+=
,可以使用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 )。
清单10.使用fetch_and_add定义的运算符++,-,+ =和-=
value_type operator+=( D addend ) {
return fetch_and_add(addend)+addend;
}
value_type operator-=( D addend ) {
// Additive inverse of addend computed using binary minus,
// instead of unary minus, for sake of avoiding compiler warnings.
return operator+=(D(0)-addend);
}
value_type operator++() {
return fetch_and_add(1)+1;
}
value_type operator--() {
return fetch_and_add(__TBB_MINUS_ONE(D))-1;
}
value_type operator++(int) {
return fetch_and_add(1);
}
value_type operator--(int) {
return fetch_and_add(__TBB_MINUS_ONE(D));
}
请注意, atomic<T>
中的类型T
只能是整数类型,枚举类型或指针类型。
结论
不可能在一篇文章中公正地描述一个具有英特尔TBB规模的图书馆。 确实,英特尔网站上有数十篇文章重点介绍了英特尔TBB的多个方面。 取而代之的是,本文试图深入了解Intel TBB随附的一些引人注目的功能-任务,并发容器,算法以及创建无锁代码的方法。 希望本文的介绍激发了您的兴趣,英特尔TBB将获得另一个热情的用户—就像作者本人一样。
翻译自: https://www.ibm.com/developerworks/aix/library/au-intelthreadbuilding/index.html