记录一次linux应用内存调试过程(续)

写在前面

本文所描述的内存调试过程,主要是记录最近项目里面遇到的一个内存使用问题。过程大概是,测试软件稳定性时,发现系统内存随着时间的变化,会不断的增长,并且不会恢复。由于怀疑是,应用程序出现了内存泄漏,所以开启了针对于内存泄漏的分析、调试,过程中使用了程序功能模块隔离法、valgrind工具、编写单独程序测试(怀疑是mosquitto存在问题)等方法,最后发现没有内存泄漏的地方。后来,实在找不到问题的原因,甚至怀疑到了glibc内存分配管理器ptmalloc身上,并且将其替换成了jemalloc(Facebook使用的内存分配管理器),换完之后,系统内存使用情况,竟然恢复了正常,当时挺惊喜的。事后想想,还是太年起了,glibc的内存分配器,也是神级人物实现的,性能不可能做的这么差,所以某些事如果觉得奇怪的话,那就不能过早的下结论,需要冷静的思考,借用史强的话说,事出反常必有妖,哈哈!

事情的最后,终于找到了问题的原因,不是应用程序的问题,也不是glibc的问题,原因是应用程序在编译的时候开启了Asan内存检测功能,Asan就是那个妖,哈哈!其实这完全怪自己,当时出于好奇,启用了Asan功能,事后忘了关闭这个功能了,也算是自己挖坑,自己跳了。

Asan可以实时检测程序内部的内存使用情况,一旦发现内存被非法使用就会立即报警,我总结了Asan的基本的原理和常见内存问题的测试程序可以参考下。

使用Asan会占用大量的内存来存储应用的内存使用记录,如果应用不断的进行分配、释放内存的话,存储记录所耗费的内存空间就会不断的增长,这也就对应了前文所说的内存不断增长的问题。

谁偷吃了内存

通过注册hook的方式,跟踪内存分配和释放的热点,具体步骤如下:
1).实现malloc和free包装函数

//编译方式:gcc mymalloc.c -fPIC -shared -o libmymalloc.so
//必须在开头定义该宏
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <unistd.h>
#include <execinfo.h>
#include <sys/syscall.h>
//printf函数里调用了malloc和free,会导致malloc和free的递归调用
//最终导致coredump,这里通过两个标志控制,不会引起递归
static int enable_free_hook   = 1;
static int enable_malloc_hook = 1;
static pid_t gettid() 
{
    
    
    return syscall(SYS_gettid);
}
static void *(*real_malloc)(size_t) = NULL;
static void (*real_free)(void*) = NULL;
void *malloc(size_t size) 
{
    
    
    void *p; 
    void* buffer[128];
    int line;
    if (!real_malloc) {
    
    
        real_malloc = dlsym(RTLD_NEXT, "malloc");
        if (!real_malloc) return NULL;
    }   
    p = real_malloc(size);
    if (enable_malloc_hook) {
    
    
        enable_malloc_hook = 0;
        //line = backtrace(buffer, 128);
        //backtrace_symbols_fd(buffer, line, 0);
        printf("[tpid:%d] malloc(%lu)=%p\n", gettid(), size, p); 
        enable_malloc_hook = 1;
    }   
    return p;
}
void free(void *p)
{
    
    
    if (!real_free) {
    
    
        real_free = dlsym(RTLD_NEXT, "free");
        if (!real_free) return;
    }
    if (enable_free_hook) {
    
    
        enable_free_hook = 0;
        printf("[tpid:%d] free %p\n", gettid(), p);
        enable_free_hook = 1;
    }
    real_free(p);
}  

2).编译成动态库
gcc mymalloc.c -fPIC -shared -o libmymalloc.so -ldl

3).编写测试程序

#include <stdio.h>                                                     
#include <stdlib.h>
#include <string.h>
void test(void)
{
    
    
    char *ptr = malloc(10);
    if (!ptr) {
    
    
        printf("malloc failed.\n");
        return;
    }   
    memset(ptr, 0, 10);
    printf("malloc succ, ptr:%p.\n", ptr);
    free(ptr);
}
int main(void)
{
    
    
    test();
    return 0;
}

4).测试libmymalloc.so功能

LD_PRELOAD=./libmymalloc.so  ./malloc
[tpid:10131] malloc(10)=0x1cd3010
malloc succ, ptr:0x1cd3010.
[tpid:10131] free 0x1cd3010

解决方案:

基本原理

Linux系统内存管理分为三层:应用层、内存分配器层、内核层。

应用层主要是APP管理本进程里堆栈内存的申请和使用,常见的问题有内存泄漏、内存越界等,出现问题的时候可以使用Asan和valgrind进行探测。内核层实现虚拟内存和物理内存的管理,一般不会有问题,考虑其实现的复杂性,即便是出了问题,也不是一般人可以解决的,哈哈。中间的内存分配器,就是连接应用层和内核层的纽带,类似于内存的批发、零售商,常见内存分配器是:ptmalloc(glibc标配)、tcmalloc(google公司开发)、jemalloc(FreeBSD标配,Facebook维护使用的较多)。

系统默认使用的是ptmalloc,本系统GLIBC的版本是2.21,通过分析学习ptmalloc的实现原理,可以参考这篇文章深入学习下。

ptmalloc的基本原理如下:

  1. 小块儿内存(默认是小于128KB)的内存通过sbrk分配,内存释放后不会立即返还给OS,而是缓存起来,目的是提升小内存的分配效率。
  2. 大块儿内存(默认是大于128KB)的内存通过map分配,通过unmap释放,内存释放后,立即返还给OS,内存的分配和释放性能低。
  3. sbrk分配的内存会采用批发零售的方式,每次申请较大内存块,然后分多次分给应用使用。
  4. 内存紧缩策略会定时将部分内存释放,返还给OS。

ptmalloc的几个缺点:

  1. 内存回收策略导致内存不能真正释放,并返还给OS,比如,ptmalloc内存紧缩策略,如果通过sbrk分配的大块儿内存,多次分给了不同的部分,只有当后分配的内存释放之后,之前分配的内存块才能释放,这样会造成很多本来没有用的内存块不能返还给OS。

  2. 内存管理策略容易导致内存碎片化。

  3. 对于多核、多线程支持不友好。

  4. 内存malloc和free效率不高。

相比之下,jemalloc和tcmalloc对于ptmalloc这些问题进行了改进,降低了内存碎片化,提高了多核、多线程下内存管理性能,其中tcmalloc更适用于高并发的内存使用环境下。

使用jemalloc

buildroot支持jemalloc的集成,可以很方便的编译出jemalloc动态库。编译好之后,运行应用程序之前,定义LD_PRELOAD=/usr/lib/libjemalloc.so,应用使用的内存分配器就改为jemalloc了。经测试,使用jemalloc时,系统内存使用效率大大提高了,有点不敢相信啊,ScT进程的虚拟内存从494M直接降到了58M,当时我竟然相信了;》,后来才发现,ScT在编译的时候,启用了asan的内存泄漏检测功能,最终导致了ScT的虚拟内存飙升到494M,哎,大意了啊,竟然一直以为将近500M的虚拟内存空间竟然是合理的;)

使用malloc_trim定时清理内存

ptmalloc提供了malloc_trim,“release free memory from the top of the heap”,用于释放堆顶的内存,这里说的堆,是基于sbrk分配的内存,堆顶表示当前sbrk指针的位置,释放堆顶,个人理解就是释放掉堆顶以下空闲的内存区域,将物理内存返还给OS。
malloc_trim原型如下:

int malloc_trim(size_t pad);

pad表示留给堆顶的内存空间大小,如果pad为0,表示只保持最小的内存给堆顶。详见,man malloc_trim(3)

malloc_trim是否有效果呢?

通过下面的例子进行测试:
来自网络:https://blog.csdn.net/u013259321/article/details/112031002

在项目中测试使用malloc_trim(0)函数,释放内存非常及时,并且对性能的影响很小。但是即便是没影响业务,一次释放大量内存产出的系统调用还是会降低性能的(释放内存通过系统调用实现,耗费系统资源),避免调用太过频繁,可以通过定时器获取进程的内存占用,来决定是否触发malloc_trim(0)调用,或者使用定时器周期性地执行,间隔时间不宜过短。

代码测试:来自网络https://cloud.tencent.com/developer/article/2002948

#include <stdlib.h>                                                                                                     
#include <stdio.h>
#include <string.h>
#include <malloc.h>
#define K (1024)
#define MAXNUM 500000
int main() 
{
    
    
    char *ptrs[MAXNUM];
    int i;
    //malloc large block memory
    for (i = 0; i < MAXNUM; ++i) {
    
    
        ptrs[i] = (char *)malloc(1 * K); 
        memset(ptrs[i], 0, 1 * K); 
    }   
    //never free,only 1B memory leak, what it will impact to the system?
    //size_t msize = 10 * 1024 * 1024;
    size_t msize = 1;
    char *tmp1 = (char *)malloc(msize);
    memset(tmp1, 0, msize);
    printf("%s\n", "malloc done.");
    getchar();
    printf("%s\n", "start free memory.");
    for(i = 0; i < MAXNUM; ++i) {
    
    
        free(ptrs[i]);
    }   
    printf("%s\n", "large memory free done.");
    getchar();
    malloc_trim(0);
    printf("%s\n", "malloc_trim(0) done.");
    getchar();
    return 0;
}

代码原理:

1).首先,申请500M内存,并且使用memset进行初始化,这一步骤不能省略,只有初始化了,系统才会分配物理内存。
2).然后,再申请1B内存,这个字节内存也需要初始化,并且直到程序退出也不释放,这是为了模拟在堆顶保持1B的内存不释放,从而测试malloc_trim是否可以释放掉该字节之下的500M内存。
3).释放500M内存,可以通过free -m查看,物理内存没有释放。
4).调用malloc_trim(0)释放堆顶内存,通过free -m查看,物理内存已经释放了。

结论:

malloc_trim(0)函数尝试在堆的顶部释放可用内存,按照man手册的说法,只能释放堆顶部的内存,空洞无法释放,但是经过上面代码测试空洞是可以释放的,即便该空闲内存顶部有仍在使用的内存或者该内存块未达到M_TRIM_THRESHOLD大小,调用malloc_trim(0)后这些内存空洞仍然会归还操作系统。

使用mmap分配内存

使用sbrk分配的内存块,会被分配给多个地方,需要将内存块全部释放之后,才能将物理内存返回给OS,所以,有人建议使用mllopt(3)函数来配置M_MMAP_THRESHOLD和M_MMAP_MAX来只使用mmap来申请内存。但是,mmap/unmap是系统调用,每次使用都会造成OS性能的下降,所以,对于大块内存可以使用mmap,但是,对于小块内存的申请和释放,使用mmap是得不偿失的。

猜你喜欢

转载自blog.csdn.net/linux_embedded/article/details/129425054