用Intel
线程构建块进行安全、可伸缩性的并行编程
如果你也是今天众多编写多线程程序、利用多核计算平台的程序员之一,说不定你已经了解C++ STL中的容器类并不是线程友好的(即不太适用于多线程);如今,硬件行业的先行者Intel却提供了一款线程安全的C++模板库,来看看有没有你想要的吧。
多线程程序是出了名的难写、难测试、难调试,然而,我们又很需要多核台式及笔记本电脑所带来的性能优势,由此,开发者就不得不对他们的程序进行线程化改造。而多线程程序的开发又没有什么灵丹妙药,所以,有效地利用现有的库及工具能在一定程度上减轻由此带来的负担,Intel线程构建块(Intel Threading Building Blocks: Intel TBB)就是其中一款优秀的并行容器类,这款专门设计的C++模板库旨在辅助开发多线程程序,并可在程序中安全地添加可伸缩性并行特性。
你的容器类是线程安全的吗?
许多开发者依靠手工编写容器类或使用由C++标准模板库(STL)实现的容器类,可惜的是,这些库大多数都不是线程安全的,具体来说,当在多线程代码中使用STL时,STL规范中并未提及线程或容器类的行为,因此,这些STL容器类的实现一般都不是线程安全的。口说无凭,下面请判断使用STL map之后的值:
即便与两个不同的key相关联的两个value在上述代码中被修改之后,大多数STL实现也并未对正确性提供任何保证,如果同时执行这些操作又未进行同步,很可能会破坏map,又因为未对线程安全性有任何要求,访问两个不同的map都有可能导致数据损坏。
当然了,也可以另一种方式实现STL map模板类来保证上述代码的线程安全,不巧的是,某些常用map操作的顺序并不是以一种线程友好的方式来实现的,此时,当每个单独的操作为线程安全时,代码中的顺序操作可能会导致不确定的结果,例如,如果两个线程操作了以下代码map中的同一元素:
由Thread 0执行的代码进行了两项操作:一是它调用了操作符 [ ] 以返回一个对“Key1”相联对象的引用,如果这个key不在map中,操作符 [ ] 会为与此key相联的MyClass类型对象分配空间;二是操作符 = 被调用以复制MyClass的临时实例到由引用指向的对象中。
所期望的结果是,要么“Key1”不在map中,要么它与MyClass()的一个实例成对出现。但是如果没有用户干预的同步,即便每个操作符本身是线程安全的,都会有可能出现其他的结果。由Thread 1调用的erase方法可能会在Thread 0中对操作符 [ ] 及 = 的调用之间出现,如果这样的话,Thread 0将会对一个已删除的对象调用 = 操作符,明显是不正确的行为。这种类型的多线程bug被称为赛跑状态,无意的行为取决于哪个线程先执行它的操作。
从上例可以看出,赛跑状态的棘手之处,在于行为的非确定性,也许代码在测试时,Thread 1的erase从未出现在Thread 0的两个操作之间,因此bug就保留了下来,并存在于最终软件之中,在意想不到的时候危害软件的运行。
要想在使用非线程友好的容器类时避免此类的bug,开发者不得不在每个容器类的使用之处都加上锁,一次只允许一个线程访问容器类,这种看上去有点笨拙的方法限制了程序中的并发性,且每处新增的代码了也加大了程序的复杂性,然而,这却是利用现有类库所必须付出的代价。
挑战者的出现:Intel TBB
容器类
Intel TBB库基于运行时的并行编程模型,它不但提供了在C++中使用线程的方法,还提供了可伸缩的并行算法,如对map、queue、vector容器类的安全、可伸缩性的实现。这些模板类能直接用在用户线程代码中,或与库中的并行算法组合使用。
前面也讨论过,某些容器类的标准接口并不是天生就对线程不感冒的,因此,Intel TBB并不能只是简单地取代对方,而是依旧遵循了STL的精神,在那些需要保证线程安全的地方,提供了修改后的接口。
Intel TBB库中所有的并行容器类全由精心设计的锁实现,无论何时调用了其中的一个方法,只有方法涉及到的数据结构部分被锁定,而结构的其他部分则允许其他线程同时访问。
下面,来介绍一下concurrent_hash_map、concurrent_queue、concurrent_vector这三个模板类,并以一个例子来说明如果发现及替换不安全的容器类。
concurrent_hash_map< Key, T, HashCompare >
Intel TBB的模板类concurrent_hash_map类似于STL容器类中map,但却允许多个线程同时访问其元素,它的哈希表把类型Key的key映射为类型T的value,属性HashCompare定义了映射中使用的哈希函数,它用于确定两个key是否相等。
下面是使用string类型的例子:
struct my_hash_compare {
static size_t hash( const string& x ) {
size_t h = 0;
for( const char* s = x.c_str(); *s; ++s )
h = (h*17)^*s;
return h;
}
static bool equal( const string& x, const string& y ) {
return x==y;
};
concurrent_hash_map就像是一个类型std::pair的元素集合,一般来说,访问元素的目的,要么就是更新,要么就是读取,而模板类concurrent_hash_map则分别通过accessor和const_accessor类支持这两种类型的访问,它们就像是智能指针,实现了STL map中所缺乏的原子性访问。
accessor类代表了更新(写)操作,只要它指向了某个元素,所有其他试图在表中查找这个key的动作都会被阻拦,直到accessor完成;const_accessor也基本上类似,只不过它代表的是只读访问。在同一时间,允许有多个const_accessor指向同一元素,这样可在元素频繁读取但鲜有更新的情况下,极大地提高并发性。
我们主要通过insert、find、erase方法来访问concurrent_hash_map的元素,find与inset方法接受一个accessor或const_accessor作为参数,至于用哪个,完全取决于想要更新还是只读concurrent_hash_map;一旦方法返回,访问会一直持续到accessor或const_acessor被销毁。Remove方法隐含了写操作,因此在删除key前,它会一直等待其他的访问操作完成。
下面是插入一个新元素到concurrent_hash_map中的例子:
concurrent_hash_map string_table;
void insert_into_string_table ( string &key, MyClass &m ) {
//创建一个accessor,它会像智能指针那样进行写操作
concurrent_hash_map::accessor a;
//调用insert来创建一个新元素,如果已经有元素存在,则返回一个现有元素
// accessor对对此元素加锁,让此线程进行独占性访问
string_table.insert( a, key );
//修改由值
a->second = m;
//“a”这个accessor会在超出范围销毁时,释放对元素的锁
}
concurrent_queue< Key, T, HashCompare >
Intel TBB的模板类concurrent_queue通过类型T的value实现了一个并发队列,多个线程可以同时向队列中压入或弹出元素,默认情况下,queue是无限的,但也可设置最大容量。
通常来说,队列是先进先出的数据结构,且在单线程程序中,队列也是遵守这种严格的顺序模式的。但如果多个线程同时压入或弹出,那么就会失去“先”这个词的含义。Intel TBB模板类concurrent_queue保证了如果某个线程压入了两个值,而又一个线程弹出了这两个值,它们都会以压入时的同样顺序弹出,并没有对不同线程压入值的交互弹出进行任何限制。
并行队列常用于“生产者——消费者”模式的应用程序中,某个线程产生的数据是由另一个线程来使用的。为对这类程序提供灵活的支持,Intel TBB提供了阻塞及非阻塞版本的pop,方法pop_if_present为非阻塞,它试图弹出一个值,但如果队列为家,它立即返回;另一方面,方法pop会一直阻塞,直到找到一个可用值,并从队列中弹出它。
下面的例子通过concurrent_queue,使用了阻塞的pop方法以在两个线程间通讯,Thread 1输出由Thread 0压入队列的每个值:
与大多数STL容器类不同,concurrent_queue::size_type为有符号整型,而不是无符号。这是因为concurrent_queue::size()定义为push(压入)操作次数减去pop(弹出)操作次数,如果弹出数大于压入数,那么size()则为负数,例如,如果一个concurrent_queue为空,且有n个未决弹出操作,size()返回-n,这就方便得知有多少个使用者在等待队列。
concurrent_vector< Key, T, HashCompare >
TBB中最后一个并行容器类为concurrent_vector模板类,concurrent_vector为一可动态增长的数组,当其并行增长时,还允许访问其中的元素。
concurrent_vector类为安全地并行增长定义了两个方法:grow_by及grow_to_at_least。方法grow_by(n)允许安全地追加n个连续的元素到向量中,并返回第一个所追加元素的下标,每个元素由T()初始化;方法grow_to_at_least(n)会把一个当前小于n个元素的向量增长到大小n。
下面的代码安全地把一个C字符串追加到一个共享的向量中:
void Append ( concurrent_vector& vector, const char* string) {
size_t n = strlen(string) + 1;
memcpy( &vector[vector_grow_by(n)], string, n+1 );
}
方法size()返回向量中的元素个数,其可能包含方法grow_by与grow_to_at_least正在进行并行构造的元素,因此,在concurrent_vector正在增长时,可安全地使用iterator(迭代子),只要iterator不超出end()的当前值,然而,iterator还是有可能会引用到一个正在并行构造的元素,所以,当使用Intel TBB concurrent_vector时,对构造及访问进行同步是开发者的责任。
一个string_count
例子
下面的代码使用了STL map来计算数组Data中有多少个不同的字符串,并生成了多个Win32线程让每个线程处理其中一块数据。
方法CountOccurrences创建了线程,并把数组中需处理的一段元素传递给每个线程,方法tally进行计数任务,每个线程都会遍历分配给它的那部分数据,并递增STL map中相应的元素。示例中第30行输出计数花费的总时间及字符串的总数(这个总数应等N的数据集大小)。
00: const size_t N = 1000000;
01: static string Data[N];
02: typedef map StringTable;
03: StringTable table;
04: DWORD WINAPI tally ( LPVOID arg ) {
05:
pair *range =
06:
reinterpret_cast< pair * >(arg);
07:
for( int i=range->first; i < range->second; ++i )
08:
table[Data[i]] += 1;
09:
return 0;
10: }
11: static void CountOccurrences() {
12: HANDLE worker[8];
13: pair range[8];
14: int g = static_cast(ceil(static_cast(N) / NThread));
15: tick_count t0 = tick_count::now();
16: for (int i = 0; i < NThread; i++) {
17:
int start = i * g;
18:
range[i].first = start;
29:
range[i].second = (start + g) < N ? start + g : N;
20:
worker[i] =
21:
CreateThread( NULL, 0, tally, &range[i], 0, NULL );
22: }
23: WaitForMultipleObjects ( NThread, worker, TRUE, INFINITE );
24: tick_count t1 = tick_count::now();
25: int n = 0;
26: for( StringTable::iterator i=table.begin();
27:
i!=table.end(); ++i )
28:
n+=i->second;
39:
30: printf("total=%d time = %g/n",n,(t1-t0).seconds());
31: }
找出不安全的容器类使用之处
在Microsoft Visual Studio .NET 2005中编译以上例子,并在一台有四个Intel Xeon处理器的服务器上以四个线程执行,你会立即发现有地方出错了,由30行输出的总数在每次执行时都不同,且永远都不等于N(1000000),也许是赛跑状态或其他线程错误。
下面,使用了Intel Thread Checker来进行检测,它不但可以检查用户代码中潜在的数据赛跑、死锁及其他线程错误,还可以找出线程间的潜在依赖性,并把这些依赖性问题映射到源代码中具体的行。此例中所有的潜在依赖性问题都指向同一处地方,第08行,这也是线程访问STL map之处。
图1:Intel Thread Checker可定位依赖性,易于消除潜在的冲突。
对应的解决方案
图1清楚地表明了需要同步访问map,首先,我们使用了一个Win 32 Mutex或CRITICAL_SECTION对象来保证访问的正确,正如前面所说的,粗粒度(即人工的)的同步,是在使用非线程安全库时,唯一保证线程安全的方法。
Win32 Mutex是一种内核对象,它对不同的进程都可见,当用Mutex对象来保证对STL map访问的安全时,这会带来巨大的性能开销;另一方面,Win32 CRITICAL_SECTION是一种 轻量的、位于用户空间内及进程内的Mutex对象,所以它是一个更佳的选择。
下面是使用STL map时的同步版本及顺序实现版本的对比图。
图2:三者都没有性能优势
如果未同步,STL map表现出良好的执行性能,但由于并行访问导致了不正确的结果;与大家预料的一样,Win32 Mutex开销最大,所以性能最低;而粗粒度同步的CRITICAL_SECTION对象同样性能也不佳,也就是说,两个线程安全的实现都比原始顺序执行要花更多的时间,同时,为保证结果正确的同步,也在某些方面限制了程序的可扩展性。图3是用TBB concurrent_hash_map替换STL map之后的结果。
图3:Intel TBB concurrent_hash_map相比STL map提供了更好的性能
对比concurrent_hash_map所带来性能增长及未同步处理的STL map,就会明白,Intel TBB所提供的线程安全是要付出一定代价的,但与其他线程安全的实现不同的是,它在提供并行访问线程安全的基础上,仍然有不止一倍的速度提升,因此,TBB并行容器类提供了其他非安全容器类所不能提供的高性能。
现今,越来越多的开发者面临转移到多核系统的需要,而且这个数字还在不断增长,他们亟需可用的工具,使多线程程序的开发错误更少,TBB中的并行容器类就是应运而生的这样一种工具,它以安全、可伸缩的实现,使这个过渡的过程更加轻松,从而可避免使用C++ STL中那些非线程友好的容器类及它们带来的问题。