什么会使Ruby中的内存膨胀?

Phusion的我们在Ruby中有一个简单的多线程HTTP代理(分发DEB和RPM软件包)。 我看到它的内存消耗为1.3 GB。 但这对于无状态流程来说是疯狂的...


问题:这是什么? 答:Ruby会随着时间使用内存!

事实证明,我并不孤单。 Ruby应用程序会占用大量内存。 但是为什么呢? 根据Heroku和Nate Burkopek的说法 , 膨胀主要是由于内存碎片和过多的堆分配。

Berkopek得出结论,有两种解决方案:

  1. 使用与glibc完全不同的内存分配器-通常是jemalloc ,或者:
  2. 设置魔术环境变量MALLOC_ARENA_MAX=2 。

我担心问题的描述和建议的解决方案。 这里出问题了……我不确定问题是否已被正确描述,或者这些是唯一可用的解决方案。 也让我感到困扰的是,许多人将jemalloc称为神奇的银池。

魔术只是一门我们还不了解的科学 。 因此,我进行了一次研究之旅,以了解全部真相。 本文将涵盖以下主题:

  1. 内存分配的工作方式。
  2. 每个人都在谈论这种记忆的“碎片化”和“过度分配”是什么?
  3. 是什么导致大量内存消耗? 情况是否与人们所说的相符,或者还有其他内容? (剧透:是的,还有别的东西)。
  4. 有其他解决方案吗? (剧透:我发现了一个)。

注意:本文仅适用于Linux,并且仅适用于多线程Ruby应用程序。

目录内容



Ruby内存分配:简介


Ruby从上到下分三个级别分配内存:

  1. 管理Ruby对象的Ruby解释器。
  2. 操作系统的内存分配器库。
  3. 核心。

让我们遍历每个级别。

红宝石


在另一方面,Ruby将对象组织在称为Ruby堆页面的内存区域中。 这样的堆页面被分成相同大小的插槽,其中一个对象占用一个插槽。 无论是字符串,哈希表,数组,类还是其他东西,它都占用一个插槽。



堆页面上的插槽可能很忙或空闲。 当Ruby选择一个新对象时,它将立即尝试占用一个空闲插槽。 如果没有可用的插槽,则会突出显示新的堆页面。

插槽很小,大约40个字节。 显然,有些对象无法容纳其中,例如1 MB的行。 然后,Ruby将信息存储在堆页面之外的其他位置,并在插槽中放置一个指向此外部存储区的指针。


插槽中不适合的数据存储在堆页面外部。 Ruby在该插槽中放置一个指向此外部数据的指针

Ruby堆页面和任何外部内存区域均使用系统内存分配器进行分配。

系统内存分配器


操作系统内存分配器是glibc(C运行时)的一部分。 几乎所有应用程序都使用它,而不仅仅是Ruby。 它有一个简单的API:

  • 通过调用malloc(size)分配malloc(size) 。 给它要分配的字节数,它返回分配地址或错误。
  • 通过调用free(address)释放已分配的内存。

与Ruby不同,在Ruby中分配了相同大小的插槽,内存分配器处理分配任意大小的内存的请求。 如您将在后面学到的那样,此事实导致一些复杂性。

反过来,内存分配器访问内核API。 与内核本身的订阅者请求相比,它从内核中获取的内存块要大得多,因为内核调用成本很高且内核API有一个局限性:它只能以4 KB的倍数分配内存。


内存分配器分配大块-它们称为系统堆-并共享其内容以满足应用程序的请求

内存分配器从内核分配的内存区域称为堆。 请注意,它与Ruby堆的页面无关,因此为清楚起见,我们将使用术语system heap 。

然后,内存分配器将系统堆的一部分分配给其调用者,直到有可用空间为止。 在这种情况下,内存分配器从内核分配一个新的系统堆。 这类似于Ruby从Ruby堆的页面中选择对象的方式。


Ruby从内存分配器分配内存,而内存分配器又从内核分配内存

核心


内核只能以4 KB为单位分配内存。 这样的4K块称为页面。 为了避免与Ruby堆页面混淆,为清楚起见,我们将使用术语系统页面 (OS页面)。

原因很难解释,但这就是所有现代内核的工作方式。

通过内核分配内存会对性能产生重大影响,这就是为什么内存分配器会尽量减少内核调用的次数。

内存使用情况定义


因此,内存分配了多个级别,每个级别分配的内存超过了实际需要。 Ruby堆页面可以具有可用插槽以及系统堆。 因此,问题“使用了多少内存?”的答案。 完全取决于您要求的级别!

top或ps类的工具从内核角度显示内存使用情况。 这意味着更高的级别必须协同工作以从内核的角度释放内存。 如您将在后面学到的,这比听起来要难。

什么是碎片?


内存碎片意味着内存分配是随机分散的。 这可能会引起有趣的问题。

Ruby级别碎片


考虑Ruby垃圾回收。 对象的垃圾回收意味着将Ruby堆页面插槽标记为空闲,从而可以重复使用。 如果Ruby堆的整个页面仅由可用插槽组成,则可以将其整个页面释放回内存分配器(并可能释放回内核)。



但是,如果不是所有插槽都可用,会发生什么情况? 如果我们有很多Ruby堆页面,并且垃圾回收器在不同位置释放对象,从而最终有很多可用插槽,但是在不同页面上呢? 在这种情况下,Ruby拥有用于放置对象的空闲插槽,但是内存分配器和内核将继续分配内存!

内存分配碎片


内存分配器有一个相似但完全不同的问题。 他不需要立即清除整个系统堆。 从理论上讲,它可以释放任何单个系统页面。 但是,由于内存分配器处理任意大小的内存分配,因此系统页面上可能有多个分配。 在释放所有选择之前,它无法释放系统页面。



想想如果我们有3 KB的分配和2 KB的分配(分为两个系统页面)会发生什么情况。 如果您释放了前3 KB,则两个系统页面都将部分保留并且无法释放。



因此,如果情况不佳,系统页面上将有很多可用空间,但不会完全释放它们。

更糟糕的是:如果有很多免费场所,但其中一个没有足够大的空间来满足新的分配要求,该怎么办? 内存分配器将不得不分配整个新的系统堆。

Ruby堆页面碎片会导致内存膨胀吗?


碎片很可能导致Ruby中的内存过度使用。 如果是这样,那么两个碎片中哪一个更为有害? 这是...

  1. Ruby堆页面碎片? 或
  2. 内存分配器碎片?

第一个选项很容易检查。 Ruby提供了两个API: ObjectSpace.memsize_of_all和GC.stat 。 由于有了这些信息,您可以计算Ruby从分配器接收的所有内存。



ObjectSpace.memsize_of_all返回所有活动Ruby对象占用的内存。 也就是说,它们插槽中的所有空间以及任何外部数据。 在上图中,这是所有蓝色和橙色对象的大小。

GC.stat允许GC.stat找出所有可用插槽的大小,即上图中的整个灰色区域。 这是算法:

 GC.stat[:heap_free_slots] * GC::INTERNAL_CONSTANTS[:RVALUE_SIZE] 

概括起来,这就是Ruby所了解的所有内存,并且涉及将Ruby堆的页面碎片化。 如果从内核的角度来看,如果内存使用量较高,则剩余的内存将流到Ruby无法控制的地方,例如,第三方库或碎片。

我编写了一个简单的测试程序,该程序创建了一堆线程,每个线程都选择一个循环中的行。 这是一段时间后的结果:



只是……疯狂!

结果表明,Ruby对使用的内存总量影响很小,这与Ruby堆的页面是否碎片无关紧要。

必须寻找其他地方的罪魁祸首。 至少现在我们知道不应该对Ruby负责。

内存分配碎片研究


另一个可能的怀疑对象是内存分配器。 最后,Nate Berkopek和Heroku注意到,忙于使用内存分配器(可以完全替代jemalloc或设置魔术环境变量MALLOC_ARENA_MAX=2 )可以大大减少内存使用量。

首先让我们看看MALLOC_ARENA_MAX=2作用以及它为何MALLOC_ARENA_MAX=2 。 然后,我们在分销商级别检查碎片。

过多的内存分配和glibc


MALLOC_ARENA_MAX=2帮助的原因是MALLOC_ARENA_MAX=2多线程。 当多个线程同时尝试从同一系统堆分配内存时,它们争夺访问权限。 一次只有一个线程可以接收内存,这降低了多线程内存分配的性能。


一次只能有一个线程可以使用系统堆。 在多线程任务中,会发生冲突,因此,性能会降低

在这种情况下的内存分配器中有优化。 他尝试创建多个系统堆并将它们分配给不同的线程。 大多数情况下,一个线程只能使用其自己的堆工作,从而避免了与其他线程的冲突。

实际上,默认情况下,以这种方式分配的最大系统堆数量等于虚拟处理器的数量乘以8。也就是说,在具有两个超线程的双核系统中,每个产生2 * 2 * 8 = 32系统堆! 这就是我所说的过度分配 。

为什么默认乘数这么大? 因为内存分配器的主要开发者是Red Hat。 他们的客户是拥有强大服务器和大量RAM的大型公司。 由于内存使用量的显着增加,上述优化使您可以将平均多线程性能提高10%。 对于红帽客户来说,这是一个很好的折衷方案。 对于其余大部分-几乎没有。

内特(Nate)在其博客和Heroku文章中指出,增加系统堆数量会增加碎片,并引用了官方文档。 MALLOC_ARENA_MAX变量减少了分配给多线程的最大系统堆数。 通过这种逻辑,它减少了碎片。

可视化系统堆


Nate和Heroku的说法是否正确,即增加系统堆数量会增加碎片? 实际上,内存分配器级别的碎片是否有问题? 我不想将任何这些假设视为理所当然,所以我开始了研究。

不幸的是,没有用于可视化系统堆的工具,因此我自己编写了这样的可视化工具 。

首先,您需要以某种方式保留系统堆的分配方案。 我研究了内存分配器的源代码,并研究了它在内部如何表示内存。 然后,他编写了一个库,该库遍历这些数据结构并将架构写入文件中。 最后,他编写了一个工具,将这样的文件作为输入并将可视化文件编译为HTML和PNG图像( 源代码 )。



这是可视化一个特定系统堆(还有更多)的示例。 此可视化中的小块代表系统页面。

  • 红色区域是已使用的存储单元。
  • 灰色是不释放回核心的自由区域。
  • 白色区域释放出核。

从可视化可以得出以下结论:

  1. 有一些碎片。 红色斑点从内存中散落开来,某些系统页面只有一半红色。
  2. 令我惊讶的是, 大多数系统堆都包含大量完全免费的系统页面(灰色)!

然后它突然出现在我身上:

尽管碎片仍然是一个问题,但这不是重点!

相反,问题是很多灰色的:此内存分配器不会将内存发送回内核 !

重新研究了内存分配器的源代码之后,事实证明,默认情况下,它仅在系统堆末尾将系统页面发送到内核,甚至很少这样做 。 可能出于性能原因而实现了这种算法。

魔术技巧:包皮环切术


幸运的是,我找到了一个窍门。 有一个编程接口可以迫使内存分配器不仅为内核释放内核,还为所有相关的系统页面释放内核。 它称为malloc_trim 。

我知道此功能,但我认为它没有用,因为该手册指出:

malloc_trim()函数尝试释放堆顶部的可用内存。

本手册有误! 对源代码的分析表明,该程序释放了所有相关的系统页面,而不仅仅是顶部。

如果在垃圾回收期间调用此函数会怎样? 我修改了Ruby 2.6源代码,以从gc.c调用gc_start函数中的gc_start malloc_trim() ,例如:

 gc_prof_timer_start(objspace); { gc_marks(objspace, do_full_mark); // BEGIN MODIFICATION if (do_full_mark) { malloc_trim(0); } // END MODIFICATION } gc_prof_timer_stop(objspace); 

这是测试结果:



有很大的不同! 一个简单的补丁将内存消耗减少到几乎MALLOC_ARENA_MAX=2 。

这是可视化效果的样子:



我们看到许多与释放回内核的系统页面相对应的空白区域。

结论


事实证明,零散基本上与它无关。 碎片整理仍然有用,但是主要问题是内存分配器不喜欢将内存释放回内核。

幸运的是,该解决方案非常简单。 最主要的是找到根本原因。

可视化工具源代码


源代码

性能如何?


绩效仍然是主要问题之一。 调用malloc_trim()不能免费malloc_trim() ,但是根据代码,该算法在线性时间内工作。 因此,我求助于Noah Gibbs ,他发布了Rails Ruby Bench基准测试。 令我惊讶的是,该补丁导致性能略有提高 。





这让我震惊。 效果令人难以理解,但新闻是好的。

需要更多测试。


作为这项研究的一部分,仅验证了少数情况。 目前尚不清楚对其他工作负载的影响。 如果您想帮助测试,请与我联系 。

Source: https://habr.com/ru/post/zh-CN444482/


All Articles