Ruby Ruby 的好朋友 -- jemalloc

early · October 29, 2018 · Last by xiaox replied at April 04, 2022 · 8956 hits
Topic has been selected as the excellent topic by the admin.

几个月前翻译了Malloc 会加倍 Ruby 多线程应用的内存消耗,从这篇文章中得知了 jemalloc,以及它在减少内存碎片方面的作用。直到最近在生产环境中看到了它真实的表现,更加惊叹其神奇。它是如何做到让 Ruby 进程减少了数倍的内存消耗?对于这个点,得花点精力搞明白才行。

Ruby 进程特别是在多线程环境下,其内存消耗令人震惊,生产中一个 SIdekiq 进程轻轻松松可以消耗 3 个 G 以上。Ruby 垃圾回收这些年已经做了很多优化,变得相对成熟,但是其内存消耗为何如此高?

原因是有内存碎片。而且碎片同时存在于两个隔离的层级:

  • Ruby 堆空间,也就是 ObjectSpace
  • 动态内存分配器管理的堆空间

ObjectSpace 中以页为单位管理堆,每页中有固定数量的插槽,每个槽中可以放一个 ruby 对象 (RVALUE)。随着应用程序的运行,GC 会清除垃圾对象,同时也会申请新的页,慢慢地页中的槽就会有大量空闲,也就造成了内存碎片。

在 Ruby 中,ObjectSpace 中放不下的数据 (使 RVALUE 整体大于 40bytes) 都会通过动态内存分配器单独申请内存存放,只在 RVALUE 中保留对象的指针。而在多线程环境下,由于常用的 Malloc 实现上的缺陷,导致其在 Arena 中有严重的内存碎片,具体的可见上面的文章

使用 jemalloc,其实就是让 jemalloc 来扮演动态内存分配器的角色,替换 malloc 的工作。由于 jemalloc 的优异表现,多线程 Ruby 应用的内存消耗骤降数倍,这说明了内存消耗上的锅,glibc malloc 占大头,ObjectSpace 中的内存碎片造成的消耗要远小于 malloc 所产生的。

那 jemalloc 为何如此优秀?,终于来到本文的主题,简单了解一下 jemalloc 的设计概要

  • 按照对象的大小隔离存放。相同大小等级的对象放在一起,不同大小等级的对象分开。始终优先复用低地址的空间,这是降低内存碎片关键。
  • 谨慎划分对象的等级 。如果等级间差距过大,档次过少。容易增加对象尾部不可用空间的数量,会造成内部碎片。如果档次过多,可以预见专用于对象各等级的内存消耗会增加,这会造成外部碎片。
  • 缩紧元数据的内存消耗 。jemalloc 限制元数据内存消耗比例,不超过总内存的消耗的 2%。
  • 最小化活跃数据页的数量。操作系统按照页的方式管理虚拟内存,将数据集中到尽可能少的页上有重要意义。
  • 最小化锁竞争。jemalloc 实现了多个独立的 arenas,同时还实现了各线程独立的缓存,使得内存申请/释放过程可以无干扰并行进行。

jemalloc 将对象等级按大小分为三大级别,每个级别中有数十个小等级:

  • Small: [8], [16, 32, 48, ..., 128], [192, 256, 320, ..., 512], [768, 1024, 1280, ..., 3840]
  • Large: [4 KiB, 8 KiB, 12 KiB, ..., 4072 KiB]
  • Huge: [4 MiB, 8 MiB, 12 MiB, ...]

小于 8 字节的,分配 8 字节;大于 4KB 小于 8KB 的,分配 8KB···

像这样根据对象的大小进行严格分级,并将同一级别的对象存放在一起,不同级别的对象分开存放。通过紧凑的组织进行分级存放。

上图是 jemalloc 的核心结构图。可以看到有以下几个部分:

  • 图左上角,有多个 arenas。arenas 是管理内存的最大单位,每个 arena 彼此独立,数量一般为 CPU 核心数的四倍。
  • 每个 arena 包含多个 chunk,每个 chunk 默认为 4M(和环境有关),这也是 jemalloc 每次从操作系统申请内存的单位。实际的数据都在 chunk 内。
  • chunk 又进一步被分为多个 run(图左下角),run 对应到具体的小单位内存等级,其大小为页 (4KB) 的整数倍。同等级的 run 由相应的 bin 来管理,每个 bin 负责管理一个对象等级,当应用要申请某个等级的内存时,就到对应的 bin 中获取 (bin 通过栈和树来管理 run)。
  • 小对象中,run 会被进一步分成整数个大小相等的 region,region 是最小的内存单位,具体大小和其等级保持一致。每一个 region 就是实际分配给应用的内存单位。例如,如果应用申请 8 字节的内存,jemalloc 返回给应用的就是一个对应大小的 region。

通过将内存分成多个档次,且大小以整数倍增长,各自分别管理。同时优先复用低地址的可用区域,使得内存内存块被高效利用,不会被遗忘,同时避免了某些内存区尾部不够大 (要申请 32 字节,却只剩 8 字节了),而不能被使用的情况,这在很大程度上减少了碎片。(glibc malloc 就是将对象混在一起存放的)

为了提高并发能力,jemalloc 实现了多级缓冲机制,为每个线程实现了独立的tcache(图右上)。线程在申请内存时,先到自己的 tcache 缓冲中去取,当缓冲为空或者满了,才去 arena 进货或将内存刷回 (此时会有锁,粒度为每个小档)。这种设计可以极大地避开并发中的竞争,减少锁的产生。同时,可以让同一线程中的数据尽量在同一块相邻的内存中,这对于 cpu 高效的利用 cache line 有极大帮助。

同时,jemalloc 用自己特地改进过的左倾红黑树 (红节点在左边),作为元数据来追踪可用的内存区,提高搜索速度。

jemalloc 会限制被使用页 (dirty page) 的数量,当数量超过一定比例,就会启动 GC 对数据页进行清洗 (purge),并尝试进行合并,挤出未被使用的空间,将其释放回操作系统或返回可用区域,进一步减少了碎片。

以上。

本文内容参考了以下资料,想要准确详细的细节,请进一步查阅:

apt-get update
apt-get install libjemalloc-dev
RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 2.5.3

嗯 我就是来贴个 Code

huacnlee mark as excellent topic. 30 Oct 13:38

@so_zengtao的风,来个完整的

Ubuntu MAC centos 安装 jemalloc

sudo apt-get install libjemalloc-dev
brew install jemalloc
sudo yum install -y jemalloc jemalloc-devel

rbenv

RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 2.5.0

rvm

rvm reinstall 2.5.0 --disable-binary --with-jemalloc

源码安装

./configure --with-jemalloc
make
make install

检查安装是否正确

ruby -r rbconfig -e "puts RbConfig::CONFIG['LIBS']"
# 应该输出:  -lpthread -ljemalloc -ldl -lobjc

ruby-install

ruby-install ruby 2.5.3 -- --with-jemalloc

使用会带来啥风险呢?

Reply to jicheng1014

已经在 facebook 上正常运行多年了,获得了广泛的认可。这儿有个讨论帖 https://ruby-china.org/topics/35515

拿空间换时间,大量的内存整理,必然带来性能的下降。。

Reply to early

不科学啊,,,,

Reply to pynix

它没做内存整理,只是防止碎片

https://bugs.ruby-lang.org/issues/9113#note-12

sam saffron 说 libc 的 allocator 很垃圾,默认就不应该用这个,然后后面一堆人各种反对。

Reply to pynix

清洗是对于脏页 (dirty page) 进行的回收、合并等操作,是增加可用内存和减少碎片的操作,并不会有大量的内存移动之类的操作。具体的过程有点复杂,可以参考下附录中的这篇文章

Reply to adamshen

https://bugs.ruby-lang.org/issues/14718 在这个里由后续。结论是 malloc 的问题是在 glibc 在某个版本开始某个默认参数发生了变化,如果调回去就跟 jemalloc 差不多了。而 jemalloc 不同版本表现也不尽相同,无非是时间和空间的取舍。最后大家一致认为最佳解决方案是调整 malloc 的那个参数,因为对于 ruby 的场景更为适合。

Reply to Xenofex

等等...... 为什么这个跟我看的解释完全不一样......

glibc malloc 的问题不是从某个版本开始而是一直也是这样。更改参数可以有大幅改善。

jemalloc 的问题是源于 THP ( Transparent Huge Pages ), Ruby 2.6 已经停止使用。( https://bugs.ruby-lang.org/issues/14705 ),

Reply to ksec

我看到的解释和你接近。对于 jemalloc 持谨慎态度的人一般是两个原因:

对于 glibc malloc,它为了减少多线程环境下,申请内存所产生的锁竞争,将可分配的 arena 数量放的比较大,这导致了严重的内存碎片,也是因为它本身不是为多线程环境而设计的。将 MALLOC_ARENA_MAX 调小,可以减轻这种情况,但是会使得线程间的竞争变得严重,内存碎片是降低了,但是性能会受影响。

Reply to ksec

我这个帖子里提到了 M_ARENA_MAX 默认值的变化(从 2 到 2 * CPU 核数),有人怀疑是 red hat 为了讨好大用户(通常拥有足够的配置)所以用空间换性能。最后页面下方 sidekiq 作者和 Sam(一开始 jemalloc)的倡导者也都达成了一致,认为 malloc 和 jemalloc 的区别的原因是他们默认参数的不同。换句话说 malloc 如果设置了 M_ARENA_MAX = 2 也可以达到 jemalloc 的评测性能。因此两者并没有所谓的性能上的明显区别。

持谨慎态度的人并不仅仅是认为 jemalloc 会带来问题,而是如果不能证明 jemalloc 确实更适合 ruby,那么就没必要作为默认选项(本帖的讨论内容)。毕竟这不是一个小的改动。

@early

Our Sidekiq servers use Ubuntu 16.04, so we started by installing jemalloc: From there, we configured the LD_PRELOAD environment variable by adding the following to

Note: The location of jemalloc may vary depending on version and/or Linux distribution.

sudo apt-get install libjemalloc-dev
vim /etc/environment
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.1  # add this

原文地址

和再重新编译使用有差异?

Reply to awking

这是用动态配置的方式让应用使用 jemalloc 吧,不想用时可以动态修改环境变量来控制。重新编译就没这个灵活性了。https://github.com/jemalloc/jemalloc/wiki/Getting-Started

Reply to so_zengtao

在 mac 上遇到了如下错误,请问是什么原因?

Undefined symbols for architecture x86_64:
  "_je_calloc", referenced from:
      _rb_objspace_alloc in gc.o
      _ruby_xcalloc in gc.o
      _heap_assign_page in gc.o
      _Init_Method in vm.o
      _ruby_init_setproctitle in setproctitle.o
  "_je_free", referenced from:
      _ruby_glob0 in dir.o
      _ruby_brace_expand in dir.o
      _glob_helper in dir.o
      _dln_find_exe_r in dln_find.o
      _rb_objspace_free in gc.o
      _ruby_xfree in gc.o
      _free_const_entry_i in gc.o
      ...
  "_je_malloc", referenced from:
      _ruby_glob0 in dir.o
      _ruby_brace_expand in dir.o
      _glob_helper in dir.o
      _Init_heap in gc.o
      _gc_writebarrier_incremental in gc.o
      _rb_gc_writebarrier_remember in gc.o
      _gc_set_initial_pages in gc.o
      ...
  "_je_malloc_conf", referenced from:
      _ruby_show_version in version.o
  "_je_malloc_usable_size", referenced from:
      _rb_objspace_free in gc.o
      _ruby_xfree in gc.o
      _ruby_xcalloc in gc.o
      _free_const_entry_i in gc.o
      _rb_gc_call_finalizer_at_exit in gc.o
      _rb_gc_unregister_address in gc.o
      _objspace_xmalloc0 in gc.o
      ...
  "_je_posix_memalign", referenced from:
      _heap_assign_page in gc.o
  "_je_realloc", referenced from:
      _replace_real_basename in dir.o
      _rb_enc_register in encoding.o
      _rb_encdb_declare in encoding.o
      _rb_enc_replicate in encoding.o
      _rb_encdb_replicate in encoding.o
      _rb_encdb_dummy in encoding.o
      _rb_enc_init in encoding.o
      ...
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
make: *** [miniruby] Error 1

Mac 版本是 ? Clang 版本是? 用的 C++ 标准库版本是?

但没有尝试 jemalloc,前几天试了 Sidekiq 作者说的设置 MALLOC_ARENA_MAX=2,内存上涨是慢下来了,但性能受影响非常明显。因为现在的执行效率其实完全可以接受,暂时也就没再折腾了。

Reply to blacklee

找到原因了,自己编译的 jemalloc 有问题😂

Ruby China 的 Docker Image 打包:

https://ruby-china.org/topics/40730

Reply to awking

请教下,这种方式 怎么确定起作用了?

Reply to xiaox

先确认本地的设置已打开。再就是监控内存的使用情况

Reply to awking

OK。谢谢

You need to Sign in before reply, if you don't have an account, please Sign up first.