对于嵌入式设备来说,用户态内存管理是一项基础功能,目前主流的用户态内存管理库有glibc、uclibc、tcmalloc、jemalloc等。
0引言
对于嵌入式设备来说,用户态内存管理是一项基础功能,目前主流的用户态内存管理库有glibc、uclibc、tcmalloc、jemalloc等。
本文基于glibc2.17版本进行分析,围绕glibc内存分配原理、内存站岗问题形成原因展开讨论,并对glibc缓存大量内存(高达几十个 G甚至上百 G)且不释放的问题给出一种解决方案。
笔者遇到的问题是基于glibc进行内存管理的64 位Linux系统。具体现象如下:设备32G物理内存,在大规格打流情况下,某用户进程占用的物理内存暴涨至20G左右。
在停止打流后,观察到业务模块已经释放了绝大部分内存,但是进程占用的物理内存依然达到16G左右,此后内存状况一直维持该状态,导致系统内存紧张,若叠加上其他业务则出现了OOM的现象,已排除该进程内存泄露的可能性。
1Glibc内存分配基本原理
Glibc使用了ptmalloc的内存管理方式,本文在描述时均使用glibc来称呼。Glibc申请内存时是从分配区申请的,分为主分配区和非主分配区,分配区都有锁,在分配内存前需要先获取锁,然后再去申请内存。
一般进程都是多线程的,当多个线程同时需要申请内存时,如果只有一个分配区,那么效率太低。
glibc为了支持多线程的内存申请释放,会在多个线程同时需要申请内存时根据cpu核数分配一定数量的分配区,将分配区分配给线程。如果线程数量较多,则会出现多个线程争用一个分配区的的情况,这里不展开。
内存申请基本原理:当用户调用malloc申请内存时,glibc会查看是否已经缓存了内存,如果有缓存则会优先使用缓存内存,返回一块符合用户请求大小的内存块。
如果没有缓存或者缓存不足则会去向操作系统申请内存(可通过brk、mmap申请内存),然后切一块内存给用户,如图1所示。
图 1
内存释放基本原理:当业务模块使用完毕后调用free释放内存时,glibc会检查该内存块虚拟地址上下内存块的使用状态(fast bin除外)。若其上一块内存空闲,则与上一块内存进行合并。若下一块内存空闲,则与下一块内存进行合并。如图2所示。
若下一块内存时top chunk(top chunk一直是空闲的),则看top chunk的大小是否超过一个阈值,如果超过一个阈值则将其释放给OS,如图3所示。
图2
图3
2Glibc内存站岗及其原因
内存站岗概念:
内存站岗指的是glibc从OS申请到内存后分配给业务模块,业务模块使用完毕后释放了内存,但是glibc没有将这些空闲内存释放给OS,也就是缓存了很多空闲内存无法归还给系统的现象。
内存站岗原因:
glibc设计时就确定其内存是用于短生命周期的,因此在设计上内存释放给OS的时机是当top chunk的大小超过一个阈值时会释放top chunk的一部分内存给OS。当top chunk不超过阈值就不会释放内存给OS。
那么问题来了,若与top chunk相邻的内存块一直在使用中,那么top chunk就永远也不会超过阈值,即便业务模块释放了大量内存,达到几十个G 或者上百个G,glibc也是无法将内存还给OS的。
对于glibc来说,其有主分配和非主分配区的概念。主分配通过sbrk来增加分配区的内存大小,而非主分配区则是通过一个或多个mmap出来的内存块用链表链接起来模拟主分配区的。为了更清晰的解释内存站岗,下面举个例子来说明主分配区的内存站岗,如图4所示。
图4
如上有(a) (c) (e) (g)内存块正在使用,故而导致了空闲内存(b) (d) (f)无法和top chunk连成一块更大的空闲内存块,glibc的阈值(64位系统默认是128K),尽管目前空闲内存有将近130M,也无法还给OS。
接下来看非主分配区的内存站岗,如图 5 所示,实际的非主分配区可能有很多个heap,这里假设只有4个heap。
图5
在定位过程中,笔者与同事讨论过多次如何解决站岗。在一次讨论过程中由邓竑杰提出降低heap的size(类似于tcmalloc的做法),虽然实测后发现完全没有效果,但是为后续解决问题起到了启示作用。
后面笔者在走读代码时发现这是glibc原生机制,同时笔者在查看内存布局时观察到非主分配区大量heap均为free状态。原有机制是先释放heap3,如果heap3有内存在使用,尽管heap0、heap1、heap2的内存都释放了,那也是无法释放给系统。
glibc有多个分配区,每个分配区都几百 M 空闲内存的话,则整个进程占用达到几十个G也就不奇怪了。
3Glibc内存站岗解决方法及patch
在内存释放时,对于主分配区和非主分配其走的流程是不一样的,我们64位系统的进程内存模型为经典模式,栈是从高地址向低地址生长的。
对于主分配区的内存站岗我还没有遇到过,若主分配区内存站岗,一种方法是可以尝试madvise将主分配区的pagesize对齐的空闲内存进行释放,但是这样效果可能不太明显。
另外一种是通过创建线程,然后将主线程的业务移到新线程即可,这样主分配区就不会造成站岗了,而将站岗转移到了非主配区,而非主分配区则是我们接下来要进行优化的主战场。
针对非主分配区进行两处优化:
a) heap0,heap1,heap2是空闲的,那么我们就可以将heap1,heap2释放掉;
b) heap默认是64M,降低每个heap的size(笔者测试时设置为512K)。
图 6
这里需要特别解释一下为什么不释放heap0和最后一个heap3,heap0的组成如图7所示。图左边是第一个heap即heap0,图右边是最后一个heap即heap3。
从图中可以清晰的看到如若释放掉heap0那么会将struct malloc_state结构体释放,会造成进程崩溃。右边这个由于有在用的内存,也不能释放掉。当然如果heap3的内存全部被释放了,则由glibc原生代码进行了处理,patch不再处理。
图 7
经过修改glibc源码,优化其释放机制,实际打流测试。
在打流到峰值后,进程使用了20G的内存,在停止打流后数秒内便恢复到了打流前的内存水平,进程所占用的内存基本还给系统了。至此,glibc内存站岗问题得到解决。
以上我们介绍了如何解决内存站岗的原理,纸上得来终觉浅,现在我们看patch源码实现。
目前笔者已经将该优化的patch提交到开源社区审核,提交到社区的patch未对heap的size进行修改,这是因为想要谨慎一些,毕竟开源的代码使用场景较多,如有需要可自行决定heap的size。
Patch基于glibc2.17代码1. Index: arena.c
2. ===================================================================
3. --- arena.c (revision 2)
4. +++ arena.c (working copy)
5. @@ -652,7 +652,7 @@
6.
7. static int
8. internal_function
9. -heap_trim(heap_info *heap, size_t pad)
10. +heap_trim(heap_info *heap, heap_info* free_heap, size_t pad)
11. {
12. mstate ar_ptr = heap->ar_ptr;
13. unsigned long pagesz = GLRO(dl_pagesize);
14. @@ -659,7 +659,29 @@
15. mchunkptr top_chunk = top(ar_ptr), p, bck, fwd;
16. heap_info *prev_heap;
17. long new_size, top_size, extra, prev_size, misalign;
18. + heap_info *last_heap;
19.
20. + /*Release heap if possible*/
21. + last_heap = heap_for_ptr(top_chunk);
22. + if ((NULL != free_heap->prev) && (last_heap != free_heap)){
23. + p = chunk_at_offset(free_heap, sizeof(*free_heap));
24. + if (!inuse(p)){
25. + if (chunksize(p)+sizeof(*free_heap)+MINSIZE==free_heap->size){
26. + while (last_heap){
27. + if (last_heap->prev == free_heap){
28. + last_heap->prev == free_heap->prev;
29. + break;
30. + }
31. + last_heap = last_heap->prev;
32. + }
33. + ar_ptr->system_mem -= free_heap->size;
34. + arena_mem -= free_heap->size;
35. + unlink(p, bck, fwd);
36. + delete_heap(free_heap);
37. + return 1;
38. + }
39. + }
40. + }
41. /* Can this heap go away completely? */
42. while(top_chunk == chunk_at_offset(heap, sizeof(*heap))) {
43. prev_heap = heap->prev;
44. Index: malloc.c
45. ===================================================================
46. --- malloc.c (revision 2)
47. +++ malloc.c (working copy)
48. @@ -915,7 +915,7 @@
49. # if __WORDSIZE == 32
50. # define DEFAULT_MMAP_THRESHOLD_MAX (512 * 1024)
51. # else
52. -
# define DEFAULT_MMAP_THRESHOLD_MAX (4 * 1024 * 1024 * sizeof(long))
53. +# define DEFAULT_MMAP_THRESHOLD_MAX (256 * 1024)
54. # endif
55. #endif
56.
57. @@ -3984,7 +3984,7 @@
58. heap_info *heap = heap_for_ptr(top(av));
59.
60. assert(heap->ar_ptr == av);
61. - heap_trim(heap, mp_.top_pad);
62. + heap_trim(heap, heap_for_ptr(p), mp_.top_pad);
63. }
64. }
结束语
不同的内存管理方式均有其优势和缺陷,由于工作需要,笔者有幸研究过glibc、tcmalloc、uclibc内存管理,本文讨论了glibc内存管理存在的一个共性问题,并给出可行的解决方案。
对于内存站岗问题,一般的做法是用户自己缓存一些长时间不释放的内存。另一种是干脆将glibc替换为tcmalloc。因为 tcmalloc 的 span比较小,所以站岗发生的概率极低,即便发生也就站岗一个span的大小。若由于某些原因不能用tcmalloc代替glibc的场景,如上的解决思路可以尝试一下,该问题也困扰我们多时了,花费了较长时间和较多精力去定位。
在glibc2.28的版本中,glibc有了tcache的特性,对于业务进程使用大量小内存的场景则更加容易出现内存站岗问题。在撰写本文时查看了glibc2.33版本,开源社区还未对该问题进行修改(或许是开源社区大神认为这不是glibc的问题,而是用户不释放内存)。
原文链接:https://mp.weixin.qq.com/s/CcLabnMzy8g3Ijq_Z4p_dg