メモリサブシステム: スタック、ヒープ、アロケータ

この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-Memory

翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22

ERTSのメモリサブシステムに飛び込む前に、現代的なオペレーティングシステムにおけるプログラムの一般的なメモリレイアウトについて、基本的な語彙と理解を持っておく必要があります。 この復習では、プログラムがELF実行形式にコンパイルされ、IA-32/AMD64のようなアーキテクチャ上のLinuxで動作していると仮定します。 レイアウトと用語は、ERTSがコンパイル対象とするどのオペレーティングシステムでもほぼ同じです。

プログラムのメモリレイアウトは、おおよそ次のようになります。

プログラムのメモリレイアウト 図: プログラムのメモリレイアウト

この図は複雑に見えるかもしれませんが、それでも単純化されたものです(メモリサブシステムを完全に理解したいなら、“Understanding the Linux Kernel"や"Linux System Programming"のような書籍を読んでください)。 ここで持ち帰ってほしいのは、動的に確保できるメモリには2種類あるということです。ヒープと、メモリマップされたセグメントです。 以降このヒープは、Erlangプロセスヒープと区別するためにCヒープと呼ぶことにします。 メモリマップされたセグメントは単にセグメント、この図にあるスタックはどれもCスタックと呼ぶことにします。

Cヒープはmallocを通じて確保され、セグメントはmmapで確保されます。

メモリサブシステム

メモリサブシステムに踏み込むと、ERTSが単なるプログラミング言語の実行環境ではなく、オペレーティングシステムに近い存在であることが改めてわかります。 ERTSは、Erlangプロセスレベルでのターム用ガベージコレクタを提供するだけでなく、多種多様な低レベルメモリアロケータとメモリ確保戦略も提供しています。

メモリアロケータの全体像については、erts_allocのドキュメントを参照してください。

これらのアロケータにはすべて、挙動を微調整するための多数のパラメータが用意されており、これは運用上もっとも重要な領域のひとつだと言えます。 ここを調整することで、Raspberry Piのような小さな組み込み制御システムから、インターネット規模の2TBデータベースサーバーまで、システムの挙動を用途に合わせて設定できます。

現在、アロケータは11種類、確保戦略は6種類あり、そのほかにも任意の数値を取りうる設定を含め18を超える設定項目があります。 つまり、設定の組み合わせは事実上無限にあるということです(厳密に言えば、それぞれの数値には上限があるので無限ではありませんが、数え切れないほどの組み合わせが存在します)。

これらの設定を意味のある形で使うには、各アロケータがどう動作し、それぞれの設定がアロケータの性能にどう影響するかを理解する必要があります。

erts_allocのマニュアルには、次のような警告まで書かれています。

これらのフラグは、自分が何をしているか完全に理解している場合にのみ使ってください。 不適切な設定は深刻な性能劣化を招き、動作中いつでもシステムクラッシュを引き起こしかねません。

(出典: Ericsson AB、erts_alloc

自分が何をしているかを完全に理解できるようにする、それが本章の目的です。

そして、ガベージコレクタがどう動くかについても、詳しく見ていきます。

メモリアロケータの種類

Erlangランタイムシステムは、あらゆる状況、あらゆる種類の負荷のもとでメモリをうまく扱おうとしますが、それでも例外的な状況は必ず存在します。 本章では、メモリがどのように確保されるか、そして個々のアロケータがどう動くかを詳しく見ていきます。この知識と、後述するいくつかのツールがあれば、システムがそうした例外的な状況に陥ったときに、問題を見つけて直せるようになるはずです。

システムが陥りうる問題と、それをどう分析し是正するかについての読み物としては、Fred Hébertの「Troubleshooting Down the Logplex Rabbit Hole」というエッセイがおすすめです。

本書で「メモリアロケータ」と言うときには、特定の意味を込めています。それぞれのメモリアロケータは、ある特定の種類のメモリの確保と解放を管理します。各アロケータは特定の種類のデータを対象としており、多くの場合1つのサイズのデータに特化しています。

各メモリアロケータは、実際のメモリ確保にさまざまなアルゴリズムと設定を使えるアロケータインタフェースを実装しています。

異なるアロケータを用意する狙いは、同じサイズの確保をまとめることでフラグメンテーションを減らし、頻繁な確保を安上がりにすることで性能を高めることです。

特殊で基礎的、あるいは汎用的なメモリアロケータの種類が2つ(sys_allocmseg_alloc)あり、alloc_utilフレームワークを通じて実装された特化アロケータが9つあります。

以降のセクションでは、これらの個々のアロケータを、アロケータの一般的なフレームワーク(alloc_util)に軽く寄り道しながら見ていきます。

各アロケータには、ドキュメントやC言語のコードで使われる名前がいくつかあります。短い一覧はメモリアロケータの一覧を参照してください。C名はCコード内でそのアロケータを指すのに使われます。Type名はerl_alloc.types内で確保の種類をアロケータに結び付けるのに使われます。フラグは、Erlangの起動時にそのアロケータのパラメータを設定する際の文字です。

名前説明C名Type名フラグ
Basic allocatormallocインタフェースsys_allocSYSTEMY
Memory segment allocatormmapインタフェースmseg_alloc-M
Temporary allocator一時的な確保temp_allocTEMPORARYT
Heap allocatorErlangヒープデータeheap_allocEHEAPH
Binary allocatorバイナリデータbinary_allocBINARYB
ETS allocatorETSデータets_allocETSE
Driver allocatorドライバデータdriver_allocDRIVERR
Short lived allocator短命なメモリsl_allocSHORT_LIVEDS
Long lived allocator長命なメモリll_allocLONG_LIVEDL
Fixed allocator固定サイズのデータfix_allocFIXED_SIZEF
Standard allocatorその他大半のデータ用std_allocSTANDARDD
Literal allocatorモジュールの定数literal_allocLITERAL(なし)

表: メモリアロケータの一覧

基本アロケータ: sys_alloc

sys_allocは無効化できないアロケータで、基本的には背後にあるOSのlibc malloc実装への直接的なマッピングです。

特定のアロケータが無効化されている場合は、代わりにsys_allocが使われます。

すべての特化アロケータは、必要に応じてsys_allocかmseg_allocのいずれかを使い、オペレーティングシステムからメモリを確保します。

OSからメモリを確保する際、sys_allocは要求されたサイズに一定のキロバイト数を上乗せ(パディング)できます。 メモリを多めに確保することで、システムコールの回数を減らせます。 デフォルトのパディングは0です。

メモリが解放されるとき、sys_allocは一定量の空きメモリをプロセス内に確保したまま残します。 この空きメモリのサイズはトリム閾値と呼ばれ、デフォルトは128キロバイトです。 これもメモリ使用量の増加と引き換えにシステムコールの回数を減らす仕組みです。 つまり、デフォルト設定のままシステムを動かしていると、メモリを解放してもBeamプロセスがすぐにはOSにメモリを返さない、という挙動を目にすることがあります。

sys_allocによって確保されたメモリ領域は、beamプロセスのCヒープに置かれ、必要に応じてbrkのシステムコールによって伸長します。

メモリセグメントアロケータ: mseg_alloc

背後のオペレーティングシステムがmmapをサポートしていれば、特化アロケータはオペレーティングシステムからのメモリ確保にsys_allocの代わりにmseg_allocを使えます。

mseg_allocを通じて確保されたメモリ領域はセグメントと呼ばれます。 セグメントが解放されても、すぐにOSへ返されるわけではなく、セグメントキャッシュに保持されます。

新しいセグメントを確保するときは、可能であればキャッシュ済みのセグメントを再利用します。 つまり、要求サイズと同じか、それより大きくても大きすぎない範囲であれば再利用されます。 「大きすぎない」と見なす余剰サイズのキロバイト数は、absolute max cache bad fitという値で決まり、デフォルトは4096キロバイトです。

非常に小さな確保のために4096キロバイトのセグメントを再利用してしまわないよう、relative max cache bad fitという値もあり、キャッシュ済みセグメントが要求サイズよりその割合以上大きい場合は使わないと定めています。 デフォルト値は20パーセントです。 つまり、10KBのセグメントを要求したときに12KBのセグメントなら再利用されうる、ということです。

キャッシュのエントリ数はデフォルトで10ですが、0から30までの任意の値に設定できます。

メモリアロケータのフレームワーク: alloc_util

2つの汎用アロケータ(sys_allocとmseg_alloc)の上には、alloc_utilと呼ばれるフレームワークが構築されており、用途やデータの種類ごとに特化したメモリアロケータを実装するのに使われています。

このフレームワークはerl_alloc_util.[ch]に実装されており、ERTSが使う個々のアロケータはerts/emulator/beam/ディレクトリのerl_alloc.typesで定義されています。

SMPシステムでは、通常スケジューラスレッドごとに各種類のアロケータが1つずつ存在します。

アロケータが扱う最小のメモリ単位はブロックと呼ばれます。 アロケータを呼び出して一定量のメモリを確保すると、返ってくるのはブロックです。 メモリを解放したいときにアロケータへ渡す引数も、やはりブロックです。

ただし、アロケータがオペレーティングシステムから直接ブロックを確保するわけではありません。 アロケータはまず、sys_allocかmseg_allocのどちらかを通じて、オペレーティングシステムからキャリアを確保します。sys_allocやmseg_allocは、それぞれさらにmallocやmmapを使います。 sys_allocを使う場合キャリアはCヒープ上に置かれ、mseg_allocを使う場合キャリアはセグメント内に置かれます。

小さなブロックはマルチブロックキャリアに置かれます。マルチブロックキャリアは、その名のとおり多数のブロックを含められます。 より大きなブロックはシングルブロックキャリアに置かれ、こちらもその名のとおり1つのブロックだけを含みます。

何が小さいブロックで何が大きいブロックかは、singleblock carrier thresholdsbct)というパラメータで決まります。詳しくは後述のシステムフラグの一覧を参照してください。

ほとんどのアロケータには、決して解放されない「メインマルチブロックキャリア」が1つあります。

シングルブロックキャリアとメインマルチブロックキャリアの構造 図: シングルブロックキャリアとメインマルチブロックキャリアの構造

ブロック、キャリア、確保戦略

Erlangプロセスがメモリを必要とするとき、確保のたびにオペレーティングシステムへ直接要求するわけではありません。 代わりに、erl_alloc_util.[ch]に実装されたalloc_utilフレームワークが提供する特化アロケータとやり取りします。 これらのアロケータは、「キャリア」と呼ばれる大きな連続領域からメモリを切り出すことで要求を処理します。

キャリアは、オペレーティングシステムから直接確保されるメモリ領域です。キャリアは次のいずれかを通じて確保されます。

  • sys_allocmalloc()のような標準Cライブラリ関数を使う):メモリはプロセスヒープ上に置かれます。
  • mseg_allocmmap()を使う):メモリは通常のCヒープ領域の外側に置かれます。

キャリアはさらに、「ブロック」と呼ばれる小さなメモリ区画に分割されます。メモリが要求されると、これらのキャリアからブロックが確保されます。キャリアには主に2つの種類があります。

  • マルチブロックキャリア:複数の小さなブロックを保持し、頻繁に発生する小さな確保に向いています。デフォルトでは、アロケータは通常マルチブロックキャリアを約8MB単位で要求し(システムフラグで調整可能)、よくあるErlangのメモリパターンを効率よく扱います。
  • シングルブロックキャリア:ちょうど1つの大きなブロックだけに使われます。シングルブロックキャリア閾値を超える確保は、OSから直接確保されたシングルブロックキャリアに置かれます。

キャリア閾値(sbct)とその影響

singleblock carrier thresholdsbct)というパラメータは、「小さい」確保と「大きい」確保を分けるサイズの境界を決めます。 sbctの値より大きい確保はシングルブロックキャリアを使い、より小さい確保はマルチブロックキャリアを使います。

デフォルトでは、典型的なErlangタームより著しく大きい確保がシングルブロックキャリアに隔離されるよう、sbctの閾値が設定されています。 確保のほとんどを占める小さなオブジェクトは、マルチブロックキャリア内の領域を効率よく共有します。

アプリケーションが特異な確保パターンを示す場合、この閾値(sbct)は調整できます。 調整すると、マルチブロックキャリア内の大きな確保によるフラグメンテーションと、シングルブロックキャリアが多数できることによるオーバーヘッドとの間で、ランタイムシステムがどうバランスを取るかが変わります。

確保の大半が小さいことが望ましい理由(マルチブロックキャリア)

マルチブロックキャリアが好まれるのは、典型的なErlangのワークロード、つまり小さく短命な確保が多数を占める状況で効率がよいためです。 HiPEチームの調査によると、Erlangタームの大半は小さく(8ワード未満)、マルチブロックキャリアにきれいに収まります。

典型的なERTSアロケータは、通常次のものを維持します。

  • 頻繁に発生する小サイズの確保を扱う、アロケータごとのメインマルチブロックキャリアが1つ。このキャリアはめったに解放されず、フラグメンテーションを減らすために内部で解放済みブロックを再利用します。
  • 大きなオブジェクトやバイナリのために個別に確保される、複数のシングルブロックキャリア。これらのブロックが解放されると、シングルブロックキャリアはただちにOSへ返却されます。

メモリレイアウトの図解: キャリアとブロック

図で示すと、次のようになります。

ヒープの先頭・末尾ポインタと、キャリア・ブロックの詳細な構造 図: ヒープの先頭・末尾ポインタ(新ヒープと旧ヒープ)、およびキャリア・ブロックの詳細な構造

sbctを調整すべきとき

singleblock carrier threshold(sbct)パラメータを最適化するには、アプリケーションのメモリ確保パターンを理解する必要があります。 sbctを大きくすると、より多くの確保がマルチブロックキャリアに向かい、メモリの再利用が改善してフラグメンテーションが減ります。 これは、アプリケーションが中程度サイズのデータ構造を頻繁に確保し、フラグメンテーションやOSレベルの頻繁なメモリ要求を引き起こしている場合に、とくに有効です。

一方、sbctを小さくすると、より多くの確保がシングルブロックキャリアに追いやられます。これは、アプリケーションがときおり大きなメモリブロックを確保する場合に理にかなっています。 こうした大きな確保を切り分けて管理することで、回収が単純になり、小さな確保への干渉も防げます。

sbctの調整だけでは頻繁なマイナーガベージコレクション(多くの場合、短命な確保が多数あることが原因です)が解消しない場合は、プロセスの初期ヒープサイズ(min_heap_size)を大きくして、確保の入れ替わりを減らすことを検討してください。

実際には、sbctのデフォルト設定はほとんどのErlangアプリケーションに適しています。 プロファイリングによってフラグメンテーションやメモリオーバーヘッド、特異な確保パターンといった具体的な問題が示された場合にのみ、このパラメータを微調整してください。

メモリ確保戦略

マルチブロックキャリアの中から空きブロックを見つけるには、確保戦略が使われます。 アロケータの種類ごとにデフォルトの確保戦略がありますが、asフラグで確保戦略を指定することもできます。

Erlang Run-Time System Application Reference Manualには、次の確保戦略が挙げられています。

Best fitbf):要求されたブロックサイズを満たす最小のブロックを探します。

Address order best fitaobf):要求されたブロックサイズを満たす最小のブロックを探します。複数見つかった場合は、アドレスがもっとも低いものを選びます。

Address order first fitaoff):要求されたブロックサイズを満たす、アドレスがもっとも低いブロックを探します。

Address order first fit carrier best fitaoffcbf):要求されたブロックサイズを満たす、アドレスがもっとも低いキャリアを探し、そのキャリア内で"best fit"戦略によってブロックを探します。

Address order first fit carrier address order best fitaoffcaobf):要求されたブロックサイズを満たす、アドレスがもっとも低いキャリアを探し、そのキャリア内で"address order best fit"戦略によってブロックを探します。

Good fitgf):最良のフィットを探そうとしますが、限られた探索の中で見つかった最良のもので手を打ちます。

A fitaf):フィットを探索せず、1つの空きブロックだけを調べて要求を満たすか確認します。この戦略は一時的な確保にのみ使うことを意図しています。

erts_allocより)

確保戦略の選択は、空きメモリがどれだけ効率的に再利用されるかを左右し、フラグメンテーションと性能に直接影響します。 ほとんどのアロケータでは、メモリ利用率と確保速度のバランスを取るbest fit(bf)かaddress order best fit(aobf)がデフォルト戦略になっていることが多いです。

address order first fitやgood fitのような別の戦略も、アロケータごとに(+M<S>as <strategy>というシステムフラグで)設定できます。 別の戦略を選ぶことで、確保時の探索によるCPUオーバーヘッドが増える代わりに、メモリのフラグメンテーションを緩和できる場合があります。

一時アロケータ: temp_alloc

temp_allocアロケータは、一時的な確保、つまり非常に短命な確保に使われます。 temp_allocで確保したメモリは、Erlangプロセスのコンテキストスイッチをまたいで確保したままにしてはいけません。

temp_allocは、関数内で何らかの処理を行う際の小さなスクラッチ領域や作業領域として使えます。 Cスタックの延長だと考え、同じように解放してください。つまり安全のため、確保を行った関数から戻る前に、temp_allocで確保したメモリを解放します。 erl_alloc.typesには、エミュレータがErlangコードの実行を始める前にtemp_allocのブロックを解放すべきだという注意書きがあります。

そのアロケータと同じスケジューラ上で動くどのErlangプロセスも、ブロックが解放される前にErlangコードの実行を始めてはいけない点に注意してください。 つまり、BIFやNIFのトラップ(yield)をまたいで一時的な確保を使うことはできません。

デフォルトのR16 SMPシステムでは、スケジューラ数をNとするとtemp_allocアロケータはN+1個あります。 temp_allocは"A fit”(af)戦略を使います。temp_allocの確保パターンは基本的にスタックと同じ(ほとんどがサイズ0か1)なので、この戦略でうまく機能します。

R16では、一時アロケータは次の種類のデータに使われています。TMP_HEAPMSG_ROOTSROOTSETLOADER_TEMPNC_TMPTMPDCTRL_BUFTMP_DIST_BUFESTACKDB_TMPDB_MC_STKDB_MS_CMPL_HEAPLOGGER_DSBUFTMP_DSBUFDDLL_TMP_BUFTEMP_TERMSYS_READ_BUFENVIRONMENTCON_VPRINT_BUF

各アロケータが確保する確保タイプの最新の一覧は、erl_alloc.typesを参照してください(例えばgrep TEMPORARY erts/emulator/beam/erl_alloc.types)。

これらの種類を1つずつ解説することはしませんが、名前から推測できるとおり、一般に一時バッファや作業スタックです。

ヒープアロケータ: eheap_alloc

ヒープアロケータは、プロセス固有のデータ、つまりプロセスヒープ(新世代・旧世代)に格納されるErlangタームや、ヒープフラグメント・beamレジスタといった関連構造のためのメモリブロックを管理します。 プロセスが生成するほぼすべてのErlangタームは、最終的にeheap_allocから得たメモリに置かれます。

デフォルトでは、各スケジューラにeheap_allocのインスタンスが1つあり、そのスケジューラ上で動くプロセスのメモリはほぼローカルに保たれ、競合が減ります。 このアロケータが頻繁にこなす仕事には、次のものがあります。

  • プロセスヒープ:各Erlangプロセスは、タプルやリスト、マップ、整数、小さなバイナリ(64バイト以下)などのデータを格納する自分専用のヒープを持ちます。
  • ヒープフラグメント:プロセスが一時的にもっと多くのメモリを必要としているのにすぐにはGCできない場合(大きなメッセージを組み立てている最中など)、VMはeheap_allocから「ヒープフラグメント」を確保することがあります。次のガベージコレクションで、これらのフラグメントは統合されるか解放されます。
  • レジスタ配列:一部のランタイム実装の詳細(「beam_registers」データ構造など)もeheap_allocを使います。Erlang開発者としては、プロセスヒープのサイズを制御する、あるいはプロセスがいつ大きく短命なデータを生成するかを把握することで、eheap_allocの使われ方を最適化するのが通例です。頻繁なマイナーGCを許すよりも、大きなデータを頻繁に扱うプロセスのヒープを注意深くサイジングしておくのがよい習慣です。

バイナリアロケータ: binary_alloc

バイナリアロケータは(察しのとおり)バイナリ用のメモリを扱います。 具体的には64バイトより大きいバイナリ(refcバイナリと呼ばれます)を管理し、オフヒープに格納したうえで参照カウントによって使用状況を追跡します。 各プロセスヒープが保持するのは、実体のバイナリを指す小さなラッパー(ProcBin)だけです。

これらのバイナリのサイズはさまざまで、数百バイト程度のものから、ファイル全体の内容やネットワーク経由の外部データのようにメガバイト単位の巨大なものまであります。

このアロケータには、いくつか興味深い特徴があります。

  • Best-Fitな戦略:新しいバイナリを格納するのに最適な最小の空きブロックを選ぶため、フラグメンテーションを抑えられます。パズルのピースをきっちり収めるテトリスの名手のようなものだと考えてください。
  • 参照カウント:オフヒープのバイナリは、最後の1つのプロセスが参照を止めるまで残り続けます。忘れっぽいプロセスが1つでも巨大なバイナリを握ったままだと、望ましいより長く生き延びてしまうことがあります。
  • サブバイナリ(スライス)<<X:32, Rest/binary>>のようにバイナリをマッチさせると、小さいほうのバイナリ(Rest)は元の大きなバイナリを参照し続けます。コピーを避けられるのは利点ですが、ほんの一部を参照するために巨大なバイナリを生かしたままにしてしまっていた、と気づくこともあります。そのような場合はbinary:copy/1を呼ぶとよいでしょう。

メモリが原因不明に増え続ける場合は、プロセスに残ったままの参照によって大きなバイナリが居座っていないか確認してください。 ときには、erlang:garbage_collect/1でガベージコレクタにそっと働きかけたり、プロセスをハイバネートさせたりして、メモリの回収を早める必要があるかもしれません。

ETSアロケータ: ets_alloc

ets_allocアロケータは、おなじみのETS(Erlang Term Storage)テーブル用のメモリを管理します。 デフォルトでは、ETSテーブルは個々のプロセスに結び付けられていないため、プロセスが自分のガベージを片付けても、ETSテーブルはそのまま残ります。 いくつか重要な点を押さえておいてください。

  • 長命なデータ:ETSテーブルにデータを置くと、そのデータはプロセスヒープを離れ、専用のアロケータに落ち着きます。通常のプロセスのガベージコレクションでは、この領域は片付けられません。メモリを取り戻したいなら、明示的にデータを削除するかテーブルを削除する必要があります。
  • 多様な用途:このアロケータは、古典的なハッシュテーブルから凝ったordered_set構造、内部のメタデータまで、ETSに関するあらゆるものを扱います。人気のあるテーブルや大きなテーブルは、あっという間にメモリを大量消費する存在になりかねないので注意してください。
  • 短命なETSデータ:ETSは、マッチングや中間結果のような手早い作業のために、他のアロケータを一時的に借りることがあります。しかし主なデータはets_allocに置かれます。

ETSテーブルは驚くほど大きくなることがあるので、ときどきets:info(Tab, memory)のような関数でサイズを確認するか、システムメトリクスを通じてets_alloc全体の使用量に目を配ってください。

ドライバアロケータ: driver_alloc

ドライバアロケータは、ポートやリンクインドライバ、NIFリソース用のメモリを扱います。 平たく言えば、I/Oドライバや外部ライブラリ、ファイルディスクリプタとやり取りするなど、Erlangの安全網の外側に手を伸ばすときには、ここに行き着きます。 押さえておきたい点は次のとおりです。

  • ポートとドライバのデータ:ここでの確保には、ネットワークソケットや開いたファイルディスクリプタの構造体、リンクインドライバ固有のバッファが含まれます。
  • NIFが確保するデータ:NIFがenif_allocで手を伸ばすと、そのメモリは最終的にこのアロケータから来ます。VMは、enif_freeが呼ばれるか、NIFオブジェクトがステージから退場するまで、律儀に待ってからこの領域を回収します。
  • 外部起因のリークの可能性:NIFやドライバはErlangの通常のメモリ安全ルールを迂回するため、行儀の悪いドライバが意図せずメモリを握ったままになり、デジタル版の水漏れ蛇口のような状態を生むことがあります。driver_allocの使用量に目を配っておくと、こうした漏れに気づきやすくなります。

開発者がdriver_allocを直接操作することはめったにありませんが、とくにNIFを使っている場合は、本番環境でこのアロケータを監視しておくのが賢明です。

短命アロケータ: sl_alloc

短命アロケータ(sl_alloc)は、一時的な確保のような瞬き一つで見逃すほどの寿命よりは長いものの、それほど長くはないデータ構造のためのメモリを扱います。 挨拶を交わしてさっとコーヒーを飲んだら立ち去る程度に居座るメモリ、といったイメージです。典型的な例には次のものがあります。

  • 中間バッファ:短い処理のために必要な小さなバッファで、スケジューリングポイントをいくつかまたいで短く居座りますが、一晩泊まっていくつもりは決してありません。
  • 一時的なリスト:短命なシステムメッセージバッファや短命なスケジューリングメタデータのような、一時的なランタイム構造です。気づいたときにはもう消えています。

Erlang/OTPは、マッチ状態オブジェクトや一時的なI/Oバッファなど、つかの間の処理にsl_allocを活用しています。 これらの確保は本当に一時的なメモリよりは長生きしますが、それでも速やかに立ち去り、痕跡をほとんど残さないことが期待されています。

アプリケーションがとりわけおしゃべりで(頻繁な小さいドライバ呼び出しや短い計算など、短命な確保を大量に生成する場合)、sl_allocがかなり熱くなることがあります。 recon_alloc:usage()のようなツールで使用状況を確認すれば、sl_allocが使われすぎていないかわかります。

長命アロケータ: ll_alloc

長命アロケータは、Erlangノード自体と同じくらい長く、長期間生き続けることを意図したデータを扱います。典型的な例には次のものがあります。

  • アトム:アトムは一度作られると無期限に残り続け、ll_allocの永続的な住人になります。
  • ロード済みモジュールとコード:コンパイル済みモジュールやエクスポートされた関数、無名関数(fun)に関するメタデータもここに格納されます。例えばcode:load_file(my_module)でモジュールをロードすると、そのメタデータはll_allocに置かれます。
  • スケジューラとシステムの構造体:スケジューラの実行キュー(run_queue)やpollset情報(pollset)、プロセスレジストリ(proc_tab)といった内部ランタイム構造は、VMの動作に不可欠なのでここに置かれます。

ll_allocにあるオブジェクトは居座りがちなので、このアロケータは通常、ノードの生存期間を通じてゆっくりとですが継続的に増えていきます。 システムが頻繁にモジュールをロード・アンロードしていると、変動が見られることがあります。例えば、古いバージョンを適切にアンロードしないままホットコードロードを繰り返すと、ll_allocのメモリ使用量が徐々に膨らんでいくことがあります。

実際には、明示的にモジュールをアンロードするかノードを再起動しない限り、ほとんどのシステムはここからあまりメモリを取り戻せません。 そのため、とくにアプリケーションが動的にモジュールや大量の長期データを管理している場合は、recon_alloc:usage(ll_alloc)をときどき確認して、予想外のスパイクやフラグメンテーションがないか見ておくのが賢明です。

固定サイズアロケータ: fix_alloc

固定アロケータfix_allocは、固定サイズのオブジェクト、つまり典型的にはサイズが変わらない小さなC構造体(メッセージ参照、ドライバイベントデータ、モニタなど)の確保に特化しています。 これらのオブジェクトは一様なサイズなので、アロケータは効率よく扱えます。

デフォルトでfix_allocは"Address Order Best Fit"を使い、解放されたオブジェクトを同じサイズのブロックのリストへきちんと戻すことで、フラグメンテーションを効果的に抑えます。 違うサイズのピースを箱に無造作に放り込むのではなく、同じ形のレゴブロックをきれいに積み重ねるようなものです。

例としては、ErlMessageのような内部VM構造体、モニタの参照、スケジューラの管理用データなどがあります。

開発者がfix_allocと直接やり取りすることは通常ありませんが、システムレベルでは欠かせない存在です。 ここでの効率的な確保のおかげで、ランタイムは細かく断片化した確保の管理に足を取られずに済み、Erlangが並行処理を優雅にこなすという評判を保つ助けになっています。

標準アロケータ: std_alloc

メモリがErlangの特化アロケータのどれにもきれいに当てはまらない場合、std_allocに居場所を見つけます。ランタイムの何でも屋的な受け皿アロケータです。 Erlangにおけるメモリの「雑多引き出し」だと考えてください。

このアロケータは、雑多な種類の確保を扱います。短命か長命か曖昧な一時データへの参照、動的にサイズが決まる構造体、明確に分類できないVMサブシステムのデータ、あるいは単に他のどこにもきれいに分類できないほど独特なメモリ確保などが含まれます。

他のアロケータと同様、+Ms+Msbctのような起動時フラグでstd_allocの挙動を調整できます。 普段は放っておいて構いませんが、必要なときには調整できることを知っておくとよいでしょう。

トラブルシューティングでは、std_allocが原因不明のメモリスパイクの第一容疑者になることがあります。 erlang:system_info({allocator, std_alloc})recon_allocのようなツールを使えば、想定より多くのメモリを抱え込んでいないかすぐにわかります。

std_allocはきれいに分類できないメモリ要求を集めるため、忙しいノードではかなりの使用量を蓄積するのが普通です。 このアロケータが一貫して増え続けているのを見たら、通常はアプリケーションの挙動を再確認するか、何が「普通」かという前提を見直すべきサインです。

リテラルアロケータ: literal_alloc

literal_allocは、しばしばリテラルプールと呼ばれる、ロード済みErlangモジュール内のコンパイル時定数を格納します。 コンパイル時に定義された大きな静的バイナリやタプル、リストのような定数を、Erlangが安全に保管しておくVMの「読み取り専用メモリ棚」だと考えてください。

典型的なアロケータと違い、literal_allocはスケジューラごとではなくグローバルに管理されます。リテラルは実行中に頻繁に変更されたり回収されたりしないためです。 一度ロードされたリテラルは、対応するモジュールが明示的にパージまたはリロードされるまで残り続けます。

literal_allocの監視は普段は特に何も起きませんが、モジュールの動的な更新が頻繁だったり、リテラルの多い大きなモジュールを繰り返しロードしたりする場合は、様子を見ておく価値があります。

スケジューラごとのアロケータインスタンス

現代のOTP(R13B以降)では、ここまで説明してきたメモリアロケータの大半(eheap_allocbinary_allocets_allocなど)が複数のインスタンスを持ち、SMP(対称型マルチプロセッシング)環境での競合を最小限に抑えます。 デフォルトでは、各スケジューラスレッドがこれらのアロケータの自分専用のインスタンスを1つ持ち、さらにドライバの非同期スレッドが共有する追加のインスタンスが1つあります。

このスケジューラごとの確保方式によってメモリ管理が分割され、ロックの競合が大幅に減り、マルチコアシステムでのスケーラビリティが向上します。 これはまた、メモリ使用量とフラグメンテーションがスケジューラごとに別々に扱われることも意味しており、メモリ統計をどう解釈するか、メモリ関連の問題をどうトラブルシューティングするかに影響することがあります。

アロケータのメトリクスを調べるとき(recon_allocerlang:system_infoを通じてなど)、複数のアロケータインスタンスにまたがって集計された統計を目にします。 メモリパターンを分析する際は、この点に注意してください。

メモリ用のシステムフラグ

メモリアロケータのシステムフラグは、次のような構文に従います。

+M<S><P> <V>
  • <S>はアロケータを識別する大文字1文字です。
  • <P>はパラメータを指定します。
  • <V>は使用する値を指定します。

アロケータの識別子(<S>):

  • B:binary_alloc
  • D:std_alloc
  • E:ets_alloc
  • F:fix_alloc
  • H:eheap_alloc
  • I:literal_alloc
  • L:ll_alloc
  • M:mseg_alloc
  • R:driver_alloc
  • S:sl_alloc
  • T:temp_alloc
  • Y:sys_alloc
  • u:alloc_util(alloc_utilベースのすべてのアロケータに影響)

よく使われるフラグ

確保戦略(as):

キャリア内でどうメモリブロックを選ぶかを決めます。

  • bf:Best fit
  • aobf:Address order best fit
  • aoff:Address order first fit
  • aoffcbf:Address order first fit carrier best fit
  • ageffcbf:Age order first fit carrier best fit
  • gf:Good fit
  • af:A fit

例。

+MBas bf

(バイナリアロケータがbest-fit戦略を使います。)

Singleblock Carrier Threshold(sbct): 確保がシングルブロックキャリアを使うようになる、KB単位の閾値を定めます。

例。

+MBsbct 1024

(1024KBを超えるバイナリの確保がシングルブロックキャリアを使います。)

マルチブロックキャリアの設定:

  • 最小(smbcs)・最大(lmbcs)マルチブロックキャリアサイズ:マルチブロックキャリアの最小・最大サイズ(KB)を制御します。
  • キャリア成長段階(mbcgs:最小値と最大値の間でのキャリアサイズの成長を定めます。

例。

+MBsmbcs 512 +MBlmbcs 8192

(バイナリアロケータのマルチブロックキャリアは512KBから8MBの範囲になります。)

Abandon Carrier Utilization Limit(acul): これを下回る使用率のキャリアが放棄され、再利用されるパーセンテージの閾値です。

例。

+MBacul 50

(使用率が50%未満のバイナリキャリアは放棄済みとしてマークされます。)

Abandon Carrier Free Utilization Limit(acful): この使用率を下回ると、VMは未使用メモリを回収してよいとOSに伝えます。

例。

+MDacful 10

std_allocは、使用率が10%未満ならメモリをOSが回収可能だとマークします。)

Multiple Thread-specific Instances(t): アロケータが(スケジューラごとに1つの)複数インスタンスを使うかどうかを制御します。

例。

+MHt true

eheap_allocがスケジューラスレッドごとに別々のアロケータインスタンスを使います。)

Allocation Tagging(atags): 確保にタグを付け、計装を使ったデバッグに役立ちます。

例。

+MRatags true

driver_allocのタグ付けを有効にします。)

mseg_alloc向けの特殊フラグ

mseg_alloc(メモリセグメントアロケータ)には、専用の設定があります。

  • Super Carrier Size(scs): +MMscs 1024(1GBのスーパーキャリアを作ります。)
  • Use Large Pages(lp): +MMlp on(ラージ/ヒュージページのサポートを有効にします。)
  • Super Carrier Only(sco): +MMsco true(確保はスーパーキャリア内でのみ行われます。)
  • Maximum Cached Segments(mcs): +MMmcs 20(キャッシュ済みセグメントを最大20個まで保持します。)

sys_alloc向けの特殊フラグ

sys_allocは、システムのmallocとやり取りします。

  • Trim Threshold(tt): 空きヒープが閾値を超えたときにメモリをOSへ返却します。+MYtt 256(トリム閾値を256KBに設定します。)
  • Top Pad(tp): その後の呼び出しを減らすため、mallocがOSに追加で要求する余剰メモリです。+MYtp 512(mallocが512KBのパディングを要求します。)

リテラルアロケータ(literal_alloc)

リテラル(コンパイル時定数)を格納します。

  • Literal Super Carrier Size(scs): +MIscs 2048(リテラルアロケータのスーパーキャリアを2GBに設定します。)

グローバルフラグと便利フラグ

  • Minimal/Maximal Allocation Setup(ea): +Mea min|max|config(全アロケータ向けの設定プリセットです。)
  • Lock Physical Memory(lpm): +Mlpm all|no(VMのメモリを物理RAMにロックします。)
  • Dirty Allocator Instances(dai): +Mdai max|<number>(ダーティスケジューラ専用のアロケータインスタンスです。)

実践的な例

全アロケータでaddress-order best fitを使い、フラグメンテーションを減らします。

+Muas aobf

ETSアロケータの最大メモリを制限します(例えば2GB)。

+MEamax 2097152

全アロケータでデバッグ用の確保タグ付けを有効にします。

+Muatags true

推奨事項

  • 問題が起きない限り、デフォルト設定から始めましょう。
  • チューニングの前に、erlang:system_info/1recon_allocでアロケータの使用状況を監視しましょう。
  • 変更は、制御された環境で少しずつ試しましょう。
  • ベンチマークとプロファイリングなしに、積極的なチューニングをしないようにしましょう。
ここまで説明してきたメモリアロケータのフラグの大半は、実装に強く依存しています。 その挙動や利用可否、デフォルト値は、事前の告知なしに変更されたり、完全に削除されたりすることがあります。 さらに、ランタイム(erts_alloc)は、内部のヒューリスティックやシステムの制約に基づいて、指定された設定を無視したり調整したりすることがあります。 設定は必ず、実際のシステムメトリクスとテストで検証してください。

プロセスメモリ

プロセスの章で見たように、プロセスとは実質的にいくつかのメモリ領域の集まりにすぎません。本章では、スタックとヒープ、そしてメールボックスがどのように管理されているかを、もう少し詳しく見ていきます。

スタックとヒープのデフォルトサイズは233ワードです。 このデフォルトサイズは、Erlangの起動時に+hフラグでグローバルに変更できます。 spawn_optでプロセスを起動する際にmin_heap_sizeを設定すれば、最小ヒープサイズを個別に指定することもできます。

型システムの章で見たように、Erlangのタームはタグ付けされており、ヒープに格納されるときはコンス(cons)セルかboxedオブジェクトのどちらかになります。

タームの共有

ヒープ上のオブジェクトは、1つのプロセスの中では参照によって受け渡されます。 タプルを引数にして関数を呼び出すと、そのタプルへのタグ付き参照だけが呼び出し先の関数に渡されます。 新しいタームを組み立てるときも、サブタームへの参照だけを使います。

例えば文字列"hello"(整数のリスト[104,101,108,108,111]と同じです)があるとき、メモリレイアウトはおおよそ次のようになります。

リスト"hello"のメモリレイアウト 図: 変数Lに束縛されたリスト"hello"のメモリレイアウト

このリストを2つ含むタプルを作ると、繰り返されるのはリストへのタグ付きポインタ、つまり00000000000000000000000010000001だけです。次のコードを考えます。

L = [104, 101, 108, 108, 111],
T = {L, L}.

これは下図のようなメモリレイアウトになります。Tはアドレス136のboxedオブジェクトを指しており、そこにはこれがサイズ2のタプルであることを示すARITYVALヘッダーがあり、続く2つの要素はどちらもアドレス128にある同じリストLを指しています。

タプルT={L,L}のメモリレイアウト 図: 同じリストを共有するタプルT={L,L}のメモリレイアウト

これは安上がりで、使用する領域もごくわずかなので都合がよい仕組みです。しかしこのタプルを別のプロセスに送ったり、何らかのIOを行ったり、ディープコピーと呼ばれる操作を行ったりすると、データ構造は展開されてしまいます。 例えばタプルTを別のプロセスP2に送ると(P2 ! T)、P2のヒープには、1つ目の要素が文字列の一方のコピーを指し、2つ目の要素がもう一方のコピーを指すタプルができ、使用する領域が倍になります。この結果は、後述するメッセージ送信の節で確認できます。

入れ子になった共有タプルがある場合、ディープコピーによるこの複製はネストの深さに応じて指数的に増えていきます。 高度に共有されたタームを展開させることで、あっという間にErlangノードを落とすこともできます。完全なコード例は、オンライン付録のshare.erlを参照してください。

-module(share).

-export([share/2, size/0]).

share(0, Y) -> {Y,Y};
share(N, Y) -> [share(N-1, [N|Y]) || _ <- Y].

size() ->
    T = share:share(5,[a,b,c]),
    {{size, erts_debug:size(T)},
     {flat_size, erts_debug:flat_size(T)}}.



 1> timer:tc(fun() -> share:share(10,[a,b,c]), ok end).
 {1131,ok}

 2> share:share(10,[a,b,c]), ok.
 ok

 3> byte_size(term_to_binary(share:share(10,[a,b,c]))), ok.
 HUGE size (13695500364)
 Abort trap: 6

共有タームのメモリサイズと、展開した場合のサイズは、erts_debug:size/1erts_debug:flat_size/1という関数で計算できます。

> share:size().
{{size,19386},{flat_size,94110}}

ほとんどのアプリケーションではこれは問題になりませんが、さまざまな場面で起こりうる問題として知っておくべきです。 ディープコピーは、IO、ETSテーブル、binary_to_term、そしてメッセージ送信で使われます。

ERTSは--enable-sharing-preservingという設定オプション付きでビルドすることができ、これによりVMはこうした場面で共有タームを検出し、保持するようになります。ただし共有がない通常のケースでメッセージ送信がわずかに遅くなるため、デフォルトでは有効になっていません。 これをデフォルトモードにすべきだという意見もあります。頻繁には起きないとしても、非常にまずい事態を防げるからです。Erlangをソースからビルドする方法は、ERTSのビルドの章を参照してください。

それでは、メッセージ送信がどのように動くのかを、もう少し詳しく見ていきましょう。

メッセージ送信

プロセスP1が別の(ローカルな)プロセスP2にメッセージMを送るとき、P1はまずMのフラットサイズを計算します。 そのうえで、ローカルスケジューラのコンテキストでヒープフラグメントをheap_allocすることで、そのサイズの新しいメッセージバッファを確保します。

send.erl(オンライン付録を参照)のコードを例にすると、p1/1での送信の直前、システムの状態はおおよそ次のようになります。

送信直前のレジスタとメモリの状態 図: p1/1実行中、送信直前のレジスタとメモリの状態

ここでP1はP2へのメッセージMの送信を開始します。erl_message.cのコードは、まずMのフラットサイズ(この例では23ワード)を計算します1。 そのうえで(SMPシステムでは)P2にロックをかけられて、かつP2のヒープに十分な空きがあれば、メッセージをP2のヒープにコピーします。

P2が実行中(または終了処理中)であるか、ヒープに十分な空きがない場合は、新しいヒープフラグメントが確保されます(サイズはsizeof ErlHeapFragment - sizeof(Eterm) + 23*sizeof(Eterm)2。初期化後は次のようになります。

erl_heap_fragment:
    ErlHeapFragment* next;       NULL
    ErlOffHeap off_heap:
      erl_off_heap_header* first;   NULL
      Uint64 overhead;                 0
    unsigned alloc_size;          23
    unsigned used_size;               23
    Eterm mem[1];              ?
      ... 22 free words

続いて、メッセージはヒープフラグメントのmem部分にコピーされ、firstポインタが更新されます(この図では、構造体のレイアウトに合わせてメモリアドレスが下方向に増えることに注意してください)。

ヒープフラグメントへコピーされたメッセージ 図: ヒープフラグメントへディープコピーされたメッセージ(タプルの2要素それぞれに、リストの複製ができる)

どちらの場合でも、新しいmbox(ErlMessage)が確保され、受信側に対してロック(ERTS_PROC_LOCK_MSGQ)が取られ、ヒープ上または新しいヒープフラグメント内のメッセージがmboxにリンクされます。

erl_mesg {
   struct erl_mesg* next = NULL;
   data:  ErlHeapFragment *heap_frag = bp;
   Eterm m[0]            = message;
} ErlMessage;

そのmboxは受信側の受信キュー(msg_inq)にリンクされ、ロックが解放されます。 msg_inq.lastは、キュー内の最後のメッセージのnextフィールドを指していることに注意してください。新しいmboxがリンクされると、このnextポインタは新しいmboxを指すように更新され、lastポインタは新しいmboxのnextフィールドを指すように更新されます。

バイナリ

型システムの章で見たように、内部的にはバイナリには4種類あります。 そのうち3つ、ヒープバイナリサブバイナリマッチコンテキストは、ローカルヒープに格納され、他のオブジェクトと同じようにガベージコレクタとメッセージ送信によって扱われ、必要に応じてコピーされます。

参照カウント

一方、4つ目の種類である大きなバイナリ、すなわちrefcバイナリは、プロセスヒープの外側に部分的に格納され、参照カウントされます。

refcバイナリのペイロードは、バイナリアロケータが確保したメモリに格納されます。 そのペイロードへの小さな参照であるProcBinは、プロセスヒープに格納されます。この参照はメッセージ送信やGCによってコピーされますが、ペイロード自体には触れられません。 そのため、バイナリ全体をコピーする必要がなく、大きなバイナリを他のプロセスに送るのが比較的安上がりになります。

ProcBinを通じたrefcバイナリへの参照は、そのたびにバイナリの参照カウントを1つ増やします。 プロセスヒープ上のすべてのProcBinオブジェクトは、連結リストでつながっています。GCが走ると、このリストがたどられ、死んだProcBinごとにバイナリの参照カウントが1つ減らされます。 refcバイナリの参照カウントが0になると、そのバイナリは解放されます。

大きなバイナリが参照カウントされ、送信やガベージコレクションでコピーされないのは大きな利点ですが、ガベージコレクションと参照カウントが混在する環境には1つ問題があります。 純粋な参照カウント方式の実装であれば、オブジェクトへの参照が死んだ瞬間に参照カウントは減り、参照カウントが0になった時点でオブジェクトは解放されます。 しかしERTSの混在環境では、参照カウントされたオブジェクトへの参照は、ガベージコレクションがその参照の死を検出するまで死んだことになりません。

つまり、大きい、あるいは巨大になりがちなバイナリが、すべての参照が死んだあとも長い間居座ることがあるということです。 バイナリはグローバルに確保されるため、すべてのプロセスからのすべての参照が死ぬ必要がある、つまりそのバイナリを見たことのあるすべてのプロセスがGCを行う必要がある点に注意してください。

残念ながら、GCの意味での「見た」という観点で、どのプロセスがそのバイナリを見たことがあるのかを、開発者が見極めるのは必ずしも簡単ではありません。 例えば、作業アイテムを受け取ってワーカーに振り分けるロードバランサーがあるとします。

次のコードには、GCを行う必要のないループの例があります(完全な例はオンライン付録を参照してください)。

loop(Workers, N) ->
  receive
    WorkItem ->
       Worker = lists:nth(N+1, Workers),
       Worker ! WorkItem,
       loop(Workers, (N+1) rem length(Workers))
  end.

このサーバーは、バイナリへの参照をつかんだままひたすら増やし続け、決して解放しないため、最終的にシステムメモリをすべて使い切ってしまいます。

この問題に気づいてさえいれば、直すのは簡単です。loopの各繰り返しでgarbage_collectを呼ぶか、receiveにafter節を加えて5秒おきくらいに呼べばよいのです(after 5000 -> garbage_collect(), loop(Workers, N))。

サブバイナリとマッチング

バイナリの一部をマッチさせると、サブバイナリが得られます。 このサブバイナリは、実体のバイナリを指すポインタだけを含む小さな構造体です。バイナリの参照カウントは増えますが、追加で使う領域はごくわずかです。

もしマッチのたびにマッチした部分のコピーを新たに作るとしたら、領域と時間の両方でコストがかかります。 そのため、ほとんどの場合、バイナリに対してパターンマッチを行い、作業対象としてサブバイナリを得るというやり方が望ましいのです。

ただし困った状況もあります。例えば本のような巨大なファイルをメモリに読み込み、そこから章のような小さな部分をマッチさせて作業するとします。 すると、その章の処理が終わるまで、本の残り部分もまるごとメモリに保持されたままになるという問題が起きます。 これをファイルシステム上の多数の本に対して行い、例えばすべての本の序文を取り出したいとすると、序文の章だけでなく本1冊ずつがまるごとメモリに残ってしまいます。これは莫大なメモリ使用量につながりかねません。

大きなバイナリのごく一部だけが欲しく、その小さな部分をしばらく保持しておきたいとわかっている場合の解決策は、binary:copy/1を使うことです。 この関数は、その副作用、つまりサブバイナリを実体のバイナリから実際にコピーして取り出し、大きなバイナリへの参照を取り除くことで、うまくいけばガベージコレクトされるようにするためだけに使われます。

バイナリの構築とマッチングがどのように行われるかについては、Erlangのドキュメントにかなり踏み込んだ説明があります。

その他の興味深いメモリ領域

アトムテーブル

Erlangのアトムは、内部的には整数で表現される一意な識別子です。すべてのアトムは、アトムテーブルと呼ばれるグローバルな構造体に格納されます。 アトムテーブルは固定サイズの構造体であり、実行中のErlangシステムに存在できるアトムの数には上限があります(デフォルトで1,048,576個)。 大きな数に聞こえるかもしれませんが、不用意な使い方(とくに外部データから動的にアトムを生成する場合)はアトムの枯渇を招き、BEAM VM全体をクラッシュさせます。夜中に思いがけずレゴブロックを踏んでしまうのと同じくらい、愉快とは言えない出来事です。

アトムテーブルの各エントリには、アトムに関するメタデータが含まれます。文字列表現(アトムそのもののテキスト)、ランタイムシステムが使う一意な内部識別子、そして参照カウントやモジュール・関数での使用状況といった追加情報です。

Erlangは、3つの主要なメモリアロケータ種類を通じてアトムを管理しています。

  • atom_text:アトムの文字列表現を格納します。この領域には、アトムの実際のテキストが保存されます。
  • atom_tab:アトムテーブル自体、つまり高速な検索のためのハッシュテーブル構造を格納します。
  • atom_entry:各アトムのメタデータ(内部表現、使用回数など)のためのメモリを確保します。

アトムはガベージコレクトされません。一度作られたアトムは、VMがシャットダウンするまで残り続けます。 この設計上の判断は実装を単純化し(検索性能も高めます)ますが、リスクも伴います。制御されないアトム生成(list_to_atom/1のような関数を通じた動的なアトム生成が典型的です)は、アトムテーブルの枯渇につながりかねません。 アトムの上限に達すると、新しいアトムを作ろうとしたときにランタイムエラーが発生し、ノードが落ちることもあります。

問題が起きやすいのは、ユーザー提供のデータを直接アトムに変換する、信頼できない大量の外部入力をパースする、ループや再帰関数の中でアトムを繰り返し生成するなど、アトムが不用意に、あるいは動的に作られる場面です。 こうした状況はアトムテーブルの急速な増大につながり、アトムの上限を使い切って深刻なシステム障害を招きかねません。

これを避けるには次のようにします。

  • アトムに変換する前に、ユーザー入力を必ず検証するか許可リストで絞り込みます。
  • できる限り既存のアトムを使い、動的な識別子がどうしても必要ならバイナリや文字列を優先します。
  • ObserverのようなツールやAtomsに関する組み込み関数を使って、アトムテーブルの使用状況を定期的に監視します。
    • アトム数erlang:system_info(atom_count)):現在ロードされている一意なアトムの数。
    • アトムメモリerlang:memory(atom)):オーバーヘッドを含む、アトムが使う総バイト数。
    • アトム使用メモリerlang:memory(atom_used)):実際のアトム文字列が使うバイト数のみ。

Erlangシェルから簡単に確認するだけで、手早く状況がわかります。

1> erlang:system_info(atom_count).
34319
2> erlang:system_info(atom_limit).
1048576
3> erlang:memory(atom).
336049
4> erlang:memory(atom_used).
324520

アトムの上限に達してしまった場合、実践的な解決策が2つあります。

アトムテーブルのサイズを増やす(ただし、これは一般に短期的な応急処置であり、アトムの適切な扱いに代わるものではありません)。

erl +t <new_max_atoms>

アプリケーションを設計し直す:典型的にはバイナリや文字列、整数の識別子を代わりに使うことで、アトムの無制限な生成を避けます。

このように、アトムとその管理には注意が必要です。誤った使い方は安定性の問題を引き起こしますが、正しく扱えば、システム全体で静的で既知のキーや識別子を参照する、きわめて効率的な手段であり続けます。

アトム枯渇のリスクなしに文字列を安全にアトムへ変換するために、Erlangはlist_to_existing_atom/1関数を用意しています。 この関数は、そのアトムがすでに存在する場合にのみ成功します。この関数で新しいアトムを作ろうとすると、例外が発生します。

1> list_to_existing_atom("Hello").
** exception error: bad argument
in function  list_to_existing_atom/1
called as list_to_existing_atom("Hello")
*** argument 1: not an already existing atom

2> list_to_existing_atom("true").
true

コード領域

もう1つの重要なメモリ領域が、コンパイル済みErlangモジュールがロードされるコード領域です。 Erlangモジュールは、一度コンパイルされるとこのコード領域にロードされ、Erlangランタイムシステム内で動くすべてのプロセスの間で共有されます。 コード領域は基本的に静的で永続的です。モジュールは、(ホットコードロードによって)明示的にアンロードまたは置き換えられない限り、ロードされたままだからです。

l(Module)code:load_file(Module)のような関数でモジュールをロードまたは再ロードすると、古いコードはすぐには削除されず、どのプロセスからも参照されなくなるまで「古い」バージョンとして保持されます。 Erlangは各モジュールについて、同時に2つのバージョンを維持します。これにより、実行中のプロセスを中断させることなく、安全にアップグレードできます。

数値やアトム、バイナリといったErlangコード内で定義された定数は、モジュールのコードセグメント内にある定数プールに格納されます。 これらの定数は1つのモジュール内では一度しか格納されないため、メモリ使用の面で効率的です。 しかし、メッセージ送信やETSテーブルへの挿入など、モジュールの外で定数が使われる場合は、受け取る側のプロセスのヒープにコピーされ、全体のメモリ使用量を大きく増やすことがあります。

コードのメモリ使用量を監視し管理することは、とくにホットコードアップグレードを頻繁に行う長時間稼働のシステムでは欠かせません。 code:all_loaded/0のような組み込み関数でロード済みモジュールとその状態を調べられ、erlang:memory(code)でロード済みモジュールとその定数による総メモリ使用量を監視できます。

1> erlang:memory(code).
6378630

  1. ここではトレーシングを無視しています。トレーシングを行うと、メッセージのサイズにトレーストークンが加わり、常にヒープフラグメントを使うことになります。 ↩︎

  2. -sizeof(Eterm)という項は、ErlHeapFragmentmemがすでに1つのEterm分のサイズを持っていることに由来します。 ↩︎