本章介绍的工具使你能诊断应用程序与内存子系统之间的交互,该子系统由Linux内核和CPU管理。由于内存子系统的不同层次在性能上有数量级的差异,因此,修复应用程序使其有效地使用内存子系统会对程序性能产生巨大的影响。
阅读本章后,你将能够:
在诊断内存性能问题的时候,也许有必要观察应用程序在内存子系统的不同层次上是怎样执行的。在顶层,操作系统决定如何利用交换内存和物理内存。它决定应用程序的哪一块地址空间将被放到物理内存中,即所谓的驻留集。不属于驻留集却又被应用程序使用的其他内存将被交换到磁盘。由应用程序决定要向操作系统请求多少内存,即所谓的虚拟集。应用程序可以通过调用malloc进行显式分配,也可以通过使用大量的堆栈或库进行隐式分配。应用程序还可以分配被其自身或其他应用程序使用的共享内存。性能工具ps用于跟踪虚拟集和驻留集的大小。性能工具memprof用于跟踪应用程序的哪段代码是分配内存的。工具ipcs用于跟踪共享内存的使用情况。
当应用程序使用物理内存时,它首先与CPU的高速缓存子系统交互。现代CPU有多级高速缓存。最快的高速缓存离CPU最近(也称为L1或一级高速缓存),其容量也最小。举个例子,假设CPU只有两级高速缓存:L1和L2。当CPU请求一块内存时,处理器会检查看该块内存是否已经存在于L1高速缓存中。如果处于,CPU就可以使用。如果不在L1 高速缓存中,处理器产生一个L1高速缓存不命中。然后它会检查L2高速缓存,如果数据在L2高速缓存中,那么它可以使用。如果数据不在L2高速缓存,将产生一个L2高速缓存不命中,处理器就必须到物理内存去取回信息。最终,如果处理器从不访问物理内存(因为它会在L1或者甚至L2高速缓存中发现数据)将是最佳情况。明智地使用高速缓存,例如重新排列应用程序的数据结构以及减少代码量等方法,有可能减少高速缓存不命中的次数并提高性能。cachegrind和oprofile是很好的工具,用于发现应用程序对高速缓存的使用情况的信息,以及哪些函数和数据结构导致了高速缓存不命中。
本节讨论各种内存性能工具,它们使你能检查一个给定的应用程序是如何使用内存子系统的,包括进程使用的内存总量和不同的内存类型,内存是在哪里分配的,以及进程是如何有效使用处理器的高速缓存的。
对跟踪进程的动态内存使用情况而言,ps是一个极好的命令。除了已经介绍过的CPU 统计数据,ps还能提供关于应用程序使用内存的总量以及内存使用情况对系统影响的详细信息。
ps有许多不同的选项,可以获取一个正在运行的应用程序各种各样的状态统计信息。如同你在前面章节里看到的,ps能够检索到一个进程消耗CPU的情况,但它同时也可以检索到进程使用内存的容量和类型信息。ps可以用如下命令行调用:
ps [-o vsz, rss, tsiz, dsiz, majflt, minflt, pmem, command]
表5-1解释了给定PID情况下,ps显示的不同类型的内存统计信息。如前所述,在如何选择统计数据要显示哪些PID时,ps是很灵活的。ps-help提供了信息以说明如何指定不同的PID组。
清单5.1展示了在系统上运行的测试应用程序burn。我们要求ps给出进程的内存统计信息。
如清单5.1所示,应用程序burn的文本大小很小(1KB),但是其数据大小却很大(11122KB)。相对总的虚拟大小(11124KB)来说,进程的驻留集略小一点(10004KB),驻留集表示的是进程实际使用的物理内存总量。此外,burn产生的大多数故障都是次故障,所以,多数内存故障是由内存分配导致的,而不是由从磁盘的程序映像加载大量的文本或数据导致的。
Linux内核提供了一个虚拟文件系统,使你能提取在系统上运行的进程的信息。由/proc文件系统提供的信息通常仅被如ps之类的性能工具用于从内核提取性能数据。尽管一般不需要深入挖掘/proc中的文件,但是它确实能提供其他性能工具所无法检索到的一些信息。除了许多其他统计数据之外,/proc还提供了进程的内存使用信息和库映射信息。
/proc的接口很简单。/proc提供了许多虚拟文件,可以用cat来提取它们的信息。系统中每一个运行的PID在/proc下都有一个子目录,这个子目录含有一系列文件,其中包含的是关于该PID的信息。这些文件中,status给出的是给定进程PID的状态信息,其检索命令如下:
cat /proc//status
表5-2对status文件显示的内存统计信息进行了解释。
目录下的另一个文件是maps,它提供了关于如何使用进程虚拟地址空间的信息。其检索命令如下所示:
cat /proc//maps
表5-3解释了maps文件中的字段。
/proc提供的信息可以帮助你了解应用程序是如何分配内存的,以及它使用了哪些库。
清单5.2显示的是运行于系统上的burn测试程序。首先,我们用ps找到burn的PID (4540)。然后,用/proc的status文件抽取进程的内存统计信息。
如清单5.2所示,我们再一次看到应用程序burn的文本大小(4KB)和堆栈大小(8KB)很小,而数据大小(9776KB)很大,库大小(1312KB)较合理。小的文本大小意味着进程没有太多的可执行代码,而适中的库大小表示它使用库来支持其执行。小的堆栈大小意味着该进程没有调用深度嵌套的函数,或者没有调用使用了大型或多个临时变量的函数。VmLck的大小为0KB说明进程没有锁定内存中的任何页面,使得它们无法交换。VmRSS大小为10004KB意味着应用程序当前使用了10004KB的物理内存,不过它分配或映射的大小为VmSize或11124KB。如果应用程序开始使用之前已分配但并非正在使用的内存,那么VmRSS会增加,而VmSize会保持不变。
如前所述,应用程序的VmLib的大小为非零值,因此程序使用了库。在清单5.3中,我们查看进程的maps来了解它使用的那些库。
就像你在清单5.3看到的,应用程序burn使用了两个库:ld和libc。libc文本部分(由权限r-xp表示)的范围从0x4002f0000到0x40162000,即大小为0x133000或1257472字节。
libc数据部分(由权限rw-p表示)的范围从40162000到40166000,即大小为0x4000 或16384字节。libc的文本部分大于ld的文本部分,后者的大小为0x15000或86016字节。libc的数据部分也大于ld的数据部分,后者的大小为0x1000或4096字节。libc是burn链接的大库。
/proc被证明是从内核直接提取性能统计信息的有效途径。由于统计信息是基于文本的,因此可以使用标准的Linux工具来访问它们。
memprof是一种图形化的内存使用情况剖析工具。它展示了程序在运行时是如何分配内存的。memprof显示了应用程序消耗的内存总量,以及哪些函数应对这些内存使用量负责。此外,memprof还可以显示哪些代码路径要对内存使用量负责。比如,如果函数foo()不分配内存,但是调用函数bar()要分配大量的内存,那么memprof就会向你显示foo()自身使用的内存量以及foo()调用的全部函数。应用程序运行时,memprof会动态更新这些信息。
memprof是一个图形化的应用程序,但是也有一些命令行选项来调整其执行。memprof 用如下命令调用:
memprof [–follow -fork] [-follow -exec] application
memprof剖析给定的“application”,并为其内存使用情况创建一个图形化输出。虽然memprof可以在任何应用程序上运行,但是如果它依赖的应用程序和库使用调试符号编译,那么它就能提供更多的信息。
表5-4说明了控制memprof行为的选项,条件是memprof监控的应用程序调用了fork 或exec。这通常发生在应用程序启动一个新进程或执行一条新命令的时候。
一旦被调用,memprof会创建一个带有一系列菜单和选项的窗口,使你能选择要剖析的应用程序。
假设我有如清单5.4所示的示例代码,并且我想要剖析这段代码。在这个称为memory eater的应用程序中,函数foo()不分配内存,但是它调用的函数bar()却要分配内存。
用-g3标志编译该应用程序后(这样应用程序就包含了符号),我们使用memprof来剖析该应用程序:
[ezoltelocalhost example]s memprof ./memory_eater memintercept(3965):
_MEMPROF_SOCKET =/tmp/memprof.Bm1AKu memintercept(3965):New process,operation = NEW,old_pid = 0
memprof创建如图5-1所示的应用程序窗口。如同你看到的,它不仅给出了应用程序memory_eater的内存使用情况,还显示了一系列的按钮和菜单使你能对分析进行控制。
如果你点击Profile按钮,memprof会显示对应用程序的内存分析。图5-2中的第一个信息框显示的是每个函数消耗的内存量(用“Self”表示),以及该函数及其子函数消耗的内存总量(用“Total”表示)。和预期的一样,函数foo()不分配任何内存,因此它的Self 值为0,但它的Total值为100000,这是因为它调用的函数要分配内存。
当你点击上面信息框中不同的函数时,Children和Callers信息框会发生变化。这样你就可以看到应用程序的哪些函数在使用内存。
memprof提供了一种以图形方式遍历大量的关于内存分配数据的途径。它给出了一种简单的方法来确定给定函数及其调用的函数的内存分配情况。
valgrind是一个强大的工具,使你能调试棘手的内存管理错误。虽然valgrind主要是一个开发者工具,但它也有一个“界面”能显示处理器的高速缓存使用情况。valgrind模拟当前的处理器,并在这个虚拟处理器上运行应用程序,同时跟踪内存使用情况。它还能模拟处理器高速缓存,并确定程序在哪里有指令和数据高速缓存的命中或缺失。
虽然valgrind很有用,但是它的高速缓存统计信息却是不准确的(因为valgrind只是处理器的模拟,而不是一个真实的硬件)。valgrind不计算通常由对Linux内核的系统调用导致的高速缓存缺失,也不计算由于上下文切换而发生的高速缓存缺失。此外,valgrind运行应用程序的速度比本机执行程序的速度慢得多。但是,valgrind提供了与应用程序高速缓存使用情况最接近的数据。valgrind可以运行于任何可执行文件。如果程序已经用符号编译过了(在编译时把-g3传递给gcc),它还是能够准确地找出要对高速缓存的使用情况负责的代码行。
当使用valgrind分析一个特定应用程序的高速缓存使用情况时,要经历两个阶段:收集和注释。收集阶段从如下命令行开始:
valgrind -skin=cachegrind application
valgrind是一个灵活的工具,它有几种不同的“界面”使其可以执行不同类型的分析。在收集阶段,valgrind用cachegrind界面来收集关于高速缓存使用情况的信息。上述命令行中的application表示的是要剖析的应用程序。收集阶段在屏幕上显示的是概要信息,但它同时也在名为cachegrind.out.pid的文件中保存了更加详细的统计数据,文件名中的pid是其运行时被剖析应用程序的PID。当收集阶段完成后,使用命令cg_annotate把高速缓存使用情况映射回应用程序源代码。cg_annotate调用方式如下:
cg_annotate -pid [–auto=yeslno]
cg_annotate获取valgrind生成的信息,并用它来注释被剖析的应用程序。选项–pid是必须的,因为pid是你有兴趣进行剖析的对象的PID。默认情况下,cg_annotate只显示函数级的高速缓存使用情况。如果你的设置为–auto=yes,那么就会在源代码级显示高速缓存的使用情况。
本例展示了在一个简单应用程序上运行的valgrind(v2.0)。该应用程序清除一大块内存区域,然后调用两个函数:a()和b(),这两个函数都要访问这个内存区域。函数a()访问内存的次数是函数b()访问次数的10倍。首先,如清单5.5所示,我们用cachegrind界面在应用程序上运行valgrind。
在清单5.5的运行中,应用程序执行了11317111条指令;该项显示在Irefs统计数据中。进程在L1(215)和L2(214)指令高速缓存中的缺失次数低得令人吃惊,由I1和L2i miss rate的0.0%表示。进程的数据引用总次数为6908012,其中,4405958次为读,2502054次为写。24.9%的读和12.4%的写无法由L1高速缓存满足。幸运的是,我们几乎总是可以在L2数据高速缓存中满足读操作,因此,它们显示的缺失率为0%。写操作仍然是个问题,其缺失率为12.4%。在这个应用程序中,数据的内存访问是需要调查的问题。
理想的应用程序应该有非常低的指令高速缓存和数据高速缓存缺失率。要消除指令高速缓存缺失,可能需要用不同的编译器选项重新编译应用程序,或者裁剪代码,这样热代码就不需要与不常用的代码一起共享icache空间了。要消除数据高速缓存缺失,使用数组而非链表作为数据结构,如果可能的话,降低数据结构中元素的大小,并用高速缓存友好的方式来访问内存。在任何情况下,valgrind有助于指出哪个访问/数据结构需要进行优化。该应用程序运行的汇总信息表明数据访问是主要问题。
如清单5.5所示,这条命令显示了整体运行的高速缓存的使用情况统计信息。但是,对开发应用程序,或调查性能问题而言,更为有趣的是查看高速缓存缺失发生的位置,而不是仅仅了解在应用程序运行期间出现的缺失总数。要确定由哪个函数为高速缓存缺失负责,我们运行cg_annoate,如清单5.6所示。它向我们展示了哪个函数要为哪个高速缓存缺失负责。和我们预想的一样,函数a()的缺失次数(1000000)是函数b()(100000)的10倍。