This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
<audio id="audio" title="06 基础篇 | 进程的哪些内存类型容易引起内存泄漏?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/cf/874c294a0e6b8a8129ccf4276852a6cf.mp3"></audio>
你好,我是邵亚方。今天我们进入课程的第二个模块,来聊一下内存泄漏的话题。
相信你在平时的工作中,应该遇到过下面这些场景:
- 伴随着服务器中的后台任务持续地运行,系统中可用内存越来越少;
- 应用程序正在运行时忽然被OOM kill掉了
- 进程看起来没有消耗多少内存,但是系统内存就是不够用了;
- ……
类似问题,很可能就是内存泄漏导致的。我们都知道,内存泄漏指的是内存被分配出去后一直没有被释放,导致这部分内存无法被再次使用,甚至更加严重的是,指向这块内存空间的指针都不存在了,进而再也无法访问这块内存空间。
我们平时遇到的内存泄漏可能是应用程序的内存泄漏也可能是内核操作系统的内存泄漏而应用程序的内存泄漏可能是堆内存heap的泄漏也可能是内存映射区Memory Mapping Region的泄漏。这些不同类型的内存泄漏它们的表现形式也是不一样的解决方案也不一样所以为了更好地处理内存泄漏问题我们首先就需要去了解这些不同的内存类型。
这些不同的内存类型都可以理解为是进程地址空间(Address Space)的一部分,那地址空间是怎么工作的呢?
## 进程的地址空间
我们用一张图,来表示进程的地址空间。图的左侧是说进程可以通过什么方式来更改进程虚拟地址空间,而中间就是进程虚拟地址空间是如何划分的,右侧则是进程的虚拟地址空间所对应的物理内存或者说物理地址空间。
<img src="https://static001.geekbang.org/resource/image/c3/32/c321c56a7b719bf14b0b5133d0a66132.jpg" alt="">
我们来具体聊一下这个过程。
应用程序首先会调用内存申请释放相关的函数比如glibc提供的malloc(3)、 free(3)、calloc(3)等或者是直接使用系统调用mmap(2)、munmap(2)、 brk(2)、sbrk(2)等。
如果使用的是库函数这些库函数其实最终也是对系统调用的封装所以可以理解为是应用程序动态申请释放内存最终是要经过mmap(2)、munmap(2)、brk(2)、sbrk(2)等这些系统调用。当然从库函数到系统调用这其中还涉及到这些库本身进行的一些内存层面的优化比如说malloc(3)既可能调用mmap(2)又可能会调用brk(2)。
然后这些内存申请和释放相关的系统调用会修改进程的地址空间 (address space)其中brk(2)和sbrk(2)修改的是heap(堆)而mmap(2)和munmap(2)修改的是Memory Mapping Region内存映射区
请注意这些针对的都是虚拟地址应用程序都是跟虚拟地址打交道不会直接跟物理地址打交道。而虚拟地址最终都要转换为物理地址由于Linux都是使用Page来进行管理的所以这个过程叫Paging分页
我们用一张表格来简单汇总下这些不同的申请方式所对应的不同内存类型这张表格也包含了我们在课程上一个模块讲的Page Cache所以你可以把它理解为是进程申请内存的类型大汇总
<img src="https://static001.geekbang.org/resource/image/85/0f/85e7da0e15587c6a1d31f7e60e1ab00f.jpg" alt="">
这里面涉及很多术语,我们对其中重要的部分做些简单介绍,来看看这张表里的哪些部分容易出现内存泄漏。
进程运行所需要的内存类型有很多种,总的来说,这些内存类型可以从是不是文件映射,以及是不是私有内存这两个不同的维度来做区分,也就是可以划分为上面所列的四类内存。
- **私有匿名内存**。进程的堆、栈以及mmap(MAP_ANON | MAP_PRIVATE)这种方式申请的内存都属于这种类型的内存。其中栈是由操作系统来进行管理的,应用程序无需关注它的申请和释放;堆和私有匿名映射则是由应用程序(程序员)来进行管理的,它们的申请和释放都是由应用程序来负责的,所以它们是容易产生内存泄漏的地方。
- **共享匿名内存**。进程通过mmap(MAP_ANON | MAP_SHARED)这种方式来申请的内存比如说tmpfs和shm。这个类型的内存也是由应用程序来进行管理的所以也可能会发生内存泄漏。
- **私有文件映射**。进程通过mmap(MAP_FILE | MAP_PRIVATE)这种方式来申请的内存比如进程将共享库Shared libraries和可执行文件的代码段Text Segment映射到自己的地址空间就是通过这种方式。对于共享库和可执行文件的代码段的映射这是通过操作系统来进行管理的应用程序无需关注它们的申请和释放。而应用程序直接通过mmap(MAP_FILE | MAP_PRIVATE)来申请的内存则是需要应用程序自己来进行管理,这也是可能会发生内存泄漏的地方。
- **共享文件映射**。进程通过mmap(MAP_FILE | MAP_SHARED)这种方式来申请的内存我们在上一个模块课程中讲到的File Page Cache就属于这类内存。这部分内存也需要应用程序来申请和释放所以也存在内存泄漏的可能性。
了解了进程虚拟地址空间这些不同的内存类型后,我们来继续看下它们对应的物理内存。
刚刚我们也提到进程虚拟地址空间是通过Paging分页这种方式来映射为物理内存的进程调用malloc()或者mmap()来申请的内存都是虚拟内存只有往这些内存中写入数据后比如通过memset才会真正地分配物理内存 。
你可能会有疑问如果进程只是调用malloc()或者mmap()而不去写这些地址,即不去给它分配物理内存,是不是就不用担心内存泄漏了?
答案是这依然需要关注内存泄露,因为这可能导致进程虚拟地址空间耗尽,即虚拟地址空间同样存在内存泄露的问题。我们在下节课的案例篇中,也会分析对应的案例 ,这里先不展开描述了。
接下来,我们继续用一张图片来细化一下分页的过程。
<img src="https://static001.geekbang.org/resource/image/5e/68/5e2dacf3890cd9d508d3e0181a8ac868.jpg" alt="">
如上图所示Paging的大致过程是CPU将要请求的虚拟地址传给MMUMemory Management Unit内存管理单元然后MMU先在高速缓存TLBTranslation Lookaside Buffer页表缓存中查找转换关系如果找到了相应的物理地址则直接访问如果找不到则在地址转换表Page Table里查找计算。最终进程访问的虚拟地址就对应到了实际的物理地址。
了解了地址空间的相关知识之后你就能够对进程的地址空间做一个合理的规划或者说合理的控制了。这样出现问题时不至于产生太严重的影响你可以把规划好进程的地址空间理解为是进程内存问题的兜底方案。Linux上最典型的规划进程地址空间的方式就是通过ulimit你可以通过调配它来规划进程最大的虚拟地址空间、物理地址空间、栈空间是多少等等。
对于进程地址空间相关的知识我们先聊到这里,接下来我们看下如何使用工具来观察进程的地址空间。
## 用数据观察进程的内存
学会观察进程地址空间是分析内存泄漏问题的前提,当你怀疑内存有泄漏时,首先需要去观察哪些内存在持续增长,哪些内存特别大,这样才能够判断出内存泄漏大致是出在哪里,然后针对性地去做分析;相反,如果你在没有仔细观察进程地址空间之前,就盲目猜测问题出在哪,处理问题很可能会浪费大量时间,甚至会南辕北辙。
那么都有哪些观察进程的工具呢我们常用来观察进程内存的工具比如说pmap、ps、top等都可以很好地来观察进程的内存。
首先我们可以使用top来观察系统所有进程的内存使用概况打开top后然后按g再输入3从而进入内存模式就可以了。在内存模式中我们可以看到各个进程内存的%MEM、VIRT、RES、CODE、DATA、SHR、nMaj、nDRT这些信息通过strace来跟踪top进程你会发现这些信息都是从/proc/[pid]/statm和/proc/[pid]/stat这个文件里面读取的
```
$ strace -p `pidof top`
open(&quot;/proc/16348/statm&quot;, O_RDONLY) = 9
read(9, &quot;40509 1143 956 24 0 324 0\n&quot;, 1024) = 26
close(9) = 0
...
open(&quot;/proc/16366/stat&quot;, O_RDONLY) = 9
read(9, &quot;16366 (kworker/u16:1-events_unbo&quot;..., 1024) = 182
close(9)
...
```
除了nMajMajor Page Fault 主缺页中断,指内容不在内存中然后从磁盘中来读取的页数)外,%MEM则是从RES计算而来的其余的内存信息都是从statm文件里面读取的如下是top命令中的字段和statm中字段的对应关系
<img src="https://static001.geekbang.org/resource/image/a9/1d/a9615117becc0244a2a23802a9cf5c1d.jpg" alt="">
另外如果你观察仔细的话可能会发现有些时候所有进程的RES相加起来要比系统总的物理内存大这是因为RES中有一些内存是被一些进程给共享的。
在明白了系统中各个进程的内存使用概况后如果想要继续看某个进程的内存使用细节你可以使用pmap。如下是pmap来展示sshd进程地址空间里的部分内容
```
$ pmap -x `pidof sshd`
Address Kbytes RSS Dirty Mode Mapping
000055e798e1d000 768 652 0 r-x-- sshd
000055e7990dc000 16 16 16 r---- sshd
000055e7990e0000 4 4 4 rw--- sshd
000055e7990e1000 40 40 40 rw--- [ anon ]
...
00007f189613a000 1800 1624 0 r-x-- libc-2.17.so
00007f18962fc000 2048 0 0 ----- libc-2.17.so
00007f18964fc000 16 16 16 r---- libc-2.17.so
00007f1896500000 8 8 8 rw--- libc-2.17.so
...
00007ffd9d30f000 132 40 40 rw--- [ stack ]
...
```
每一行表示一种类型的内存Virtual Memory Area每一列的含义如下。
- **Mapping**用来表示文件映射中占用内存的文件比如sshd这个可执行文件或者堆[heap],或者栈[stack],或者其他,等等。
- **Mode**它是该内存的权限比如“r-x”是可读可执行它往往是代码段(Text Segment)“rw-”是可读可写,这部分往往是数据段(Data Segment)“r”是只读这往往是数据段中的只读部分。
- **Address、Kbytes、RSS、Dirty**Address和Kbytes分别表示起始地址和虚拟内存的大小RSSResident Set Size则表示虚拟内存中已经分配的物理内存的大小Dirty则表示内存中数据未同步到磁盘的字节数。
可以看到通过pmap我们能够清楚地观察一个进程的整个的地址空间包括它们分配的物理内存大小这非常有助于我们对进程的内存使用概况做一个大致的判断。比如说如果地址空间中[heap]太大那有可能是堆内存产生了泄漏再比如说如果进程地址空间包含太多的vma可以把maps中的每一行理解为一个vma那很可能是应用程序调用了很多mmap而没有munmap再比如持续观察地址空间的变化如果发现某些项在持续增长那很可能是那里存在问题。
pmap同样也是解析的/proc里的文件具体文件是/proc/[pid]/maps和/proc/[pid]/smaps其中smaps文件相比maps的内容更详细可以理解为是对maps的一个扩展。你可以对比/proc/[pid]/maps和pmaps的输出你会发现二者的内容是一致的。
除了观察进程自身的内存外,我们还可以观察进程分配的内存和系统指标的关联,我们就以常用的/proc/meminfo为例来说明我们上面提到的四种内存类型私有匿名私有文件共享匿名共享文件是如何体现在系统指标中的。
<img src="https://static001.geekbang.org/resource/image/9a/9f/9afa1845b3a308e2ae5b84a8b798f49f.jpg" alt="">
如上图所示,凡是私有的内存都会体现在/proc/meminfo中的AnonPages这一项凡是共享的内存都会体现在Cached这一项匿名共享的则还会体现在Shmem这一项。
```
$ cat /proc/meminfo
...
Cached: 3799380 kB
...
AnonPages: 1060684 kB
...
Shmem: 8724 kB
...
```
同样地,我也建议你动手写一些测试用例来观察下,这样你理解得就会更深刻。
我们对进程的内存管理相关的基础知识就先讲到这里,在下节课我们来讲一讲内存泄漏的实际案例以及其危害。
## 课堂总结
这节课我们讲述进程内存管理相关的一些知识,包括进程的虚拟内存与物理内存,要点如下。
- 进程直接读写的都是虚拟地址虚拟地址最终会通过Paging分页来转换为物理内存的地址Paging这个过程是由内核来完成的。
- 进程的内存类型可以从anon匿名与file文件、private私有与shared共享这四项来区分为4种不同的类型进程相关的所有内存都是这几种方式的不同组合。
- 查看进程内存时可以先使用top来看系统中各个进程的内存使用概况再使用pmap去观察某个进程的内存细节。
进程的内存管理涉及到非常多的术语对于常用的一些术语比如VIRT、RES、SHR等你还是需要牢记它们的含义的只有熟练掌握了它们的含义你在分析内存问题时才会更加地得心应手。比如说如果RES太高而SHR不高那可能是堆内存泄漏如果SHR很高那可能是tmpfs/shm之类的数据在持续增长如果VIRT很高而RES很小那可能是进程不停地在申请内存但是却没有对这些内存进行任何的读写操作即虚拟地址空间存在内存泄漏。
同样地,我希望你自己可以写一些测试用例来观察这些指标的变化。
## 课后作业
课后你可以写一些测试程序,分别分配我们这堂课提到的四种不同类型的内存,观察进程地址空间的变化,以及系统内存指标的变化。欢迎你在留言区与我讨论。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。

View File

@@ -0,0 +1,202 @@
<audio id="audio" title="07 案例篇 | 如何预防内存泄漏导致的系统假死?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/56/29f0f8f5754fe45ced47a62d7d45c656.mp3"></audio>
你好,我是邵亚方。
上节课,我们讲了有哪些进程的内存类型会容易引起内存泄漏,这一讲我们来聊一聊,到底应该如何应对内存泄漏的问题。
我们知道,内存泄漏是件非常容易发生的事,但如果它不会给应用程序和系统造成危害,那它就不会构成威胁。当然我不是说这类内存泄漏无需去关心,对追求完美的程序员而言,还是需要彻底地解决掉它的。
而有一些内存泄漏你却需要格外重视,比如说长期运行的后台进程的内存泄漏,这种泄漏日积月累,会逐渐耗光系统内存,甚至会引起系统假死。
我们在了解内存泄漏造成的危害之前,先一起看下什么样的内存泄漏是有危害的。
## 什么样的内存泄漏是有危害的?
下面是一个内存泄漏的简单示例程序。
```
#include &lt;stdlib.h&gt;
#include &lt;string.h&gt;
#define SIZE (1024 * 1024 * 1024) /* 1G */
int main()
{
char *p = malloc(SIZE);
if (!p)
return -1;
memset(p, 1, SIZE);
/* 然后就再也不使用这块内存空间 */
/* 没有释放p所指向的内存进程就退出了 */
/* free(p); */
return 0;
}
```
我们可以看到这个程序里面申请了1G的内存后没有进行释放就退出了那这1G的内存空间是泄漏了吗
我们可以使用一个简单的内存泄漏检查工具(valgrind)来看看。
```
$ valgrind --leak-check=full ./a.out
==20146== HEAP SUMMARY:
==20146== in use at exit: 1,073,741,824 bytes in 1 blocks
==20146== total heap usage: 1 allocs, 0 frees, 1,073,741,824 bytes allocated
==20146==
==20146== 1,073,741,824 bytes in 1 blocks are possibly lost in loss record 1 of 1
==20146== at 0x4C29F73: malloc (vg_replace_malloc.c:309)
==20146== by 0x400543: main (in /home/yafang/test/mmleak/a.out)
==20146==
==20146== LEAK SUMMARY:
==20146== definitely lost: 0 bytes in 0 blocks
==20146== indirectly lost: 0 bytes in 0 blocks
==20146== possibly lost: 1,073,741,824 bytes in 1 blocks
==20146== still reachable: 0 bytes in 0 blocks
==20146== suppressed: 0 bytes in 0 blocks
```
从valgrind的检查结果里我们可以清楚地看到申请的内存只被使用了一次memset就再没被使用但是在使用完后却没有把这段内存空间给释放掉这就是典型的内存泄漏。那这个内存泄漏是有危害的吗
这就要从进程地址空间的分配和销毁来说起,下面是一个简单的示意图:
<img src="https://static001.geekbang.org/resource/image/e0/64/e0e227529ba7f2fcab1ab445c4634764.jpg" alt="" title="进程地址空间申请和释放示意图">
从上图可以看出进程在退出的时候会把它建立的映射都给解除掉。换句话说进程退出时会把它申请的内存都给释放掉这个内存泄漏就是没危害的。不过话说回来虽然这样没有什么危害但是我们最好还是要在程序里加上free §这才是符合编程规范的。我们修改一下这个程序加上free§再次编译后通过valgrind来检查就会发现不存在任何内存泄漏了
```
$ valgrind --leak-check=full ./a.out
==20123== HEAP SUMMARY:
==20123== in use at exit: 0 bytes in 0 blocks
==20123== total heap usage: 1 allocs, 1 frees, 1,073,741,824 bytes allocated
==20123==
==20123== All heap blocks were freed -- no leaks are possible
```
总之如果进程不是长时间运行那么即使存在内存泄漏比如这个例子中的只有malloc没有free它的危害也不大因为进程退出时内核会把进程申请的内存都给释放掉。
我们前面举的这个例子是对应用程序无害的内存泄漏,我们继续来看下哪些内存泄漏会给应用程序产生危害 。我们同样以malloc为例看一个简单的示例程序
```
#include &lt;stdlib.h&gt;
#include &lt;string.h&gt;
#include &lt;unistd.h&gt;
#define SIZE (1024 * 1024 * 1024) /* 1G */
void process_memory()
{
char *p;
p = malloc(SIZE);
if (!p)
return;
memset(p, 1, SIZE);
/* Forget to free this memory */
}
/* 处理其他事务为了简便起见我们就以sleep为例 */
void process_others()
{
sleep(1);
}
int main()
{
/* 这部分内存只处理一次,以后再也不会用到 */
process_memory();
/* 进程会长时间运行 */
while (1) {
process_others();
}
return 0;
```
这是一个长时间运行的程序process_memory()中我们申请了1G的内存去使用然后就再也不用它了由于这部分内存不会再被利用这就造成了内存的浪费如果这样的程序多了被泄漏出去的内存就会越来越多然后系统中的可用内存就会越来越少。
对于后台服务型的业务而言,基本上都是需要长时间运行的程序,所以后台服务的内存泄漏会给系统造成实际的危害。那么,究竟会带来什么样的危害,我们又该如何去应对呢?
## 如何预防内存泄漏导致的危害?
我们还是以上面这个malloc()程序为例在这个例子中它只是申请了1G的内存如果说持续不断地申请内存而不释放你会发现很快系统内存就会被耗尽进而触发OOM killer去杀进程。这个信息可以通过dmesg该命令是用来查看内核日志的这个命令来查看
```
$ dmesg
[944835.029319] a.out invoked oom-killer: gfp_mask=0x100dca(GFP_HIGHUSER_MOVABLE|__GFP_ZERO), order=0, oom_score_adj=0
[...]
[944835.052448] Out of memory: Killed process 1426 (a.out) total-vm:8392864kB, anon-rss:7551936kB, file-rss:4kB, shmem-rss:0kB, UID:0 pgtables:14832kB oom_score_adj:0
```
系统内存不足时会唤醒OOM killer来选择一个进程给杀掉在我们这个例子中它杀掉了这个正在内存泄漏的程序该进程被杀掉后整个系统也就变得安全了。但是你要注意**OOM killer选择进程是有策略的它未必一定会杀掉正在内存泄漏的进程很有可能是一个无辜的进程被杀掉。**而且OOM本身也会带来一些副作用。
我来说一个发生在生产环境中的实际案例,这个案例我也曾经[反馈给Linux内核社区来做改进](https://lore.kernel.org/linux-mm/1586597774-6831-1-git-send-email-laoar.shao@gmail.com/),接下来我们详细说一下它。
这个案例跟OOM日志有关OOM日志可以理解为是一个单生产者多消费者的模型如下图所示
<img src="https://static001.geekbang.org/resource/image/b3/a7/b39503a3fb39e731d2d4c51687db70a7.jpg" alt="" title="OOM info">
这个单生产者多消费者模型其实是由OOM killer打印日志OOM info时所使用的printk类似于userspace的printf机制来决定的。printk会检查这些日志需要输出给哪些消费者比如写入到内核缓冲区kernel buffer然后通过dmesg命令来查看我们通常也都会配置rsyslog然后rsyslogd会将内核缓冲区的内容给转储到日志文件/var/log/messages服务器也可能会连着一些控制台console 比如串口这些日志也会输出到这些console。
问题就出在console这里如果console的速率很慢输出太多日志会非常消耗时间而当时我们配置了“console=ttyS1,19200”即波特率为19200的串口这是个很低速率的串口。一个完整的OOM info需要约10s才能打印完这在系统内存紧张时就会成为一个瓶颈点为什么会是瓶颈点呢答案如下图所示
<img src="https://static001.geekbang.org/resource/image/2c/e7/2c4e5452584e9a1525921dffbdfda4e7.jpg" alt="" title="OOM为什么会成为瓶颈点">
进程A在申请内存失败后会触发OOM在发生OOM的时候会打印很多很多日志这些日志是为了方便分析为什么OOM会发生然后会选择一个合适的进程来杀掉从而释放出来空闲的内存这些空闲的内存就可以满足后续内存申请了。
如果这个OOM的过程耗时很长即打印到slow console所需的时间太长如上图红色部分所示其他进程进程B也在此时申请内存也会申请失败于是进程B同样也会触发OOM来尝试释放内存而OOM这里又有一个全局锁oom_lock来进行保护进程B尝试获取trylock这个锁的时候会失败就只能再次重试。
如果此时系统中有很多进程都在申请内存,那么这些申请内存的进程都会被阻塞在这里,这就形成了一个恶性循环,甚至会引发系统长时间无响应(假死)。
针对这个问题我与Linux内核内存子系统的维护者Michal Hocko以及OOM子模块的活跃开发者Tetsuo Handa进行了[一些讨论](https://lore.kernel.org/linux-mm/1586597774-6831-1-git-send-email-laoar.shao@gmail.com/),不过我们并没有讨论出一个完美的解决方案,目前仍然是只有一些规避措施,如下:
<li>
<p>**在发生OOM时尽可能少地打印信息**<br>
通过将[vm.oom_dump_tasks](https://www.kernel.org/doc/Documentation/sysctl/vm.txt)调整为0可以不去备份dump当前系统中所有可被kill的进程信息如果系统中有很多进程这些信息的打印可能会非常消耗时间。在我们这个案例里这部分耗时约为6s多占OOM整体耗时10s的一多半所以减少这部分的打印能够缓解这个问题。</p>
但是,**这并不是一个完美的方案,只是一个规避措施**。因为当我们把vm.oom_dump_tasks配置为1时是可以通过这些打印的信息来检查OOM killer是否选择了合理的进程以及系统中是否存在不合理的OOM配置策略的。如果我们将它配置为0就无法得到这些信息了而且这些信息不仅不会打印到串口也不会打印到内核缓冲区导致无法被转储到不会产生问题的日志文件中。
</li>
<li>
<p>**调整串口打印级别不将OOM信息打印到串口**<br>
通过调整[/proc/sys/kernel/printk](https://www.kernel.org/doc/Documentation/sysctl/kernel.txt)可以做到避免将OOM信息输出到串口我们通过设置console_loglevel来将它的级别设置的比OOM日志级别为4就可以避免OOM的信息打印到console比如将它设置为3:</p>
</li>
```
# 初始配置(为7)所有信息都会输出到console
$ cat /proc/sys/kernel/printk
7 4 1 7
# 调整console_loglevel级别不让OOM信息打印到console
$ echo &quot;3 4 1 7&quot; &gt; /proc/sys/kernel/printk
# 查看调整后的配置
$ cat /proc/sys/kernel/printk
3 4 1
```
但是这样做会导致所有低于默认级别为4的内核日志都无法输出到console在系统出现问题时我们有时候比如无法登录到服务器上面时会需要查看console信息来判断问题是什么引起的如果某些信息没有被打印到console可能会影响我们的分析。
这两种规避方案各有利弊你需要根据你的实际情况来做选择如果你不清楚怎么选择时我建议你选择第二种因为我们使用console的概率还是较少一些所以第二种方案的影响也相对较小一些。
OOM相关的一些日志输出后就到了下一个阶段选择一个最需要杀死的进程来杀掉。OOM killer在选择杀掉哪个进程时也是一个比较复杂的过程而且如果配置不当也会引起其他问题。关于这部分的案例我们会在下节课来分析。
## 课堂总结
这节课我们讲了什么是内存泄漏,以及内存泄漏可能造成的危害。对于长时间运行的后台任务而言,它存在的内存泄漏可能会给系统带来比较严重的危害,所以我们一定要重视这些任务的内存泄漏问题。
内存泄漏问题是非常容易发生的所以我们需要提前做好内存泄漏的兜底工作即使有泄漏了也不要让它给系统带来很大的危害。长时间的内存泄漏问题最后基本都会以OOM结束所以你需要去掌握OOM的相关知识来做好这个兜底工作。
如果你的服务器有慢速的串口设备那你一定要防止它接收太多的日志尤其是OOM产生的日志因为OOM的日志量是很大的打印完整个OOM信息kennel会很耗时进而导致阻塞申请内存的进程甚至会严重到让整个系统假死。
墨菲定律告诉我们,如果事情有变坏的可能,不管这种可能性有多小,它总会发生。对应到内存泄漏就是,当你的系统足够复杂后,它总是可能会发生的。所以,对于内存泄漏问题,你在做好预防的同时,也一定要对它发生后可能带来的危害做好预防。
## 课后作业
请写一些应用程序来构造内存泄漏的测试用例然后使用valgrind来进行观察。欢迎在留言区分享你的看法。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。

View File

@@ -0,0 +1,130 @@
<audio id="audio" title="08 案例篇 | Shmem进程没有消耗内存内存哪去了" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/07/4933d2de6c743f80ff4fd01bbd29d507.mp3"></audio>
你好,我是邵亚方。
在前一节课我们讲述了进程堆内存的泄漏以及因为内存泄漏而导致的OOM的危害。这节课我们继续讲其他类型的内存泄漏这样你在发现系统内存越来越少时就能够想到会是什么在消耗内存。
有的内存泄漏会体现在进程内存里面这种相对好观察些而有的内存泄漏就很难观察了因为它们无法通过观察进程消耗的内存来进行判断从而容易被忽视比如Shmem内存泄漏就属于这种容易被忽视的这节课我们重点来讲讲它。
## 进程没有消耗内存,内存哪去了?
我生产环境上就遇到过一个真实的案例。我们的运维人员发现某几台机器used已使用的内存越来越多但是通过top以及其他一些命令却检查不出来到底是谁在占用内存。随着可用内存变得越来越少业务进程也被OOM killer给杀掉这给业务带来了比较严重的影响。于是他们向我寻求帮助看看产生问题的原因是什么。
我在之前的课程中也提到过,在遇到系统内存不足时,我们首先要做的是查看/proc/meminfo中哪些内存类型消耗较多然后再去做针对性分析。但是如果你不清楚/proc/meminfo里面每一项的含义即使知道了哪几项内存出现了异常也不清楚该如何继续去分析。所以你最好是记住/proc/meminfo里每一项的含义。
回到我们这个案例,通过查看这几台服务器的/proc/meminfo发现是Shmem的大小有些异常
```
$ cat /proc/meminfo
...
Shmem 16777216 kB
...
```
那么Shmem这一项究竟是什么含义呢该如何去进一步分析到底是谁在使用Shmem呢
我们在前面的基础篇里提到Shmem是指匿名共享内存即进程以mmapMAP_ANON|MAP_SHARED这种方式来申请的内存。你可能会有疑问进程以这种方式来申请的内存不应该是属于进程的RESresident比如下面这个简单的示例
```
#include &lt;sys/mman.h&gt;
#include &lt;string.h&gt;
#include &lt;unistd.h&gt;
#define SIZE (1024*1024*1024)
int main()
{
char *p;
p = mmap(NULL, SIZE, PROT_READ|PROT_WRITE, MAP_ANON|MAP_SHARED, -1, 0);
if (!p)
return -1;
memset(p, 1, SIZE);
while (1) {
sleep(1);
}
return 0;
}
```
运行该程序后通过top可以看到确实会体现在进程的RES里面而且还同时体现在了进程的SHR里面也就是说如果进程是以mmap这种方式来申请内存的话我们是可以通过进程的内存消耗来观察到的。
但是在我们生产环境上遇到的问题各个进程的RES都不大看起来和/proc/meminfo中的Shmem完全对应不起来这又是为什么呢
先说答案这跟一种特殊的Shmem有关。我们知道磁盘的速度是远远低于内存的有些应用程序为了提升性能会避免将一些无需持续化存储的数据写入到磁盘而是把这部分临时数据写入到内存中然后定期或者在不需要这部分数据时清理掉这部分内容来释放出内存。在这种需求下就产生了一种特殊的Shmemtmpfs。tmpfs如下图所示
<img src="https://static001.geekbang.org/resource/image/24/eb/248b083e0c263b096c66f8078e3a3aeb.jpg" alt="">
它是一种内存文件系统只存在于内存中它无需应用程序去申请和释放内存而是操作系统自动来规划好一部分空间应用程序只需要往这里面写入数据就可以了这样会很方便。我们可以使用moun命令或者df命令来看系统中tmpfs的挂载点
```
$ df -h
Filesystem Size Used Avail Use% Mounted on
...
tmpfs 16G 15G 1G 94% /run
...
```
就像进程往磁盘写文件一样进程写完文件之后就把文件给关闭掉了这些文件和进程也就不再有关联所以这些磁盘文件的大小不会体现在进程中。同样地tmpfs中的文件也一样它也不会体现在进程的内存占用上。讲到这里你大概已经猜到了我们Shmem占用内存多是不是因为Shmem中的tmpfs较大导致的呢
tmpfs是属于文件系统的一种。对于文件系统我们都可以通过df来查看它的使用情况。所以呢我们也可以通过df来看是不是tmpfs占用的内存较多结果发现确实是它消耗了很多内存。这个问题就变得很清晰了我们只要去分析tmpfs中存储的是什么文件就可以了。
我们在生产环境上还遇到过这样一个问题systemd不停地往tmpfs中写入日志但是没有去及时清理而tmpfs配置的初始值又太大这就导致systemd产生的日志量越来越多最终可用内存越来越少。
针对这个问题解决方案就是限制systemd所使用的tmpfs的大小在日志量达到tmpfs大小限制时自动地清理掉临时日志或者定期清理掉这部分日志这都可以通过systemd的配置文件来做到。tmpfs的大小可以通过如下命令比如调整为2G调整
```
$ mount -o remount,size=2G /run
```
tmpfs作为一种特殊的Shmem它消耗的内存是不会体现在进程内存中的这往往会给问题排查带来一些难度。要想高效地分析这种类型的问题你必须要去熟悉系统中的内存类型。除了tmpfs之外其他一些类型的内存也不会体现在进程内存中比如内核消耗的内存/proc/meminfo中的Slab高速缓存、KernelStack内核栈和VmallocUsed内核通过vmalloc申请的内存这些也是你在不清楚内存被谁占用时需要去排查的。
如果tmpfs消耗的内存越积越多而得不到清理最终的结果也是系统可用内存不足然后触发OOM来杀掉进程。它很有可能会杀掉很重要的进程或者是那些你认为不应该被杀掉的进程。
## OOM杀进程的危害
OOM杀进程的逻辑大致如下图所示
<img src="https://static001.geekbang.org/resource/image/15/ee/150863953f090f09179e87814322a5ee.jpg" alt="">
OOM killer在杀进程的时候会把系统中可以被杀掉的进程扫描一遍根据进程占用的内存以及配置的oom_score_adj来计算出进程最终的得分然后把得分oom_score最大的进程给杀掉如果得分最大的进程有多个那就把先扫描到的那个给杀掉。
进程的oom_score可以通过/proc/[pid]/oom_score来查看你可以扫描一下你系统中所有进程的oom_score其中分值最大的那个就是在发生OOM时最先被杀掉的进程。不过你需要注意由于oom_score和进程的内存开销有关而进程的内存开销又是会动态变化的所以该值也会动态变化。
如果你不想这个进程被首先杀掉那你可以调整该进程的oom_score_adj改变这个oom_score如果你的进程无论如何都不能被杀掉那你可以将oom_score_adj配置为-1000。
通常而言我们都需要将一些很重要的系统服务的oom_score_adj配置为-1000比如sshd因为这些系统服务一旦被杀掉我们就很难再登陆进系统了。
但是,除了系统服务之外,不论你的业务程序有多重要,都尽量不要将它配置为-1000。因为你的业务程序一旦发生了内存泄漏而它又不能被杀掉这就会导致随着它的内存开销变大OOM killer不停地被唤醒从而把其他进程一个个给杀掉我们之前在生产环境中就遇到过类似的案例。
OOM killer的作用之一就是找到系统中不停泄漏内存的进程然后把它给杀掉如果没有找对那就会误杀其他进程甚至是误杀了更为重要的业务进程。
OOM killer除了会杀掉一些无辜进程外它选择杀进程的策略也未必是正确的。接下来又到了给内核找茬的时刻了这也是我们这个系列课程的目的告诉你如何来学些Linux内核但同时我也要告诉你要对内核有怀疑态度。下面这个案例就是一个内核的Bug。
在我们的一个服务器上我们发现OOM killer在杀进程的时候总是会杀掉最先扫描到的进程而由于先扫描到的进程的内存太小就导致OOM杀掉进程后很难释放出足够多的内存然后很快再次发生OOM。
这是在Kubernetes环境下触发的一个问题Kubernetes会将某些重要的容器配置为Guaranteed [对应的oom_score_adj为-998](https://kubernetes.io/docs/tasks/administer-cluster/out-of-resource/)以防止系统OOM的时候把该重要的容器给杀掉。 然而如果容器内部发生了OOM就会触发这个内核Bug导致总是杀掉最先扫描到的那个进程。
针对该内核Bug我也给社区贡献了一个patch[mm, oom: make the calculation of oom badness more accurate](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9066e5cfb73cdbcdbb49e87999482ab615e9fc76)来修复这个选择不到合适进程的问题在这个patch的commit log里我详细地描述了该问题感兴趣的话你可以去看下。
## 课堂总结
这节课我们学习了tmpfs这种类型的内存泄漏以及它的观察方法这种类型的内存泄漏和其他进程内存泄漏最大的不同是你很难通过进程消耗的内存来判断是哪里在泄漏因为这种类型的内存不会体现在进程的RES中。但是如果你熟悉内存问题的常规分析方法你就能很快地找到问题所在。
- 在不清楚内存被谁消耗时,你可以通过/proc/meminfo找到哪种类型的内存开销比较大然后再对这种类型的内存做针对性分析。
- 你需要配置合适的OOM策略oom_score_adj来防止重要的业务被过早杀掉比如将重要业务的oom_score_adj调小为负值同时你也需要考虑误杀其他进程你可以通过比较进程的/proc/[pid]/oom_score来判断出进程被杀的先后顺序。
- 再次强调一遍,你需要学习内核,但同时你也需要对内核持怀疑态度。
总之,你对不同内存类型的特点了解越多,你在分析内存问题的时候(比如内存泄漏问题)就会更加高效。熟练掌握这些不同的内存类型,你也能够在业务需要申请内存时选择合适的内存类型。
## 课后作业
请你运行几个程序分别设置不同的oom_score_adj并记录下它们的oom_score是什么样的然后消耗系统内存触发OOM看看oom_score和进程被杀的顺序是什么关系。欢迎你在留言区与我讨论。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="09 分析篇 | 如何对内核内存泄漏做些基础的分析?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/25/01426282302497e9cd96a67f0ae86525.mp3"></audio>
你好,我是邵亚方。
如果你是一名应用开发者,那你对应用程序引起的内存泄漏应该不会陌生。但是,你有没有想过,内存泄漏也可能是由操作系统(内核)自身的问题引起的呢?这是很多应用开发者以及运维人员容易忽视的地方,或者是相对陌生的领域。
然而陌生的领域不代表不会有问题,如果在陌生的领域发生了问题,而你总是习惯于分析应用程序自身,那你可能要浪费很多的分析时间,却依然一无所获。所以,对于应用开发者或者运维人员而言,掌握基本的内核内存泄漏分析方法也是必需的,这样在它发生问题时,你可以有一个初步的判断,而不至于一筹莫展。
内核内存泄漏往往都会是很严重的问题,这通常意味着要重启服务器来解决了,我们肯定并不希望只能靠重启服务器来解决它,不然那就只能没完没了地重启了。我们希望的应该是,在发生了内存泄漏后,能够判断出来是不是内核引起的问题,以及能够找到引起问题的根因,或者是向更专业的内核开发者求助来找到问题根因,从而彻底解决掉它,以免再次重启服务器。
那么,我们该如何判断内存泄漏是否是内核导致的呢?这节课我们就来讲一讲内核内存泄漏的基础分析方法。
## 内核内存泄漏是什么?
在进行具体的分析之前,我们需要先对内核内存泄漏有个初步的概念,究竟内核内存泄漏是指什么呢?这得从内核空间内存分配的基本方法说起。
我们在[06基础篇](https://time.geekbang.org/column/article/280455)里讲过进程的虚拟地址空间address space既包括用户地址空间也包括内核地址空间。这可以简单地理解为进程运行在用户态申请的内存对应的是用户地址空间进程运行在内核态申请的内存对应的是内核地址空间如下图所示
<img src="https://static001.geekbang.org/resource/image/41/d9/41909181c0f6aa0958c33df52cd626d9.jpg" alt="">
应用程序可以通过malloc()和free()在用户态申请和释放内存与之对应可以通过kmalloc()/kfree()以及vmalloc()/vfree()在内核态申请和释放内存。当然,还有其他申请和释放内存的方法,但大致可以分为这两类。
从最右侧的物理内存中你可以看出这两类内存申请方式的主要区别kmalloc()内存的物理地址是连续的而vmalloc()内存的物理地址则是不连续的。这两种不同类型的内存也是可以通过/proc/meminfo来观察的
```
$ cat /proc/meminfo
...
Slab: 2400284 kB
SReclaimable: 47248 kB
SUnreclaim: 2353036 kB
...
VmallocTotal: 34359738367 kB
VmallocUsed: 1065948 kB
...
```
其中vmalloc申请的内存会体现在VmallocUsed这一项中即已使用的Vmalloc区大小而kmalloc申请的内存则是体现在Slab这一项中它又分为两部分其中SReclaimable是指在内存紧张的时候可以被回收的内存而SUnreclaim则是不可以被回收只能主动释放的内存。
内核之所以将kmalloc和vmalloc的信息通过/proc/meminfo给导出来也是为了在它们引起问题的时候让我们可以有方法来进行排查。在讲述具体的案例以及排查方法之前我们先以一个简单的程序来看下内核空间是如何进行内存申请和释放的。
```
/* kmem_test */
#include &lt;linux/init.h&gt;
#include &lt;linux/vmalloc.h&gt;
#define SIZE (1024 * 1024 * 1024)
char *kaddr;
char *kmem_alloc(unsigned long size)
{
char *p;
p = vmalloc(size);
if (!p)
pr_info(&quot;[kmem_test]: vmalloc failed\n&quot;);
return p;
}
void kmem_free(const void *addr)
{
if (addr)
vfree(addr);
}
int __init kmem_init(void)
{
pr_info(&quot;[kmem_test]: kernel memory init\n&quot;);
kaddr = kmem_alloc(SIZE);
return 0;
}
void __exit kmem_exit(void)
{
kmem_free(kaddr);
pr_info(&quot;[kmem_test]: kernel memory exit\n&quot;);
}
module_init(kmem_init)
module_exit(kmem_exit)
MODULE_LICENSE(&quot;GPLv2&quot;);
```
这是一个典型的内核模块在这个内核模块中我们使用vmalloc来分配了1G的内存空间然后在模块退出的时候使用vfree释放掉它。这在形式上跟应用申请/释放内存其实是一致的,只是申请和释放内存的接口函数不一样而已。
我们需要使用Makefile来编译这个内核模块
```
obj-m = kmem_test.o
all:
make -C /lib/modules/`uname -r`/build M=`pwd`
clean:
rm -f *.o *.ko *.mod.c *.mod *.a modules.order Module.symvers
```
执行make命令后就会生成一个kmem_test的内核模块接着执行下面的命令就可以安装该模块了
```
$ insmod kmem_test
```
用rmmod命令则可以把它卸载掉
```
$ rmmod kmem_test
```
这个示例程序就是内核空间内存分配的基本方法。你可以在插入/卸载模块前后观察VmallocUsed的变化以便于你更好地理解这一项的含义。
那么,在什么情况下会发生内核空间的内存泄漏呢?
跟用户空间的内存泄漏类似内核空间的内存泄漏也是指只申请内存而不去释放该内存的情况比如说如果我们不在kmem_exit()这个函数中调用kmem_free(),就会产生内存泄漏问题。
那么,内核空间的内存泄漏与用户空间的内存泄漏有什么不同呢?我们知道,用户空间内存的生命周期与用户进程是一致的,进程退出后这部分内存就会自动释放掉。但是,内核空间内存的生命周期是与内核一致的,却不是跟内核模块一致的,也就是说,在内核模块退出时,不会自动释放掉该内核模块申请的内存,只有在内核重启(即服务器重启)时才会释放掉这部分内存。
总之,一旦发生内核内存泄漏,你很难有很好的方法来优雅地解决掉它,很多时候唯一的解决方案就是重启服务器,这显然是件很严重的问题。同样地,我也建议你来观察下这个行为,但是你需要做好重启服务器的心理准备。
kmalloc的用法跟vmalloc略有不同你可以参考[kmalloc API](https://www.kernel.org/doc/htmldocs/kernel-api/API-kmalloc.html)和[kfree API](https://www.kernel.org/doc/htmldocs/kernel-api/API-kfree.html)来修改一下上面的测试程序然后观察下kmalloc内存和/proc/meminfo中那几项的关系我在这里就不做演示了留给你作为课后作业。
内核内存泄漏的问题往往会发生在一些驱动程序中比如说网卡驱动SSD卡驱动等以及我们自己开发的一些驱动因为这类驱动不像Linux内核那样经历过大规模的功能验证和测试所以相对容易出现一些隐藏很深的问题。
我们在生产环境上就遇到过很多起这类第三方驱动引发的内存泄漏问题,排查起来往往也比较费时。作为一个解决过很多这类问题的过来人,我对你的建议是,当你发现内核内存泄漏时,首先需要去质疑的就是你们系统中的第三方驱动程序,以及你们自己开发的驱动程序。
那么,我们该如何来观察内核内存泄漏呢?
## 如何观察内核内存泄漏?
在前面已经讲过,我们可以通过/proc/meminfo来观察内核内存的分配情况这提供了一个观察内核内存的简便方法
- 如果/proc/meminfo中内核内存比如VmallocUsed和SUnreclaim太大那很有可能发生了内核内存泄漏
- 另外你也可以周期性地观察VmallocUsed和SUnreclaim的变化如果它们持续增长而不下降也可能是发生了内核内存泄漏。
/proc/meminfo只是提供了系统内存的整体使用情况如果我们想要看具体是什么模块在使用内存那该怎么办呢
这也可以通过/proc来查看所以再次强调一遍当你不清楚该如何去分析时你可以试着去查看/proc目录下的文件。以上面的程序为例安装kmem_test这个内核模块后我们可以通过/proc/vmallocinfo来看到该模块的内存使用情况
```
$ cat /proc/vmallocinfo | grep kmem_test
0xffffc9008a003000-0xffffc900ca004000 1073745920 kmem_alloc+0x13/0x30 [kmem_test] pages=262144 vmalloc vpages N0=262144
```
可以看到,在[kmem_test]这个模块里通过kmem_alloc这个函数申请了262144个pages即总共1G大小的内存。假设我们怀疑kmem_test这个模块存在问题我们就可以去看看kmem_alloc这个函数里申请的内存有没有释放的地方。
上面这个测试程序相对比较简单一些,所以根据/proc/vmallocinfo里面的信息就能够简单地看出来是否有问题。但是生产环境中运行的一些驱动或者内核模块在逻辑上会复杂得多很难一眼就看出来是否存在内存泄漏这往往需要大量的分析。
那对于这种复杂场景下的内核内存泄漏问题,基本的分析思路是什么样的呢?
## 复杂场景下内核内存泄漏问题分析思路
如果我们想要对内核内存泄漏做些基础的分析,最好借助一些内核内存泄漏分析工具,其中最常用的分析工具就是[kmemleak](https://www.kernel.org/doc/html/v4.10/dev-tools/kmemleak.html)。
kmemleak是内核内存泄漏检查的利器但是它的使用也存在一些不便性因为打开该特性会给性能带来一些损耗所以生产环境中的内核都会默认关闭该特性。该特性我们一般只用在测试环境中然后在测试环境中运行需要分析的驱动程序以及其他内核模块。
与其他内存泄漏检查工具类似kmemleak也是通过检查内核内存的申请和释放来判断是否存在申请的内存不再使用也不释放的情况。如果存在就认为是内核内存泄漏然后把这些泄漏的信息通过/sys/kernel/debug/kmemleak这个文件导出给用户分析。同样以我们上面的程序为例检查结果如下
```
unreferenced object 0xffffc9008a003000 (size 1073741824):
comm &quot;insmod&quot;, pid 11247, jiffies 4344145825 (age 3719.606s)
hex dump (first 32 bytes):
38 40 18 ba 80 88 ff ff 00 00 00 00 00 00 00 00 8@..............
f0 13 c9 73 80 88 ff ff 18 40 18 ba 80 88 ff ff ...s.....@......
backtrace:
[&lt;00000000fbd7cb65&gt;] __vmalloc_node_range+0x22f/0x2a0
[&lt;000000008c0afaef&gt;] vmalloc+0x45/0x50
[&lt;000000004f3750a2&gt;] 0xffffffffa0937013
[&lt;0000000078198a11&gt;] 0xffffffffa093c01a
[&lt;000000002041c0ec&gt;] do_one_initcall+0x4a/0x200
[&lt;000000008d10d1ed&gt;] do_init_module+0x60/0x220
[&lt;000000003c285703&gt;] load_module+0x156c/0x17f0
[&lt;00000000c428a5fe&gt;] __do_sys_finit_module+0xbd/0x120
[&lt;00000000bc613a5a&gt;] __x64_sys_finit_module+0x1a/0x20
[&lt;000000004b0870a2&gt;] do_syscall_64+0x52/0x90
[&lt;000000002f458917&gt;] entry_SYSCALL_64_after_hwframe+0x44/0xa9
```
由于该程序通过vmalloc申请的内存以后再也没有使用所以被kmemleak标记为了“unreferenced object”我们需要在使用完该内存空间后就释放它以节省内存。
如果我们想在生产环境上来观察内核内存泄漏就无法使用kmemleak了那还有没有其他的方法呢
我们可以使用内核提供的内核内存申请释放的tracepoint来动态观察内核内存使用情况
<img src="https://static001.geekbang.org/resource/image/4c/8c/4c434f56b5c41f9cc2eb53a2c98f948c.jpg" alt="">
当我们使能这些tracepoints后就可以观察内存的动态申请和释放情况了只是这个分析过程不如kmemleak那么高效。
当我们想要观察某些内核结构体的申请和释放时可能没有对应的tracepiont。这个时候就需要使用kprobe或者systemtap来针对具体的内核结构体申请释放函数进行追踪了。下面就是我们在生产环境中的一个具体案例。
业务方反馈说docker里面的可用内存越来越少不清楚是什么状况在我们通过/proc下面的文件/proc/slabinfo判断出来是dentry消耗内存过多后写了一个systemtap脚本来观察dentry的申请和释放
```
# dalloc_dfree.stp
# usage : stap -x pid dalloc_dfree.stp
global free = 0;
global alloc = 0;
probe kernel.function(&quot;d_free&quot;) {
if (target() == pid()) {
free++;
}
}
probe kernel.function(&quot;d_alloc&quot;).return {
if (target() == pid()) {
alloc++;
}
}
probe end {
printf(&quot;alloc %d free %d\n&quot;, alloc, free);
}
```
我们使用该工具进行了多次统计都发现是dentry的申请远大于它的释放
```
alloc 2041 free 1882
alloc 18137 free 6852
alloc 22505 free 10834
alloc 33118 free 20531
```
于是我们判断在容器环境中dentry的回收存在问题最终定位出这是3.10版本内核的一个Bug 如果docker内部内存使用达到了limit但是全局可用内存还很多那就无法去回收docker内部的slab了。当然这个Bug在[新版本内核上已经fix了](https://lwn.net/Articles/628829/)。
好了,我们这节课就讲到这里。
## 课堂总结
这节课我们讲了一种更难分析以及引起危害更大的内存泄漏:内核内存泄漏。我们还讲了针对这种内存泄漏的常用分析方法:
- 你可以通过/proc/meminfo里面的信息来看内核内存的使用情况然后根据这里面的信息来做一些基本的判断如果内核太大那就值得怀疑
- kmemleak是内核内存分析的利器但是一般只在测试环境上使用它因为它对性能会有比较明显的影响
- 在生产环境中可以使用tracepoint或者kprobe来追踪特定类型内核内存的申请和释放从而帮助我们判断是否存在内存泄漏。但这往往需要专业的知识你在不明白的时候可以去请教一些内核专家
- 内核内存泄漏通常都是第三方驱动或者自己写的一些内核模块导致的,在出现内核内存泄漏时,你可以优先去排查它们。
## 课后作业
我们这节课讲的内容对应用开发者会有些难度对于运维人员而言也是需要掌握的。所以我们的课后作业主要是针对运维人员或者内核初学者的请写一个systemtap脚本来追踪内核内存的申请和释放。欢迎你在留言区与我讨论。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。

View File

@@ -0,0 +1,245 @@
<audio id="audio" title="10 分析篇 | 内存泄漏时,我们该如何一步步找到根因?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/68/412a07e3793675c2134dde65d03f6968.mp3"></audio>
你好,我是邵亚方。
通过我们前面的基础篇以及案例篇的学习,你对内存泄漏应该有了自己的一些理解。这节课我来跟你聊一聊系统性地分析内存泄漏问题的方法:也就是说,在面对内存泄漏时,我们该如何一步步去找到根因?
不过我不会深入到具体语言的实现细节以及具体业务的代码逻辑中而是会从Linux系统上通用的一些分析方法来入手。这样不论你使用什么开发语言不论你在开发什么它总能给你提供一些帮助。
## 如何定位出是谁在消耗内存
内存泄漏的外在表现通常是系统内存不够严重的话可能会引起OOM (Out of Memory),甚至系统宕机。那在发生这些现象时,惯用的分析套路是什么呢?
首先,我们需要去找出到底是谁在消耗内存,/proc/meminfo可以帮助我们来快速定位出问题所在。
/proc/meminfo中的项目很多我们没必要全部都背下来不过有些项是相对容易出问题的也是你在遇到内存相关的问题时需要重点去排查的。我将这些项列了一张表格也给出了每一项有异常时的排查思路。
<img src="https://static001.geekbang.org/resource/image/a4/30/a48d1c573d19e30ecee8dc6f6fdd3930.jpg" alt="">
总之如果进程的内存有问题那使用top就可以观察出来如果进程的内存没有问题那你可以从/proc/meminfo入手来一步步地去深入分析。
接下来,我们分析一个实际的案例,来看看如何分析进程内存泄漏是什么原因导致的。
## 如何去分析进程的内存泄漏原因?
这是我多年以前帮助一个小伙伴分析的内存泄漏问题。这个小伙伴已经使用top排查出了业务进程的内存异常但是不清楚该如何去进一步分析。
他遇到的这个异常是业务进程的虚拟地址空间VIRT被消耗很大但是物理内存RES使用得却很少所以他怀疑是进程的虚拟地址空间有内存泄漏。
我们在“[06讲](https://time.geekbang.org/column/article/280455)”中也讲过出现该现象时可以用top命令观察这是当时保存的生产环境信息部分信息做了脱敏处理
```
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
31108 app 20 0 285g 4.0g 19m S 60.6 12.7 10986:15 app_server
```
可以看到app_server这个程序的虚拟地址空间VIRT这一项很大有285GB。
那该如何追踪app_server究竟是哪里存在问题呢
我们可以用pidstat命令关于该命令你可以[man pidstat](https://linux.die.net/man/1/pidstat))来追踪下该进程的内存行为,看看能够发现什么现象。
```
$ pidstat -r -p 31108 1
04:47:00 PM 31108 353.00 0.00 299029776 4182152 12.73 app_server
...
04:47:59 PM 31108 149.00 0.00 299029776 4181052 12.73 app_server
04:48:00 PM 31108 191.00 0.00 299040020 4181188 12.73 app_server
...
04:48:59 PM 31108 179.00 0.00 299040020 4181400 12.73 app_server
04:49:00 PM 31108 183.00 0.00 299050264 4181524 12.73 app_server
...
04:49:59 PM 31108 157.00 0.00 299050264 4181456 12.73 app_server
04:50:00 PM 31108 207.00 0.00 299060508 4181560 12.73 app_server
...
04:50:59 PM 31108 127.00 0.00 299060508 4180816 12.73 app_server
04:51:00 PM 31108 172.00 0.00 299070752 4180956 12.73 app_server
```
如上所示在每个整分钟的时候VSZ会增大10244KB这看起来是一个很有规律的现象。然后我们再来看下增大的这个内存区域到底是什么你可以通过/proc/PID/smaps来看关于/proc提供的信息你可以回顾我们课程的“[05讲](https://time.geekbang.org/column/article/279307)”):
增大的内存区域,具体如下:
```
$ cat /proc/31108/smaps
...
7faae0e49000-7faae1849000 rw-p 00000000 00:00 0
Size: 10240 kB
Rss: 80 kB
Pss: 80 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 80 kB
Referenced: 60 kB
Anonymous: 80 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
7faae1849000-7faae184a000 ---p 00000000 00:00 0
Size: 4 kB
Rss: 0 kB
Pss: 0 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 0 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
```
可以看到它包括一个私有地址空间这从rw-p这个属性中的private可以看出来以及一个保护页 这从—p这个属性可以看出来即进程无法访问。对于有经验的开发者而言从这个4K的保护页就可以猜测出应该跟线程栈有关了。
然后我们跟踪下进程申请这部分地址空间的目的是什么通过strace命令来跟踪系统调用就可以了。因为VIRT的增加它的系统调用函数无非是mmap或者brk那么我们只需要strace的结果来看下mmap或brk就可以了。
用strace跟踪如下
```
$ strace -t -f -p 31108 -o 31108.strace
```
线程数较多,如果使用-f来跟踪线程跟踪的信息量也很大逐个搜索日志里面的mmap或者brk真是眼花缭乱 所以我们来grep一下这个大小(10489856即10244KB),然后过滤下就好了:
```
$ cat 31108.strace | grep 10489856
31152 23:00:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31151 23:01:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31157 23:02:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31158 23:03:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31165 23:04:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31163 23:05:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31153 23:06:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31155 23:07:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31149 23:08:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31147 23:09:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31159 23:10:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31157 23:11:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31148 23:12:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31150 23:13:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31173 23:14:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
```
从这个日志我们可以看到出错的是mmap()这个系统调用那我们再来看下mmap这个内存的目的
```
31151 23:01:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 &lt;unfinished ...&gt;
31151 23:01:00 mprotect(0x7fa94bbc0000, 4096, PROT_NONE &lt;unfinished ...&gt; &lt;&lt;&lt; 创建一个保护页
31151 23:01:00 clone( &lt;unfinished ...&gt; &lt;&lt;&lt; 创建线程
31151 23:01:00 &lt;... clone resumed&gt; child_stack=0x7fa94c5afe50, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND
|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID
|CLONE_CHILD_CLEARTID, parent_tidptr=0x7fa94c5c09d0, tls=0x7fa94c5c0700, child_tidptr=0x7fa94c5c09d0) = 20610
```
可以看出这是在clone时申请的线程栈。到这里你可能会有一个疑问既然线程栈消耗了这么多的内存那理应有很多才对啊
但是实际上系统中并没有很多app_server的线程那这是为什么呢答案其实比较简单线程短暂执行完毕后就退出了可是mmap的线程栈却没有被释放。
我们来写一个简单的程序复现这个现象,问题的复现是很重要的,如果很复杂的问题可以用简单的程序来复现,那就是最好的结果了。
如下是一个简单的复现程序mmap一个40K的线程栈然后线程简单执行一下就退出。
```
#include &lt;stdio.h&gt;
#include &lt;unistd.h&gt;
#include &lt;sys/mman.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/wait.h&gt;
#define _SCHED_H
#define __USE_GNU
#include &lt;bits/sched.h&gt;
#define STACK_SIZE 40960
int func(void *arg)
{
printf(&quot;thread enter.\n&quot;);
sleep(1);
printf(&quot;thread exit.\n&quot;);
return 0;
}
int main()
{
int thread_pid;
int status;
int w;
while (1) {
void *addr = mmap(NULL, STACK_SIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0);
if (addr == NULL) {
perror(&quot;mmap&quot;);
goto error;
}
printf(&quot;creat new thread...\n&quot;);
thread_pid = clone(&amp;func, addr + STACK_SIZE, CLONE_SIGHAND|CLONE_FS|CLONE_VM|CLONE_FILES, NULL);
printf(&quot;Done! Thread pid: %d\n&quot;, thread_pid);
if (thread_pid != -1) {
do {
w = waitpid(-1, NULL, __WCLONE | __WALL);
if (w == -1) {
perror(&quot;waitpid&quot;);
goto error;
}
} while (!WIFEXITED(status) &amp;&amp; !WIFSIGNALED(status));
}
sleep(10);
}
error:
return 0;
}
```
然后我们用pidstat观察该进程的执行可以发现它的现象跟生产环境中的问题是一致的
```
$ pidstat -r -p 535 5
11:56:51 PM UID PID minflt/s majflt/s VSZ RSS %MEM Command
11:56:56 PM 0 535 0.20 0.00 4364 360 0.00 a.out
11:57:01 PM 0 535 0.00 0.00 4364 360 0.00 a.out
11:57:06 PM 0 535 0.20 0.00 4404 360 0.00 a.out
11:57:11 PM 0 535 0.00 0.00 4404 360 0.00 a.out
11:57:16 PM 0 535 0.20 0.00 4444 360 0.00 a.out
11:57:21 PM 0 535 0.00 0.00 4444 360 0.00 a.out
11:57:26 PM 0 535 0.20 0.00 4484 360 0.00 a.out
11:57:31 PM 0 535 0.00 0.00 4484 360 0.00 a.out
11:57:36 PM 0 535 0.20 0.00 4524 360 0.00 a.out
^C
Average: 0 535 0.11 0.00 4435 360 0.00 a.out
```
你可以看到VSZ每10s增大40K但是增加的那个线程只存在了1s就消失了。
至此我们就可以推断出app_server的代码哪里有问题了然后小伙伴去修复该代码Bug很快就把该问题给解决了。
当然了,应用程序的内存泄漏问题其实是千奇百怪的,分析方法也不尽相同,我们讲述这个案例的目的是为了告诉你一些通用的分析技巧。我们掌握了这些通用分析技巧,很多时候就可以以不变来应万变了。
## 课堂总结
这节课我们讲述了系统性分析Linux上内存泄漏问题的分析方法要点如下
- top工具和/proc/meminfo文件是分析Linux上内存泄漏问题甚至是所有内存问题的第一步我们先找出来哪个进程或者哪一项有异常然后再针对性地分析
- 应用程序的内存泄漏千奇百怪,所以你需要掌握一些通用的分析技巧,掌握了这些技巧很多时候就可以以不变应万变。但是,这些技巧的掌握,是建立在你的基础知识足够扎实的基础上。你需要熟练掌握我们这个系列课程讲述的这些基础知识,熟才能生巧。
## 课后作业
请写一个内存泄漏的程序,然后观察/proc/[pid]/maps以及smaps的变化pid即内存泄漏的程序的pid。欢迎你在留言区与我讨论。
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,我们下一讲见。