linux性能评估-内存案例实战篇
1.内存泄漏,该如何定位和处理
机器配置:2 CPU,4GB 内存
预先安装 sysstat、Docker 以及 bcc 软件包,⽐如:
# install sysstat docker
sudo apt-get install -y sysstat docker.io
# Install bcc
sudo apt-key adv --keyserver keyserver.ubuntu --recv-keys 4052245BD4284CDD
echo "deb 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)
软件包bcc,它提供了⼀系列的 Linux 性能分析⼯具,常⽤来动态追踪进程和内核的⾏为。更多⼯作原理你先不⽤深究,后⾯学习我们会逐步接触。这⾥你只需要记住,按照上⾯步骤安装完后,它提供的所有⼯具都位于 /usr/share/bcc/tools 这个⽬录中。
注意:bcc-tools 需要内核版本为 4.1 或者更⾼,如果你使⽤的是 CentOS7,或者其他内核版本⽐较旧的系统,那么你需要⼿动升级内核版本后再安装。
同以前的案例⼀样,下⾯的所有命令都默认以 root ⽤户运⾏,如果你是⽤普通⽤户⾝份登陆系统,请运⾏ sudo su root 命令切换到 root ⽤户。
安装完成后,再执⾏下⾯的命令来运⾏案例:
$ 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 ⾥各指标的含义,记得复习前⾯内容,或者执⾏ man vmstat 查询。
root@ubuntu:/home/xhong# 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
003923242100404234807912601341198711046156194237210
0039232421002722348079129600052207386119900
003923242100148234887912960005186371009900
00392324210018023520791332000373297456119900
003923242100056235207913320000150342109900
从输出中你可以看到,内存的 free 列在不停的变化,并且是下降趋势;⽽ buffer 和 cache 基本保持不变。
未使⽤内存在逐渐减⼩,⽽ buffer 和 cache 基本不变,这说明,系统中使⽤的内存⼀直在升⾼。但这并不能说明有内存泄漏,因为应⽤程序运⾏中需要的内存也可能会增⼤。⽐如说,程序中如果⽤了⼀个动态增长的数组来缓存计算结果,占⽤内存⾃然会增长。
那怎么确定是不是内存泄漏呢?或者换句话说,有没有简单⽅法出让内存增长的进程,并定位增长内存⽤在哪⼉呢?
根据前⾯内容,你应该想到了⽤ top 或 ps 来观察进程的内存使⽤情况,然后出内存使⽤⼀直增长的进程,最后再通过 pmap 查看进程的内存分布。
但这种⽅法并不太好⽤,因为要判断内存的变化情况,还需要你写⼀个脚本,来处理 top 或者 ps 的输出。
这⾥,我介绍⼀个专门⽤来检测内存泄漏的⼯具,memleak。memleak 可以跟踪系统或指定进程的内存分配、释放请求,然后定期输出⼀
个未释放内存和相应调⽤栈的汇总情况(默认 5 秒)。
当然,memleak 是 bcc 软件包中的⼀个⼯具,我们⼀开始就装好了,执⾏ /usr/share/bcc/tools/memleak 就可以运⾏它。⽐如,我们运⾏下⾯的命令:
# -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
32768bytes in 4allocations 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 10stacks with outstanding allocations:
addr = 7f8f70863220 size = 8192
addr = 7f8f70861210 size = 8192
addr = 7f8f7085b1e0 size = 8192
addr = 7f8f7085f200 size = 8192
addr = 7f8f7085d1f0 size = 8192
40960bytes in 5allocations from stack
fibonacci+0x1f[app]
child+0x4f[app]
start_thread+0xdb[libpthread-2.27.so]
这⼀次,我们终于看到了内存分配的调⽤栈,原来是 fibonacci() 函数分配的内存没释放。
定位了内存泄漏的来源,下⼀步⾃然就应该查看源码,想办法修复它。我们⼀起来看案例应⽤的源代码:
$ 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;
printf("%dth => %lld\n", n, *v);
free(v);    // 释放内存
sleep(1);
}
}
修复后的代码放到了 app-fix.c,也打包成了⼀个 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 10stacks with outstanding allocations:
[10:23:23] Top 10stacks with outstanding allocations:
现在,我们看到,案例应⽤已经没有遗留内存,证明我们的修复⼯作成功完成。
⼩结:
应⽤程序可以访问的⽤户内存空间,由只读段、数据段、堆、栈以及⽂件映射段等组成。其中,堆内存和内存映射,需要应⽤程序来动态管理内存段,所以我们必须⼩⼼处理。不仅要会⽤标准库函数malloc() 来动态分配内存,还要记得在⽤完内存后,调⽤库函数 _free() 来 _ 释放它们。
今天的案例⽐较简单,只⽤加⼀个 free() 调⽤就能修复内存泄漏。不过,实际应⽤程序就复杂多了。⽐如说,
malloc() 和 free() 通常并不是成对出现,⽽是需要你,在每个异常处理路径和成功路径上都释放内存。
在多线程程序中,⼀个线程中分配的内存,可能会在另⼀个线程中访问和释放。
更复杂的是,在第三⽅的库函数中,隐式分配的内存可能需要应⽤程序显式释放。
所以,为了避免内存泄漏,最重要的⼀点就是养成良好的编程习惯,⽐如分配内存后,⼀定要先写好内存释放的代码,再去开发其他逻辑。
2.内存中的Buffer 和 Cache 在不同场景下的使⽤情况
机器配置:2 CPU,4GB 内存。
预先安装 sysstat 包,如 apt install sysstat。
准备环节的最后⼀步,为了减少缓存的影响,记得在第⼀个终端中,运⾏下⾯的命令来清理系统缓存:
# 清理⽂件页、⽬录项、Inodes 等各种缓存
$ echo 3> /proc/sys/vm/drop_caches
这⾥的 /proc/sys/vm/drop_caches ,就是通过 proc ⽂件系统修改内核⾏为的⼀个⽰例,写⼊ 3 表⽰清理⽂件页、⽬录项、Inodes 等各种缓存。
场景 1:磁盘和⽂件写案例
1.我们先来模拟第⼀个场景。⾸先,在第⼀个终端,运⾏下⾯这个 vmstat 命令:
写⽂件:
root@ubuntu:/home/xhong# vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r  b  swpd  free  buff  cache  si  so    bi    bo  in  cs us sy id wa st
207922767604643900178292716106735203429429300
0079227676021639001783280000303571219700
0079227676021639001783280000155342209800
输出界⾯⾥,内存部分的 buff 和 cache ,以及 io 部分的 bi 和 bo 就是我们要关注的重点。
buff 和 cache 就是我们前⾯看到的 Buffers 和 Cache,单位是 KB。
bi 和 bo 则分别表⽰块设备读取和写⼊的⼤⼩,单位为块 / 秒。因为 Linux 中块的⼤⼩是 1KB,所以这个单位也就等价于 KB/s。
正常情况下,空闲系统中,你应该看到的是,这⼏个值在多次结果中⼀直保持不变。
2.接下来,到第⼆个终端执⾏ dd 命令,通过读取随机设备,⽣成⼀个 500MB ⼤⼩的⽂件:
$ dd if=/dev/urandom of=/tmp/file bs=1M count=500
3.然后再回到第⼀个终端,观察 Buffer 和 Cache 的变化情况:
root@ubuntu:/home/xhong# vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r  b  swpd  free  buff  cache  si  so    bi    bo  in  cs us sy id wa st
007922767612002560177500716106241202428429300
0079227676120025601775200000115304109900
0079227676095227041775120013624161353119900
00792276761324270417752000005701065339400
007922767609442988177520402880507803429500
0079227676095229921775560040338789319600
0079227676095229921775560000174325109900
10792276611028302432609200108657486807431455410
10792276447596357648901600592159744101510854534400
1079227628586835806506960041761288638611544410
00792276234968358870238400121105925274712375840
0079227623496835887023840000184340219800
00792276234968360070238800092200386209900
通过观察 vmstat 的输出,我们发现,在 dd 命令运⾏时, Cache 在不停地增长,⽽ Buffer 基本保持不变。
再进⼀步观察 I/O 的情况,你会看到,
在 Cache 刚开始增长时,块设备 I/O 很少,bi 只出现了⼀次 488 KB/s,bo 则只有⼀次 4KB。⽽过⼀段时间后,才会出现⼤量的块设备写,⽐如 bo 变成了 159744。
当 dd 命令结束后,Cache 不再增长,但块设备写还会持续⼀段时间,并且,多次 I/O 写的结果加起来,才是 dd 要写的 500M 的数据。
把这个结果,跟我们刚刚了解到的 Cache 的定义做个对⽐,你可能会有点晕乎。为什么前⾯⽂档上说 Cache 是⽂件读的页缓存,怎么现在写⽂件也有它的份?
这个疑问,我们暂且先记下来,接着再来看另⼀个磁盘写的案例。两个案例结束后,我们再统⼀进⾏分析。
不过,对于接下来的案例,必须强调⼀点:
下⾯的命令对环境要求很⾼,需要你的系统配置多块磁盘,并且磁盘分区 /dev/sdb1 还要处于未使⽤状态。如果你只有⼀块磁盘,千万不要尝试,否则将会对你的磁盘分区造成损坏。
如果你的系统符合标准,就可以继续在第⼆个终端中,运⾏下⾯的命令。清理缓存后,向磁盘分区 /dev/sdb1 写⼊ 2GB 的随机数据:
写磁盘:
# ⾸先清理缓存
$ echo 3> /proc/sys/vm/drop_caches
# 然后运⾏ dd 命令向磁盘分区 /dev/sdb1 写⼊ 2G 数据
$ dd if=/dev/urandom of=/dev/sdb1 bs=1M count=2048
然后,再回到终端⼀,观察内存和 I/O 的变化情况:
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r  b  swpd  free  buff  cache  si  so    bi    bo  in  cs us sy id wa st
100758478015359297436006840314231485020
10074185803153841016680000321440505000
10072536644758441062080000201370505000
10070933526318001105200000232230505000
110693005679052011498000012804231680504290
10067572049492401193960001838042419105326210
11065915161107960123840000773162223205216330
从这⾥你会看到,虽然同是写数据,写磁盘跟写⽂件的现象还是不同的。写磁盘时(也就是 bo ⼤于 0 时),Buffer 和 Cache 都在增长,但显然 Buffer 的增长快得多。
这说明,写磁盘⽤到了⼤量的 Buffer,这跟我们在⽂档中查到的定义是⼀样的。
对⽐两个案例,我们发现,写⽂件时会⽤到 Cache 缓存数据,⽽写磁盘则会⽤到 Buffer 来缓存数据。所以,回到刚刚的问题,虽然⽂档上只提到,Cache 是⽂件读的缓存,但实际上,Cache 也会缓存写⽂件时的数据。
场景 2:磁盘和⽂件读案例
了解了磁盘和⽂件写的情况,我们再反过来想,磁盘和⽂件读的时候,⼜是怎样的呢?
我们回到第⼆个终端,运⾏下⾯的命令。清理缓存后,从⽂件 /tmp/file 中,读取数据写⼊空设备:
# ⾸先清理缓存
$ echo 3> /proc/sys/vm/drop_caches
# 运⾏ dd 命令读取⽂件数据
$ dd if=/tmp/file of=/dev/null
然后,再回到终端⼀,观察内存和 I/O 的变化情况:
root@ubuntu:/home/xhong# vmstat 1
linux下的sleep函数
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r  b  swpd  free  buff  cache  si  so    bi    bo  in  cs us sy id wa st
0078997269699611596223964615102945198418429300
20789972696988116042239640000175479119900
10789972696988116042239640000162388119900
00789972696988116042239640000167460209800
1078997255450411712366208001425040251125714217410
10789972323364117125973800023104004501531310474310
0078997218445211712736464001386440258527716296140
007899721844521171273646400048111281119900
00789972184452117127364640000460885229600
观察 vmstat 的输出,你会发现读取⽂件时(也就是 bi ⼤于 0 时),Buffer 保持不变,⽽ Cache 则在不停增长。这跟我们查到的定
义“Cache 是对⽂件读的页缓存”是⼀致的。
那么,磁盘读⼜是什么情况呢?我们再运⾏第⼆个案例来看看。
⾸先,回到第⼆个终端,运⾏下⾯的命令。清理缓存后,从磁盘分区 /dev/sda1 中读取数据,写⼊空设备:
# ⾸先清理缓存
$ echo 3> /proc/sys/vm/drop_caches
# 运⾏ dd 命令读取⽂件
$ dd if=/dev/sda1 of=/dev/null bs=1M count=1024
然后,再回到终端⼀,观察内存和 I/O 的变化情况:
root@ubuntu:/home/xhong# vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r  b  swpd  free  buff  cache  si  so    bi    bo  in  cs us sy id wa st
207899727532004720178428615103045198418429300
0078997275283648601784241601720566998319610
0078997275283648601784200000416773329500
1078997275283648601784200000155307019900
01789972576872178924178432001741400106214284424860
1078997222756452760417846000348672481179165632647240
2079002881060687904171188072390144721275151913732290
007905647814869458417122403321362043325718401148240
00790564781486945841712240000151305119800
00790564781486945841712240000181382119900
00790564781486945841712240000166360109800
观察 vmstat 的输出,你会发现读磁盘时(也就是 bi ⼤于 0 时),Buffer 和 Cache 都在增长,但显然 Buffer 的增长快很多。这说明读磁盘时,数据缓存到了 Buffer 中。
得出这个结论:读⽂件时数据会缓存到 Cache 中,⽽读磁盘时数据会缓存到 Buffer 中。
到这⾥你应该发现了,虽然⽂档提供了对 Buffer 和 Cache 的说明,但是仍不能覆盖到所有的细节。⽐如说,今天我们了解到的这两点:
Buffer 既可以⽤作“将要写⼊磁盘数据的缓存”,也可以⽤作“从磁盘读取数据的缓存”。
Cache 既可以⽤作“从⽂件读取数据的页缓存”,也可以⽤作“写⽂件的页缓存”。
简单来说,Buffer 是对磁盘数据的缓存,⽽ Cache 是⽂件数据的缓存,它们既会⽤在读请求中,也会⽤在写请求中
从写的⾓度来说,不仅可以优化磁盘和⽂件的写⼊,对应⽤程序也有好处,应⽤程序可以在数据真正落盘前,就返回去做其他⼯作。
从读的⾓度来说,不仅可以提⾼那些频繁访问数据的读取速度,也降低了频繁 I/O 对磁盘的压⼒。