内存

​ 内存是负载测试需要关注的重要指标之一,当内存使用过度导致系统内存不足时会触发oom-killer。OOM(Out of Memory)是内核的一种保护机制,内核监控进程的内存使用情况,并且使用 oom_score 为每个进程的内存使用情况进行评分:一个进程消耗的内存越大,oom_score 则越大;一个进程运行占用的 CPU 越多,oom_score 则越小。触发OOM时,内核根据oom_score比分杀死占用内存过多的进程从而保护系统。

​ 应用程序在内存方面涉及的问题有多个方面,如:

  • 内存占用过多
  • 内存泄漏
  • 内存越界
  • 内存重复释放
  • 内存释放后重用

​ 内存使用过多、内存泄漏可能使系统触发oom杀死进程。内存越界、重复释放、释放后重用导致应用程序逻辑异常、且大概率引起程序崩溃,崩溃时的堆栈可能与内存踩坏的代码毫无关联,定位问题耗费时间。

内存分布

​ 对于大型应用程序进行内存优化时,如ds(游戏副本:包含物理引擎、AI寻路组件、业务逻辑等共几百万行代码)。首先需要知道内存分布情况,明确各个子模块对内存的占用比例。

内存打桩

​ 内存打桩是获取内存分布一种粗粒度的方法,即在函数调用前后分别记录已分配的虚拟内存,从而得到这个函数分配内存的情况。这种方式开销较小,在开发阶段、现网均可使用。

gperftools

​ 使用gperftools可以获取细粒度的内存分布,gperftools是一套工具合集,功能包括:CPU分析、内存分析、内存泄漏检查等,获取内存分布使用 gperftools的heapprofiler,将heapprofiler添加进可执行程序,并添加-ltcmalloc的链接,运行后可以得到一份树形结构的内存占用分布图,如:

avatar

  • A->B框之间连接线上的数字表示B框的内存占用情况,以MB为单位。
  • 框内前面两行代表函数名称
  • 框内的第三行表示该函数负责的内用占用数以及对应的百分比
  • 框内的第四行代表函数调用的所有子函数总共负责的内存占用数及百分比。

内存分析

valgrind

​ valgrind是使用较多的内存泄漏检测工具,功能强大。valgrind基于dynamic binary instrumentation 原理,采用动态分析的方式,使用valgrind只需在拉起目标进程前加上valgrind的相关参数。例如对ps使用valgrind,指令如下:

1
2
valgrind -v --tool=memcheck --leak-check=full --show-reachable=yes --leak-resolution=high --log-file='valg.txt' --track-origins=yes ps
分析结果存储在--log-file指定的参数中

除了结果分析齐全,非侵入式也是valgrind的优点之一,使用valgrind分析目标进程不需要重编。当现网出现问题时可以使用valgrind进行分析,但需要仔细考量valgrind引起的性能开销,通常valgrind会使目标进程的性能降低20.x以上。

AddressSantinizer

​ Address Sanitizer(ASan)是一个快速的内存错误检测工具,从gcc 4.8+开始已经成为gcc的一部分,ASan基于compile-time instrumentation原理,使用方式简单,分析时添加如下编译参数:

1
2
-fsanitize=address
-fno-omit-frame-pointer

编译后添加环境变量:

1
2
3
4
export ASAN_OPTIONS=log_path=/data/addr_saniti.log:log_exe_name=1:abort_on_error=1:disable_coredump=0:unmap_shadow_on_exit=1

log_patch设置结果路径
abort_on_error=1:disable_coredump=0:unmap_shadow_on_exit=1 设置出现问题时进程coredump

Address Sanitizer性能开销很小,在2.x左右,该工具可以检查以下多种内存问题:

  • OOB
  • UAF
  • Use-After-Return
  • Use-After-Scope
  • Doube-Free
  • Memory Leaks

内存优化

内存池

​ 使用tcmalloc、jemalloc替换glibc提供的malloc / free函数,tcmalloc、jemalloc是两款优秀的内存分配器,使用方式简单,编译时将lib链接进去即可。tcmalloc、jemalloc分配速度比glibc提供的malloc/free更快,且尽量避免了内存碎片。

内存复用

​ 内存复用的很多case万变不离其宗,均是使用内存共享页的方式。如进程的fork机制、文件采用mmap读写等。应用程序也可以主动创建共享内存,共享内存的创建有三种方式:

  • shm*函数:shmget、shmat、shmdt。
  • 使用mmp的MAP_SHARED特性,创建基于文件的共享内存。
  • 使用mmp的MAP_ANONYMOUS特性,创建基于父子进程之间的匿名共享内存。
内存写时复制(copy on write)

​ 写时复制在内存拷贝发生时不会立即分配内存,而是延迟到数据被修改时才真正分配内存空间,从而避免不必要的内存拷贝。同时,写时复制机制运用恰当能产生很好的效果,如:

  • redis的落盘:当redis落盘时需要将整个数据库当时的快照写入磁盘。redis使用fork()函数复制一份当前进程的副本,父进程继续接受请求,子进程进行快照落盘。
  • stl string的拷贝:在拷贝函数调用时并不会马上复制底层的字符串,而是等真正修改的时间再进行内存分配,从而避免不必要的内存浪费。
  • 使用fork拉起多个相同进程:在初始阶段将各种资源加载、分配完毕,使用fork拉起子进程处理业务逻辑。

​ 应用程序使用写时复制机制,也可以利用mmap的MAP_PRIVATE特性。项目ds(游戏副本)的PhysX引擎Serialization文件有180M,打开的方式从read换成mmap的MAP_PRIVATE之后,将140M的内存变成了共享内存,从而有效降低了内存瓶颈。

调整数据结构
  • 使用内存更加紧凑的数据结构,规避内存的浪费。如redis中压缩列表的使用、sds的type实现
  • 读写分离,将只读的数据放在一起,利用mmap进行共享。