A fixed-size, multi-thread optimized allocator
原文URL:http://list.cs.brown.edu/people/jwicks/libstdc++/html/ext/mt_allocator.html
简介
mt allocator是一个固定大小(2的幂)内存的分配器,最初是为多线程应用程序(以下简称为MT程序)设计的。经过多年的改进,现在它在单线程应用程序(以下简称为ST程序)里也有出色的表现了。
本文的目的是从应用程序的角度描述mt allocator的“内幕”。
总体设计
mt allocator有3个组成部分:描述内存池特征的参数,把内存池关联到通用或专用方案的policy类,和从policy类继承来的实际的内存分配器类。
描述内存池特征的参数是:
template<bool _Thread>
class __pool
这个类表示是否支持线程,然后对多线程(bool==true)和单线程(bool==false)情况进行显式特化。可以用定制的参数来替代这个类。
对于policy类,至少有2种不同的风格,每种都可以和上面不同的内存池参数单独搭配:第一个策略,__common_pool_policy,实现了一个通用内存池,即使分配的对象类型不同,比如char和long,也使用同一个的内存池。这是默认的策略。
struct __common_pool_policy
template < typename _Tp, bool _Thread >
struct __per_type_pool_policy
第二个策略,__per_type_pool_policy,对每个对象类型都实现了一个单独的内存池,于是char和long会使用不同的内存池。这样可以对某些类型进行单独调整。
把上面这些放到一起,我们就得到了实际的内存分配器:
class __mt_alloc : public __mt_alloc_base < _Tp > , _Poolp
这个类有标准库要求的接口,比如allocate和deallocate函数等。
可调参数
有些配置参数可以修改,或调整。有一个嵌套类:包含了所有可调的参数,即:
l 字节对齐
l 多少字节以上的内存直接用new分配
l 可分配的最小的内存块大小
l 每次从OS申请的内存块的大小
l 可支持的最多线程数目
l 单个线程能保存的空闲块的百分比(超过的空闲块会归还给全局空闲链表)
l 是否直接使用new和delete
对这些参数的调整必须在任何内存分配动作之前,即内存分配器初始化的时候,比如:
struct pod
{
int i;
int j;
} ;
int main()
{
typedef pod value_type;
typedef __gnu_cxx::__mt_alloc<value_type> allocator_type;
typedef __gnu_cxx::__pool_base::_Tune tune_type;
tune_type t_default;
tune_type t_opt(16, 5120, 32, 5120, 20, 10, false);
tune_type t_single(16, 5120, 32, 5120, 1, 10, false);
tune_type t;
t = allocator_type::_M_get_options();
allocator_type::_M_set_options(t_opt);
t = allocator_type::_M_get_options();
allocator_type a;
allocator_type::pointer p1 = a.allocate(128);
allocator_type::pointer p2 = a.allocate(5128);
a.deallocate(p1, 128);
a.deallocate(p2, 5128);
return 0;
}
初始化
静态变量(内存链表的指针,控制参数等)的初始化为默认的值,比如:
__mt_alloc < _Tp > ::_S_freelist_headroom = 10 ;
首次调用allocate()时,也会调用_S_init()函数。为了保证在MT程序里它只被调用一次,我们使用了__gthread_once(参数是_S_once_mt和_S_init)函数;在ST程序里则检查静态bool变量_S_initialized。
_S_init()函数:如果设置了GLIBCXX_FORCE_NEW环境变量,它会把_S_force_new设置成true,这样allocate()就直接用new来申请内存,deallocate()用delete来释放内存。
如果没有设置GLIBCXX_FORCE_NEW,ST和MT程序都会:
1)计算bin的个数。bin是指2的指数字节的内存集合。默认情况下,mt allocator只处理128字节以内的小内存分配(或者通过在_S_init()里设置_S_max_bytes来更改这个值),这样就有如下几个字节大小的bin:1,2,4,8,16,32,64,128。
2)创建_S_binmap数组。所有的内存申请都上调到2 的指数大小,所以29字节的内存申请会交给32字节的bin处理。_S_binmap数组的作用就是快速定位到合适的bin,比如数值29被定位到5(bin 5 = 32字节)。
3)创建_S_bin数组。这个数组由bin_record组成,数组的长度就是前面计算的bin的个数,比如,当_S_max_bytes = 128时长度为8。
4)初始化每个bin_record:first是<block_record *>数组,程序可以有多少个线程,这个数组就有多长(ST程序只有1个线程,MT程序最多允许_S_max_threads个线程)。first里保存的是这个bin里每个线程第一个空闲块的地址,比如,我们要找线程3里32字节的空闲块,只需调用:_S_bin[ 5 ].first[ 3 ]。开始的时候first数组元素全是NULL。
对于MT程序,还要进行下面的工作:
5)创建一个空闲线程ID(1到_S_max_threads间的一个数值)的列表,列表的入口是_S_thread_freelist_first。由于__gthread_self()函数返回的不是我们需要的1到_S_max_threads之间的数值,而是类似于进程ID的随机数,所以我们需要创建一个thread_record链表,长度为_S_max_threads,每个thread_record元素的thread_id字段依次初始化成1,2,3,直到_S_max_threads,作为4)步中first的索引。当一个线程调用allocate()或deallocate()时,我们会调用_S_get_thread_id(),检查线程本地存储的变量_S_thread_key的值。如果是NULL则表示是新创建的线程,那么从_S_thread_freelist_first列表里拿出一个元素给该线程。下次调用_S_get_thread_id()时就会找到这个对象,并且找到thread_id字段和对应的bin位置。所以,首先调用allocate()的线程会分配到thread_id=1的thread_record,于是它的bin索引就是1,我们可以用_S_bin[ 5 ].first[ 1 ]来为它获取32字节的空闲内存。当创建_S_thread_key时我们定制了析构函数,这样当线程退出后,它的thread_record会归还给_S_thread_freelist_first,以便重复使用。_S_thread_freelist_first链表有锁保护,在增、删元素的时候加锁。
6)初始化每个bin_record 的空闲和使用的块数计数器。bin_record->free是size_t 的数组,记录每个线程空闲块的个数。bin_record->used也是size_t 的数组,记录每个线程正在使用的块的个数。这些数组的元素初始值都是0。
7)初始化每个bin_record的锁。bin_record->mutex用来保护全局的空闲块链表,每当有内存块加入或拿出某个bin时,都要进行加锁。这种情况只出现在线程需要从全局空闲链表里获取内存,或者把一些内存归还给全局链表的时候。
单线程模型(简化的多线程模型)
我们从空闲块链表的内存布局开始。下面是bin 3里线程号为3的空闲链表的头2个块:
在ST程序里,所有的操作都在全局内存池里——即thread_id为0(MT程序里任何线程都不会分配到这个id)。
当程序申请内存(调用allocate()),我们首先看申请的内存大小是否大于_S_max_bytes,如果是则直接用new。
否则通过_S_binmap找出合适的bin。查看一下_S_bin[ bin ].first[ 0 ]就能知道是否有空闲的块。如果有,那么直接把块移出_S_bin[ bin ].first[ 0 ],返回数据的地址。
如果没有空闲块,就需要从系统申请内存,然后建立空闲块链表。已知block_record的大小和当前bin管理的块的大小,我们算出申请的内存能分出多少个块,然后建立链表,并把第一个块的数据返回给用户。
内存释放的过程同样简单。先把指针转换回block_record指针,根据内存大小找到合适的bin,然后把块加到空闲列表的前面。
通过一系列的性能测试,我们发现“加到空闲列表前面”比加到后面有10%的性能提升。
多线程模型
在ST程序里从来用不到thread_id变量,那么现在我们从它的作用开始介绍。
向共享容器申请或释放内存的MT程序有“所有权”的概念,但有一个问题就是线程只把空闲内存返回到自己的空闲块链表里。(比如一个线程专门进行内存的申请,然后转交给其他线程使用,那么其他线程的空闲链表会越来越长,最终导致内存用尽)
每当一个块从全局链表(没有所有权)移到某个线程的空闲链表时,都会设置thread_id。其他需要设置thread_id的情况还包括直接从空白内存上建立某个线程的空闲块链表,和释放某个块删除时,发现申请块的线程id和执行释放操作的线程id不同的时候。
那么到底thread_id有什么用呢?当释放块时,我们比较块的thread_id和当前线程的thread_id是否一致,然后递减生成这个块的线程的used变量,确保free和used计数器的正确。这是很重要的,因为它们决定了是否需要把内存归还给全局内存池。
当程序申请内存(调用allocate()),我们首先看申请的内存大小是否大于_S_max_bytes,如果是则直接用new。
否则通过_S_binmap找出合适的bin。_S_get_thread_id()返回当前线程的thread_id,如果这是第一次调用allocate(),线程会得到一个新的thread_id,保存在_S_thread_key里。
查看_S_bin[ bin ].first[ thread_id ]能知道是否有空闲的内存块。如果有,则移出第一个块,返回给用户,别忘了更新used和free计数器。
如果没有,我们先从全局链表(freelist (0))里寻找。如果找到了,那么把当前bin锁住,然后从全局空闲链表里移出最多block_count(从OS申请的一个内存块能生成多少个当前bin的块)个块到当前线程的空闲链表里,改变它们的所有权,更新计数器和指针。接着把bin解锁,把_S_bin[ bin ].first[ thread_id ]里第一个块返回给用户。
最多只移动block_count个块的原因是,降低后续释放块请求可能导致的归还操作(通过_S_freelist_headroom来计算,后面详述)。
如果在全局链表里也没有空闲块了,那么我们需要从OS申请内存。这和ST程序的做法一样,只有一点注意区别:从新申请的内存块(大小为_S_chunk_size字节)上建立起来的空闲链表直接交给当前进程,而不是加入全局空闲链表。
释放内存块的基本操作很简单:把内存块直接加到当前线程的空闲链表里,更新计数器和指针(前面说过如果当前线程的id和块的thread id不一致的情况下该如何处理)。随后free和used计数器就要发挥作用了,即空闲链表的长度(free)和当前线程正在使用的块的个数(used)。
让我们回想前面一个线程专门负责分配内存的程序模型。假设开始时每个线程使用了512个32字节的块,那么他们的used计数器此时都是516。负责分配内存的线程接着又得到了1000个32字节的块,那么此时它的used计数器是1516。
如果某个线程释放了500个块,每次释放操作都会导致used计数器递减,和该线程的空闲链表(free)越来越长。不过deallocate()会把free控制在used的_S_freelist_headroom%以内(默认是10%),于是当free超过52(516 / 10)时,释放的空闲块会归还给全局空闲链表,从而负载分配的线程就能重用它们。
为了减少锁竞争(这种归还操作需要对bin进行加锁),归还操作是以block_count个块为单位进行的(和从全局空闲链表里获得块一样)。这个“规则”还可以改进,减少某些块“来回转移”的几率。