咸蛋村小班
C++
扫盲课程
SGI STL 内存分配解析
作者:狐狸发
void 小品()
{
菜鸟咸蛋CY(简称CY)一路走一路哼哼唧唧:早上去上班,天天不迟到,小鸟说早早早,你为什么不穿小裤衩~~.各位观众,昨天咸蛋村遭遇了特大裤衩盗窃事件,很多村民的裤衩被盗,案情很严重,村民很生气,没办法,只好先去买裤衩了.
CY来到咸蛋村小卖部:老板,给来条裤衩啵.
泡泡:来了客官,丫的臀围多少啊.
CY:85cm啵.
泡泡:ok,给你来条S号的咸蛋牌裤衩,你看合身不,等一下,要试穿请到更衣室,裤衩试穿了不能退.
CY:丫的还来霸王条款呀,大小正合适,回见了啵.
Idel(20);
可乐:今年过节不偷窃,不偷窃呀不偷窃,要偷只偷小裤衩,这鼠年一到贼滴品位都不一样勒,大老爷们滴裤衩都保不住勒,这往后滴日子没法过勒冏.没办法,只好先去买个裤衩了.掌柜的,给来条裤衩.
泡泡:嘿,你小子也来买裤衩,行啊,臀围多少呀.
可乐:88cm.
泡泡:ok,给你来条M号的咸蛋牌裤衩,打15折.
可乐:什么叫打15折啊.
泡泡:就是多收一半钱呀.
可乐:凭什么呀.
泡泡:你不是火星人吗,那要交关税呀.
可乐:我OO你个XX.
Idel(20);
狒狒:RP爆发啦爆发啦,俺滴裤衩也有人偷,暗爽呀,魅力值高就是好呀,不知道是那位美女要拿去寄托相思啊,问世间情为何物,直教人没了裤衩~~,得去再买几条,万一晚上美女还有需要怎么办.泡泡兄,给来条裤衩.
泡泡:哎呀呀,裤衩需求大涨呀,看来要加强进货啦,这位臀围多少呀.
狒狒:95cm.
泡泡:ok,给你来条L号咸蛋牌裤衩.
狒狒:今天心情好,不用找零啦,就当请你喝茶啦.
Idel(50);
50米:老板,来条裤衩.
泡泡:不是吧,你的裤衩也被偷了?
50米:才怪,不知道咋滴,就是没人偷,我就奇了怪了,难道是因为不是品牌的?
泡泡:那你的臀围?
50米(小声):50m.
泡泡:冏,没这个尺寸呀.
50米:那怎么办呀,是不是该返回个NULL啊.
泡泡:没办法,只好现做了,你等会儿啊.
50米:要等多久啊?
泡泡:那可说不准,裁缝只有一个,有好多活要干呢,一个一个轮着来吧.
50米:冏zZZ.
}
狐狸发:好了,小品演完了,不知道各位有什么收获啊,悟出些什么没有.
呆熊:痱子捂出来几个.
Sammy:咦,这标题不是< SGI STL 内存分配解析>吗,跟裤衩有什么必然的联系吗,完全是挂羊头卖狗肉吧.
狐狸发:没错,我要讲的就是SGI STL的内存分配解析机制,这个小品里面正好提到了几个重点,这种手法就叫做隐喻,用一些人们熟知的概念去做比喻,可以更好向观众的表达观点.
点总:俺悟了,SGI STL内存分配肯定跟偷裤衩有关.
众人:冏.
狐狸发:众所周知,内裤的尺寸是根据一定的比例固定制作的,比如臀围84-87cm对应S号,88-92cm对应M号,93-97cm对应L号,98-110对应XL号,比如CY的臀围是85
cm,那么他应该选择S号的.这样做的好处是厂家可以按分类大量地生产产品以提高生产力.SGI STL中的node_alloc也采用了这种方法,下面我们看下它的源代码中是怎么进行分类的.
# if
defined (__OS400__) || defined (_WIN64)
enum
{_ALIGN = 16, _ALIGN_SHIFT = 4, _MAX_BYTES = 256};
# else
enum
{_ALIGN = 8, _ALIGN_SHIFT = 3, _MAX_BYTES = 128};
# endif
/* __OS400__ */
这里可以清楚的看到,以32位环境为例,内存是以8字节为边界分类的,最大的内存块是128字节.
static
void* _STLP_CALL allocate(size_t& __n)
{ return (__n > (size_t)_MAX_BYTES) ? __stl_new(__n) : _M_allocate(__n); }
这里可以看到大于
_MAX_BYTES的内存就不在高速内存池中分配了,就跟50米的裤衩需要定制一样,交由OS处理.这样一来就有128除以8,共16个分类.
可能有人会有疑问,如果我只分配3个字节,而你最少要分配8个字节,是不是会有浪费呢.关于这一点其实不用担心,我们用VC++7.1做示例,在MSDN上查找C++ Compiler Options中的/Zp这一项可以看到struct member alignment缺省值是8,这也就是说,自定义的struct一般是8的倍数,所以SGI STL取这个值也无可厚非.
再来看SGI STL内存池中是怎么进行内存分配的呢.
void
* __node_alloc_impl::_M_allocate(size_t& __n)
{
__n = _S_round_up(__n);
_Obj* __r = _S_free_list[_S_FREELIST_INDEX(__n)].pop();
if (__r == 0)
{
__r = _S_refill(__n);
}
…
return __r;
}
这里我只列出了关键代码,
_S_round_up
的作用是将不能被8整除的数补充为能被8整除的,比如输入3,输出为8._S_FREELIST_INDEX
(__n)
是一个宏,代码如下:
#define
_S_FREELIST_INDEX(__bytes) ((__bytes - size_t(1)) >> (int)_ALIGN_SHIFT)
这个宏的作用就是找到内存尺寸的分类索引,刚才说到,SGI STL将1-128字节大小的内存块以8字节为单位分成了16块,对应16个freelist,每个freelist专门管理一个尺寸的内存块,那么这个宏的目的是为了找到所属的freelist用来分配内存.
如果freelist空了,那么就用
_S_refill
再分配一些内存块,就好比商店批量进货.
这里先简单分析一下,这里可以看出freelist的分配时间复杂度是O(1)的,批量分配内存时间复杂度也是O(1)的,_S_refill批量加入内存块的时间复杂度是O(n)的,我把_S_refill中关键代码提取出来看一下就知道.
for
(--__nobjs; __nobjs != 0; --__nobjs)
{
__cur_item
= __REINTERPRET_CAST(_Obj*, __REINTERPRET_CAST(char*, __cur_item) + __n);
__my_freelist->push(__cur_item);
}
不用看的太复杂,这里是个for循环,那么肯定是O(n)的了,如果批量分配内存设计的合理的话,那么这个方案的性能是很好的.
下面再看看,内存释放的部分.
void
__node_alloc_impl::_M_deallocate(void *__p, size_t __n)
{
_S_free_list[_S_FREELIST_INDEX(__n)].push(__STATIC_CAST(_Obj*, __p));
…
}
内存块匹配到freelist部分就不重复讲了,这里可以看到就是简单的push,有此可以知道内存的释放时间复杂度是O(1)的.
有人就奇了怪了,你怎么知道freelist的push和pop的时间复杂度是O(1)的呢.
哎呀,这个偶忘记说了,那么我们就细细道来.
SGI STL的freelist的实现是last in first out(LIFO),简单的说就是后进先出,跟栈一样.那么有人会问,那么在多线程下是不是有锁来解决并发问题呢?回答是肯定的,不过还有一个好消息,那就是SGI STL的freelist对支持
cmpxchg8b
这样指令的环境提供了Lock free的算法支持,使得并发下的锁定代价降低到很小.那么究竟有多小呢?
我自己做了一下简单的测试:
使用SGI STL的allocator和new分配16字节的结构4000000次
CPU
:PD2.8G
OS
: Windows xp2
编译器:VC++7.1
线程数:4
debug
模式 时间比例是1:14.
Release
模式 时间比例是1:4.5
这个数值仅供参考,不过也说明了效率上会有提升的啦.有兴趣的话你可以自己做个测试.
freelist push,pop
的代码如下
使用Windows API
void
push(item* __item)
{
InterlockedPushEntrySList
(&_M_head, __item);
}
item
* pop()
{
return
InterlockedPopEntrySList(&_M_head);
}
Windows下直接使用内嵌汇编
void
push(item* __item)
{
__asm
{
mov esi, this
mov ebx, __item
mov eax, [esi] // _M._M_data._M_top
mov edx, [esi+4] // _M._M_data._M_sequence
L1: mov [ebx], eax // __item._M_next = _M._M_data._M_top
lea ecx, [edx+1] // new sequence = _M._M_data._M_sequence + 1
lock cmpxchg8b qword ptr [esi]
jne L1 // Failed, retry! (edx:eax now contain most recent _M_sequence:_M_top)
}
}
item
* pop()
{
__asm
{
mov esi, this
mov eax, [esi] // _M._M_data._M_top
mov edx, [esi+4] // _M._M_data._M_sequence
L1: test eax, eax // _M_top == NULL?
je L2 // Yes, we're done
mov ebx, [eax] // new top = _M._M_data._M_top->_M_next
lea ecx, [edx+1] // new sequence = _M._M_data._M_sequence + 1
lock cmpxchg8b qword ptr [esi]
jne L1 // Failed, retry! (edx:eax now contain most recent _M_sequence:_M_top)
L2:
}
}
Linux平台下使用内嵌汇编
void
push(item* __item)
{
int __tmp1; // These dummy variables are used to tell GCC that the eax, ecx,
int __tmp2; // and edx registers will not have the same value as their input.
int __tmp3; // The optimizer will remove them as their values are not used.
__asm__ __volatile__
(" movl %%ebx, %%edi/n/t"
" movl %%ecx, %%ebx/n/t"
"L1_%=: movl %%eax, (%%ebx)/n/t" // __item._M_next = _M._M_data._M_top
" leal 1(%%edx),%%ecx/n/t" // new sequence = _M._M_data._M_sequence + 1
"lock; cmpxchg8b (%%esi)/n/t"
" jne L1_%=/n/t" // Failed, retry! (edx:eax now contain most recent _M_sequence:_M_top)
" movl %%edi, %%ebx"
:"=a" (__tmp1), "=d" (__tmp2), "=c" (__tmp3)
:"a" (_M._M_data._M_top), "d" (_M._M_data._M_sequence), "c" (__item), "S" (&_M._M_data)
:"edi", "memory", "cc");
}
item
* pop()
{
item* __result;
int __tmp;
__asm__ __volatile__
(" movl %%ebx, %%edi/n/t"
"L1_%=: testl %%eax, %%eax/n/t" // _M_top == NULL?
" je L2_%=/n/t" // If yes, we're done
" movl (%%eax), %%ebx/n/t" // new top = _M._M_data._M_top->_M_next
" leal 1(%%edx),%%ecx/n/t" // new sequence = _M._M_data._M_sequence + 1
"lock; cmpxchg8b (%%esi)/n/t"
" jne L1_%=/n/t" // We failed, retry! (edx:eax now contain most recent _M_sequence:_M_top)
"L2_%=: movl %%edi, %%ebx"
:"=a" (__result), "=d" (__tmp)
:"a" (_M._M_data._M_top), "d" (_M._M_data._M_sequence), "S" (&_M._M_data)
:"edi", "ecx", "memory", "cc");
return __result;
}
看来这个神奇的Lock free的算法使得freelist的存取时间复杂度为O(1)的同时也保证了单位时间周期缩短,大大减少了多线程下锁定等待时间.其实就算不用在内存分配算法上,作为一个多线程编程中的Stack数据结构也是很好很强大的啊.
CY:stop,啥玩艺Lockfree啊,这个
cmpxchg8b指令怎么就Lockfree啊,你不是在忽悠我们吧.
狐狸发:靠,怎么越讲越多了,岂不是要拖课.没办法,讲不清楚前面就白讲了,强烈要求提升待遇.关于
cmpxchg8b指令,请大家找google和baidu.
众人哗然.
狐狸发:不过我还是讲一下它的功用以及用它实现这个Lockfree的freelist的原理.这个指令的原理就是修改前先比较是否目标是原始值,如果以被修改就失败并放弃再次修改该值.举个例子,原来有个整数X,值为1,这时候2个线程都要对其进行加1操作,线程A对其进行cmpxchg操作想把它从1改为2,如果这时候线程B还没有把X改为2,那么操作成功,这时候线程B对其进行cmpxchg操作想把它从1改为2,但是发现X不是1了,那么操作失败,进行重试再用cmpxchg操作想把它从2改为3,这次成功了,X的值变成了3,好了2个线程都操作完了,并取得了正确的结果.
CY:哦,那么线程B岂不是执行了2次增量操作,如果CPU核心多了这样是不是会浪费太大呢.
狐狸发:在这个问题上其实是没什么浪费的,表面上看上去线程B多做了无用功,但是这2个线程本来就是要串行执行逻辑的,总有1个线程一定要等另1个线程先完成,如果用锁定的话,那么这个线程要被先睡眠,然后被唤醒,还可能要更新缓存,代价会大很多,比如在WindowXP下,线程切换至少是10ms,用来做自增动作能做多少次呀.
那么继续话题,用这个操作我们来实现freelist的push动作,假设有2个线程A和B要将节点X和Y push到freelist里面,freelist的首节点为H,指向它的指针为PH.A把X的next的值设为H,然后用cmpxchg将PH的值从H改为X,假设线程B还没有改变成功,那么线程A改变成功,push完成.线程B也进行和A一样地动作,只是他cmpxchg将PH的值从H改为Y的时候,发现PH的值变成X了,那么再重试一次,这次用cmpxchg将PH的值从X改为Y,结果成功,2个线程都完成了push动作.
Thread A Thread B
X.next -> H
cmpxchg PH,H,X Y.next -> H
成功
cmpxchg PH,H,Y
失败,PH已被改为X
Y.next -> X
cmpxchg PH,X,Y
成功
那么下面再讲一下怎么实现freelist的pop动作呢.
CY:这个太简单了,跟push一样啵,把PH用cmpxchg改成H的next,再把H拿出来就是了.
狐狸发:说的很对.但是事情没有就这样结束.假设线程A要从freelist里面pop一个节点,freelist的首节点指针PH的值为H这时候线程B将freelist的里面的pop,push了多次,最后一次push正好是原来H,不同地是这次H的next变了.
Thread A执行前的freelist H->N1->N2->N3
Thread B执行后的freelist H->N3->N2
Thread A执行后的freelist N1->?
那么Thread A拿出来到还是H,但是会把PH设置成N1而不是N3.
这个问题怎么解决呢,大家可能注意到我上面的指令用滴是cmpxchg而不是一开始说的cmpxchg8b,解决这个问题正是要用cmpxchg8b来完成,cmpxchg可以用来修改4字节的整数,而cmpxchg8b是用来修改8字节的整数的,由于可以多修改4个字节的整数,那么我们在push和pop的时候加上一个计数器,是不是就可以防止刚才那个问题呢.
我们在PH后面加上一个Count,每次push或pop都把它加1,那么Thread A执行cmpxchg8b的时候会发现PH虽然都是H,但是Count改变了,那么A会重试,最后的结果就会正确了.
SGI STL中的源代码是这样的
Union
{
long long _M_align;
struct
{
item* _M_top; // Topmost element in the freelist
unsigned int _M_sequence; // Sequence counter to prevent "ABA problem"
} _M_data;
} _M;
Lockfree freelist的实现就讲完了,小朋友们听明白了吗.
众人:zzZ…….
狐狸发:好处都说完了,那么缺点也要说一下,这个内存分配器的缺点就是内存池不能动态的回收已分配的内存,对于某些内存应用峰值很高但是峰值时间很短的情况下会有内存浪费的现象,如果你的工作环境内存很多就不用太在意.好了,下课鸟.
众人奇迹般复活,找寻艳照去也.
总结语:在Windows 2000,Windows XP下的应用中,由于MS自带的PJ STL使用new导致效率低下,采用SGI STL会有很大的性能提升,而linux,Windows 2003的内存分配已经采用了的办法,所以直接使用原先的STL也关系不大.
注:该程序引用的代码来自STLport-5.1.4
再注:本故事纯属虚构,如有雷同,那是RPWT.