7.2  内存池的核心逻辑—内存栈

在内存池中,首先要有一个内存块管理的核心模块,来负责所有内存块的申请、分发、回收和释放工作,经过设计,笔者是使用“栈”来完成的这个模块,因此,笔者将其定名为“内存栈”(Memory Stack)。下面我们将详细讨论其设计细节。

7.2.1  内存管理的数学模型

内存块如果要提升可重用性,必须对内存块尺寸进行取模,否则的话,很容易因为几个Bytes的偏差,导致内存块无法重用,被迫向系统频繁申请新的内存空间,那意义就不大了。

取模的主要目的,是减少内存块的种类,以有限几个尺寸的内存块,应对绝大多数内存使用要求。

笔者看过STL的内存管理模块源代码,对其内存块的取模机制深表钦佩,因此,在笔者自己的内存池中,也是按照这种方式取模。

提示:32位系统,有个字节对齐问题,即一个程序变量单元,如一个结构体,一个内存块,如果其尺寸不是4Bytes的整倍数,操作系统会按照比它大的整倍数分配内存,这其实也是操作系统在取模。比如我们的一个结构体为7Bytes,操作系统分配时会分配8Bytes,一个14Bytes的内存块,操作系统会分配16Bytes,这主要是简化内存地址运算,以一定的内存消耗,来提升程序的运行速度。

我们对内存的取模也是这个原理,当然,我们不可能像操作系统那样,机械地以4Bytes为模数,那样,内存块种类还是太多,管理起来压力很大,内存池的效率也不高。

笔者仿造STL的取模方式,在内存池中按照如下逻辑取模,简单说来,就是从16Bytes开始,以两倍方式递增模数。直到4G内存为止。当然,实际使用时,超过1M的内存块,一般应用很少,即使有,基本上也属于应用程序永久缓冲区,很少会中途频繁释放,因此,笔者的内存池管理,一般模数为16Bytes~1M即可。

序号

内存块大小模数(Bytes)

应对申请需求(Bytes)

1

16

1~16

2

32

17~32

3

64

33~64

4

128

65~128

5

256

129~256

6

512

257~512

7

1k

513~1k

8

2k

1k+1~2k

9

4k

2k+1~4k

10

8k

4k+1~8k

11

16k

8k+1~16k

12

32k

16k+1~32k

13

64k

32k+1~64k

14

128k

64k+1~128k

15

256k

128k+1~256k

16

512k

256k+1~512k

17

1M

512k+1~1M

图7.1:内存模数表

如表7.1所示,内存池实际上是一个树型数据结构在管理,每一种类型的内存块,构成一个链表,形成树的“右枝”,而所有右枝的链头,又是以一个链表在管理,形成树的“左枝”。请注意,这并不是二叉树,还是一颗普通的树结构。如下图所示:

图7.2:内存管理树模型

我们举个例子,当应用程序申请一块57Bytes的内存块,程序逻辑会沿着树的左枝,从头到尾比对,首先是16Bytes的的管理链,由于57>16,因此,无法在这根右枝进行管理,因此继续往下,32Bytes也不行,64Bytes,比57要大,可以使用,因此,就在64Bytes这根右枝实现内存块管理。

管理原则:分配时,首先在合适的右枝寻找可用的内存块,如果有,则直接分配给应用程序重用该块,如果没有,则向系统申请一块64Bytes的内存块,分配给应用程序使用。而当应用程序释放时,内存块本身是64Bytes的,因此,可以直接挂回到64Bytes这根右枝,等待下次重用。

提示:这里面有一个隐含的推论,如果一个应用程序,需要一块57Bytes的内存块,那么,我们分配一块比它大的内存块,比如64Bytes的内存块,是完全可以的,应用程序不关心自己实际获得的内存块大小,同时,这种稍稍超大的分配机制,也不回引发任何的内存溢出bug,反而更安全,因此,这种内存取模分配的思路,是完全可行的。

7.2.2  管理模型的优化

虽然上文我们讨论的是以链表方式管理,不过,在实做中,笔者发现一个问题,即链表效率不高,原因很简单,笔者的链表是以队列方式管理,每次从右枝取出内存块,是从链表头取出,但释放时,将内存块推回右枝,需要循环遍历到链表尾部进行挂链操作。这在高速的内存申请和释放时,会严重影响链表的效率。

笔者经过思考,发现一个问题,当一个内存块被推回一个右枝,其实已经是无属性的,比如,64Bytes这个右枝上,挂的都是64Bytes大小的内存块,应用程序申请时,使用任何一块都是可以的,无需考虑这块是在链头还是链尾,同时,申请的内存块,都是需要初始化的,应用程序也不关心这块内存块是否刚刚被使用完,还是已经空闲很久了。笔者理解这个内存树的右枝,其实已经是前文所说的“被动池”逻辑了。

我们知道,在“推”入和“提取”这个逻辑上,“栈”的效率远高于“队列”,通常我们不使用栈的唯一原因,主要是栈是“后进先出”逻辑,而队列是“先进先出”逻辑,而我们常见的应用模型,一般都有数据顺序要求,因此,队列的使用场合,远多于栈结构。

但此处既然我们已经明确论证了,内存块无顺序需求,那么,我们完全可以使用栈模型来管理内存树的右枝,以提高效率。

这在实做时非常简单,当应用程序释放一块内存,我们需要推回右枝时,直接将其挂接到链头即可,取消了无意义的循环遍历链尾的操作,虽然,下次申请时,最后释放的一块内存会被最先分配使用,但这又有什么关系呢?

这个优化看似很小,但实做时威力惊人,经笔者测试,内存块的申请和释放吞吐量,在“队列”管理方式下,每秒仅5万次左右,一旦使用“栈”方式管理,迅速提升到40~50万次,提升了整整一个数量级。

正因为如此,笔者才将内存池最核心的内存管理模块,定名为内存栈(Memory Stack)。

提示:在进行程序开发时,很多时候,需要针对业务需求进行分析,实现针对性优化,很多时候,很小的一点优化,都可以大幅度提升程序的性能。反过来说,通用的优化其实不存在,只有深刻理解了业务需求之后,才有可能实施有效的优化方案。

提示:原则上,程序开发应该遵循“先实现,后优化”的原则,笔者常说的“先解决有无问题,再解决好坏问题”,也是这个意思,本章在此先讨论优化,是因为笔者这个内存池在实践中已经经过了多次优化,有条件讨论此事,并不意味着可以再程序实现前实施优化,请各位读者关注这个细节。事实上,本书展示的内存池,已经是笔者第19个版本,中间经过了十几次优化的结果。

7.2.3  关于链表管理的思考

讨论完上面的问题,我们再来讨论一下基本数据结构管理的问题。我们知道,虽然我们的内存池是树结构管理,但具体到每个右枝上,还是链表,而链表元素是动态申请的,依赖内部指向下一元素的指针,实现链接关系。

具体到我们内存块管理上,我们发现一个基本的链表元素,至少需要定义成如下形式:

typedef struct _CHAIN_TOKEN_

{

struct _CHAIN_TOKEN_* m_pNext;  //指向下一链表元素的指针

char* m_pBuffer;                //指向真实内存块的指针

}SChainToken;

这就带来一个问题,链表的元素,应该分为两部分,一部分是实现链表管理的逻辑数据,如:m_pNext,另一部分,是业务相关的数据,如m_pBuffer,这样的数据结构,造成程序开发非常麻烦。

比如一个简单的内存申请和释放动作,以上述数据结构管理,其基本逻辑如下:

内存申请:

1、检查链表有无空闲内存单元

2、如果有,提取其中的m_pBuffer,准备返回给应用程序使用

3、从链表中卸载已经为空的链表管理元素,直接释放给系统(注意,无管控的内存块释放,内存碎片的隐患)

内存释放:

1、寻找合适的链,准备做挂链操作

2、申请一个链表管理单元,将内存块的指针放入其中的m_pBuffer

3、执行挂链操作,填充m_pBext指针

大家注意到没有,我们本意是内存管理,减小内存碎片,但是,为了实现链表的管理,反而中间引入了一个多余的链表单元申请和释放逻辑,反而增加了内存碎片的产生可能,这种方法当然不可取。

另外,这里还有一个隐患,我们分配给应用程序的内存块,其中所有的内存单元,都是对应用程序透明的,应用程序可以任意使用,这说明,这块内存块中,没有存储任何关于内存块尺寸的信息,当应用程序释放指针时,我们面临一个问题,就是怎么确定这根指针指向的内存块,究竟有多大,应该挂在哪个右枝上,等待下次使用。

这个问题不解决,上述的内存释放逻辑的第一步,寻找合适的链,根本无法完成。

因此,为了记录内存块的尺寸信息,我们必须内部再建立一个映射表,将我们管理的每根内存块指针,在申请时的具体尺寸,都记录下来,等释放时,需要根据指针,逆查其对应的长度数据,才能完成功能。

笔者做开发有个原则:“简单的程序才是好程序”,上述逻辑虽然最终也能完成功能,但无论怎么看,都太复杂了,不是好的解决方案。

为此,笔者经过了较长时间的思考,发现所有问题的核心焦点,无非只有两条:

1、如何使链表的管理数据,不要发生新的动态内存分配。

2、如何使分配出去的指针,能够携带相关的缓冲区尺寸信息,避免额外的存储和查询压力。

经过分析,笔者突发奇想,既然我们内存池管理的就是内存块,就有存储能力,为什么我们不能利用内存块做一点自己的管理数据存储呢?大不了这个内存块实际可用内存,比我们从操作系统申请的,要小一点,但这又有什么关系呢?应用程序需要的只是自己需要的内存块,这个内存块原来有多大?能给应用程序使用的又有多大?应用程序并不关心。

经过考虑,笔者做了如下一个结构体:

typedef struct _TONY_MEM_BLOCK_HEAD_

{

ULONG m_ulBlockSize;                        //内存块的尺寸

struct _TONY_MEM_BLOCK_HEAD_* m_pNext;      //指向下一链表元素的指针

}STonyMemoryBlockHead;

//本结构体的长度,经过计算,恒定为8Bytes

const ULONG STonyMemoryBlockHeadSize=sizeof(STonyMemoryBlockHead);

上述结构体,包含了内存块尺寸信息,来满足释放时查找的需求,同时,包含了指向下一元素的指针,这个指针,在分配给应用程序使用时,是无效的,只有当这个内存块挂接在链表中时,才有意义。

笔者这么思考,当我们向系统申请一个内存块,比如说64Bytes,我们内存池占用其中最开始的8Bytes来存储上述信息,也就是说,实际能给应用程序使用的,只有56Bytes。如图7.3:

图7.3:内存块组织结构

我们假定这个内存块的真实尺寸为N Bytes,我们从系统申请的首指针为p0,那么,我们占用8Bytes作为管理使用,当应用程序申请时,我们真实分配给应用程序的指针为p1=(p0+8)。这样,当应用程序释放时,我们只需要执行p0=(p1-8),即可求出原始首地址,并以此获得所有的管理信息。当最后向系统释放内存时,我们只要记得释放p0即可。

提示:此处可能出于业务考虑,有点违背C和C++无错化程序设计方法中,关于指针不得参与四则运算的原则,不过,没办法,需求如此,只有这条路走了。因此,违背就违背一点了。

唯一需要我们注意的细节,是我们在分析应用程序的内存申请需求时,不能以申请的内存块的真实尺寸进行比对,而应该比对减去8Bytes之后的数据。即64Bytes这个右枝上提供的内存,只有56Bytes大小,如果超过这个值,请找下一链,即到128 Bytes这个右枝处理,当然,此时的128 Byte的右枝,也仅能提供120 Bytes的内存块,以此类推。

有鉴于此,笔者做了如下的宏定义,来界定所有的计算行为:

//根据一个应用程序数据块的长度,计算一个内存块的真实大小,即n+8

#define TONY_MEM_BLOCK_SIZE(nDataLength) \

(nDataLength+STonyMemoryBlockHeadSize)

//根据向系统申请的内存块,计算其应用程序数据内存的真实大小,即n-8

#define TONY_MEM_BLOCK_DATA_SIZE(nBlockSize) \

(nBlockSize-STonyMemoryBlockHeadSize)

//根据应用程序释放的指针,逆求真实的内存块指针,即p0=p1-8

#define TONY_MEM_BLOCK_HEAD(pData) \

((STonyMemoryBlockHead*)(((char*)pData)-STonyMemoryBlockHeadSize))

//根据一个内存块的真实指针,求数据内存块的指针,即p1=p0+8

#define TONY_MEM_BLOCK_DATA(pHead) \

(((char*)pHead)+STonyMemoryBlockHeadSize)

//最小内存块长度,16 Bytes,由于我们管理占用8 Bytes,这个最小长度不能再小了,

//否则无意义,即使这样,我们最小的内存块,能分配给应用程序使用的,仅有8 Bytes。

#define TONY_XIAO_MEMORY_STACK_BLOCK_MIN 16

//这是管理的最大内存块长度,1M,如前文表中所示,超过此限制,内存池停止服务

//改为直接向系统申请和释放。

#define TONY_XIAO_MEMORY_STACK_MAX_SAVE_BLOCK_SIZE (1*1024*1024)


《0bug-C/C++商用工程之道》节选01--内存栈-1相关推荐

  1. 《0bug-C/C++商用工程之道》节选00--内存管理的基本要求

    最近被朋友们老是问到一些嵌入式的问题,很多是技术底层细节的问题,很不好回答,因为涉及到技术,要么不写,要写就要长篇大论,太费精力,也不太适合在网上上讨论. 想了一下,干脆这样,我将在博客上不定期.部分 ...

  2. 《0bug-C/C++商用工程之道》首版勘误表

    接到出版社通知,<0bug-C/C++商用工程之道>第一版已告售罄,正准备第二次印刷,因此,我最近虽然工作很忙,也还是赶着把勘误表做出来,以便在第二次印刷时修订. 说来惭愧,本书虽然名为& ...

  3. 关于《0 bug -- C/C++商用工程之道》的一处bug

    这两天有很多朋友已经买了书了,并且开始看,呵呵,我心里也很高兴. 嗯,要说江湖上藏龙卧虎呢,这不,这才几天时间,已经有朋友指出我书中的一处明显错误,这里我正式给大家说明一下,免得对各位读者有个不好的误 ...

  4. 《0 bug ---- C/C++商用工程之道》目录

    扉页 前言 1.1  为什么要写本书? 1.2  本书包括哪些内容 1.3  商用工程开发和软件编程的区别 1.4   商用程序员的核心思想 1.5  本书适合哪些读者看 1.6  本书中一些名词解释 ...

  5. 贝尔商道赚钱思维36道第01道:天地无所谓仁与不仁

    本文主要介绍贝尔商道对"天地不仁以万物为刍狗"的理解,以及误解"天地不仁"会对赚钱造成的影响,还有我们如何顺应天地之道赚钱. "天地不仁"的 ...

  6. 无名特质 —— 建筑的永恒之道节选

    建筑的空间模式和内部设计会影响发生于其中的事件和活动的模式,而正是这些事件和活动的模式的反复重演,构成了我们的生活:   因此,要使我们的生活接近于内心所期望的那样舒适自由,就要学会去发现和改善建筑内 ...

  7. 商用工程开发中的一些习惯

    学生大本营中有同学提出这个问题,感觉有一定代表性,这里做个回答. 一家之言哈,欢迎拍砖. 问题:    C++中当函数返回值类型时,系统会默认调用拷贝构造函数将返回值复制到函数作用域外,然后销毁超出作 ...

  8. 工程之道 | CPU推理性能提高数十倍,MegEngine计算图、MatMul优化解析

    本文转载自旷视研究院. 背景及引言 在深度学习大规模落地边缘端场景的今天,如何最大程度降本增效,是企业与开发者共同关注的话题.其中,模型的训练与推理是两个关键环节. 天元(MegEngine)深度学习 ...

  9. mysql workbench 1064_mysql - MySQL Workbench中的正向工程输出错误1064 - 堆栈内存溢出

    这是MySQL Workbench在进行正向工程时创建的脚本: -- MySQL Workbench Forward Engineering SET @OLD_UNIQUE_CHECKS=@@UNIQ ...

最新文章

  1. 无需服务器的个人博客 (2018.5.22更新)
  2. (转)xshell无法在vim中复制黏贴
  3. Spring 中 Bean 的生命周期
  4. Qt学习之路(28): 坐标变换
  5. 使用Swashbuckle构建RESTful风格文档
  6. 十二:内存简单介绍和OC的内存管理
  7. 指纹图像方向图matlab,matlab指纹方向场方向图程序
  8. Everything是如何搜索的
  9. share一段采集程序的代码
  10. 【Java系列】八大排序算法
  11. Rhino显示左边的工具栏
  12. python爬取统计局数据_利用Python抓取行政区划码的方法
  13. 更深层次的了解计算机,想要对电脑拉力试验机有更深层次的了解,看这里
  14. IntelliJ IDEA双击打不开的解决过程
  15. ECS架构 Entitas-CSharp学习之路(三)
  16. Qt实现一个简单的编译器(软件生成器)
  17. git reset 怎么还原_git reset后如何恢复
  18. Attention is all you need 论文精读笔记 Transformer
  19. 三十行代码实现打开笔记本摄像头进行实时口罩识别
  20. “天地不仁,以万物为刍狗”的真正意思

热门文章

  1. ssh : how to add hostkey to “know_hosts”
  2. 验证登陆信息的合法性
  3. 终于有人把SaaS讲明白了
  4. 跟着阿里大牛捞干货:2019这5本书带你玩转大数据
  5. 骚操作!他居然同时给谷歌和 Facebook 上班干活
  6. 究竟如何保证session一致性?
  7. SpringBoot中如何灵活的实现接口数据的加解密功能?
  8. 字节跳动也取消大小周了,996内卷要终结了?
  9. Winter is coming,明星公司也裁员了...
  10. autopoi升级到4.0版本修改方法