翻译 Malloc 会加倍 Ruby 多线程应用的内存消耗

early · 2018年07月01日 · 最后由 chad_lwm 回复于 2019年09月24日 · 3960 次阅读

总结: 内存碎片是一个难以测量和诊断的问题,但是解决这个问题有时也很容易。让我们看看在 CRuby 程序中,导致内存碎片的一个根源: 每个线程的内存动态分配(malloc's per-thread memory arenas)

不是每次一个简单的配置修改就能彻底解决一个问题。

我曾有一个客户端,它的 Sidekiq 进程使用了非常多的内存,大概每个进程都占用了 1G。它们刚开始启动时,大概每个只占用 300M,然后就在几个小时内缓慢增长到了一个 G,然后内存的占用才开始平稳。

我修改了一个单一的环境变量: MALLOC_ARENA_MAX, 将其值设为 2。

进程重启后,上面说的内存占用缓慢增长现象消失,内存的最终稳定在之前的一半: 大约每个进程 512M。

现在,在你拷贝这个魔法般的环境变量到你的应用环境之前,你要知道: 它也会带来问题。 你可能不会被它所解决的问题所困扰。 这里没有银弹。

Ruby 并不是一个在内存使用上很轻的语言。很多 Rails 应用在每个进程内存消耗 1G 的情况下煎熬着。这接近 Java 的水平了。Sidekiq,这个流行的 Ruby 异步任务处理框架,它的进程可以变得越来越大。其中有很多原因,其中有一个很难去诊断和调试的原因: 内存碎片。

Ruby内存消耗呈对数增长

这个问题(内存消耗)在 Ruby 进程中呈现出缓慢,爬行般地增长。也常被误认为内存泄漏。然而,不像内存泄漏时内存呈线性增长,它是呈对数增长的。

内存泄漏在 Ruby 中常常是由 C 拓展程序 bug 导致的。 举个例子,如果你的 Markdown 解析器每次泄漏 10kb,你的内存消耗将会永久地呈线性增长,因为你一般会以一个常规的频率调用 markdown 解析器。

内存碎片会造成内存呈对数增长。 它看起来像一个长长的曲线,会到达某个不可见的限制点。所有 Ruby 进程都有一些内存碎片问题。Ruby 管理内存方式必然会导致这个问题。

尤其是 Ruby 不会在内存中移动对象。这样可能会破坏持有 Ruby 对象指针的 C 扩展程序。如果不能在内存中移动这些对象,碎片就是一个必然的结果。这是一个在 C 项目中相当普遍的问题,不仅仅是 Ruby。

这是内存碎片的图表,注意看在MALLOC_ARENA_MAX为2后,碎片数量有一个巨大的下掉

碎片有时会造成 Ruby 占用超过它实际需要两倍的内存,有时还可能会是 4 倍之多!

Ruby 程序员不会常去思考内存问题,尤其是动态内存分配(malloc)的级别。这也没有问题,这门语言就是为让程序员避开内存问题而设计的。它在手册页是正确的。但是尽管 Ruby 能保证内存安全,但它不能提供完美的内存抽象化。这在内存使用中不能被完全忽略。因为 Ruby 程序员太幼稚和不熟悉计算机如何管理内存,以至于当问题发生时,他们常常束手无策,不知道如何去调试,可能还会将问题丢锅给动态解释型语言,比如 Ruby。

Rubyists 和内存之间隔了四个抽象层,这使得问题更加糟糕。第一层是 Ruby 虚拟机本身,它有自己追踪管理内存的方式(有时被叫做ObjectSpace)。第二层是动态内存分配器 (allocator), 它依赖于你具体使用了哪种实现方式,不同的方式之间会有非常大差异。第三层是操作系统,它将实际的物理内存地址抽象为虚拟内存地址。它会因内核的不同而产生显著的差异,例如 Mach 和 Linux 就有很大不同。最后一层是硬件,它使用了一些策略,使得频繁存取的数据处在特别的位置,使得它们能被更快地获取到。这里面有很多 CPU 参与的地方,就像translation lookaside buffer

这些隔离使得 Rubyist 很难去处理内存碎片。这个问题常常发生在 Ruby 虚拟机和动态内存分配器的级别,这也是超过 95% 的 Rubyist 不熟悉的地方。

有些碎片是必然的,但是它也可能让你的内存使用翻倍,使情况变得很糟糕。你怎么才能知道你所遇到的情况是前者还是后者呢? 是什么造成了严重的内存碎片呢? 我对此有一个观点,它导致了 Ruby 多线程应用的内存碎片,像 Puma,Passenger 企业版,还有多线程任务处理器 Sidekiq 或 Sucker Punch。

glibc 内存分配器中每个线程的内存场所 (Per-Thread Memory Arenas in glibc Malloc)

这一切归结为标准 glibc malloc 实现的一个特殊功能,称为per-thread memory arenas

为了理解为什么,我需要解释一下在 CRuby 中 GC 如何快速运行。

(Aaron Patterson 做的 ObjectSpace 可视化。每个像素是一个 RVALUE。绿色的是新的,红色的是老的。更多)

所有对象都会在ObjectSpace拥有一个条目 (entry)。ObjectSpace是一个很大的链表,它包含了每个在进程中活跃 Ruby 对象的条目。链表中的条目采用了RVALUE的形式,RVALUE是一个有 40 个字节的 C 结构体,这个结构体包含了 Ruby 对象部分基础数据。 结构体内的详细内容取决于 Ruby 对象的类。举个例子,如果有一个很短的字符串"hello",那么字符串的字节数据就会直接嵌入到RVALUE中。但是,这里只有 40 字节,如果这个字符串有 23 字节或者更长,那么RVALUE中只会保存实际数据的原始指针。实际数据存放在RVALUE外面。

RVALUE进一步在 ObjectSpace 中被组织成 16KB“页”。每个页包含大约 408 个RVALUE

这些数据可以在任意 Ruby 进程中的GC::INTERNAL_CONSTANTS变量中找到:

GC::INTERNAL_CONSTANTS
=> {
:RVALUE_SIZE=>40,
:HEAP_PAGE_OBJ_LIMIT=>408,
# ...
}

创建一个长的字符串(例如有 1000 个字符的 HTTP 响应),像这样:

    1. 增加一个 RVALUEObjectSpace链表。如果在ObjectSpace没有空闲插槽 ( free slots) 了,那么,我们会调用malloc(16384)在这个链表中增加一个堆页。(译者: 16384bytes 等于 16kb。一个插槽也就是一个 RVALUE 结构体占的位置)
    1. 调用malloc(1000),返回一个 1000 个字节大小的内存地址 (实际上,Ruby 将会获取的空间比它所需要的更大,用来适应字符串大小的调整)。 这个内存块存放上面说的 HTTP 响应。

内存分配器在这里所调用的,正是我想提醒你的地方。它做的所有事情都是去某个地方寻找一个特定大小的内存空间。实际上,malloc 的连续性是不确定的,正是这样,它不能保证分配的内存空间的实际位置在哪里。这意味着,从 Ruby 虚拟机的视角来看,碎片 (这基本上是关于内存在哪里的问题) 是动态内存分配器 (allocator) 导致的问题。 (但是,分配模式和大小肯定会让分配器变得更难)

Ruby 能做的事情是测量自己ObjectSpace中的碎片。GC 模块中有一个方法GC.stat,它提供了当前内存和 GC 的健康信息。它是压倒性的,并没有记录在案,输出是一个哈希:

GC.stat
=> {
:count=>12,
:heap_allocated_pages=>91,
:heap_sorted_length=>91,
# ... way more keys ...
}

我想让你注意哈希中的这两个 key: GC.stat[:heap_live_slots]GC.stat[:heap_eden_pages]

heap_live_slots代表ObjectSpace中被活跃 (没有被标记为冻结)RVALUE结构体占据的插槽 (slots) 数量。大致可以认为是当前活跃的 Ruby 对象。

heap_eden_pagesObjectSpace中至少有一个活跃插槽 (slot) 的页 (page) 的数量。ObjectSpace页中,有至少一个活跃插槽 (slot) 的页,被称为伊甸页 (eden pages)。ObjectSpace中,没有包含活跃对象的页,被称作墓穴页 (tomb pages)。从 GC 的视角来看,这有非常重要的差别,因为墓穴页的空间可以还给操作系统。这样,GC 会将新对象先放到伊甸页中,然后所有伊甸页填满后再坟墓页。这减少了内存碎片。

如果你用活跃的插槽数除以伊甸页中所有插槽数,你可以测量出ObjectSpace当前的碎片。下面的例子是我在一个新的 irb 进程中得到的:

5.times { GC.start }
GC.stat[:heap_live_slots] # 24508
GC.stat[:heap_eden_pages] # 83
GC::INTERNAL_CONSTANTS[:HEAP_PAGE_OBJ_LIMIT] # 408

# live_slots / (eden_pages * slots_per_page)
# 24508 / (83 * 408) = 72.3%

当前伊甸页中大约有 28% 的插槽没有被使用。空闲插槽的比例很高,表明它们分散在更多的堆页中,如果我们能移动它们,则不会这样。这是一种内存的内存碎片。

另一种测量内部碎片的方式来自于 Ruby 虚拟机中的GC.stat[:heap_sorted_length]。这个 key 是堆的"实际长度"。如果我们有三个 ObjectSpace 页,假设我释放了中间的那页,那么我只有两个堆页保留。但是,我不能在内存中移动堆页,所以这个堆 (基本上是堆页面的最高索引) 的"长度"还是 3。(译者: 关于这个点可以进一步参考这篇文章)

GC.stat[:heap_sorted_length]除以 GC.stat[:heap_eden_pages]可以测量 ObjectSpace 级别的内存碎片。如果得到低比例的结果,则表明在 ObjectSpace 中有大量的堆页般大小的"漏洞"。(译者:这些空闲的堆页没有被实际使用,但是它还是占用了系统的内存)

这些测量很有趣,大部分的内存碎片 (和大量的分配) 并没有发生在 ObjectSpace 中。它们发生在进程为那些不能嵌入单个 RVALUE 中的对象而分配内存时。 根据 Aaron Patterson 和 Sam Saffron 的实验,大部分情况下就是这样。一个典型的 Rails 应用中 50%-80% 的内存占用是通过malloc调用产生,为体积大于几个字节的对象分配空间。

Aaron 说的"managed by the GC"的意思是管理ObjectSpace链表。

好吧,让我们来谈谈每个线程的内存使用。

per-thread memory arena是在glibc 2.10中引入的优化, 现在已经在 arena.c 里面了。它旨在减少访问内存时,线程之间的竞争。

在一个简单的基本分配器设计中,分配器确保一次只有一个线程可以从主体请求一个内存块。这个锁保证了两个线程不会意外的获得同一个内存块。如果真的发生了这种情况,那会造成相当讨厌的多线程 bug。但是,如果应用程序有多个线程,那运行会变得缓慢,因为会有很多锁的争抢。所有的内存获取都需要通过这个锁,所以你可以看到,这会是一个瓶颈。

由于锁对性能的影响,移除它已成为分配器设计中的主要工作。已经有一些无锁的分配器了。

per-thread memory arena 的实现缓解了以下过程中锁的竞争 (转述自这篇文章):

  • 1. 在一个线程中调用 malloc,线程尝试获取锁,去访问之前访问过的内存场所 (arena) (或者主内存区)。
  • 2. 如果内存场所 (arena) 不可用,则尝试下一个内存场所 (arena) ,如果有其他内存 arena。
  • 3. 如果所有内存场所 (arenas) 都不可用,创建一个新的场所 (arena) 并使用它。这个新的场所会被追加到一个链表的最后。

通过这种方式,主区域基本上被扩展进了一个堆的链表。mallopt会限制能创建的区域 (arenas) 的数量,特别是M_ARENA_MAX参数 (文档在这里)。默认情况下,per-thread memory arenas对这个数量限制是内核数量的 8 倍。大多数 Ruby Web 应用每个核跑了 5 个线程,Sidekiq 跑的线程数量可能更多。这意味着在实际情况下,会有很多很多 arenas 被 Ruby 应用创建。

让我们来看看这将如何在多线程 Ruby 应用程序中发挥作用。

  • 1. 你运行了一个 Sidekiq 进程,它有 25 个线程。
  • 2. Sidekiq 运行 5 个新 job。这些 job 和一个外部的服务通讯。它们发送一个 HTTPS 请求出去,然后在 3 秒后收到回复。
  • 3 每一个 job 发送 HTTP 请求,通过 IO 模块等待回复。通常来说,CRuby 中所有的 IO 操作会释放 GIL,这意味着线程可以并行执行,然后就会争抢获取主内存 arena 的锁,这会导致新的内存arena的创建。

如果 CRuby 的多线程运行时不会有 IO 操作,那么它们不会去争抢主内存区域的锁,因为 GIL 避免了两个线程在同一时刻执行。这样,per-thread-memory arenas只会影响有 IO 操作的多线程应用。

这是如何导致内存碎片的呢?

内存碎片,本质上是一个装箱问题。我们如何高效的在多个装箱之间分配大小奇怪的条目,使得消耗的内存最小? 对于分配器来说,分包打包要困难得多,因为 Ruby 从不会移动内存位置 (一旦分配了一个位置,这份数据会保持不动,直到被回收)。per-thread memory arenas实质上会创建大量不同的箱子 (bins),它们不能被合并或打包在一起。装箱 (Bin-packing) 已经是NP-hard,而且这些限制会使获得最佳方案变得更难。

Per-thread memory arenas导致了大量的 RSS(实际使用物理内存) 使用,这是 glibc malloc 跟踪器的一个已知问题。实际上, MallocInternals wiki 明确地说:

因为线程的冲突压力增加,通过 mmap 创建额外的 arenas,来缓解这个压力。能被创建的 arenas 的总数不超过 CPU 核数的 8 倍 (除非用户另有指定,否则参阅 mallopt),这意味着线程数多的应用依然会遇到锁竞争,但是会换来更少的碎片。

你可以做的是: 降低可以使用的 arenas 数量,来减少碎片。这儿有一个明确地交换: 更少的 arenas 数量会减低内存碎片,但是应用会因为锁竞争的增加而变慢。

Heroku 在创建 Cedar-14 堆栈时发现了per-thread memory arenas的这种副作用,它将 glibc 升级到版本 2.19。

Heroku 用户在升级到这个新栈的时候发现内存消费增加了。 Terrence Hone 的测试发现了一些有趣的结果:

Configuration Memory Use
Base (unlimited arenas) 1.73x
Base (before arenas introduced) 1x
MALLOC_ARENA_MAX=1 0.86
MALLOC_ARENA_MAX=2 0.87

基本上, libc 2.19 默认的内存 arenas 行为减少了 10% 的执行时间,但是增加了 75% 的内存消耗。减少 arenas 的最大数量到 2 会消除这个速度上的提升,但是比旧 Cedar-10 堆栈减少了 10%的内存使用量(与默认的内存竞技场行为相比内存使用量减少了约 2 倍!)。

Configuration Response Times
Base (unlimited arenas) 0.9x
Base (before arenas introduced) 1x
MALLOC_ARENA_MAX=1 1.15x
MALLOC_ARENA_MAX=2 1.03x

对于几乎所有 Ruby 应用,75% 的内存增加来换 10% 的速度提升不是一个划算的交易。但是让我们在这里得到一些更加真实的结果。

复制程序

我写了一个Demo 应用,这是一个 Sidekiq 任务,用来生成一些随机地数据,然后写入数据库。

在我调整 MALLOC_ARENA_MAX 的值为 2 之后,内存的使用在 24 小时后减少了 15%。我注意到现实世界的工作负载大大放大了这种效果,这意味着我还没有完全理解可能导致这种碎片的分配模式 (allocation pattern)。 我在 Complete Guide to Rails Performance 看到了大量的内存图表,它们显示在 MALLOC_ARENA_MAX = 2 的情况下有 2-3 倍的内存节省。

解决这个问题

这儿有两个主要的解决途径,以及未来可能的解决方案。

方案一: 降低内存 arenas

一个相当明显的解决方案是减少可用的最大内存区域 (arenas) 数量。我们可以通过修改 MALLOC_ARENA_MAX 环境变量来实现。如前面所说的,这会增加分配器中的锁争用,导致应用的性能下降。

这里不能推荐一个通用的设置,但是 2 到 4 的的范围似乎适合大多数 Ruby 应用。修改 MALLOC_ARENA_MAX 到 1 似乎会对性能有较高的负面影响,也只会带来很少的内存消耗下降 (1% - 2%)。在你的应用做相应的取舍之前,要去实验一下上面的设置,看看它们带来的内存和性能上的减少情况。

方案二: jemalloc

另一个可能的途径是简单的使用一个不同的内存分配器。jemalloc 也实现了per-thread arenas。但是它的设计似乎避免了 malloc 中的碎片问题。

上面的推文来自于我从 CodeTriage 的后台任务处理器中删除了 jemalloc。你可以看到,影响是很显著的。我也实验了将 MALLOC_ARENA_MAX 设为 2,但是内存使用依然比 jemalloc 多 4 倍。如果你可以在 Ruby 中切换到 jemalloc,那就干吧。 相较于 malloc,它似乎有一样或更好的性能,但会使用更少的内存。

这不是一个关于 jemalloc 的博客,但是有一些在 Ruby 中使用 jemalloc 的优越点。

方案三: 压缩 GC

如果可以移动内存的位置,碎片通常可以被减少。我们不能在 CRuby 做到这点,因为 C 扩展会使用指向 Ruby 内存的原生指针。移动了位置将会造成段错误,或读取到不正确的数据。

Aaron Patterson 正在致力于创建压缩 GC。这项工作看起来有希望,但也许将来还有一段路要走。

总结 (TL;DR):

由于 malloc 在per-thread memory arenas导致的碎片,Ruby 多线程程序或许会消费它们实际需要内存的 2 到 4 倍。为了解决这个问题,你可以设置 MALLOC_ARENA_MAX 降低最大 arenas 数量,或者使用其他更好的内存分配器,像 jemalloc。

这里潜在的内存节省是如此之大,代价如此微小,以至于我建议如果您在生产环境中使用 Ruby 和 Puma 或 Sidekiq,则应始终使用 jemalloc。

虽然这种效应在 CRuby 中最为明显,但它也可能会影响 JVM 和 JRuby

翻译自: https://www.speedshop.co/2017/12/04/malloc-doubles-ruby-memory.html

共收到 1 条回复
early Ruby 的好朋友 -- jemalloc 中提及了此贴 10月29日 23:12

赞,文章非常有深度

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册