Linux内存的工作原理
- 对于普通进程来说,能看到的其实是内核提供的虚拟内存,这些虚拟内存还需要通过页表,有系统映射为物理内存
- 当进程通过malloc()申请虚拟内存后,系统并不会立即为其分配物理内存,而是在首次访问时,才通过缺页异常陷入内核中分配内存
- 为了协调CPU与磁盘间的性能差异,Linux还会使用Cache和Buffer,分别把文件和磁盘读写的数据缓存到内存中
对应用程序来说,动态内存的分配和回收,很容易发送各种各样的“事故”,比如:
- 没正确回收分配后的内存,导致了泄露
- 访问的是已经分配内存边界处的地址,导致程序异常退出,等等
那么,内存泄露到底是怎么发生的呢?以及发生内存泄露之后该怎么排查和定位呢?
受到内存泄露,就要先从内存的分配和回收说起了。
内存的分配和回收
进程分为用户空间和内核空间,而用户空间内存包括多个不同的内存段,比如只读段、数据段、堆、栈以及文件映射区。这些内存段是应用程序使用内存的基本方式:
- 栈内存有系统自动分配和管理。一般存储局部变量。一旦程序运行超出了这个局部变量作用域,栈内存就会被系统自动回收,所以不会产生内存泄露的问题
- 堆内存由应用程序自己来分配和管理,一般使用malloc之类的手动申请。除非程序退出,这些堆内存并不会被系统自动释放,必须应用程序明确调用库函数free()来释放他们。如果应用程序没有正确释放堆内存,就会造成内存泄露。
- 只读段,包括程序的代码和常量,由于是只读的,不会再去分配新的内存,所以也不会产生内存泄露
- 数据段,包括全局变量和静态变量,这些变量在定义时就已经确定了大小,所以也不会产生内存泄露
- 内存映射段,包括动态链接库和共享内存,其中共享内存有程序动态分配和申请。所以,如果程序在分配之后忘了回收,就会导致跟堆内存类似的泄露问题
内存泄露的危害非常大,这些忘记释放的内存,不仅应用程序自己不能访问,系统也不能把它们再次分配给其他应用。内存泄露不断累积,甚至会耗尽系统内存。
虽然,系统最终可以通过OOM(Out of Memory)机制杀死进程,但进程在OOM前,可能已经引发了一连串的反应,导致严重的性能问题。
比如,其他需要内存的进程,可能无法分配新的内存;内存不足,又会触发系统的缓存回收以及swap机制,从而进一步导致IO的性能问题等。
内存泄漏的危害这么⼤,那我们应该怎么检测这种问题呢?
案例
接下来,我们就⽤⼀个计算斐波那契数列的案例,来看看内存泄漏问题的定位和处理⽅法
准备
- 机器配置:2 CPU,8GB 内存
- 预先安装 sysstat、Docker 以及 bcc 软件包:
- sysstat 软件包中的 vmstat ,可以观察内存的变化情况;
- bcc 软件包提供了⼀系列的 Linux 性能分析⼯具,常⽤来动态追踪进程和内核的⾏为。
# install sysstat docker
sudo apt-get install -y sysstat docker.io
# Install bcc
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 4052245BD4284CDD
echo "deb https://repo.iovisor.org/apt/bionic bionic main" | sudo tee /etc/apt/sources.list.d/iovisor.list
sudo apt-get update
sudo apt-get install -y bcc-tools libbcc-examples linux-headers-$(uname -r)
开始
启动案例:
$ docker run --name=app -itd feisky/app:mem-leak
确认案例应⽤已经正常启动。如
$ docker logs app
2th => 1
3th => 2
4th => 3
5th => 5
6th => 8
7th => 13
从输出中,我们可以发现,这个案例会输出斐波那契数列的⼀系列数值。实际上,这些数值每隔 1 秒输出⼀次。
知道了这些,我们应该怎么检查内存情况,判断有没有泄漏发⽣呢?你⾸先想到的可能是 top ⼯具,不过,top 虽然能观察系统和进程的内存占⽤情况,但今天的案例并不适合。内存泄漏问题,我们更应该关注内存使⽤的变化趋势。
可以使用vmstat。运⾏下⾯的 vmstat ,等待⼀段时间,观察内存的变化情况
# 每隔3秒输出⼀组数据
$ vmstat 3
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 6601824 97620 1098784 0 0 0 0 62 322 0 0 100 0 0
0 0 0 6601700 97620 1098788 0 0 0 0 57 251 0 0 100 0 0
0 0 0 6601320 97620 1098788 0 0 0 3 52 306 0 0 100 0 0
0 0 0 6601452 97628 1098788 0 0 0 27 63 326 0 0 100 0 0
2 0 0 6601328 97628 1098788 0 0 0 44 52 299 0 0 100 0 0
0 0 0 6601080 97628 1098792 0 0 0 0 56 285 0 0 100 0 0
从输出中你可以看到:
- 内存的 free 列在不停的变化,并且是下降趋势;⽽ buffer 和 cache 基本保持不变。
- 未使用内存在渐渐减小,而buffer和cache基本不变,这说明,系统中使用的内存一直在升高。但这并不能说明有内存泄露,因为应用程序运行中需要的内存也可能会增大。比如说,程序中如果用了一个动态增长的数据来缓存计算结果,占用内存自然会增长。
- 那怎么确定是不是内存泄露呢?或者换句话说,有没有简单的方法找出内存增长的进程,并定位增长内存用在哪儿呢?
bbc提供了一个叫做memleak
的工具,是专门用来检测内存泄露的。它可以跟踪系统或者指定进程的内存分配、释放请求,然后定期输出一个未释放内存和相应调⽤栈的汇总情况(默认5 秒)。
# -a 表示显示每个内存分配请求的⼤⼩以及地址
# -p 指定案例应⽤的PID号
$ /usr/share/bcc/tools/memleak -a -p $(pidof app)
WARNING: Couldn't find .text section in /app
WARNING: BCC can't handle sym look ups for /app
addr = 7f8f704732b0 size = 8192
addr = 7f8f704772d0 size = 8192
addr = 7f8f704712a0 size = 8192
addr = 7f8f704752c0 size = 8192
32768 bytes in 4 allocations from stack
[unknown] [app]
[unknown] [app]
start_thread+0xdb [libpthread-2.27.so]
- 从 memleak 的输出可以看到,案例应⽤在不停地分配内存,并且这些分配的地址没有被回收。
- 这⾥有⼀个问题,Couldn’t find .text section in /app,所以调⽤栈不能正常输出,最后的调⽤栈部分只能看到 [unknown] 的标志。
- 为什么会有这个错误呢?实际上,这是由于案例应⽤运⾏在容器中导致的。memleak ⼯具运⾏在容器之外,并不能直接访问进程路径 /app。
⽐⽅说,在终端中直接运⾏ ls 命令,你会发现,这个路径的确不存在:
$ ls /app
ls: cannot access '/app': No such file or directory
类似的问题, CPU 模块中的 perf 使⽤⽅法中已经提到好⼏个解决思路。
- 最简单的⽅法,就是在容器外部构建相同路径的⽂件以及依赖库。
- 这个案例只有⼀个⼆进制⽂件,所以只要把案例应⽤的⼆进制⽂件放到 /app 路径中,就可以修复这个问题。
⽐如,你可以运⾏下⾯的命令,把 app ⼆进制⽂件从容器中复制出来,然后重新运⾏ memleak ⼯具:
$ docker cp app:/app /app
$ /usr/share/bcc/tools/memleak -p $(pidof app) -a
Attaching to pid 12512, Ctrl+C to quit.
[03:00:41] Top 10 stacks with outstanding allocations:
addr = 7f8f70863220 size = 8192
addr = 7f8f70861210 size = 8192
addr = 7f8f7085b1e0 size = 8192
addr = 7f8f7085f200 size = 8192
addr = 7f8f7085d1f0 size = 8192
40960 bytes in 5 allocations from stack
fibonacci+0x1f [app]
child+0x4f [app]
start_thread+0xdb [libpthread-2.27.so]
- 这⼀次,我们终于看到了内存分配的调⽤栈,原来是 fibonacci() 函数分配的内存没释放。
- 定位了内存泄漏的来源,下⼀步⾃然就应该查看源码,想办法修复它。我们⼀起来看案例应⽤的源代码 app.c:
$ docker exec app cat /app.c
\.\.\.
long long *fibonacci(long long *n0, long long *n1)
{
//分配1024个⻓整数空间⽅便观测内存的变化情况
long long *v = (long long *) calloc(1024, sizeof(long long));
*v = *n0 + *n1;
return v;
}
void *child(void *arg)
{
long long n0 = 0;
long long n1 = 1;
long long *v = NULL;
for (int n = 2; n > 0; n++) {
v = fibonacci(&n0, &n1);
n0 = n1;
n1 = *v;
printf("%dth => %lld\n", n, *v);
sleep(1);
}
}
\.\.\.
- 你会发现, child() 调⽤了 fibonacci() 函数,但并没有释放 fibonacci() 返回的内存。所以,想要修复泄漏问题,在 child() 中加⼀个释放函数就可以了
void *child(void *arg)
{
\.\.\.
for (int n = 2; n > 0; n++) {
v = fibonacci(&n0, &n1);
n0 = n1;
n1 = *v;
free(v); // 释放内存
printf("%dth => %lld\n", n, *v);
sleep(1);
}
}
- 修复后的代码也打包成了⼀个 Docker 镜像。可以运⾏下⾯的命令,验证⼀下内存泄漏是否修复:
# 清理原来的案例应⽤
$ docker rm -f app
# 运⾏修复后的应⽤
$ docker run --name=app -itd feisky/app:mem-leak-fix
# 重新执⾏ memleak⼯具检查内存泄漏情况
$ /usr/share/bcc/tools/memleak -a -p $(pidof app)
Attaching to pid 18808, Ctrl+C to quit.
[10:23:18] Top 10 stacks with outstanding allocations:
[10:23:23] Top 10 stacks with outstanding allocations:
- 现在,我们看到,案例应⽤已经没有遗留内存,证明我们的修复⼯作成功完成。
总结
- 为了避免内存泄漏,最重要的⼀点就是养成良好的编程习惯,⽐如分配内存后,⼀定要先写好内存释放的代码,再去开发其他逻辑。还是那句话,有借有还,才能⾼效运转,再借不难。
- 当然,如果已经完成了开发任务,你还可以⽤ memleak ⼯具,检查应⽤程序的运⾏中,内存是否泄漏。如果发现了内存泄漏情况,再根据 memleak 输出的应⽤程序调⽤栈,定位内存的分配位置,从⽽释放不再访问的内存。