借助线程局部存储减少同步处理

借助线程局部存储减少同步处理 (PDF 241KB)

摘要

同步化处理通常是一个昂贵的操作,它限制一个多线程程序的性能。 使用线程局部数据结构来代替由线程共享的数据结构可在某些情况下减少同步化处理,使一个程序运行得更快。

本文是“英特尔多线程应用开发指南”系列的一部分,该系列介绍了针对英特尔® 平台开发高效多线程应用的指导原则。

背景

当数据结构由一组线程共享时,至少有一个线程写入其中,线程之间的同步化处理有时需要确保是否所有线程均会看到一致的共享数据。 这种情况下,线程的一个典型同步化访问制度是一个线程获取锁,读取或写入共享数据结构,然后释放锁。

所有形式的锁都有一定的开销以维持锁数据结构,同时它们使用了减慢现代处理器速度的原子结构。 同步化处理也同样减慢了程序运行速度,因为其消除了同步代码中的并行执行,形成了一系列执行瓶颈。 因此,当同步化在一个时间关键代码段发生时,代码性能将会受到影响。

如果该程序可被重新写入并使用线程局部存储而非共享数据结构,那么同步化可以从多线程、时间关键代码段中消除。 如果代码的属性是这样,那么共享数据访问的实时定序将不重要了。 如果在不频繁、非时间关键代码段期间,定序能够安全被推迟执行,当访问定序重要时,同步化也可被消除。

思考一下,例如,使用一个变量计数发生在几个线程上的事件。 下面是用 OpenMP* 编写这样一个程序的一种方法:

 

int count=0;

	#pragma omp parallel shared(count)

	{

	. . .

	if (event_happened) {

	#pragma omp atomic

	count++;

	}

	. . .

	}

	

 

该程序在每次事件发生时都需付出代价,因为其必须实施同步化以确保一次只能有一个线程增加计数。 每个事件都会引发同步化。 消除同步化使得程序运行更快。 其中一种方法是让每个线程在并行区域期间计数其自身的事件,然后在合计各个事件。 该技术体现在下面的程序中:

 

int count=0;

	int tcount=0;

	#pragma omp threadprivate(tcount)


	omp_set_dynamic(0);


	#pragma omp parallel

	{

	. . .

	if (event_happened) {

	tcount++;

	}

	. . .

	}

	#pragma omp parallel shared(count)

	{

	#pragma omp atomic

	count += tcount;

	}

	

 

该程序使用了一个 tcount 变量,它是各线程的私有变量,可存储各自的计数。 在第一个并行区域计数完所有局部事件后,随后的一个区域将这个计数添加至整个计数中。 该解决方案以每事件同步化换取每线程同步化。 如果事件数量多于线程数量,那么性能将会提高。 请注意,该程序的前提是假设并行区域以相同线程数量执行。 调度 omp_set_dynamic(0)可防止线程的数量不同于该编程所需的数量。

针对编程中对时间要求苛刻的部分,使用线程局部存储的另一个好处是,如果处理器没有共享一个数据缓存,那么与共享数据相比,数据停留在处理器缓存的时间更长。 当几个处理器的数据缓存中存在相同的地址,并且该地址是由它们其中一个写入的,那么所有其他处理器的缓存一定会失效,因而导致当其他处理器访问该地址时,需要重新从内存中获取。 然而,线程局部数据除了局部处理器之外绝不允许其他任何处理器写入,因此更有可能留在自身处理器缓存中。

上述代码段显示了借助 OpenMP 指定线程局部数据的一个方法。 若使用 Pthreads 来指定线程局部数据,编程者必须创建一个线程局部数据密钥,然后通过该密钥来访问数据。 例如:

 

#include 


	pthread_key_t tsd_key;

	 value;



	if( pthread_key_create(&tsd_key, NULL) ) err_abort(status, “Error creating key”);

	if( pthread_setspecific( tsd_key, value))

	err_abort(status, “Error in pthread_setspecific”);

	. . .

	value = ()pthread_getspecific( tsd_key );



	With Windows threads, the operation is very similar. The programmer allocates a TLS index with TlsAlloc, then uses that index to set a thread-local value. For example:


	DWORD tls_index;

	LPVOID value;


	tls_index = TlsAlloc();

	if (tls_index == TLS_OUT_OF_INDEXES) err_abort( tls_index, “Error in TlsAlloc”);

	status = TlsSetValue( tls_index, value );

	if ( status == 0 ) err_abort( status, “Error in TlsSetValue”);

	. . .

	value = TlsGetValue( tls_index );

	

 

使用 OpenMP 时,通过将线程局部数据指定在并行程序上的一个私有子句中,编程者同样也可创建线程局部变量。 这些变量将会被自动释放在并行区域末尾。 当然,另一种不考虑线程模式的方法也可指定线程局部数据,即使用一个给定范围内的分配在堆栈上的变量。 这样的变量将在范围末尾被释放。

建议

如果同步化在一个代码的关键时间段内被编码,或者一个被同步化的操作需要被实时序列化,那么线程局部存储技术适用。 如果操作的实时顺序比较重要,并且在关键时间段期间可获取足够的信息来进行随后的复制序列化,那么此项技术在代码的非关键时间段仍适用。

例如,思考一下下面的示例,线程将数据写入共享缓存区:

 

int buffer[NENTRIES];


	main() {


	. . .


	#pragma omp parallel

	{

	. . .

	update_log(time, value1, value2);

	. . .

	}


	. . .

	}

	void update_log(time, value1, value2)

	{

	#pragma omp critical

	{

	if (current_ptr + 3 > NENTRIES) { print_buffer_overflow_message(); }


	buffer[current_ptr] = time;

	buffer[current_ptr+1] = value1;

	buffer[current_ptr+2] = value2;

	current_ptr += 3;

	}

	}

	

 

假设时间是递增值,针对该缓冲区数据的程序的唯一实际需求是数据可不定期写入一个文件内,并能够按时间排序。 借助线程局部缓冲,同步化可在 update_log例程中被消除。 每个线程将分配一个单独的 tpbuffertpcurrent_ptr副本。 借此可消除 update_log中的临界区。 随后,来自各个线程私有缓冲区的入口将根据时间值在该程序的一个非关键时间段内合并。

使用指南

关于该项技术的使用,请务必仔细权衡。 此项技术不能消除同步化需求,它仅能将同步化从代码的一个关键时间段迁移至该代码的一个非关键时间段内。

  • 首先,需确定包含同步化的原代码段性能是否因同步化而大幅下降。 英特尔® Parallel Amplifier 和/或英特尔® VTune ™ Performance Analyzer 可用来检测每个代码段的性能问题。
  • 其次,需确定操作时间定序是否对应用至关重要。 如果不是,同步化也被消除,例如事件计数代码中的同步化。 如果时间定序非常重要,定序能否随后正确地重新构建?
  • 最后,验证同步化被迁移至代码的另一个位置不会在新的地点引发类似的性能问题。 其中一种验证方法是观察同步化次数是否会因你的工作量而自动减少(例如上述示例中的事件计数)。

更多资源

有关编译器优化的更完整信息,请参阅优化通知