glibc下的内存管理
在解码过程中我们也遇到了类似的问题,第一次解码的音频比较大60s,耗了3G的内存,reset之后内存并没有退还给操作系统,第二次即使解一个10s的音频
几周前我曾提到,我被项目组分配去做了一些探究linux下内存管理机制的活儿。因为我们的产品遇到了一些与之相关的“诡异”问题。这些问题以及相关情况可以概括如下:
- 先介绍一下相关的背景。由于我们是3D软件,所以用户经常会有“导入/导出”各种geometry的需求。而一个存储这些数据的文件,可能含有不止一个geometry,而且每个geometry中也可能存在着成千上万个面片/多边形等各种基本元素。这些元素本身都不大,但数量很多。
- 第一次导入geometry时,会占据大量内存(比如说吧,有1.5G)以上;在不关闭软件而进行各种“清理”操作后,内存却基本不释放;接着再次导入相同的geometry时,内存也没有明显增加;然而如果再进行一次导入操作的话,内存又会被大量占用(约1G以上)。
- 将以上试验,换成先导入geometry1, 然后清理场景, 再导入geometry2,此时geometry2的内存占用量,要比单独首次导入geometry2时所占用的内存量要小。
- valgrind是一款在linux下经常使用检查各种内存管理问题的工具集合。我们用valgrind的memcheck组件进行过专门的内存泄露测试,并未发现明显的泄露情况。
- 我们的产品在mac平台上也有相应的版本。拿到mac os x上做实验,发现同样的代码,表现并不相同。其中每次清理场景后,都会有可观的内存(约600-800MB)被退回给操作系统(OS), 不过并不完全等于导入geometry前的内存量。
- 可以确定linux上的malloc函数用的是glibc的ptmalloc的实现。而mac上没有用到glibc,是它自己的实现。(具体信息待查)
- 我们的产品在为这些需求分配内存的时候,虽然经过“包装”,但主要是为了检查内存是否用尽从而及时提出警告。归根到底使用的还是标准的glibc的分配器(__libc_malloc(size_t))
以上的描述都是基于客观事实。而我探索的主要手段,就是根据这些事实搜索互联网(google/百度)。几天下来收获颇丰。下面总结一些收获。
-
类似的案例 :
- GLIBC内存分配机制引发的“内存泄露”
我们正在开发的类数据库系统有一个内存模块,出现了一个疑似”内存泄露”问题,现象如下:内存模块的内存释放以后没有归还操作系统,比如内存模块占用的内存为10GB,释放内存以后,通过TOP命令或者/proc/pid/status查看占用的内存有时仍然为10G,有时为5G,有时为3G, etc,内存释放的行为不确定。
- 有大量的相关提问在stackoverflow上可以被搜到。可自行搜索。比如这个 : Linux Allocator Does Not Release Small Chunks of Memory
- GLIBC内存分配机制引发的“内存泄露”
-
malloc()/free(), mmap(), brk(), 还有,用户程序-->glibc -->linux kernel之间的关系
- malloc()/free()是C语言下负责内存分配/释放的两个非常基础的函数。然而,作为C标准,ANSI C并没有指定它们具体应该如何实现。因此在各个系统级平台上(windows, mac, linux等等),调用这两个函数时,底层的内存操纵方式并不一样。
- 在linux下,malloc()/free()的实现是由glibc库负责的。这是一个相当底层的库,它会根据一定的策略,与系统底层通信(调用系统API)。因为glibc的这层关系,在涉及到内存管理方面,用户程序并不会直接和linux kernel进行交互,而是交由glibc托管,所以可以认为glibc提供了一个默认版本的内存管理器。它们的关系就像这样:用户程序---->glibc---->linux kernel。
-
glibc使用了ptmalloc作为其内存管理器的实现。关于ptmalloc究竟是如何管理内存的,我看了很多教程,其中这篇 我认为讲得最通透,想了解真相的同学推荐去那里看。下面是给自己做的潦草总结,不适合作为学习读物(截图都是link过来的)。
- brk分配的内chunk list,只能从top开始线性向下释放。释放掉中间的chunk,无法归还给OS,而是并链入到了bins/fast bins的容器中。
- mmap分配的内存,等于是直接从物理内存中映射了一块过来。释放这块内存时,可以直接归还给OS。
- 对于reqest的一块内存,到底是由brk分配,还是由mmap分配,这是由glibc策略机制决定的。
- 有个threshold,可以调节这种策略。默认下,小于128kb由brk分配,大于等于则由mmap分配。
- 但现代的glibc实现中(还没调查从哪个版本开始),支持了动态调节threshold技术。默认下,在64位系统上,brk可以动态调整到从128kb到32mb。调整策略基本可以概括为:发现对顶可以release的可用内存超过256kb的话,就将threshold调整到256kb。依次类推直到32mb.
- 这个threshold也是可以人为控制的。具体见下面的链接。
- 以上几点我写了一个小程序进行过验证,发现的确如此。测试的内容大概为,用一个双向链表(std::deque)装载设计过的chuck,根据指令,要么为尾端压入一个chunk, 要么从尾端弹出一个chunk,要么从首端弹出一个chunk,观察内存用量。发现,对于小size的chunk,从尾端弹出元素后,内存都可以释放,但从首端弹出的chunk,内存并没有释放;如果chunk足够大,无论从尾端还是首端,内存都可以释放。
glibc使用如此的两种机制管理用户程序的内存,是有意设计使然。毕竟,与系统底层通信的代价是昂贵的,如果动辄就直接操纵大量小块内存,就相当于频繁地与系统调用进行通信,这样显然会降低程序的运行效率。将小块内存放入brk维护的一个堆中,就相当于实现了一块缓存(cache),用完了可以先攒起来,到时候可以一起归还给系统。公正地讲,这种设计挺smart的。
可是,它还没有smart得足够好。首先,由于它的实现相对来说还是比较简单,只维护了堆顶的一个指针。因此想要归还给系统的话,必须从顶向下,依次归还。想象一下这种情况,假如堆顶有块内存一直被占用着,而下面的所有内存都已经没用了。那下面的这些内存,可以归还给系统吗?很遗憾,这种设计决定了答案是不可以。这就出现了“洞(Hole)”的问题。
另外,这种设计对一些由于业务需求,频繁申请/释放小块内存的用户程序而言,也不够友好。像我们的这种3D软件,正是典型的一种情况:一个巨大的几何体,实际上是由成千上万的小面片组成的,每一个都不大,就是数量多。所以我们的软件就会面临“已经释放了内存,但却没有归还给系统”的诡异问题。对付这种问题,最佳的策略,应该是早期就精心设计并使用一种适合我们软件的“专用内存池”技术,申请连续的大块内存空间,手动”切割“开给众多小面片使用。到时候根据情况再分批归还给系统。总之,专门设计自己的内存管理方案总归是灵活多变的,可以视项目的需求情况而打造。
话说回来, 虽然glibc制定了这种有些“强硬”的内存管理方案,但也提供了一些方法允许调节相关阈值(threshold),我们虽然不能干涉怎么管理内存,但好歹可以通过这些方法,决定“多大算大,多小算小”以及“攒到多少就归还”等这类问题。
-
mallopt() 与 malloc_trim(0)
-
mallopt是一个专门调节相关阈值的函数,具体细节就不讲了,man手册上说得就挺明白的。下面贴的一段还是留给自己的。想了解详情的同学请点这里。
#include < malloc.h >
int mallopt(int param, int value);
M_MMAP_THRESHOLD
For allocations greater than or equal to the limit specified (in bytes) by M_MMAP_THRESHOLD that can't be satisfied from the free list, the memory-allocation functions employ mmap(2) instead of increasing the program break using sbrk(2).
Allocating memory using mmap(2) has the significant advantage that the allocated memory blocks can always be independently released back to the system. (By contrast, the heap can be trimmed only if memory is freed at the top end.) On the other hand, there are some disadvantages to the use of mmap(2): deallocated space is not placed on the free list for reuse by later allocations; memory may be wasted because mmap(2) allocations must be page-aligned; and the kernel must perform the expensive task of zeroing out memory allocated via mmap(2). Balancing these factors leads to a default setting of 128*1024 for the M_MMAP_THRESHOLD parameter.
The lower limit for this parameter is 0. The upper limit is DEFAULT_MMAP_THRESHOLD_MAX: 5121024 on 32-bit systems or 410241024sizeof(long) on 64-bit systems.
Note: Nowadays, glibc uses a dynamic mmap threshold by default. The initial value of the threshold is 128*1024, but when blocks larger than the current threshold and less than or equal to DEFAULT_MMAP_THRESHOLD_MAX are freed, the threshold is adjusted upwards to the size of the freed block. When dynamic mmap thresholding is in effect, the threshold for trimming the heap is also dynamically adjusted to be twice the dynamic mmap threshold. Dynamic adjustment of the mmap threshold is disabled if any of the M_TRIM_THRESHOLD, M_TOP_PAD, M_MMAP_THRESHOLD, or M_MMAP_MAX parameters is set.
-
malloc_trim()是一个很有意思的函数。“有意思”在我到现在还不是很明白它到底是怎么工作的。这里也是我很想向各位请教的地方(如有见解,请不吝赐教)。根据man手册的解释,它应该是负责告诉glibc在brk维护的堆队列中,堆顶留下多少的空余空间(free space),其他往上的空余空间全部归还给系统。而且手册明确说明,它不能归还除堆顶之外的内存。下面贴一段man手册的官方描述:
The malloc_trim() function attempts to release free memory at the top of the heap (by calling sbrk(2) with a suitable argument).
The pad argument specifies the amount of free space to leave untrimmed at the top of the heap. If this argument is 0, only the minimum amount of memory is maintained at the top of the heap (i.e., one page or less). A nonzero argument can be used to maintain some trailing space at the top of the heap in order to allow future allocations to be made without having to extend the heap with sbrk(2).
按照描述所说,malloc_trim(0)应该只是归还堆顶上全部的空余内存给系统,按道理,它不应该会有能力归还堆顶下面的那些空余内存(那些“洞”)。不过,我自己做的小程序实验中,却推翻了这个论断。当我调用了malloc_trim(0)以后,我发现堆中全部的空余内存全部被归还给系统了,包括那些洞。不过,free list bing/fast bin中依然维护着这些内存地址,当再次需要申请小内存块时,总是前面的洞被再次从系统中“要”回来,然后分给调用者。这一点显得malloc_trim(0)很高级,我当然也很欢迎它具有这样出色的表现。但因为这样的行为与官方的手册描述有出入,让我理解起这个模型来相当困惑,真是百思不得姐...
我做实验的平台是Linux RH5。代码也贴了出来(写得很烂)。考虑到贴在这里会显得很臃肿,我把它分享在这里。注意这个版本中已经把双向链表替换成了静态数组,纯粹是为了做实验,效果是一样的。
-
-
由此想到的一些经验之谈
- 注意以后写geometry相关的功能时,使用std::vector操作的时候尽可能小心。尽量成批reserve一块内存使用。减少在容器已满的情况下仍然push_back单个元素的操作,这样非常容易产生碎片。
- 另外即便是在栈上分配一个std::vector(意味着出栈即被回收),也要注意它维护的队列却是分配在heap上的。也就是说一个这样的临时对象所操作过的内存,依然可能产生碎片。如果这样的函数被频繁调用,碎片就会非常多。
- 还有即便我们做过shrink_to_fit的工作(std::vector<t*>(v).swap(v)),如果里面是碎片,那也会被驻留在brk维护的free_list中,不会被释放。