避免线程之间发生堆冲突

避免线程之间发生堆冲突 (PDF 256KB)

摘要

由于系统运行时库使用锁定的方式同步对堆的访问,因此从系统堆分配内存的操作成本非常高。 对于锁的争用限制了多线程的性能优势。 要解决这个问题,可采用避免使用共享锁的分配战略,或使用第三方堆管理器。

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

背景

系统堆(为 malloc 所用)是一种共享资源。 为了确保多线程可以安全使用系统堆,必须添加同步机制以管理对共享堆的访问。 实现同步(在本例中获得锁)需要与操作系统进行两次交互(即,锁定和解锁),这会带来大笔开销。 所有内存分配的串行化问题更大,因为线程需要花费大量时间等待锁,而不是执行有用的工作。

在图1 和图 2 中显示的 Intel Parallel Amplifier 截屏说明了多线程 CAD 应用中的堆争用问题。


图 1. 堆分配例程和其所调用的内核函数是主要的性能瓶颈,消耗了大部分应用执行时间。


图 2. 堆分配例程中的关键部分是争夺最激烈的同步对象,导致大量等待时间和较低的利用率。

建议

英特尔编译器中的 OpenMP* 实施导出两个函数: kmp_mallockmp_free。 这两个函数确保为OpenMP所用的每个线程分配一个线程堆,避免使用保护标准系统堆访问的锁。

Win32* API 函数 HeapCreate 可用于为应用使用的所有线程分配独立的堆。 HEAP_NO_SERIALIZE 标志用于禁止在该新堆上使用同步,因为只有单条线程可以访问它。 堆句柄可存储在线程本地存储 (TLS) 位置,以便在应用线程需要分配或释放内存时随时使用该堆。 注意,以这种方式分配的内存必须由执行分配的同一线程明确释放。

下面的示例演示了如何使用上面提到的 Win32 API 特性来避免堆争用。 新线程创建伊始便使用动态加载库(.DLL) 加以注册,并为每条线程请求独立管理的非同步堆,然后使用 TLS 来记录分配给线程的堆。

 

#include 

	

	static DWORD tls_key;

	

	__declspec(dllexport) void *

	thr_malloc( size_t n )

	{

	return HeapAlloc( TlsGetValue( tls_key ), 0, n );

	}

	

	__declspec(dllexport) void

	thr_free( void *ptr )

	{

	HeapFree( TlsGetValue( tls_key ), 0, ptr );

	}

	

	// This example uses several features of the WIN32 programming API

	// It uses a .DLL module to allow the creation and destruction of

	// threads to be recorded.

	

	BOOL WINAPI DllMain(

	HINSTANCE hinstDLL, // handle to DLL module

	DWORD fdwReason, // reason for calling function

	LPVOID lpReserved ) // reserved

	{

	switch( fdwReason ) {

	case DLL_PROCESS_ATTACH:

	// Use Thread Local Storage to remember the heap

	tls_key = TlsAlloc();

	TlsSetValue( tls_key, GetProcessHeap() );

	break;

	

	case DLL_THREAD_ATTACH:

	// Use HEAP_NO_SERIALIZE to avoid lock overhead

	TlsSetValue( tls_key, HeapCreate( HEAP_NO_SERIALIZE, 0, 0 ) );

	break;

	

	case DLL_THREAD_DETACH:

	HeapDestroy( TlsGetValue( tls_key ) );

	break;

	

	case DLL_PROCESS_DETACH:

	TlsFree( tls_key );

	break;

	}

	return TRUE; // Successful DLL_PROCESS_ATTACH.

	}

	

	

 

在使用 POSIX* 线程 (Pthreads*) 的应用中,可通过pthread_key_createpthread_{get|set}特定 API 获取 TLS 的访问权,但是并无通用 API 可供创建独立堆。 虽然可以为每个线程分配大块内存,并将其地址存储在 TLS 中,但是管理这部分存储还是编程人员的责任。

除了使用多个独立堆,还可以结合其它技术最大限度地减少因共享锁(用于保护系统堆)引起的锁争用。 如果仅在小语境中访问内存,那么有时候可使用 alloca 例程从当前堆栈帧分配内存。 当函数返回后,该内存自动释放。

 

// Uses of malloc() can sometimes be replaces with alloca()

	{

	…

	char *p = malloc( 256 );

	

	// Use the allocated memory

	process( p );

	

	free( p );

	…

	}

	

	// If the memory is allocated and freed in the same routine.

	

	{

	…

	char *p = alloca( 256 );

	

	// Use the allocated memory

	process( p );

	…

	}

	

 

注意,微软不赞成使用_alloca,而是推荐使用安全性更高的 _malloca 例程。 该例程可根据请求的内存大小从堆栈或堆进行分配;因此,通过 _malloca 获得的内存应使用 _freea释放。

按线程释放列表是另一项技巧。 最初,使用 malloc从系统堆中分配内存。 当内存要正常释放时,它会被添加到一个按线程链接列表。 如果该线程需要重新分配同样大小的内存,它可以立即在该列表中检索存储的分配信息,而不必回到系统堆。

 

struct MyObject {

	struct MyObject *next;

	…

	};

	

	// the per-thread list of free memory objects

	static __declspec(thread)

	struct MyObject *freelist_MyObject = 0;

	

	struct MyObject *

	malloc_MyObject( )

	{

	struct MyObject *p = freelist_MyObject;

	

	if (p == 0)

	return malloc( sizeof( struct MyObject ) );

	

	freelist_MyObject = p->next;

	

	return p;

	}

	

	void

	free_MyObject( struct MyObject *p )

	{

	p->next = freelist_MyObject;

	freelist_MyObject = p;

	}

	

 

如果上述技巧不适用(例如,分配内存的线程不一定就是释放内存的线程)或内存管理仍然存在瓶颈,可以考虑使用第三方技术来替代堆管理器。 英特尔 ® 线程构建块 (Intel TBB) 提供了一个支持多线程的内存管理器,可与支持 Intel TBB 并使用 OpenMP 的应用以及手动线程化应用配合使用。 在本文最后的其它资源部分列举了一些其它第三方堆管理器。

使用指南

使用任何一种优化方法,都存在取舍问题。 在本例中是用更低的系统堆争用以换取更高的内存利用率。 当每个线程保有其自己的私有堆或对象集时,这些区域对于其它线程来说不可用。 这可能导致不同线程之间的内存不平衡,类似于线程执行不同的工作负载时遇到的负载不平衡。 内存不平衡可能引起工作集的大小和应用使用的总内存增加。 内存使用的增加通常对性能有轻微影响。 当内存使用的增加耗尽了所有可用内存时就会发生异常。 如果发生这种状况,可能会引起应用中断或swap(切换)到磁盘。

更多资源

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