学习 Intel 线程构建块开源库
简介
并行编程是未来的发展趋势,但是如何实现高性能的并行编程,从而有效地利用多核 CPU 呢?使用诸如 POSIX 线程这样的线程库当然也是一种选择,不过,最初引入 POSIX 线程框架时已经考虑到了 C
语言。这是一种非常低级别的方法,例如,如果您无法访问任何并发容器,那么就不能使用任何并发算法。为此,Intel 推出了 Intel® 线程构建块 (Intel TBB),一种用于并行编程的基于 C++
语言的框架,它提供了大量有趣的特性,具有比线程更高程度的抽象。
常用缩略语
- POSIX:面向 UNIX 的可移植操作系统接口
下载和安装 Intel TBB 非常简单:解压后的目录层次结构与 UNIX® 系统类似,其中包括 include、bin、lib 和 doc 文件夹。出于本文的目的,我选择使用 tbb30_20110427oss 稳定版。
开始使用 Intel TBB
Intel TBB 提供了许多好处。您可以先关注以下几个特性:
- 与线程不同,您可以对任务使用更高程度的抽象。Intel 声称,在 Linux® 系统上,启动和结束任务的速度是对线程执行相同操作的 18 倍。
- Intel TBB 附带了一个任务调度程序,该程序可以跨多个逻辑和物理内核高效地处理负载平衡。Intel TBB 中的默认任务调度策略不同于大多数线程调度程序所拥有的轮询策略。
- Intel TBB 提供了一些可直接使用的线程安全容器,比如
concurrent_vector
和concurrent_queue
。 - 可以使用通用的并行算法,如
parallel_for
和parallel_reduce
。 - 模板类
atomic
中提供了无锁(Lock-free,也称为 mutex-free)并发编程支持。这种支持使得 Intel TBB 适合用于高性能的应用程序,因为 Intel TBB 可以锁定和解除锁定互斥体 (mutex)。 - 这都是用
C++
实现的!没有进行任何扩展或使用宏,Intel TBB 只使用这种语言,同时还使用了大量的模板。
使用 Intel TBB 需要具备许多先决条件。在开始之前,您应当满足以下要求:
- 一些
C++
模板,并了解标准模板库 (STL)。 - 了解线程,POSIX 线程或 Windows® 线程均可。
虽然不是必须的,但是 C++0x
中的 lambda 功能对 Intel TBB 来说相当有用。
本文对 Intel TBB 的讨论是从创建和研究一些任务和同步原语(互斥体)开始的,然后了解如何使用并发容器和并行算法。最后介绍如何使用原子模板来实现无锁编程。
了解 Intel TBB 任务
Intel TBB 基于 任务 的概念。您需要定义自己的任务,这些任务是从 tbb::task
中派生的,并使用 tbb/task.h 进行声明。用户需要在自己的代码中重写纯虚拟方法 task* task::execute ( )
。下面展示了每个 Intel TBB 任务的一些属性:
- 当 Intel TBB 任务调度程序选择运行一些任务时,会调用该任务的
execute
方法。这是入口点。 -
execute
方法会返回一个task*
,告诉调度程序将要运行的下一个任务。如果它返回 NULL,那么调度程序可以自由选择下一个任务。 -
task::~task( )
是虚拟的,不管用户任务占用了什么资源,都必须在这个析构函数 (destructor) 中释放。 - 任务是通过调用
task::allocate_root( )
来分配的。 - 主任务通过调用
task::spawn_root_and_wait(task)
来完成任务的运行。
下面的 清单 1 展示了第一个任务以及调用它的方式:
清单 1. 创建第一个 Intel 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 引入了一些新的概念:
- Intel 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); }
那么,为什么 Intel TBB 要求显式设置 set_ref_count
呢?文档中指出这样做主要是出于性能考虑。在衍生子任务之前,必须始终为任务设置 ref 计数。参见 参考资料,获得更多内容的链接。
您还可以创建任务组。下面的代码创建了一个任务组,它衍生了两个任务并等待它们完成。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 计数执行任何操作。使用 Intel TBB 任务可以完成许多事情。请参考 Intel TBB 文档,以获得更多的详细信息。接下来我们将探讨并发容器。
并发容器:vector
现在,让我们关注 Intel TBB 的并发容器之一:concurrent_vector
。该容器在头文件 tbb/concurrent_vector.h 中进行声明,基本接口与 STL vector 类似:
template<typename T, class A = cache_aligned_allocator<T> > class concurrent_vector;
可以将多个线程安全添加到 vector,无需进行任何显式锁定。根据 Intel TBB 手册的解释,concurrent_vector
提供了以下属性:
- 它提供了对其元素的随机访问;索引从位置 0 开始。
- 可以安全地增加并发数量,还可以同时添加多个线程。
- 添加新的元素并不会影响现有索引或迭代器。
但是,实现并发性需要付出代价。与 STL 不同,STL 添加新的元素会涉及数据移动,而 concurrent_vector
不会移动数据。该容器包含一系列连续的内存片段。显然,这会增加容器开销。
对于 vector 的并发性,有三种方法可以使用:
-
push_back
:在 vector 末端添加元素。 -
grow_by(N)
:向concurrent_vector
添加类型为T
的 N 个连续元素,并返回指向第一个附加元素的迭代器。每个元素通过T ( )
进行初始化。 -
grow_to_at_least(N)
:将 vector 的大小增加到 N(如果 vector 的当前大小小于 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 提供的现成的并行算法
Intel TBB 的一大优点是它使您能够并行处理源代码的多个部分,无需了解如何创建和维护线程。最常见的并行算法是 parallel_for
。请考虑下面的示例:
void serial_only (int* array, int size) { for (int count = 0; count < size; ++count) apply_transformation (array [count]); }
现在,如果前面的代码片段中的 apply_transformation
例程没有出现异常,比如只对单个数组元素应用某些转换,那么您将可以顺利地将负载分配到多个 CPU 内核中。您需要使用 Intel 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()
方法定义内部创建的序列 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)); }
Intel TBB 中的其他并行算法
Intel 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. 对数组元素求和的序列循环
#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
。Intel TBB 要求 summation_helper
类满足两个条件:它必须提供一个名为 join
的方法来添加部分合计值,还要提供一个包含特殊参数的构造函数(称为 splitting constructor)。清单 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 构造函数则用于启动新的 worker 线程。
Intel TBB 还提供了其他几个有用的算法,parallel_sort
是其中最有用的算法之一。参见 Intel TBB 参考手册(请参阅 参考资料),以获得有关的更多详细信息。
使用 Intel TBB 进行无锁编程
多线程编程过程中经常出现的一个问题是:锁定和解锁互斥体浪费了许多 CPU 周期。如果您了解 POSIX 线程,那么 Intel TBB 的 atomic
模板会令您大吃一惊。它的速度比互斥体快多了,而且您不再需要对代码进行锁定和解锁。atomic
可以解决所有编码问题吗?不,它的使用存在严格的限制;无论如何,如果您希望创建高性能代码,那么它非常有效。下面展示了如何将一个整数声明为 atomic
类型:
#include "tbb/atomic.h" using namespace tbb; atomic<int> count; atomic<float* > pointer_to_float;
现在,假设前面的可变计数可由多个控制线程访问。通常,您需要在执行写操作时对计数使用互斥体;然而,有了 atomic<int>
之后,您再也不需要这样做了。参见 清单 9。
清单 9. atomic 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
时,会立刻向 count
增加 1000 个计数:要么所有线程都立即看到更新后的 count
值,要么所有线程都继续显示旧值。这就是将 count
声明为 atomic
变量的原因:对 count
的操作是原子性的,不会被进程或线程调度打断。不管线程是如何调度的,count
在不同的线程中不会出现不同的值。要深入讨论无锁编程,请参阅 参考资料。
atomic<T>
类提供了以下 5 个基本操作:
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
之上实现的。如 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
类型只能是整数类型、枚举类型或指针类型。
结束语
本文篇幅有限,无法对 Intel TBB 库进行全面的描述。但是,Intel 的网站提供了这方面的大量文章,介绍了 Intel TBB 的各个方面。本文的目的只是简单介绍 Intel TBB 提供的一些有趣特性,比如任务、并发容器、算法,以及实现无锁代码的方式。希望本文的介绍能够激起您对 Intel TBB 的兴趣并使您成为其热心用户,就像本文的作者一样。