作者:来自 vivo 互联网存储团队- Wang Yuzhi
本文以一次线上故障为基础介绍了使用 glibc 进行内存管理可能碰到问题,进而对库中内存分配与释放机制进行分析,最后提供了相应问题的解决方案。
一、引言
内存对象的分配与释放一直是后端开发人员代码设计中需要考虑的问题,考虑不周极易造成内存泄漏、内存访问越界等问题。在发生内存异常后,开发人员往往花费大量时间排查用户管理层代码,而忽视了C运行时,库层和操作系统层本身的实现也可能会带来内存问题。本文先以一次线上内存事故引出问题,再逐步介绍 glibc 库的内存布局设计、内存分配、释放逻辑,最后给出相应的解决方案。
二、内存告警事件
在一次线上运维过程中发现服务出现内存告警。
【监控系统-自定义监控-告警-持续告警】
检测规则: xxx内存使用率监测:一般异常(>4096)
集群id:xxx
集群名称: xxxxxx
异常对象(当前值): xx.xx.xx.xx-xxxxxxx(11335)
开始时间: 2023-08-10 17:10:30
告警时间: 2023-08-10 18:20:32
持续时间: 1h10m2s
异常比例: 2.1918 (8/365)
异常级别: 一般
备注:-
随即查看服务相关监控,判断是业务流量激增带来的内存短时间增高,或是发生了内存泄漏。
通过查看 OPS 和服务自身统计的内存监控,发现在告警时间内存在业务流量突增现象,但是内存已经下降到正常值了。然而告警持续到了18:20依然没有恢复,跟监控表现不符,登录机器后发现实例的内存并没有恢复,随即怀疑用户层发生内存泄漏。
经过分析,由于内存统计代码每次调用 new、delete 之后才会对统计值进行增减,而监控中服务统计内存已经下降,说明已经正常调用 delete 进行内存释放,而操作系统层面发现内存依然居高不下,怀疑使用的c运行库 glibc 存在内存释放问题。
三、glibc 内存管理机制
3.1 glibc 简介
glibc 全称为 GUN C Library,是一个开源的标准C库,其对操作系统相关调用进行了封装,提供包括数学、字符串、文件 I/O、内存管理、多线程等方面标准函数和系统调用接口供用户使用。
3.2 内存管理布局
以 Linux 内核 v2.6.7 之后的32位模式下的虚拟内存布局方式为例:
-
Kernel Space(内核空间)— 存储内核和驱动程序的代码和数据;
-
Stack(栈区)— 存储程序执行期间的本地变量和函数的参数,从高地址向低地址生长;
-
Memory Mapping Segment(内存映射区)— 简称为 mmap,用来文件或其他对象映射进内存;
-
Heap(堆区)— 动态内存分配区域,通过 malloc、new、free 和 delete 等函数管理;
-
BSS segment(未初始化变量区)— 存储未被初始化的全局变量和静态变量;
-
DATA segment(数据区)— 存储在源代码中有预定义值的全局变量和静态变量;
-
TEXT segment(代码区)— 存储只读的程序执行代码,即机器指令。
其中 Heap 和 Mmap 区域是可以提供给用户程序使用的虚拟内存空间。
Heap 操作
操作系统提供了 brk() 函数,c运行时库提供了 sbrk() 函数从 Heap 中申请内存,函数声明如下:
int brk(void *addr);
void *sbrk(intptr_t increment);
-
brk() 通过设置进程堆的结束地址进行内存分配与释放,即可以一次性的分配或释放一整段连续的内存空间。比较适合于一次性分配大块内存的情况,如果设置的结束地址过大或过小会造成内存碎片或内存浪费的问题。
-
sbrk 函数通过传入的 increment 参数决定增加或减少堆空间的大小,可以动态的多次分配或释放空间达到需要多少内存就申请多少内存的效果,有效避免了内存碎片和浪费问题。
Mmap 操作
在 Linux 中提供了 mmap() 和 munmap() 函数操作虚拟内存空间,函数声明如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
其中 mmap 能够将文件或者其他对象映射进内存,munmap 能够删除特定地址区域的内存映射。
3.3 内存分配器
开源社区公开了很多现成的内存分配器,包括 dlmalloc、ptmalloc、jemalloc、tcmalloc......,由于 glibc 用的是 ptmalloc 所以本文只对该内存分配器进行介绍。
3.3.1 Arena(分配区)
堆管理结构如下所示:
struct malloc_state {
mutex_t mutex; /* Serialize access. */
int flags; /* Flags (formerly in max_fast). */
#if THREAD_STATS
/* Statistics for locking. Only used if THREAD_STATS is defined. */
long stat_lock_direct, stat_lock_loop, stat_lock_wait;
#endif
mfastbinptr fastbins[NFASTBINS]; /* Fastbins */
mchunkptr top;
mchunkptr last_remainder;
mchunkptr bins[NBINS * 2];
unsigned int binmap[BINMAPSIZE]; /* Bitmap of bins */
struct malloc_state *next; /* Linked list */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};
ptmalloc 对进程内存是通过一个个的分配区进行管理的,而分配区分为主分配区(arena)和非主分配区(narena),两者区别在于主分配区中可以使用 sbrk 和 mmap 向操作系统申请内存,而非主分配区只能通过 mmap 申请内存。
对于一个进程,只有一个主分配区和若干个非主分配区,主分配区只能由第一个线程来创建持有,其和非主分配区由环形链表的形式相互连接,整个分配区中通过变量互斥锁支持多线程访问。
当一个线程调用 malloc 申请内存时,该线程先查看线程私有变量中是否已经存在一个分配区。如果存在,则对该分配区加锁,加锁成功的话就用该分配区进行内存分配;失败的话则搜索环形链表找一个未加锁的分配区。如果所有分配区都已经加锁,那么 malloc 会开辟一个新的分配区加入环形链表并加锁,用它来分配内存。释放操作同样需要获得锁才能进行。
3.3.2 chunk
ptmalloc 通过 malloc_chunk 来管理内存,定义如下:
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
-
prev_size:存储前一个 chunk 的大小。如果前一个 chunk 没有被使用,则 prev_size 的值表示前一个 chunk 的大小,如果前一 chunk 已被使用,则 prev_size 的值没有意义。
-
size:表示当前 chunk 的大小,包括所请求的有效数据大小,以及堆块头部和尾部的管理信息等附加信息的大小。
-
fd 和 bk:表示 chunk 在空闲链表中的前一个和后一个堆块的指针。如果该 chunk 被占用,则这两个指针没有意义。
-
fd_nextsize 和 bk_nextsize:表示同一空闲链表上下一个堆块的指针。fd_nextsize 指向下一个比当前 chunk 大小大的第一个空闲 chunk , bk_nextszie 指向前一个比当前 chunk 大小小的第一个空闲 chunk,增加这两个字段可以加快遍历空闲 chunk ,并查找满足需要的空闲 chunk 。
使用该数据结构能够更快的在链表中查找到空闲 chunk 并分配。
3.3.3 空闲链表(bins)
在 ptmalloc 中,会将大小相似的 chunk 链接起来,叫做空闲链表(bins),总共有128个 bin 供 ptmalloc 使用。用户调用 free 函数释放内存的时候,ptmalloc 并不会立即将其归还操作系统,而是将其放入 bins 中,这样下次再调用 malloc 函数申请内存的时候,就会从 bins 中取出一块返回,这样就避免了频繁调用系统调用函数,从而降低内存分配的开销。
在 ptmalloc 中,bin主要分为以下四种:
-
fast bin
-
unsorted bin
-
small bin
-
large bin
其中根据 bin 的分类,可以分为 fast bin 和 bins,而 bins 又可以分为 unsorted bin、small bin 以及 large bin 。
- fast bin
程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而, malloc 中在分配过程中引入了 fast bins 。
fast bin 总共有10个,本质上就是10个单链表,每个 fast bin 中所包含的 chunk size 以8字节逐渐递增,即如果第一个 fast bin 中 chunk size 均为16个字节,第二个 fast bin 的 chunk size 为24字节,以此类推,最后一个 fast bin 的 chunk size 为80字节。值得注意的是 fast bin 中 chunk 释放并不会与相邻的空闲 chunk 合并,这是由于 fast bin 设计的初衷就是小内存的快速分配和释放,因此系统将属于 fast bin 的 chunk 的P(未使用标志位)总是设置为1,这样即使当 fast bin 中有某个 chunk 同一个 free chunk 相邻的时候,系统也不会进行自动合并操作。
malloc 操作:
在 malloc 申请内存的时候,如果申请的内存大小范围在fast bin 以内,则先在 fast bin 中进行查找,如果 fast bin 中存在空闲 chunk 则返回。否则依次从 small bin、unsorted bin、large bin 中进行查找。
free 操作:
先通过 chunksize 函数根据传入的地址指针获取该指针对应的 chunk 的大小;然后根据这个 chunk 大小获取该 chunk 所属的 fast bin,然后再将此 chunk 添加到该 fast bin 的链尾。
-
unsorted bin
是 bins 的缓冲区,顾名思义,unsorted bin 中的 chunk 无序,这种设计能够让 glibc 的 malloc 机制有第二次机会重新利用最近释放的 chunk 从而加快内存分配的时间。
与 fast bin 不同,unsorted bin 采用的是 FIFO 的方式。
malloc 操作:
当需要的内存大小大于 fast bin 的最大大小,则先在 unsorted 中寻找,如果找到了合适的 chunk 则直接返回,否则继续在 small bin 和l arge bin中搜索。
free 操作:
当释放的内存大小大于fast bin的最大大小,则将释放的 chunk 写入 unsorted bin。
-
small bin
大小小于512字节的 chunk 被称为 small chunk,而保存 small chunks 的 bin 被称为 small bin。62个 small bin 中,每个相邻的的 small bin 之间相差8字节,同一个 small bin 中的 chunk 拥有相同大小。
small bin 指向的是包含空闲区块的双向循环链表。内存分配和释放逻辑如下:
malloc 操作:
当需要的内存不存在于 fast bin 和 unsorted bin 中,并且大小小于512字节,则在 small bin 中进行查找,如果找到了合适的 chunk 则直接返回。
free 操作:
free 一个 chunk 时会检查该 chunk 相邻的 chunk 是否空闲,如果空闲则需要先合并,然后将合并的 chunk 先从所属的链表中删除然后合并成一个新的 chunk,新的 chunk 会被添加在 unsorted bin 链表的前端。
-
large bin
大小大于等于512字节的 chunk 被称为 large chunk,而保存 large chunks 的 bin 被称为 large bin。large bins 中每一个 bin 分别包含了一个给定范围内的 chunk,其中的 chunk 按大小递减排序,大小相同则按照最近使用时间排列。63 large bin 中的每一个都与 small bin 的操作方式大致相同,但不是存储固定大小的块,而是存储大小范围内的块。每个 large bin 的大小范围都设计为不与 small bin 的块大小或其他large bin 的范围重叠。
malloc 操作:
首先确定用户请求的大小属于哪一个 large bin,然后判断该 large bin 中最大的 chunk 的 size 是否大于用户请求的 size。如果大于,就从尾开始遍历该 large bin,找到第一个 size 相等或接近的 chunk,分配给用户。如果该 chunk 大于用户请求的 size 的话,就将该 chunk 拆分为两个 chunk:前者返回给用户,且 size 等同于用户请求的 size;剩余的部分做为一个新的 chunk 添加到 unsorted bin 中。
free 操作:
large bin 的 fee 操作与 small bin 一致,此处不再赘述。
3.3.4 特殊 chunk
-
top chunk
top chunk 是堆最上面的一段空间,它不属于任何 bin,当所有的 bin 都无法满足分配要求时,就要从这块区域里来分配,分配的空间返回给用户,剩余部分形成新的 top chunk,如果 top chunk 的空间也不满足用户的请求,就要使用 brk 或者 mmap 来向系统申请更多的堆空间(主分配区使用 brk、sbrk,非主分配区使用 mmap)。
-
mmaped chunk
当分配的内存非常大(大于分配阀值,默认128K)的时候需要被 mmap 映射,则会放到 mmaped chunk 上,释放 mmaped chunk 上的内存的时候会将内存直接交还给操作系统。(chunk 中的M标志位置1)
-
last remainder chunk
如果用户申请的 size 属于 small bin 的,但是又不能精确匹配的情况下,这时候采用最佳匹配(比如申请128字节,但是对应的bin是空,只有256字节的 bin 非空,这时候就要从256字节的 bin 上分配),这样会 split chunk 成两部分,一部分返给用户,另一部分形成 last remainder chunk,插入到 unsorted bin 中。
3.3.5 hunk 的合并与切分
-
合并
当 chunk 释放时,如果前后两个相邻的 chunk 均空闲,则会与前后两个相邻 chunk 合并,随后将合并结果放入 unsorted bin 中。
-
切分
当需要分配的内存小于待分配的 chunk 块,则会将待分配 chunk 块切割成两个 chunk 块,其中一个 chunk 块大小等同于用户需要分配内存的大小。需要注意的是分裂后的两个 chunk 必须均大于 chunk 的最小大小,否则不会进行拆分。
3.4 内存分配
内存分配流程可以分为三步:
第一步:根据用户请求大小转换为实际需要分配 chunk 空间的大小;
第二步:在 bins 中搜索还没有归还给操作系统的 chunk 块,具体流程如下图所示。
-
如果所需分配的 chunk 大小小于等于 max_fast (fast bins 中要求的最大 chunk 大小,默认为64B),则尝试在 fast bins 中获取 chunk,如果获取 chunk 则返回。否则进入下一步。
-
判断所需大小是否可能处于 small bins 中,即判断 chunk_size < 512B是否成立。如果 chunk 大小处在 small bins 中则在 small bins 中搜索合适的 chunk,即找到合适的 small bin,然后从该 bin 的尾部摘取一个满足大小要求的 chunk 返回。如果 small bins 中无法找到合适的 chunk 则进入下一步。
-
到这一步说明待分配的内存块要么是一个大的 chunk,要么只是没有在 small bin 中找到。分配器先在 fast bin 中尝试合并 chunk,并将 chunk 写入 unsorted chunk 中,此时再遍历 unsorted chunk 如果能够找到合适的 chunk 则按需将该 chunk 切分(可能不需要),将生成的 chunk 中其中一个放入 small bins 或者 large bins 中,另一个与待分配内存块相同大小的 chunk 则返回。
-
在 large bins 中搜索合适的 chunk,如果能够找到则将该 chunk 切分成需要分配的内存大小,另一部分则继续写入 bins 中。如果无法找到合适的 chunk,则进入下一步。
-
尝试从 top chunk 中分配一块内存给用户,剩下一部分生成新的 top chunk 。
第三步:如果 top chunk 依然无法满足分配请求,通过 sbrk 或 mmap 增加 top chunk 的大小并分配内存给用户。
3.5 内存释放
-
判断当前 chunk 是否是 mmap 映射区域映射的内存,如果是则直接使用 munmap 释放这块内存映射(内存映射的内存能够通过标记进行识别);
-
判断 chunk 是否与 top chunk 相邻,如果相邻则直接与 top chunk 合并;
-
如果 chunk 的大小大于 max_fast(64B),则将其放入 unsorted bin,
并检查是否有合并,如果能够合并则将 chunk 合并后根据大小加入合适的 bin 中;
-
如果 chunk 的大小小于
max_fast(64B),则直接放入 fast bin 中,如果没有合并情况则 free 内存。如果在当前 chunk 相邻的 chunk 空闲,则触发合并,并将合并后的结果写入 unsorted bin 中,此时如果合并后的结果大于 max_fast(64B),则触发整个 fast bins 的合并操作,此时 fast bins 将会被遍历,将所有相邻的空闲 chunk 进行合并,然后将合并后的 chunk 写入 unsorted bin 中,fast bin 此时会变为空。如果合并后的 chunk 与 top chunk 相邻则会合并到 top chunk 中;
-
如果 top chunk 大小大于 mmap 收缩阈值(默认128KB),如果是,则对于主分配区则会试图归还 top chunk 中一部分给操作系统,此时 free 结束。
3.6 内存碎片
按照 glibc 的内存分配策略,我们考虑下如下场景:
-
假设 brk 起始地址为512k
-
malloc 40k 内存,即 chunk A,brk = 512k + 40k = 552k
-
malloc 50k 内存,即 chunk B,brk = 552k + 50k = 602k
-
malloc 60k 内存,即 chunk C,brk = 602k + 60k = 662k
-
free chunk A。
此时 chunk A 为空闲块,但是如果 chunk C 和 chunk B 一直不释放无法直接通过移动brk指针来释放 chunk A 的内存,必须等待 chunk B 和 chunk C 释放才能和 top chunk 合并并将内存归还给操作系统。
四、问题分析与解决
通过前面的内存分配器运行原理能够很容易得出原因,由于程序中连续调用 free/delete 释放内存仅仅只是将内存写入内存分配器的 bins 中,并没有将其归还给操作系统,所以会出现疑似内存未回收的情况。并且如果每次 delete 的内存都不与 top chunk 相邻,会导致 chunk 块长时间留在空闲链表中无法合并到 top chunk,从而出现内存无法释放给操作系统的现象。
4.1 优化办法
-
通过限制服务端内存最大大小能够有效避免内存被c运行库撑的太高,导致服务器 OOM 的情况。
-
c运行库替换成 jemalloc,jemalloc 与 glibc 的实现方式不同,能够更快将内存归还给操作系统。
4.2 效果对比测试
为了验证优化后的内存使用效果,编写测试代码,模拟线上 pipline 模式下的3000万次连续请求,对比请求过程中的内存峰值、连接断开后的内存使用状况:
-
glibc内存分配器
内存峰值
连接断开后内存占用
- jemalloc内存分配器
内存峰值
连接断开后内存占用
根据测试结果,jemalloc 相较于 glibc 释放空闲内存速度快12%。
参考链接: