文档章节

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

shzwork
 shzwork
发布于 07/24 07:02
字数 3020
阅读 1
收藏 0

       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的再一次优化了内存利用率,降低了内存碎片率。

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

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

 



作者:耐寒
链接:https://www.jianshu.com/p/c23f1d450c56
来源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

本文转载自:https://www.jianshu.com/p/c23f1d450c56

shzwork
粉丝 15
博文 825
码字总数 11168
作品 0
厦门
私信 提问
网络编程的演进——从 Apache 到 Nginx

Apache Apache HTTP 服务器是 Robert McCool 在 1995 年写成,并在 1999 年开始在 Apache 软件基金会的 框架下进行开发。由于 Apache HTTP 服务器是基金会最开始的一个项目也是最为有名的一个...

xjtuhit
2018/01/04
0
0
分布式服务框架之服务化最佳实践

在服务化之前,业务通常都是本地API调用,本地方法调用性能损耗较小。服务化之后,服务提供者和消费者之间采用远程网络通信,增加了额外的性能损耗,业务调用的时延将增大,同时由于网络闪断...

李景枫
2016/05/09
169
0
线程池的设计(一):半同步半异步线程池的设计

前置问答: 1.为什么要使用线程池? 避免大量创建和销毁线程,提升性能。 2.线程池主要工作流程?一是外部不断地向线程池添加任务,而是线程池内部不断地取任务执行,这是典型的生产者消费者...

苗永超
2016/04/07
580
2
Netty(二) 从线程模型的角度看 Netty 为什么是高性能的?

前言 在之前的 SpringBoot 整合长连接心跳机制 一文中认识了 Netty。 但其实只是能用,为什么要用 Netty?它有哪些优势?这些其实都不清楚。 本文就来从历史源头说道说道。 传统 IO 在 Nett...

crossoverJie
2018/07/04
0
0
高效率的nio框架--nio java raptor

设计初衷是提供方便易用,且高效率的nio框架,一部分实现上参考了mina。还包括线程池,编解码,内存池等机制,以便于开发高性能tcp程序。 文档后续会慢慢的补上。 整体实现上尽量少的使用锁,...

齐楠
2012/12/12
3.2K
0

没有更多内容

加载失败,请刷新页面

加载更多

Android OkHttp + Retrofit 取消请求的方法

本文链接 前言 在某一个界面,用户发起了一个网络请求,因为某种原因用户在网络请求完成前离开了当前界面,比较好的做法是取消这个网络请求。对于OkHttp来说,具体是调用Call的cancel方法。 ...

shzwork
今天
6
0
并发编程之Callable异步,Future模式

Callable 在Java中,创建线程一般有两种方式,一种是继承Thread类,一种是实现Runnable接口。然而,这两种方式的缺点是在线程任务执行结束后,无法获取执行结果。我们一般只能采用共享变量或...

codeobj
今天
6
0
Ubuntu环境下安装PaddlePaddle

开篇 深度学习技术是目前非常热门的技术,笔者在闲暇之余决定学习一下这门技术,入门选择了百度开源的PaddlePaddle框架。 paddlepaddle介绍 飞桨(PaddlePaddle) 是国际领先的端到端开源深度学...

豫华商
今天
6
0
LeetCode 第 287 号问题:寻找重复数,一道非常简单的数组遍历题,加上四个条件后感觉无从下手

今天分享的题目来源于 LeetCode 第 287 号问题:寻找重复数。 题目描述 给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只...

五分钟学算法
今天
6
0
vuex mapActions

本文转载于:专业的前端网站➧vuex mapActions 在组件中使用 this.$store.dispatch('xxx') 分发 action,或者使用 mapActions 辅助函数将组件的 methods 映射为 store.dispatch 调用(需要先在...

前端老手
今天
6
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部