pprof和ps之间的内存使用差异

4

我一直在尝试对使用 cobra 构建的 cli 工具进行堆使用情况分析。 pprof 工具显示如下:

Flat    Flat%   Sum%    Cum Cum%    Name    Inlined?
1.58GB  49.98%  49.98%  1.58GB  49.98%  os.ReadFile 
1.58GB  49.98%  99.95%  1.58GB  50.02%  github.com/bytedance/sonic.(*frozenConfig).Unmarshal    
0       0.00%   99.95%  3.16GB  100.00% runtime.main    
0       0.00%   99.95%  3.16GB  100.00% main.main   
0       0.00%   99.95%  3.16GB  100.00% github.com/spf13/cobra.(*Command).execute   
0       0.00%   99.95%  3.16GB  100.00% github.com/spf13/cobra.(*Command).ExecuteC  
0       0.00%   99.95%  3.16GB  100.00% github.com/spf13/cobra.(*Command).Execute   (inline)
0       0.00%   99.95%  3.16GB  100.00% github.com/mirantis/broker/misc.ParseUcpNodesInspect    
0       0.00%   99.95%  3.16GB  100.00% github.com/mirantis/broker/cmd.glob..func3  
0       0.00%   99.95%  3.16GB  100.00% github.com/mirantis/broker/cmd.getInfos 
0       0.00%   99.95%  3.16GB  100.00% github.com/mirantis/broker/cmd.Execute  
0       0.00%   99.95%  1.58GB  50.02%  github.com/bytedance/sonic.Unmarshal

但是ps在最后阶段几乎消耗了6752.23 Mb(rss)。

此外,我将defer profile.Start(profile.MemProfileHeap).Stop()放在最后一个执行的函数中。将分析器放入func main中不会显示任何内容。因此,我跟踪了这些函数,并发现最后一个函数使用了相当多的内存。

The graph

我的问题是,如何找到缺失的约3GB内存?


1
ps和pprof测量不同的东西。这里没有什么可看的。重复。 - Volker
@Volker会删除该帖子。但在此之前,您能否分享一下如何查找缺失的3GB? - arif
1
内存是复杂的。仅仅因为 ps 或 top 显示了内存使用情况,并不意味着它实际上被“使用”了。没有任何东西丢失,只是 ps 显示了一个“不同”的指标。 - Volker
@Volker,抱歉我的知識有限。我面臨的問題是當程序達到一定程度時,OOM就會殺死我的程序。這就是為什麼我開始尋找如何進行優化的原因。 - arif
3
“如何降低总体内存消耗?”和“为什么 ps 和 pprof 显示的数字不同?”是非常不同的问题。首先,可以通过使用数据流替换像 os.ReadFile 这样适用于大量数据的明显处理方法来减少内存消耗。 - Volker
1个回答

15

有多个问题(与您的问题有关):

  1. ps(以及top等)显示多个内存读数。通常感兴趣的是名为RESRSS的读数,但你没有说明是哪一个。
    基本上,查看通常命名为VIRT的读数是没有意义的。

  2. 正如Volker所说,pprof不测量内存消耗,而是测量(在你运行它的模式下)内存分配速率——即“多少”,而不是“多频繁”。

    要理解这意味着什么,请考虑pprof的工作方式。 在分析期间,计时器会滴答作响,在每次滴答时,分析器会对您正在运行的程序进行某种快照,扫描所有活动goroutine的堆栈,并将堆上的活动对象归属于那些堆栈的栈帧中包含的变量,每个栈帧都属于一个活动函数。

    这意味着,如果您的进程将调用os.ReadFile,例如,按其合同,每次读取1 GiB文件都会分配足以包含整个文件内容的字节切片,而分析器的计时器将能够确定这100个调用中的每一个(它可能会错过一些调用,因为它是采样的),os.ReadFile将被归属于已经分配了100 GiB。
    但是,如果您的程序不是以持有这些调用返回的每个切片的方式编写的,而是在处理完这些切片并在处理后将其丢弃后进行某些操作,则来自过去调用的切片可能已经被GC收集,直到新的切片被分配。

  3. 虽然规范没有要求,但Go的两个“标准”现代实现——最初称为“gc”的实现(大多数人认为这是主要实现)和GCC前端——都具有垃圾回收器,该回收器与您自己的进程流并行运行;实际上,它收集您的进程生成的垃圾的时刻受到一组复杂的启发式算法的控制(如果感兴趣,请从这里开始),这些算法试图在花费CPU时间进行GC和花费RAM不进行GC之间取得平衡;-),这意味着对于短期进程,GC可能甚至不会启动一次,这意味着您的进程将以所有生成的垃圾仍在浮动状态结束,并且当进程结束时,所有该内存将按照通常的方式被操作系统回收。

  4. 当GC收集垃圾时,释放的内存不会立即返回给操作系统。而是涉及两个阶段的过程:

    • 首先,释放的区域将返回到Go rutime的内存管理器中,该管理器是您正在运行的程序的一部分。 这是一个明智的选择,因为在典型的程序中,内存消耗通常足够高,而释放的内存很可能很快被重新分配。

    • 其次,停留足够长时间的内存页面将标记以让操作系统知道它可以用于自

      简而言之,这个主题足够复杂,你似乎过于匆忙地得出了某些结论 ;-)
      更新。 我决定稍微扩展一下内存管理,因为我觉得你的头脑中可能缺少某些零散的东西,因此你可能会发现对你问题的评论是无关紧要和轻视的。
      建议不要使用pstop等工具来衡量用Go编写的程序的内存消耗的原因在于,当代高级编程语言编写的程序所使用的运行时环境实现的内存管理与OS内核和它们运行的硬件上实现的底层内存管理相去甚远。
      让我们考虑Linux具体的实例。你可以直接向内核请求分配内存:{{link1:mmap(2)}}是一个系统调用,可以做到这一点。如果使用MAP_PRIVATE(通常也使用MAP_ANONYMOUS),内核将确保进程的页表具有足够多的新条目,以包含您请求的连续区域的许多字节所需的许多{{link3:}},并返回序列中第一个页面的地址。此时,您可能认为进程的RSS已增加了该字节数,但实际上并没有:内存被“保留”但尚未实际分配;要真正分配内存页面,进程必须通过读取或写入其中任何字节来“触摸”页面:这将在CPU上生成所谓的“页面错误”,内核处理程序将要求硬件实际分配真正的“硬件”内存页面。只有在那之后,页面才会真正计入进程的RSS

      好的,这很有趣,但是你可能会看到一个问题:使用完整页面(在不同系统上可能具有不同大小;通常在x86衍生系统上为4 KiB)操作不太方便。当您使用高级语言编程时,您不会考虑内存的低级细节; 相反,您期望正在运行的程序以某种方式呈现“对象”(我在这里不是指OOP; 只是包含某些语言或用户定义类型的值的内存片段),因为您需要它们。

      这些对象可以是任何大小,大多数情况下比单个内存页面要小得多,并且更重要的是,大多数情况下,当分配它们时,您甚至都不会考虑这些对象占用了多少空间。

      即使在像C这样的语言中编程,这些天被认为是相当低级别的语言,您通常也习惯于使用标准C库提供的malloc(3)系列内存管理函数,该函数允许您分配任意大小的内存区域。

      解决这种问题的方法是在内核所能为程序提供的基础上,有一个更高级别的内存管理器来管理你的程序,事实上,每个通用高级语言编写的程序(即使是C和C++!)都在使用它:对于解释性语言(如Perl、Tcl、Python、POSIX shell等),由解释器提供;对于字节编译语言,例如Java,由执行该代码的进程(例如Java的JRE)提供;对于编译成机器(CPU)代码的语言,例如Go的“标准”实现,由包含在生成的可执行映像文件中或在程序被动态加载到内存以进行执行时链接到程序中的“运行时”代码提供。
      这样的内存管理器通常非常复杂,因为它们必须处理许多复杂的问题,如内存碎片,而且通常尽可能避免与内核通信,因为系统调用很慢。
      后一种要求自然意味着进程级内存管理器会尝试缓存它们从内核获取的内存,并且不愿意将其释放回去。
      所有这些意味着,在典型的活跃Go程序中,您可能会遇到疯狂的内存使用率——大量小对象被分配和释放,几乎不会对从进程“外部”监视的RSS值产生影响:所有这些使用率都由进程内存管理器处理,并且——就像Go实现库的情况一样——自然地与MM紧密集成的GC。
      因此,在长时间运行的生产级别的Go程序中,为了对正在发生的情况有有用的可操作想法,这样的程序通常会提供一组不断更新的指标(将其传递、收集和监视称为遥测)。对于Go程序,负责生成这些指标的部分可以定期调用runtime.ReadMemStatsruntime/debug.ReadGCStats,或直接使用runtime/metrics所提供的内容。在像Zabbix、Graphana等监控系统中查看这些指标非常有启示性:您可以看到每个GC周期后,进程内存管理器可用的空闲内存量如何增加,而RSS大致保持不变。
      请注意,您可能需要考虑在特殊的环境变量GODEBUG中运行您的Go程序,并使用各种与GC相关的调试设置来使运行程序的Go运行时发出有关GC工作详细信息的说明here(还请参见this)。
      希望这能激发您对这些问题进行进一步探索的好奇心 ;-)
      您可能会发现this是关于由Go运行时实现的内存管理的良好介绍——与内核和硬件相关;推荐阅读。

2
感谢您的热心回答我的问题,@kostix。 - arif
2
信息丰富的答案;谢谢。 - Kyslik

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接