erlang 内存增长的一些处理

文章翻译来至   https://blog.heroku.com/archives/2013/11/7/logplex-down-the-rabbit-hole

并非原创。因为觉得文章写的非常好,所以翻译出来。翻译不准的地方请校正。


作者的内存出现崩溃,然后作者开始着手研究Erlang的内存分布。

翻译如下:

开始我决定去等待节点再次crach。我希望这时候的crash dump 能够保留一些关于内存的细节,这将会对我有些许的帮助。也许Logplex 节点非常讨厌我去做别的事情,但是她们不会永远不不会超出内存限制。她们将会等待并且nag然后死亡,或者不会引起我的注意。


最终我登录了那些不有问题的节点然后运行了下面的Erlang shell:

[erlang:garbage_collect(Pid) || Pid <- processes() ].

这行shell语句非常有效的通过了所有的进程,并且强制进行了一次垃圾回收。然后警报停止了。


手动进行GC创造了奇迹。但问题是:为什么会这样? 在回答这个问题之前,我需要数据。这个节点并没有crash。

垃圾不见了,并且在一个节点能够展示这个恼人的问题之前,花了好几周的时间。我没有任何的想法,只能等待。

幸运的是随着时间的推移,这种现象发生的越来越频繁。有好几个节点最终将要超出负荷并且为我死掉。通过对crash dump的数据分析发现,并没有单个的进程消耗的会导致危险的内存。并且也没有任何一个消息堆积(大于26)会导致他爆炸。有一些进程有很高的内存,但是在线的profiling并没有给出任何一个单个的进程会导致问题。


随着时间的推移,我一直保持手动回收gc每当有内存警报出现,但是有些事情不太对劲。下面是一张关于手动GC前后的os内存消耗对比图。


并且下面是一张刚启动的节点的内存消耗图:



这张图非常明确的展示了两个事情:

1: GC如果没有被prompted看起来好像难以处理这些工作。(VM自己的GC遇到困难)

2: 一些内存没有直接被Erlang分配,并且随着时间的推移一直在保持和增长。


我决定集中处理第一个问题,因为他一执行就能展示切实的成果,并且能够阻止错误的和crash。

我不知道罪魁祸首,但是一定程度来讲,有一些节点开始触发错误。我坐在终端前,等待发生一些变化。

但是OS却展现了如下的内容:


下面是在这段时间内内存的统计数据。

logplex@ip.internal)1> [{K,V / math:pow(1024,3)} || {K,V} <- erlang:memory()].
[{total,10.441928684711456},
 {processes,3.8577807657420635},
 {processes_used,3.8577076755464077},
 {system,6.584147918969393},
 {atom,3.5428348928689957e-4},
 {atom_used,3.483891487121582e-4},
 {binary,2.7855424359440804},
 {code,0.008501693606376648},
 {ets,3.745322160422802}]

%% waiting 20 minutes %%

logplex@ip.internal)1> [{K,V / math:pow(1024,3)} || {K,V} <- erlang:memory()].
[{total,11.024032421410084},
 {processes,3.953512007370591},
 {processes_used,3.9534691693261266},
 {system,7.070520414039493},
 {atom,3.5428348928689957e-4},
 {atom_used,3.483891487121582e-4},
 {binary,3.2071433141827583},
 {code,0.008501693606376648},
 {ets,3.8099956661462784}]

这个展示了非常多的内存增长,大多数是二进制的内存,从2.79GB增长到了3.21GB。机器了某某人的一篇博客,我决定尽我最大努力确定这个就是引起问题的根本原因。


Erlang的二进制主要分为两种类型。二进制不超过64K都会被直接分配进程自己的私有堆中,并且放在她们经常存放的地方。二进制数据超过这个将会被分配到全局的专门的二进制堆中,并且每个进程有一份她们的引用,在她们私有的进程堆中。这些二进制都会被计数引用,只有在所遇的引用都被GC之后这些二进制才会被释放。


在99%的案例中,这种机制将会工作的非常好。但是在某些特殊的例子中,VM这种机制却不能表现的很好。

比如下面两种情况:(感觉翻译的不太好,下面的原句)

  • do too little work to warrant allocations and garbage collection;
  • eventually grow a large stack or heap with various data structures, collect them, then get to work with a lot of refc binaries. Filling the heap again with binaries (even though a virtual heap is used to account for the refc binaries' real size) may take a lot of time, giving long delays between garbage collections.

1: 做非常少的工作(在分配和回收方面做的事情太少)来保证内存的分配和垃圾的回收。

2: 服务器随着时间的推移,最终因为多类型的数据结构生成了非常多的二进制引用。再次把二进制堆填满花费了太多的时间,导致垃圾回收非常大的延迟。

在Logplex案例中,第二种情况是导致问题的原因。我通过轮训给每个程序执行process_info(Pid,binary),来确定这个结论。process_info(Pid,binary)将会返回一系列的一个二进制引用的列表。这个列表的长度能够知道哪个进程拥有最多的二进制引用,但是这些还不够。这些引用也许是正在被使用的。建立这个列表,然后调用全局的垃圾回收。然后通过对比,就可以知道哪些进程拥有最多的需要回收的二进制引用。


(logplex@ip.internal)3> MostLeaky = fun(N) ->
(logplex@ip.internal)3>     lists:sublist(
(logplex@ip.internal)3>      lists:usort(
(logplex@ip.internal)3>          fun({K1,V1},{K2,V2}) -> {V1,K1} =< {V2,K2} end,
(logplex@ip.internal)3>          [try
(logplex@ip.internal)3>               {_,Pre} = erlang:process_info(Pid, binary),
(logplex@ip.internal)3>               erlang:garbage_collect(Pid),
(logplex@ip.internal)3>               {_,Post} = erlang:process_info(Pid, binary),
(logplex@ip.internal)3>               {Pid, length(Post)-length(Pre)}
(logplex@ip.internal)3>           catch
(logplex@ip.internal)3>               _:_ -> {Pid, 0}
(logplex@ip.internal)3>           end || Pid <- processes()]),
(logplex@ip.internal)3>      N)
(logplex@ip.internal)3> end,
(logplex@ip.internal)3> MostLeaky(10).

自从添加了这个功能到recon library,所以再也没有人需要手动去调用他。上面的这么小的数据展示了一些进程拥有超过100000条过期的二进制的引用,并且有一些拥有超过10000条引用。这些告诉我一些进程拥有大量的二进制数据,并且一些调查显示,她们全部都是一些buffers或者drains。这是一条坏消息,因为这意味着,Logplex构建的方法不小心刚好跑进了VM回收垃圾的特殊的案例中去了。


Picking Up The Trash

通常,引用的二进制内存数据泄漏能够在一下几个方面被解决。

1: 在指定的时间间隔内进行手动GC。(讨厌)

2: 手动跟踪二进制数据的尺度,判断是否回收。这样做可能会被VM做的更加糟糕。

3: 停止使用二进制(不考虑)

4: 在适当的时候调用睡眠(不明白什么意思)


我决定用一个至今还在使用的方法做个快速的修复。

在一个简单的只有自己循环的模块上定期进行内存监控。检查他是否有一个阀值会使系统进行垃圾回收。如果需要,该模块还允许手动调用。

这个脚本按语气运行,并且高内存的警告很快被驯服了。记录增量,等待后续的检查。



因为某种原因,尝试失败了。

几周后,日志上来看,并没有导致内存问题的事故。日志展示,系统的垃圾回收如需而至。并且所有的一切看起来非常正常。 Resorting to emergency measures as the only way to get the node to drop high amounts of memory isn't ideal, 但是我们永远不知道下次的快速飙升将会在什么时候。I decided to add a bunch of hibernation calls in non-intrusive locations (inactive drains, or when disconnecting from a remote endpoint), which would allow us to garbage collect globally much less frequently, while keeping memory lower overall when people register mostly inactive drains.


除了五周后一个节点崩溃了(虽然被很好的修复了)所有的事情进行的非常好。全局垃圾回收几乎没有被触发。查看os和Logplex 节点的日志发现os分配了15G的内存给Logplex。但是它却仅仅展示使用不到一半的内存。我们有非常严重的内存泄漏。


第二个尝试来修复这个问题。

关于Erlang的内存机制是怎样运行的。

在这点上,我尽我最大的对VM的了解并且怀疑内存泄漏的原因 1:内存是跑到VM报道的外面去了,2: 节点本身存在非常多的碎片。


不知道怎么去做,我联系了Lukas Larsson。几年前,当我花费我的前两个周末在Erlang Solution Ltd中伦敦学习的时候,Lukas也在这里花了几天的时间,并且充当我对于公司和城市的向导。从那时起,Lukas成了Ericsson OTP团队的顾问。我也去了AdGear,再然后去了HeroKu。我们偶尔在IRc会议上有所联系。并且Lukas经常帮我解决一些关于VM的棘手的问题。

我问Lukas 我如何确定是内存泄漏是因为错误还是碎片引起的。我给他展示了一些收集到的数据。我分享我所学到的东西因为这些很有趣,这些信息从来没有在任何文档上有记录。


erlang:memory/0-1 返回的结果并不是OS真正给VM分配的内存。为了理解内存去哪里了,我们熟悉必须要理解非常多的操作系统的内存分配器。



1: temp_alloc:存储一些暂时被使用的数据(比如简单的c语言调用)

2: eheap_alloc:堆数据,比如Erlang的进程的堆。

3: binary_alloc:二进制分配器,通常用于分配一些全局的二进制数据。

4: driver_alloc: 通常用于used to store driver data in particular, which doesn't keep drivers that generate Erlang terms from using other allocators. The driver data allocated here contains locks/mutexes, options, Erlang ports, etc.


5: sl_alloc: 短时间存在的内存数据块。小型的buffer块或者VM调度信息。

6: ets_alloc:ETS表的全局分配器

7: ll_alloc:长时间存在的内存数据块

8: fix_alloc:被经常使用的固定尺寸的数据结构数据。比如进程内部的C数据结构。

9: std_alloc:上面没有被提及的都将由这个分配。




The entire list of where given data types live can be found in the source.

By default, there will be one instance of each allocator per scheduler (and you should have one scheduler per core), plus one instance to be used by linked-in drivers using async threads. 这个导致结构有点像上面给出的图,但是每个叶子节点都被分成了好几部分。


上面这些子分配器将会通过mseg_alloc (像系统申请一整块分页的内存)和 sys_alloc(调用系统的malloc方法)来申请内存,具体决定使用哪个,有两种方式。第一种方式:多数据块的carrier (mbcs),(carrier的定义在erlang的系统文档里面有)。这个carrier将会一次性申请一大块内存用于存储多个Erlang数据。对于每个mbc carrier,VM将会拔出一定数量的内存,我们的案例默认给出8M内存,这个数值可以被配置。每个被分配的term将会去很多个mbc carrier中寻找一个空间来驻留。


当item所需的内存超过single block carrier 阀值的时候(sbct)。内存分配器将会分配成single block carrier(sbcs)。一个single block carrier 将会为‘mmsbc’直接从mseg_alloc中申请内存,然后转换成sys_alloc方式来存储这些term,直到他被释放。


所以,通过观察一些东西例如二进制内存分配器,我们将会得到一些类似于下面的东西:

 


每当一个mbc(或者首次的'mmsb' sbc)将会被回收的时候,mseg_alloc分配器将会尽量让她们在内存中多保留一会,所以下次的分配,vm系统可以直接从前面分配的取出,而不需要再次像系统申请。


当我们使用系统调用: erlang:memory(total) 的时候,我们获取到的总内存并不是mseg_alloc 为所有的将来会使用的调用预留的内存。这个信息,至少说明os所报告的内存和Vm所报告的内存的差别需要再斟酌。现在我们需要知道我们的节点为什么会有这样的变化,并且他是否真的泄漏了。

幸运的是:Erlang提供了获取所有的分配器信息的系统调用


[{{A, N}, Data} || A <- [temp_alloc, eheap_alloc, binary_alloc, ets_alloc,
                          driver_alloc, sl_alloc, ll_alloc, fix_alloc, std_alloc],
                   {instance, N, Data} <- erlang:system_info({allocator,Allocator})]

文中应该有误,这个代码不能运行

[{{A, N}, Data} || A <- [temp_alloc, eheap_alloc, binary_alloc, ets_alloc,
                          driver_alloc, sl_alloc, ll_alloc, fix_alloc, std_alloc],
                   {instance, N, Data} <- erlang:system_info({allocator,A})]
这样应该是没有问题的


这个调用并不好看(确实,跑出来的数据一大堆,简直不能看)。在这个数据dump中,你将会接收到所有的分配器分配的数据。所有类型的block,size,等等。我把这个内嵌在了recon库中。


为了计算出Logplex节点是否有内存泄漏, 我必须检查所有分配好的blocks加起来是否接近等于os分配的内存。


幸运的是:分配器分配的内存加起来与操作系统分配的内存数相匹配。这意味着程序所利用的所有内存都来自于内存分配器。所以泄漏直接来自于c代码是不可能的。(Erlang 源代码没问题)


下一个内存泄漏的嫌疑来自碎片。为了检测这个想法, you can compare the amount of memory consumed by actively allocated blocks in every allocator to the amount of memory attributed to carriers, which can be done by calling recon_alloc:fragmentation(current) for the current values, andrecon_alloc:fragmentation(max) for the peak usage.


通过查看dump出的数据,Lukas发现二进制内存的分配是我们最大的问题所在。这个carrier 尺寸非常大,并且使用率非常的低:最差百分之3,最好百分之24.通常情况下,你可能期望使用率能够达到百分之50。另一方面,当我们看这些分配器的时候高峰的时候,二进制分配器最高能达到 90 %。


Lukas得出了一个结论,结果匹配我们的内存图。当节点有大量的二进制内存增加的时候,有大量的carriers被分配,如下所示。


当内存被释放的时候,一些残留物在Logplex buffer中,导致了非常低的使用率,就像下面那样。



结果是一大片的接近空白的block无法被释放。Erlang VM节点永远都不会去整理碎片。并且这些占据的内存将会保持 很长的时间才会消失。这些数据可能被保存在缓冲区中几个小时甚至很多天。当下次有使用高峰期的时候,节点也许需要更多的内存分配到ETS表,或者eheap_alooc中。并且大多数内存因为这些接近空白的二进制块将不能被使用。


修复这个问题将会是一个非常困难的部分。你需要知道你的系统处于什么样的负载,并且内存分配的模式。举个例子,我知道我们的 99% 二进制数据将会小于10kb,因为这是我们硬性规定了log信息的长度。然后你需要知道内存分配的不同的模式。


1: Best fit(bf)

2:    Address order best fit (aobf)

3:   Address order first fist(aoff)

4:   Address order first fit carriers best fit (aoffcbf)

5:  Address order first fit carrier address order best fit (aoffcaobf)

6:  Good fit (gf)

7:  A fit





For best fit(bf), the VM builds a balanced binary tree of all free block's sizes,and will try to find the smallest one that will accommodate the piece of data and allocate it there. In the drawing above , having a piece of data that requires three blocks would likely end in area 3.


Address order best fit (aobf) will work similarly, but the tree instead is based on the addresses of the blocks. So the VM will look for the smallest block available that can accommodate the data, but if many of the same size exist, it will favor picking one that has a lower address. If I have a piece of data that requires three blocks, I'll still likely end up in area 3, but if I need two blocks, this strategy will favor the first mbcs in the diagram above with area 1 (instead of area 5). This could make the VM have a tendency to favor the same carriers for many allocations.

Address order first fit (aoff) will favor the address order for its search, and as soon as a block fits, aoff uses it. Where aobf and bf would both have picked area 3 to allocate four blocks, this one will get area 2 as a first priority given its address is lowest. In the diagram below, if we were to allocate four blocks, we'd favor block 1 to block 3 because its address is lower, whereas bf would have picked either 3 or 4, and aobf would have picked 3.

alloc examples #2


Address order first fit carrier best fit (aoffcbf) is a strategy that will first favor a carrier that can accommodate the size and then look for the best fit within that one. So if we were to allocate two blocks in the diagram above, bf and aobf would both favor block 5, aoff would pick block 1. aoffcbf would pick area 2, because the first mbcs can accommodate it fine, and area 2 fits it better than area 1.

Address order first fit carrier address order best fit (aoffcaobf) will be similar to aoffcbf, but if multiple areas within a carrier have the same size, it will favor the one with the smallest address between the two rather than leaving it unspecified.

Good fit (gf) is a different kind of allocator; it will try to work like best fit (bf), but will only search for a limited amount of time. If it doesn't find a perfect fit there and then, it will pick the best one encountered so far. The value is configurable through thembsd VM argument.

A fit (af), finally, is an allocator behavior for temporary data that looks for a single existing memory block, and if the data can fit, af uses it. If the data can't fit, af allocates a new one.

Each of these strategies can be applied individually to every kind of allocator, so that the heap allocator and the binary allocator do not necessarily share the same strategy.


The Memory is Comming From Inside The House 

Lukas建议我们为二进制内存分配器使用(aobf) 分配策略。并且减少我们的二进制内存分配器的平均 mbcs的尺寸。 通过这个使用这个策略,我们使用了更多的cpu用来决定数据该跑到哪里,但是VM将有望在更多的案例里获得更多的自由块。意味着我们将会拥有更少的接近空白的缓冲块。


我在几个节点中使用了这些方法并且开始等待。 The problem with these settings is that failures could take up to five weeks to show up in regular nodes when we have multiple dozens of them, 然后慢慢的频率增加。测试我放在产品上的实验的成功花费了相当长的时间。在那些使用了更多的cpu的节点上,在3到4周后都没有发生崩溃。我决定进一步推进实验。我把这些改变放在了所有的节点上。但是对于这些选项,所有的节点都需要被重启。通常我们使用Erlang的热更新特性来代替重启。代替了重启这些节点,我等待了好几周的时间来等她们崩溃。知道崩溃,大概25% 的节点已经运行在了新的内存分配方案上,75%的节点仍然运行在旧的分配方案上。最初的几个节点看起来非常的稳定。


所有的新的分配正常发生崩溃都发生在那些旧的节点上,现在 那些30%-35%的节点从来没有崩溃过。我认为实验成功,同时还知道那些死去的节点是那些旧的节点,而不是新的节点。所有的节点都跑在了新的稳定的Erlang版本上。


问题解决:

几周后,我发现实际上 这个解决方案并不完美。碎片仍然存在。但是我们看到了一些改善。

Whereas most binary allocators that saw a significant amount of usage before the fixes would have usage rates between 3% and 25%, the new nodes seeing a significant amount of usage tend to have at least 35% to 40% usage, with some of them having well above 90% across the cluster. This more than doubles our efficiency in memory and usage high enough that we have yet to lose a VM without first being able to trigger a global garbage collection call. In fact, we haven't lost memory due to Out-Of-Memory errors that were directly attributable to drains and how Logplex is built. This doesn't mean the cluster components never fail anymore; I still see failures resulting from bad load-balancing between the nodes or connections and services going down that we incorrectly built around (and are now remediating).

I also saw cases in which the nodes would not fail, but have degraded quality of service. The most glaring example of this was some bad behavior from some nodes under heavy load, with high drop rates in Logplex messages to be delivered to users.



  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值