高性能后台服务为什么需要内存池

       C/C++程序为什么比起其它语言开发的程序效率要高,一个很重要的原因就是可以直接操作内存,今天就来讲讲为什么需要内存池的技术。

从一个示例开始

       先看下面两段代码,都是去重复的创建和删除对象0x5FFFFF次,他们的执行后的效率怎么样呢?
       程序一:

#include <iostream>
#include <time.h>

class CTestClass{
    char m_chBuf[4096];
};

int main(){
    clock_t count = clock();

    for(unsigned int i=0; i<0x5fffff; i++){
        CTestClass *p = new CTestClass;
        delete p;
    }

    std::cout << "Interval = " << clock() - count  << " ticks" << std::endl;

    return 0;
}

       程序二:

#include <iostream>
#include <time.h>

char buf[4100];

class CTestClass{
    char m_chBuf[4096];
public:
    void *operator new(size_t size){
        return (void *)buf;
    }

    void  operator delete(void *p){}
};

int main(){
    clock_t count = clock();

    for(unsigned int i=0; i<0x5fffff; i++){
        CTestClass *p = new CTestClass;
        delete p;
    }

    std::cout << "Interval = " << clock() - count  << " ticks" << std::endl;

    return 0;
}

       在我的机器上执行的情况是:



       从上图的结果可以看出他们执行的效率大约相差8倍,那这是为什么呢?这是因为程序二重载了new运算后避免了使用malloc/free等系统调用,众所周知,系统调用是很耗时的操作。

内存是如何分配的

1. brk和mmap是什么鬼?

       我们先看下系统内存的布局,如下图:



应用程序动态分配内存的区域就是用户栈和全局区直接的一块区域,这块区域允许我们使用mmap、munmap和brk去向内核申请和释放内存的系统调用,内核刚把应用程序加载时,brk指针指向全局区的高地址,当我们调用brk系统调用,内核把brk指针往高地址移动,同时内核把它所管理的这一块内存返还给用户,用户因此就获得一块可以自由操作的内存;mmap不一样的是直接从动态内存区域直接映射一块未被使用的内存块给用户,该内存块可以通过munmap释放。且看下面一段程序(程序三):

#include <unistd.h>
#include <iostream>
#include <time.h>

int main(){
    void* p = sbrk(0);
    clock_t count = clock();
    for(unsigned int i=0; i<0x5fffff; i++){
        int* p1 = (int*)p;
        int ret = brk(p1 + 256);  //malloc 1k
        p1[256] = 256;
        ret = brk(p);     // free 1k
    }

    std::cout <<"Interval = " << clock() - count << "ticks" << std::endl;

    return 0;
}

sbrk和brk都可以向内核申请内存和释放,只不过调用的方式不一样而已,上面通过sbrk获得应用程序刚加载的时候brk指针所在的位置,在for循环里通过调用brk函数去申请和释放内存,但是这种通过系统调用去申请和释放内存的效率怎么样?下图是测试的结果



       通过这种原始的系统调用去分配和释放内存的效率是相当低的,总体来讲存在以下三个问题:

  • brk、mmap和munmap都是系统调用,调用的成本是非常非常高的;
  • 释放的时候又直接归还给操作系统了,而实际上是还可以加以利用的;
  • brk最近申请的内存没有释放的话,之前申请的内存是无法释放的,比如brk申请1K,再申请2k,再申请4k内存,此时如果想释放最先申请的1K内存,那不好意思,brk不支持这个,因为brk函数智能移动brk指针,要等到4k和2k内存都释放了才能释放这个1k内存,内存碎片的产生的根源就是因为这。

2. glibc粉墨登场了

       正是因为使用原始的系统调用brk、mmap和munmap存在很多问题,那么libc针对性的给出了优化的方案,如glibc一开始就调用brk或者mmap准备一块比较大的内存,然后把比较大的内存划分成小内存,当我们调用malloc时直接给用户,释放内存时并不一定就归还给内核,而是继续持有,以便调用malloc时能够继续用,而不用去向操作系统要(哦!我的措辞不够严谨,且听下文详细分析)。
       glibc的内存块采用chunk管理,并且将大小相似的chunk用链表管理,一个链表被称为一个bin。chunk的结构如下图:



总共是128个bin,前64个bin里,相邻的bin内的chunk大小相差8字节,称为small bin,后面的是large bin,large bin里的chunk按先大小,再最近使用的顺序排列。分配内存时从所有的bins里查找最合适的chunk。如果是特别大的内存则从top chunk分配,如果不够且小于128k则调用brk扩充top chunk的内存,否则直接调用mmap分配内存。



       glibc的分配流程是:
  1. 获取分配区的锁,防止多线程冲突。
  2. 计算出需要分配的内存的chunk实际大小。
  3. 判断chunk的大小,如果小于max_fast(64b),则取fast bins上去查询是否有适合的chunk,如果有则分配结束。
  4. chunk大小是否小于512B,如果是,则从small bins上去查找chunk,如果有合适的,则分配结束。
  5. 继续从 unsorted bins上查找。如果unsorted bins上只有一个chunk并且大于待分配的chunk,则进行切割,并且剩余的chunk继续扔回unsorted bins;如果unsorted bins上有大小和待分配chunk相等的,则返回,并从unsorted bins删除;如果unsorted bins中的某一chunk大小 属于small bins的范围,则放入small bins的头部;如果unsorted bins中的某一chunk大小 属于large bins的范围,则找到合适的位置放入。
  6. 从large bins中查找,找到链表头后,反向遍历此链表,直到找到第一个大小 大于待分配的chunk,然后进行切割,如果有余下的,则放入unsorted bin中去,分配则结束。
  7. 如果搜索fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了(top chunk相当于分配区的剩余内存空间)。判断top chunk大小是否满足所需chunk的大小,如果是,则从top chunk中分出一块来。
  8. 如果top chunk也不能满足需求,则需要扩大top chunk。主分区上,如果分配的内存小于分配阀值(默认128k),则直接使用brk()分配一块内存;如果分配的内存大于分配阀值,则需要mmap来分配;非主分区上,则直接使用mmap来分配一块内存。通过mmap分配的内存,就会放入mmap chunk上,mmap chunk上的内存会直接回收给操作系统。

       具体如图:


malloc流程

       glibc的释放流程:

  1. 获取分配区的锁,保证线程安全。
  2. 如果free的是空指针,则返回,什么都不做。
  3. 判断当前chunk是否是mmap映射区域映射的内存,如果是,则直接munmap()释放这块内存。前面的已使用chunk的数据结构中,我们可以看到有M来标识是否是mmap映射的内存。
  4. 判断chunk是否与top chunk相邻,如果相邻,则直接和top chunk合并(和top chunk相邻相当于和分配区中的空闲内存块相邻)。转到步骤8
  5. 如果chunk的大小大于max_fast(64b),则放入unsorted bin,并且检查是否有合并,有合并情况并且和top chunk相邻,则转到步骤8;没有合并情况则free。
  6. 如果chunk的大小小于 max_fast(64b),则直接放入fast bin,fast bin并没有改变chunk的状态。没有合并情况,则free;有合并情况,转到步骤7
  7. 在fast bin,如果当前chunk的下一个chunk也是空闲的,则将这两个chunk合并,放入unsorted bin上面。合并后的大小如果大于64KB,会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中,fast bin会变为空。合并后的chunk和topchunk相邻,则会合并到topchunk中。转到步骤8
  8. 判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。free结束。

       那么glibc存在什么不足呢?

  • 后分配的内存先释放,因为 ptmalloc 收缩内存是从 top chunk 开始,如果与 top chunk 相邻的 chunk 不能释放, top chunk 以下的 chunk 都无法释放。
  • 多线程锁开销大, 需要避免多线程频繁分配释放。
  • 内存从thread的分配区中分配, 内存不能从一个分配区移动到另一个分配区, 就是说如果多线程使用内存不均衡,容易导致内存的浪费。比如说线程1使用了300M内存,完成任务后glibc没有释放给操作系统,线程2开始创建了一个新的arena,但是线程1的300M却不能用了。
  • 每个chunk至少8字节的开销很大
  • 不定期分配长生命周期的内存容易造成内存碎片,不利于回收。 64位系统最好分配32M以上内存,这是使用mmap的阈值。

3. 是时候请出tamalloc了

       tcmalloc是Google开源的一个内存管理库, 作为glibc malloc的替代品。目前已经在chrome、safari等知名软件中运用。
       Talk is cheap, show me the code。

#include <iostream>
#include <time.h>
#include <google/tcmalloc.h>

class CTestClass{    
    char m_chBuf[4096];
};

int main(){
    clock_t count = clock();    
    
    for(unsigned int i=0; i<0x5fffff; i++){
        CTestClass *p = new CTestClass;
        delete p;
    }
    
    std::cout << "Interval = " << clock() - count  << " sec" << std::endl;

    return 0;
}

       同样的代码引入了tcmalloc分配内存后,效率怎么样呢?



       至此,我们做个总结:



       那tcmalloc又是如何做到的呢?

4.tcmalloc解决并发和内存碎片问题

我们先来看一张图:


tcmalloc流程

我们先来解释一下,tcmalloc为每一个线程分配了一个ThreadCache区域,每个线程都从属于自己的ThreadCache里分配和释放内存,这样就避免了glibc里多线程分配和释放内存的时候的锁的操作,锁是很有代价的,从上文的对比测试我们也能查看出他们的差异,那么我们总结出今天的第一个设计上的结论:真正高效的并发不是使用互斥锁、原子操作和自旋锁去解决对共享资源的访问,而是不共享任何资源,如果真要共享那么实现线程/进程间的通信

那么tcmalloc是如何优化内存碎片的问题呢?答案就是CentralCache,当释放内存的时候,ThreadCache依然遵循批量释放的策略,对象积累到一定程度就释放给 CentralCache;CentralCache发现一个 Span的内存完全释放了,就可以把这个 Span 归还给 PageHeap;PageHeap发现一批连续的Page都释放了,就可以归还给操作系统,当ThreadCache的内存不够用的时候,那么就向CentralCache去借内存,然后分配出去,那么我们就能得出一个结论内存可以通过CentralCache在ThreadCache间流动,那么就在glibc的再一次优化了内存利用率,降低了内存碎片率。

高性能服务器为什么需要内存池

最后我们以一位大师的话来总结为什么需要内存池的设计:


为什么需要内存池
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容

  • 2017年末,手Q春节红包项目期间,为保障活动期间服务正常稳定,我对性能不佳的Ark Server进行了改造和重写...
    90后老码农阅读 10,320评论 1 12
  • 搬运自牛客网大神总结 extern关键字 extern修饰变量是个声明,此变量/函数是在别处定义的,要在此处引用 ...
    leon4ever阅读 3,654评论 0 9
  • 操作系统对内存的管理 没有内存抽象的年代 在早些的操作系统中,并没有引入内存抽象的概念。程序直接访问和操作的都是物...
    Mr槑阅读 16,685评论 3 24
  • 内存是计算机非常关键的部件之一,是暂时存储程序以及数据的空间,CPU只有有限的寄存器可以用于 存储计算数据,而大部...
    dreamer_lk阅读 1,195评论 2 10
  • “金钱往往成为真正情义的障碍物。” 昨天和室友一起进市里吃东西,小A身上没带现金,于所向小B借了四块钱座公车,小A...
    22fAncy阅读 224评论 0 0