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

early · 2018年07月01日 · 962 次阅读

总结: 内存碎片是一个难以测量和诊断的问题,但是解决这个问题有时也很容易。让我们看看在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

共收到 0 条回复
early Ruby 的好朋友 -- jemalloc 中提及了此贴 10月29日 23:12
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册