149-Linux内核技术实战课

开篇词 | 如何让Linux内核更好地服务应用程序？

你好，我是邵亚方，欢迎加入我的课程，和我一起学习 Linux 内核知识。

从 2010 年接触 Linux 内核到现在，因为工作的关系，我参与解决了大量直接与生产环境相关的性能问题。前些年，我还在蘑菇街的时候，那会蘑菇街的业务增长速度非常快。你知道，业务增长了，随之而来的肯定就是服务的稳定性挑战了，比如 TCP 重传该怎么分析、怎么在运⾏时不打断任务的情况下排查内存泄漏问题、CPU sys 利⽤率⾼怎么快速解决，这都是实实在在的问题，你会就会，不会就是不会。

以我们常见的 TCP 重传为例，如果你熟悉的话，服务器上一般都会有 TCP 重传率的监控，如下图所示：

就像图片显示的那样，这么高的 TCP 重传率，必然会导致系统 QPS 减小，所以你不敢含糊，得赶紧找问题出在哪里。但真正排查的时候，你会发现不知道从哪里开始，因为发生重传时的现场信息并没有记录下来。之所以没有记录下来这些现场信息，是因为记录的成本太昂贵了。如果你排查过网络问题，你应该知道，网络数据量是非常大的，即使只记录 TCP 头部信息也是非常大的存储开销，而且信息记录的过程也会带来较多的性能开销。

为了解决这类问题，我们团队就针对 TCP 重传的监控做了很多的改进，这些改进可以在不对业务性能造成明显影响的情况下，提升团队定位和分析问题的效率。我们记录的 TCP 重传现场信息如下所示：

18:21:58 10.17.130.19:20612 124.74.250.144:44  SYN_SENT
18:22:00 10.17.130.19:20612 124.74.250.144:443 SYN_SENT
18:23:21 10.17.130.19:20716 124.74.250.144:443 SYN_SENT
18:23:23 10.17.130.19:20716 124.74.250.144:443 SYN_SENT
18:24:39 10.17.130.19:20796 124.74.250.144:443 SYN_SENT
18:24:41 10.17.130.19:20796 124.74.250.144:443 SYN_SENT
18:25:43 10.17.130.19:20861 124.74.250.144:443 SYN_SENT
18:25:45 10.17.130.19:20861 124.74.250.144:443 SYN_SENT
18:27:23 10.17.130.19:20973 124.74.250.144:443 SYN_SENT
18:27:25 10.17.130.19:20973 124.74.250.144:443 SYN_SENT

通过上面这些信息，你能很简单地看到，TCP 重传是发生在哪些服务器（IP 地址）之间，哪些业务上（服务端口），以及为什么会重传（SYN_SENT）。你看，这样效率不就高了吗？具体我们是怎么做的，后面咱们课程里我会详细和你说。

其实在我看来，对于类似 TCP 重传这样复杂稳定性问题的定位，除了从开发⼈员的视⻆来分析外，更是需要能够从系统、从内核的视⻆来分析，只有这样，你才能够追本溯源、一劳永逸地解决问题。

而大家之所以觉得这些问题难，本质上还是对 Linux 内核理解不到位。比如说，我接触过的业务开发者们，基本上都被业务的性能毛刺困扰过，但是很多人在分析这些性能毛刺时，只能分析到哪些系统调用引起的毛刺，而一些业务专家，却可以再往底层看是什么系统资源引起的业务毛刺。再比如，当发生 TCP 重传时，有人可以从 tcpdump 里面的信息看到是哪个 TCP 连接进行重传，然而高手们却可以通过这些信息看到为什么会发生重传。

能够深入到 Linux 内核层分析问题的这些人，他们看问题能直击本质，定位、分析问题的能力强，往往能解决别人解决不了的问题，所以他们基本上也是各自领域的翘楚。

然而，大部分做应用的开发者往往会忽略对 Linux 内核的学习，这并不难理解，毕竟本职工作更多是在业务代码的优化和调配上，在互联网公司普遍“996”工作的大环境下，很难有精力和时间去深入到内核层面学习。而且，Linux 内核本身就是很复杂的，这种复杂度不仅让应用开发者望而却步，也让很多内核初学者知难而退。

就拿我自己来说吧，我为了学习好 Linux 内核，就啃过非常多中英文的技术书籍，比如：

为了理解应用程序是如何使用 Linux 内核的，我把《Unix 环境高级编程》这本书读了很多遍；

为了掌握系统体系结构，我仔细读完了《深入理解计算机系统》和《支撑处理器的技术》；

为了理解应用程序在 Linux 上是如何编译的，我深入阅读了《An Introduction to GCC》以及《Linkers and Loaders》；

为了掌握 Linux 设备驱动的运行原理，我阅读了《Linux 设备驱动程序》这本书，然后，我才开始深入阅读《深入理解 Linux 内核》这本 Linux 内核开发者的入门书籍；

在学习过程中，为了更好的掌握内存子系统，我阅读了《深入理解 Linux 虚拟内存管理》；

为了掌握网络子系统，我阅读了《TCP/IP Illustrated, Volume 1: The Protocols》以及《TCP/IP Architecture, Design and Implementation in Linux》；

为了熟悉其他操作系统的设计原理，我阅读了《The Design and Implementation of the FreeBSD Operating System》来学习 FreeBSD；

为了更好的分析 Linux 内核问题，我读了《Debug Hacks : 深入调试的技术和工具》和《Systems Performance : Enterprise and the Cloud》。

为了更好的和国外开发者做交流，我又阅读了大量的英文原版书籍来提升自己的英语水平，上面我列的这些书名为英文的就是其中部分原版技术书籍。

不可否认，如果想系统学习 Linux 内核，成本是非常高的。不过话说回来，如果你不是内核开发者，也确实没有必要去搞懂它的每个细节，去掌握它的每一个机制，去理解它所有的优秀设计思想。

在我看来，一个优秀的软件，或者一个优秀的代码，存在的本质是为了解决我们遇到的实实在在的问题，更好地满足我们实际的需求。

也就是说，你能通过掌握的 Linux 内核知识，解决实际应用层的问题就够了，这也是我开设这门课的初衷。我希望能把自己多年的 Linux 内核学习和实践经验，通过“解决问题，满足需求”的方式传递给你，让 Linux 内核更好地服务你的应用程序。

为了更好地达到这个目的，我会从一些生产环境中比较常见的问题入手，带你去了解：你的应用程序是怎么跟系统资源打交道的？你的业务类型应该要选择什么样的配置才会更好？出了棘手的问题该如何一步步地去排查？

那么，从系统资源的维度，我们需要关注的问题可以分为四类，分别是磁盘 I/O、内存、网络 I/O、CPU。那在这系列课程中，我会带你从这四大类中的典型问题入手，深入学习其中的：Page Cache 管理问题、内存泄漏问题、TCP 重传问题、内核态 CPU 利用率飙高问题。这也对应着我们课程的四个模块。

掌握了这四类典型问题以及其分析思路，你会对磁盘 I/O、内存、网络 I/O 和 CPU 这四类服务器上最基础的资源有更加深入的理解，在遇到其他问题时也能够触类旁通，从此再也不用回避一些棘手的系统问题。

为了方便你循序渐进地学习，我们的每个模块都会按照基础篇、案例篇和分析篇的方式来呈现。

在 Page Cache 管理这个模块中，我会重点分析如何更好地利用 Page Cache 来减少无谓的 I/O 开销，Page Cache 管理不当会引起的一些问题，以及如何去分析和解决这类问题。

在内存泄漏这个模块中，我会重点分析应用程序都是如何从系统中申请内存以及如何释放的。通过内存泄露这类案例来带你了解应用程序使用内存的细节，以及如果内存使用不当会引发的一些问题。当然，我也会带你去观察、分析和解决这类问题。

在 TCP 重传这个模块中，我会重点分析 TCP 连接的建立、传输以及断开的过程。这个过程究竟会受哪些配置项的影响？如果配置不当会引起什么网络问题？然后我会从 TCP 重传这类具体案例出发，来带你认识你必须要去掌握的一些网络细节知识，以及遇到网络相关的问题时，你该如何去分析和解决它。

在内核态 CPU 利用率飙高这个模块中，我会分析应用程序该如何高效地使用 CPU，以及哪些情况下会导致 CPU 的使用很低效：比如内核态 CPU 利用率过高就是一个很低效的表现。那么，针对内核态 CPU 利用率高的这个案例，我会侧重讲解哪些 Linux 内核的特性或者系统配置项会引起这种问题，以及如何分析和解决具体的问题。

在每个模块的最后，我都会总结一下这些常见问题的一般分析思路，让你在面对类型问题时能够有一个大致的分析方向。

当然，这个课不仅仅是针对应用开发者和运维，对于内核开发者，特别是不那么资深的内核开发者也会很有帮助，它可以帮助你更好地理解业务。Linux 内核本质上是给业务服务的，理解好了业务，你才能更好地实现内核特性。就像我给 Linux 内核提交一些 patch 时，maintainer 们总是会喜欢问这个问题：“What is your real life usecase ?” 结合业务，不盲目设计，也不要过度设计，这是每一个 Linux 内核开发都需要谨记的。

这个课程，除了教你如何更好地学习 Linux 内核之外，也会带你批判性地来看 Linux 内核。

Linux 内核从 Linus 在 1991 年发布第一版开始，迄今已发展了近 30 年，代码行数也从最开始的 1 万行代码发展到了现在的几千万行代码，这么复杂甚至有点臃肿的工程，肯定是存在 Bug 的，而且也存在很多糟糕的设计。所以，你在平时工作中遇到的很多费解的现象， 也有可能是 Linux 内核缺陷，我们在这个课程里也会带你认识这些随处可见的缺陷。

在你遇到有些疑惑的地方，或者你觉得 Linux 内核哪里不够好时，你可以大胆地去质疑它，去向精通 Linux 内核的人求助，或者向 Linux 社区求助，正是因为你的这些需求，我们才能把 Linux 内核建设的更好更强大。虽然 Linus 本人是个稍微有点脾气的人，但是整体上 Linux 社区是很友好的，不论你遇到什么样子的难题，总有人能够解答你的疑问，甚至引导你该如何更好地改进 Linux 内核。

最后，我来大概介绍一下我在 Linux 内核领域的工作经历吧。我从 2010 年开始，在华为正式从事 Linux 内核的开发工作，然后又经历了外企（Juniper Networks），也经历了互联网企业（蘑菇街）以及现在所在的某知名互联网企业。

不同的企业风格，不同的业务场景，但是它们对 Linux 内核都有着相同的诉求：更好更稳定。

这些不同的业务场景，也丰富了我对 Linux 内核的认识，我也逐渐感受到 Linux 内核在处理更多以及更新的业务场景下的不足之处，所以我也慢慢地参与到 Linux 内核社区中来改进 Linux 内核。

我目前主要是活跃在 Linux 内核的内存管理子系统 ( linux-mm@kvack.org )，如果你有关注这个邮件列表的话，会经常看到我的名字 (Yafang Shao < laoar.shao@gmail.com >)。如果你在工作中遇到 Linux 内核相关的问题，也可以直接发送邮件给我，不过因为平时工作要“996”，我大概率只能在周末才会有精力来回复你。当然，也欢迎你在这个课程下留言提问，我看到都会第一时间处理。

在最后，我再次温馨地提醒你一下，Linux 底层知识的学习并不是一蹴而就的，想要很好地掌握它是会花很多时间的。但是，如果有经验丰富的人带你来学习，你的学习时间会大大缩短，你的学习成本也会降低很多。我相信，通过这个系列课程的学习，你不仅可以很好地掌握必备的 Linux 基础知识，也能够从中学习到很多解决实际问题的技巧，以及避免去踩很多已经被别人踩过的坑。

好了，最后欢迎你与我讨论，你现在的工作中有遇到哪些困惑吗？你对 Linux 内核理解到什么程度了呢？你可以把自己当下的起点或者疑惑记录下来，等全部学完这个课程再来回顾，相信你会有不一样的体会。

01 基础篇（一）| 如何用数据观测Page Cache？

你好，我是邵亚方。今天我想和你聊一聊 Page Cache 的话题。

Page Cache 你应该不陌生了，如果你是一名应用开发者或者 Linux 运维人员，那么在工作中，你可能遇见过与 Page Cache 有关的场景，比如：

服务器的 load 飙高；

服务器的 I/O 吞吐飙高；

业务响应时延出现大的毛刺；

业务平均访问时延明显增加。

这些问题，很可能是由于 Page Cache 管理不到位引起的，因为 Page Cache 管理不当除了会增加系统 I/O 吞吐外，还会引起业务性能抖动，我在生产环境上处理过很多这类问题。

据我观察，这类问题出现后，业务开发人员以及运维人员往往会束手无策，究其原因在于他们对 Page Cache 的理解仅仅停留在概念上，并不清楚 Page Cache 如何和应用、系统关联起来，对它引发的问题自然会束手无策了。所以，要想不再踩 Page Cache 的坑，你必须对它有个清晰的认识。

那么在我看来，认识 Page Cache 最简单的方式，就是用数据说话，通过具体的数据你会更加深入地理解 Page Cache 的本质。为了帮你消化和理解，我会用两节课的时间，用数据剖析什么是 Page Cache，为什么需要 Page Cache，Page Cache 的产生和回收是什么样的。这样一来，你会从本质到表象，透彻理解它，深切感受它和你的应用程序之间的关系，从而能更好地理解上面提到的四个问题。

不过，在这里我想给你提个醒，要学习今天的内容，你最好具备一些 Linux 编程的基础，比如，如何打开一个文件；如何读写一个文件；如何关闭一个文件等等。这样，你理解今天的内容会更加容易，当然了，不具备也没有关系，如果遇到你实在看不懂的地方，你可以查阅《UNIX 环境高级编程》这本书，它是每一位 Linux 开发者以及运维人员必看的入门书籍。

好了，话不多说，我们进入今天的学习。

什么是 Page Cache？

我记得很多应用开发者或者运维在向我寻求帮助，解决 Page Cache 引起的问题时，总是喜欢问我 Page Cache 到底是属于内核还是属于用户？针对这样的问题，我一般会让他们先看下面这张图：

应用程序产生Page Cache的逻辑示意图

通过这张图片你可以清楚地看到，红色的地方就是 Page Cache，很明显，Page Cache 是内核管理的内存，也就是说，它属于内核不属于用户。

那咱们怎么来观察 Page Cache 呢？其实，在 Linux 上直接查看 Page Cache 的方式有很多，包括 /proc/meminfo、free 、/proc/vmstat 命令等，它们的内容其实是一致的。

我们拿 /proc/meminfo 命令举例看一下（如果你想了解 /proc/meminfo 中每一项具体含义的话，可以去看Kernel Documentation的 meminfo 这一节，它详细解释了每一项的具体含义，Kernel Documentation 是应用开发者想要了解内核最简单、直接的方式）。

$ cat /proc/meminfo
...
Buffers:            1224 kB
Cached:           111472 kB
SwapCached:        36364 kB
Active:          6224232 kB
Inactive:         979432 kB
Active(anon):    6173036 kB
Inactive(anon):   927932 kB
Active(file):      51196 kB
Inactive(file):    51500 kB
...
Shmem:             10000 kB
...
SReclaimable:      43532 kB
...

根据上面的数据，你可以简单得出这样的公式（等式两边之和都是 112696 KB）：

Buffers + Cached + SwapCached = Active(file) + Inactive(file) + Shmem + SwapCached

那么等式两边的内容就是我们平时说的 Page Cache。请注意你没有看错，两边都有 SwapCached，之所以要把它放在等式里，就是说它也是 Page Cache 的一部分。

接下来，我带你分析一下这些项的具体含义。等式右边这些项把 Buffers 和 Cached 做了一下细分，分为了 Active(file)，Inactive(file) 和 Shmem，因为 Buffers 更加依赖于内核实现，在不同内核版本中它的含义可能有些不一致，而等式右边和应用程序的关系更加直接，所以我们从等式右边来分析。

在 Page Cache 中，Active(file)+Inactive(file) 是 File-backed page（与文件对应的内存页），是你最需要关注的部分。因为你平时用的 mmap() 内存映射方式和 buffered I/O 来消耗的内存就属于这部分，最重要的是，这部分在真实的生产环境上也最容易产生问题，我们在接下来的课程案例篇会重点分析它。

而 SwapCached 是在打开了 Swap 分区后，把 Inactive(anon)+Active(anon) 这两项里的匿名页给交换到磁盘（swap out），然后再读入到内存（swap in）后分配的内存。由于读入到内存后原来的 Swap File 还在，所以 SwapCached 也可以认为是 File-backed page，即属于 Page Cache。这样做的目的也是为了减少 I/O。你是不是觉得这个过程有些复杂？我们用一张图直观地看一下：

我希望你能通过这个简单的示意图明白 SwapCached 是怎么产生的。在这个过程中你要注意，SwapCached 只在 Swap 分区打开的情况下才会有，而我建议你在生产环境中关闭 Swap 分区，因为 Swap 过程产生的 I/O 会很容易引起性能抖动。

除了 SwapCached，Page Cache 中的 Shmem 是指匿名共享映射这种方式分配的内存（free 命令中 shared 这一项），比如 tmpfs（临时文件系统），这部分在真实的生产环境中产生的问题比较少，不是我们今天的重点内容，我们这节课不对它做过多关注，你知道有这回事就可以了。

当然了，很多同学也喜欢用 free 命令来查看系统中有多少 Page Cache，会根据 buff/cache 来判断存在多少 Page Cache。如果你对 free 命令有所了解的话，肯定知道 free 命令也是通过解析 /proc/meminfo 得出这些统计数据的，这些都可以通过 free 工具的源码来找到。free 命令的源码是开源，你可以去看下procfs里的 free.c 文件，源码是最直接的理解方式，它会加深你对 free 命令的理解。

不过你是否好奇过，free 命令中的 buff/cache 究竟是指什么呢？我们在这里先简单地看一下：

$ free -k
              total        used        free      shared  buff/cache   available
Mem:        7926580     7277960      492392       10000      156228      430680
Swap:       8224764      380748     7844016

通过 procfs 源码里面的proc/sysinfo.c这个文件，你可以发现 buff/cache 包括下面这几项：

buff/cache = Buffers + Cached + SReclaimable

通过前面的数据我们也可以验证这个公式: 1224 + 111472 + 43532 的和是 156228。

另外，这里你要注意，你在做比较的过程中，一定要考虑到这些数据是动态变化的，而且执行命令本身也会带来内存开销，所以这个等式未必会严格相等，不过你不必怀疑它的正确性。

从这个公式中，你能看到 free 命令中的 buff/cache 是由 Buffers、Cached 和 SReclaimable 这三项组成的，它强调的是内存的可回收性，也就是说，可以被回收的内存会统计在这一项。

其中 SReclaimable 是指可以被回收的内核内存，包括 dentry 和 inode 等。而这部分内容是内核非常细节性的东西，对于应用开发者和运维人员理解起来相对有些难度，所以我们在这里不多说。

掌握了 Page Cache 具体由哪些部分构成之后，在它引发一些问题时，你就能够知道需要去观察什么。比如说，应用本身消耗内存（RSS）不多的情况下，整个系统的内存使用率还是很高，那不妨去排查下是不是 Shmem(共享内存) 消耗了太多内存导致的。

讲到这儿，我想你应该对 Page Cache 有了一些直观的认识了吧？当然了，有的人可能会说，内核的 Page Cache 这么复杂，我不要不可以么？

我相信有这样想法的人不在少数，如果不用内核管理的 Page Cache，那有两种思路来进行处理：

第一种，应用程序维护自己的 Cache 做更加细粒度的控制，比如 MySQL 就是这样做的，你可以参考MySQL Buffer Pool ，它的实现复杂度还是很高的。对于大多数应用而言，实现自己的 Cache 成本还是挺高的，不如内核的 Page Cache 来得简单高效。

第二种，直接使用 Direct I/O 来绕过 Page Cache，不使用 Cache 了，省的去管它了。这种方法可行么？那我们继续用数据说话，看看这种做法的问题在哪儿？

为什么需要 Page Cache？

通过第一张图你其实已经可以直观地看到，标准 I/O 和内存映射会先把数据写入到 Page Cache，这样做会通过减少 I/O 次数来提升读写效率。我们看一个具体的例子。首先，我们来生成一个 1G 大小的新文件，然后把 Page Cache 清空，确保文件内容不在内存中，以此来比较第一次读文件和第二次读文件耗时的差异。具体的流程如下。

先生成一个 1G 的文件：

ddif=/dev/zeroof=/home/yafang/test/dd.outbs=4096count=((1024*256))

其次，清空 Page Cache，需要先执行一下 sync 来将脏页（第二节课，我会解释一下什么是脏页）同步到磁盘再去 drop cache。

$ sync && echo 3 > /proc/sys/vm/drop_caches

第一次读取文件的耗时如下：

$ time cat /home/yafang/test/dd.out &> /dev/null
real  0m5.733s
user  0m0.003s
sys  0m0.213s

再次读取文件的耗时如下：

$ time cat /home/yafang/test/dd.out &> /dev/null
real  0m0.132s
user  0m0.001s
sys  0m0.130s

通过这样详细的过程你可以看到，第二次读取文件的耗时远小于第一次的耗时，这是因为第一次是从磁盘来读取的内容，磁盘 I/O 是比较耗时的，而第二次读取的时候由于文件内容已经在第一次读取时被读到内存了，所以是直接从内存读取的数据，内存相比磁盘速度是快很多的。这就是 Page Cache 存在的意义：减少 I/O，提升应用的 I/O 速度。

所以，如果你不想为了很细致地管理内存而增加应用程序的复杂度，那你还是乖乖使用内核管理的 Page Cache 吧，它是 ROI(投入产出比) 相对较高的一个方案。

你要知道，我们在做方案抉择时找到一个各方面都很完美的方案还是比较难的，大多数情况下都是经过权衡后来选择一个合适的方案。因为，我一直坚信，合适的就是最好的。

而我之所以说 Page Cache 是合适的，而不是说它是最好的，那是因为 Page Cache 的不足之处也是有的，这个不足之处主要体现在，它对应用程序太过于透明，以至于应用程序很难有好方法来控制它。

为什么这么说呢？要想知道这个答案，你就需要了解 Page Cache 的产生过程，这里卖个关子，我在下一讲会跟你讨论。

课堂总结

我们这节课主要是讲述了如何很好地理解 Page Cache，在我看来，要想很好的理解它，直观的方式就是从数据入手，所以，我从如何观测 Page Cache 出发来带你认识什么是 Page Cache；然后再从它为什么容易产生问题出发，带你回顾了它存在的意义，我希望通过这样的方式，帮你明确这样几个要点：

Page Cache 是属于内核的，不属于用户。

Page Cache 对应用提升 I/O 效率而言是一个投入产出比较高的方案，所以它的存在还是有必要的。

在我看来，如何管理好 Page Cache，最主要的是你要知道如何来观测它以及观测关于它的一些行为，有了这些数据做支撑，你才能够把它和你的业务更好地结合起来。而且，在我看来，当你对某一个概念很模糊、搞不清楚它到底是什么时，最好的认知方式就是先搞明白如何来观测它，然后动手去观测看看它究竟是如何变化的，正所谓纸上得来终觉浅，绝知此事要躬行！

这节课就讲到这里，下一节我们使用数据来观察 Page Cache 的产生和释放，这样一来，你就能了解 Page Cache 的整个生命周期，从而对于它引发的一些问题能有一个大概的判断。

课后作业

最后我给你留一道思考题，请你写一个程序来构造出来 Page Cache，然后观察 /proc/meminfo 和 /proc/vmstat 里面的数据是如何变化的， 欢迎在留言区分享你的看法。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

02 基础篇（二）| Page Cache是怎样产生和释放的？

你好，我是邵亚方。

上一讲，我们主要讲了“什么是 Page Cache”（What），“为什么需要 Page Cache”（Why），我们这堂课还需要继续了解一下“How”：也就是 Page Cache 是如何产生和释放的。

在我看来，对 Page Cache 的“What-Why-How”都有所了解之后，你才会对它引发的问题，比如说 Page Cache 引起的 load 飙高问题或者应用程序的 RT 抖动问题更加了然于胸，从而防范于未然。

其实，Page Cache 是如何产生和释放的，通俗一点的说就是它的“生”（分配）与“死”（释放），即 Page Cache 的生命周期，那么接下来，我们就先来看一下它是如何“诞生”的。

Page Cache 是如何“诞生”的？

Page Cache 的产生有两种不同的方式：

Buffered I/O（标准 I/O）；

Memory-Mapped I/O（存储映射 I/O）。

这两种方式分别都是如何产生 Page Cache 的呢？来看下面这张图：

Page Cache产生方式示意图

从图中你可以看到，虽然二者都能产生 Page Cache，但是二者的还是有些差异的：

标准 I/O 是写的 (write(2)) 用户缓冲区 (Userpace Page 对应的内存)，然后再将用户缓冲区里的数据拷贝到内核缓冲区 (Pagecache Page 对应的内存)；如果是读的 (read(2)) 话则是先从内核缓冲区拷贝到用户缓冲区，再从用户缓冲区读数据，也就是 buffer 和文件内容不存在任何映射关系。

对于存储映射 I/O 而言，则是直接将 Pagecache Page 给映射到用户地址空间，用户直接读写 Pagecache Page 中内容。

显然，存储映射 I/O 要比标准 I/O 效率高一些，毕竟少了“用户空间到内核空间互相拷贝”的过程。这也是很多应用开发者发现，为什么使用内存映射 I/O 比标准 I/O 方式性能要好一些的主要原因。

我们来用具体的例子演示一下 Page Cache 是如何“诞生”的，就以其中的标准 I/O 为例，因为这是我们最常使用的一种方式，如下是一个简单的示例脚本：

#!/bin/sh

#这是我们用来解析的文件
MEM_FILE="/proc/meminfo"

#这是在该脚本中将要生成的一个新文件
NEW_FILE="/home/yafang/dd.write.out"

#我们用来解析的Page Cache的具体项
active=0
inactive=0
pagecache=0

IFS=' '

#从/proc/meminfo中读取File Page Cache的大小
function get_filecache_size()
{
        items=0
        while read line
        do
                if [[ "$line" =~ "Active:" ]]; then
                        read -ra ADDR <<<"$line"
                        active=${ADDR[1]}
                        let "items=$items+1"
                elif [[  "$line" =~ "Inactive:" ]]; then
                        read -ra ADDR <<<"$line"
                        inactive=${ADDR[1]}
                        let "items=$items+1"
                fi

                if [ $items -eq 2 ]; then
                        break;
                fi
        done < $MEM_FILE
}

#读取File Page Cache的初始大小
get_filecache_size
let filecache="$active + $inactive"

#写一个新文件，该文件的大小为1048576 KB
dd if=/dev/zero of=$NEW_FILE bs=1024 count=1048576 &> /dev/null

#文件写完后，再次读取File Page Cache的大小
get_filecache_size

#两次的差异可以近似为该新文件内容对应的File Page Cache
#之所以用近似是因为在运行的过程中也可能会有其他Page Cache产生
let size_increased="$active + $inactive - $filecache"

#输出结果
echo "File size 1048576KB, File Cache increased" $size_inc

在这里我提醒你一下，在运行该脚本前你要确保系统中有足够多的 free 内存（避免内存紧张产生回收行为），最终的测试结果是这样的：

File size 1048576KB, File Cache increased 1048648KB

通过这个脚本你可以看到，在创建一个文件的过程中，代码中 /proc/meminfo 里的 Active(file) 和 Inactive(file) 这两项会随着文件内容的增加而增加，它们增加的大小跟文件大小是一致的（这里之所以略有不同，是因为系统中还有其他程序在运行）。另外，如果你观察得很仔细的话，你会发现增加的 Page Cache 是 Inactive(File) 这一项，你可以去思考一下为什么会这样？这里就作为咱们这节课的思考题。

当然，这个过程看似简单，但是它涉及的内核机制还是很多的，换句话说，可能引起问题的地方还是很多的，我们用一张图简单描述下这个过程：

这个过程大致可以描述为：首先往用户缓冲区 buffer(这是 Userspace Page) 写入数据，然后 buffer 中的数据拷贝到内核缓冲区（这是 Pagecache Page），如果内核缓冲区中还没有这个 Page，就会发生 Page Fault 会去分配一个 Page，拷贝结束后该 Pagecache Page 是一个 Dirty Page（脏页），然后该 Dirty Page 中的内容会同步到磁盘，同步到磁盘后，该 Pagecache Page 变为 Clean Page 并且继续存在系统中。

我建议你可以将 Alloc Page 理解为 Page Cache 的“诞生”，将 Dirty Page 理解为 Page Cache 的婴幼儿时期（最容易生病的时期），将 Clean Page 理解为 Page Cache 的成年时期（在这个时期就很少会生病了）。

但是请注意，并不是所有人都有童年的，比如孙悟空，一出生就是成人了，Page Cache 也一样，如果是读文件产生的 Page Cache，它的内容跟磁盘内容是一致的，所以它一开始是 Clean Page，除非改写了里面的内容才会变成 Dirty Page（返老还童）。

就像我们为了让婴幼儿健康成长，要悉心照料他 / 她一样，为了提前发现或者预防婴幼儿时期的 Page Cache 发病，我们也需要一些手段来观测它：

$ cat /proc/vmstat | egrep "dirty|writeback"
nr_dirty 40
nr_writeback 2

如上所示，nr_dirty 表示当前系统中积压了多少脏页，nr_writeback 则表示有多少脏页正在回写到磁盘中，他们两个的单位都是 Page(4KB)。

通常情况下，小朋友们（Dirty Pages）聚集在一起（脏页积压）不会有什么问题，但在非常时期比如流感期间，就很容易导致聚集的小朋友越多病症就会越严重。与此类似，Dirty Pages 如果积压得过多，在某些情况下也会容易引发问题，至于是哪些情况，又会出现哪些问题，我们会在案例篇中具体讲解。

明白了 Page Cache 的“诞生”后，我们再来看一下 Page Cache 的“死亡”：它是如何被释放的？

Page Cache 是如何“死亡”的？

你可以把 Page Cache 的回收行为 (Page Reclaim) 理解为 Page Cache 的“自然死亡”。

言归正传，我们知道，服务器运行久了后，系统中 free 的内存会越来越少，用 free 命令来查看，大部分都会是 used 内存或者 buff/cache 内存，比如说下面这台生产环境中服务器的内存使用情况：

$ free -g
       total  used  free  shared  buff/cache available
Mem:     125    41     6       0          79        82
Swap:      0     0     0

free 命令中的 buff/cache 中的这些就是“活着”的 Page Cache，那它们什么时候会“死亡”（被回收）呢？我们来看一张图：

你可以看到，应用在申请内存的时候，即使没有 free 内存，只要还有足够可回收的 Page Cache，就可以通过回收 Page Cache 的方式来申请到内存，回收的方式主要是两种：直接回收和后台回收。

那它是具体怎么回收的呢？你要怎么观察呢？其实在我看来，观察 Page Cache 直接回收和后台回收最简单方便的方式是使用 sar：

$ sar -B 1
02:14:01 PM  pgpgin/s pgpgout/s   fault/s  majflt/s  pgfree/s pgscank/s pgscand/s pgsteal/s    %vmeff

02:14:01 PM      0.14    841.53 106745.40      0.00  41936.13      0.00      0.00      0.00      0.00
02:15:01 PM      5.84    840.97  86713.56      0.00  43612.15    717.81      0.00    717.66     99.98
02:16:01 PM     95.02    816.53 100707.84      0.13  46525.81   3557.90      0.00   3556.14     99.95
02:17:01 PM     10.56    901.38 122726.31      0.27  54936.13   8791.40      0.00   8790.17     99.99
02:18:01 PM    108.14    306.69  96519.75      1.15  67410.50  14315.98     31.48  14319.38     99.80
02:19:01 PM      5.97    489.67  88026.03      0.18  48526.07   1061.53      0.00   1061.42     99.99

借助上面这些指标，你可以更加明确地观察内存回收行为，下面是这些指标的具体含义：

pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数。

pgscand/s: Application 在内存申请过程中每秒直接扫描的 page 个数。

pgsteal/s: 扫描的 page 中每秒被回收的个数。

%vmeff: pgsteal/(pgscank+pgscand), 回收效率，越接近 100 说明系统越安全，越接近 0 说明系统内存压力越大。

这几个指标也是通过解析 /proc/vmstat 里面的数据来得出的，对应关系如下：

关于这几个指标我说一个小插曲，要知道，如果 Linux Kernel 本身设计不当会给你带来困扰。所以，如果你观察到应用程序的结果跟你的预期并不一致，也有可能是因为内核设计上存在问题，你可以对内核持适当的怀疑态度哦，下面这个是我最近遇到的一个案例。

如果你对 Linus 有所了解的话，应该会知道 Linus 对 Linux Kernel 设计的第一原则是“never break the user space”。很多内核开发者在设计内核特性的时候，会忽略掉新特性对应用程序的影响，比如在前段时间就有人 (Google 的一个内核开发者) 提交了一个 patch 来修改内存回收这些指标的含义，但是最终被我和另外一个人 (Facebook 的一个内核开发者) 把他的这个改动给否决掉了。具体的细节并不是咱们这节课的重点，我就不多说了，我建议你在课下看这个讨论：[PATCH] mm: vmscan: consistent update to pgsteal and pgscan，可以看一下内核开发者们在设计内核特性时是如何思考的，这会有利于你更加全面的去了解整个系统，从而让你的应用程序更好地融入到系统中。

课堂总结

以上就是本节课的全部内容了，本节课，我们主要讲了 Page Caceh 是如何“诞生”的，以及如何“死亡”的，我要强调这样几个重点：

Page Cache 是在应用程序读写文件的过程中产生的，所以在读写文件之前你需要留意是否还有足够的内存来分配 Page Cache；

Page Cache 中的脏页很容易引起问题，你要重点注意这一块；

在系统可用内存不足的时候就会回收 Page Cache 来释放出来内存，我建议你可以通过 sar 或者 /proc/vmstat 来观察这个行为从而更好的判断问题是否跟回收有关

总的来说，Page Cache 的生命周期对于应用程序而言是相对比较透明的，即它的分配与回收都是由操作系统来进行管理的。正是因为这种“透明”的特征，所以应用程序才会难以控制 Page Cache，Page Cache 才会容易引发那么多问题。在接下来的案例篇里，我们就来看看究竟会引发什么样子的问题，以及你正确的分析思路是什么样子的。

课后作业

因为每个人的关注点都不一样，对问题的理解也不一样。假如你是一个应用开发者，你会更加关注应用的性能和稳定性；假如你是一个运维人员，你会更加关注系统的稳定性；假如你是初学内核的开发者，你会想要关注内核的实现机制。

所以我留了不同的作业题，主题是围绕“Inactive 与 Active Page Cache 的关系”当然了，对应的难度也不同：

如果你是一名应用开发者，那么我想问问你为什么第一次读写某个文件，Page Cache 是 Inactive 的？如何让它变成 Active 的呢？在什么情况下 Active 的又会变成 Inactive 的呢？明白了这个问题，你会对应用性能调优有更加深入的理解。

如果你是一名运维人员，那么建议你思考一下，系统中有哪些控制项可以影响 Inactive 与 Active Page Cache 的大小或者二者的比例？

如果你是一名初学内核的开发者，那么我想问你，对于匿名页而言，当产生一个匿名页后它会首先放在 Active 链表上；而对于文件页而言，当产生一个文件页后它会首先放在 Inactive 链表上。请问为什么会这样子？这是合理的吗？欢迎在留言区分享你的看法。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

03 案例篇 | 如何处理Page Cache难以回收产生的load飙高问题？

你好，我是邵亚方。今天这节课，我想跟你聊一聊怎么处理在生产环境中，因为 Page Cache 管理不当引起的系统 load 飙高的问题。

相信你在平时的工作中，应该会或多或少遇到过这些情形：系统很卡顿，敲命令响应非常慢；应用程序的 RT 变得很高，或者抖动得很厉害。在发生这些问题时，很有可能也伴随着系统 load 飙得很高。

那这是什么原因导致的呢？据我观察，大多是有三种情况：

直接内存回收引起的 load 飙高；

系统中脏页积压过多引起的 load 飙高；

系统 NUMA 策略配置不当引起的 load 飙高。

这是应用开发者和运维人员向我咨询最多的几种情况。问题看似很简单，但如果对问题产生的原因理解得不深，解决起来这类问题就会很棘手，甚至配置得不好，还会带来负面的影响。

所以这节课，我们一起来分析下这三种情况，可以说，搞清楚了这几种情况，你差不多就能解决掉绝大部分 Page Cache 引起的 load 飙高问题了。如果你对问题的原因排查感兴趣，也不要着急，在第 5 讲，我会带你学习 load 飙高问题的分析方法。

接下来，我们就来逐一分析下这几类情况。

直接内存回收引起 load 飙高或者业务时延抖动

直接内存回收是指在进程上下文同步进行内存回收，那么直接内存回收具体是怎么引起 load 飙高的呢？

因为直接内存回收是在进程申请内存的过程中同步进行的回收，而这个回收过程可能会消耗很多时间，进而导致进程的后续行为都被迫等待，这样就会造成很长时间的延迟，以及系统的 CPU 利用率会升高，最终引起 load 飙高。

我们详细地描述一下这个过程，为了尽量不涉及太多技术细节，我会用一张图来表示，这样你理解起来会更容易。

内存回收过程

从图里你可以看到，在开始内存回收后，首先进行后台异步回收（上图中蓝色标记的地方），这不会引起进程的延迟；如果后台异步回收跟不上进行内存申请的速度，就会开始同步阻塞回收，导致延迟（上图中红色和粉色标记的地方，这就是引起 load 高的地址）。

那么，针对直接内存回收引起 load 飙高或者业务 RT 抖动的问题，一个解决方案就是及早地触发后台回收来避免应用程序进行直接内存回收，那具体要怎么做呢？

我们先来了解一下后台回收的原理，如图：

它的意思是：当内存水位低于 watermark low 时，就会唤醒 kswapd 进行后台回收，然后 kswapd 会一直回收到 watermark high。

那么，我们可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收，该选项最终控制的是内存回收水位，不过，内存回收水位是内核里面非常细节性的知识点我们可以先不去讨论。

vm.min_free_kbytes = 4194304

对于大于等于 128G 的系统而言，将 min_free_kbytes 设置为 4G 比较合理，这是我们在处理很多这种问题时总结出来的一个经验值，既不造成较多的内存浪费，又能避免掉绝大多数的直接内存回收。

该值的设置和总的物理内存并没有一个严格对应的关系，我们在前面也说过，如果配置不当会引起一些副作用，所以在调整该值之前，我的建议是：你可以渐进式地增大该值，比如先调整为 1G，观察 sar -B 中 pgscand 是否还有不为 0 的情况；如果存在不为 0 的情况，继续增加到 2G，再次观察是否还有不为 0 的情况来决定是否增大，以此类推。

在这里你需要注意的是，即使将该值增加得很大，还是可能存在 pgscand 不为 0 的情况（这个略复杂，涉及到内存碎片和连续内存申请，我们在此先不展开，你知道有这么回事儿就可以了）。那么这个时候你要考虑的是，业务是否可以容忍，如果可以容忍那就没有必要继续增加了，也就是说，增大该值并不是完全避免直接内存回收，而是尽量将直接内存回收行为控制在业务可以容忍的范围内。

这个方法可以用在 3.10.0 以后的内核上（对应的操作系统为 CentOS-7 以及之后更新的操作系统）。

当然了，这样做也有一些缺陷：提高了内存水位后，应用程序可以直接使用的内存量就会减少，这在一定程度上浪费了内存。所以在调整这一项之前，你需要先思考一下，应用程序更加关注什么，如果关注延迟那就适当地增大该值，如果关注内存的使用量那就适当地调小该值。

除此之外，对 CentOS-6(对应于 2.6.32 内核版本) 而言，还有另外一种解决方案：

vm.extra_free_kbytes = 4194304

那就是将 extra_free_kbytes 配置为 4G。extra_free_kbytes 在 3.10 以及以后的内核上都被废弃掉了，不过由于在生产环境中还存在大量的机器运行着较老版本内核，你使用到的也可能会是较老版本的内核，所以在这里还是有必要提一下。它的大致原理如下所示：

extra_free_kbytes 的目的是为了解决 min_free_kbyte 造成的内存浪费，但是这种做法并没有被内核主线接收，因为这种行为很难维护会带来一些麻烦，感兴趣的可以看一下这个讨论：add extra free kbytes tunable

总的来说，通过调整内存水位，在一定程度上保障了应用的内存申请，但是同时也带来了一定的内存浪费，因为系统始终要保障有这么多的 free 内存，这就压缩了 Page Cache 的空间。调整的效果你可以通过 /proc/zoneinfo 来观察：

$ egrep "min|low|high" /proc/zoneinfo
...
        min      7019
        low      8773
        high     10527
...

其中 min、low、high 分别对应上图中的三个内存水位。你可以观察一下调整前后 min、low、high 的变化。需要提醒你的是，内存水位是针对每个内存 zone 进行设置的，所以 /proc/zoneinfo 里面会有很多 zone 以及它们的内存水位，你可以不用去关注这些细节。

系统中脏页过多引起 load 飙高

接下来，我们分析下由于系统脏页过多引起 load 飙高的情况。在前一个案例中我们也提到，直接回收过程中，如果存在较多脏页就可能涉及在回收过程中进行回写，这可能会造成非常大的延迟，而且因为这个过程本身是阻塞式的，所以又可能进一步导致系统中处于 D 状态的进程数增多，最终的表现就是系统的 load 值很高。

我们来看一下这张图，这是一个典型的脏页引起系统 load 值飙高的问题场景：

如图所示，如果系统中既有快速 I/O 设备，又有慢速 I/O 设备（比如图中的 ceph RBD 设备，或者其他慢速存储设备比如 HDD），直接内存回收过程中遇到了正在往慢速 I/O 设备回写的 page，就可能导致非常大的延迟。

这里我多说一点。这类问题其实是不太好去追踪的，为了更好追踪这种慢速 I/O 设备引起的抖动问题，我也给 Linux Kernel 提交了一个 patch 来进行更好的追踪：mm/page-writeback: introduce tracepoint for wait_on_page_writeback()，这种做法是在原来的基础上增加了回写的设备，这样子用户就能更好地将回写和具体设备关联起来，从而判断问题是否是由慢速 I/O 设备导致的（具体的分析方法我会在后面第 5 讲分析篇里重点来讲）。

那如何解决这类问题呢？一个比较省事的解决方案是控制好系统中积压的脏页数据。很多人知道需要控制脏页，但是往往并不清楚如何来控制好这个度，脏页控制的少了可能会影响系统整体的效率，脏页控制的多了还是会触发问题，所以我们接下来看下如何来衡量好这个“度”。

首先你可以通过 sar -r 来观察系统中的脏页个数：

$ sar -r 1
07:30:01 PM kbmemfree kbmemused  %memused kbbuffers  kbcached  kbcommit   %commit  kbactive   kbinact   kbdirty
09:20:01 PM   5681588   2137312     27.34         0   1807432    193016      2.47    534416   1310876         4
09:30:01 PM   5677564   2141336     27.39         0   1807500    204084      2.61    539192   1310884        20
09:40:01 PM   5679516   2139384     27.36         0   1807508    196696      2.52    536528   1310888        20
09:50:01 PM   5679548   2139352     27.36         0   1807516    196624      2.51    536152   1310892        24

kbdirty 就是系统中的脏页大小，它同样也是对 /proc/vmstat 中 nr_dirty 的解析。你可以通过调小如下设置来将系统脏页个数控制在一个合理范围:

vm.dirty_background_bytes = 0

vm.dirty_background_ratio = 10

vm.dirty_bytes = 0

vm.dirty_expire_centisecs = 3000

vm.dirty_ratio = 20

调整这些配置项有利有弊，调大这些值会导致脏页的积压，但是同时也可能减少了 I/O 的次数，从而提升单次刷盘的效率；调小这些值可以减少脏页的积压，但是同时也增加了 I/O 的次数，降低了 I/O 的效率。

至于这些值调整大多少比较合适，也是因系统和业务的不同而异，我的建议也是一边调整一边观察，将这些值调整到业务可以容忍的程度就可以了，即在调整后需要观察业务的服务质量 (SLA)，要确保 SLA 在可接受范围内。调整的效果你可以通过 /proc/vmstat 来查看：

$ grep "nr_dirty_" /proc/vmstat
nr_dirty_threshold 366998
nr_dirty_background_threshold 183275

你可以观察一下调整前后这两项的变化。这里我要给你一个避免踩坑的提示，解决该方案中的设置项如果设置不妥会触发一个内核 Bug，这是我在 2017 年进行性能调优时发现的一个内核 Bug，我给社区提交了一个 patch 将它 fix 掉了，具体的 commit 见writeback: schedule periodic writeback with sysctl , commit log 清晰地描述了该问题，我建议你有时间看一看。

系统 NUMA 策略配置不当引起的 load 飙高

除了我前面提到的这两种引起系统 load 飙高或者业务延迟抖动的场景之外，还有另外一种场景也会引起 load 飙高，那就是系统 NUMA 策略配置不当引起的 load 飙高。

比如说，我们在生产环境上就曾经遇到这样的问题：系统中还有一半左右的 free 内存，但还是频频触发 direct reclaim，导致业务抖动得比较厉害。后来经过排查发现是由于设置了 zone_reclaim_mode，这是 NUMA 策略的一种。

设置 zone_reclaim_mode 的目的是为了增加业务的 NUMA 亲和性，但是在实际生产环境中很少会有对 NUMA 特别敏感的业务，这也是为什么内核将该配置从默认配置 1 修改为了默认配置 0: mm: disable zone_reclaim_mode by default ，配置为 0 之后，就避免了在其他 node 有空闲内存时，不去使用这些空闲内存而是去回收当前 node 的 Page Cache，也就是说，通过减少内存回收发生的可能性从而避免它引发的业务延迟。

那么如何来有效地衡量业务延迟问题是否由 zone reclaim 引起的呢？它引起的延迟究竟有多大呢？这个衡量和观察方法也是我贡献给 Linux Kernel 的：mm/vmscan: add tracepoints for node reclaim ，大致的思路就是利用 linux 的 tracepoint 来做这种量化分析，这是性能开销相对较小的一个方案。

我们可以通过 numactl 来查看服务器的 NUMA 信息，如下是两个 node 的服务器：

$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27 28 29 30 31 32 33 34 35
node 0 size: 130950 MB
node 0 free: 108256 MB
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39 40 41 42 43 44 45 46 47
node 1 size: 131072 MB
node 1 free: 122995 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

其中 CPU0～11，24～35 的 local node 为 node 0；而 CPU12～23，36～47 的 local node 为 node 1。如下图所示：

推荐将 zone_reclaim_mode 配置为 0。

vm.zone_reclaim_mode = 0

因为相比内存回收的危害而言，NUMA 带来的性能提升几乎可以忽略，所以配置为 0，利远大于弊。

好了，对于 Page Cache 管理不当引起的系统 load 飙高和业务时延抖动问题，我们就分析到这里，希望通过这篇的学习，在下次你遇到直接内存回收引起的 load 飙高问题时不再束手无策。

总的来说，这些问题都是 Page Cache 难以释放而产生的问题，那你是否想过，是不是 Page Cache 很容易释放就不会产生问题了？这个答案可能会让你有些意料不到：Page Cache 容易释放也有容易释放的问题。这到底是怎么回事呢，我们下节课来分析下这方面的案例。

课堂总结

这节课我们讲的这几个案例都是内存回收过程中引起的 load 飙高问题。关于内存回收这事，我们可以做一个形象的类比。我们知道，内存是操作系统中很重要的一个资源，它就像我们在生活过程中很重要的一个资源——钱一样，如果你的钱（内存）足够多，那想买什么就可以买什么，而不用担心钱花完（内存用完）后要吃土（引起 load 飙高）。

但是现实情况是我们每个人用来双十一购物的钱（内存）总是有限的，在买东西（运行程序）的时候总需要精打细算，一旦预算快超了（内存快不够了），就得把一些不重要的东西（把一些不活跃的内容）从购物车里删除掉（回收掉），好腾出资金（空闲的内存）来买更想买的东西（运行需要运行的程序）。

我们讲的这几个案例都可以通过调整系统参数 / 配置来解决，调整系统参数 / 配置也是应用开发者和运维人员在发生了内核问题时所能做的改动。比如说，直接内存回收引起 load 飙高时，就去调整内存水位设置；脏页积压引起 load 飙高时，就需要去调整脏页的水位；NUMA 策略配置不当引起 load 飙高时，就去检查是否需要关闭该策略。同时我们在做这些调整的时候，一定要边调整边观察业务的服务质量，确保 SLA 是可以接受的。

如果你想要你的系统更加稳定，你的业务性能更好，你不妨去研究一下系统中的可配置项，看看哪些配置可以帮助你的业务。

课后作业

这节课我给你布置的作业是针对直接内存回收的，现在你已经知道直接内存回收容易产生问题，是我们需要尽量避免的，那么我的问题是：请你执行一些模拟程序来构造出直接内存回收的场景（小提示： 你可以通过 sar -B 中的 pgscand 来判断是否有了直接内存回收）。欢迎在留言区分享你的看法。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

04 案例篇 | 如何处理Page Cache容易回收引起的业务性能问题？

你好，我是邵亚方。我们在前一节课讲了 Page Cache 难以回收导致的 load 飙高问题，这类问题是很直观的，相信很多人都遭遇过。这节课，我们则是来讲相反的一些问题，即 Page Cache 太容易回收而引起的一些问题。

这类问题因为不直观所以陷阱会很多，应用开发者和运维人员会更容易踩坑，也正因为这类问题不直观，所以他们往往是一而再再而三地中招之后，才搞清楚问题到底是怎么回事。

我把大家经常遇到的这类问题做个总结，大致可以分为两方面：

误操作而导致 Page Cache 被回收掉，进而导致业务性能下降明显；

内核的一些机制导致业务 Page Cache 被回收，从而引起性能下降。

如果你的业务对 Page Cache 比较敏感，比如说你的业务数据对延迟很敏感，或者再具体一点，你的业务指标对 TP99（99 分位）要求较高，那你对于这类性能问题应该多多少少有所接触。当然，这并不意味着业务对延迟不敏感，你就不需要关注这些问题了，关注这类问题会让你对业务行为理解更深刻。

言归正传，我们来看下发生在生产环境中的案例。

对 Page Cache 操作不当产生的业务性能下降

我们先从一个相对简单的案例说起，一起分析下误操作导致 Page Cache 被回收掉的情况，它具体是怎样发生的。

我们知道，对于 Page Cache 而言，是可以通过 drop_cache 来清掉的，很多人在看到系统中存在非常多的 Page Cache 时会习惯使用 drop_cache 来清理它们，但是这样做是会有一些负面影响的，比如说这些 Page Cache 被清理掉后可能会引起系统性能下降。为什么？

其实这和 inode 有关，那 inode 是什么意思呢？inode 是内存中对磁盘文件的索引，进程在查找或者读取文件时就是通过 inode 来进行操作的，我们用下面这张图来表示一下这种关系：

如上图所示，进程会通过 inode 来找到文件的地址空间（address_space），然后结合文件偏移（会转换成 page index）来找具体的 Page。如果该 Page 存在，那就说明文件内容已经被读取到了内存；如果该 Page 不存在那就说明不在内存中，需要到磁盘中去读取。你可以理解为 inode 是 Pagecache Page（页缓存的页）的宿主（host），如果 inode 不存在了，那么 PageCache Page 也就不存在了。

如果你使用过 drop_cache 来释放 inode 的话，应该会清楚它有几个控制选项，我们可以通过写入不同的数值来释放不同类型的 cache（用户数据 Page Cache，内核数据 Slab，或者二者都释放），这些选项你可以去看Kernel Documentation 的描述。

于是这样就引入了一个容易被我们忽略的问题：当我们执行 echo 2 来 drop slab 的时候，它也会把 Page Cache 给 drop 掉，很多运维人员都会忽视掉这一点。

在系统内存紧张的时候，运维人员或者开发人员会想要通过 drop_caches 的方式来释放一些内存，但是由于他们清楚 Page Cache 被释放掉会影响业务性能，所以就期望只去 drop slab 而不去 drop pagecache。于是很多人这个时候就运行 echo 2 > /proc/sys/vm/drop_caches，但是结果却出乎了他们的意料：Page Cache 也被释放掉了，业务性能产生了明显的下降。

很多人都遇到过这个场景：系统正在运行着，忽然 Page Cache 被释放掉了，由于不清楚释放的原因，所以很多人就会怀疑，是不是由其他人 / 程序执行了 drop_caches 导致的。那有没有办法来观察这个 inode 释放引起 Page Cache 被释放的行为呢？答案是有的。关于这一点，我们在下一节课会讲到。我们先来分析下如何观察是否有人或者有程序执行过 drop_caches。

由于 drop_caches 是一种内存事件，内核会在 /proc/vmstat 中来记录这一事件，所以我们可以通过 /proc/vmstat 来判断是否有执行过 drop_caches。

$ grep drop /proc/vmstat
drop_pagecache 3
drop_slab 2

如上所示，它们分别意味着 pagecache 被 drop 了 3 次（通过 echo 1 或者 echo 3），slab 被 drop 了 2 次（通过 echo 2 或者 echo 3）。如果这两个值在问题发生前后没有变化，那就可以排除是有人执行了 drop_caches；否则可以认为是因为 drop_caches 引起的 Page Cache 被回收。

针对这类问题，你除了在执行 drop cache 前三思而后行之外，还有其他的一些根治的解决方案。在讲这些解决方案之前，我们先来看一个更加复杂一点的案例，它们有一些共性，解决方案也类似，只是接下来这个案例涉及的内核机制更加复杂。

内核机制引起 Page Cache 被回收而产生的业务性能下降

我们在前面已经提到过，在内存紧张的时候会触发内存回收，内存回收会尝试去回收 reclaimable（可以被回收的）内存，这部分内存既包含 Page Cache 又包含 reclaimable kernel memory(比如 slab)。我们可以用下图来简单描述这个过程：

我简单来解释一下这个图。Reclaimer 是指回收者，它可以是内核线程（包括 kswapd）也可以是用户线程。回收的时候，它会依次来扫描 pagecache page 和 slab page 中有哪些可以被回收的，如果有的话就会尝试去回收，如果没有的话就跳过。在扫描可回收 page 的过程中回收者一开始扫描的较少，然后逐渐增加扫描比例直至全部都被扫描完。这就是内存回收的大致过程。

接下来我所要讲述的案例就发生在“relcaim slab”中，我们从前一个案例已然知道，如果 inode 被回收的话，那么它对应的 Page Cache 也都会被回收掉，所以如果业务进程读取的文件对应的 inode 被回收了，那么该文件所有的 Page Cache 都会被释放掉，这也是容易引起性能问题的地方。

那这个行为是否有办法观察？这同样也是可以通过 /proc/vmstat 来观察的，/proc/vmstat 简直无所不能（这也是为什么我会在之前说内核开发者更习惯去观察 /proc/vmstat）。

 $ grep inodesteal /proc/vmstat
 pginodesteal 114341
 kswapd_inodesteal 1291853

这个行为对应的事件是 inodesteal，就是上面这两个事件，其中 kswapd_inodesteal 是指在 kswapd 回收的过程中，因为回收 inode 而释放的 pagecache page 个数；pginodesteal 是指 kswapd 之外其他线程在回收过程中，因为回收 inode 而释放的 pagecache page 个数。所以在你发现业务的 Page Cache 被释放掉后，你可以通过观察来发现是否因为该事件导致的。

在明白了 Page Cache 被回收掉是如何发生的，以及知道了该如何观察之后，我们来看下该如何解决这类问题。

如何避免 Page Cache 被回收而引起的性能问题？

我们在分析一些问题时，往往都会想这个问题是我的模块有问题呢，还是别人的模块有问题。也就是说，是需要修改我的模块来解决问题还是需要修改其他模块来解决问题。与此类似，避免 Page Cache 里相对比较重要的数据被回收掉的思路也是有两种：

从应用代码层面来优化；

从系统层面来调整。

从应用程序代码层面来解决是相对比较彻底的方案，因为应用更清楚哪些 Page Cache 是重要的，哪些是不重要的，所以就可以明确地来对读写文件过程中产生的 Page Cache 区别对待。比如说，对于重要的数据，可以通过 mlock(2) 来保护它，防止被回收以及被 drop；对于不重要的数据（比如日志），那可以通过 madvise(2) 告诉内核来立即释放这些 Page Cache。

我们来看一个通过 mlock(2) 来保护重要数据防止被回收或者被 drop 的例子：

#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>

#define FILE_NAME "/home/yafang/test/mmap/data"
#define SIZE (1024*1000*1000)

int main()
{
        int fd;
        char *p;
        int ret;

        fd = open(FILE_NAME, O_CREAT|O_RDWR, S_IRUSR|S_IWUSR);
        if (fd < 0)
                return -1;

        /* Set size of this file */
        ret = ftruncate(fd, SIZE);
        if (ret < 0)
                return -1;

        /* The current offset is 0, so we don't need to reset the offset. */
        /* lseek(fd, 0, SEEK_CUR); */

        /* Mmap virtual memory */
        p = mmap(0, SIZE, PROT_READ|PROT_WRITE, MAP_FILE|MAP_SHARED, fd, 0);
        if (!p)
                return -1;

        /* Alloc physical memory */
        memset(p, 1, SIZE);

        /* Lock these memory to prevent from being reclaimed */
        mlock(p, SIZE);

        /* Wait until we kill it specifically */
        while (1) {
                sleep(10);
        }

        /*
         * Unmap the memory.
         * Actually the kernel will unmap it automatically after the
         * process exits, whatever we call munamp() specifically or not.
         */
        munmap(p, SIZE);

        return 0;
}

在这个例子中，我们通过 mlock(2) 来锁住了读 FILE_NAME 这个文件内容对应的 Page Cache。在运行上述程序之后，我们来看下该如何来观察这种行为：确认这些 Page Cache 是否被保护住了，被保护了多大。这同样可以通过 /proc/meminfo 来观察:

$ egrep "Unevictable|Mlocked" /proc/meminfo
Unevictable:     1000000 kB
Mlocked:         1000000 kB

然后你可以发现，drop_caches 或者内存回收是回收不了这些内容的，我们的目的也就达到了。

在有些情况下，对应用程序而言，修改源码是件比较麻烦的事，如果可以不修改源码来达到目的那就最好不过了。Linux 内核同样实现了这种不改应用程序的源码而从系统层面调整来保护重要数据的机制，这个机制就是 memory cgroup protection。

它大致的思路是，将需要保护的应用程序使用 memory cgroup 来保护起来，这样该应用程序读写文件过程中所产生的 Page Cache 就会被保护起来不被回收或者最后被回收。memory cgroup protection 大致的原理如下图所示：

如上图所示，memory cgroup 提供了几个内存水位控制线 memory.{min, low, high, max} 。

memory.max

这是指 memory cgroup 内的进程最多能够分配的内存，如果不设置的话，就默认不做内存大小的限制。

memory.high

如果设置了这一项，当 memory cgroup 内进程的内存使用量超过了该值后就会立即被回收掉，所以这一项的目的是为了尽快的回收掉不活跃的 Page Cache。

memory.low

这一项是用来保护重要数据的，当 memory cgroup 内进程的内存使用量低于了该值后，在内存紧张触发回收后就会先去回收不属于该 memory cgroup 的 Page Cache，等到其他的 Page Cache 都被回收掉后再来回收这些 Page Cache。

memory.min

这一项同样是用来保护重要数据的，只不过与 memoy.low 有所不同的是，当 memory cgroup 内进程的内存使用量低于该值后，即使其他不在该 memory cgroup 内的 Page Cache 都被回收完了也不会去回收这些 Page Cache，可以理解为这是用来保护最高优先级的数据的。

那么，如果你想要保护你的 Page Cache 不被回收，你就可以考虑将你的业务进程放在一个 memory cgroup 中，然后设置 memory.{min,low} 来进行保护；与之相反，如果你想要尽快释放你的 Page Cache，那你可以考虑设置 memory.high 来及时的释放掉不活跃的 Page Cache。

更加细节性的一些设置我们就不在这里讨论了，我建议你可以自己动手来设置后观察一下，这样你理解会更深刻。

课堂总结

我们在前一篇讲到了 Page Cache 回收困难引起的 load 飙高问题，这也是很直观的一类问题；在这一篇讲述的则是一类相反的问题，即 Page Cache 太容易被回收而引起的一些问题，这一类问题是不那么直观的一类问题。

对于很直观的问题，我们相对比较容易去观察分析，而且由于它们比较容易观察，所以也相对能够得到重视；对于不直观的问题，则不是那么容易观察分析，相对而言它们也容易被忽视。

外在的特征不明显，并不意味着它产生的影响不严重，就好比皮肤受伤流血了，我们知道需要立即止血这个伤病也能很容易得到控制；如果是内伤，比如心肝脾肺肾有了问题，则容易被忽视，但是这些问题一旦积压久了往往造成很严重的后果。所以对于这类不直观的问题，我们还是需要去重视它，而且尽量做到提前预防，比如说：

如果你的业务对 Page Cache 所造成的延迟比较敏感，那你最好可以去保护它，比如通过 mlock 或者 memory cgroup 来对它们进行保护；

在你不明白 Page Cache 是因为什么原因被释放时，你可以通过 /proc/vmstat 里面的一些指标来观察，找到具体的释放原因然后再对症下药的去做优化。

课后作业

这节课给你布置的作业是与 mlock 相关的，请你思考下，进程调用 mlock() 来保护内存，然后进程没有运行 munlock() 就退出了，在进程退出后，这部分内存还被保护吗，为什么？欢迎在留言区分享你的看法。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

05 分析篇 | 如何判断问题是否由Page Cache产生的？

你好，我是邵亚方。

在前面几节课里，我们讲了 Page Cache 的一些基础知识，以及如何去处理 Page Cache 引发的一些问题。这节课我们来讲讲，如何判断问题是不是由 Page Cache 引起的。

我们知道，一个问题往往牵扯到操作系统的很多模块，比如说，当系统出现 load 飙高的问题时，可能是 Page Cache 引起的；也可能是锁冲突太厉害，物理资源（CPU、内存、磁盘 I/O、网络 I/O）有争抢导致的；也可能是内核特性设计缺陷导致的，等等。

如果我们没有判断清楚问题是如何引起的而贸然采取措施，非但无法解决问题，反而会引起其他负面影响，比如说，load 飙高本来是 Page Cache 引起的，如果你没有查清楚原因，而误以为是网络引起的，然后对网络进行限流，看起来把问题解决了，但是系统运行久了还是会出现 load 飙高，而且限流这种行为还降低了系统负载能力。

那么当问题发生时，我们如何判断它是不是由 Page Cache 引起的呢？

Linux 问题的典型分析手段

Linux 上有一些典型的问题分析手段，从这些基本的分析方法入手，你可以一步步判断出问题根因。这些分析手段，可以简单地归纳为下图：

Linux的典型分析手段

从这张图中我们可以看到，Linux 内核主要是通过 /proc 和 /sys 把系统信息导出给用户，当你不清楚问题发生的原因时，你就可以试着去这几个目录下读取一下系统信息，看看哪些指标异常。比如当你不清楚问题是否由 Page Cache 引起时，你可以试着去把 /proc/vmstat 里面的信息给读取出来，看看哪些指标单位时间内变化较大。如果 pgscan 相关指标变化较大，那就可能是 Page Cache 引起的，因为 pgscan 代表了 Page Cache 的内存回收行为，它变化较大往往意味着系统内存压力很紧张。

/proc 和 /sys 里面的信息可以给我们指出一个问题分析的大致方向，我们可以判断出问题是不是由 Page Cache 引起的，但是如果想要深入地分析问题，知道 Page Cache 是如何引起问题的，我们还需要掌握更加专业的分析手段，专业的分析工具有 ftrace，ebpf，perf 等。

当然了，这些专业工具的学习成本也相对略高一些，但你不能觉得它难、成本高，就不学了，因为掌握了这些分析工具后，再遇到疑难杂症，你分析起来会更加得心应手。

为了让你在遇到问题时更加方便地找到合适的分析工具，我借用Bredan Gregg 的一张图，并根据自己的经验，把这张图略作了一些改进，帮助你学习该如何使用这些分析工具：

在这张图里，整体上追踪方式分为了静态追踪（预置了追踪点）和动态追踪（需要借助 probe）：

如果你想要追踪的东西已经有了预置的追踪点，那你直接使用这些预置追踪点就可以了；

如果没有预置追踪点，那你就要看看是否可以使用 probe(包括 kprobe 和 uprobe) 来实现。

因为分析工具自身也会对业务造成一些影响（Heisenbug），比如说使用 strace 会阻塞进程的运行，再比如使用 systemtap 也会有加载编译的开销等，所以我们在使用这些工具之前也需要去详细了解下这些工具的副作用，以免引起意料之外的问题。

比如我多年以前在使用 systemtap 的 guru（专家）模式的时候，因为没有考虑到 systemtap 进程异常退出后，可能不会卸载 systemtap 模块从而引发系统 panic 的问题。

上面这些就是 Linux 问题的一些典型分析方法，了解了这些分析方法，你再遇到问题就能知道该选择什么样的工具来去分析。对于 Page Cache 而言，首先我们可以通过 /proc/vmstat 来做一个大致判断，然后再结合 Page Cache 的 tracepoint 来做更加深入的分析。

接下来我们一起分析两个具体问题。

系统现在 load 很高，是由 Page Cache 引起的吗？

我相信你肯定会遇到过这种场景：业务一直稳定运行着却忽然出现很大的性能抖动，或者系统一直稳定运行着却忽然出现较高的 load 值，那怎么去判断这个问题是不是由 Page Cache 引起的呢？在这里，我根据自己多年的经验，总结了一些分析的步骤。

分析问题的第一步，就是需要对系统的概括做一个了解，对于 Page Cahe 相关的问题，我推荐你使用 sar 来采集 Page Cache 的概况，它是系统默认配置好的工具，使用起来非常简单方便。

我在课程的第 1 讲也提到了对 sar 的一些使用：比如通过 sar -B 来分析分页信息 (Paging statistics)， 以及 sar -r 来分析内存使用情况统计 (Memory utilization statistics) 等。在这里，我特别推荐你使用 sar 里面记录的 PSI（Pressure-Stall Information）信息来查看 Page Cache 产生压力情况，尤其是给业务产生的压力，而这些压力最终都会体现在 load 上。不过该功能需要 4.20 以上的内核版本才支持，同时 sar 的版本也要更新到 12.3.3 版本以上。比如 PSI 中表示内存压力的如下输出：

some avg10=45.49 avg60=10.23 avg300=5.41 total=76464318
full avg10=40.87 avg60=9.05 avg300=4.29 total=58141082

你需要重点关注 avg10 这一列，它表示最近 10s 内存的平均压力情况，如果它很大（比如大于 40）那 load 飙高大概率是由于内存压力，尤其是 Page Cache 的压力引起的。

明白了概况之后，我们还需要进一步查看究竟是 Page Cache 的什么行为引起的系统压力。

因为 sar 采集的只是一些常用的指标，它并没有覆盖 Page Cache 的所有行为，比如说内存规整（memory compaction）、业务 workingset 等这些容易引起 load 飙高的问题点。在我们想要分析更加具体的原因时，就需要去采集这些指标了。通常在 Page Cache 出问题时，这些指标中的一个或多个都会有异常，这里我给你列出一些常见指标：

采集完这些指标后，我们就可以分析 Page Cache 异常是由什么引起的了。比如说，当我们发现，单位时间内 compact_fail 变化很大时，那往往意味着系统内存碎片很严重，已经很难申请到连续物理内存了，这时你就需要去调整碎片指数或者手动触发内存规整，来减缓因为内存碎片引起的压力了。

我们在前面的步骤中采集的数据指标，可以帮助我们来定位到问题点究竟是什么，比如下面这些问题点。但是有的时候，我们还需要知道是什么东西在进行连续内存的申请，从而来做更加有针对性的调整，这就需要进行进一步的观察了。我们可以利用内核预置的相关 tracepoint 来做更加细致的分析。

我们继续以内存规整 (memory compaction) 为例，来看下如何利用 tracepoint 来对它进行观察：

#首先来使能compcation相关的一些tracepoing
$ echo 1 >
/sys/kernel/debug/tracing/events/compaction/mm_compaction_begin/enable
$ echo 1 >
/sys/kernel/debug/tracing/events/compaction/mm_compaction_end/enable

#然后来读取信息，当compaction事件触发后就会有信息输出
$ cat /sys/kernel/debug/tracing/trace_pipe
           <...>-49355 [037] .... 1578020.975159: mm_compaction_begin:
zone_start=0x2080000 migrate_pfn=0x2080000 free_pfn=0x3fe5800
zone_end=0x4080000, mode=async
           <...>-49355 [037] .N.. 1578020.992136: mm_compaction_end:
zone_start=0x2080000 migrate_pfn=0x208f420 free_pfn=0x3f4b720
zone_end=0x4080000, mode=async status=contended

从这个例子中的信息里，我们可以看到是 49355 这个进程触发了 compaction，begin 和 end 这两个 tracepoint 触发的时间戳相减，就可以得到 compaction 给业务带来的延迟，我们可以计算出这一次的延迟为 17ms。

很多时候由于采集的信息量太大，我们往往需要借助一些自动化分析的工具来分析，这样会很高效。比如我之前写过一个perf script来分析直接内存回收对业务造成的延迟。另外你也可以参考 Brendan Gregg 基于 bcc(eBPF) 写的direct reclaim snoop来观察进程因为 direct reclaim 而导致的延迟。

系统 load 值在昨天飙得很高，是由 Page Cache 引起的吗？

上面的问题是实时发生的，对实时问题来说，因为有现场信息可供采集，所以相对好分析一些。但是有时候，我们没有办法及时地去搜集现场信息，比如问题发生在深夜时，我们没有来得及去采集现场信息，这个时候就只能查看历史记录了。

我们可以根据 sar 的日志信息来判断当时发生了什么事情。我之前就遇到过类似的问题。

曾经有一个业务反馈说 RT 抖动得比较明显，让我帮他们分析一下抖动的原因，我把业务 RT 抖动的时间和 sar -B 里的 pgscand 不为 0 的时刻相比较后发现，二者在很多时候都是吻合的。于是，我推断业务抖动跟 Page Cache 回收存在一些关系，然后我让业务方调 vm.min_free_kbytes 来验证效果，业务方将该值从初始值 90112 调整为 4G 后效果立竿见影，就几乎没有抖动了。

在这里，我想再次强调一遍，调整 vm.min_free_kbytes 会存在一些风险，如果系统本身内存回收已经很紧张，再去调大它极有可能触发 OOM 甚至引起系统宕机。所以在调大的时候，一定要先做一些检查，看看此时是否可以调整。

当然了，如果你的 sysstat 版本较新并且内核版本较高，那你也可以观察 PSI 记录的日志信息是否跟业务抖动相吻合。根据 sar 的这些信息我们可以推断出故障是否跟 Page Cache 相关。

既然是通过 sar 的日志信息来评判，那么对日志信息的丰富度就有一定要求。你需要对常见的一些问题做一些归纳总结，然后把这些常见问题相关联的指标记录在日志中供事后分析，这样可以帮助你更加全面地分析问题，尤其是发生频率较高的一些问题。

比如，曾经我们的业务经常发生一些业务抖动，在通过我们上述的分析手段分析出来是 compation 引起的问题后，而且这类问题较多，我们便把 /proc/vmstat 里 compaction 相关的指标（我们在上面的表格里有写到具体是哪些指标）记录到我们日志系统中。在业务再次出现抖动后，我们就可以根据日志信息来判断是否跟 compaction 相关了。

课堂回顾

好了，这节课我们就讲到这里，我们简单回顾一下。这节课我们讲了 Page Cache 问题的分析方法论，按照这个方法论我们几乎可以分析清楚 Page Cache 相关的所有问题，而且也能帮助我们了解业务的内存访问模式，从而帮助我们更好地对业务来做优化。

当然这套分析方法论不仅仅适用于 Page Cache 引发的问题，对于系统其他层面引起的问题同样也适用。让我们再次回顾一下这些要点：

在观察 Page Cache 的行为时，你可以先从最简单易用的分析工具比如 sar 入手，来得到一个概况，然后再使用更加专业一些的工具比如 tracepoint 去做更细致的分析。这样你就能分析清楚 Page Cache 的详细行为，以及它为什么会产生问题；

对于很多的偶发性的问题，往往需要采集很多的信息才能抓取出来问题现场，这种场景下最好使用 perf script 来写一些自动化分析的工具来提升效率；

如果你担心分析工具会对生产环境产生性能影响，你可以把信息采集下来之后进行离线分析，或者使用 ebpf 来进行自动过滤分析，请注意 ebpf 需要高版本内核的支持。

这是我沉淀下来的定位问题的方法。也希望你在遇到问题时不逃避，刨根问底寻找根本原因是什么，相信你一定也会有自己的问题分析方法论，然后在出现问题时能够快速高效地找到原因。

课后作业

假设现在内存紧张， 有很多进程都在进行直接内存回收，如何统计出来都是哪些进程在进行直接内存回收呢？欢迎在留言区分享你的看法。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

06 基础篇 | 进程的哪些内存类型容易引起内存泄漏？

你好，我是邵亚方。今天我们进入课程的第二个模块，来聊一下内存泄漏的话题。

相信你在平时的工作中，应该遇到过下面这些场景：

伴随着服务器中的后台任务持续地运行，系统中可用内存越来越少；

应用程序正在运行时忽然被 OOM kill 掉了；

进程看起来没有消耗多少内存，但是系统内存就是不够用了；

……

类似问题，很可能就是内存泄漏导致的。我们都知道，内存泄漏指的是内存被分配出去后一直没有被释放，导致这部分内存无法被再次使用，甚至更加严重的是，指向这块内存空间的指针都不存在了，进而再也无法访问这块内存空间。

我们平时遇到的内存泄漏可能是应用程序的内存泄漏，也可能是内核（操作系统）的内存泄漏；而应用程序的内存泄漏可能是堆内存（heap）的泄漏，也可能是内存映射区（Memory Mapping Region）的泄漏。这些不同类型的内存泄漏，它们的表现形式也是不一样的，解决方案也不一样，所以为了更好地处理内存泄漏问题，我们首先就需要去了解这些不同的内存类型。

这些不同的内存类型都可以理解为是进程地址空间 (Address Space) 的一部分，那地址空间是怎么工作的呢？

进程的地址空间

我们用一张图，来表示进程的地址空间。图的左侧是说进程可以通过什么方式来更改进程虚拟地址空间，而中间就是进程虚拟地址空间是如何划分的，右侧则是进程的虚拟地址空间所对应的物理内存或者说物理地址空间。

我们来具体聊一下这个过程。

应用程序首先会调用内存申请释放相关的函数，比如 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，所以你可以把它理解为是进程申请内存的类型大汇总：

这里面涉及很多术语，我们对其中重要的部分做些简单介绍，来看看这张表里的哪些部分容易出现内存泄漏。

进程运行所需要的内存类型有很多种，总的来说，这些内存类型可以从是不是文件映射，以及是不是私有内存这两个不同的维度来做区分，也就是可以划分为上面所列的四类内存。

私有匿名内存。进程的堆、栈，以及 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() 而不去写这些地址，即不去给它分配物理内存，是不是就不用担心内存泄漏了？

答案是这依然需要关注内存泄露，因为这可能导致进程虚拟地址空间耗尽，即虚拟地址空间同样存在内存泄露的问题。我们在下节课的案例篇中，也会分析对应的案例 ，这里先不展开描述了。

接下来，我们继续用一张图片来细化一下分页的过程。

如上图所示，Paging 的大致过程是，CPU 将要请求的虚拟地址传给 MMU（Memory Management Unit，内存管理单元），然后 MMU 先在高速缓存 TLB（Translation 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("/proc/16348/statm", O_RDONLY)     = 9
read(9, "40509 1143 956 24 0 324 0\n", 1024) = 26
close(9)                                = 0
...
open("/proc/16366/stat", O_RDONLY)      = 9
read(9, "16366 (kworker/u16:1-events_unbo"..., 1024) = 182
close(9)
...

除了 nMaj（Major Page Fault， 主缺页中断，指内容不在内存中然后从磁盘中来读取的页数）外，%MEM 则是从 RES 计算而来的，其余的内存信息都是从 statm 文件里面读取的，如下是 top 命令中的字段和 statm 中字段的对应关系：

另外如果你观察仔细的话，可能会发现，有些时候所有进程的 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 分别表示起始地址和虚拟内存的大小，RSS（Resident 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 为例，来说明我们上面提到的四种内存类型（私有匿名，私有文件，共享匿名，共享文件）是如何体现在系统指标中的。

如上图所示，凡是私有的内存都会体现在 /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 很小，那可能是进程不停地在申请内存，但是却没有对这些内存进行任何的读写操作，即虚拟地址空间存在内存泄漏。

同样地，我希望你自己可以写一些测试用例来观察这些指标的变化。

课后作业

课后你可以写一些测试程序，分别分配我们这堂课提到的四种不同类型的内存，观察进程地址空间的变化，以及系统内存指标的变化。欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

07 案例篇 | 如何预防内存泄漏导致的系统假死？

你好，我是邵亚方。

上节课，我们讲了有哪些进程的内存类型会容易引起内存泄漏，这一讲我们来聊一聊，到底应该如何应对内存泄漏的问题。

我们知道，内存泄漏是件非常容易发生的事，但如果它不会给应用程序和系统造成危害，那它就不会构成威胁。当然我不是说这类内存泄漏无需去关心，对追求完美的程序员而言，还是需要彻底地解决掉它的。

而有一些内存泄漏你却需要格外重视，比如说长期运行的后台进程的内存泄漏，这种泄漏日积月累，会逐渐耗光系统内存，甚至会引起系统假死。

我们在了解内存泄漏造成的危害之前，先一起看下什么样的内存泄漏是有危害的。

什么样的内存泄漏是有危害的？

下面是一个内存泄漏的简单示例程序。

#include <stdlib.h>
#include <string.h>

#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）就再没被使用，但是在使用完后却没有把这段内存空间给释放掉，这就是典型的内存泄漏。那这个内存泄漏是有危害的吗？

这就要从进程地址空间的分配和销毁来说起，下面是一个简单的示意图：

进程地址空间申请和释放示意图

从上图可以看出，进程在退出的时候，会把它建立的映射都给解除掉。换句话说，进程退出时，会把它申请的内存都给释放掉，这个内存泄漏就是没危害的。不过话说回来，虽然这样没有什么危害，但是我们最好还是要在程序里加上 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 <stdlib.h>
#include <string.h>
#include <unistd.h>

#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 内核社区来做改进，接下来我们详细说一下它。

这个案例跟 OOM 日志有关，OOM 日志可以理解为是一个单生产者多消费者的模型，如下图所示：

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 才能打印完，这在系统内存紧张时就会成为一个瓶颈点，为什么会是瓶颈点呢？答案如下图所示：

OOM为什么会成为瓶颈点

进程 A 在申请内存失败后会触发 OOM，在发生 OOM 的时候会打印很多很多日志（这些日志是为了方便分析为什么 OOM 会发生），然后会选择一个合适的进程来杀掉，从而释放出来空闲的内存，这些空闲的内存就可以满足后续内存申请了。

如果这个 OOM 的过程耗时很长（即打印到 slow console 所需的时间太长，如上图红色部分所示），其他进程（进程 B）也在此时申请内存，也会申请失败，于是进程 B 同样也会触发 OOM 来尝试释放内存，而 OOM 这里又有一个全局锁（oom_lock）来进行保护，进程 B 尝试获取（trylock）这个锁的时候会失败，就只能再次重试。

如果此时系统中有很多进程都在申请内存，那么这些申请内存的进程都会被阻塞在这里，这就形成了一个恶性循环，甚至会引发系统长时间无响应（假死）。

针对这个问题，我与 Linux 内核内存子系统的维护者 Michal Hocko 以及 OOM 子模块的活跃开发者 Tetsuo Handa 进行了一些讨论，不过我们并没有讨论出一个完美的解决方案，目前仍然是只有一些规避措施，如下：

在发生 OOM 时尽可能少地打印信息

通过将vm.oom_dump_tasks调整为 0，可以不去备份（dump）当前系统中所有可被 kill 的进程信息，如果系统中有很多进程，这些信息的打印可能会非常消耗时间。在我们这个案例里，这部分耗时约为 6s 多，占 OOM 整体耗时 10s 的一多半，所以减少这部分的打印能够缓解这个问题。

但是，这并不是一个完美的方案，只是一个规避措施。因为当我们把 vm.oom_dump_tasks 配置为 1 时，是可以通过这些打印的信息来检查 OOM killer 是否选择了合理的进程，以及系统中是否存在不合理的 OOM 配置策略的。如果我们将它配置为 0，就无法得到这些信息了，而且这些信息不仅不会打印到串口，也不会打印到内核缓冲区，导致无法被转储到不会产生问题的日志文件中。

调整串口打印级别，不将 OOM 信息打印到串口

通过调整/proc/sys/kernel/printk可以做到避免将 OOM 信息输出到串口，我们通过设置 console_loglevel 来将它的级别设置的比 OOM 日志级别（为 4）小，就可以避免 OOM 的信息打印到 console，比如将它设置为 3:

# 初始配置(为7)：所有信息都会输出到console
$ cat /proc/sys/kernel/printk
7  4  1  7

# 调整console_loglevel级别，不让OOM信息打印到console
$ echo "3 4 1 7" > /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 来进行观察。欢迎在留言区分享你的看法。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

08 案例篇 | Shmem：进程没有消耗内存，内存哪去了？

你好，我是邵亚方。

在前一节课，我们讲述了进程堆内存的泄漏以及因为内存泄漏而导致的 OOM 的危害。这节课我们继续讲其他类型的内存泄漏，这样你在发现系统内存越来越少时，就能够想到会是什么在消耗内存。

有的内存泄漏会体现在进程内存里面，这种相对好观察些；而有的内存泄漏就很难观察了，因为它们无法通过观察进程消耗的内存来进行判断，从而容易被忽视，比如 Shmem 内存泄漏就属于这种容易被忽视的，这节课我们重点来讲讲它。

进程没有消耗内存，内存哪去了？

我生产环境上就遇到过一个真实的案例。我们的运维人员发现某几台机器 used（已使用的）内存越来越多，但是通过 top 以及其他一些命令，却检查不出来到底是谁在占用内存。随着可用内存变得越来越少，业务进程也被 OOM killer 给杀掉，这给业务带来了比较严重的影响。于是他们向我寻求帮助，看看产生问题的原因是什么。

我在之前的课程中也提到过，在遇到系统内存不足时，我们首先要做的是查看 /proc/meminfo 中哪些内存类型消耗较多，然后再去做针对性分析。但是如果你不清楚 /proc/meminfo 里面每一项的含义，即使知道了哪几项内存出现了异常，也不清楚该如何继续去分析。所以你最好是记住 /proc/meminfo 里每一项的含义。

回到我们这个案例，通过查看这几台服务器的 /proc/meminfo，发现是 Shmem 的大小有些异常：

$ cat /proc/meminfo
...
Shmem  16777216 kB
...

那么 Shmem 这一项究竟是什么含义呢？该如何去进一步分析到底是谁在使用 Shmem 呢？

我们在前面的基础篇里提到，Shmem 是指匿名共享内存，即进程以 mmap（MAP_ANON|MAP_SHARED）这种方式来申请的内存。你可能会有疑问，进程以这种方式来申请的内存不应该是属于进程的 RES（resident）吗？比如下面这个简单的示例：

#include <sys/mman.h>
#include <string.h>
#include <unistd.h>
#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 有关。我们知道，磁盘的速度是远远低于内存的，有些应用程序为了提升性能，会避免将一些无需持续化存储的数据写入到磁盘，而是把这部分临时数据写入到内存中，然后定期或者在不需要这部分数据时，清理掉这部分内容来释放出内存。在这种需求下，就产生了一种特殊的 Shmem：tmpfs。tmpfs 如下图所示：

它是一种内存文件系统，只存在于内存中，它无需应用程序去申请和释放内存，而是操作系统自动来规划好一部分空间，应用程序只需要往这里面写入数据就可以了，这样会很方便。我们可以使用 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 杀进程的逻辑大致如下图所示：

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），以防止系统 OOM 的时候把该重要的容器给杀掉。 然而，如果容器内部发生了 OOM 就会触发这个内核 Bug，导致总是杀掉最先扫描到的那个进程。

针对该内核 Bug，我也给社区贡献了一个 patch（mm, oom: make the calculation of oom badness more accurate）来修复这个选择不到合适进程的问题，在这个 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 和进程被杀的顺序是什么关系。欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

09 分析篇 | 如何对内核内存泄漏做些基础的分析？

你好，我是邵亚方。

如果你是一名应用开发者，那你对应用程序引起的内存泄漏应该不会陌生。但是，你有没有想过，内存泄漏也可能是由操作系统（内核）自身的问题引起的呢？这是很多应用开发者以及运维人员容易忽视的地方，或者是相对陌生的领域。

然而陌生的领域不代表不会有问题，如果在陌生的领域发生了问题，而你总是习惯于分析应用程序自身，那你可能要浪费很多的分析时间，却依然一无所获。所以，对于应用开发者或者运维人员而言，掌握基本的内核内存泄漏分析方法也是必需的，这样在它发生问题时，你可以有一个初步的判断，而不至于一筹莫展。

内核内存泄漏往往都会是很严重的问题，这通常意味着要重启服务器来解决了，我们肯定并不希望只能靠重启服务器来解决它，不然那就只能没完没了地重启了。我们希望的应该是，在发生了内存泄漏后，能够判断出来是不是内核引起的问题，以及能够找到引起问题的根因，或者是向更专业的内核开发者求助来找到问题根因，从而彻底解决掉它，以免再次重启服务器。

那么，我们该如何判断内存泄漏是否是内核导致的呢？这节课我们就来讲一讲内核内存泄漏的基础分析方法。

内核内存泄漏是什么？

在进行具体的分析之前，我们需要先对内核内存泄漏有个初步的概念，究竟内核内存泄漏是指什么呢？这得从内核空间内存分配的基本方法说起。

我们在06 基础篇里讲过，进程的虚拟地址空间（address space）既包括用户地址空间，也包括内核地址空间。这可以简单地理解为，进程运行在用户态申请的内存，对应的是用户地址空间，进程运行在内核态申请的内存，对应的是内核地址空间，如下图所示：

应用程序可以通过 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 <linux/init.h>
#include <linux/vmalloc.h>

#define SIZE (1024 * 1024 * 1024)

char *kaddr;

char *kmem_alloc(unsigned long size)
{
        char *p;
        p = vmalloc(size);
        if (!p)
                pr_info("[kmem_test]: vmalloc failed\n");
        return p;
}

void kmem_free(const void *addr)
{
        if (addr)
                vfree(addr);
}

int __init kmem_init(void)
{
        pr_info("[kmem_test]: kernel memory init\n");
        kaddr = kmem_alloc(SIZE);
        return 0;
}

void __exit kmem_exit(void)
{
        kmem_free(kaddr);
        pr_info("[kmem_test]: kernel memory exit\n");
}

module_init(kmem_init)
module_exit(kmem_exit)

MODULE_LICENSE("GPLv2");

这是一个典型的内核模块，在这个内核模块中，我们使用 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和kfree API来修改一下上面的测试程序，然后观察下 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。

kmemleak 是内核内存泄漏检查的利器，但是，它的使用也存在一些不便性，因为打开该特性会给性能带来一些损耗，所以生产环境中的内核都会默认关闭该特性。该特性我们一般只用在测试环境中，然后在测试环境中运行需要分析的驱动程序以及其他内核模块。

与其他内存泄漏检查工具类似，kmemleak 也是通过检查内核内存的申请和释放，来判断是否存在申请的内存不再使用也不释放的情况。如果存在，就认为是内核内存泄漏，然后把这些泄漏的信息通过 /sys/kernel/debug/kmemleak 这个文件导出给用户分析。同样以我们上面的程序为例，检查结果如下：

unreferenced object 0xffffc9008a003000 (size 1073741824):
  comm "insmod", 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:
    [<00000000fbd7cb65>] __vmalloc_node_range+0x22f/0x2a0
    [<000000008c0afaef>] vmalloc+0x45/0x50
    [<000000004f3750a2>] 0xffffffffa0937013
    [<0000000078198a11>] 0xffffffffa093c01a
    [<000000002041c0ec>] do_one_initcall+0x4a/0x200
    [<000000008d10d1ed>] do_init_module+0x60/0x220
    [<000000003c285703>] load_module+0x156c/0x17f0
    [<00000000c428a5fe>] __do_sys_finit_module+0xbd/0x120
    [<00000000bc613a5a>] __x64_sys_finit_module+0x1a/0x20
    [<000000004b0870a2>] do_syscall_64+0x52/0x90
    [<000000002f458917>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

由于该程序通过 vmalloc 申请的内存以后再也没有使用，所以被 kmemleak 标记为了“unreferenced object”，我们需要在使用完该内存空间后就释放它以节省内存。

如果我们想在生产环境上来观察内核内存泄漏，就无法使用 kmemleak 了，那还有没有其他的方法呢？

我们可以使用内核提供的内核内存申请释放的 tracepoint，来动态观察内核内存使用情况：

当我们使能这些 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("d_free") {
        if (target() == pid()) {
                free++;
        }
}

probe kernel.function("d_alloc").return {
        if (target() == pid()) {
                alloc++;
        }
}

probe end {
        printf("alloc %d free %d\n", 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 了。

好了，我们这节课就讲到这里。

课堂总结

这节课我们讲了一种更难分析以及引起危害更大的内存泄漏：内核内存泄漏。我们还讲了针对这种内存泄漏的常用分析方法：

你可以通过 /proc/meminfo 里面的信息来看内核内存的使用情况，然后根据这里面的信息来做一些基本的判断：如果内核太大那就值得怀疑；

kmemleak 是内核内存分析的利器，但是一般只在测试环境上使用它，因为它对性能会有比较明显的影响；

在生产环境中可以使用 tracepoint 或者 kprobe，来追踪特定类型内核内存的申请和释放，从而帮助我们判断是否存在内存泄漏。但这往往需要专业的知识，你在不明白的时候可以去请教一些内核专家；

内核内存泄漏通常都是第三方驱动或者自己写的一些内核模块导致的，在出现内核内存泄漏时，你可以优先去排查它们。

课后作业

我们这节课讲的内容对应用开发者会有些难度，对于运维人员而言也是需要掌握的。所以我们的课后作业主要是针对运维人员或者内核初学者的：请写一个 systemtap 脚本来追踪内核内存的申请和释放。欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

10 分析篇 | 内存泄漏时，我们该如何一步步找到根因？

你好，我是邵亚方。

通过我们前面的基础篇以及案例篇的学习，你对内存泄漏应该有了自己的一些理解。这节课我来跟你聊一聊系统性地分析内存泄漏问题的方法：也就是说，在面对内存泄漏时，我们该如何一步步去找到根因？

不过，我不会深入到具体语言的实现细节，以及具体业务的代码逻辑中，而是会从 Linux 系统上通用的一些分析方法来入手。这样，不论你使用什么开发语言，不论你在开发什么，它总能给你提供一些帮助。

如何定位出是谁在消耗内存 ？

内存泄漏的外在表现通常是系统内存不够，严重的话可能会引起 OOM (Out of Memory)，甚至系统宕机。那在发生这些现象时，惯用的分析套路是什么呢？

首先，我们需要去找出到底是谁在消耗内存，/proc/meminfo 可以帮助我们来快速定位出问题所在。

/proc/meminfo 中的项目很多，我们没必要全部都背下来，不过有些项是相对容易出问题的，也是你在遇到内存相关的问题时，需要重点去排查的。我将这些项列了一张表格，也给出了每一项有异常时的排查思路。

总之，如果进程的内存有问题，那使用 top 就可以观察出来；如果进程的内存没有问题，那你可以从 /proc/meminfo 入手来一步步地去深入分析。

接下来，我们分析一个实际的案例，来看看如何分析进程内存泄漏是什么原因导致的。

如何去分析进程的内存泄漏原因？

这是我多年以前帮助一个小伙伴分析的内存泄漏问题。这个小伙伴已经使用 top 排查出了业务进程的内存异常，但是不清楚该如何去进一步分析。

他遇到的这个异常是，业务进程的虚拟地址空间（VIRT）被消耗很大，但是物理内存（RES）使用得却很少，所以他怀疑是进程的虚拟地址空间有内存泄漏。

我们在“06 讲”中也讲过，出现该现象时，可以用 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）来追踪下该进程的内存行为，看看能够发现什么现象。

$ 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 讲”）：

增大的内存区域，具体如下：

$ 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 <unfinished ...>
31151 23:01:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31157 23:02:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31158 23:03:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31165 23:04:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31163 23:05:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31153 23:06:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31155 23:07:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31149 23:08:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31147 23:09:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31159 23:10:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31157 23:11:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31148 23:12:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31150 23:13:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31173 23:14:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>

从这个日志我们可以看到，出错的是 mmap() 这个系统调用，那我们再来看下 mmap 这个内存的目的：

31151 23:01:00 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0 <unfinished ...>
31151 23:01:00 mprotect(0x7fa94bbc0000, 4096, PROT_NONE <unfinished ...>   <<< 创建一个保护页
31151 23:01:00 clone( <unfinished ...>   <<< 创建线程
31151 23:01:00 <... clone resumed> 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 <stdio.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/wait.h>
#define _SCHED_H
#define __USE_GNU
#include <bits/sched.h>

#define STACK_SIZE 40960

int func(void *arg)
{
    printf("thread enter.\n");
    sleep(1);
    printf("thread exit.\n");

    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("mmap");
            goto error;
        }
        printf("creat new thread...\n");
        thread_pid = clone(&func, addr + STACK_SIZE, CLONE_SIGHAND|CLONE_FS|CLONE_VM|CLONE_FILES, NULL);
        printf("Done! Thread pid: %d\n", thread_pid);
        if (thread_pid != -1) {
            do {
                w = waitpid(-1, NULL, __WCLONE | __WALL);
                if (w == -1) {
                    perror("waitpid");
                    goto error;
                }
            } while (!WIFEXITED(status) && !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）。欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

11 基础篇 | TCP连接的建立和断开受哪些系统配置影响？

你好，我是邵亚方。

如果你做过 Linux 上面网络相关的开发，或者分析过 Linux 网络相关的问题，那你肯定吐槽过 Linux 系统里面让人眼花缭乱的各种配置项，应该也被下面这些问题困扰过：

Client 为什么无法和 Server 建立连接呢？

三次握手都完成了，为什么会收到 Server 的 reset 呢？

建立 TCP 连接怎么会消耗这么多时间？

系统中为什么会有这么多处于 time-wait 的连接？该这么处理？

系统中为什么会有这么多 close-wait 的连接？

针对我的业务场景，这么多的网络配置项，应该要怎么配置呢？

……

因为网络这一块涉及到的场景太多了，Linux 内核需要去处理各种各样的网络场景，不同网络场景的处理策略也会有所不同。而 Linux 内核的默认网络配置可能未必会适用我们的场景，这就可能导致我们的业务出现一些莫名其妙的行为。

所以，要想让业务行为符合预期，你需要了解 Linux 的相关网络配置，让这些配置更加适用于你的业务。Linux 中的网络配置项是非常多的，为了让你更好地了解它们，我就以最常用的 TCP/IP 协议为例，从一个网络连接是如何建立起来的以及如何断开的来开始讲起。

TCP 连接的建立过程会受哪些配置项的影响？

TCP建连过程

上图就是一个 TCP 连接的建立过程。TCP 连接的建立是一个从 Client 侧调用 connect()，到 Server 侧 accept() 成功返回的过程。你可以看到，在整个 TCP 建立连接的过程中，各个行为都有配置选项来进行控制。

Client 调用 connect() 后，Linux 内核就开始进行三次握手。

首先 Client 会给 Server 发送一个 SYN 包，但是该 SYN 包可能会在传输过程中丢失，或者因为其他原因导致 Server 无法处理，此时 Client 这一侧就会触发超时重传机制。但是也不能一直重传下去，重传的次数也是有限制的，这就是 tcp_syn_retries 这个配置项来决定的。

假设 tcp_syn_retires 为 3，那么 SYN 包重传的策略大致如下：

tcp_syn_retries示意图

在 Client 发出 SYN 后，如果过了 1 秒 ，还没有收到 Server 的响应，那么就会进行第一次重传；如果经过 2s 的时间还没有收到 Server 的响应，就会进行第二次重传；一直重传 tcp_syn_retries 次。

对于 tcp_syn_retries 为 3 而言，总共会重传 3 次，也就是说从第一次发出 SYN 包后，会一直等待（1 + 2 + 4 + 8）秒，如果还没有收到 Server 的响应，connect() 就会产生 ETIMEOUT 的错误。

tcp_syn_retries 的默认值是 6，也就是说如果 SYN 一直发送失败，会在（1 + 2 + 4 + 8 + 16+ 32 + 64）秒，即 127 秒后产生 ETIMEOUT 的错误。

我们在生产环境上就遇到过这种情况，Server 因为某些原因被下线，但是 Client 没有被通知到，所以 Client 的 connect() 被阻塞 127s 才去尝试连接一个新的 Server， 这么长的超时等待时间对于应用程序而言是很难接受的。

所以通常情况下，我们都会将数据中心内部服务器的 tcp_syn_retries 给调小，这里推荐设置为 2，来减少阻塞的时间。因为对于数据中心而言，它的网络质量是很好的，如果得不到 Server 的响应，很可能是 Server 本身出了问题。在这种情况下，Client 及早地去尝试连接其他的 Server 会是一个比较好的选择，所以对于客户端而言，一般都会做如下调整：

net.ipv4.tcp_syn_retries = 2

有些情况下 1s 的阻塞时间可能都很久，所以有的时候也会将三次握手的初始超时时间从默认值 1s 调整为一个较小的值，比如 100ms，这样整体的阻塞时间就会小很多。这也是数据中心内部经常进行一些网络优化的原因。

如果 Server 没有响应 Client 的 SYN，除了我们刚才提到的 Server 已经不存在了这种情况外，还有可能是因为 Server 太忙没有来得及响应，或者是 Server 已经积压了太多的半连接（incomplete）而无法及时去处理。

半连接，即收到了 SYN 后还没有回复 SYNACK 的连接，Server 每收到一个新的 SYN 包，都会创建一个半连接，然后把该半连接加入到半连接队列（syn queue）中。syn queue 的长度就是 tcp_max_syn_backlog 这个配置项来决定的，当系统中积压的半连接个数超过了该值后，新的 SYN 包就会被丢弃。对于服务器而言，可能瞬间会有非常多的新建连接，所以我们可以适当地调大该值，以免 SYN 包被丢弃而导致 Client 收不到 SYNACK：

net.ipv4.tcp_max_syn_backlog = 16384

Server 中积压的半连接较多，也有可能是因为有些恶意的 Client 在进行 SYN Flood 攻击。典型的 SYN Flood 攻击如下：Client 高频地向 Server 发 SYN 包，并且这个 SYN 包的源 IP 地址不停地变换，那么 Server 每次接收到一个新的 SYN 后，都会给它分配一个半连接，Server 的 SYNACK 根据之前的 SYN 包找到的是错误的 Client IP， 所以也就无法收到 Client 的 ACK 包，导致无法正确建立 TCP 连接，这就会让 Server 的半连接队列耗尽，无法响应正常的 SYN 包。

为了防止 SYN Flood 攻击，Linux 内核引入了 SYN Cookies 机制。SYN Cookie 的原理是什么样的呢？

在 Server 收到 SYN 包时，不去分配资源来保存 Client 的信息，而是根据这个 SYN 包计算出一个 Cookie 值，然后将 Cookie 记录到 SYNACK 包中发送出去。对于正常的连接，该 Cookies 值会随着 Client 的 ACK 报文被带回来。然后 Server 再根据这个 Cookie 检查这个 ACK 包的合法性，如果合法，才去创建新的 TCP 连接。通过这种处理，SYN Cookies 可以防止部分 SYN Flood 攻击。所以对于 Linux 服务器而言，推荐开启 SYN Cookies：

net.ipv4.tcp_syncookies = 1

Server 向 Client 发送的 SYNACK 包也可能会被丢弃，或者因为某些原因而收不到 Client 的响应，这个时候 Server 也会重传 SYNACK 包。同样地，重传的次数也是由配置选项来控制的，该配置选项是 tcp_synack_retries。

tcp_synack_retries 的重传策略跟我们在前面讲的 tcp_syn_retries 是一致的，所以我们就不再画图来讲解它了。它在系统中默认是 5，对于数据中心的服务器而言，通常都不需要这么大的值，推荐设置为 2 :

net.ipv4.tcp_synack_retries = 2

Client 在收到 Serve 的 SYNACK 包后，就会发出 ACK，Server 收到该 ACK 后，三次握手就完成了，即产生了一个 TCP 全连接（complete），它会被添加到全连接队列（accept queue）中。然后 Server 就会调用 accept() 来完成 TCP 连接的建立。

但是，就像半连接队列（syn queue）的长度有限制一样，全连接队列（accept queue）的长度也有限制，目的就是为了防止 Server 不能及时调用 accept() 而浪费太多的系统资源。

全连接队列（accept queue）的长度是由 listen(sockfd, backlog) 这个函数里的 backlog 控制的，而该 backlog 的最大值则是 somaxconn。somaxconn 在 5.4 之前的内核中，默认都是 128（5.4 开始调整为了默认 4096），建议将该值适当调大一些：

net.core.somaxconn = 16384

当服务器中积压的全连接个数超过该值后，新的全连接就会被丢弃掉。Server 在将新连接丢弃时，有的时候需要发送 reset 来通知 Client，这样 Client 就不会再次重试了。不过，默认行为是直接丢弃不去通知 Client。至于是否需要给 Client 发送 reset，是由 tcp_abort_on_overflow 这个配置项来控制的，该值默认为 0，即不发送 reset 给 Client。推荐也是将该值配置为 0:

net.ipv4.tcp_abort_on_overflow = 0

这是因为，Server 如果来不及 accept() 而导致全连接队列满，这往往是由瞬间有大量新建连接请求导致的，正常情况下 Server 很快就能恢复，然后 Client 再次重试后就可以建连成功了。也就是说，将 tcp_abort_on_overflow 配置为 0，给了 Client 一个重试的机会。当然，你可以根据你的实际情况来决定是否要使能该选项。

accept() 成功返回后，一个新的 TCP 连接就建立完成了，TCP 连接进入到了 ESTABLISHED 状态：

TCP状态转换

上图就是从 Client 调用 connect()，到 Server 侧 accept() 成功返回这一过程中的 TCP 状态转换。这些状态都可以通过 netstat 或者 ss 命令来看。至此，Client 和 Server 两边就可以正常通信了。

接下来，我们看下 TCP 连接断开过程中会受哪些系统配置项的影响。

TCP 连接的断开过程会受哪些配置项的影响？

TCP的四次挥手

如上所示，当应用程序调用 close() 时，会向对端发送 FIN 包，然后会接收 ACK；对端也会调用 clsoe() 来发送 FIN，然后本端也会向对端回 ACK，这就是 TCP 的四次挥手过程。

首先调用 close() 的一侧是 active close（主动关闭）；而接收到对端的 FIN 包后再调用 close() 来关闭的一侧，称之为 passive close（被动关闭）。在四次挥手的过程中，有三个 TCP 状态需要额外关注，就是上图中深红色的那三个状态：主动关闭方的 FIN_WAIT_2 和 TIME_WAIT，以及被动关闭方的 CLOSE_WAIT 状态。除了 CLOSE_WAIT 状态外，其余两个状态都有对应的系统配置项来控制。

我们首先来看 FIN_WAIT_2 状态，TCP 进入到这个状态后，如果本端迟迟收不到对端的 FIN 包，那就会一直处于这个状态，于是就会一直消耗系统资源。Linux 为了防止这种资源的开销，设置了这个状态的超时时间 tcp_fin_timeout，默认为 60s，超过这个时间后就会自动销毁该连接。

至于本端为何迟迟收不到对端的 FIN 包，通常情况下都是因为对端机器出了问题，或者是因为太繁忙而不能及时 close()。所以，通常我们都建议将 tcp_fin_timeout 调小一些，以尽量避免这种状态下的资源开销。对于数据中心内部的机器而言，将它调整为 2s 足以：

net.ipv4.tcp_fin_timeout = 2

我们再来看 TIME_WAIT 状态，TIME_WAIT 状态存在的意义是：最后发送的这个 ACK 包可能会被丢弃掉或者有延迟，这样对端就会再次发送 FIN 包。如果不维持 TIME_WAIT 这个状态，那么再次收到对端的 FIN 包后，本端就会回一个 Reset 包，这可能会产生一些异常。

所以维持 TIME_WAIT 状态一段时间，可以保障 TCP 连接正常断开。TIME_WAIT 的默认存活时间在 Linux 上是 60s（TCP_TIMEWAIT_LEN），这个时间对于数据中心而言可能还是有些长了，所以有的时候也会修改内核做些优化来减小该值，或者将该值设置为可通过 sysctl 来调节。

TIME_WAIT 状态存在这么长时间，也是对系统资源的一个浪费，所以系统也有配置项来限制该状态的最大个数，该配置选项就是 tcp_max_tw_buckets。对于数据中心而言，网络是相对很稳定的，基本不会存在 FIN 包的异常，所以建议将该值调小一些：

net.ipv4.tcp_max_tw_buckets = 10000

Client 关闭跟 Server 的连接后，也有可能很快再次跟 Server 之间建立一个新的连接，而由于 TCP 端口最多只有 65536 个，如果不去复用处于 TIME_WAIT 状态的连接，就可能在快速重启应用程序时，出现端口被占用而无法创建新连接的情况。所以建议你打开复用 TIME_WAIT 的选项：

net.ipv4.tcp_tw_reuse = 1

还有另外一个选项 tcp_tw_recycle 来控制 TIME_WAIT 状态，但是该选项是很危险的，因为它可能会引起意料不到的问题，比如可能会引起 NAT 环境下的丢包问题。所以建议将该选项关闭：

net.ipv4.tcp_tw_recycle = 0

因为打开该选项后引起了太多的问题，所以新版本的内核就索性删掉了这个配置选项：tcp: remove tcp_tw_recycle.

对于 CLOSE_WAIT 状态而言，系统中没有对应的配置项。但是该状态也是一个危险信号，如果这个状态的 TCP 连接较多，那往往意味着应用程序有 Bug，在某些条件下没有调用 close() 来关闭连接。我们在生产环境上就遇到过很多这类问题。所以，如果你的系统中存在很多 CLOSE_WAIT 状态的连接，那你最好去排查一下你的应用程序，看看哪里漏掉了 close()。

至此，TCP 四次挥手过程中需要注意的事项也讲完了。

好了，我们这节课就到此为止。

课堂总结

这节课我们讲了很多的配置项，我把这些配置项汇总到了下面这个表格里，方便你记忆：

当然了，有些配置项也是可以根据你的服务器负载以及 CPU 和内存大小来做灵活配置的，比如 tcp_max_syn_backlog、somaxconn、tcp_max_tw_buckets 这三项，如果你的物理内存足够大、CPU 核数足够多，你可以适当地增大这些值，这些往往都是一些经验值。

另外，我们这堂课的目的不仅仅是为了让你去了解这些配置项，最主要的是想让你了解其背后的机制，这样你在遇到一些问题时，就可以有一个大致的分析方向。

课后作业

课后请你使用 tcpdump 这个工具来观察下 TCP 的三次握手和四次挥手过程，巩固今天的学习内容。欢迎在留言区分享你的看法。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

12 基础篇 | TCP收发包过程会受哪些配置项影响？

你好，我是邵亚方。我们这节课来讲一下，TCP 数据在传输过程中会受到哪些因素干扰。

TCP 收包和发包的过程也是容易引起问题的地方。收包是指数据到达网卡再到被应用程序开始处理的过程。发包则是应用程序调用发包函数到数据包从网卡发出的过程。你应该对 TCP 收包和发包过程中容易引发的一些问题不会陌生，比如说：

网卡中断太多，占用太多 CPU，导致业务频繁被打断；

应用程序调用 write() 或者 send() 发包，怎么会发不出去呢；

数据包明明已经被网卡收到了，可是应用程序为什么没收到呢；

我想要调整缓冲区的大小，可是为什么不生效呢；

是不是内核缓冲区满了从而引起丢包，我该怎么观察呢；

…

想要解决这些问题呢，你就需要去了解 TCP 的收发包过程容易受到哪些因素的影响。这个过程中涉及到很多的配置项，很多问题都是这些配置项跟业务场景不匹配导致的。

我们先来看下数据包的发送过程，这个过程会受到哪些配置项的影响呢？

TCP 数据包的发送过程会受什么影响？

TCP数据包发送过程

上图就是一个简略的 TCP 数据包的发送过程。应用程序调用 write(2) 或者 send(2) 系列系统调用开始往外发包时，这些系统调用会把数据包从用户缓冲区拷贝到 TCP 发送缓冲区（TCP Send Buffer），这个 TCP 发送缓冲区的大小是受限制的，这里也是容易引起问题的地方。

TCP 发送缓冲区的大小默认是受 net.ipv4.tcp_wmem 来控制：

net.ipv4.tcp_wmem = 8192 65536 16777216

tcp_wmem 中这三个数字的含义分别为 min、default、max。TCP 发送缓冲区的大小会在 min 和 max 之间动态调整，初始的大小是 default，这个动态调整的过程是由内核自动来做的，应用程序无法干预。自动调整的目的，是为了在尽可能少的浪费内存的情况下来满足发包的需要。

tcp_wmem 中的 max 不能超过 net.core.wmem_max 这个配置项的值，如果超过了，TCP 发送缓冲区最大就是 net.core.wmem_max。通常情况下，我们需要设置 net.core.wmem_max 的值大于等于 net.ipv4.tcp_wmem 的 max：

net.core.wmem_max = 16777216

对于 TCP 发送缓冲区的大小，我们需要根据服务器的负载能力来灵活调整。通常情况下我们需要调大它们的默认值，我上面列出的 tcp_wmem 的 min、default、max 这几组数值就是调大后的值，也是我们在生产环境中配置的值。

我之所以将这几个值给调大，是因为我们在生产环境中遇到过 TCP 发送缓冲区太小，导致业务延迟很大的问题，这类问题也是可以使用 systemtap 之类的工具在内核里面打点来进行观察的（观察 sk_stream_wait_memory 这个事件）:

# sndbuf_overflow.stp
# Usage :
# $ stap sndbuf_overflow.stp
probe kernel.function("sk_stream_wait_memory")
{
    printf("%d %s TCP send buffer overflow\n",
         pid(), execname())
}

如果你可以观察到 sk_stream_wait_memory 这个事件，就意味着 TCP 发送缓冲区太小了，你需要继续去调大 wmem_max 和 tcp_wmem:max 的值了。

应用程序有的时候会很明确地知道自己发送多大的数据，需要多大的 TCP 发送缓冲区，这个时候就可以通过 setsockopt(2) 里的 SO_SNDBUF 来设置固定的缓冲区大小。一旦进行了这种设置后，tcp_wmem 就会失效，而且这个缓冲区大小设置的是固定值，内核也不会对它进行动态调整。

但是，SO_SNDBUF 设置的最大值不能超过 net.core.wmem_max，如果超过了该值，内核会把它强制设置为 net.core.wmem_max。所以，如果你想要设置 SO_SNDBUF，一定要确认好 net.core.wmem_max 是否满足需求，否则你的设置可能发挥不了作用。通常情况下，我们都不会通过 SO_SNDBUF 来设置 TCP 发送缓冲区的大小，而是使用内核设置的 tcp_wmem，因为如果 SO_SNDBUF 设置得太大就会浪费内存，设置得太小又会引起缓冲区不足的问题。

另外，如果你关注过 Linux 的最新技术动态，你一定听说过 eBPF。你也可以通过 eBPF 来设置 SO_SNDBUF 和 SO_RCVBUF，进而分别设置 TCP 发送缓冲区和 TCP 接收缓冲区的大小。同样地，使用 eBPF 来设置这两个缓冲区时，也不能超过 wmem_max 和 rmem_max。不过 eBPF 在一开始增加设置缓冲区大小的特性时并未考虑过最大值的限制，我在使用的过程中发现这里存在问题，就给社区提交了一个 PATCH 把它给修复了。你感兴趣的话可以看下这个链接：bpf: sock recvbuff must be limited by rmem_max in bpf_setsockopt()。

tcp_wmem 以及 wmem_max 的大小设置都是针对单个 TCP 连接的，这两个值的单位都是 Byte（字节）。系统中可能会存在非常多的 TCP 连接，如果 TCP 连接太多，就可能导致内存耗尽。因此，所有 TCP 连接消耗的总内存也有限制：

net.ipv4.tcp_mem = 8388608 12582912 16777216

我们通常也会把这个配置项给调大。与前两个选项不同的是，该选项中这些值的单位是 Page（页数），也就是 4K。它也有 3 个值：min、pressure、max。当所有 TCP 连接消耗的内存总和达到 max 后，也会因达到限制而无法再往外发包。

因 tcp_mem 达到限制而无法发包或者产生抖动的问题，我们也是可以观测到的。为了方便地观测这类问题，Linux 内核里面预置了静态观测点：sock_exceed_buf_limit。不过，这个观测点一开始只是用来观察 TCP 接收时遇到的缓冲区不足的问题，不能观察 TCP 发送时遇到的缓冲区不足的问题。后来，我提交了一个 patch 做了改进，使得它也可以用来观察 TCP 发送时缓冲区不足的问题：net: expose sk wmem in sock_exceed_buf_limit tracepoint ，观察时你只需要打开 tracepiont（需要 4.16+ 的内核版本）：

$ echo 1 > /sys/kernel/debug/tracing/events/sock/sock_exceed_buf_limit/enable

然后去看是否有该事件发生：

$ cat /sys/kernel/debug/tracing/trace_pipe

如果有日志输出（即发生了该事件），就意味着你需要调大 tcp_mem 了，或者是需要断开一些 TCP 连接了。

TCP 层处理完数据包后，就继续往下来到了 IP 层。IP 层这里容易触发问题的地方是 net.ipv4.ip_local_port_range 这个配置选项，它是指和其他服务器建立 IP 连接时本地端口（local port）的范围。我们在生产环境中就遇到过默认的端口范围太小，以致于无法创建新连接的问题。所以通常情况下，我们都会扩大默认的端口范围：

net.ipv4.ip_local_port_range = 1024 65535

为了能够对 TCP/IP 数据流进行流控，Linux 内核在 IP 层实现了 qdisc（排队规则）。我们平时用到的 TC 就是基于 qdisc 的流控工具。qdisc 的队列长度是我们用 ifconfig 来看到的 txqueuelen，我们在生产环境中也遇到过因为 txqueuelen 太小导致数据包被丢弃的情况，这类问题可以通过下面这个命令来观察：

$ ip -s -s link ls dev eth0

…

TX: bytes packets errors dropped carrier collsns

3263284 25060 0 0 0 0

如果观察到 dropped 这一项不为 0，那就有可能是 txqueuelen 太小导致的。当遇到这种情况时，你就需要增大该值了，比如增加 eth0 这个网络接口的 txqueuelen：

$ ifconfig eth0 txqueuelen 2000

或者使用 ip 这个工具：

$ ip link set eth0 txqueuelen 2000

在调整了 txqueuelen 的值后，你需要持续观察是否可以缓解丢包的问题，这也便于你将它调整到一个合适的值。

Linux 系统默认的 qdisc 为 pfifo_fast（先进先出），通常情况下我们无需调整它。如果你想使用TCP BBR来改善 TCP 拥塞控制的话，那就需要将它调整为 fq（fair queue, 公平队列）：

net.core.default_qdisc = fq

经过 IP 层后，数据包再往下就会进入到网卡了，然后通过网卡发送出去。至此，你需要发送出去的数据就走完了 TCP/IP 协议栈，然后正常地发送给对端了。

接下来，我们来看下数据包是怎样收上来的，以及在接收的过程中会受哪些配置项的影响。

TCP 数据包的接收过程会受什么影响？

TCP 数据包的接收过程，同样也可以用一张图来简单表示：

TCP数据包接收过程

从上图可以看出，TCP 数据包的接收流程在整体上与发送流程类似，只是方向是相反的。数据包到达网卡后，就会触发中断（IRQ）来告诉 CPU 读取这个数据包。但是在高性能网络场景下，数据包的数量会非常大，如果每来一个数据包都要产生一个中断，那 CPU 的处理效率就会大打折扣，所以就产生了 NAPI（New API）这种机制让 CPU 一次性地去轮询（poll）多个数据包，以批量处理的方式来提升效率，降低网卡中断带来的性能开销。

那在 poll 的过程中，一次可以 poll 多少个呢？这个 poll 的个数可以通过 sysctl 选项来控制：

net.core.netdev_budget = 600

该控制选项的默认值是 300，在网络吞吐量较大的场景中，我们可以适当地增大该值，比如增大到 600。增大该值可以一次性地处理更多的数据包。但是这种调整也是有缺陷的，因为这会导致 CPU 在这里 poll 的时间增加，如果系统中运行的任务很多的话，其他任务的调度延迟就会增加。

接下来继续看 TCP 数据包的接收过程。我们刚才提到，数据包到达网卡后会触发 CPU 去 poll 数据包，这些 poll 的数据包紧接着就会到达 IP 层去处理，然后再达到 TCP 层，这时就会面对另外一个很容易引发问题的地方了：TCP Receive Buffer（TCP 接收缓冲区）。

与 TCP 发送缓冲区类似，TCP 接收缓冲区的大小也是受控制的。通常情况下，默认都是使用 tcp_rmem 来控制缓冲区的大小。同样地，我们也会适当地增大这几个值的默认值，来获取更好的网络性能，调整为如下数值：

net.ipv4.tcp_rmem = 8192 87380 16777216

它也有 3 个字段：min、default、max。TCP 接收缓冲区大小也是在 min 和 max 之间动态调整 ，不过跟发送缓冲区不同的是，这个动态调整是可以通过控制选项来关闭的，这个选项是 tcp_moderate_rcvbuf 。通常我们都是打开它，这也是它的默认值：

net.ipv4.tcp_moderate_rcvbuf = 1

之所以接收缓冲区有选项可以控制自动调节，而发送缓冲区没有，那是因为 TCP 接收缓冲区会直接影响 TCP 拥塞控制，进而影响到对端的发包，所以使用该控制选项可以更加灵活地控制对端的发包行为。

除了 tcp_moderate_rcvbuf 可以控制 TCP 接收缓冲区的动态调节外，也可以通过 setsockopt() 中的配置选项 SO_RCVBUF 来控制，这与 TCP 发送缓冲区是类似的。如果应用程序设置了 SO_RCVBUF 这个标记，那么 TCP 接收缓冲区的动态调整就是关闭，即使 tcp_moderate_rcvbuf 为 1，接收缓冲区的大小始终就为设置的 SO_RCVBUF 这个值。

也就是说，只有在 tcp_moderate_rcvbuf 为 1，并且应用程序没有通过 SO_RCVBUF 来配置缓冲区大小的情况下，TCP 接收缓冲区才会动态调节。

同样地，与 TCP 发送缓冲区类似，SO_RCVBUF 设置的值最大也不能超过 net.core.rmem_max。通常情况下，我们也需要设置 net.core.rmem_max 的值大于等于 net.ipv4.tcp_rmem 的 max：

net.core.rmem_max = 16777216

我们在生产环境中也遇到过，因达到了 TCP 接收缓冲区的限制而引发的丢包问题。但是这类问题不是那么好追踪的，没有一种很直观地追踪这种行为的方式，所以我便在我们的内核里添加了针对这种行为的统计。

为了让使用 Linux 内核的人都能很好地观察这个行为，我也把我们的实践贡献给了 Linux 内核社区，具体可以看这个 commit：tcp: add new SNMP counter for drops when try to queue in rcv queue。使用这个 SNMP 计数，我们就可以很方便地通过 netstat 查看，系统中是否存在因为 TCP 接收缓冲区不足而引发的丢包。

不过，该方法还是存在一些局限：如果我们想要查看是哪个 TCP 连接在丢包，那么这种方式就不行了，这个时候我们就需要去借助其他一些更专业的 trace 工具，比如 eBPF 来达到我们的目的。

课堂总结

好了，这节课就讲到这里，我们简单回顾一下。TCP/IP 是一个很复杂的协议栈，它的数据包收发过程也是很复杂的，我们这节课只是重点围绕这个过程中最容易引发问题的地方来讲述的。我们刚才提到的那些配置选项都很容易在生产环境中引发问题，并且也是我们针对高性能网络进行调优时必须要去考虑的。我把这些配置项也总结为了一个表格，方便你来查看：

这些值都需要根据你的业务场景来做灵活的调整，当你不知道针对你的业务该如何调整时，你最好去咨询更加专业的人员，或者一边调整一边观察系统以及业务行为的变化。

课后作业

我们这节课中有两张图，分别是 TCP 数据包的发送过程 和 TCP 数据包的接收过程，我们可以看到，在 TCP 发送过程中使用到了 qdisc，但是在接收过程中没有使用它，请问是为什么？我们可以在接收过程中也使用 qdisc 吗？欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

13 案例篇 | TCP拥塞控制是如何导致业务性能抖动的？

你好，我是邵亚方。这节课我来跟大家分享 TCP 拥塞控制与业务性能抖动之间的关系。

TCP 拥塞控制是 TCP 协议的核心，而且是一个非常复杂的过程。如果你不了解 TCP 拥塞控制的话，那么就相当于不理解 TCP 协议。这节课的目的是通过一些案例，介绍在 TCP 拥塞控制中我们要避免踩的一些坑，以及针对 TCP 性能调优时需要注意的一些点。

因为在 TCP 传输过程中引起问题的案例有很多，所以我不会把这些案例拿过来具体去一步步分析，而是希望能够对这些案例做一层抽象，把这些案例和具体的知识点结合起来，这样会更有系统性。并且，在你明白了这些知识点后，案例的分析过程就相对简单了。

我们在前两节课（第 11 讲和第 12 讲）中讲述了单机维度可能需要注意的问题点。但是，网络传输是一个更加复杂的过程，这中间涉及的问题会更多，而且更加不好分析。相信很多人都有过这样的经历：

等电梯时和别人聊着微信，进入电梯后微信消息就发不出去了；

和室友共享同一个网络，当玩网络游戏玩得正开心时，游戏忽然卡得很厉害，原来是室友在下载电影；

使用 ftp 上传一个文件到服务器上，没想到要上传很久；

……

在这些问题中，TCP 的拥塞控制就在发挥着作用。

TCP 拥塞控制是如何对业务网络性能产生影响的 ？

我们先来看下 TCP 拥塞控制的大致原理。

TCP拥塞控制

上图就是 TCP 拥塞控制的简单图示，它大致分为四个阶段。

1. 慢启动

TCP 连接建立好后，发送方就进入慢速启动阶段，然后逐渐地增大发包数量（TCP Segments）。这个阶段每经过一个 RTT（round-trip time），发包数量就会翻倍。如下图所示：

TCP Slow Start示意图

初始发送数据包的数量是由 init_cwnd（初始拥塞窗口）来决定的，该值在 Linux 内核中被设置为 10（TCP_INIT_CWND），这是由 Google 的研究人员总结出的一个经验值，这个经验值也被写入了RFC6928。并且，Linux 内核在 2.6.38 版本中也将它从默认值 3 修改为了 Google 建议的 10，你感兴趣的话可以看下这个 commit： tcp: Increase the initial congestion window to 10。

增大 init_cwnd 可以显著地提升网络性能，因为这样在初始阶段就可以一次性发送很多 TCP Segments，更加细节性的原因你可以参考RFC6928的解释。

如果你的内核版本比较老（低于 CentOS-6 的内核版本），那不妨考虑增加 init_cwnd 到 10。如果你想要把它增加到一个更大的值，也不是不可以，但是你需要根据你的网络状况多做一些实验，从而得到一个较为理想的值。因为如果初始拥塞窗口设置得过大的话，可能会引起很高的 TCP 重传率。当然，你也可以通过 ip route 的方式来更加灵活地调整该值，甚至将它配置为一个 sysctl 控制项。

增大 init_cwnd 的值对于提升短连接的网络性能会很有效，特别是数据量在慢启动阶段就能发送完的短连接，比如针对 http 这种服务，http 的短连接请求数据量一般不大，通常在慢启动阶段就能传输完，这些都可以通过 tcpdump 来进行观察。

在慢启动阶段，当拥塞窗口（cwnd）增大到一个阈值（ ssthresh，慢启动阈值）后，TCP 拥塞控制就进入了下一个阶段：拥塞避免（Congestion Avoidance）。

2. 拥塞避免

在这个阶段 cwnd 不再成倍增加，而是一个 RTT 增加 1，即缓慢地增加 cwnd，以防止网络出现拥塞。网络出现拥塞是难以避免的，由于网络链路的复杂性，甚至会出现乱序（Out of Order）报文。乱序报文产生原因之一如下图所示：

TCP乱序报文

在上图中，发送端一次性发送了 4 个 TCP segments，但是第 2 个 segment 在传输过程中被丢弃掉了，那么接收方就接收不到该 segment 了。然而第 3 个 TCP segment 和第 4 个 TCP segment 能够被接收到，此时 3 和 4 就属于乱序报文，它们会被加入到接收端的 ofo queue（乱序队列）里。

丢包这类问题在移动网络环境中比较容易出现，特别是在一个网络状况不好的环境中，比如在电梯里丢包率就会很高，而丢包率高就会导致网络响应特别慢。在数据中心内部的服务上很少会有数据包在网络链路中被丢弃的情况，我说的这类丢包问题主要是针对网关服务这种和外部网络有连接的服务上。

针对我们的网关服务，我们自己也做过一些 TCP 单边优化工作，主要是优化 Cubic 拥塞控制算法，以缓解丢包引起的网络性能下降问题。另外，Google 前几年开源的一个新的拥塞控制算法 BBR在理论上也可以很好地缓解 TCP 丢包问题，但是在我们的实践中，BBR 的效果并不好，因此我们最终也没有使用它。

我们再回到上面这张图，因为接收端没有接收到第 2 个 segment，因此接收端每次收到一个新的 segment 后都会去 ack 第 2 个 segment，即 ack 17。紧接着，发送端就会接收到三个相同的 ack（ack 17）。连续出现了 3 个响应的 ack 后，发送端会据此判断数据包出现了丢失，于是就进入了下一个阶段：快速重传。

3. 快速重传和快速恢复

快速重传和快速恢复是一起工作的，它们是为了应对丢包这种行为而做的优化，在这种情况下，由于网络并没有出现拥塞，所以拥塞窗口不必恢复到初始值。判断丢包的依据就是收到 3 个相同的 ack。

Google 的工程师同样对 TCP 快速重传提出了一个改进策略：tcp early retrans，它允许一些情况下的 TCP 连接可以绕过重传延时（RTO）来进行快速重传。3.6 版本以后的内核都支持了这个特性，因此，如果你还在使用 CentOS-6，那么就享受不到它带来的网络性能提升了，你可以将你的操作系统升级为 CentOS-7 或者最新的 CentOS-8。 另外，再多说一句，Google 在网络方面的技术实力是其他公司没法比的，Linux 内核 TCP 子系统的 maintainer 也是 Google 的工程师（Eric Dumazet）。

除了快速重传外，还有一种重传机制是超时重传。不过，这是非常糟糕的一种情况。如果发送出去一个数据包，超过一段时间（RTO）都收不到它的 ack，那就认为是网络出现了拥塞。这个时候就需要将 cwnd 恢复为初始值，再次从慢启动开始调整 cwnd 的大小。

RTO 一般发生在网络链路有拥塞的情况下，如果某一个连接数据量太大，就可能会导致其他连接的数据包排队，从而出现较大的延迟。我们在开头提到的，下载电影影响到别人玩网络游戏的例子就是这个原因。

关于 RTO，它也是一个优化点。如果 RTO 过大的话，那么业务就可能要阻塞很久，所以在 3.1 版本的内核里引入了一种改进来将 RTO 的初始值从 3s 调整为 1s，这可以显著节省业务的阻塞时间。不过，RTO=1s 在某些场景下还是有些大了，特别是在数据中心内部这种网络质量相对比较稳定的环境中。

我们在生产环境中发生过这样的案例：业务人员反馈说业务 RT 抖动得比较厉害，我们使用 strace 初步排查后发现，进程阻塞在了 send() 这类发包函数里。然后我们使用 tcpdump 来抓包，发现发送方在发送数据后，迟迟不能得到对端的响应，一直到 RTO 时间再次重传。与此同时，我们还尝试了在对端也使用 tcpdump 来抓包，发现对端是过了很长时间后才收到数据包。因此，我们判断是网络发生了拥塞，从而导致对端没有及时收到数据包。

那么，针对这种网络拥塞引起业务阻塞时间太久的情况，有没有什么解决方案呢？一种解决方案是，创建 TCP 连接，使用 SO_SNDTIMEO 来设置发送超时时间，以防止应用在发包的时候阻塞在发送端太久，如下所示：

ret = setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len);

当业务发现该 TCP 连接超时后，就会主动断开该连接，然后尝试去使用其他的连接。

这种做法可以针对某个 TCP 连接来设置 RTO 时间，那么，有没有什么方法能够设置全局的 RTO 时间（设置一次，所有的 TCP 连接都能生效）呢？答案是有的，这就需要修改内核。针对这类需求，我们在生产环境中的实践是：将 TCP RTO min、TCP RTO max、TCP RTO init 更改为可以使用 sysctl 来灵活控制的变量，从而根据实际情况来做调整，比如说针对数据中心内部的服务器，我们可以适当地调小这几个值，从而减少业务阻塞时间。

上述这 4 个阶段是 TCP 拥塞控制的基础，总体来说，拥塞控制就是根据 TCP 的数据传输状况来灵活地调整拥塞窗口，从而控制发送方发送数据包的行为。换句话说，拥塞窗口的大小可以表示网络传输链路的拥塞情况。TCP 连接 cwnd 的大小可以通过 ss 这个命令来查看：

$ ss -nipt
State       Recv-Q Send-Q                        Local Address:Port                                       Peer Address:Port
ESTAB       0      36                             172.23.245.7:22                                        172.30.16.162:60490
users:(("sshd",pid=19256,fd=3))
   cubic wscale:5,7 rto:272 rtt:71.53/1.068 ato:40 mss:1248 rcvmss:1248 advmss:1448 cwnd:10 bytes_acked:19591 bytes_received:2817 segs_out:64 segs_in:80 data_segs_out:57 data_segs_in:28 send 1.4Mbps lastsnd:6 lastrcv:6 lastack:6 pacing_rate 2.8Mbps delivery_rate 1.5Mbps app_limited busy:2016ms unacked:1 rcv_space:14600 minrtt:69.402

通过该命令，我们可以发现这个 TCP 连接的 cwnd 为 10。

如果你想要追踪拥塞窗口的实时变化信息，还有另外一个更好的办法：通过 tcp_probe 这个 tracepoint 来追踪：

/sys/kernel/debug/tracing/events/tcp/tcp_probe

但是这个 tracepoint 只有 4.16 以后的内核版本才支持，如果你的内核版本比较老，你也可以使用 tcp_probe 这个内核模块（net/ipv4/tcp_probe.c）来进行追踪。

除了网络状况外，发送方还需要知道接收方的处理能力。如果接收方的处理能力差，那么发送方就必须要减缓它的发包速度，否则数据包都会挤压在接收方的缓冲区里，甚至被接收方给丢弃掉。接收方的处理能力是通过另外一个窗口——rwnd（接收窗口）来表示的。那么，接收方的 rwnd 又是如何影响发送方的行为呢？

接收方是如何影响发送方发送数据的？

同样地，我也画了一张简单的图，来表示接收方的 rwnd 是如何影响发送方的：

rwnd与cwnd

如上图所示，接收方在收到数据包后，会给发送方回一个 ack，然后把自己的 rwnd 大小写入到 TCP 头部的 win 这个字段，这样发送方就能根据这个字段来知道接收方的 rwnd 了。接下来，发送方在发送下一个 TCP segment 的时候，会先对比发送方的 cwnd 和接收方的 rwnd，得出这二者之间的较小值，然后控制发送的 TCP segment 个数不能超过这个较小值。

关于接收方的 rwnd 对发送方发送行为的影响，我们曾经遇到过这样的案例：业务反馈说 Server 向 Client 发包很慢，但是 Server 本身并不忙，而且网络看起来也没有问题，所以不清楚是什么原因导致的。对此，我们使用 tcpdump 在 server 上抓包后发现，Client 响应的 ack 里经常出现 win 为 0 的情况，也就是 Client 的接收窗口为 0。于是我们就去 Client 上排查，最终发现是 Client 代码存在 bug，从而导致无法及时读取收到的数据包。

对于这种行为，我同样给 Linux 内核写了一个 patch 来监控它：tcp: add SNMP counter for zero-window drops 。这个 patch 里增加了一个新的 SNMP 计数：TCPZeroWindowDrop。如果系统中发生了接收窗口太小而无法收包的情况，就会产生该事件，然后该事件可以通过 /proc/net/netstat 里的 TCPZeroWindowDrop 这个字段来查看。

因为 TCP 头部大小是有限制的，而其中的 win 这个字段只有 16bit，win 能够表示的大小最大只有 65535（64K），所以如果想要支持更大的接收窗口以满足高性能网络，我们就需要打开下面这个配置项，系统中也是默认打开了该选项：

net.ipv4.tcp_window_scaling = 1

关于该选项更加详细的设计，你如果想了解的话，可以去参考RFC1323。

好了，关于 TCP 拥塞控制对业务网络性能的影响，我们就先讲到这里。

课堂总结

TCP 拥塞控制是一个非常复杂的行为，我们在这节课里讲到的内容只是其中一些基础部分，希望这些基础知识可以让你对 TCP 拥塞控制有个大致的了解。我来总结一下这节课的重点：

网络拥塞状况会体现在 TCP 连接的拥塞窗口（cwnd）中，该拥塞窗口会影响发送方的发包行为；

接收方的处理能力同样会反馈给发送方，这个处理是通过 rwnd 来表示的。rwnd 和 cwnd 会共同作用于发送方，来决定发送方最大能够发送多少 TCP 包；

TCP 拥塞控制的动态变化，可以通过 tcp_probe 这个 tracepoint（对应 4.16+ 的内核版本）或者是 tcp_probe 这个内核模块（对应 4.16 之前的内核版本）来进行实时观察，通过 tcp_probe 你能够很好地观察到 TCP 连接的数据传输状况。

课后作业

通过 ssh 登录到服务器上，然后把网络关掉，过几秒后再打开，请问这个 ssh 连接还正常吗？为什么？欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

14 案例篇 | TCP端到端时延变大，怎样判断是哪里出现了问题？

你好，我是邵亚方。

如果你是一名互联网从业者，那你对下面这个场景应该不会陌生：客户端发送请求给服务端，服务端将请求处理完后，再把响应数据发送回客户端，这就是典型的 C/S（Client/Server）架构。对于这种请求 - 响应式的服务，我们不得不面对的问题是：

如果客户端收到的响应时间变大了，那么这是客户端自身的问题呢，还是因为服务端处理得慢呢，又或者是因为网络有抖动呢？

即使我们已经明确了是服务端或者客户端的问题，那么究竟是应用程序自身引起的问题呢，还是内核导致的问题呢？

而且很多时候，这种问题往往是一天可能最多抖动一两次，我们很难去抓现场信息。

为了更好地处理这类折磨人的问题，我也摸索了一些手段来进行实时追踪，既不会给应用程序和系统带来明显的开销，又可以在出现这些故障时能够把信息给抓取出来，从而帮助我们快速定位出问题所在。

因此，这节课我来分享下我在这方面的一些实践，以及解决过的一些具体案例。

当然，这些实践并不仅仅适用于这种 C/S 架构，对于其他应用程序，特别是对延迟比较敏感的应用程序，同样具备参考意义。比如说：

如果我的业务运行在虚拟机里面，那怎么追踪呢？

如果 Client 和 Server 之间还有一个 Proxy，那怎么判断是不是 Proxy 引起的问题呢？

那么，我们就先从生产环境中 C/S 架构的网络抖动案例说起。

如何分析 C/S 架构中的网络抖动问题？

典型的C/S架构

上图就是一个典型的 C/S 架构，Client 和 Server 之间可能经过了很复杂的网络，但是对于服务器开发者或者运维人员而言，这些中间网络可以理解为是一个黑盒，很难去获取这些网络的详细信息，更不用说到这些网络设备上去做 debug 了。所以，我在这里把它们都简化为了一个 Router（路由器），然后 Client 和 Server 通过这个路由器来相互通信。比如互联网场景中的数据库服务（像 MySQL）、http 服务等都是这种架构。而当时给我们提需求来诊断网络抖动问题的也是 MySQL 业务。因此，接下来我们就以 MySQL 为例来进行具体讲解。

MySQL 的业务方反馈说他们的请求偶尔会超时很长，但不清楚是什么原因引起了超时，对应到上图中，就是 D 点收到响应的时刻和 A 点发出请求的时刻，这个时间差会偶然性地有毛刺。在发生网络问题时，使用 tcpdump 来抓包是常用的分析手段，当你不清楚该如何来分析网络问题时，那就使用 tcpdump 先把事故现场保存下来吧。

如果你使用过 tcpdump 来分析问题，那你应该也吐槽过用 tcpdump 分析问题会很麻烦。如果你熟悉 wireshark 的话，就会相对容易一些了。但是对于大部分人而言呢，学习 wirkshark 的成本也是很高的，而且 wireshark 的命令那么多，把每一条命令都记清楚也是件很麻烦的事。

回到我们这个案例中，在 MySQL 发生抖动的时候，我们的业务人员也使用了 tcpdump 来抓包保存了现场，但是他们并不清楚该如何分析这些 tcpdump 信息。在我帮他们分析这些 tcpdump 信息的时候，也很难把 tcpdump 信息和业务抖动的时刻关联起来。这是因为虽然我们知道业务抖动发生的时刻，比如说 21:00:00.000 这个时刻，但是在这一时刻的附近可能会有非常多的 TCP 数据包，很难简单地依赖时间戳把二者关联起来。而且，更重要的原因是，我们知道 TCP 是数据流，上层业务的一个请求可能会被分为多个 TCP 包（TCP Segment），同样地，多个请求也可能被合并为一个 TCP 包。也就是说，TCP 流是很难和应用数据关联起来的。这就是用 tcpdump 分析业务请求和响应的难点。

针对 tcpdump 难以分析应用协议的问题，有一个思路是，在 tcpdump 的时候把数据也保存下来，然后使用 tcpdump 再进一步去解析这些应用协议。但是你会发现，用这种方式来处理生产环境中的抖动问题是很不现实的，因为在生产环境中的 TCP 连接动辄数百上千条，我们往往并不清楚抖动发生在哪个 TCP 连接上。如果把这些 TCP 流都 dump 出来，data 部分即使只 dump 与应用协议有关的数据，这对磁盘 I/O 也是个负担。那有什么好办法吗？

好办法还是需要和应用协议关联起来，不过我们可以把这些应用协议做一层抽象，从而可以更简单地来解析它们，甚至无需解析。对于 MySQL 而言呢，工具tcprstat就是来做这件事的。

tcprstat 的大致原理是利用 MySQL 的 request-response 特征来简化对协议内容的处理。request-response 是指一个请求到达 MySQL 后，MySQL 处理完该请求，然后回 response，Client 侧收到 response 后再去发下一个 request，然后 MySQL 收到下一个 request 并处理。也就是说这种模型是典型的串行方式，处理完了一个再去处理下一个。所以 tcprstat 就可以以数据包到达 MySQL Server 侧作为起始时间点，以 MySQL 将最后一个数据包发出去作为结束时间点，然后这二者的时间差就是 RT（Response Time），这个过程大致如下图所示：

tcprstat追踪RT抖动

tcprstat 会记录 request 的到达时间点，以及 request 发出去的时间点，然后计算出 RT 并记录到日志中。当时我们把 tcprstat 部署到 MySQL server 侧后，发现每一个 RT 值都很小，并没有延迟很大的情况，所以看起来服务端并没有问题。那么问题是否发生在 Client 这里呢？

在我们想要把 tcprstat 也部署在 Client 侧抓取信息时，发现它只支持在 Server 侧部署，所以我们对 tcprstat 做了一些改造，让它也可以部署在 Client 侧。

这种改造并不麻烦，因为在 Client 侧解析的也是 MySQL 协议，只是 TCP 流的方向跟 Server 侧相反，Client 侧是发请求收响应，而 Server 侧是收请求发响应。

在改造完成后，我们就开始部署 tcprstat 来抓取抖动现场了。在业务发生抖动时，通过我们抓取到的信息显示，Client 在收到响应包的时候就已经发生延迟了，也就是说问题同样也不是发生在 Client 侧。这就有些奇怪了，既然 Client 和 Server 都没有问题，难道是网络链路出现了问题？

为了明确这一点，我们就在业务低峰期使用 ping 包来检查网络是否存在问题。ping 了大概数小时后，我们发现 ping 响应时间忽然变得很大，从不到 1ms 的时间增大到了几十甚至上百 ms，然后很快又恢复正常。

根据这个信息，我们推断某个交换机可能存在拥塞，于是就联系交换机管理人员来分析交换机。在交换机管理人员对这个链路上的交换机逐一排查后，最终定位到一台接入交换机确实有问题，它会偶然地出现排队很长的情况。而之所以 MySQL 反馈有抖动，其他业务没有反馈，只是因为这个接入交换机上的其他业务并不关心抖动。在交换机厂商帮忙修复了这个问题后，就再也没有出现过这种偶发性的抖动了。

这个结果看似很简单，但是分析过程还是很复杂的。因为一开始我们并不清楚问题发生在哪里，只能一步步去排查，所以这个分析过程也花费了几天的时间。

交换机引起的网络抖动问题，只是我们分析过的众多抖动案例之一。除了这类问题外，我们还分析过很多抖动是由于 Client 侧存在问题或者 Server 侧存在问题。在分析了这么多抖动问题之后，我们就开始思考，能否针对这类问题来做一个自动化分析系统呢？而且我们部署运行 tcprstat 后，也发现它存在一些不足之处，主要是它的性能开销略大，特别是在 TCP 连接数较多的情况下，它的 CPU 利用率甚至能够超过 10%，这难以满足我们生产环境中长时间运行的需要。

tcprstat 会有这么高的 CPU 开销，原因其实与 tcpdump 是类似的，它在旁路采集数据后会拷贝到用户空间来处理，这个拷贝以及处理时间就比较消耗 CPU。

为了满足生产环境的需求，我们在 tcprstat 的基础上，做了一个更加轻量级的分析系统。

如何轻量级地判断抖动发生在哪里？

我们的目标是在 10Gb 网卡的高并发场景下尽量地降低监控开销，最好可以控制在 1% 以内，而且不能给业务带来明显延迟。要想降低 CPU 开销，很多工作就需要在内核里面来完成，就跟现在很流行的 eBPF 这个追踪框架类似：内核处理完所有的数据，然后将结果返回给用户空间。

能够达到这个目标的方案大致有两种：一种是使用内核模块，另一种是使用轻量级的内核追踪框架。

使用内核模块的缺点是它的安装部署会很不方便，特别是在线上内核版本非常多的情况下，比如说，我们的线上既有 CentOS-6 的操作系统，也有 CentOS-7 的操作系统，每个操作系统又各自有很多小版本，以及我们自己发布的版本。统一线上的内核版本是件很麻烦的事，这会涉及到很多变更，不太现实。所以这种现状也就决定了，使用内核模块的方式需要付出比较高的维护成本。而且内核模块的易用性也很差，这会导致业务人员和运维人员排斥使用它，从而增加它的推广难度。基于这些考虑，我们最终选择了基于 systemtap 这个追踪框架来开发。没有选择 eBPF 的原因是，它对内核版本要求较高，而我们线上很多都是 CentOS-7 的内核。

基于 systemtap 实现的追踪框架大致如下图所示：

TCP流的追踪

它会追踪每一个 TCP 流，TCP 流对应到内核里的实现就是一个 struct sock 实例，然后记录 TCP 流经过 A/B/C/D 这四个点的时刻，依据这几个时间点，我们就可以得到下面的结论：

如果 C-B 的时间差较大，那就说明 Server 侧有抖动，否则是 Client 或网络的问题；

如果 D-A 的时间差较小，那就说明是 Client 侧问题，否则是 Server 或者网络的问题。

这样在发生 RT 抖动时，我们就能够区分出抖动是发生在 Client，Server，还是网络中了，这会大大提升分析定位问题的效率。在定位到问题出在哪里后，你就可以使用我们在“11 讲”、“12 讲”和“13 讲”里讲到的知识点，再去进一步分析具体的原因是什么了。

在使用 systemtap 的过程中我们也踩了不少坑，在这里也分享给你，希望你可以避免：

systemtap 的加载过程是一个开销很大的过程，主要是 CPU 的开销。因为 systemtap 的加载会编译 systemtap 脚本，这会比较耗时。你可以提前将你的 systemtap 脚本编译为内核模块，然后直接加载该模块来避免 CPU 开销；

systemtap 有很多开销控制选项，你可以设置开销阈值来作为兜底方案，以防止异常情况下它占用太多 CPU；

systemtap 进程异常退出后可能不会卸载 systemtap 模块，在你发现 systemtap 进程退出后，你需要检查它是否也把对应的内核模块给卸载了。如果没有，那你需要手动卸载一下，以免产生不必要的问题。

C/S 架构是互联网服务中比较典型的场景，那针对其他场景我们该如何来分析问题呢？接下来我们以虚拟机这种场景为例来看一下。

虚拟机场景下该如何判断抖动是发生在宿主机上还是虚拟机里？

随着云计算的发展，越来越多的业务开始部署在云上，很多企业或者使用自己定制的私有云，或者使用公有云。我们也有很多业务部署在自己的私有云中，既有基于 KVM 的虚拟机，也有基于 Kubernetes 和 Docker 的容器。以虚拟机为例，在 Server 侧发生抖动的时候，业务人员还想进一步知道，抖动是发生在 Server 侧的虚拟机内部，还是发生在 Server 侧的宿主机上。要想实现这个需求，我们只需要进一步扩展，再增加新的 hook 点，去记录 TCP 流经过虚拟机的时间点就好了，如下图所示：

虚拟机场景下TCP流的追踪

这样我们就可以根据 F 和 E 的时间差来判断抖动是否发生在虚拟机内部。针对这个需求，我们对 tcprstat 也做了类似的改造，让它可以识别出抖动是否发生在虚拟机内部。这个改造也不复杂，tcprstat 默认只处理目标地址为本机的数据包，不会处理转发包，所以我们让它支持混杂模式，然后就可以处理转发包了。当然，虚拟机的具体网络配置是千差万别的，你需要根据你的实际虚拟网络配置来做调整。

总之，希望你可以举一反三，根据你的实际业务场景来做合理的数据分析，而不要局限于我们这节课所列举的这几个具体场景。

课堂总结

我们这堂课以典型的 C/S 架构为例，分析了 RT 发生抖动时，该如何高效地识别出问题发生在哪里。我来总结一下这节课的重点：

tcpdump 是分析网络问题必须要掌握的工具，但是用它分析问题并不容易。在你不清楚该如何分析网络问题时，你可以先使用 tcpdump 把现场信息保存下来；

TCP 是数据流，如何把 TCP 流和具体的业务请求 / 响应数据包关联起来，是分析具体应用问题的前提。你需要结合你的业务模型来做合理的关联；

RT 抖动问题是很棘手的，你需要结合你的业务模型来开发一些高效的问题分析工具。如果你使用的是 Redhat 或者 CentOS，那么你可以考虑使用 systemtap；如果是 Ubuntu，你可以考虑使用 lttng。

课后作业

结合这堂课的第一张图，在这张图中，请问是否可以用 TCP 流到达 B 点的时刻（到达 Server 这台主机的时间）减去 TCP 流经过 A 点的时刻（到达 Client 这台主机的时间）来做为网络耗时？为什么？

结合我们在“13 讲“里讲到的 RTT 这个往返时延，你还可以进一步思考：是否可以使用 RTT 来作为这次网络耗时？为什么？欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

15 分析篇 | 如何高效地分析TCP重传问题？

你好，我是邵亚方。

我们在基础篇和案例篇里讲了很多问题，比如说 RT 抖动问题、丢包问题、无法建连问题等等。这些问题通常都会伴随着 TCP 重传，所以我们往往也会抓取 TCP 重传信息来辅助我们分析这些问题。

而且 TCP 重传也是一个信号，我们通常会利用这个信号来判断系统是否稳定。比如说，如果一台服务器的 TCP 重传率很高，那这个服务器肯定是存在问题的，需要我们及时采取措施，否则可能会产生更加严重的故障。

但是，TCP 重传率分析并不是一件很容易的事，比如说现在某台服务器的 TCP 重传率很高，那究竟是什么业务在进行 TCP 重传呢？对此，很多人并不懂得如何来分析。所以，在这节课中，我会带你来认识 TCP 重传是怎么回事，以及如何来高效地分析它。

什么是 TCP 重传 ？

我在“开篇词”中举过一个 TCP 重传率的例子，如下图所示：

这是互联网企业普遍都有的 TCP 重传率监控，它是服务器稳定性的一个指标，如果它太高，就像上图中的那些毛刺一样，往往就意味着服务器不稳定了。那 TCP 重传率究竟表示什么呢？

其实 TCP 重传率是通过解析 /proc/net/snmp 这个文件里的指标计算出来的，这个文件里面和 TCP 有关的关键指标如下：

TCP 重传率的计算公式如下：

retrans = (RetransSegs－last RetransSegs) ／ (OutSegs－last OutSegs) * 100

也就是说，单位时间内 TCP 重传包的数量除以 TCP 总的发包数量，就是 TCP 重传率。那我们继续看下这个公式中的 RetransSegs 和 OutSegs 是怎么回事，我画了两张示例图来演示这两个指标的变化：

不存在重传的情况

存在重传的情况

通过这两个示例图，你可以发现，发送端在发送一个 TCP 数据包后，会把该数据包放在发送端的发送队列里，也叫重传队列。此时，OutSegs 会相应地加 1，队列长度也为 1。如果可以收到接收端对这个数据包的 ACK，该数据包就会在发送队列中被删掉，然后队列长度变为 0；如果收不到这个数据包的 ACK，就会触发重传机制，我们在这里演示的就是超时重传这种情况，也就是说发送端在发送数据包的时候，会启动一个超时重传定时器（RTO），如果超过了这个时间，发送端还没有收到 ACK，就会重传该数据包，然后 OutSegs 加 1，同时 RetransSegs 也会加 1。

这就是 OutSegs 和 RetransSegs 的含义：每发出去一个 TCP 包（包括重传包），OutSegs 会相应地加 1；每发出去一个重传包，RetransSegs 会相应地加 1。同时，我也在图中展示了重传队列的变化，你可以仔细看下。

除了上图中展示的超时重传外，还有快速重传机制。关于快速重传，你可以参考“13 讲”，我就不在这里详细描述了。

明白了 TCP 重传是如何定义的之后，我们继续来看下哪些情况会导致 TCP 重传。

引起 TCP 重传的情况在整体上可以分为如下两类。

丢包

TCP 数据包在网络传输过程中可能会被丢弃；接收端也可能会把该数据包给丢弃；接收端回的 ACK 也可能在网络传输过程中被丢弃；数据包在传输过程中发生错误而被接收端给丢弃……这些情况都会导致发送端重传该 TCP 数据包。

拥塞

TCP 数据包在网络传输过程中可能会在某个交换机 / 路由器上排队，比如臭名昭著的 Bufferbloat（缓冲膨胀）；TCP 数据包在网络传输过程中因为路由变化而产生的乱序；接收端回的 ACK 在某个交换机 / 路由器上排队……这些情况都会导致发送端再次重传该 TCP 数据包。

总之，TCP 重传可以很好地作为通信质量的信号，我们需要去重视它。

那么，当我们发现某个主机上 TCP 重传率很高时，该如何去分析呢？

分析 TCP 重传的常规手段

最常规的分析手段就是 tcpdump，我们可以使用它把进出某个网卡的数据包给保存下来：

$ tcpdump -s 0 -i eth0 -w tcpdumpfile

然后在 Linux 上我们可以使用 tshark 这个工具（wireshark 的 Linux 版本）来过滤出 TCP 重传包：

$ tshark -r tcpdumpfile -R tcp.analysis.retransmission

如果有重传包的话，就可以显示出来了，如下是一个 TCP 重传的示例：

3481  20.277303 10.17.130.20 -> 124.74.250.144 TCP 70 [TCP Retransmission] 35993 > https [SYN] Seq=0 Win=14600 Len=0 MSS=1460 SACK_PERM=1 TSval=3231504691 TSecr=0

3659  22.277070 10.17.130.20 -> 124.74.250.144 TCP 70 [TCP Retransmission] 35993 > https [SYN] Seq=0 Win=14600 Len=0 MSS=1460 SACK_PERM=1 TSval=3231506691 TSecr=0

8649  46.539393 58.216.21.165 -> 10.17.130.20 TLSv1 113 [TCP Retransmission] Change Cipher Spec, Encrypted Handshake Messag

借助 tcpdump，我们就可以看到 TCP 重传的详细情况。从上面这几个 TCP 重传信息中，我们可以看到，这是发生在 10.17.130.20:35993 - 124.74.250.144: 443 这个 TCP 连接上的重传；通过[SYN]这个 TCP 连接状态，可以看到这是发生在三次握手阶段的重传。依据这些信息，我们就可以继续去 124.74.250.144 这个主机上分析 https 这个服务为什么无法建立新的连接了。

但是，我们都知道 tcpdump 很重，如果直接在生产环境上进行采集的话，难免会对业务造成性能影响。那有没有更加轻量级的一些分析方法呢？

如何高效地分析 TCP 重传 ？

其实，就像应用程序实现一些功能需要调用对应的函数一样，TCP 重传也需要调用特定的内核函数。这个内核函数就是 tcp_retransmit_skb()。你可以把这个函数名字里的 skb 理解为是一个需要发送的网络包。那么，如果我们想要高效地追踪 TCP 重传情况，那么直接追踪该函数就可以了。

追踪内核函数最通用的方法是使用 Kprobe，Kprobe 的大致原理如下：

Kprobe基本原理

你可以实现一个内核模块，该内核模块中使用 Kprobe 在 tcp_retransmit_skb 这个函数入口插入一个 probe，然后注册一个 break_handler，这样在执行到 tcp_retransmit_skb 时就会异常跳转到注册的 break_handler 中，然后在 break_handler 中解析 TCP 报文（skb）就可以了，从而来判断是什么在重传。

如果你觉得实现内核模块比较麻烦，可以借助 ftrace 框架来使用 Kprobe。Brendan Gregg 实现的tcpretrans采用的就是这种方式，你也可以直接使用它这个工具来追踪 TCP 重传。不过，该工具也有一些缺陷，因为它是通过读取 /proc/net/tcp 这个文件来解析是什么在重传，所以它能解析的信息比较有限，而且如果 TCP 连接持续时间较短（比如短连接），那么该工具就无法解析出来了。另外，你在使用它时需要确保你的内核已经打开了 ftrace 的 tracing 功能，也就是 /sys/kernel/debug/tracing/tracing_on 中的内容需要为 1；在 CentOS-6 上，还需要 /sys/kernel/debug/tracing/tracing_enabled 也为 1。

$ cat /sys/kernel/debug/tracing/tracing_on
1

如果为 0 的话，你需要打开它们，例如：

$ echo 1 > /sys/kernel/debug/tracing/tracing_on

然后在追踪结束后，你需要来关闭他们：

$ echo 0 > /sys/kernel/debug/tracing/tracing_on

由于 Kprobe 是通过异常（Exception）这种方式来工作的，所以它还是有一些性能开销的，在 TCP 发包快速路径上还是要避免使用 Kprobe。不过，由于重传路径是慢速路径，所以在重传路径上添加 Kprobe 也无需担心性能开销。

Kprobe 这种方式使用起来还是略有些不便，为了让 Linux 用户更方便地观察 TCP 重传事件，4.16 内核版本中专门添加了TCP tracepoint来解析 TCP 重传事件。如果你使用的操作系统是 CentOS-7 以及更老的版本，就无法使用该 Tracepoint 来观察了；如果你的版本是 CentOS-8 以及后续更新的版本，那你可以直接使用这个 Tracepoint 来追踪 TCP 重传，可以使用如下命令：

$ cd /sys/kernel/debug/tracing/events/
$ echo 1 > tcp/tcp_retransmit_skb/enable

然后你就可以追踪 TCP 重传事件了：

$ cat trace_pipe
<idle>-0     [007] ..s. 265119.290232: tcp_retransmit_skb: sport=22 dport=62264 saddr=172.23.245.8 daddr=172.30.18.225 saddrv6=::ffff:172.23.245.8 daddrv6=::ffff:172.30.18.225 state=TCP_ESTABLISHED

可以看到，当 TCP 重传发生时，该事件的基本信息就会被打印出来。多说一句，在最开始的版本中是没有“state=TCP_ESTABLISHED”这一项的。如果没有这一项，我们就无法识别该重传事件是不是发生在三次握手阶段了，所以我给内核贡献了一个 PATCH 来显示 TCP 连接状态，以便于问题分析，具体见tcp: expose sk_state in tcp_retransmit_skb tracepoint这个 commit。

追踪结束后呢，你需要将这个 Tracepoint 给关闭：

$ echo 0 > tcp/tcp_retransmit_skb/enable

Tracepoint 这种方式不仅使用起来更加方便，而且它的性能开销比 Kprobe 要小，所以我们在快速路径上也可以使用它。

因为 Tracepoint 对 TCP 重传事件的支持，所以 tcpretrans 这个工具也跟着进行了一次升级换代。它通过解析该 Tracepoint 实现了对 TCP 重传事件的追踪，而不再使用之前的 Kprobe 方式，具体你可以参考bcc tcpretrans。再多说一句，Brendan Gregg 在实现这些基于 ebpf 的 TCP 追踪工具之前也曾经跟我讨论过，所以我对他的这个工具才会这么熟悉。

我们针对 TCP 重传事件的分析就先讲到这里，希望能给你带来一些启发，去开发一些更加高效的工具来分析你遇到的 TCP 问题或者其他问题。

课堂总结

这堂课我们主要讲了 TCP 重传的一些知识，关于 TCP 重传你需要重点记住下面这几点：

TCP 重传率可以作为 TCP 通信质量的信号，如果它很高，那说明这个 TCP 连接很不稳定；

产生 TCP 重传的问题主要是丢包和网络拥塞这两种情况；

TCP 重传时会调用特定的内核函数，我们可以追踪该函数的调用情况来追踪 TCP 重传事件；

Kprobe 是一个很通用的追踪工具，在低版本内核上，你可以使用这个方法来追踪 TCP 重传事件；

Tracepoint 是一个更加轻量级也更加方便的追踪 TCP 重传的工具，但是需要你的内核版本为 4.16+；

如果你想要更简单些，那你可以直接使用 tcpretrans 这个工具。

课后作业

请问我们提到的 tracepoint 观察方式，或者 tcpretrans 这个工具，可以追踪收到的 TCP 重传包吗？为什么？欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

16 套路篇 | 如何分析常见的TCP问题？

你好，我是邵亚方。

对互联网服务而言， 网络问题是非常多的，而且很多问题的外在表现都是网络问题，这就需要我们从网络入手，分析清楚根本原因是什么。而要分析各种各样的网络问题，你必须掌握一些分析手段，这样在出现问题的时候，你就可以高效地找到原因。这节课我就带你来了解下 TCP 的常见问题，以及对应的分析套路。

在 Linux 上检查网络的常用工具

当服务器产生问题，而我们又不清楚问题和什么有关时，就需要运行一些工具来检查系统的整体状况。其中，dstat 是我们常用的一种检查工具：

$ dstat
--total-cpu-usage-- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai stl| read  writ| recv  send|  in   out | int   csw
  8   1  91   0   0|   0  4096B|7492B 7757B|   0     0 |4029  7399
  8   1  91   0   0|   0     0 |7245B 7276B|   0     0 |4049  6967
  8   1  91   0   0|   0   144k|7148B 7386B|   0     0 |3896  6971
  9   2  89   0   0|   0     0 |7397B 7285B|   0     0 |4611  7426
  8   1  91   0   0|   0     0 |7294B 7258B|   0     0 |3976  7062

如上所示，dstat 会显示四类系统资源的整体使用情况和两个关键的系统指标。这四类系统资源分别是：CPU、磁盘 I/O、 网络和内存。两个关键的系统指标是中断次数（int）和上下文切换次数（csw）。而每个系统资源又会输出它的一些关键指标，这里你需要注意以下几点：

如果你发现某一类系统资源对应的指标比较高，你就需要进一步针对该系统资源做更深入的分析。假设你发现网络吞吐比较高，那就继续观察网络的相关指标，你可以用 dstat -h 来查看，比如针对 TCP，就可以使用 dstat -tcp：

$ dstat --tcp
------tcp-sockets-------
lis  act  syn  tim  clo
  27   38    0    0    0
  27   38    0    0    0

它会统计并显示系统中所有的 TCP 连接状态，这些指标的含义如下：

在得到了 TCP 连接的整体状况后，如果你想要看 TCP 连接的详细信息，你可以使用 ss 这个命令来继续观察。通过 ss 你可以查看到每个 TCP 连接都是什么样的：

$ ss -natp
State         Recv-Q         Send-Q                                       Local Address:Port                     Peer Address:Port
LISTEN0      100                                  0.0.0.0:36457         0.0.0.0:*                                                                                users:(("test",pid=11307,fd=17))
LISTEN0      5                                    0.0.0.0:33811         0.0.0.0:*                                                                                users:(("test",pid=11307,fd=19))
ESTAB 0      0                                  127.0.0.1:57396       127.0.1.1:34751                                                                            users:(("test",pid=11307,fd=106))
ESTAB 0      0                                  127.0.0.1:57384       127.0.1.1:34751                                                                            users:(("test",pid=11307,fd=100))

如上所示，我们能查看到每个 TCP 连接的状态（State）、接收队列大小（Recv-Q）、发送队列大小（Send-Q）、本地 IP 和端口（Local Address:Port ）、远端 IP 和端口（Peer Address:Port）以及打开该 TCP 连接的进程信息。

除了 ss 命令外，你也可以使用 netstat 命令来查看所有 TCP 连接的详细信息：

$ netstat -natp

不过，我不建议你使用 netstat，最好还是用 ss。因为 netstat 不仅比 ss 慢，而且开销也大。netstat 是通过直接读取 /proc/net/ 下面的文件来解析网络连接信息的；而 ss 使用的是 netlink 方式，这种方式的效率会高很多。

netlink 在解析时会依赖内核的一些诊断模块，比如解析 TCP 信息就需要 tcp_diag 这个诊断模块。如果诊断模块不存在，那么 ss 就无法使用 netlink 这种方式了，这个时候它就会退化到和 netstat 一样，也就是使用解析 /proc/net/ 这种方式，当然了，它的效率也会相应变差。

另外，如果你去看 netstat 手册，通过 man netstat，你会发现这样一句话“This program is obsolete. Replacement for netstat is ss”。所以，以后在分析网络连接问题时，我们尽量还是使用 ss，而不是 netstat。

netstat 属于 net-tools 这个比较古老的工具集，而 ss 属于 iproute2 这个工具集。net-tools 中的常用命令，几乎都可以用 iproute2 中的新命令来代替，比如：

除了查看系统中的网络连接信息外，我们有时候还需要去查看系统的网络状态，比如说系统中是否存在丢包，以及是什么原因引起了丢包，这时候我们就需要 netstat -s 或者它的替代工具 nstat 了：

$ nstat -z | grep -i drop
TcpExtLockDroppedIcmps          0                  0.0
TcpExtListenDrops               0                  0.0
TcpExtTCPBacklogDrop            0                  0.0
TcpExtPFMemallocDrop            0                  0.0
TcpExtTCPMinTTLDrop             0                  0.0
TcpExtTCPDeferAcceptDrop        0                  0.0
TcpExtTCPReqQFullDrop           0                  0.0
TcpExtTCPOFODrop                0                  0.0
TcpExtTCPZeroWindowDrop         0                  0.0
TcpExtTCPRcvQDrop               0                  0.0

上面输出的这些信息就包括了常见的丢包原因，因为我的这台主机很稳定，所以你可以看到输出的结果都是 0。

假如你通过这些常规检查手段没有发现异常，那你就需要考虑使用网络分析的必备工具——tcpdump 了。

分析网络问题你必须要掌握的工具：tcpdump

tcpdump 的使用技巧有很多，在这里我们不讲述这些使用技巧，而是讲述一下它的工作原理，以便于你理解 tcpdump 到底在干什么，以及它能够分析什么样的问题。

tcpdump 的大致原理如下图所示：

tcpdump基本原理

tcpdump 抓包使用的是 libpacp 这种机制。它的大致原理是：在收发包时，如果该包符合 tcpdump 设置的规则（BPF filter），那么该网络包就会被拷贝一份到 tcpdump 的内核缓冲区，然后以 PACKET_MMAP 的方式将这部分内存映射到 tcpdump 用户空间，解析后就会把这些内容给输出了。

通过上图你也可以看到，在收包的时候，如果网络包已经被网卡丢弃了，那么 tcpdump 是抓不到它的；在发包的时候，如果网络包在协议栈里被丢弃了，比如因为发送缓冲区满而被丢弃，tcpdump 同样抓不到它。我们可以将 tcpdump 的能力范围简单地总结为：网卡以内的问题可以交给 tcpdump 来处理；对于网卡以外（包括网卡上）的问题，tcpdump 可能就捉襟见肘了。这个时候，你需要在对端也使用 tcpdump 来抓包。

你还需要知道一点，那就是 tcpdump 的开销比较大，这主要在于 BPF 过滤器。如果系统中存在非常多的 TCP 连接，那么这个过滤的过程是非常耗时的，所以在生产环境中要慎用。但是，在出现网络问题时，如果你真的没有什么排查思路，那就想办法使用 tcpdump 来抓一下包吧，也许它的输出会给你带来一些意外的惊喜。

如果生产环境上运行着很重要的业务，你不敢使用 tcpdump 来抓包，那你就得去研究一些更加轻量级的追踪方式了。接下来，我给你推荐的轻量级追踪方式是 TCP Tracepoints。

TCP 疑难问题的轻量级分析手段：TCP Tracepoints

Tracepoint 是我分析问题常用的手段之一，在遇到一些疑难问题时，我通常都会把一些相关的 Tracepoint 打开，把 Tracepoint 输出的内容保存起来，然后再在线下环境中分析。通常，我会写一些 Python 脚本来分析这些内容，毕竟 Python 在数据分析上还是很方便的。

对于 TCP 的相关问题，我也习惯使用这些 TCP Tracepoints 来分析问题。要想使用这些 Tracepoints，你的内核版本需要为 4.16 及以上。这些常用的 TCP Tracepoints 路径位于 /sys/kernel/debug/tracing/events/tcp/ 和 /sys/kernel/debug/tracing/events/sock/，它们的作用如下表所示：

这里多说两句，表格里的 tcp_rcv_space_adjust 是我在分析 RT 抖动问题时贡献给内核的，具体你可以看net: introduce a new tracepoint for tcp_rcv_space_adjust这个 commit。还有 inet_sock_set_state，该 Tracepoint 也是我贡献给 Linux 内核的，具体可详见net: tracepoint: replace tcp_set_state tracepoint with inet_sock_set_state tracepoint。其实我对“inet_sock_set_state”这个名字不太满意，本来想使用“inet_sk_set_state”来命名的，因为后者更精炼，但是为了更好地匹配内核里的 struct inet_sock 结构体，我还是选择了现在这个略显臃肿的名字。

我们回到 TCP Tracepoints 这一轻量级的追踪方式。有一篇文章对它讲解得很好，就是 Brendan Gregg 写的TCP Tracepoints，这里面还详细介绍了基于 Tracepoints 的一些工具，如果你觉得用 Python 脚本解析 TCP Tracepoints 的输出有点麻烦，你可以直接使用里面推荐的那些工具。不过，你需要注意的是，这些工具都是基于 ebpf 来实现的，而 ebpf 有一个缺点，就是它在加载的时候 CPU 开销有些大。这是因为有一些编译工作比较消耗 CPU，所以你在使用这些命令时要先看下你的系统 CPU 使用情况。当 ebpf 加载起来后，CPU 开销就很小了，大致在 1% 以内。在停止 ebpf 工具的追踪时，也会有一些 CPU 开销，不过这个开销比加载时消耗的要小很多，但是你同样需要注意一下，以免影响到业务。

相比于 tcpdump 的臃肿，这些 TCP Tracepoints 就很轻量级了，你有必要用一用它们。

好了， 我们这节课就讲到这里。

课堂总结

我们讲了 TCP 问题分析的惯用套路，我再次强调一下这节课的重点：

尽量不要使用 netstat 命令，而是多使用它的替代品 ss，因为 ss 的性能开销更小，运行也更快；

当你面对网络问题一筹莫展时，可以考虑使用 tcpdump 抓包看看，当系统中的网络连接数较大时，它对系统性能会产生比较明显的影响，所以你需要想办法避免它给业务带来实质影响；

TCP Tracepoints 是比较轻量级的分析方案，你需要去了解它们，最好试着去用一下它们。

课后作业

请问 tcpdump 在解析内核缓冲区里的数据时，为什么使用 PACKET_MMAP 这种方式？你了解这种方式吗？这样做的好处是什么？欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

17 基础篇 | CPU是如何执行任务的？

你好，我是邵亚方。

如果你做过性能优化的话，你应该有过这些思考，比如说：

如何让 CPU 读取数据更快一些？

同样的任务，为什么有时候执行得快，有时候执行得慢？

我的任务有些比较重要，CPU 如果有争抢时，我希望可以先执行这些任务，这该怎么办呢？

多线程并行读写数据是如何保障同步的？

…

要想明白这些问题，你就需要去了解 CPU 是如何执行任务的，只有明白了 CPU 的执行逻辑，你才能更好地控制你的任务执行，从而获得更好的性能。

CPU 是如何读写数据的 ？

我先带你来看下 CPU 的架构，因为你只有理解了 CPU 的架构，你才能更好地理解 CPU 是如何执行指令的。CPU 的架构图如下所示：

CPU架构

你可以直观地看到，对于现代处理器而言，一个实体 CPU 通常会有两个逻辑线程，也就是上图中的 Core 0 和 Core 1。每个 Core 都有自己的 L1 Cache，L1 Cache 又分为 dCache 和 iCache，对应到上图就是 L1d 和 L1i。L1 Cache 只有 Core 本身可以看到，其他的 Core 是看不到的。同一个实体 CPU 中的这两个 Core 会共享 L2 Cache，其他的实体 CPU 是看不到这个 L2 Cache 的。所有的实体 CPU 会共享 L3 Cache。这就是典型的 CPU 架构。

相信你也看到，在 CPU 外还会有内存（DRAM）、磁盘等，这些存储介质共同构成了体系结构里的金字塔存储层次。如下所示：

金字塔存储层次

在这个“金字塔”中，越往下，存储容量就越大，它的速度也会变得越慢。Jeff Dean 曾经研究过 CPU 对各个存储介质的访问延迟，具体你可以看下latency里的数据，里面详细记录了每个存储层次的访问延迟，这也是我们在性能优化时必须要知道的一些延迟数据。你可不要小瞧它，在某些场景下，这些不同存储层次的访问延迟差异可能会导致非常大的性能差异。

我们就以 Cache 访问延迟（L1 0.5ns，L2 10ns）和内存访问延迟（100ns）为例，我给你举一个实际的案例来说明访问延迟的差异对性能的影响。

之前我在做网络追踪系统时，为了更方便地追踪 TCP 连接，我给 Linux Kernel 提交了一个 PATCH 来记录每个连接的编号，具体你可以参考这个 commit：net: init sk_cookie for inet socket。该 PATCH 的大致作用是，在每次创建一个新的 TCP 连接时（关于 TCP 这部分知识，你可以去温习上一个模块的内容），它都会使用 net namespace（网络命名空间）中的 cookie_gen 生成一个 cookie 给这个新建的连接赋值。

可是呢，在这个 PATCH 被合入后，Google 工程师 Eric Dumazet 发现在他的 SYN Flood 测试中网络吞吐量会下降约 24%。后来经过分析发现，这是因为 net namespace 中所有 TCP 连接都在共享 cookie_gen。在高并发情况下，瞬间会有非常多的新建 TCP 连接，这时候 cookie_gen 就成了一个非常热的数据，从而被缓存在 Cache 中。如果 cookie_gen 的内容被修改的话，Cache 里的数据就会失效，那么当有其他新建连接需要读取这个数据时，就不得不再次从内存中去读取。而你知道，内存的延迟相比 Cache 的延迟是大很多的，这就导致了严重的性能下降。这个问题就是典型的 False Sharing，也就是 Cache 伪共享问题。

正因为这个 PATCH 给高并发建连这种场景带来了如此严重的性能损耗，所以它就被我们给回退（Revert）了，你具体可以看Revert "net: init sk_cookie for inet socket"这个 commit。不过，cookie_gen 对于网络追踪还是很有用的，比如说在使用 ebpf 来追踪 cgroup 的 TCP 连接时，所以后来 Facebook 的一个工程师把它从 net namespace 这个结构体里移了出来，改为了一个全局变量。

由于 net namespace 被很多 TCP 连接共享，因此这个结构体非常容易产生这类 Cache 伪共享问题，Eric Dumazet 也在这里引入过一个 Cache 伪共享问题：net: reorder ‘struct net’ fields to avoid false sharing。

接下来，我们就来看一下 Cache 伪共享问题究竟是怎么回事。

Cache Line False Sharing

如上图所示，两个 CPU 上并行运行着两个不同线程，它们同时从内存中读取两个不同的数据，这两个数据的地址在物理内存上是连续的，它们位于同一个 Cache Line 中。CPU 从内存中读数据到 Cache 是以 Cache Line 为单位的，所以该 Cache Line 里的数据被同时读入到了这两个 CPU 的各自 Cache 中。紧接着这两个线程分别改写不同的数据，每次改写 Cache 中的数据都会将整个 Cache Line 置为无效。因此，虽然这两个线程改写的数据不同，但是由于它们位于同一个 Cache Line 中，所以一个 CPU 中的线程在写数据时会导致另外一个 CPU 中的 Cache Line 失效，而另外一个 CPU 中的线程在读写数据时就会发生 cache miss，然后去内存读数据，这就大大降低了性能。

Cache 伪共享问题可以说是性能杀手，我们在写代码时一定要留意那些频繁改写的共享数据，必要的时候可以将它跟其他的热数据放在不同的 Cache Line 中避免伪共享问题，就像我们在内核代码里经常看到的 ____cacheline_aligned 所做的那样。

那怎么来观测 Cache 伪共享问题呢？你可以使用perf c2c这个命令，但是这需要较新版本内核支持才可以。不过，perf 同样可以观察 cache miss 的现象，它对很多性能问题的分析还是很有帮助的。

CPU 在写完 Cache 后将 Cache 置为无效（invalidate）, 这本质上是为了保障多核并行计算时的数据一致性，一致性问题是 Cache 这个存储层次很典型的问题。

我们再来看内存这个存储层次中的典型问题：并行计算时的竞争，即两个 CPU 同时去操作同一个物理内存地址时的竞争。关于这类问题，我举一些简单的例子给你说明一下。

以 C 语言为例：

struct foo {
    int a;
    int b;
};

在这段示例代码里，我们定义了一个结构体，该结构体里的两个成员 a 和 b 在地址上是连续的。如果 CPU 0 去写 a，同时 CPU 1 去读 b 的话，此时不会有竞争，因为 a 和 b 是不同的地址。不过，a 和 b 由于在地址上是连续的，它们可能会位于同一个 Cache Line 中，所以为了防止前面提到的 Cache 伪共享问题，我们可以强制将 b 的地址设置为 Cache Line 对齐地址，如下:

struct foo {
    int a;
    int b ____cacheline_aligned;
};

接下来，我们看下另外一种情况：

struct foo {
    int a:1;
    int b:1;
};

这个示例程序定义了两个位域（bit field），a 和 b 的地址是一样的，只是属于该地址的不同 bit。在这种情况下，CPU 0 去写 a （a = 1），同时 CPU 1 去写 b （b = 1），就会产生竞争。在总线仲裁后，先写的数据就会被后写的数据给覆盖掉。这就是执行 RMW 操作时典型的竞争问题。在这种场景下，就需要同步原语了，比如使用 atomic 操作。

关于位操作，我们来看一个实际的案例。这是我前段时间贡献给 Linux 内核的一个 PATCH：psi: Move PF_MEMSTALL out of task->flags，它在 struct task_struct 这个结构体里增加了一个 in_memstall 的位域，在该 PATCH 里无需考虑多线程并行操作该位域时的竞争问题，你知道这是为什么吗？我将它作为一个课后思考题留给你，欢迎你在留言区与我讨论交流。为了让这个问题简单些，我给你一个提示：如果你留意过 task_struct 这个结构体里的位域被改写的情况，你会发现只有 current（当前在运行的线程）可以写，而其他线程只能去读。但是 PF_* 这些全局 flag 可以被其他线程写，而不仅仅是 current 来写。

Linux 内核里的 task_struct 结构体就是用来表示一个线程的，每个线程都有唯一对应的 task_struct 结构体，它也是内核进行调度的基本单位。我们继续来看下 CPU 是如何选择线程来执行的。

CPU 是如何选择线程执行的 ？

你知道，一个系统中可能会运行着非常多的线程，这些线程数可能远超系统中的 CPU 核数，这时候这些任务就需要排队，每个 CPU 都会维护着自己运行队列（runqueue）里的线程。这个运行队列的结构大致如下图所示：

CPU运行队列

每个 CPU 都有自己的运行队列（runqueue），需要运行的线程会被加入到这个队列中。因为有些线程的优先级高，Linux 内核为了保障这些高优先级任务的执行，设置了不同的调度类（Scheduling Class），如下所示：

这几个调度类的优先级如下：Deadline > Realtime > Fair。Linux 内核在选择下一个任务执行时，会按照该顺序来进行选择，也就是先从 dl_rq 里选择任务，然后从 rt_rq 里选择任务，最后从 cfs_rq 里选择任务。所以实时任务总是会比普通任务先得到执行。

如果你的某些任务对延迟容忍度很低，比如说在嵌入式系统中就有很多这类任务，那就可以考虑将你的任务设置为实时任务，比如将它设置为 SCHED_FIFO 的任务：

$ chrt -f -p 1 1327

如果你不做任何设置的话，用户线程在默认情况下都是普通线程，也就是属于 Fair 调度类，由 CFS 调度器来进行管理。CFS 调度器的目的是为了实现线程运行的公平性，举个例子，假设一个 CPU 上有两个线程需要执行，那么每个线程都将分配 50% 的 CPU 时间，以保障公平性。其实，各个线程之间执行时间的比例，也是可以人为干预的，比如在 Linux 上可以调整进程的 nice 值来干预，从而让优先级高一些的线程执行更多时间。这就是 CFS 调度器的大致思想。

好了，我们这堂课就先讲到这里。

课堂总结

我来总结一下这节课的知识点：

要想明白 CPU 是如何执行任务的，你首先需要去了解 CPU 的架构；

CPU 的存储层次对大型软件系统的性能影响会很明显，也是你在性能调优时需要着重考虑的；

高并发场景下的 Cache Line 伪共享问题是一个普遍存在的问题，你需要留意一下它；

系统中需要运行的线程数可能大于 CPU 核数，这样就会导致线程排队等待 CPU，这可能会导致一些延迟。如果你的任务对延迟容忍度低，你可以通过一些手段来人为干预 Linux 默认的调度策略。

课后作业

这节课的作业就是我们前面提到的思考题：在psi: Move PF_MEMSTALL out of task->flags这个 PATCH 中，为什么没有考虑多线程并行操作新增加的位域（in_memstall）时的竞争问题？欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

18 案例篇 | 业务是否需要使用透明大页：水可载舟，亦可覆舟？

你好，我是邵亚方。

我们这节课的案例来自于我在多年以前帮助业务团队分析的一个稳定性问题。当时，业务团队反映说他们有一些服务器的 CPU 利用率会异常飙高，然后很快就能恢复，并且持续的时间不长，大概几秒到几分钟，从监控图上可以看到它像一些毛刺。

因为这类问题是普遍存在的，所以我就把该问题的定位分析过程分享给你，希望你以后遇到 CPU 利用率飙高的问题时，知道该如何一步步地分析。

CPU 利用率是一个很笼统的概念，在遇到 CPU 利用率飙高的问题时，我们需要看看 CPU 到底在忙哪类事情，比如说 CPU 是在忙着处理中断、等待 I/O、执行内核函数？还是在执行用户函数？这个时候就需要我们细化 CPU 利用率的监控，因为监控这些细化的指标对我们分析问题很有帮助。

细化 CPU 利用率监控

这里我们以常用的 top 命令为例，来看看 CPU 更加细化的利用率指标（不同版本的 top 命令显示可能会略有不同）：

%Cpu(s): 12.5 us, 0.0 sy, 0.0 ni, 87.4 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

top 命令显示了 us、sy、ni、id、wa、hi、si 和 st 这几个指标，这几个指标之和为 100。那你可能会有疑问，细化 CPU 利用率指标的监控会不会带来明显的额外开销？答案是不会的，因为 CPU 利用率监控通常是去解析 /proc/stat 文件，而这些文件中就包含了这些细化的指标。

我们继续来看下上述几个指标的具体含义，这些含义你也可以从top 手册里来查看：

       us, user    : time running un-niced user processes
       sy, system  : time running kernel processes
       ni, nice    : time running niced user processes
       id, idle    : time spent in the kernel idle handler
       wa, IO-wait : time waiting for I/O completion
       hi : time spent servicing hardware interrupts
       si : time spent servicing software interrupts
       st : time stolen from this vm by the hypervisor

上述指标的具体含义以及注意事项如下：

在上面这几项中，idle 和 wait 是 CPU 不工作的时间，其余的项都是 CPU 工作的时间。idle 和 wait 的主要区别是，idle 是 CPU 无事可做，而 wait 则是 CPU 想做事却做不了。你也可以将 wait 理解为是一类特殊的 idle，即该 CPU 上有至少一个线程阻塞在 I/O 时的 idle。

而我们通过对 CPU 利用率的细化监控发现，案例中的 CPU 利用率飙高是由 sys 利用率变高导致的，也就是说 sys 利用率会忽然飙高一下，比如在 usr 低于 30% 的情况下，sys 会高于 15%，持续几秒后又恢复正常。

所以，接下来我们就需要抓取 sys 利用率飙高的现场。

抓取 sys 利用率飙高现场

我们在前面讲到，CPU 的 sys 利用率高，说明内核函数执行花费了太多的时间，所以我们需要采集 CPU 在 sys 飙高的瞬间所执行的内核函数。采集内核函数的方法有很多，比如：

通过 perf 可以采集 CPU 的热点，看看 sys 利用率高时，哪些内核耗时的 CPU 利用率高；

通过 perf 的 call-graph 功能可以查看具体的调用栈信息，也就是线程是从什么路径上执行下来的；

通过 perf 的 annotate 功能可以追踪到线程是在内核函数的哪些语句上比较耗时；

通过 ftrace 的 function-graph 功能可以查看这些内核函数的具体耗时，以及在哪个路径上耗时最大。

不过，这些常用的追踪方式在这种瞬间消失的问题上是不太适用的，因为它们更加适合采集一个时间段内的信息。

那么针对这种瞬时的状态，我希望有一个系统快照，把当前 CPU 正在做的工作记录下来，然后我们就可以结合内核源码分析为什么 sys 利用率会高了。

有一个工具就可以很好地追踪这种系统瞬时状态，即系统快照，它就是 sysrq。sysrq 是我经常用来分析内核问题的工具，用它可以观察当前的内存快照、任务快照，可以构造 vmcore 把系统的所有信息都保存下来，甚至还可以在内存紧张的时候用它杀掉内存开销最大的那个进程。sysrq 可以说是分析很多疑难问题的利器。

要想用 sysrq 来分析问题，首先需要使能 sysyrq。我建议你将 sysrq 的所有功能都使能，你无需担心会有什么额外开销，而且这也没有什么风险。使能方式如下：

$ sysctl -w kernel.sysrq = 1

sysrq 的功能被使能后，你可以使用它的 -t 选项把当前的任务快照保存下来，看看系统中都有哪些任务，以及这些任务都在干什么。使用方式如下：

$ echo t > /proc/sysrq-trigger

然后任务快照就会被打印到内核缓冲区，这些任务快照信息你可以通过 dmesg 命令来查看：

$ dmesg

当时我为了抓取这种瞬时的状态，写了一个脚本来采集，如下就是一个简单的脚本示例：

#!/bin/sh

while [ 1 ]; do
     top -bn2 | grep "Cpu(s)" | tail -1 | awk '{
         # $2 is usr, $4 is sys.
         if ($2 < 30.0 && $4 > 15.0) {
              # save the current usr and sys into a tmp file
              while ("date" | getline date) {
                   split(date, str, " ");
                   prefix=sprintf("%s_%s_%s_%s", str[2],str[3], str[4], str[5]);
               }

              sys_usr_file=sprintf("/tmp/%s_info.highsys", prefix);
              print $2 > sys_usr_file;
              print $4 >> sys_usr_file;

              # run sysrq
              system("echo t > /proc/sysrq-trigger");
         }
     }'
     sleep 1m
done

这个脚本会检测 sys 利用率高于 15% 同时 usr 较低的情况，也就是说检测 CPU 是否在内核里花费了太多时间。如果出现这种情况，就会运行 sysrq 来保存当前任务快照。你可以发现这个脚本设置的是 1 分钟执行一次，之所以这么做是因为不想引起很大的性能开销，而且当时业务团队里有几台机器差不多是一天出现两三次这种状况，有些机器每次可以持续几分钟，所以这已经足够了。不过，如果你遇到的问题出现的频率更低，持续时间更短，那就需要更加精确的方法了。

透明大页：水可载舟，亦可覆舟？

我们把脚本部署好后，就把问题现场抓取出来了。从 dmesg 输出的信息中，我们发现处于 R 状态的线程都在进行 compcation（内存规整），线程的调用栈如下所示（这是一个比较古老的内核，版本为 2.6.32）：

java          R  running task        0 144305 144271 0x00000080
 ffff88096393d788 0000000000000086 ffff88096393d7b8 ffffffff81060b13
 ffff88096393d738 ffffea003968ce50 000000000000000e ffff880caa713040
 ffff8801688b0638 ffff88096393dfd8 000000000000fbc8 ffff8801688b0640

Call Trace:
 [<ffffffff81060b13>] ? perf_event_task_sched_out+0x33/0x70
 [<ffffffff8100bb8e>] ? apic_timer_interrupt+0xe/0x20
 [<ffffffff810686da>] __cond_resched+0x2a/0x40
 [<ffffffff81528300>] _cond_resched+0x30/0x40
 [<ffffffff81169505>] compact_checklock_irqsave+0x65/0xd0
 [<ffffffff81169862>] compaction_alloc+0x202/0x460
 [<ffffffff811748d8>] ? buffer_migrate_page+0xe8/0x130
 [<ffffffff81174b4a>] migrate_pages+0xaa/0x480
 [<ffffffff81169660>] ? compaction_alloc+0x0/0x460
 [<ffffffff8116a1a1>] compact_zone+0x581/0x950
 [<ffffffff8116a81c>] compact_zone_order+0xac/0x100
 [<ffffffff8116a951>] try_to_compact_pages+0xe1/0x120
 [<ffffffff8112f1ba>] __alloc_pages_direct_compact+0xda/0x1b0
 [<ffffffff8112f80b>] __alloc_pages_nodemask+0x57b/0x8d0
 [<ffffffff81167b9a>] alloc_pages_vma+0x9a/0x150
 [<ffffffff8118337d>] do_huge_pmd_anonymous_page+0x14d/0x3b0
 [<ffffffff8152a116>] ? rwsem_down_read_failed+0x26/0x30
 [<ffffffff8114b350>] handle_mm_fault+0x2f0/0x300
 [<ffffffff810ae950>] ? wake_futex+0x40/0x60
 [<ffffffff8104a8d8>] __do_page_fault+0x138/0x480
 [<ffffffff810097cc>] ? __switch_to+0x1ac/0x320
 [<ffffffff81527910>] ? thread_return+0x4e/0x76e
 [<ffffffff8152d45e>] do_page_fault+0x3e/0xa0
 [<ffffffff8152a815>] page_fault+0x25/0x30

从该调用栈我们可以看出，此时这个 java 线程在申请 THP（do_huge_pmd_anonymous_page）。THP 就是透明大页，它是一个 2M 的连续物理内存。但是，因为这个时候物理内存中已经没有连续 2M 的内存空间了，所以触发了 direct compaction（直接内存规整），内存规整的过程可以用下图来表示：

THP Compaction

这个过程并不复杂，在进行 compcation 时，线程会从前往后扫描已使用的 movable page，然后从后往前扫描 free page，扫描结束后会把这些 movable page 给迁移到 free page 里，最终规整出一个 2M 的连续物理内存，这样 THP 就可以成功申请内存了。

direct compaction 这个过程是很耗时的，而且在 2.6.32 版本的内核上，该过程需要持有粗粒度的锁，所以在运行过程中线程还可能会主动检查（_cond_resched）是否有其他更高优先级的任务需要执行。如果有的话就会让其他线程先执行，这便进一步加剧了它的执行耗时。这也就是 sys 利用率飙高的原因。关于这些，你也都可以从内核源码的注释来看到：

/*
 * Compaction requires the taking of some coarse locks that are potentially
 * very heavily contended. Check if the process needs to be scheduled or
 * if the lock is contended. For async compaction, back out in the event
 * if contention is severe. For sync compaction, schedule.
 * ...
 */

在我们找到了原因之后，为了快速解决生产环境上的这些问题，我们就把该业务服务器上的 THP 关掉了，关闭后系统变得很稳定，再也没有出现过 sys 利用率飙高的问题。关闭 THP 可以使用下面这个命令：

$ echo never > /sys/kernel/mm/transparent_hugepage/enabled

关闭了生产环境上的 THP 后，我们又在线下测试环境中评估了 THP 对该业务的性能影响，我们发现 THP 并不能给该业务带来明显的性能提升，即使是在内存不紧张、不会触发内存规整的情况下。这也引起了我的思考，THP 究竟适合什么样的业务呢？

这就要从 THP 的目的来说起了。我们长话短说，THP 的目的是用一个页表项来映射更大的内存（大页），这样可以减少 Page Fault，因为需要的页数少了。当然，这也会提升 TLB 命中率，因为需要的页表项也少了。如果进程要访问的数据都在这个大页中，那么这个大页就会很热，会被缓存在 Cache 中。而大页对应的页表项也会出现在 TLB 中，从上一讲的存储层次我们可以知道，这有助于性能提升。但是反过来，假设应用程序的数据局部性比较差，它在短时间内要访问的数据很随机地位于不同的大页上，那么大页的优势就会消失。

因此，我们基于大页给业务做性能优化的时候，首先要评估业务的数据局部性，尽量把业务的热点数据聚合在一起，以便于充分享受大页的优势。以我在华为任职期间所做的大页性能优化为例，我们将业务的热点数据聚合在一起，然后将这些热点数据分配到大页上，再与不使用大页的情况相比，最终发现这可以带来 20% 以上的性能提升。对于 TLB 较小的架构（比如 MIPS 这种架构），它可以带来 50% 以上的性能提升。当然了，我们在这个过程中也对内核的大页代码做了很多优化，这里就不展开说了。

针对 THP 的使用，我在这里给你几点建议：

不要将 /sys/kernel/mm/transparent_hugepage/enabled 配置为 always，你可以将它配置为 madvise。如果你不清楚该如何来配置，那就将它配置为 never；

如果你想要用 THP 优化业务，最好可以让业务以 madvise 的方式来使用大页，即通过修改业务代码来指定特定数据使用 THP，因为业务更熟悉自己的数据流；

很多时候修改业务代码会很麻烦，如果你不想修改业务代码的话，那就去优化 THP 的内核代码吧。

好了，这节课就讲到这里。

课堂总结

我们来回顾一下这节课的要点：

细化 CPU 利用率监控，在 CPU 利用率高时，你需要查看具体是哪一项指标比较高；

sysrq 是分析内核态 CPU 利用率高的利器，也是分析很多内核疑难问题的利器，你需要去了解如何使用它；

THP 可以给业务带来性能提升，但也可能会给业务带来严重的稳定性问题，你最好以 madvise 的方式使用它。如果你不清楚如何使用它，那就把它关闭。

课后作业

我们这节课的作业有三种，你可以根据自己的情况进行选择：

如果你是应用开发者，请问如何来观察系统中分配了多少 THP？

如果你是初级内核开发者，请问在进行 compaction 时，哪些页可以被迁移？哪些不可以被迁移？

如果你是高级内核开发者，假设现在让你来设计让程序的代码段也可以使用 hugetlbfs，那你觉得应该要做什么？

欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

19 案例篇 | 网络吞吐高的业务是否需要开启网卡特性呢？

你好，我是邵亚方。

通过上一讲我们对 CPU 利用率的细化，相信你已经知道，对于应用而言，它的目标是让 CPU 的开销尽量用在执行用户代码上，而非其他方面。usr 利用率越高，说明 CPU 的效率越高。如果 usr 低，就说明 CPU 执行应用的效率不高。在第 18 讲里，我们还讲了 CPU 时间浪费在 sys 里的案例。那今天这一讲，我们一起来看看 CPU 在 softirq 上花费过多时间所引起的业务性能下降问题，这也是我们在生产环境中经常遇到的一类问题。接下来我会为你讲解相关案例，以及这类问题常用的观察方法。

中断与业务进程之间是如何相互干扰的？

这是我多年以前遇到的一个案例，当时业务反馈说为了提升 QPS（Query per Second），他们开启了 RPS（Receivce Packet Steering）来模拟网卡多队列，没想到开启 RPS 反而导致了 QPS 明显下降，不知道是什么原因。

其实，这类特定行为改变引起的性能下降问题相对好分析一些。最简单的方式就是去对比这个行为前后的性能数据。即使你不清楚 RPS 是什么，也不知道它背后的机制，你也可以采集需要的性能指标进行对比分析，然后判断问题可能出在哪里。这些性能指标包括 CPU 指标，内存指标，I/O 指标，网络指标等，我们可以使用 dstat 来观察它们的变化。

在业务打开 RPS 之前的性能指标：

$ dstat
You did not select any stats, using -cdngy by default.
----total-cpu-usage---- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai hiq siq| read  writ| recv  send|  in   out | int   csw
 64  23   6   0   0   7|   0  8192B|7917k   12M|   0     0 |  27k 1922
 64  22   6   0   0   8|   0     0 |7739k   12M|   0     0 |  26k 2210
 61  23   9   0   0   7|   0     0 |7397k   11M|   0     0 |  25k 2267

打开了 RPS 之后的性能指标：

$ dstat
You did not select any stats, using -cdngy by default.
----total-cpu-usage---- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai hiq siq| read  writ| recv  send|  in   out | int   csw
 62  23   4   0   0  12|   0     0 |7096k   11M|   0     0 |  49k 2261
 74  13   4   0   0   9|   0     0 |4003k 6543k|   0     0 |  31k 2004
 59  22   5   0   0  13|   0  4096B|6710k   10M|   0     0 |  48k 2220

我们可以看到，打开 RPS 后，CPU 的利用率有所升高。其中，siq 即软中断利用率明显增加，int 即硬中断频率也明显升高，而 net 这一项里的网络吞吐数据则有所下降。也就是说，在网络吞吐不升反降的情况下，系统的硬中断和软中断都明显增加。由此我们可以推断出，网络吞吐的下降应该是中断增加导致的结果。

那么，接下来我们就需要分析到底是什么类型的软中断和硬中断增加了，以便于找到问题的源头。

系统中存在很多硬中断，这些硬中断及其发生频率我们都可以通过 /proc/interruptes 这个文件来查看：

$ cat /proc/interrupts

如果你想要了解某个中断的详细情况，比如中断亲和性，那你可以通过 /proc/irq/[irq_num]来查看，比如：

$ cat /proc/irq/123/smp_affinity

软中断可以通过 /proc/softirq 来查看：

$ cat /proc/softirqs

当然了，你也可以写一些脚本来观察各个硬中断和软中断的发生频率，从而更加直观地查看是哪些中断发生得太频繁。

关于硬中断和软中断的区别，你可能多少有些了解，软中断是用来处理硬中断在短时间内无法完成的任务的。硬中断由于执行时间短，所以如果它的发生频率不高的话，一般不会给业务带来明显影响。但是由于内核里关中断的地方太多，所以进程往往会给硬中断带来一些影响，比如进程关中断时间太长会导致网络报文无法及时处理，进而引起业务性能抖动。

我们在生产环境中就遇到过这种关中断时间太长引起的抖动案例，比如 cat /proc/slabinfo 这个操作里的逻辑关中断太长，它会致使业务 RT 抖动。这是因为该命令会统计系统中所有的 slab 数量，并显示出来，在统计的过程中会关中断。如果系统中的 slab 数量太多，就会导致关中断的时间太长，进而引起网络包阻塞，ping 延迟也会因此明显变大。所以，在生产环境中我们要尽量避免去采集 /proc/slabinfo，否则可能会引起业务抖动。

由于 /proc/slabinfo 很危险，所以它的访问权限也从 2.6.32 版本时的 0644更改为了后来的0400，也就是说只有 root 用户才能够读取它，这在一定程度上避免了一些问题。如果你的系统是 2.6.32 版本的内核，你就需要特别注意该问题。

如果你要分析因中断关闭时间太长而引发的问题，有一种最简单的方式，就是使用 ftrace 的 irqsoff 功能。它不仅可以统计出中断被关闭了多长时间，还可以统计出为什么会关闭中断。不过，你需要注意的是，irqsoff 功能依赖于 CONFIG_IRQSOFF_TRACER 这个配置项，如果你的内核没有打开该配置项，那你就需要使用其他方式来去追踪了。

如何使用 irqsoff 呢？首先，你需要去查看你的系统是否支持了 irqsoff 这个 tracer：

$ cat /sys/kernel/debug/tracing/available_tracers

如果显示的内容包含了 irqsoff，说明系统支持该功能，你就可以打开它进行追踪了：

$ echo irqsoff > /sys/kernel/debug/tracing/current_tracer

接下来，你就可以通过 /sys/kernel/debug/tracing/trace_pipe 和 trace 这两个文件，来观察系统中的 irqsoff 事件了。

我们知道，相比硬中断，软中断的执行时间会长一些，而且它也会抢占正在执行进程的 CPU，从而导致进程在它运行期间只能等待。所以，相对而言它会更容易给业务带来延迟。那我们怎么对软中断的执行频率以及执行耗时进行观测呢？你可以通过如下两个 tracepoints 来进行观测：

/sys/kernel/debug/tracing/events/irq/softirq_entry
/sys/kernel/debug/tracing/events/irq/softirq_exit

这两个 tracepoint 分别表示软中断的进入和退出，退出时间减去进入时间就是该软中断这一次的耗时。关于 tracepoint 采集数据的分析方式，我们在之前的课程里已经讲过多次，所以就不在这里继续描述了。

如果你的内核版本比较新，支持 eBPF 功能，那你同样可以使用 bcc 里的softirqs.py这个工具来进行观测。它会统计软中断的次数和耗时，这对我们分析软中断引起的业务延迟来说，是比较方便的。

为了避免软中断太过频繁，进程无法得到 CPU 而被饿死的情况，内核引入了 ksoftirqd 这个机制。如果所有的软中断在短时间内无法被处理完，内核就会唤醒 ksoftirqd 处理接下来的软中断。ksoftirqd 与普通进程的优先级一样，也就是说它会和普通进程公平地使用 CPU，这在一定程度上可以避免用户进程被饿死的情况，特别是对于那些更高优先级的实时用户进程而言。

不过，这也会带来一些问题。如果 ksoftrirqd 长时间得不到 CPU，就会致使软中断的延迟变得很大，它引起的典型问题也是 ping 延迟。如果 ping 包无法在软中断里得到处理，就会被 ksoftirqd 处理。而 ksoftirqd 的实时性是很难得到保障的，可能需要等其他线程执行完，ksoftirqd 才能得到执行，这就会导致 ping 延迟变得很大。

要观测 ksoftirqd 延迟的问题，你可以使用 bcc 里的runqlat.py。这里我们以网卡中断为例，它唤醒 ksoftirqd 的逻辑大致如下图所示：

中断与ksoftirqd

我们具体来看看这个过程：软中断被唤醒后会检查一遍软中断向量表，逐个处理这些软中断；处理完一遍后，它会再次检查，如果又有新的软中断要处理，就会唤醒 ksoftrqd 来处理。ksoftirqd 是 per-cpu 的内核线程，每个 CPU 都有一个。对于 CPU1 而言，它运行的是 ksoftirqd/1 这个线程。ksoftirqd/1 被唤醒后会检查软中断向量表并进行处理。如果你使用 ps 来查看 ksoftirqd/1 的优先级，会发现它其实就是一个普通线程（对应的 Nice 值为 0）：

$ ps -eo "pid,comm,ni" | grep softirqd
    9 ksoftirqd/0       0
   16 ksoftirqd/1       0
   21 ksoftirqd/2       0
   26 ksoftirqd/3       0

总之，在软中断处理这部分，内核需要改进的地方还有很多。

softirq 是如何影响业务的？

在我们对硬中断和软中断进行观察后发现，使能 RPS 后增加了很多 CAL（Function Call Interrupts）硬中断。CAL 是通过软件触发硬中断的一种方式，可以指定 CPU 以及需要执行的中断处理程序。它也常被用来进行 CPU 间通信（IPI），当一个 CPU 需要其他 CPU 来执行特定中断处理程序时，就可以通过 CAL 中断来进行。

如果你对 RPS 的机制有所了解的话，应该清楚 RPS 就是通过 CAL 这种方式来让其他 CPU 去接收网络包的。为了验证这一点，我们可以通过 mpstat 这个命令来观察各个 CPU 利用率情况。

使能 RPS 之前的 CPU 利用率如下所示：

$ mpstat -P ALL 1
Average:     CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
Average:     all   70.18    0.00   19.28    0.00    0.00    5.86    0.00    0.00    0.00    4.68
Average:       0   73.25    0.00   21.50    0.00    0.00    0.00    0.00    0.00    0.00    5.25
Average:       1   58.85    0.00   14.46    0.00    0.00   23.44    0.00    0.00    0.00    3.24
Average:       2   74.50    0.00   20.00    0.00    0.00    0.00    0.00    0.00    0.00    5.50
Average:       3   74.25    0.00   21.00    0.00    0.00    0.00    0.00    0.00    0.00    4.75

使能 RPS 之后各个 CPU 的利用率情况为：

$ mpstat -P ALL 1
Average:     CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
Average:     all   66.21    0.00   17.73    0.00    0.00   11.15    0.00    0.00    0.00    4.91
Average:       0   68.17    0.00   18.33    0.00    0.00    7.67    0.00    0.00    0.00    5.83
Average:       1   60.57    0.00   15.81    0.00    0.00   20.80    0.00    0.00    0.00    2.83
Average:       2   69.95    0.00   19.20    0.00    0.00    7.01    0.00    0.00    0.00    3.84
Average:       3   66.39    0.00   17.64    0.00    0.00    8.99    0.00    0.00    0.00    6.99

我们可以看到，使能 RPS 之后，softirq 在各个 CPU 之间更加均衡了一些，本来只有 CPU1 在处理 softirq，使能后每个 CPU 都会处理 softirq，并且 CPU1 的 softirq 利用率降低了一些。这就是 RPS 的作用：让网络收包软中断处理在各个 CPU 间更加均衡，以防止其在某个 CPU 上达到瓶颈。你可以看到，使能 RPS 后整体的 %soft 比原来高了很多。

理论上，处理网络收包软中断的 CPU 变多，那么在单位时间内这些 CPU 应该可以处理更多的网络包，从而提升系统整体的吞吐。可是，在我们的案例中，为什么会引起业务的 QPS 不升反降呢？

其实，答案同样可以从 CPU 利用率中得出。我们可以看到在使能 RPS 之前，CPU 利用率已经很高了，达到了 90% 以上，也就是说 CPU 已经在超负荷工作了。而打开 RPS，RPS 又会消耗额外的 CPU 时间来模拟网卡多队列特性，这就会导致 CPU 更加超负荷地工作，从而进一步挤压用户进程的处理时间。因此，我们会发现，在打开 RPS 后 %usr 的利用率下降了一些。

我们知道 %usr 是衡量用户进程执行时间的一个指标，%usr 越高意味着业务代码的运行时间越多。如果 %usr 下降，那就意味着业务代码的运行时间变少了，在业务没有进行代码优化的前提下，这显然是一个危险的信号。

由此我们可以发现，RPS 的本质就是把网卡特性（网卡多队列）给 upload 到 CPU，通过牺牲 CPU 时间来提升网络吞吐。如果你的系统已经很繁忙了，那么再使用该特性无疑是雪上加霜。所以，你需要注意，使用 RPS 的前提条件是：系统的整体 CPU 利用率不能太高。

找到问题后，我们就把该系统的 RPS 特性关闭了。如果你的网卡比较新，它可能会支持硬件多队列。硬件多队列是在网卡里做负载均衡，在这种场景下硬件多队列会很有帮助。我们知道，与 upload 相反的方向是 offload，就是把 CPU 的工作给 offload 到网卡上去处理，这样可以把 CPU 解放出来，让它有更多的时间执行用户代码。关于网卡的 offload 特性，我们就不在此讨论了。

好了，这节课就讲到这里。

课堂总结

我们来简单回顾一下这节课的重点：

硬中断、软中断以及 ksoftirqd 这个内核线程，它们与用户线程之间的关系是相对容易引发业务抖动的地方，你需要掌握它们的观测方式；

硬中断对业务的主要影响体现在硬中断的发生频率上，但是它也容易受线程影响，因为内核里关中断的地方有很多；

软中断的执行时间如果太长，就会给用户线程带来延迟，你需要避免你的系统中存在耗时较大的软中断处理程序。如果有的话，你需要去做优化；

ksoftirqd 的优先级与用户线程是一致的，因此，如果软中断处理函数是在 ksoftirqd 里执行的，那它可能会有一些延迟；

RPS 的本质是网卡特性 unload 到 CPU，靠牺牲 CPU 时间来提升吞吐，你需要结合你的业务场景来评估是否需要开启它。如果你的网卡支持了硬件多队列，那么就可以直接使用硬件多队列了。

课后作业

我们这节课的作业有两种，你可以根据自己的情况进行选择。

入门：

请问如果软中断以及硬中断被关闭的时间太长，会发生什么事？

高级：

如果想要追踪网络数据包在内核缓冲区停留了多长时间才被应用读走，你觉得应该如何来追踪？

欢迎你在留言区与我讨论。

感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友，我们下一讲见。

20 分析篇 | 如何分析CPU利用率飙高问题 ？

你好，我是邵亚方。

如果你是一名应用开发者，那你应该知道如何去分析应用逻辑，对于如何优化应用代码提升系统性能也应该有自己的一套经验。而我们这节课想要讨论的是，如何拓展你的边界，让你能够分析代码之外的模块，以及对你而言几乎是黑盒的 Linux 内核。

在很多情况下，应用的性能问题都需要通过分析内核行为来解决，因此，内核提供了非常多的指标供应用程序参考。当应用出现问题时，我们可以查看到底是哪些指标出现了异常，然后再做进一步分析。不过，这些内核导出的指标并不能覆盖所有的场景，我们面临的问题可能更加棘手：应用出现性能问题，可是系统中所有的指标都看起来没有异常。相信很多人都为此抓狂过。那出现这种情况时，内核到底有没有问题呢，它究竟在搞什么鬼？这节课我就带你探讨一下如何分析这类问题。

我们知道，对于应用开发者而言，应用程序的边界是系统调用，进入到系统调用中就是 Linux 内核了。所以，要想拓展分析问题的边界，你首先需要知道该怎么去分析应用程序使用的系统调用函数。对于内核开发者而言，边界同样是系统调用，系统调用之外是应用程序。如果内核开发者想要拓展分析问题的边界，也需要知道如何利用系统调用去追踪应用程序的逻辑。

如何拓展你分析问题的边界？

作为一名内核开发者，我对应用程序逻辑的了解没有对内核的了解那么深。不过，当应用开发者向我寻求帮助时，尽管我对他们的应用逻辑一无所知，但这并不影响我对问题的分析，因为我知道如何借助分析工具追踪应用程序的逻辑。经过一系列追踪之后，我就能对应用程序有一个大概的认识。

我常用来追踪应用逻辑的工具之一就是 strace。strace 可以用来分析应用和内核的“边界”——系统调用。借助 strace，我们不仅能够了解应用执行的逻辑，还可以了解内核逻辑。那么，作为应用开发者的你，就可以借助这个工具来拓展你分析应用问题的边界。

strace 可以跟踪进程的系统调用、特定的系统调用以及系统调用的执行时间。很多时候，我们通过系统调用的执行时间，就能判断出业务延迟发生在哪里。比如我们想要跟踪一个多线程程序的系统调用情况，那就可以这样使用 strace：

$ strace -T -tt -ff -p pid -o strace.out

不过，在使用 strace 跟踪进程之前，我希望你可以先明白 strace 的工作原理，这也是我们这节课的目的：你不只要知道怎样使用工具，更要明白工具的原理，这样在出现问题时，你就能明白该工具是否适用了。

了解工具的原理，不要局限于如何使用它

strace 工具的原理如下图所示（我们以上面的那个命令为例来说明）：

strace基本原理

我们从图中可以看到，对于正在运行的进程而言，strace 可以 attach 到目标进程上，这是通过 ptrace 这个系统调用实现的（gdb 工具也是如此）。ptrace 的 PTRACE_SYSCALL 会去追踪目标进程的系统调用；目标进程被追踪后，每次进入 syscall，都会产生 SIGTRAP 信号并暂停执行；追踪者通过目标进程触发的 SIGTRAP 信号，就可以知道目标进程进入了系统调用，然后追踪者会去处理该系统调用，我们用 strace 命令观察到的信息输出就是该处理的结果；追踪者处理完该系统调用后，就会恢复目标进程的执行。被恢复的目标进程会一直执行下去，直到下一个系统调用。

你可以发现，目标进程每执行一次系统调用都会被打断，等 strace 处理完后，目标进程才能继续执行，这就会给目标进程带来比较明显的延迟。因此，在生产环境中我不建议使用该命令，如果你要使用该命令来追踪生产环境的问题，那就一定要做好预案。

假设我们使用 strace 跟踪到，线程延迟抖动是由某一个系统调用耗时长导致的，那么接下来我们该怎么继续追踪呢？这就到了应用开发者和运维人员需要拓展分析边界的时刻了，对内核开发者来说，这才算是分析问题的开始。

学会使用内核开发者常用的分析工具

我们以一个实际案例来说明吧。有一次，业务开发者反馈说他们用 strace 追踪发现业务的 pread(2) 系统调用耗时很长，经常会有几十毫秒（ms）的情况，甚至能够达到秒级，但是不清楚接下来该如何分析，因此让我帮他们分析一下。

因为已经明确了问题是由 pread(2) 这个系统调用引起的，所以对内核开发者而言，后续的分析就相对容易了。分析这类问题最合适的工具是 ftrace，我们可以使用 ftrace 的 function_trace 功能来追踪 pread(2) 这个系统调用到底是在哪里耗费了这么长的时间。

要想追踪 pread(2) 究竟在哪里耗时长，我们就需要知道该系统调用对应的内核函数是什么。我们有两种途径可以方便地获取到系统调用对应的内核函数：

查看include/linux/syscalls.h文件里的内核函数：

你可以看到，与 pread 有关的函数有多个，由于我们的系统是 64bit 的，只需关注 64bit 相关的系统调用就可以了，所以我们锁定在 ksys_pread64 和 sys_read64 这两个函数上。通过该头文件里的注释我们能知道，前者是内核使用的，后者是导出给用户的。那么在内核里，我们就需要去追踪前者。另外，请注意，不同内核版本对应的函数可能不一致，我们这里是以最新内核代码 (5.9-rc) 为例来说明的。

通过 /proc/kallsyms 这个文件来查找：

$ cat /proc/kallsyms | grep pread64

…

ffffffffa02ef3d0 T ksys_pread64

…

/proc/kallsyms 里的每一行都是一个符号，其中第一列是符号地址，第二列是符号的属性，第三列是符号名字，比如上面这个信息中的 T 就表示全局代码符号，我们可以追踪这类的符号。关于这些符号属性的含义，你可以通过man nm来查看。

接下来我们就使用 ftrace 的 function_graph 功能来追踪 ksys_pread64 这个函数，看看究竟是内核的哪里耗时这么久。function_graph 的使用方式如下：

# 首先设置要追踪的函数
$ echo ksys_pread64 > /sys/kernel/debug/tracing/set_graph_function

# 其次设置要追踪的线程的pid，如果有多个线程，那需要将每个线程都逐个写入
$ echo 6577 > /sys/kernel/debug/tracing/set_ftrace_pid
$ echo 6589 >> /sys/kernel/debug/tracing/set_ftrace_pid

# 将function_graph设置为当前的tracer，来追踪函数调用情况
$ echo function_graph > /sys/kernel/debug/tracing/current_trace

然后我们就可以通过 /sys/kernel/debug/tracing/trace_pipe 来查看它的输出了，下面就是我追踪到的耗时情况：

我们可以发现 pread(2) 有 102ms 是阻塞在 io_schedule() 这个函数里的，io_schedule() 的意思是，该线程因 I/O 阻塞而被调度走，线程需要等待 I/O 完成才能继续执行。在 function_graph 里，我们同样也能看到 pread**(2)** 是如何一步步执行到 io_schedule 的，由于整个流程比较长，我在这里只把关键的调用逻辑贴出来：

 21)               |            __lock_page_killable() {
 21)   0.073 us    |              page_waitqueue();
 21)               |              __wait_on_bit_lock() {
 21)               |                prepare_to_wait_exclusive() {
 21)   0.186 us    |                  _raw_spin_lock_irqsave();
 21)   0.051 us    |                  _raw_spin_unlock_irqrestore();
 21)   1.339 us    |                }
 21)               |                bit_wait_io() {
 21)               |                  io_schedule() {

我们可以看到，pread（2）是从 __lock_page_killable 这个函数调用下来的。当 pread(2) 从磁盘中读文件到内存页（page）时，会先 lock 该 page，读完后再 unlock。如果该 page 已经被别的线程 lock 了，比如在 I/O 过程中被 lock，那么 pread(2) 就需要等待。等该 page 被 I/O 线程 unlock 后，pread(2) 才能继续把文件内容读到这个 page 中。我们当时遇到的情况是：在 pread(2) 从磁盘中读取文件内容到一个 page 中的时候，该 page 已经被 lock 了，于是调用 pread(2) 的线程就在这里等待。这其实是合理的内核逻辑，没有什么问题。接下来，我们就需要看看为什么该 page 会被 lock 了这么久。

因为线程是阻塞在磁盘 I/O 里的，所以我们需要查看一下系统的磁盘 I/O 情况，我们可以使用 iostat 来观察：

$ iostat -dxm 1

追踪信息如下：

其中，sdb 是业务 pread(2) 读取的磁盘所在的文件，通常情况下它的读写量很小，但是我们从上图中可以看到，磁盘利用率（%util）会随机出现比较高的情况，接近 100%。而且 avgrq-sz 很大，也就是说出现了很多 I/O 排队的情况。另外，w/s 比平时也要高很多。我们还可以看到，由于此时存在大量的 I/O 写操作，磁盘 I/O 排队严重，磁盘 I/O 利用率也很高。根据这些信息我们可以判断，之所以 pread(2) 读磁盘文件耗时较长，很可能是因为被写操作饿死导致的。因此，我们接下来需要排查到底是谁在进行写 I/O 操作。

通过 iotop 观察 I/O 行为，我们发现并没有用户线程在进行 I/O 写操作，写操作几乎都是内核线程 kworker 来执行的，也就是说用户线程把内容写在了 Page Cache 里，然后 kwoker 将这些 Page Cache 中的内容再同步到磁盘中。这就涉及到了我们这门课程第一个模块的内容了：如何观测 Page Cache 的行为。

自己写分析工具

如果你现在还不清楚该如何来观测 Page Cache 的行为，那我建议你再从头仔细看一遍我们这门课程的第一个模块，我在这里就不细说了。不过，我要提一下在 Page Cache 模块中未曾提到的一些方法，这些方法用于判断内存中都有哪些文件以及这些文件的大小。

常规方式是用 fincore 和 mincore，不过它们都比较低效。这里有一个更加高效的方式：通过写一个内核模块遍历 inode 来查看 Page Cache 的组成。该模块的代码较多，我只说一下核心的思想，伪代码大致如下：

iterate_supers // 遍历super block
  iterate_pagecache_sb // 遍历superblock里的inode
      list_for_each_entry(inode, &sb->s_inodes, i_sb_list)
        // 记录该inode的pagecache大小
        nrpages = inode->i_mapping->nrpages;
        /* 获取该inode对应的dentry，然后根据该dentry来查找文件路径；
         * 请注意inode可能没有对应的dentry，因为dentry可能被回收掉了，
         * 此时就无法查看该inode对应的文件名了。
         */
        dentry = dentry_from_inode(inode);
        dentry_path_raw(dentry, filename, PATH_MAX);

使用这种方式不仅可以查看进程正在打开的文件，也能查看文件已经被进程关闭，但文件内容还在内存中的情况。所以这种方式分析起来会更全面。

通过查看 Page Cache 的文件内容，我们发现某些特定的文件占用的内存特别大，但是这些文件都是一些离线业务的文件，也就是不重要业务的文件。因为离线业务占用了大量的 Page Cache，导致该在线业务的 workingset 大大减小，所以 pread(2) 在读文件内容时经常命中不了 Page Cache，进而需要从磁盘来读文件，也就是说该在线业务存在大量的 pagein 和 pageout。

至此，问题的解决方案也就有了：我们可以通过限制离线业务的 Page Cache 大小，来保障在线业务的 workingset，防止它出现较多的 refault。经过这样调整后，业务再也没有出现这种性能抖动了。

你是不是对我上面提到的这些名字感到困惑呢？也不清楚 inode 和 Page Cache 是什么关系？如果是的话，那就说明你没有好好学习我们这门课程的 Page Cache 模块，我建议你从头再仔细学习一遍。

好了，我们这节课就讲到这里。

课堂总结

我们这节课的内容，对于应用开发者和运维人员而言是有些难度的。我之所以讲这些有难度的内容，就是希望你可以拓展分析问题的边界。这节课的内容对内核开发者而言基本都是基础知识，如果你看不太明白，说明你对内核的理解还不够，你需要花更多的时间好好学习它。我研究内核已经有很多年了，尽管如此，我还是觉得自己对它的理解仍然不够深刻，需要持续不断地学习和研究，而我也一直在这么做。

我们现在回顾一下这节课的重点：

strace 工具是应用和内核的边界，如果你是一名应用开发者，并且想去拓展分析问题的边界，那你就需要去了解 strace 的原理，还需要了解如何去分析 strace 发现的问题；

ftrace 是分析内核问题的利器，你需要去了解它；

你需要根据自己的问题来实现特定的问题分析工具，要想更好地实现这些分析工具，你必须掌握很多内核细节。

课后作业

关于我们这节课的“自己写分析工具”这部分，我给你留一个作业，这也是我没有精力和时间去做的一件事：请你在 sysrq 里实现一个功能，让它可以显示出系统中所有 R 和 D 状态的任务，以此来帮助开发者分析系统 load 飙高的问题。

我在我们的内核里已经实现了该功能，不过在推给 Linux 内核时，maintainer 希望我可以用另一种方式来实现。由于那个时候我在忙其他事情，这件事便被搁置了下来。如果你实现得比较好，你可以把它提交给 Linux 内核，提交的时候你也可以 cc 一下我（laoar.shao@gmail.com）。对了，你在实现时，也可以参考我之前的提交记录：scheduler: enhancement to show_state_filter and SysRq。欢迎你在留言区与我讨论。

最后，感谢你的阅读，如果你认为这节课的内容有收获，也欢迎把它分享给你的朋友。

加餐 | 我是如何使用tracepoint来分析内核Bug的？

你好，我是邵亚方。

我们这个系列课程的目标受众是应用开发者和运维人员，所以，你可以看到课程里的案例在分析应用问题的时候，都在尽量避免分析内核 bug，避免把内核代码拿过来逐个函数地解析为什么会这样。我希望这个课程可以降低内核的门槛，让更多人可以更加容易地了解内核机制，从而更好地解决应用难题、提升应用性能。

不过，在我们这个课程的学习者中还是有一些内核开发者的，因此，我写了这篇加餐来分析内核 bug，希望能把分析内核 bug 的一些经验分享给这些内核开发者们。

通过对课程的学习，你应该能发现，我对 tracepoint 和 ftrace 是极其推崇的。我对它推崇备至不是没有道理的，这节课我就带你来看下我是如何借助 tracepoint 来分析内核 bug 的。

炫技般存在的 tracepoint 内核源码

如果你看过 tracepoint 的内核代码，相信你一定对它炫技般存在的宏定义印象深刻。我在第一眼看到这些宏定义时，也是一脸懵逼，不知从何下手，但是很快我就看懂了。为了证明我看懂了，我还特意给 tracepoint 的这些宏定义又增加了一些定义，我增加的这个宏定义，其关键部分如下：

如果你能看明白这些，那就说明你对这些 tracepoint 宏的工作机制一清二楚了。当然，这节课我不是来剖析 tracepoint 内核源码的。如果你不懂 tracepoint 内核源码，也不妨碍你使用它，不过这对一名内核开发者而言终究是一件憾事。

因为我经常使用 tracepoint，所以我对 tracepoint 的一些功能也比较上心。比如，最近在我的推动下，tracepoint 模块的 maintainer Steven Rostedt又给 tracepoint 增加了一个宏定义。我之所以推动 Steven 增加该宏，是为了让 tracepoint 函数可以在头文件中使用，以减少因为额外函数调用而带来的开销。有了这个新增的宏之后，你就可以方便地在头文件中使用 tracepoint 了。

接下来我要讲的这个内核 bug，就是借助 tracepoint 来分析的。

内存申请在慢速路径上为什么会失败

有一次，业务人员反馈说他们在启动程序时会偶然失败，我查看内核日志后发现了下面这些报错信息（这个系统为 CentOS-7，对应的内核版本为 3.10）：

kworker/31:0: page allocation failure: order:5, mode:0x104050
CPU: 31 PID: 635928 Comm: kworker/31:0 Tainted: G
0000000000104050 000000009a44a60e ffff882016b93808 ffffffff81686b13
ffff882016b93898 ffffffff81187010 0000000000000000 ffff88207ffd8000
0000000000000005 0000000000104050 ffff882016b93898 000000009a44a60e
 Call Trace:
[<ffffffff81686b13>] dump_stack+0x19/0x1b
[<ffffffff81187010>] warn_alloc_failed+0x110/0x180
[<ffffffff816826a7>] __alloc_pages_slowpath+0x6b7/0x725
[<ffffffff8118b5c5>] __alloc_pages_nodemask+0x405/0x420
[<ffffffff811cf77a>] alloc_pages_current+0xaa/0x170
[<ffffffff81185eee>] __get_free_pages+0xe/0x50
[<ffffffff811db01e>] kmalloc_order_trace+0x2e/0xa0
[<ffffffff811e05d9>] __kmalloc_track_caller+0x219/0x230
[<ffffffff8119f78f>] krealloc+0x4f/0xa0
[<ffffffffa07eebe6>] osdmap_set_max_osd+0x76/0x1d0 [libceph]
[<ffffffffa07f14f6>] ceph_osdmap_decode+0x216/0x600 [libceph]
[<ffffffffa07ecce4>] handle_one_map+0x224/0x250 [libceph]
[<ffffffffa07ed98f>] ceph_osdc_handle_map+0x6cf/0x720 [libceph]
[<ffffffffa07e3340>] dispatch+0x350/0x7c0 [libceph]
[<ffffffffa07deecf>] try_read+0x4df/0x1260 [libceph]
[<ffffffffa07dfd09>] ceph_con_workfn+0xb9/0x650 [libceph]
[<ffffffff810a845b>] process_one_work+0x17b/0x470
[<ffffffff810a9296>] worker_thread+0x126/0x410
[<ffffffff810b0a4f>] kthread+0xcf/0xe0
[<ffffffff81697118>] ret_from_fork+0x58/0x90
Mem-Info:
active_anon:13891624 inactive_anon:358552 isolated_anon:0#012 active_file:1652146 inactive_file:14520052 isolated_file:0#012 unevictable:0 dirty:472 writeback:0 unstable:0#012 slab_reclaimable:1071185 slab_unreclaimable:201037#012 mapped:53234 shmem:1046930 pagetables:31965 bounce:0#012 free:966225 free_pcp:185 free_cma:0
Node 0 DMA free:15864kB min:48kB low:60kB high:72kB active_anon:0kB inactive_anon:0kB active_file:0kB inactive_file:0kB unevictable:0kB isolated(anon):0kB isolated(file):0kB present:15948kB managed:15864kB mlocked:0kB dirty:0kB writeback:0kB mapped:0kB shmem:0kB slab_reclaimable:0kB slab_unreclaimable:0kB kernel_stack:0kB pagetables:0kB unstable:0kB bounce:0kB free_pcp:0kB local_pcp:0kB free_cma:0kB writeback_tmp:0kB pages_scanned:0 all_unreclaimable? yes
 lowmem_reserve[]: 0 1700 64161 64161
Node 0 DMA32 free:261328kB min:5412kB low:6764kB high:8116kB active_anon:303220kB inactive_anon:2972kB active_file:51840kB inactive_file:239320kB unevictable:0kB isolated(anon):0kB isolated(file):0kB present:1952512kB managed:1741564kB mlocked:0kB dirty:0kB writeback:0kB mapped:1160kB shmem:15968kB slab_reclaimable:687588kB slab_unreclaimable:183756kB kernel_stack:6640kB pagetables:724kB unstable:0kB bounce:0kB free_pcp:0kB local_pcp:0kB free_cma:0kB writeback_tmp:0kB pages_scanned:0 all_unreclaimable? no
lowmem_reserve[]: 0 0 62460 62460
Node 0 Normal free:272880kB min:198808kB low:248508kB high:298212kB active_anon:26813396kB inactive_anon:912660kB active_file:2646440kB inactive_file:30619568kB unevictable:0kB isolated(anon):0kB isolated(file):0kB present:65011712kB managed:63960000kB mlocked:0kB dirty:36kB writeback:0kB mapped:144152kB shmem:2567180kB slab_reclaimable:1870364kB slab_unreclaimable:262608kB kernel_stack:12880kB pagetables:45840kB unstable:0kB bounce:0kB free_pcp:652kB local_pcp:0kB free_cma:0kB writeback_tmp:0kB pages_scanned:32 all_unreclaimable? no
lowmem_reserve[]: 0 0 0 0
Node 1 Normal free:3315332kB min:205324kB low:256652kB high:307984kB active_anon:28449880kB inactive_anon:518576kB active_file:3910304kB inactive_file:27221320kB unevictable:0kB isolated(anon):0kB isolated(file):0kB present:67108864kB managed:66056916kB mlocked:0kB dirty:1852kB writeback:0kB mapped:67624kB shmem:1604572kB slab_reclaimable:1726788kB slab_unreclaimable:357784kB kernel_stack:18928kB pagetables:81296kB unstable:0kB bounce:0kB free_pcp:0kB local_pcp:0kB free_cma:0kB writeback_tmp:0kB pages_scanned:0 all_unreclaimable? no
lowmem_reserve[]: 0 0 0 0
Node 0 DMA: 0*4kB 1*8kB (U) 1*16kB (U) 1*32kB (U) 1*64kB (U) 1*128kB (U) 1*256kB (U) 0*512kB 1*1024kB (U) 1*2048kB (M) 3*4096kB (M) = 15864kB
Node 0 DMA32: 36913*4kB (UEM) 14087*8kB (UEM) 44*16kB (UEM) 17*32kB (UEM) 0*64kB 0*128kB 0*256kB 0*512kB 0*1024kB 0*2048kB 0*4096kB = 261596kB
Node 0 Normal: 69629*4kB (UEM) 411*8kB (UEM) 1*16kB (E) 3*32kB (E) 0*64kB 0*128kB 0*256kB 0*512kB 0*1024kB 0*2048kB 0*4096kB = 281916kB
Node 1 Normal: 241701*4kB (UEM) 240734*8kB (UEM) 24010*16kB (UEM) 990*32kB (UEM) 175*64kB (UEM) 20*128kB (UEM) 3*256kB (EM) 0*512kB 0*1024kB 0*2048kB 0*4096kB = 3323044kB
Node 0 hugepages_total=0 hugepages_free=0 hugepages_surp=0 hugepages_size=1048576kB
Node 0 hugepages_total=0 hugepages_free=0 hugepages_surp=0 hugepages_size=2048kB
Node 1 hugepages_total=0 hugepages_free=0 hugepages_surp=0 hugepages_size=1048576kB

从上面的日志可以看出，这是在 __alloc_pages_slowpath() 分配内存失败，所以有了这个打印:

__alloc_pages_slowpath
{
    ...
nopage:
    // 这里打印的错误日志
    warn_alloc_failed(gfp_mask, order, NULL);
    return NULL;
}

此时申请的内存大小是 order 5，也就是 32 个连续页。紧接着，我们可以看到各个 node 具体内存使用情况的打印，该机器共有 2 个 node：

Node 0 DMA free:15864kB min:48kB
Node 0 DMA32 free:261328kB min:5412kB
Node 0 Normal free:272880kB min:198808kB
Node 1 Normal free:3315332kB min:205324kB

从中我们可以发现，各个 zone 的 free 内存都大于 min，而且相差不止 32 个 page。也就是说，从 free 内存的大小来看，各个 zone 都是可以满足需求的。那么，为什么此时会申请内存失败呢？

接下来，我们一起分析失败的原因。

逐一排查可能的情况

对于 3.10 版本的内核而言，在内存分配慢速路径里失败，原因可以分为以下三种情况：

特殊的 GFP flags 导致；

进程自身的状态；

reclaim 和 compact 无法满足需求。

现在，我们来逐一排查究竟是哪种情况导致的。

GFP flags

此时的 GFP flags 是 0x104050，对应于下面这几项：

#define ___GFP_WAIT             0x10u
#define ___GFP_IO               0x40u
#define ___GFP_COMP             0x4000u
#define ___GFP_KMEMCG           0x100000u

看到这里，你是否思考过：为什么不直接在内核日志里打印出这些 GFP flags 呢？如果你思考了，那么恭喜你，你具备内核开发者的特质；如果你没有思考过，那么你需要加强这方面的思考：我觉得内核这里有点不好，我得改变它。

我觉得内核日志里打印这些数字不如直接打印对应的 GFP flags 好，然后我就去查看最新的内核代码，发现这部分已经在新版本的内核里被修改过了，看来其他的内核开发者与我的想法一致。当然，这也说明了使用老版本的内核做开发是一件多么憋屈的事，因为你会发现你在老版本内核里分析清楚的内核 bug，早已在新版本中被别人给 fix 了，这大大限制了我们的发挥空间。

通过前面的调用栈，我们可以知道申请内存是在 osdmap_set_max_osd() 这个函数中进行的，它对应的内核代码如下：

osdmap_set_max_osd
    addr = krealloc(map->osd_addr, max*sizeof(*addr), GFP_NOFS);
    if (!addr)
        return -ENOMEM;

我们看到这里的 GFP flags 为 GFP_NOFS，它的定义如下：

#define GFP_NOFS        (__GFP_WAIT | __GFP_IO)

这与内核日志里的 GFP flags 是对得上的。然后我们去比较goto nopage;的情况，可以发现这几个 flag 不会导致这种情况，也就是不会导致 nopage。

关于 GFP_NOFS 的作用，我在这里大致说明一下。它的作用是为了防止某些路径上触发直接内存回收时，回收到正在进行 I/O 的 page 从而引起死锁。那什么情况下可能会引起死锁呢？你可以参考一下我尚未完成的PATCH：xfs: avoid deadlock when trigger memory reclaim in ->writepages。这个链接里描述的问题在 3.10 版本以及最新版本的内核上都存在，之所以我还没有完成该 PATCH，是因为它依赖于我的另外一组 PATCH，而我目前很少有精力去写它们。具体的逻辑你可以看下这个 PATCH 的代码以及描述，我就不在这里细说了。

现在，我们排除了 GFP flags 产生 nopage 的可能，接下来继续看看另外两种情况。

进程标记 current->flag

在 warn_alloc_failed 里，我们可以看到，如果是因为进程自身的状态有问题（比如正在退出，或者正在 oom 过程中等），那么SHOW_MEM_FILTER_NODES这个标记位就会被清掉，然后各个 zone 的具体内存信息就不会被打印出来了。

因此，内存申请在慢速路径上失败也不是因为这个原因。

reclaim 和 compact 无法满足需求

现在就只有“reclaim 和 compact 无法满足需求”这一种情况了。

根据前面的信息我们可以知道，此时 free 的内存其实挺多，可以排除 reclaim 无法满足需求的情况。所以，只剩下 compcat 这个因素了。也就是说，由于申请的是连续页，而系统中没有足够的连续页，所以 compact 也满足不了需求，进而导致分配内存失败。

那么，在什么情况下 compact 会失败呢？我们继续来看代码：

try_to_compact_pages
    int may_enter_fs = gfp_mask & __GFP_FS;
    int may_perform_io = gfp_mask & __GFP_IO;

    if (!order || !may_enter_fs || !may_perform_io)
      return rc;

我们可以看到__GFP_FS没有被设置，无法进行 compaction，直接返回了COMPACT_SKIPPED。

明白了问题所在后，我们需要在生产环境上验证一下，看看到底是不是这个原因。

使用 tracepoint 分析生产环境

tracepoint 是一种性能开销比较小的追踪手段，在生产环境上使用它，不会给业务带来明显的性能影响。

在使用 tracepoint 分析前，我们需要明确它可以追踪什么事件。

因为我们目前的问题是 compact fail，所以我们需要去追踪 direct compact 这个事件。新版本的内核里有 compact 相关的 tracepoint，我们直接打开对应的 tracepoint 就可以了。不过，3.10 版本的内核没有 compact 相关的 tracepoint，这个时候我们就需要借助 kprobe 机制了，最简单的方式是利用 ftrace 提供的 kprobe_events 功能或者是 ftrace 的 function tracer 功能。我们以 function tracer 为例来追踪 direct compact：

$ echo function > /sys/kernel/debug/tracing/current_tracer
$ echo __alloc_pages_direct_compact > /sys/kernel/debug/tracing/set_ftrace_filter

这样，当发生 direct compact 时，在 trace_pipe 中就会有相应的信息输出。不过，这显示不了 compact 的细节，我们还需要结合其他手段查看是否进行了 compact。方式有很多，在这里，我们结合源码来看一种比较简单的方式：

 __alloc_pages_direct_compact
   try_to_compact_pages
     /* Check if the GFP flags allow compaction */
     if (!order || !may_enter_fs || !may_perform_io)
       return rc;

      // 如果可以进行direct compact的话，会有COMPACTSTALL事件
      count_compact_event(COMPACTSTALL);

从源码中我们能看到，如果可以进行 direct compact 的话，会有相应的 COMPACTSTALL 事件，而该事件会统计在 /proc/vmstat 中：

$ cat /proc/vmstat | grep compact

这样我们就可以知道调用 __alloc_pages_direct_compact 时，有没有真正进行 compact 的行为。另外，在 compact 的过程中还会伴随着 direct reclaim，我们也需要看下 direct reclaim 的细节，看看 direct claim 能否成功回收内存。我们可以借助 direct reclaim 的 tracepoint 来查看，该 tracepoint 在 3.10 版本的内核里已经有了：

$ echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_vmscan_direct_reclaim_begin/enable

$ echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_vmscan_direct_reclaim_end/enable

在追踪这些事件之后，我们就可以看到 direct compact 前后的信息了。

direct compact 前的 vmstat 指标为：

$ cat /proc/vmstat | grep compact
compact_migrate_scanned 690141298
compact_free_scanned 186406569096
comoact_isolated 332972232
compact_stall 87914
compact_fail 40935
compact_success 46979

compact 过程中的事件：

compact 结束后的 vmstat 指标为：

$ cat /proc/vmstat | grep compact
compact_migrate_scanned 690141298
compact_free_scanned 186406569096
comoact_isolated 332972232
compact_stall 87914
compact_fail 40935
compact_success 46979

我们可以看到，在 compact 前后，compcat_stall 这个指标没有任何变化，也就是说 try_to_compact_pages 中没有进行真正的 compact 行为；从 direct reclaim 事件中的 nr_reclaimed=3067 可以看到，此时可以回收到足够的 page，也就是说 direct reclaim 没有什么问题；同样，direct reclaim 的“order=5, gfp_flags=GFP_NOFS|GFP_COMP” 也与之前日志里的信息吻合。因此，这些追踪数据进一步印证了我们之前的猜测：__GFP_FS没有被设置，无法进行 compaction。

我们现在再次观察申请内存失败时的日志，可以发现，此时 free list 上其实有当前 order 的内存（因为没有 GFP_DMA，所以会先从 NORMAL zone 申请内存）：

Node 0 Normal: 69629*4kB (UEM) 411*8kB (UEM) 1*16kB (E) 3*32kB (E) 0*64kB 0*128kB 0*256kB 0*512kB 0*1024kB 0*2048kB 0*4096kB = 281916kB
Node 1 Normal: 241701*4kB (UEM) 240734*8kB (UEM) 24010*16kB (UEM) 990*32kB (UEM) 175*64kB (UEM) 20*128kB (UEM) 3*256kB (EM) 0*512kB 0*1024kB 0*2048kB 0*4096kB = 3323044kB

我们能看到 node 1 大于 order 5（即 128K）的连续物理内存有很多，那为什么不能从这些 zone->free_area[order]里分配内存呢？

答案就在于该zone的水位不满足要求（见__zone_watermark_ok()）：

__zone_watermark_ok
{
    ...
    for (o = 0; o < order; o++) {
        free_pages -= z->free_area[o].nr_free << o;
        min >>= 1;
        if (free_pages <= min)
            return false;
    }
}

对于 node 1 而言，4K/8K/16K/32K/64K 内存和为 3319716kB，该 zone 的 watermark min 为 205324kB，该 node 的总内存为 3323044KB，我们可以简单地进行如下比较：

(3323044-3319716) 为3328KB
(205324kB >> 5) 为6416KB

因此，order 5 无法满足水位。

根据上述这些追踪信息，我们可以得出这样的结论：在内存分配慢速路径上失败，是因为当前的内存申请请求无法进行碎片整理，而系统中 low order 的内存又太多，从而导致了该 order 的内存申请失败。

解决方案

因为此时 normal zone 的 order=5 的 free page 依然有很多，而整体的 watermark 又满足需求，所以不应该让 order=5 的内存申请失败，这是一个内核缺陷。我去查看 upstream 的最新代码时，发现该缺陷已经被修改过了。你可以看到，使用老版本的内核做开发的确是一件很憋屈的事。

关于 upstream 的修改方案，你可以参考这个 patch 以及它的一些依赖：

mm, page_alloc: only enforce watermarks for order-0 allocations

如果你无法通过修改内核来解决这一问题的话，那就采取一些规避措施。

规避方案一：

通过 drop_caches 清理掉 pagecache，不过这种做法也有很多缺陷，具体你可以参考我们这个课程的 pagecache 模块，我在这里就不细说了。

规避方案二：

调整碎片指数，确保紧急情况下能够申请到连续页。内存碎片指数对应的文件是/proc/sys/vm/extfrag_threshold，它的默认值是 500 ，我们可以适当降低该值，不过在这种情况下，降低它的效果并不会很明显。

规避方案三：

手动 compact，你可以通过写入 /proc/sys/vm/compact_memory 来触发 compact。

规避方案四：

调整 vm.vfs_cache_pressure，降低它的值，让 pagecache 被回收得更多，以此来减少 freelist 中 order 为 0 的 page 个数。

至此，我们这个问题的分析就结束了。

总结

在比较新的内核上，我们也可以通过 eBPF 来分析内核 bug，比如在我们的生产环境中，我就通过 eBPF 追踪过 fadvise 的内核 bug 引起的业务抖动问题，具体 bug 你可以看看我贡献给内核的这个PATCH: mm,fadvise: improve the expensive remote LRU cache draining after FADV_DONTNEED，这也再次印证了我的观点：内核开发者只有在新版本的内核里做开发，才会有更多的发挥空间。

另外，虽然 eBPF 很强大，但是它依然难以替代 ftrace 在我心中的地位。

结束语 | 第一次看内核代码，我也很懵逼

你好，我是邵亚方。

我们的专栏就要接近尾声了，一路走来，非常感谢你的支持。我在这里想跟你分享一下我学习 Linux 内核的经历，希望我的个人经历能够给你带来一些启发。

我在从事 Linux 内核开发工作之前，几乎没有接触过 Linux 内核源码，只是对 Linux 操作系统略微了解一些，这还得益于大学期间 Ubuntu 的流行。那个时候我对什么都感到好奇，看到 Ubuntu 忽然流行起来，就在自己的电脑上也安装了 Windows+Ubuntu 双系统，天天折腾 Linux 命令和一些 Ubuntu 的使用技巧。然后没过多久，我就觉得自己是一个 Linux 专家了，开始在一些论坛上“指指点点”，觉得自己无所不能。

我们说，从 0 到 1 是一个从无到有的过程，对于发明创造来说这很难，但对于学习新东西而言，从 0 到 1 就相对容易了。不过，我那个时候把“学习”当成了“发明创造”，认为自己跨越了从 0 到 1 的阶段，就算是突破了学习 Linux 最难的部分，再往后就会顺其自然。

直到我开始实现一个 Linux 内核特性，才意识到自己对 Linux 的认知有多么肤浅。我之前掌握的那些 Linux 知识，只是让我从 0 迈出了一小步，而后面实际上还有 99 步要走。好在我及时意识到了自己的狂妄自大，开始埋头对照着 Linux 内核源码学习《深入 Linux 内核》这本书。在我的师傅手把手的指导以及周围同事的热心帮助下，我终于实现了 Linux 内核特性。

后来，在我能够独立实现 Linux 内核特性后，我又开始飘了，认为自己是 Linux 内核专家了。没错，我又在犯之前的错误，以为掌握一个新技能，只要从 0 迈向 1 就是突破了最难的部分，剩下的路就会一马平川。

这种狂妄自大一直伴随着我，直到我开始参与 Linux 社区的工作。当我与来自世界各地的开发者们讨论问题时，我再一次发现自己对 Linux 内核的认知有多肤浅。这并不是说我周围的同事不如社区里的开发者，而是我的同事们会在我遇到困难时帮助我一起想办法解决，与 Linux 社区的开发者们打交道则不同，我需要自己解决他们的质疑。

当然，Linux 内核社区是很开放的，里面也有很多既热心又谦逊的人帮助你。与他们打交道越多，讨论的越多，你就越会觉得自己对 Linux 内核的认知很肤浅。有时候，为了反驳他们的质疑，甚至只是为了单纯指出他们逻辑上的缺陷，我会去看大量的内核代码，写大量的内核代码以及大量的测试用例…每当 Linux 内核子系统的 maintainer 承认他们的观点是错的，而我的观点是正确的时候，我都会感觉很快乐，因为自己的努力没有白费。

不过，即便如此，我并不觉得自己对 Linux 内核的理解已经到了多么深刻的地步，因为我知道我还在从 1 到 99 的道路上艰难前进着，我也不知道自己到底走了多少步，不过，这对我来说已经不重要了。俗话说，事不过三，你会发现我的心态已经变了，我不再犯之前的错误了。

我只是想告诉你，对于 Linux 的学习者而言，从 0 到 1 真的是最简单的一步，往后的每一步会越来越难。走完 99 步之后，你可能才开始面临最艰难的那一步。也许当你从第 99 步正式跨越到第 100 步时，就能够发明创造一个新的东西了？就像 Linus 向社区发布 Linux 内核的第一个版本一样，他从 0 到 1 创造了 Linux。而我们不断地对它学习、研究，在壮大 Linux 的同时，也在走着自己的 0 到 99 步。至于我们从 99 迈向 100 时究竟会实现怎样的突破，我并不清楚，因为我自己还没有达到这样的境界，不过，未来的你和我或许有机会能验证它。

最后，再次感谢你一路以来的支持与陪伴，我们后会有期！

《Linux 内核技术实战课》就要结束了，这里有一份毕业问卷，题目不多，希望你能花两分钟填一下。十分期待能听到你说一说，你对这个课程的想法和建议。

结课测试 | 这些Linux内核技术实战技能你都掌握了吗？

你好，我是邵亚方。

到这里，《Linux 内核技术实战课》这门课程已经全部结束了。我给你准备了一个结课小测试，来帮助你检验自己的学习效果。

这套测试题共有 20 道题目，均为多选题，满分 100 分，系统自动评分。

还等什么，点击下面按钮开始测试吧！