ガベージコレクション
この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-GC
翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22)
プロセスがスタックとヒープの空きを使い果たすと、マイナーガベージコレクションによって空間を回収しようとします。この処理のコードはerl_gc.cにあります。
コピーガベージコレクション
ERTSは世代別コピー方式のガベージコレクタを採用しています。コピー方式のコレクタとは、ガベージコレクションのたびに生存している若い項をすべて古いヒープから新しいヒープへコピーし、そのあとで古いヒープを破棄する方式です。世代別コレクタは、ほとんどの項が若いうちに死ぬという原則にもとづいています。一時的に生成され、使われ、捨てられる項がほとんどだということです。古くなった項は古い世代へと昇格し、そちらはより頻度を落として回収されます。いったん古くなった項はその後も長く生き続ける可能性が高い、という考え方によるものです。
概念的には、ガベージコレクションは次のように進みます。
- まずすべてのルート(たとえばスタック)を集めます。
- 各ルートについて、それがヒープ上のオブジェクトを指していて、かつそのオブジェクトにまだ転送ポインタが付いていなければ、そのオブジェクトを新しいヒープへコピーします。コピーした各オブジェクトについては、元の場所に新しいコピーを指す転送ポインタを書き込みます。
- 続いて新しいヒープを順にたどり、ルートに対して行ったのと同じ処理をします。
具体例
この処理の詳細を、具体例を通して見ていきます。ここでは古い世代を持たないマイナーコレクションを取り上げ、ルートセットとしてスタックだけを使います。実際にはプロセス辞書やトレースデータ、プローブデータなども含めてルートセットが構成されます。
gc_exampleモジュールでgarbage_collectを呼び出すとどう振る舞うかを見ていきましょう。このコードはconsの2つの要素とタプルから同じ文字列を共有して生成し、そのタプルは後で不要になってガベージになります。GCの後にはヒープ上に文字列が1つだけ残るはずです。つまり、最初に項{["Hello","Hello"], "Hello"}を生成します(すべてのインスタンスで同じ文字列"Hello"を共有しています)。そして、GCを起動する時点では項["Hello","Hello"]だけを保持しています。
-module(gc_example).
-export([example/0]).
example() ->
T = gen_data(),
S = element(1, T),
erlang:garbage_collect(),
S.
gen_data() ->
S = gen_string($H, $e, $l, $l, $o),
T = gen_tuple([S,S],S),
T.
gen_string(A,B,C,D,E) ->
[A,B,C,D,E].
gen_tuple(A,B) ->
{A,B}.
この例をコンパイルしたら、Erlangシェルを起動して呼び出しを試し、次の呼び出しの準備をしておきます(まだリターンキーは押しません)。
1> gc_example:example().
["Hello","Hello"]
2> spawn(gc_example,example,[]).
gdbでシステムを調査する
続いて、gdbで自分のErlangノード(この例ではOS PIDが2955)にアタッチします。
$ gdb /home/happi/otp/lib/erlang/erts-6.0/bin/beam.smp 2955
sudoを付ける必要があります。次に、gdb上でGCのメイン関数の先頭にブレークポイントを設定し、ノードを再開させます。
(gdb) break garbage_collect_0
(gdb) cont
Continuing.
ここでErlangシェル側でリターンキーを押すと、実行がブレークポイントで止まります。
Breakpoint 1, garbage_collect_0 (A__p=0x7f673d085f88, BIF__ARGS=0x7f673da90340) at beam/bif.c:3771
3771 FLAGS(BIF_P) |= F_NEED_FULLSWEEP;
PCBを調査する
ここでプロセスのPCBを調べてみます。
(gdb) p *(Process *) A__p
$1 = {common = {id = 1408749273747, refc = {counter = 1}, tracer_proc = 18446744073709551611, trace_flags = 0, u = {alive = {
started_interval = 0, reg = 0x0, links = 0x0, monitors = 0x0, ptimer = 0x0}, release = {later = 0, func = 0x0, data = 0x0,
next = 0x0}}}, htop = 0x7f6737145950, stop = 0x7f6737146000, heap = 0x7f67371458c8, hend = 0x7f6737146010, heap_sz = 233,
min_heap_size = 233, min_vheap_size = 46422, fp_exception = 0, hipe = {nsp = 0x0, nstack = 0x0, nstend = 0x0, ncallee = 0x7f673d080000,
closure = 0, nstgraylim = 0x0, nstblacklim = 0x0, ngra = 0x0, ncsp = 0x7f673d0863e8, narity = 0, float_result = 0}, arity = 0,
arg_reg = 0x7f673d086080, max_arg_reg = 6, def_arg_reg = {393227, 457419, 18446744073709551611, 233, 46422, 2000}, cp = 0x7f673686ac40,
i = 0x7f673be17748, catches = 0, fcalls = 1994, rcount = 0, schedule_count = 0, reds = 0, group_leader = 893353197987, flags = 0,
fvalue = 18446744073709551611, freason = 0, ftrace = 18446744073709551611, next = 0x7f673d084cc0, nodes_monitors = 0x0,
suspend_monitors = 0x0, msg = {first = 0x0, last = 0x7f673d086120, save = 0x7f673d086120, len = 0, mark = 0x0, saved_last = 0x7d0}, u = {
bif_timers = 0x0, terminate = 0x0}, dictionary = 0x0, seq_trace_clock = 0, seq_trace_lastcnt = 0,
seq_trace_token = 18446744073709551611, initial = {393227, 457419, 0}, current = 0x7f673be17730, parent = 1133871366675,
approx_started = 1407857804, high_water = 0x7f67371458c8, old_hend = 0x0, old_htop = 0x0, old_heap = 0x0, gen_gcs = 0,
max_gen_gcs = 65535, off_heap = {first = 0x0, overhead = 0}, mbuf = 0x0, mbuf_sz = 0, psd = 0x0, bin_vheap_sz = 46422,
bin_vheap_mature = 0, bin_old_vheap_sz = 46422, bin_old_vheap = 0, sys_task_qs = 0x0, state = {counter = 41002}, msg_inq = {first = 0x0,
last = 0x7f673d086228, len = 0}, pending_exit = {reason = 0, bp = 0x0}, lock = {flags = {counter = 1}, queue = {0x0, 0x0, 0x0, 0x0},
refc = {counter = 1}}, scheduler_data = 0x7f673bd6c080, suspendee = 18446744073709551611, pending_suspenders = 0x0, run_queue = {
counter = 140081362118912}, hipe_smp = {have_receive_locks = 0}}
ずいぶん情報量が多いですが、興味深いのはスタックとヒープに関する部分です。
hend = 0x7f6737146010,
stop = 0x7f6737146000,
htop = 0x7f6737145950,
heap = 0x7f67371458c8,
スタックの中身
ここで(今では失われてしまった)いくつかのヘルパースクリプトを使うと、スタックとヒープの中身を意味のある形で調べられます。
(gdb) source gdb_scripts
(gdb) print_p_stack A__p
0x00007f6737146008 [0x00007f6737145929] cons -> 0x00007f6737145928
(gdb) print_p_heap A__p
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f6737145919] cons -> 0x00007f6737145918
0x00007f6737145928 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145920 [0xfffffffffffffffb] NIL
0x00007f6737145918 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145910 [0x00007f67371458f9] cons -> 0x00007f67371458f8
0x00007f6737145908 [0x000000000000048f] 72
0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371458f8 [0x000000000000065f] 101
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
ここで見えているのは、プロセスが文字列"Hello"をヒープ上に確保し、そのリストを2回含むconsセルと、そのconsとリストを含むタプルを確保したあとのヒープです。ルートセット、この場合はスタックですが、これは2つの"Hello"のコピーを含むconsを指すポインタを保持しています。タプルの方は死んでいます。つまり、どこからも参照されていません。
ガベージコレクションはルートセットの計算と、新しいヒープ(to space)の確保から始まります。デバッガでGCのコードにステップインしていくと、この処理の様子を見ることができます。ここでは詳細には立ち入りません。何ステップか進めると、ルートセットに含まれるすべての項が新しいヒープへコピーし終わった時点に到達します。この処理は(バージョンによりますが)erl_gc.cの1272行目付近から始まるwhileループによって行われます。
この例ではルートはアドレス0x00007f95666597f0を指すconsで、文字(整数)‘H’を含んでいます。consセルが現在のヒープ(from spaceと呼びます)からto spaceへ移動されると、head(car)の値はmoved consタグ(値0)で上書きされます。
ヒープの中身
最初のステップでルートセットが移動された後、from spaceとto spaceは次のようになります。
from space:
(gdb) print_p_heap p
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0xfffffffffffffffb] NIL
0x00007f6737145918 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145910 [0x00007f67371458f9] cons -> 0x00007f67371458f8
0x00007f6737145908 [0x000000000000048f] 72
0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371458f8 [0x000000000000065f] 101
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
to space:
(gdb) print_heap n_htop-1 n_htop-2
0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f6737145918
0x00007f67371445b0 [0x00007f6737145909] cons -> 0x00007f6737145908
from spaceでは最初のconsセルのheadが0(サイズ0のタプルのように見えます)で上書きされ、tailはto spaceにある新しいconsセルを指す転送ポインタで上書きされています。to spaceには、from spaceにあるconsのheadとtailをそれぞれ指す2つの後方ポインタを持つ最初のconsセルができています。
残りのヒープの移動
コレクタがルートセットの処理を終えると、to spaceにはまだ生存しているすべての項への後方ポインタが並んだ状態になります。ここからコレクタはto spaceの掃引を始めます。使うポインタは2つで、未処理のヒープの底を指すn_hpと、ヒープの先頭を指すn_htopです。
n_htop:
0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f6737145918
n_hp 0x00007f67371445b0 [0x00007f6737145909] cons -> 0x00007f6737145908
GCは次にn_hpが指す値を見ます。この場合はfrom spaceを指し戻すconsです。そこでこのconsをto spaceへ移動し、新しいconsの分だけn_htopを進め、最初のconsを処理済みとするためにn_hpも進めます。
from space:
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0xfffffffffffffffb] NIL
0x00007f6737145918 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0
0x00007f6737145908 [0x0000000000000000] Tuple size 0
0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371458f8 [0x000000000000065f] 101
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
to space:
n_htop:
0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371458f8
0x00007f67371445c0 [0x000000000000048f] 72
n_hp 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f6737145918
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
2つめのconsについても同じ処理が起こります。
from space:
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0x00007f67371445d1] cons -> 0x00007f67371445d0
0x00007f6737145918 [0x0000000000000000] Tuple size 0
0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0
0x00007f6737145908 [0x0000000000000000] Tuple size 0
0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371458f8 [0x000000000000065f] 101
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
to space:
n_htop:
0x00007f67371445d8 [0xfffffffffffffffb] NIL
0x00007f67371445d0 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371458f8
n_hp 0x00007f67371445c0 [0x000000000000048f] 72
SEEN 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f67371445d0
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
to spaceの次の要素は即値の72で、これは単に読み飛ばされるだけです(n_hp++)。その次にまた別のconsがあり、これは移動されます。
同じ処理がもう一度起こります。
from space:
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0x00007f67371445d1] cons -> 0x00007f67371445d0
0x00007f6737145918 [0x0000000000000000] Tuple size 0
0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0
0x00007f6737145908 [0x0000000000000000] Tuple size 0
0x00007f6737145900 [0x00007f67371445e1] cons -> 0x00007f67371445e0
0x00007f67371458f8 [0x0000000000000000] Tuple size 0
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
to space:
n_htop:
0x00007f67371445e8 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371445e0 [0x000000000000065f] 101
0x00007f67371445d8 [0xfffffffffffffffb] NIL
n_hp 0x00007f67371445d0 [0x00007f6737145909] cons -> 0x00007f6737145908
SEEN 0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371445e0
SEEN 0x00007f67371445c0 [0x000000000000048f] 72
SEEN 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f67371445d0
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
ここで、すでに移動済みのセルを指すconsに到達します。GCはアドレス0x00007f6737145908にあるIS_MOVED_CONSタグを見つけ、移動先のセルのtail(*n_hp++ = ptr[1];)から移動先の情報をコピーします。こうすることで、GCをまたいでも共有関係が保たれます。このステップはfrom spaceには影響しませんが、to space側の後方ポインタは書き換えられます。
to space:
n_htop:
0x00007f67371445e8 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371445e0 [0x000000000000065f] 101
n_hp 0x00007f67371445d8 [0xfffffffffffffffb] NIL
SEEN 0x00007f67371445d0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
SEEN 0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371445e0
SEEN 0x00007f67371445c0 [0x000000000000048f] 72
SEEN 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f67371445d0
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
続いてリストの残り(文字列本体)が移動されます。
from space:
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0x00007f67371445d1] cons -> 0x00007f67371445d0
0x00007f6737145918 [0x0000000000000000] Tuple size 0
0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0
0x00007f6737145908 [0x0000000000000000] Tuple size 0
0x00007f6737145900 [0x00007f67371445e1] cons -> 0x00007f67371445e0
0x00007f67371458f8 [0x0000000000000000] Tuple size 0
0x00007f67371458f0 [0x00007f67371445f1] cons -> 0x00007f67371445f0
0x00007f67371458e8 [0x0000000000000000] Tuple size 0
0x00007f67371458e0 [0x00007f6737144601] cons -> 0x00007f6737144600
0x00007f67371458d8 [0x0000000000000000] Tuple size 0
0x00007f67371458d0 [0x00007f6737144611] cons -> 0x00007f6737144610
0x00007f67371458c8 [0x0000000000000000] Tuple size 0
to space:
n_htop:
n_hp
SEEN 0x00007f6737144618 [0xfffffffffffffffb] NIL
SEEN 0x00007f6737144610 [0x00000000000006ff] 111
SEEN 0x00007f6737144608 [0x00007f6737144611] cons -> 0x00007f6737144610
SEEN 0x00007f6737144600 [0x00000000000006cf] 108
SEEN 0x00007f67371445f8 [0x00007f6737144601] cons -> 0x00007f6737144600
SEEN 0x00007f67371445f0 [0x00000000000006cf] 108
SEEN 0x00007f67371445e8 [0x00007f67371445f1] cons -> 0x00007f67371445f0
SEEN 0x00007f67371445e0 [0x000000000000065f] 101
SEEN 0x00007f67371445d8 [0xfffffffffffffffb] NIL
SEEN 0x00007f67371445d0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
SEEN 0x00007f67371445c8 [0x00007f67371445e1] cons -> 0x00007f67371445e0
SEEN 0x00007f67371445c0 [0x000000000000048f] 72
SEEN 0x00007f67371445b8 [0x00007f67371445d1] cons -> 0x00007f67371445d0
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
この例のまとめ
この例からは、いくつか押さえておきたい点があります。Erlangで項が作られるときは、要素から順にボトムアップで組み立てられます。一方でガベージコレクタはトップダウンに動き、最上位の構造から始めて要素をコピーしていきます。つまり、最初のGCの後にはポインタの向きが変わるということです。これ自体に実害はありませんが、実際のヒープを眺めるときには知っておいた方がよいことです。構造がボトムアップになっているはずだと決めつけることはできません。
もう一点、GCは幅優先でヒープをたどります。そのため、GCの後は1つの項について見ると局所性がむしろ悪化していることがほとんどです。現代的なキャッシュのサイズを考えれば、これが問題になることは通常ありません。もちろん、これが問題になるような病的な例を作ることもできますが、逆に深さ優先の探索が問題を引き起こすような病的な例も同様に作れます。
3点目として、共有関係が保たれるという点は本当に重要です。これが保たれなければ、GCの後に前よりも多くの空間を使ってしまうことになりかねません。
ここからは、世代についてさらに詳しく説明します。
Erlangのガベージコレクションにおける世代
ErlangのBEAM仮想マシンは世代別コピー方式のガベージコレクタを採用しています。これは、ほとんどのオブジェクトが「若いうちに死ぬ」という観察にもとづいてメモリを効率よく管理する方式です。そのため、最近確保されたばかりのものは頻繁に回収され、生き残ったデータはより回収頻度の低い古い世代へと昇格します。
PCB(プロセスコントロールブロック)にはヒープに関する情報が含まれており、その中には次のようなフィールドがあります。
- high_water:ヒープ上で古いオブジェクトと新しいオブジェクトの境界を示すポインタです。この線より下にあるオブジェクトは過去のコレクションを生き延びた「古い」オブジェクトであり、上にあるオブジェクトは新しく作られたもので、短命である可能性が高いものです。
- old_heap:「古い」オブジェクト用に確保されたヒープ領域を指すポインタです。オブジェクトはマイナーガベージコレクションを生き延びた後、この領域へ昇格します。
- old_htop:古いヒープにおける現在の先頭位置を示します。昇格したオブジェクトが新たに置かれる場所です。
- old_hend:古いヒープ領域の終端を示します。古いヒープに追加のガベージコレクションやリサイズが必要になるかどうかの基準として参照されます。
- gen_gcs:あるプロセスに対して行われたマイナー世代別ガベージコレクションの回数を数えます。
- max_gen_gcs:ガベージコレクタがフルスイープを行うまでに許容するマイナーコレクションの最大回数を定めます。このしきい値により、定期的にヒープ全体(新旧両方)が漏れなく掃除されます。
PCBには、メモリ管理に関わる次のようなフィールドも含まれています。
- off_heap:ガベージコレクション時に考慮する必要があるヒープ外の構造体へのポインタを保持します。ヒープ外データの例としては、大きなバイナリが挙げられます。
- mbufとmbuf_sz:主にメッセージパッシングで競合が発生した際に使われるヒープの断片(メモリバッファ、いわゆる「mバッファ」)を表します。ロック競合やヒープ空間の問題を避けるため、メッセージはmバッファへコピーされます。
- bin_vheap_sz、bin_vheap_mature、bin_old_vheap_sz、bin_old_vheap:メインヒープの外に保存されるバイナリデータに関するフィールドです。
bin_vheap_szはプロセスヒープに確保されたバイナリのサイズを追跡します。bin_vheap_matureはバイナリの成熟した仮想ヒープサイズを表し、ガベージコレクションを生き延びたバイナリのサイズを示します。bin_old_vheap_szとbin_old_vheapは、昇格して若い世代のものとは別に追跡されている、古い世代のバイナリに関するものです。
世代別ヒープの概念図
ヒープと古いヒープの配置は、(単純化していますが)次のように表せます。
図: 新しい世代のヒープ(左)と古い世代のヒープ(右)。矢印はない。各区画の境界にあるラベルはhend・stop・htop・heapおよびold_hend・old_htop・old_heapが指す位置を示している。
ここには2つのヒープ領域の模式図があります。
- 左側の通常のヒープは、新しいオブジェクトが最初に確保される場所です。
- 右側の古いヒープには、マイナーコレクションを生き延びて古い世代に昇格した、より長生きなオブジェクトが格納されます。
実務上の意味合い
頻繁なマイナーガベージコレクション(gen_gcsで追跡されます)は、多くの場合、あるプロセスが短命なデータを次々と確保しては捨てていることを示しています。これに気づいたら、コードパスを最適化することでパフォーマンスを改善できる可能性が高いといえます。特に、すぐにガベージになる一時的・中間的なデータ構造の生成を減らすことが有効です。そうすることでガベージコレクタのオーバーヘッドが小さくなり、実行が効率化され、CPU使用率も下がります。
同様に、high_waterポインタが継続的に上昇し続けているのが見えたら、そのプロセスが永続的なデータを増やし続けているサインかもしれません。このパターンは、より多くのオブジェクトがガベージコレクションのサイクルを生き延びて古い世代に昇格し、時間とともにメモリを消費し続けていることを示します。これに注意を払うことで、メモリリークやデータのライフサイクル上の非効率に気づきやすくなります。理想は、上昇し続ける一方の状態ではなく、安定したヒープ使用量です。
old_heap、old_htop、old_hendといった他の重要なフィールドからは、プロセスがどれだけのデータをより回収頻度の低い古い世代のヒープへ昇格させてきたかがわかります。これらを監視することで、そのプロセスが長生きするデータをうまく扱えているか、あるいは不要になった古いデータへの参照を余分に保持していないかについて手がかりが得られます。
同様に、メモリバッファ(mbuf、mbuf_sz)の使用が過剰になっている場合、多くはプロセス間メッセージングにおける競合や高いスループットを示しています。これらの値が異常に大きくなっているなら、メッセージのバッチ化や通信パターンの再構成など、メッセージング戦略の見直しを検討してください。同じ原則はバイナリヒープ(bin_vheap_szとbin_vheap_mature)にも当てはまります。バイナリヒープが大きい場合は、意図せず大きなバイナリを必要以上に長く保持していないか、バイナリの扱い方を見直す必要があるかもしれません。
最適化のための提案
BEAM VMにおけるガベージコレクションの仕組みを理解しておくと、アプリケーションのパフォーマンスを最適化するうえで重要な手がかりが得られます。現代的なハードウェアではキャッシュの局所性に関わる問題はかなり緩和されていますが、それでもErlangの世代別ガベージコレクタが幅優先のコピー方式を採用していることは押さえておく価値があります。この方式はデータの局所性を悪化させることがあり、パフォーマンスに非常に敏感なコードパスでは微妙な影響として現れることがあります。
さらに、ヒープの断片化、とくにメモリバッファ(mバッファ)の使用状況にも注意してください。メッセージパッシングに大きく依存するアプリケーションでは、これらのバッファが頻繁に使われることでヒープの断片化が顕著になることがあります。mbufやmbuf_szの過剰な増加は、競合や非効率なメッセージ処理のサインです。そのような場面では、メッセージのバッチ化や通信パターンの見直しによってメッセージパッシングの戦略を最適化すると、大きな改善が見込めます。
マイナーコレクションの頻度(gen_gcs)やメジャーコレクションのしきい値(max_gen_gcs)といったガベージコレクションの統計値を監視することも欠かせません。これらの指標を定期的に確認することで、過剰な一時データの確保や意図しない長寿命データといった非効率を早期に検知できます。こうしたパターンを早く見つけられれば、メモリの非効率を修正し、メモリリークや不要なガベージコレクションのオーバーヘッドを未然に防げます。
最後に、計算の過程で大量の一時データが生じる場面についても考えておきましょう。頻繁なガベージコレクションがボトルネックになっているなら、プロセスの初期ヒープサイズを前もって大きく設定しておくことが有効です。これはErlangのspawn_optに{min_heap_size, Size}オプションを渡すことで実現できます。この方法は、あらかじめ十分なヒープ空間を確保しておくことでGCの頻度を減らし、メモリ回収の繰り返しによるパフォーマンスへの悪影響を最小限に抑えます。
スタックとヒープの伸長
BEAM上で動くErlangアプリケーションを効果的に管理するには、プロセスのメモリがどのように伸び縮みするかを理解しておく必要があります。具体的には、Erlangのプロセスはスタックとヒープという2つの主要な領域から動的にメモリを確保します。
スタックの伸長
スタックには、関数呼び出し、リターンアドレス、ローカル変数、計算中に必要な中間データが保持されます。関数呼び出しが発生すると、パラメータやリターンアドレス、ローカル変数を格納するための新しいスタックフレームが作られます。コードの再帰呼び出しが深くなるにつれ、スタックは自然に伸び、ヒープ領域に向かって下方向に広がっていきます。逆に、関数の実行が終わると、不要になったフレームが破棄されてスタックは縮みます。スタックはアドレスの低い方向へ伸びていきます。
関数呼び出しがあまりに深くネストしすぎたり、再帰の使い方を誤ったりすると、スタックオーバーフローが発生します。注意深く設計されたErlangアプリケーションでは、過度に深い再帰を避けるか、末尾呼び出し最適化される形の再帰を使うことで、不要なスタックの伸長を防ぐべきです。
ヒープの伸長
一方、ヒープにはタプルやリスト、マップ、バイナリなど、動的に確保されるErlangの項が格納されます。スタックとは逆に、ヒープはアドレスの高い方向へ伸びていきます。プロセスが新たに確保したデータはヒープの先頭(htop)に置かれ、そこは絶えず上方向へ移動していきます。ヒープの空きがなくなると、ガベージコレクションが発生し、使われていない項からメモリが回収されます。
ガベージコレクションは、生存している項を新しい領域へコピーすることで圧縮し、断片化を減らします。GCの後、ヒープのサイズは(回収されたメモリの分だけ)縮むこともあれば、変わらないこともありますし、プロセスの要求を満たすだけのメモリを回収できなければ逆に大きくなることもあります。
スタックとヒープの関係
Erlangでは、スタックとヒープは概念的には別のものですが、実際にはプロセス用に確保された同じ連続したメモリブロックの中に同居しています。両者は互いに向かい合う方向へ伸びていきます。
- スタック:アドレスの低い方向へ伸びます。
- ヒープ:アドレスの高い方向へ伸びます。
スタックとヒープが衝突すると、プロセスはメモリを使い果たした状態になり、これがガベージコレクションの引き金となってヒープ空間の回収が試みられます。それでも十分な空間を回収できなければ、BEAMはそのプロセスにより大きなメモリ領域を確保します。
最適化の実践的な助言
プロファイリングの際は、スタックの深さとヒープサイズに注目してください(observerやetopが役立ちます)。一時的な項が積み重なったり、不要なコピーが発生していたりすると、ヒープが過剰に伸びることがあります。深い再帰やネストの多い呼び出しについては、スタックの使用状況も監視してください。
計算の過程で大量の一時的なヒープ領域が必要になる場合は、あらかじめ大きめのヒープを確保しておくことを検討してください。
スタックとヒープがこのように動的に関係し合っていることを理解しておけば、アプリケーションのメモリの振る舞いを精密に制御でき、より高性能で効率的、かつ安定したErlangコードを書けるようになります。
メモリ特性と最適化の機会
ここでは、これまでの研究にもとづく特性や知見を通じて、Erlang・BEAMにおけるメモリ管理とガベージコレクションへの理解をさらに深めていきます。
参照カウントかコピー方式か
Erlangでは項が不変であるため、メモリ上に循環参照が生まれる余地はそもそもありません。この不変性ゆえに、Erlangは理論上、参照カウントのようなより単純なガベージコレクション戦略を使うこともできたはずです。しかし参照カウントには、とくに頻繁な更新やマルチスレッド環境での競合にまつわるオーバーヘッドが伴います。Erlangがコピー方式のコレクタに頼っているのは、Erlangの項が一般に小さいためでもあります。コピー方式のコレクタは、キャッシュの局所性に優れ、断片化も最小限に抑えられます。
典型的な項のサイズ分布
HiPE(High-Performance Erlang)チームの研究によれば、Erlangの項は一般に小さく、その大半が単純な構造で占められています。彼らの計測では、典型的なErlangアプリケーションにおける項のおおよその分布は次のようになっています。
- 確保される項全体の約75%は単純なconsセルです。
- 約24%はconsではないものの、8ワード未満の小さな項です。
- 8ワード以上の項はわずか約1%にすぎません。
こうした観察を踏まえると、世代別コピー方式のガベージコレクション戦略はErlangのワークロードに非常によく合っています。項の大多数(約99%)が短命または小さいため、効率よくコピーし、最小限のオーバーヘッドで管理できます。
断片化の低減と局所性の向上
Erlangの項の多くがコンパクトであるという性質のおかげで、コピー方式のガベージコレクタはメモリの局所性を保ちつつヒープの断片化を減らすことに優れています。小さな項が頻繁に新しいヒープ領域へコピーされることで、ガベージコレクタは連続したメモリブロックを維持し、CPUキャッシュの局所性を高めます。実際には、これによってキャッシュミスが減り、メモリアクセスのパターンがより予測しやすくなることで、パフォーマンスが向上します。
アプリケーション最適化への示唆
こうした特性を踏まえると、コピー方式のガベージコレクタには明確な利点があります。
- 断片化の最小化:各ガベージコレクションでメモリが圧縮されるため、ヒープの断片化はごくわずかです。これにより、メモリ利用の効率が保たれます。
- 局所性の向上:生存している項を1つの連続したメモリ領域内の新しい場所へコピーすることで、キャッシュの局所性が向上し、現代的なCPU上での実行速度が高まります。
一方で、コピー方式には次のようなトレードオフもあります。
- コピーのオーバーヘッド:項を絶えずコピーし続けることは、大きなデータ構造や長生きする項に対してオーバーヘッドをもたらすことがあります。ただし、Erlangの用途では通常は無視できる程度です。
- 大きな項への影響:まれではありますが、8ワードを超える大きな項(データ全体の約1%を占めます)は、コピー時にパフォーマンス上の不利益をこうむることがあります。こうした項は、明示的にバイナリやヒープ外の領域へ確保することで最適化できる場合があります。
参照カウントを採用しない理由
Erlangに循環構造がないという点だけを見ると、参照カウント方式が使えそうに思えるかもしれません。しかし参照カウントには、項への参照が変わるたびにオーバーヘッドが発生し、断片化の問題自体を本質的に解決するわけでもありません。小さな項を頻繁に確保するというErlangの用途を踏まえると、コピー方式のガベージコレクションは今なお参照カウントに勝ります。
実践的な結論
Erlangのデータの特性が世代別コピー方式のGCに自然に合致しているとわかれば、BEAMがなぜこの方式を採用しているのかが見えてきます。ここから導かれる最適化の方針は、次のようなものです。
- 不要な確保や一時的なデータを最小限に抑えること。
- 大きい、あるいは複雑なデータ構造をできるだけ避けるか、大きなデータをメインヒープの外へ逃がすこと。
- 大きな一時データを生成するプロセスについては、ヒープサイズを慎重に設定すること。
こうしたメモリ管理の実践をErlangのガベージコレクション戦略に沿わせることで、アプリケーションのパフォーマンスとリソース利用の両面を最適化できます。