目录
一、前言
内存池主要是为了解决“内存复用时,系统产生的额外开销”的问题。内存池可以避免重复创建和销毁,过多的系统调用,进而提高性能。这个技术也称为池化技术,本质上就是为了复用,减少开销,提升性能。
这种开销有如下几类:
1. 效率开销(性能开销),频繁的系统调用等等操作,对性能影响很大。
2. 减少内存碎片(内存浪费),碎片化指的是内存被分割的不连续,导致后续的内存分配无法实现,但是实际上系统中仍然有很多内存未被使用。
内存池如名字所言,在设计时需要先把池子中注入一些内存,要用内存的时候,直接去池子中取,不需要再从系统中分配了。这是池化技术的缺点,必须要预先分配一段空间,预分配空间过小,则不够使用,过大则浪费较多。
对于内存池设计,目前主流的内存池设计均是围绕着heap(堆)。著名的tcmalloc开源库就是这类典型的代表。当然开源界还有很多相关的代表。
但是,中间件的内存池需要围绕着Share Memory进行设计,典型的代表是Iceoryx。但是Iceoryx基于静态配置设计,一旦配置完成,无法动态扩展。因此,就中间件的内存池设计而言,并没有一个基于Share Memory并且支持动态扩展的内存池设计。
我们进一步推广一下,基于无属性Share Memory的内存池设计,无属性Share Memory表示其不关心内存是MCU的.data段的大数组分配,还是Linux侧共享内存分配,亦或是Linux侧的堆分配,亦或者Linux侧的大数组全局变量分配。我们尝试将多平台的内存池整合成到一个架构中,当然代码肯定会有所区别,但是原理一致。
最后,必须要强调一点,这种设计并无好坏之分,只有适合不适合的区别。本文仅做一些探讨。
二、中间件内存池设计
2.1中间件内存池地址问题
内存池作为一段整体,实际上需要其他的数据结构去辅助内存池设计。在MCU上,辅助代码可以放在Flash上,内存放在RAM中,所有进程通过同一个地址可以访问到同样的物理地址。在Linux上,如果存放在同一个进程空间,那么内存池地址可以复用。如果是不同的进程空间访问同一块Share Memory,那么就需要做地址的二次映射。基于Share Memory的地址映射方案可参考Iceoryx的参考地址设计。
2.2中间件内存池空闲块划分
为了能够分配不同大小的内存块,必然要缓存不同大小的内存块。那么,不同大小需要准备多少个不同的大小?8字节的内存块、16字节、64字节、128字节…..1024字节等等的内存块?
以Iceoryx的内存池为例,根据配置划分不同大小的内存块,如果不主动进行配置,则使用默认配置。第一次分配时,直接划分64Kbyte,只浪费了100字节,但是等多次分配之后,池子中只有大块的内存,当申请小字节的内存时,只能将大块内存分配给用户,导致浪费严重。
为了解决资源浪费的问题,很多基于heap的内存池会将多余的内存划分出去,便于重新利用。如下图所示,每一次分配都会将多余的字节划分到其他段中,但是还是有少量的内存浪费,这种浪费已经在可接受的范围内了。
2.3中间件内存池空闲块索引
上述这些内存池的空闲块划分都是按照固定大小划分的。当用户申请某某字节长度大小的内存块时,会把这个长度映射转成内存块的地址。比如说申请16字节大小内存,会找到16字节大小空闲块的内存地址,将其从内存池中移除,返回地址给用户,整体的流程如下图所示。
如何去寻找最适合的内存块是值得去思考的。Iceoryx通过数组循环的方式去索引合适的空闲块。经典的内存池设计都是通过“桶-自由链表”的方式实现空闲块的索引。以这张图为例,任何字节会被索引成8Byte,16Byte,….,64KByte,256KByte的位置,这个位置就是一个桶,桶中装有链表,这个链表是自由链表,也就是头插式的链表,性能较高。
2.4二级索引表内存池介绍
如上这些方式在内存池的设计中很常见。这边再介绍一种通过2级索引表索引内存块的方法。假设我们想要管理1G大小的内存池,也就是2^30字节。2G内存占据的地址空间为0x0000 0000~0x3FFF FFFF,总计30个bit,最高位为bit29。假设一级索引偏移6个bit,则一级索引需要一个30-6=24位的数据即可,最高位为bit23。对一级索引得到的二级索引再次拆分32份即(bit0~bit31),32份正好可以用一个uint32的变量表示。那么最终的一级和二级索引如下所示:
uint32_t FirstLevel_Index; //有效位24bit
uint32_t SecondLevel_Index[24]; //有效位32bit
addr* ToTalTable_Index[24][32]; //最终的索引表
我们假设申请数据块大小为a,在数学模型中a为给定数值,一级索引的值为未知解x1,二级索引的值为未知解x2,此外,x1最多为24bit且x1左移6bit可以还原30bit数据,二级索引拆分为32份,32换算为2^5,可以得出一个参数5,那么申请数据块大小与二级索引表的数学模型如下:
我们需要推导得到给定数值a和未知解x1之间的映射函数f1,以及给定数值a和未知解x1、x2之间的映射函数f2。msb(a)特指获取a的最高有效位。
根据题意, x1会偏移6位,那么可得如下函数:
根据题意,x2需要分成32份,那么尝试得到如下函数:
将函数整理一下,得到一些有趣的表达式,如下所示:
最终整理可得:
当x1固定时,a的取值范围的差值rangex1为:
当x1固定并且x2也固定时,a的取值范围差值rangex2为:
上述表达式也就是意味着,二级索引的每一条子索引(单条范围)都由一级索引决定,二级索引平等的划分一级索引空间范围。
x1/bit位置 | rangex1/字节 | 取值范围/字节 | 单条二级索引大小/字节 |
0 | 64 | 64 ~ 1270 ~ 127 | 4 |
1 | 128 | 128 ~ 255 | 4 |
2 | 256 | 256 ~ 511 | 8 |
3 | 512 | 512 ~ 1023 | 16 |
4 | 1024 | 1024 ~ 2047 | 32 |
5 | 2048 | 2048 ~ 4095 | 64 |
6 | 4096 | 4096 ~ 8191 | 128 |
7 | 8192 | 8192 ~ 16383 | 256 |
8 | 16384 | 16384 ~ 32K - 1 | 512 |
9 | 32K | 32K ~ 64K - 1 | 1K |
… | … | … | … |
… | … | … | … |
… | … | .. | … |
22 | 256M | 256M ~ 512M - 1 | 8M |
23 | 512M | 512M ~ 1024M - 1 | 16M |
为了不浪费空间,对x1等于0时进行特殊处理,让其管控0 ~ 127字节的范围。
综上所述,通过二级索引表可以索引到任意大小范围的空闲块,并且小空间的单条范围也相应的减少,大空间的数据块单条范围相应的变大,这种划分比较符合实际需求。
我们举例说明如何进行索引,假设我们需求1022字节的空间,计算式如下:
对应的索引表为一级索引为bit3,二级索引为bit31, FirstLevel_Index变量bit3置位,SecondLevel_Index[3]变量bit31置位,ToTalTable_Index[3][31]为想要的地址。整体的流程如下:
在空闲块的分配过程中,如果最后只有大数据块,那么大数据块会将不需要的内存再次存储进整个二级索引中。如果一个同一个数据块存在多样,通过“桶-自由链表”的方式可以解决多个相同大小空闲块的索引问题。
三、结束语
内存池分配方案本无好坏之分,有时候最好的方式就是给每一个主题预分配好固定大小的内存块,再对这个内存块按照主题的数据结构大小*数量的方式划分好,按照index逐个索引就行。
上文讲到的多个方案,需要根据实际的情形去使用,过于复杂的内存池设计方案实际会极大的影响程序的运行效率。
本文仅仅是初探内存池设计,关于内存池的互斥访问,内存碎片的归类整理等细节,仍待更多讨论。