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.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) |
软件包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 0 0 392324 2100404 23480 791260 13 41 19871 104 615 619 4 23 72 1 0 0 0 392324 2100272 23480 791296 0 0 0 52 207 386 1 1 99 0 0 0 0 392324 2100148 23488 791296 0 0 0 5 186 371 0 0 99 0 0 0 0 392324 2100180 23520 791332 0 0 0 373 297 456 1 1 99 0 0 0 0 392324 2100056 23520 791332 0 0 0 0 150 342 1 0 99 0 0 |
从输出中你可以看到,内存的 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 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() 函数分配的内存没释放。
定位了内存泄漏的来源,下一步自然就应该查看源码,想办法修复它。我们一起来看案例应用的源代码:
$ 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 10 stacks with outstanding allocations: [ 10 : 23 : 23 ] Top 10 stacks 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 2 0 792276 760464 3900 178292 7 16 1067 35 203 429 4 2 93 0 0 0 0 792276 760216 3900 178328 0 0 0 0 303 571 2 1 97 0 0 0 0 792276 760216 3900 178328 0 0 0 0 155 342 2 0 98 0 0 |
输出界面里, 内存部分的 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 0 0 792276 761200 2560 177500 7 16 1062 41 202 428 4 2 93 0 0 0 0 792276 761200 2560 177520 0 0 0 0 115 304 1 0 99 0 0 0 0 792276 760952 2704 177512 0 0 136 24 161 353 1 1 99 0 0 0 0 792276 761324 2704 177520 0 0 0 0 570 1065 3 3 94 0 0 0 0 792276 760944 2988 177520 4 0 288 0 507 803 4 2 95 0 0 0 0 792276 760952 2992 177556 0 0 4 0 338 789 3 1 96 0 0 0 0 792276 760952 2992 177556 0 0 0 0 174 325 1 0 99 0 0 1 0 792276 611028 3024 326092 0 0 108 65748 680 743 1 45 54 1 0 1 0 792276 447596 3576 489016 0 0 592 159744 1015 1085 4 53 44 0 0 1 0 792276 285868 3580 650696 0 0 4 176128 863 861 1 54 44 1 0 0 0 792276 234968 3588 702384 0 0 12 110592 527 471 2 37 58 4 0 0 0 792276 234968 3588 702384 0 0 0 0 184 340 2 1 98 0 0 0 0 792276 234968 3600 702388 0 0 0 92 200 386 2 0 99 0 0 |
通过观察 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 1 0 0 7584780 153592 97436 0 0 684 0 31 423 1 48 50 2 0 1 0 0 7418580 315384 101668 0 0 0 0 32 144 0 50 50 0 0 1 0 0 7253664 475844 106208 0 0 0 0 20 137 0 50 50 0 0 1 0 0 7093352 631800 110520 0 0 0 0 23 223 0 50 50 0 0 1 1 0 6930056 790520 114980 0 0 0 12804 23 168 0 50 42 9 0 1 0 0 6757204 949240 119396 0 0 0 183804 24 191 0 53 26 21 0 1 1 0 6591516 1107960 123840 0 0 0 77316 22 232 0 52 16 33 0 |
从这里你会看到,虽然同是写数据,写磁盘跟写文件的现象还是不同的。写磁盘时(也就是 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 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 789972 696996 11596 223964 6 15 1029 45 198 418 4 2 93 0 0 2 0 789972 696988 11604 223964 0 0 0 0 175 479 1 1 99 0 0 1 0 789972 696988 11604 223964 0 0 0 0 162 388 1 1 99 0 0 0 0 789972 696988 11604 223964 0 0 0 0 167 460 2 0 98 0 0 1 0 789972 554504 11712 366208 0 0 142504 0 2511 2571 4 21 74 1 0 1 0 789972 323364 11712 597380 0 0 231040 0 4501 5313 10 47 43 1 0 0 0 789972 184452 11712 736464 0 0 138644 0 2585 2771 6 29 61 4 0 0 0 789972 184452 11712 736464 0 0 0 48 111 281 1 1 99 0 0 0 0 789972 184452 11712 736464 0 0 0 0 460 885 2 2 96 0 0 |
观察 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 2 0 789972 753200 4720 178428 6 15 1030 45 198 418 4 2 93 0 0 0 0 789972 752836 4860 178424 16 0 172 0 566 998 3 1 96 1 0 0 0 789972 752836 4860 178420 0 0 0 0 416 773 3 2 95 0 0 1 0 789972 752836 4860 178420 0 0 0 0 155 307 0 1 99 0 0 0 1 789972 576872 178924 178432 0 0 174140 0 1062 1428 4 42 48 6 0 1 0 789972 227564 527604 178460 0 0 348672 48 1179 1656 3 26 47 24 0 2 0 790028 81060 687904 171188 0 72 390144 72 1275 1519 1 37 32 29 0 0 0 790564 78148 694584 171224 0 332 136204 332 571 840 1 14 82 4 0 0 0 790564 78148 694584 171224 0 0 0 0 151 305 1 1 98 0 0 0 0 790564 78148 694584 171224 0 0 0 0 181 382 1 1 99 0 0 0 0 790564 78148 694584 171224 0 0 0 0 166 360 1 0 98 0 0 |
观察 vmstat 的输出,你会发现读磁盘时(也就是 bi 大于 0 时),Buffer 和 Cache 都在增长,但显然 Buffer 的增长快很多。这说明读磁盘时,数据缓存到了 Buffer 中。
得出这个结论:读文件时数据会缓存到 Cache 中,而读磁盘时数据会缓存到 Buffer 中。
到这里你应该发现了,虽然文档提供了对 Buffer 和 Cache 的说明,但是仍不能覆盖到所有的细节。比如说,今天我们了解到的这两点:
- Buffer 既可以用作“将要写入磁盘数据的缓存”,也可以用作“从磁盘读取数据的缓存”。
- Cache 既可以用作“从文件读取数据的页缓存”,也可以用作“写文件的页缓存”。
简单来说,Buffer 是对磁盘数据的缓存,而 Cache 是文件数据的缓存,它们既会用在读请求中,也会用在写请求中
从写的角度来说,不仅可以优化磁盘和文件的写入,对应用程序也有好处,应用程序可以在数据真正落盘前,就返回去做其他工作。
从读的角度来说,不仅可以提高那些频繁访问数据的读取速度,也降低了频繁 I/O 对磁盘的压力。