内存池之二:定长内存池

/ 1评 / 0

Published by orzz.org(). (https://orzz.org/memory-pool-fixed/)

内存池是一种用来分配内存的池技术,重点在“池”,即内存的重用上。
重点不在“池”上的内存分配技术当然也是有的,比如stl的内存分配器(SGI STL使用了内存池,而有很多其他版本的STL则没有),重点在“分配器”的概念上。
内存的重用,能够带来不少好处。最为直观的好处是,提高内存分配/释放的速度;不大明显的好处,则是避免程序产生内存碎片(即空闲却又无法利用的内存)。
另外,由于内存池托管了内存分配,其实它属于内存分配器的一种,因此内存分配器的好处它也自然可以享受到。比如在内存分配的时候增加一些标记,可以使之支持内存泄露检测之类。
内存池根据各自的适用场景,又分为很多种:比如单线程/多线程的分类;又比如定长/变长的分类。
想要实现一个通用(即可以在各种场景下使用),又高效的内存池,不是一件容易的事情;学习、对比、然后使用第三方的内存池,也需要一定的时间成本。所以很多时候,在自己的代码里,又或实际的项目中,若非有确凿的证据表明内存管理方面存在不可忽视的瓶颈,否则大家使用的还是new/delete,或者malloc/free。
下面我们一起来实现一个简单的定长内存池,并尝试对它进行一些拓展。

1. 只分配固定大小内存的fixed_pool

我们先考虑一个最简单的内存池:不考虑单线程/多线程,不考虑分配大小可变,不考虑一个内存是否被回收两次,也不考虑回收的上限(即只从系统获取内存而不归还 —— 这其实在很多时候不会是一个问题,因为我们不会无限制的分配内存,而总是会一边分配一边释放,这样释放的内存就被保存起来供下次分配用)。
这么简单的内存池也是有它的适用场景的,比如在同一个线程里需要不停分配/释放同样大小内存块或对象的时候。
它的定义可以简单得像这个样子:

通过它,我们只能干两件事情,那就是分配一块固定大小的内存,或者释放(回收)掉刚刚分配出来的内存。
那么,固定的大小如何传递呢?
通常有两种方法,一种是通过模板参数传递:

这种方法不需要额外的size_t成员来保存一个内存block的大小,在编译时这些信息就都被决定好了。
另一种是通过构造函数传递:

虽然这样会消耗一个size_t的空间,但是不论把block的大小设为什么,fixed_pool的类型始终是不会有改变的。
为了方便后面使用它来构造通用的定长内存池,这里我使用第二种方法来定义fixed_pool。
那么block_size_的初始化过程如下:

接下来是最关键的地方了,alloc的实现。
想要实现alloc,首先要思考下我们用的分配算法。
最直观也最简单的一种想法是:

  • 需要一个,就构造一个
  • 若使用完了,就将内存放入空闲链表
  • 再构造的时候,看链表里有没有内存块,有就直接取

  • 那么首先,需要定义链表结点和空闲链表头:

    接下来就是alloc的实现:

    在这里要注意malloc的大小是block_size_,而不是sizeof(block_t*) + block_size_。因为只要block_size_满足大于sizeof(block_t*),结点本身的内存大小就足够存储下一个结点的指针了。而内存一旦被alloc出去之后,next_指针里的内存其实是不需要保存的(因为这里不检查内存的有效性,也没有其他的需求要求内存块离开内存池后需要通过它访问内存池内部的数据),可以认为一旦分配出去的内存,就和内存池无关了,只有当内存被归还之后,才需要用到这个next_指针。
    最后是free的实现:

    好了,到了这里我们可以先写一小段测试代码来试探下这个简单内存池的性能。
    想要测试内存分配性能,有几种常规做法,比如连续分配再连续释放,或蝶式分配释放(即随机分配释放)。这里采用蝶式分配释放来测试性能。
    算法如下,先通过init函数初始化好随机索引数组,然后后面通过遍历这个随机的数组,就可以保证分配时的数据长度,和分配/释放都是随机的。

    void init(int n = 1)的参数是预留的,为后面的多线程内存池测试做准备。
    负责进行测试的算法如下:

    最后,还需要两个外敷的wrapper,用来包裹出统一的测试接口:

    测试代码中的stopwatch和random,是我自己实现的简单的秒表和随机数,代码可以在这里看到:
    https://code.google.com/p/nixy/source/browse/trunk/nixycore/time/stopwatch.h
    https://code.google.com/p/nixy/source/browse/trunk/nixycore/random/random.h
    好了,现在可以来写一个简单的main函数,

    并跑一跑看看测试效果:

    测试平台:

  • CPU:i3-M330 @ 2.13GHz
  • RAM:8GB
  • OS:Windows 7 SP1 64位

  • 编译环境:

  • mingw 4.8,release,O2

  • 在测试时为了公平,我特意把分配的大小固定在了1KB。从数据上看,fixed_pool比直接的new/delete要快一个数量级。
    这里有几个细节,或者说上面的简单内存池的缺点:

  • 1. fixed_pool的内存申请不是连续的。

  • 上面的alloc算法,在不论多少次分配之后,若池里的内存不够了,都只会新申请一个block_size长度的内存。这意味着一次性的连续大量内存分配并不比系统的内存分配器好多少。而且从系统分配内存时,在分配时间间隔不确定的情况下,可能会让进程的内存碎片更严重。

  • 2. fixed_pool没有考虑向系统释放内存。

  • 向系统释放内存在一些应用场景里其实不是必要的。比如你在main函数中直接new int,严格来说并不算内存泄漏。没有delete的内存在程序退出后会由系统帮你妥妥的善后掉,不存在不能被其他程序使用的问题。
    但同样是由于没有内存的释放过程,fixed_pool在使用时必须是单例形式,或者说全局的。由此,上面的测试外敷类fixed_alloc里使用了static静态成员的方法提供唯一的fixed_pool实例。
    第一个问题需要改进前面的内存分配算法:在每次需要扩充容量时,都获取一大块内存,在这一大块内存上划分出链表结构,形成free_list_。
    第二个问题则可以通过不同的模板配置来解决:通过传入一个模板参数,来配置fixed_pool是否在析构时向系统释放其中的内存。若在使用时只需要一个全局的内存分配入口点,那么系统释放内存就是不必要的,反之则是必要的。
    下面先来解决第一个问题。

    2. 在需要时申请一大块连续内存

    fixed_pool的alloc接口可以改成这个样子:

    这样,就把“在需要时获取一大块内存”的expand部分从alloc里单独抽出来了。
    现在的fixed_pool,expand算法是这样的:

    改进一下算法,很简单就可以调整成每次分配10个block_size_长度的内存:

    可以很容易发现,count固定为10是一个不好的做法。好的做法应当是修改为某个预测模型:比如vector的内部实现里,一般在每次需要扩充内存的时候,会将当前的内存容量翻倍。
    这一类的算法,要求expand根据被调用的次数决定count的值,因此count不能再是一个常量或局部变量了。
    为了保持fixed_pool代码的整洁,可以把expand的具体实现交给一个Policy。这样fixed_pool就不需要关注该如何expand,只需要维护free_list_链表就可以了。
    抽象出上述预测模型的Policy可以像这样:

    这是个非常简单的模型。每次调用expand的时候,内部都会自动将count_翻倍,然后返回更大的内存块。
    如果实际需要的话,后面可以很方便的实现并应用其它预测模型,比如以斐波那契数列(Fibonacci)方式增长的预测模型。
    fixed_pool和expand修改一下就可以使用这个模型了:

    expand里面用来在一整块内存上构建链表的算法,其实和boost.pool、loki里的小内存分配器的基础算法是异曲同工的。
    使用新的fixed_pool测试的结果:

    由于expand需要提前构建空闲链表,而第一个版本则没有这个开销,所以平均来说会比第一次慢不少,不过速度仍然是new/delete的6倍左右。
    接下来解决上面提到的第二个问题。

    3. 支持内存的归还

    前面在解决第一个问题的时候,已经把fixed_pool改写成了一个可以根据Policy变化算法的类了。
    因此这里只需要构建一个可以自动释放内存的Policy就行了:

    这个策略也不复杂,其实就是构建了一个链表,把每次expand返回的内存块串了起来,然后再在析构的时候把它们销毁掉。
    归还策略的fixed_pool的测试用alloc就不再需要static了:

    下面对它做一下测试(同时使用new/delete、expand_default和expand_return):

    可以发现,速度不升反降,变得和new/delete相差无几了。
    观察下测试算法,里面是在每两次test循环之后,析构一次alloc。相对于new/delete,expand_return其实就是把零碎的分配/释放集中在了一起完成。
    从测试结果上可以看出来,一次大内存释放,和多次的小内存释放比起来,并没有什么性能优势。
    稍微修改一下测试方法:

    然后再跑一次测试:

    return_alloc的速度一下子快了3倍。所以说,对于内存池而言,应当尽量不要频繁的把内存归还给系统,否则内存池的预存储意义就不大了。

    4. 利用fixed_pool构建多级分配

    早期Loki的SmallObjAllocator,是使用一个数组存储各种长度的FixedAllocator(和上文的fixed_pool类似),数组下标和FixedAllocator的分配长度没有直接关系。从SmallObjAllocator申请内存和回收内存,都需要使用二分查找定位合适的FixedAllocator。
    现在的Loki更换了算法,直接使用(256 + 4 - 1) / 4 = 64个FixedAllocator组成的数组,数组中的每个FixedAllocator分配的内存依次递增4 byte。这样就不再需要二分查找的开销,直接通过简单的计算就可以快速定位合适的FixedAllocator。
    我们可以模仿它的做法,将内存分为3级:
    第一级用于分配小内存,使用64个fixed_pool,内存大小以sizeof(void*)为单位递增,32位系统里最大就能够分配到256bytes;
    第二级用于分配大内存,也使用64个fixed_pool,采用64bytes为分配间隔,最大能够分配到4KB;
    更大的内存将被分类到第三级,由默认的new/delete处理。
    Loki的内存释放接口带有一个size_t参数,需要指定待释放内存的大小。若不指定大小,则会导致一次线性搜索。
    而这里的目标是让mem_pool的接口和malloc/free保持一致。为了避免线性搜索,只好采用空间换时间的做法,在每个分配的内存块的头部放入一个size_t来保存此内存块的大小。
    先定义出mem_pool需要的这些配置,以及外部接口:

    然后,在mem_pool的构造函数里,分配出pools_数组的每个元素,并在析构函数释放资源:

    这是在常数时间内根据分配大小定位一个fixed_pool的算法:

    有了上面这些东西,现在可以很轻松的实现出alloc和free:

    先测试一下,定义一个mem_pool_alloc:

    然后运行:

    可以看到mem_pool和fixed_pool的速度相差无几。
    下面把Alloc Size调整为4Byte-1.0KB,再试试:

    最后,我们来实现realloc。realloc稍显复杂,首先,把realloc的“外壳”搭建出来:

    然后,真正的realloc算法可以单独抽取出来在do_realloc里实现,这样代码整体会比较清爽:

    为了测试realloc,前面的测试算法需要调整一下:

    然后在测试用的alloc内分别包装出realloc语义:

    测试结果:

    可以看到,使用realloc又比一般的alloc/free要快了不少。


    本文简单的介绍了定长内存池的相关设计,变长内存池等其它类型的内存池后文中再慢慢展开。


    参考文章:

  • 1. 常见C++内存池技术
  • 2. 内存池到底为我们解决了什么问题
  • 3. 内存池(MemPool)技术详解
  • 4. developerWorks 图书频道: C++ 应用程序性能优化,第 6 章:内存池
  • 5. 【原创】技术系列之 内存管理(一)
  • 6. 【原创】技术系列之 内存管理(二)
  • 7. 差点被boost::pool里的一行代码吓到
  • 8. SGI STL空间配置器和内存池
  • 9. 内存分配(4)–LOKI的小物件分配器
  • 10. 小对象的分配技术
  • Published by orzz.org(). (https://orzz.org/memory-pool-fixed/)

    1. 增达网说道:

      受教了!呵呵!

    发表回复

    您的电子邮箱地址不会被公开。 必填项已用*标注

    此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据