Ruby Ruby 的好朋友 -- jemalloc

early · 2018年10月29日 · 最后由 xiaox 回复于 2022年04月04日 · 8956 次阅读
本帖已被管理员设置为精华贴

几个月前翻译了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 将本帖设为了精华贴。 10月30日 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

使用会带来啥风险呢?

jicheng1014 回复

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

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

early 回复

不科学啊,,,,

pynix 回复

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

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

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

pynix 回复

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

adamshen 回复

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

Xenofex 回复

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

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

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

ksec 回复

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

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

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

原文地址

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

awking 回复

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

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
charleszhang 回复

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

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

blacklee 回复

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

Ruby China 的 Docker Image 打包:

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

awking 回复

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

xiaox 回复

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

awking 回复

OK。谢谢

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