scalable_zone是zone其中一种类型, 分配堆内存管理者,malloc,free等函数真正的入口
SZone
在zone中,会根据申请的堆大小选择不同rack进行分配
zone和rack关系如下图
scalable_zone即s_zone, 拥有三个Rack_S对象,分别用来处理不同大小的堆内存操作
- tiny _rack 处理小于1009字节的分配
- small _rack 处理1009B~32KB的分配(iOS 1009B~15KB)
- medium_rack 处理32(iOS 15)KB~8M的分配
- (如果大于8M内存的申请,则通过系统调用申请)
相关函数
1 | create_scalable_szone // 创建zone |
Rack
在rack中,会根据当前运行线程的所在的核选择不同Magazine进行分配
rack和magazine关系如下
rack的属性magazine*
指向的是magazine数组。该数组内存区域通过系统调用mach_vm_map
申请, 申请大小magazine数组数量+1
magazines数量即rack
的num_magazines
属性如何确定呢? 以及为什么+1,多出来的那个magazine用来干啥的?
num_magazines
由当前主机CPU核数决定. 如果CPU核数没有设置,num_magazines=1
,如果大于1小于64,则取CPU核数值,如果大于64,则取64; 这么设计的目的是为了真正并发的性能
- 多核上多线程通过操作不同的magazine(对内存的malloc和free等), 可以减少lock的使用
- 让多核CPU的缓存得到充分利用,每个核拥有自己的多级缓存
+1
是为了给magazines数组加上一个depot magazine
, 放置在数组的首位置上,并且rack
的magazines
指向magazines数组第2个元素的位置
depot magazine
是当前线程所在的核对应的magazine无法分配出heap空间时,会通过depot magazine
分配。分配出的空间地址对应的heap region会添加到当前magazine中,在free block块合并的时候,也会给depot magazine
添加heap region. 当然后面会详细讲
相关函数
1 | rack_init // 初始化rack和创建magazines |
Magazine
magazine是真正操作堆内存的对象
magazine的结构如下, 其中每个属性注释在后面的malloc和free过程中都会说到
1 | struct magazine_s { |
Heap Region
堆内存 heap region是用户态malloc内存真正的区域
设计
数据结构如下
1 | typedef struct tiny_region { |
tiny_region布局结构如下
tiny_region通过系统调用mach_vm_map
分配, 分配出来的空间划分为两部分
Data
data区按照16个字节作为一个block, block也是malloc申请的最小单位
block也刚好能存储两个指针数据,可以作为空闲链表的一个节点MetaData
metaData存储着data区的使用情况(bitmap)以及heap_region prev和next的metaData指针(双向链表)
和magazine的关系
- magazine的
firstNode
和lastNode
指向heap region metaData双向链表的第一个和最后一个节点 - magazine的
mag_last_region
指向最后一次magazine向内核申请的heap region
描述如下图
Last_Free
magazine
的last_free等属性
储存着该magazine
最后一次free的block块地址,块大小,以及块地址的heap region
地址
Free_Lists
free
过的内存会作为节点储存在空闲链表中,而free_lists
是magazine
的空闲链表数组,数组元素空闲链表按照内存块大小递增
对于tiny_rack最多分配1008个字节,因此该rack下的每个magazine的空闲链表数组个数最大为1008/16 + 1 = 64,即第1个空闲链表的每个节点内存大小为1block(=16B), 第2个空闲链表的每个节点内存大小为2blocks(=32B)…第63个空闲链表的每个节点内存为63blocks(=1008B), 但最后一个第64个空闲链表的每个节点内存大于等于64blocks(>=1024B,该链表节点是free时合并产生)
magazine中的空闲链表数组结构如下
对于16B空闲链表的节点,只存储前驱和后继节点指针
对于大于16字节小于等于1008B空闲链表的节点,前面16字节存储前驱和后继指针,指针后两个字节存储块大小(如图,在free过程中向下一个block合并时用到), 最后两个字节也储存着块大小数据(在free过程中向上一个block合并时用到). 对应函数
TINY_FREE_SIZE
对于最后一个空闲链表,每个节点都是大于等于1024B. 其他指针,块大小数据布局同上.
SZone架构总结
整个宏观的架构设计就解释到这了,如下图
当然上面的解释还是太宏观了,很多巧妙的设计操作以及分配策略都没有提及,接下来就看看具体的malloc和free操作
Malloc and Free
tiny_malloc
tiny_malloc_should_clear
是整个分配流程, 在函数szone_malloc_should_clear
中通过判断申请的内存大小调用的,当申请大小小于1009个字节时调用
msize
msize表示block申请大小单位,1表示申请1个block,16字节。当申请大小不满16B,分配1个block,申请大于16B小于等于32B,分配2个block, 以此类推…
具体实现: msize = (size+15) >> 4 即 msize = (size+15)/16
整个分配简介流程如下,后面对每个步骤过程会详细介绍
1 | void * |
current magazine
根据当前线程找到tiny_rack对应tiny _magazine, 之后的分配流程操作会基于该magazinelast_free
判断该magazine的last_free cache大小和申请大小是否匹配,如果匹配,则分配该地址free_list of curretn magazine
2步骤失败,通过magazine的空闲链表分配free_list of depot magazine
3步骤失败,通过tiny _rack的depot _magazine的空闲链表分配mmap
4步骤失败,通过系统调用向内核申请一块heap region, 并从中分配return nil
5步骤失败,return nil
大致过程就介绍到这了,如果对细节不敢兴趣,可以直接跳到tiny_free
了解free的过程
下面开始介绍详细过程
1. current magazine
函数tiny_mag_get_thread_index
根据当前线程所在的CPU核,获取tiny_rack
的magazines
的偏移,从而得到当前核的magazine
2. last_free
magazine
属性mag_last_free
, 表示该magazine
最后一次free的地址(没有添加到空闲链表中)
通过上一步得到的magazine,判断magazine的mag_last_free_msize
和当前申请大小(转换成msize)是否匹配, 如果匹配,则返回该地址mag_last_free
验证demo
1 | void* ptr1 = malloc(16); // msize = 1 |
打印可以发现ptr1和ptr2是同一地址
漏洞利用
如果cleared_requested
为false, 那么mag _last _free分配出来的内存的内容不会被清除,还是上一个对象的内容
3. free_list of curretn magazine(重点)
函数tiny_malloc_from_free_list
从当前magazine
的空闲链表和last heap region
中分配空间
从空闲链表分配也有好几个步骤
free_list of msize
找到msize对应大小的空闲链表(之前说过magazine拥有空闲链表数组),有节点,直接分配头节点
first free_list which is more than msize and has node
1步骤失败,找到第一个比msize大, 节点不为空的空闲链表(bit操作);有节点,分配节点,并将多余的块大小添加到对应的空闲链表中
last free_list
查看空闲链表数组的最后一个空闲链表(节点内存大于1008B)是否有节点,如果有,则分配,并将多余的块大小添加到对应的空闲链表中
last heap region
2,3步骤失败,表示当前magazine的空闲链表数组中比申请msize大的空闲链表都没有节点.
从mag_last_region
中申请内存,该heap region 是该magazine最后一次向内核申请的堆内存区域,有些内存block还没有用过(free过的存在空闲链表中)
- 4步骤失败,表示最后一块heap内存区域也没有多余的空间了,整个大的步骤结束
return_tiny_alloc
如果申请成功,做一些收尾工作,即将节点从空闲链表中移除,将多余的块作为节点添加到对应的空闲链表中,设置block对应的bitmap信息
下面是从空闲链表分配的详细过程
1. 从空闲链表数组中找到大小匹配的空闲链表
获取申请大小msize对应的空闲链表偏移(slot),得到对应msize的空闲链表
1
2
3
4
5
6static inline grain_t
tiny_slot_from_msize(msize_t msize)
{
// msize > 63(tiny limit) ? 63 : msize-1
return (!msize || (msize > NUM_TINY_SLOTS) ? NUM_TINY_SLOTS : msize - 1);
}
如果空闲链表有节点,该节点为这次申请内存地址,并将该节点从该空闲链表移除. 移除该节点,空闲链表如果没有节点,设置空闲链表metadata的bitmap对应的bit位为0
1
2
3
4
5
6if (next) {
next->previous = ptr->previous;
} else {
BITMAPV_CLR(tiny_mag_ptr->mag_bitmap, slot); // 设置bit位
}
the_slot->p = next; // (将空闲链表指向下一节点)
这里的tiny_malloc的bitmap用了8个字节64bit位表示该magazine的64个空闲链表,0表示链表无节点,1表示有链表节点
- 如果该空闲链表没有节点,接着走下面流程👇
2. 从第一个大于申请msize的空闲链表申请
比如申请的msize为2,但是msize为2对应的空闲链表没有节点,这时候查看msize为3,4,5…63的空闲链表
取出表示大于msize的bitmap
1
bitmap = ((uint64_t *)(tiny_mag_ptr->mag_bitmap))[0] & ~((1ULL << slot) - 1);
小于msize对应的bit位设置为1
找到第一个bit不为0的bitmap的位置(slot偏移)
1
slot = BITMAPV_CTZ(bitmap); // __builtin_ctzl(返回从第0位开始bit为0的bit个数)
通过1和2操作得到第一个大于申请msize的空闲链表偏移. 然后和上面一样判断节点有值与否,移除节点等操作
如果有节点,取得节点大小(上面说的大于16B的block节点中会存储该节点大小数据),将该节点多余的block作为节点添加到对应大小的空闲链表中
1
2
3
4msize(申请大小)
this_msize = TINY_FREE_SIZE(ptr); // 取得该节点的block大小
leftover_msize = this_msize - msize; // 多余块大小
tiny_free_list_add_ptr; // 将多余块按照大小添加到对应的空闲链表
- 如果该空闲链表没有节点,接着走下面流程👇
3. 从最大的空闲链表(第64个)中申请
找到空闲链表数组的最后一个元素,即最大的空闲链表(节点大于等于1024B, >=64 Blocks)中查看是否有节点
如果有, 和上面一样,从链表中移除该节点,设置bitmap, 将该节点多余的空间作为新节点添加到对应的空闲链表中
如果没有, 接着走下面流程👇
4. 从最后一个heap region中分配
如果上述从空闲链表申请堆内存操作失败,表示之前所有的
heap region
中没有一块连续的大于等于申请大小的空间,因为之前的heap region
的blocks要么在空闲链表中(free过的),要么在使用中.
只有最后一个heap region
中的blocks是在空闲链表中或者在使用中,或者从未使用过
因此从最后一个heap region
中申请堆内存
1 | if (tiny_mag_ptr->mag_bytes_free_at_end >= TINY_BYTES_FOR_MSIZE(msize)) { |
mag_bytes_free_at_end
表示该heap region
还剩余的未使用过的大小TINY_REGION_END(tiny_mag_ptr->mag_last_region)
表示该heap region
的blocks的尾位置
如下图
绿色块表示从未使用过,是连续的.
mag_bytes_free_at_end
即表示绿色块的大小
蓝色表示正在使用中
黄色的块是使用过且free了的,在free_list
中
- 将最前面的绿色块位置作为此次申请的heap地址
mag_bytes_free_at_end
减掉此次申请的msize大小- 如果该
heap region
还有block,设置刚刚申请块对应在metadata的bitmap的边界bit位为1, 作为下次申请的起始位置
Blocks Bitmap设置如下
其中每8个字节(64bit)表示32个blocks
前面的32bit(蓝色)表示32个block的block_header(可以理解为连续blocks起始block)
bit···10010001 (block_header) | ···10010001 (in use)
表示第1个block到第4个block一段正在使用的连续空间,同理第5个block到第7个block也是如此后面32bit(绿色)中的bit表示blocks是否正在使用(1表示使用中,0表示未使用)
bit···10010001 (block_header) | ···10000001 (in use)
表示第1个block到第4个block一段正在使用的连续空间,但第5个block到第7个block表示的是free的连续空间
对应函数
1 | set_tiny_meta_header_in_use |
通过
heap region
的bitmap信息,在free的时候,可以知道free地址申请的内存大小
5. 收尾工作
- 在上面的过程中,如果成功分配了空间,则记录信息
- 设置该
magazine
的mag_num_objects
加一,该magazine
字节使用mag_num_bytes_in_objects
加上msize*16 设置当前分配的堆地址所在的
heap region
的metadata. 包括该heap region
的字节使用trailer->bytes_used
和bitmap设置(上面解释了)通过堆地址,获取该地址所在的
heap region
的地址 (free时只需要地址的原因)
1 | // TINY_BLOCKS_ALIGN = 20 |
去掉
NUM _TINY _BLOCKS * TINY _QUANTUM
(64520*16) 最多能占用的20个bit位. 得到region基地址这是因为在向内核申请
heap region
时,指定了堆地址内存对齐为(1<<20)-1
,顾所有的heap region
起始地址后20个bit为0. 系统调用函数mach_vm_map
的第4个参数为内存对齐
- 分配空间失败,走下面的流程👇
4. free_list of depot magazine
tiny_get_region_from_depot
函数从depot magazine
中寻找一个能分配msize空闲链表(大于等于msize)的第一个节点所在的heap region
, 将该heap region
从depot magazine
移到current magazine
然后在从current magazine
中分配(即上一步流程)
函数流程如下
1 | bool tiny_get_region_from_depot(...) { |
tiny_find_msize_region
函数从depot magazine
的空闲链表数组中寻找一个大于等于申请大小msize的节点(过程同上面free_lists
一样),通过节点地址得到该节点的heap region
(TINY_REGION_FOR_PTR
函数, 上面也说过)recirc_list_extract
函数将上一步得到的heap region
从depot magazine
双向链表region_trailer
中移除tiny_free_detach_region
通过遍历heap region
blocks的bitmap,将free的blocks从depot magazine
的空闲链表中移除tiny_free_reattach_region
通过遍历heap region
blocks的bitmap,将free的blocks添加到current magazine
的空闲链表中recirc_list_splice_first
将heap region
作为节点添加到current magazine
的双向链表region_trailer
中
变化过程如下(msize=2)
变化之前
变化之后
如果上面的tiny_find_msize_region
失败,表示depot magazine
空闲链表数组中也没有大于等于msize的节点. 接着走下面的流程👇
5. mmap
mvm_allocate_pages_securely
向内核申请一块heap region
- 失败,
return nil
- 成功,
tiny_malloc_from_region_no_lock
函数从刚刚申请的heap region
中分配msize
1 | void* tiny_malloc_from_region_no_lock(...) { |
至此,整个tiny_malloc的过程就分析完了,下面看看对应的free过程tiny _free吧
tiny_free
函数free()
通过函数find_registered_zone
找到释放地址所在的zone
, 再调用zone
的basic_zone
在zone创建时注册的free
函数
s_zone
注册的free函数为szone_free
,szone_free
会通过释放地址ptr
尝试获取ptr
所在的heap region
, 包括tiny
, small
, medium
. 如果tiny
(<1009B)返回的heap region
不为空, 则走free_tiny
函数
对于tiny_free
函数主要有3个步骤,如下
1 | void free_tiny() { |
- 根据
heap region
的metadata的bitmap,验证ptr是否是free过的(In Use对应的bit位为0)- 设置free_cache, 如果释放内存小于16个msize,则此次释放地址设置成
last_free
, 然后对上一次的last_free
操作- 释放
last_free
真正free堆内存在tiny_free_no_lock
函数中
tiny_ free_ no_lock
正常只需要将释放地址作为节点添加到对应大小的空闲链表中,然后设置地址块对应的bitmap信息就行了.
但是在libmalloc
的free
中有个合并过程,合并为了整理内存碎片,为了之后分配更大的内存提供可能
tiny_ free_ no_lock
函数如下
1 | bool tiny_free_no_lock() { |
向前合并(上一个block)
对于大于16字节空闲链表的节点,前面16字节存储前驱和后继指针,指针后两个字节存储块大小msize, 最后两个字节也储存着块大小msize. 对应函数TINY_FREE_SIZE
合并前如下图
图中红色表示当前释放的内存, 绿色表示在空闲链表的内存
合并后如下图
此时还没加入到空闲链表中,待下面向后合并步骤完成,才加入到空闲链表中
合并过程如下
block_header
:
判断当前free ptr
地址的前一个block是否是block_header
根据bitmap判断,即图中100前一个bit是否为1
如果是1则
free ptr
前1个block为单独的连续的空间,即为block_header
如果不是1, 则表示free ptr
前n个block为单独的连续的空间previous
(如图所示)pre_size
:
如果前一个block是block_ header,那么连续空间previous
大小pre_size
等于1如果不是block_header,表示连续空间`previous`大小大于1
对于大于1的blocks, 如果该连续空间在空闲链表中,该空间的最后两个字节储存着该区域所占用的block大小(如图
pre_size
=2)如果该空间不在空闲链表中(不需要合并),也暂时先读最后两个字节作为
pre_size
,之后再通过bitmap的block_header==1
进行验证
验证
:
根据上一步获取到的pre_size
,将free_ptr
对应的bit位向前移动pre_size
, 得到previous
的起始位置对应的bit. 当该bit位的block_header
=1,In Use
=0 表示该连续区间在空闲链表中需要合并(如上图所示)但如果
···1010100···(block_header) ···0010100···(in use)
这种情况.100
还是要释放的内存, 前面的10
内存块为使用中,这时候如果该内存块最后两个字节为4. 那么在合并中就会取100
的前面4个块作为一个连续的区间,对应bit为1010(block_header) 0010(in use)
,此时验证也是正确的. 那么使用中的10
两个内存block就会被合并解决: 验证该连续区域大小
从
1010(block_header) 0010(in use)
内存起始位置计算得到的大小为2,与4不符合. 验证失败 (空闲链表节点的前驱后继指针之后(16字节之后)存储着块大小msize)合并
:如果
free ptr
前面的连续空间previous
在空闲链表中,将free ptr
和previous
进行合并- 先将
previous
从当前空闲链表中移除 - 清除
free ptr
内存区域对应的bitmap信息 free ptr
和previous
大小和作为新free大小free ptr
=previous
- 先将
向后合并(下一个block)
对于大于16字节空闲链表的节点,前面16字节存储前驱和后继指针,指针后两个字节存储块大小msize
合并前如下图
合并后如下图
合并过程和上面类似
合并结束
合并结束后更新合并内存块对应的bitmap信息,将合并的内存块添加到对应的空闲链表中
更新magazine
数据,更新当前heap region
的metadata信息
至此,整个tiny_free
过程结束
总结思考
libmalloc
确实是一个优秀的用户堆管理库. 里面有许多优秀的分配策略设计, 并发的支持设计,内存碎片管理,性能考虑等等. 之前看有些书说过的堆设计的问题,它都有对应的解决策略
多核并发性设计
采用一核对应一个堆管理
magazine
,让该核下的虚拟并发性能更好堆管理最小单位
将堆内存划分为多个相同大小的
block
, 即堆中的最小单位, 16B, 更利于空闲链表管理采用空闲链表数组
将不同大小的
free
了的堆空间组成不同空闲链表形成一个空闲链表数组,加快分配效率,更易于管理多级分配策略
last free
属性缓存;- 空闲链表匹配;
- 寻找更大的空闲链表(分割处理多余的空间); // 对应bit优化算法找到空闲链表
- 最后一个可利用的堆
- 仓库
depot magazine
- 内核
bitmap记录
block
信息使用64bit表示32个
blocks
信息;Block Header
andIn Use
系统调用返回对齐的
heap region
对齐20bit, 一个
heap region
中blocks能占用的空间,通过堆内存中的地址去掉后20bit可以获得该heap region
的基地址合并。碎片内存整理
free
过程中,整理前后内存碎片. 将碎片内存合并在一起,利用分配更大的堆内存等等