总结:内存碎片是一个难以测量和诊断的问题,但是解决这个问题有时也很容易。让我们看看在 CRuby 程序中,导致内存碎片的一个根源:每个线程的内存动态分配(malloc's per-thread memory arenas)
不是每次一个简单的配置修改就能彻底解决一个问题。
我曾有一个客户端,它的 Sidekiq 进程使用了非常多的内存,大概每个进程都占用了 1G。它们刚开始启动时,大概每个只占用 300M,然后就在几个小时内缓慢增长到了一个 G,然后内存的占用才开始平稳。
我修改了一个单一的环境变量: MALLOC_ARENA_MAX
,将其值设为 2。
进程重启后,上面说的内存占用缓慢增长现象消失,内存的最终稳定在之前的一半:大约每个进程 512M。
现在,在你拷贝这个魔法般的环境变量到你的应用环境之前,你要知道:它也会带来问题。你可能不会被它所解决的问题所困扰。这里没有银弹。
Ruby 并不是一个在内存使用上很轻的语言。很多 Rails 应用在每个进程内存消耗 1G 的情况下煎熬着。这接近 Java 的水平了。Sidekiq,这个流行的 Ruby 异步任务处理框架,它的进程可以变得越来越大。其中有很多原因,其中有一个很难去诊断和调试的原因:内存碎片。
这个问题(内存消耗)在 Ruby 进程中呈现出缓慢,爬行般地增长。也常被误认为内存泄漏。然而,不像内存泄漏时内存呈线性增长,它是呈对数增长的。
内存泄漏在 Ruby 中常常是由 C 拓展程序 bug 导致的。举个例子,如果你的 Markdown 解析器每次泄漏 10kb,你的内存消耗将会永久地呈线性增长,因为你一般会以一个常规的频率调用 markdown 解析器。
内存碎片会造成内存呈对数增长。它看起来像一个长长的曲线,会到达某个不可见的限制点。所有 Ruby 进程都有一些内存碎片问题。Ruby 管理内存方式必然会导致这个问题。
尤其是 Ruby 不会在内存中移动对象。这样可能会破坏持有 Ruby 对象指针的 C 扩展程序。如果不能在内存中移动这些对象,碎片就是一个必然的结果。这是一个在 C 项目中相当普遍的问题,不仅仅是 Ruby。
碎片有时会造成 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 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 响应),像这样:
RVALUE
到 ObjectSpace
链表。如果在ObjectSpace
没有空闲插槽 ( free slots) 了,那么,我们会调用malloc(16384)
在这个链表中增加一个堆页。(译者:16384bytes 等于 16kb。一个插槽也就是一个 RVALUE 结构体占的位置)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_pages
是ObjectSpace
中至少有一个活跃插槽 (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
的实现缓解了以下过程中锁的竞争 (转述自这篇文章):
通过这种方式,主区域基本上被扩展进了一个堆的链表。mallopt
会限制能创建的区域 (arenas) 的数量,特别是M_ARENA_MAX
参数 (文档在这里)。默认情况下,per-thread memory arenas
对这个数量限制是内核数量的 8 倍。大多数 Ruby Web 应用每个核跑了 5 个线程,Sidekiq 跑的线程数量可能更多。这意味着在实际情况下,会有很多很多 arenas 被 Ruby 应用创建。
让我们来看看这将如何在多线程 Ruby 应用程序中发挥作用。
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) 数量。我们可以通过修改 MALLOC_ARENA_MAX 环境变量来实现。如前面所说的,这会增加分配器中的锁争用,导致应用的性能下降。
这里不能推荐一个通用的设置,但是 2 到 4 的的范围似乎适合大多数 Ruby 应用。修改 MALLOC_ARENA_MAX 到 1 似乎会对性能有较高的负面影响,也只会带来很少的内存消耗下降 (1% - 2%)。在你的应用做相应的取舍之前,要去实验一下上面的设置,看看它们带来的内存和性能上的减少情况。
另一个可能的途径是简单的使用一个不同的内存分配器。jemalloc 也实现了per-thread arenas
。但是它的设计似乎避免了 malloc 中的碎片问题。
上面的推文来自于我从 CodeTriage 的后台任务处理器中删除了 jemalloc。你可以看到,影响是很显著的。我也实验了将 MALLOC_ARENA_MAX 设为 2,但是内存使用依然比 jemalloc 多 4 倍。如果你可以在 Ruby 中切换到 jemalloc,那就干吧。相较于 malloc,它似乎有一样或更好的性能,但会使用更少的内存。
这不是一个关于 jemalloc 的博客,但是有一些在 Ruby 中使用 jemalloc 的优越点。
不要再 Ruby 中使用 jemalloc 4.x。它会和 Transparent Huge Pages 产生一些坏的影响,这会减低内存的节约。在 ruby 中要使用 jemalloc3.6.。5.0 的性能现在还不清楚。
你不需要把 jemalloc 编译进 ruby。你可以用 LD_PRELOAD 动态的加载它。
如果可以移动内存的位置,碎片通常可以被减少。我们不能在 CRuby 做到这点,因为 C 扩展会使用指向 Ruby 内存的原生指针。移动了位置将会造成段错误,或读取到不正确的数据。
Aaron Patterson 正在致力于创建压缩 GC。这项工作看起来有希望,但也许将来还有一段路要走。
由于 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