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 对磁盘的压力。