避免线程之间的堆争用
由于系统运行时库用于同步对堆的访问而使用的锁,从系统堆分配内存可能是一项昂贵的操作。该锁的争用会限制多线程带来的性能优势。了解如何解决此问题。
摘要
由于系统运行时库用于同步对堆的访问而使用的锁,从系统堆分配内存可能是一项昂贵的操作。该锁的争用会限制多线程带来的性能优势。要解决此问题,请应用一种避免使用共享锁的分配策略,或使用第三方堆管理器。
本文是大型系列“Intel 多线程应用程序开发指南”的一部分,该系列为 Intel® 平台上的高效多线程应用程序开发提供了指导方针。
背景
系统堆(如malloc
所使用的)是一个共享资源。为了使其能够安全地被多个线程使用,必须添加同步机制来控制对共享堆的访问。同步(在本例中为锁的获取)需要与操作系统进行两次交互(即锁定和解锁),这会产生昂贵的开销。所有内存分配的串行化是一个更大的问题,因为线程会花费大量时间等待锁,而不是执行有用的工作。
图 1 和图 2 中 Intel® Parallel Amplifier 的屏幕截图说明了多线程 CAD 应用程序中的堆争用问题。
通知
Intel® 编译器中的 OpenMP* 实现导出了两个函数:kmp_malloc
和 kmp_free
。这些函数维护一个附加到 OpenMP 使用的每个线程的每线程堆,并避免使用保护对标准系统堆访问的锁。
可以使用 Win32* API 函数 HeapCreate
为应用程序使用的所有线程分配独立的堆。使用标志 HEAP_NO_SERIALIZE
来禁用此新堆的同步使用,因为只有一个线程会访问它。堆句柄可以存储在线程局部存储 (TLS) 位置,以便在应用程序线程需要分配或释放内存时使用此堆。请注意,以这种方式分配的内存必须由执行分配的同一线程显式释放。
下面的示例说明了如何使用上述 Win32 API 功能来避免堆争用。它使用动态加载库(.DLL)在创建时注册新线程,为每个线程请求独立管理的非同步堆,并使用 TLS 来记住分配给该线程的堆。
#include <windows.h> 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_create
和 pthread_{get|set}specific
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 ); … }
但是,请注意 Microsoft 已弃用 _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® Threading Building Blocks (Intel® TBB) 提供了一个对多线程友好的内存管理器,可与 Intel TBB 启用的应用程序以及 OpenMP 和手动线程化的应用程序一起使用。其他一些第三方堆管理器列在本文章末尾的“附加资源”部分。
使用指南
任何优化都会带来权衡。在这种情况下,权衡是在系统堆上降低争用以换取更高的内存使用量。当每个线程维护自己的私有堆或对象集合时,其他线程无法使用这些区域。这可能导致线程之间的内存不平衡,类似于线程执行不同量的工作时遇到的负载不平衡。内存不平衡可能导致工作集大小和应用程序的总内存使用量增加。内存使用量的增加通常对性能影响很小。例外情况是当内存使用量的增加耗尽可用内存时。如果发生这种情况,应用程序可能会中止或交换到磁盘。
额外资源
- Intel® 软件网络并行编程社区
- Microsoft Developer Network:HeapAlloc, HeapCreate, HeapFree
- Microsoft Developer Network:TlsAlloc, TlsGetValue, TlsSetValue
- Microsoft Developer Network:_alloca, _malloca, _freea
- MicroQuill SmartHeap for SMP
- HOARD 内存分配器
- Intel® Threading Building Blocks
- Intel Threading Building Blocks 开源版
James Reinders, *Intel Threading Building Blocks: Outfitting C++ for Multi-core Processor Parallelism*. O’Reilly Media, Inc. Sebastopol, CA, 2007.