应用安全一个轻量级内存池的实现与细节
Posted i春秋
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了应用安全一个轻量级内存池的实现与细节相关的知识,希望对你有一定的参考价值。
i春秋社区
引言
内存池作为一种的内存管理机制被广泛地运用于各种领域当中,内存池拥有快速的内存分配与更加健壮的管理机制,同时在不同的平台与环境当中也拥有不同的实现方式,所以为了更安全地利用内存,本文提出一种轻量级的内存池实现,可以非常方便的移植到内存空间较小的平台当中,可运用在不同的嵌入式平台,服务端及小范围的内存管理当中。
从内存开始
巧妇难为无米之炊,既然我们要说是内存池,显然你首先至少需要找到一个能存储数据的东西,否者接下来都没有讨论的意义。
”
一块内存
假如你读过一些内存池相关的文章,也许现在我们普遍的做法就是把这一块内存区域均等的分为多个block,比如假如这里的size是512字节,假如每个block占64字节,那么这个内存区域就会被分为8个block。
当然还有更进一步,4个block组成一个chunk,那么512字节的内存区域,就包含有2个chunk或者说包含有8个block,当然,这里仅仅只是用512字节作为一个比方。
也许你觉得这样分是在是多此一举,但假如这个size大到64M或者是128M,那么,用一个chunk为单位,对内存池进行寻址,显然会比你用字节为单位进行寻址要快得多,并且寻址范围也会大的多,并且以chunk(或者别的更大的单位)进行内存管理,对内存碎片的整理合并也会方便且快得多而且很容易使用map进行映射,这样就能够非常快的寻址处理。
这种思想被广泛的运用在了文件系统与内存管理方面,例如windows操作系统就会建立一个页目录及页表来管理其内存空间,采用这种映射方式,它允许其内存在物理上不是连续的,甚至映射到硬盘或其他存储设备当中(虚拟内存)。
分割一块可用的内存
但遗憾的是,我们并不将内存进行这种分割处理,我们可以假如我们将一个Block分割为64字节,假设我需要一个86字节的内存空间,那么我们需要2个block也就是128字节,因为block作为一个整体是不可分的,那么我们将有42字节被白白的浪费了。
在内存以G为单位的PC机上,也许这些损失实在是无足轻重,然而对于仅仅只有几百kb甚至是几kb的片上系统来说,这些损失就显得实在是太过于昂贵了。
你也许会考虑到进一步缩小block提高细分度来减少这种损失,但是,即便是对block进行的寻址,也是需要内存空间的,并且更小的细粒度除了浪费内存之外,也显得毫无意义。
技术不是扯淡,更不是拿起一本书就开始照本宣科,脱离实际的去争论某种高深架构或者方法如何优秀,只是为了掩饰自己能力的贫庸.
我们再来看看这里内存池所要面向的环境:
1.轻量级内存池,首先决定了它不能过于的臃肿与庞大,在保证功能的前提之下,能多简单就多简单,没必要把简单的问题复杂化;
2.内存分配较小。这就意味着使用映射表的方法意义实在不大,也许10000*10000的复杂度会很大的优于100lg100,但是10lg10和10*10的差别确实不怎么大,即使我们建立的内存表都用链表来进行管理,因为内存较小,表也不大,即使是遍历也未必会比映射慢上多少;
”
那么,问题就开始变得简单了,假如内存池是一条等待切割的绳子,现在一个人想要3米长的绳子,那么非常简单,0-3米我们规定就是他的了,又一个人想要2米的绳子,那么我们再规定3m-5m这一段距离的绳子就属于它的了。
那么,最基本的内存分配就这样实现了
我们先定义内存池的基本结构
我们定义一个函数,用来初始化这个内存池
它的实现是
下面几张图表示内存的分配情况:
1.内存初始化时
2.分配内存A后
3.分配内存B后
有借有还:内存碎片与回收
记录分配节点
计算机是一个伟大的发明,毫无疑问的在某些数学角度方面,它的计算速度要比人快得多,但是遗憾的是,计算机未必比人聪明,而且是一个奇怪的家伙,他记忆力很很差劲,只能靠一些存储器件来帮助他进行记忆,比如寄存器和硬盘。
上面的方案看上去似乎工作的非常好,而且也不会出上面问题,当然,假如你的内存是无限大的话,你完全可以这么干,然而在某些平台上,你却不得不抠门地去节省那几个字节甚至是几个位。
上面的方案显然碰到了一些问题,我们分配了AB两个内存块,也许A当中只是存储了一些临时的数据,或者说这些数据我们用不到了,我们也许会希望释放掉这个内存,并且把空闲的内存归还到内存池。
然而上面的方案我们只是把内存简简单单的划分出去了,而没有做任何记录,显然的,我们应该给它做个记录,为了节省内存,我们尽量将记录变得简略。
如何放置内存节点
上面的思路也许看上去非常简单,不需要太多的思考也能想出来,但是如何放置内存节点和如何回收回来的内存节点确值得思考。
在网上流行的很多内存池中,基本都采用了链表的方式去连接和管理节点,假如新增加一个节点,就使用malloc分配一个,然而在一个要求高度可移植的内存池方面,这个想法却是不完善的。
首先,你不知道堆到底有多少空间供你分配,因为非常多的小型系统的堆大小仅仅只有可怜的几kb,而且用户很可能已经将那些空间使用的所剩无几了,所以这个办法显然是被否决的。
另一种办法是采用类似于windows的内存管理机制,使用页表,但之前也已经提及了,这种办法实在是太奢侈了,另外,放置节点的位置也需要讲究,我们看看释放内存的函数
Free(void *address)
使用sizeof(MemoryNode)我们很容易确定内存节点的大小,所以在free的时候,我们仅仅需要将address减去sizeof(MemoryNode),我们就可以非常容易获得这个内存节点,并且根据这个节点来取得内存数据区的起始位置和结束位置。
释放的内存节点与管理
现在,我们可以将那些释放掉的内存节点进行管理了,我们仍然需要将释放的内存节点存储在内存池当中,但我们将不采用链表的形式存储,相对于链表,线性存储显然颇具优势。
我们需要做的,仅仅只是为其挑一个存储的位置与空间,将这些释放的节点存储在内存池的尾部,是一个不错的选择,因为受到池内存大小的限制,释放的内存节点经过整理后也不会显得过于的庞大。
释放第一块内存:
1.拷贝节点到内存池尾部
2.实际上无需其它的操作,这块内存意义上已经释放了
我们用下面的代码,简单为内存池增加一个释放的内存节点:
不能失败的内存释放
但是上面的办法真的没有问题吗,我们看看Free的原型
Void Free(void *Address)它的返回值是null也就是必须成功,那么什么时候上面的方案会失败呢,非常明显的,每当释放一个内存节点,我们将从尾部去记录释放的内存节点,尾部的空间实际上是输入FreeSize的,当FreeSize小于一个内存节点的大小的时候,分配就会失败
(FreeSize已经不足以保存一个节点,释放失败)
释放失败的后果是严重的,它将导致那块内存被永久的占用,比如你内存池大小是1M,已经分配了1023字节,那么,这个内存池除非你恰好释放的是最后一个节点(稍后讨论),否则这个内存池将直接陷入瘫痪。
但是办法总是有的,其中最有效的是,我们在内存分配期间,就需要为内存的释放预留出空间。
这个虽然稍微有点儿浪费空间,但是这对内存池的健康与维护大有好处,并且MemoryNode结构本身并不大,释放空间操作相比于分配操作往往要少的多,绝大部分的空间都是随着内存分配的开始到程序运行的结束。
那么这也就意味着,在我们分配内存的时候,至少需要分配两个MemoryNode节点大小的内存空间才能够满足要求。
同时我们也需要记录下当前有多少个已经释放的内存节点与在释放的节点中最大的节点大小是多少,这样做方便后期的查找与遍历与在释放的内存节点中分配新的内存,我们修改MemoryPool结构体:
同时,从FreeSize中分配内存的实现的代码就如下所示
内存的碎片与减少碎片
也许最合理的内存回收是能够回收多少是多少,回收完后仍然是线性的,例如现在我释放 了一个大小为A(8字节) B(12字节) C(4字节)的内存,下次我需要分配24字节,可以直接从之前释放的空间中申请。
但是,实际情况上却常常难以做到,因为我们申请的内存,必须是线性连续分配的,但释放后的内存空间,往往不是连续的.
(非连续内存导致碎片)
A是一个释放的内存节点,很遗憾的是,不论是A的前方或者后方都是一家被内存池分配出去的内存,A将不能进行合并,对于这种情况,调用之前写好的PX_AllocFreeMemoryNode函数分配一个节点,并将A的内存节点信息直接保存在内存池的尾部
2.可以向前合并的节点
A是一个待释放的内存节点,B是一个已释放的的内存节点,可以看到,BA在内存空间上是相连的,所以我们需要将BA两个内存空间进行合并。
A是一个待释放的内存节点,B是一个已释放的的内存节点, AB在内存空间上是相连的,所以我们需要将AB两个内存空间进行合并,其过程与向前合并时类似的。但是向后合并我们还可以更进一步地进行优化,来看看下面这种情况
4.前后合并节点正文
前后合并的其实就是向前合并和向后合并的组合,但我们需要的是注意一下合并的顺序,因为向后合并存在一种特殊的情况,我们可以将其选择出来并进行专门的处理,就如下图所示的情况
最后,依照上述的几种情况编写内存释放函数
分配内存
从空闲空间中分配内存
在上一个章节的” 分割一块可用的内存”,”不能失败的内存释放”中,给出了这个空闲空间分配内存的方法与分配代码,在这里就不再复述了,它的流程非常简单:
”
短短几步,就能够完成空闲空间的内存分配,当内存空间不够时,返回0
从已释放内存中分配内存
回收内存再分配,是一个内存池是否能够持续良好持续运行的一个必要功能,同时,这也常常成为碎片产生的原因.
但是内存池是否能够完全避免产生碎片呢,当然这并不是不可能的,但是付出的代价往往不值得我们这样去做.所以,内存碎片,尽可能的减少却不能够完全避免.
那么应该如何减少碎片呢,其实这和环境与用法有很大的不同,一个老练的码农往往喜欢把内存分配到2的整数幂,这也对减少碎片有很大的帮助,
比如你分配了一个64字节大小的内存释放之后又分配一个32字节和4个8字节的内存,刚好就填充了之前的空间,越小的内存分配越容易填充之前的碎片,当然,假如你申请的内存和释放的内存一直保持一致的大小,这个碎片也就能够最大程度的消除了.
过多的碎片不仅占用了不必要的内存空间,也拖慢了再分配和释放,因此,内存分配并不仅仅只是算法上的,很多时候也是使用和习惯上的.
那么如果需要从已分配内存当中新分配内存应该怎么办呢,我们先来讨论一下不产生碎片的情况,最直观的是,你释放了一个内存的大小,刚好就是你想再分配内存的大小,这是无缝的,自然不会产生碎片.但是我们不能就仅仅止步于此,我们再来看看内存的结构,
是的,在这个内存池结构中,分配一个size大小的内存,需要size+2*sizeof(MemoryNode)大小的内存空间,分配内存分配的大小,是我们主观上的大小,我们为其多分配一些内存空间,并不会出什么大不了的问题。
总结
只要计算机还存在一天,人类与内存管理的战斗就永远不会结束,为了更好的更加高效地更加安全地利用内存,多种多样的分配算法也孕育而生。
但即使是到了今天,这样的战斗也仍然在继续,高效与应用安全似乎永远是一个矛盾体,内存管理不是什么轻松的事情,为了高效我们在底层使用页表,在编码中使用指针让程序猿直接管理内存。
但即便是最老练的程序猿,也可能在一个不小心中导致内存的泄漏,为了方便我们使用各种各样的GC(垃圾回收机制)同时避免一些内存泄漏之类的麻烦事,但与此同时的我们也牺牲了太多的执行效率在内存的拷贝与管理上。
幸运的是,即便没有那些一劳永逸的解决方案,我们也总能够找到办法使用合适的方法用在那些合适的环境中。实际上本文所述的内存池就使用在了stm32f103vet6的MCU上用于管理一个渲染器的内存并且工作良好。
这也行就说明了,大多的算法是个双刃剑,也许我们不能找到最好的,但我们可以找到最合适的.
为奖励大家对知识的渴望和坚持之心
mua~
本文属i春秋原创奖励计划,未经许可禁止转载。
参与活动在i春秋社区发帖并注明参与奖励计划即可!
以上是关于应用安全一个轻量级内存池的实现与细节的主要内容,如果未能解决你的问题,请参考以下文章
细节拉满,80 张图带你一步一步推演 slab 内存池的设计与实现
Linux线程池 | 线程安全的单例模式 | STL智能指针与线程安全 | 读者写者问题