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

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


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

事实证明,我并不孤单。 Ruby应用程序会占用大量内存。 但是为什么呢? 根据HerokuNate 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堆页面可以具有可用插槽以及系统堆。 因此,问题“使用了多少内存?”的答案。 完全取决于您要求的级别!

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

什么是碎片?


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

Ruby级别碎片


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



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

内存分配碎片


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



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



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

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

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


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

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

第一个选项很容易检查。 Ruby提供了两个API: ObjectSpace.memsize_of_allGC.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