Erlangランタイムシステム入門

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

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

厳密に言えば、BEAMという名前が指すのは、ErlangのコードをコンパイルしBEAMバイトコード命令として配布・実行するための抽象機械のモデルだけです。 JVMがJavaのための抽象機械であるのと同じ関係であり、仕様に準拠したVMの実装であれば、同じコンパイル済みバイトコードをどこでも実行できるはずです。

とはいえ、バイトコードを実行するだけの素朴なインタプリタや簡単なJITコンパイラを書くこと自体は、比較的小さな仕事にすぎません。 氷山の一角です。 実用に耐えるプログラミング言語に求められる水準を満たすには、自動メモリ管理、効率的なI/O、プロセススケジューリング、マルチコアの活用、OSシグナルの処理、ネットワーク、ファイルシステムとの連携、時刻の扱いなど、はるかに多くの仕組みが必要になります。 この部分、つまり水面下に隠れているすべての仕組みを指して「ランタイムシステム」と呼びます。 もっとも、カジュアルな会話では、BEAM VM(やJVM)という言葉に、動作を支えるすべてを含めた抽象機械という意味を持たせて使うことがよくあります。

Erlangランタイムシステム、すなわちERTSは、互いに依存し合う多数のコンポーネントからなる複雑なシステムです。 C言語で書かれており、ガムスティックサイズの小さなコンピュータから、テラバイト級のメモリを積んだ最大規模のマルチコアシステムまで、幅広い環境で動くように移植性を重視して実装されています。 この上で動くのがBEAM仮想マシンであり、詳しくはErlang仮想マシン: BEAMで説明します。

ERTSとErlangランタイムシステム

一般的な意味でのErlangランタイムシステムと、その特定の実装との間には違いがあります。 Ericsson社によるErlang/OTPは、Erlangランタイムシステムおよび BEAM の事実上の標準実装です。 本書ではこの実装をERTSと呼び、綴りを崩さずに書く場合は大文字のTを使ってErlang RunTime Systemと表記します(OTPの定義についてはERTSを参照してください)。

Erlangランタイムシステムとは何か、Erlang仮想マシンとは何かについて、公式な定義は存在しません。 ERTSから実装固有の詳細をすべて取り除けば、理想的なプラトン的システムがどのようなものになるか、なんとなく想像はできます。 しかし、これは循環定義になってしまいます。 実装固有の詳細を見分けるには、そもそも一般的な定義を知っている必要があるからです。 Erlangの世界では、たいていこの点を気にするよりも実用性を優先します。

本書では「Erlangランタイムシステム」という語を、Ericsson社による特定の実装(Erlang RunTime System、通称ERTS)とは区別して、Erlangランタイムシステム一般を指す言葉として使うことにします。

本書が扱うのは主にERTSという特定の実装であり、Erlangランタイムシステム一般について論じる部分はごくわずかです。 一般的な原理について述べていると明記されていない限り、Ericsson社の実装について話していると考えて、まず間違いありません。

本書の読み方

Erlang BEAM VMは、ガムスティックサイズの小さなコンピュータから、テラバイト級のメモリを積んだ最大規模のマルチコアシステムまで動く、優れたエンジニアリングの成果です。 こうしたシステム上でアプリケーションの性能を最適化するには、アプリケーション自体を理解しているだけでは足りません。 VM自体についても深く理解している必要があります。

本書の第1部では、ランタイムシステムがどのように動くのかを深く理解できます。

この知識があれば、アプリケーションがBEAM上でどう振る舞うかを理解できるようになり、性能上の問題を見つけて修正できるようにもなります。

本書の第2部では、ERTSの運用方法、つまり動作中のシステムに接続して調べる方法を見ていきます。

以降の章では、システムを構成する各コンポーネントを一つずつ取り上げていきます。 他のコンポーネントの実装を完全に理解していなくても、それぞれの章は単独で読めるように書かれています。 ただし、各コンポーネントが何であるかについての基本的な理解は必要です。 この導入章の残りの部分で、第1部の各章を好きな順序で読み進められるだけの基礎知識と語彙を身につけられるはずです。

とはいえ、時間があるなら、最初は本書を順番どおりに読むことをお勧めします。 ErlangやERTSに固有の言葉、あるいは本書独自の意味で使われている言葉は、たいてい最初に登場した箇所で説明しています。 語彙を身につけたあとであれば、特定のコンポーネントで問題に直面したときに、第1部をリファレンスとして読み返せます。

ERTS

この節では、ERTSを構成する主要なコンポーネントの概要と、以降の章で各コンポーネントの詳しい説明を理解するために必要な語彙を紹介します。

Erlangノード

ErlangやElixirのシステムを起動すると、Erlangランタイムシステムを実行するOSプロセスが1つ立ち上がり、そのOSプロセスの中でBEAM VMが多数のErlangプロセスを実行します。 こうして動いているランタイムシステムのインスタンスのことを、Erlangのノードと呼びます(ERTSを実行しているか、別のErlang実装を実行しているかは問いません。詳しくは別のErlang実装を参照してください)。 ノードには名前を付けられ、同じネットワーク上や同じホストマシン上で動く他のErlangノードと区別できます。 Javaの世界でこれに相当するのはJVMのインスタンスです。 ただし、Erlangには透過的な分散という考え方が標準で組み込まれており、Erlangのノードどうしをネットワーク越しに接続してクラスタを構成できます。

つまり、BEAM上で動くアプリケーションのコードは、常にErlangノードというコンテキストの中で実行され、ノードを構成するすべての層がアプリケーションの性能に影響します。 ここでは、ノードを構成する層のスタックを見ていきます。 これにより、さまざまな環境でシステムを動かす際の選択肢を理解できるようになります。

Erlang OTPのドキュメントに厳密に従うなら、ノードとは、名前を与えられクラスタに参加する準備ができている、実行中のランタイムシステムのことです。 つまり、コマンドラインスイッチ--name NAME@HOST--sname NAME(Erlangランタイムの場合は-name-sname)のいずれによっても名前を与えずにElixirを起動した場合、厳密にはランタイムはあってもノードは無い、ということになります。 そのようなシステムでは、関数Node.alive?(Erlangならis_alive())はfalseを返します。

$ iex
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
              [async-threads:10]  [kernel-poll:false]

Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Node.alive?
false
iex(2)>

ランタイムシステム自体は、この用語の使い方についてそれほど厳密ではありません。 名前を与えていなくても、ノードの名前を問い合わせることはできます。 Elixirでは引数:thisを渡した関数Node.listを使い、Erlangではnodes(this)、あるいは単にnode()を呼び出します。

iex(2)> Node.list :this
[:nonode@nohost]
iex(3)>

本書では、名前が与えられているかどうかにかかわらず、実行中のランタイムのインスタンスを指してノードという語を使います。

本書の第1部では、ERTSを構成するコンポーネントについてひととおり学びます。 第2部では、Erlangノードを動かし、調べ、デバッグし、プロファイルする方法を説明します。 Erlangをソースからビルドする方法については、Erlangのビルド方法を説明する付録を参照してください。

実行環境のレイヤー

アプリケーションは1つ以上のノード上で動作し、プログラムの性能は、アプリケーションのコードだけでなく、その下にあるERTSスタックのすべての層に左右されます。 ERTSスタックの図では、1台のマシン上で2つのErlangノードが動いている様子を示しています。

ERTSスタック。1台のマシン上でNode1とNode2が動き、それぞれAppからOTP、BEAM、ERTSまでの層を積み重ね、両ノードの下にOSとHWまたはVMの層を共有している

Elixirを使っている場合は、スタックにもう1層加わります。

Elixirスタック。ERTSスタックのApp層とOTP層の間にElixir層が加わっている

ここからは、スタックの各層を順に見ながら、アプリケーションの要件に応じてどのように調整できるかを確認していきます。

スタックの一番下にあるのが、実行環境となるハードウェアです。 アプリケーションの性能を上げる最も簡単な方法は、より良いハードウェアで動かすことでしょう。 しかし、コスト面や物理的な制約、あるいは環境への配慮からハードウェアをアップグレードできない場合は、スタックの上位層に目を向ける必要があります。

ハードウェアを選ぶうえでとくに重要な要素が2つあります。 マルチコアかどうかと、32ビットか64ビットかです。 マルチコアを使うかどうか、32ビットと64ビットのどちらを使うかによって、必要となるERTSのビルドは異なります。

スタックの2番目の層はOSです。 ERTSはWindowsのほとんどのバージョンと、Linux、FreeBSD、Solaris、Mac OS Xをはじめとする、たいていのPOSIX「準拠」OS上で動きます。 現在、ERTSの開発の大半はLinuxとOS X上で行われており、性能面ではこれらのプラットフォームが最も期待できます。 とはいえ、Ericsson社は社内の多くのプロジェクトでSolarisを使ってきた経緯があり、ERTSは長年にわたってSolaris向けにチューニングされてきました。 用途によっては、Solarisシステムで最も良い性能が出ることもあります。 OSの選択は、たいてい性能要件ではなく、他の制約によって決まります。 組み込みアプリケーションを作るならRaspbianに限定されるかもしれませんし、なんらかの事情でエンドユーザー向けやクライアント向けのアプリケーションを作るなら、Windowsを使わざるを得ないこともあるでしょう。 ERTSのWindows移植版はこれまで優先度が高くなく、性能や保守の観点から見て最良の選択とは言えないかもしれません。 64ビット版のERTSを使いたいのであれば、当然ながら64ビットのマシンと64ビットのOSの両方が必要です。 本書ではOS固有の話題は基本的に扱わず、ほとんどの例はLinux上での実行を前提にしています。

スタックの3番目の層がErlangランタイムシステムです。 本書ではERTSがこれにあたります。 この層と、4番目の層であるErlang仮想マシン(BEAM)こそが、本書全体のテーマです。

5番目の層であるOTPは、Erlangの標準ライブラリを提供します。 OTPはもともと「Open Telecom Platform」の略で、電話交換機のような堅牢なアプリケーションを構築するための部品(supervisorgen_servergen_tcpなど)を提供する、いくつものErlangライブラリの総称でした。 やがて、これらのライブラリとOTPという言葉の意味は、ERTSに同梱される他の標準ライブラリすべてと混同されるようになりました。 今日では、多くの人が「Erlang/OTP」という形でOTPをErlangとセットにして、ERTSとEricsson社が配布するすべてのErlangライブラリを指す名前として使っています。 これらの標準ライブラリを知り、それをいつどう使うべきかを理解していれば、アプリケーションの性能を大きく改善できます。 本書では標準ライブラリやOTPの詳細には立ち入りません。 これらを扱った書籍は他に数多くあります。

Elixirのプログラムを動かしている場合は、6番目の層としてElixirの実行環境とElixirのライブラリが加わります。

最後に、7番目の層(App)が、あなたのアプリケーションと、利用しているサードパーティ製のライブラリです。 アプリケーションは、下位の層が提供するすべての機能を利用できます。 ハードウェアのアップグレードを別にすれば、この層こそが、アプリケーションの性能を最も手軽に改善できる場所でしょう。 プロファイリングの章では、アプリケーションのプロファイリングと最適化に役立つヒントとツールを紹介します。 デバッグの章では、アプリケーションがクラッシュする原因の見つけ方や、バグの見つけ方を見ていきます。

分散

Erlangの設計者たちが得た重要な洞察の一つが、24時間365日稼働し続けるシステムを構築するには、ハードウェア障害に対応できなければならない、というものでした。 そのためには、システムを少なくとも2台の物理マシンに分散させる必要があります。 各マシンでノードを起動し、それらのノードを互いに接続すれば、プロセスは同じノード上で動いているのと同じ感覚で、ノードをまたいで通信できます。 分散についての詳細は、分散の章を参照してください。

分散アプリケーション。Node1からNode4までの4つのノードがそれぞれAppからERTSまでの層を積み重ね、Node1とNode2、Node3とNode4がそれぞれ組になってOSとHWまたはVMを共有し、4ノード全体がネットワークで結ばれている

Erlangコンパイラ

Erlangコンパイラは、.erlファイルに書かれたErlangのソースコードを、BEAM仮想マシンのコードにコンパイルする役割を担います。 コンパイラ自体もErlangで書かれたErlangアプリケーションであり、自分自身でBEAMコードへとコンパイルされます。 出荷されるErlangベースのシステム(リリース)にコンパイラが含まれるかどうかは、そのシステムがコードの実行だけでなくコンパイルもできる必要があるかどうかによります。

ランタイムシステムをブートストラップするために、Erlangのソース配布物には、コンパイラを含むいくつかのプリコンパイル済みBEAMファイルが、bootstrapディレクトリとerts/preloadedディレクトリに収められています。

コンパイラについて詳しくは、コンパイラの章を参照してください。

Erlang仮想マシン: BEAM

BEAMは、JavaのコードをJVMが実行するのと同じように、Erlangのコードを実行するための仮想マシンです。 BEAMのコードは、Erlangノードというコンテキストの中で実行されます。

BEAM: BEAMという名前は、もともとBogdan’s Erlang Abstract Machine(ボグダンのErlang抽象マシン)の略でした。 しかし今日では、現在のメンテナの名にちなんでBjörn’s Erlang Abstract Machine(ビョルンのErlang抽象マシン)の略だと言われることのほうが多くなっています。

ERTSがErlangランタイムシステムというより一般的な概念の一実装であるのと同じように、BEAMもまた、より一般的なErlang仮想マシン(EVM)の一実装です。 EVMが何を満たすべきかについての定義は存在しませんが、BEAMには実際には2つの水準の命令があります。 汎用命令固有命令です。 汎用命令セットは、EVMの設計図と見なすこともできるでしょう。

BEAMの詳しい説明は、BEAM以降の章にあります。

プロセス

Erlangのプロセスは、基本的にはOSのプロセスと同じように動作します。 各プロセスは、自分専用のプライベートメモリ(メールボックス、ヒープ、スタック)と、そのプロセスに関する情報を持つプロセス制御ブロック(PCB)を持っています。 Erlangのプロセスはスレッドとは違います。 書き換え可能な共有メモリを持たず、通信はメッセージを介してのみ行われるので、あるプロセスから見て、通信相手が同じVM上で動いているのか、ネットワークで接続された別のノード上で動いているのかは、ほとんど区別がつきません。 Erlangのプログラムには、ロックや保護区間は必要ありません。

Erlangのコードはすべて、プロセスというコンテキストの中で実行されます。 1つのErlangノードは多数のプロセスを持つことができ、それらはメッセージパッシングとシグナルを通じて通信します。 Erlangのプロセスは、ノードどうしが接続されている限り、別のErlangノード上のプロセスとも通信できます。

プロセスとPCBについてさらに詳しく知るには、プロセスの章を参照してください。

スケジューリング

スケジューラは、次に実行するErlangプロセスを選ぶ役割を担います。 スケジューラは基本的に2つのキューを持っています。 実行可能なプロセスを並べた実行可能キューと、メッセージの受信を待っているプロセスを並べた待機キューです。

スケジューラは実行可能キューの先頭からプロセスを取り出し、1タイムスライス分の実行をBEAMに委ねます。 タイムスライスを使い切ると、BEAMは実行中のプロセスをプリエンプトし、実行可能キューの末尾に戻します。 タイムスライスを使い切る前にプロセスがreceiveでブロックした場合は、代わりに待機キューに移されます。 待機キュー内のプロセスがメッセージを受信するか、タイムアウトすると、そのプロセスは実行可能キューに移されます。

Erlangは本質的に並行です。 つまり、概念上はすべてのプロセスが同時に動いていることになっていますが、実際にはスケジューラが一度に実行するのは1つのプロセスだけです。 そのため、シングルコアのマシンであっても、基盤となるOSがプリエンプションを持たなくても、BEAMは数十万個の並行プロセスを持つ同じErlangプログラムを、変わらず実行できます。

マルチコアのマシンでは、Erlangは自動的に複数のスケジューラを、別々のOSスレッド上で動かします。 通常は物理コア1つにつきスケジューラ1つで、それぞれが自分専用の実行キューを持ちます。 あるスケジューラの仕事がなくなると、他のスケジューラから実行可能なプロセスの一部を引き取ることができます。 こうしてErlangは、プログラマがそれを意識する必要なく、真の並列性を実現しています。

実際には、プロセス間の優先度もあり、待機キューはタイミングホイールを使って実装されているなど、話はもっと複雑です。 こうした詳細はすべて、スケジューリングの章で説明します。

Erlangのタグ方式

Erlangは動的型付け言語であり、ランタイムシステムは各データオブジェクトの型を把握しておく手段を必要とします。 これを実現しているのがタグ方式です。 データオブジェクト、あるいはデータオブジェクトへのポインタには、そのオブジェクトのデータ型を示すタグが付いています。

基本的には、ポインタの一部のビットがタグ用に予約されており、VMはそのタグのビットパターンを見ることで、オブジェクトの型を判定できます。

これらのタグは、パターンマッチや型検査、プリミティブな演算のほか、ガベージコレクタでも利用されます。

タグ方式の全体像は、型システムの章で説明します。

メモリ管理

Erlangは自動メモリ管理を採用しており、プログラマはメモリの確保や解放を気にする必要がありません。 各プロセスはヒープとスタックを持ち、どちらも必要に応じて伸び縮みします。 OSのスレッドにあるようなスタックサイズの上限や、スタック用にあらかじめ確保されたアドレス範囲を気にする必要もありません。 プロセスのスタックとヒープは、最初はどちらもごく小さいため、数千、あるいは数百万というプロセスを持つことができます。

プロセスのヒープ領域が足りなくなると、VMはまずガベージコレクションによって空きヒープ領域を回収しようとします。 ガベージコレクタはプロセスのスタックとヒープをたどり、生きているデータを新しいヒープにコピーする一方で、死んでいるデータはすべて捨てます。 それでもヒープ領域が足りない場合は、より大きな新しいヒープが確保され、生きているデータはそちらに移されます。

現行の世代別コピーガベージコレクタの詳細(参照カウント方式のバイナリの扱いを含む)については、メモリの章を参照してください。

コマンドラインインターフェースとインタプリタ

erlでErlangノードを起動すると、コマンドプロンプトが表示されます。 これがErlang read eval print loop(REPL)、あるいはコマンドラインインターフェース(CLI)、単にErlangシェルと呼ばれるものです。

シェルからは、Erlangの式を直接入力して実行できます。 この場合、コードはBEAMコードにコンパイルされてBEAMによって実行されるのではなく、Erlangインタプリタによって解釈・実行されます。 一般に、インタプリタで実行されるコードはコンパイル済みのコードとまったく同じように振る舞いますが、細かな違いがいくつかあります。 とくに、インタプリタで実行されるコードは低速です。 こうした違いや、シェルに関するその他の話題は、運用の章で説明します。

別のErlang実装

本書が主に扱うのは、ERTSと呼ばれるErlang/OTPによる「標準」実装です。 しかし、他にもいくつかの実装が存在するため、この節ではそれらを簡単に見ていきます。

Erlang on Xen

Erlang on Xenは、サーバのハードウェア上で直接動作するErlang実装で、間にOS層を挟まず、Xenの薄いクライアントだけが介在します。

Erlang on Xenの仮想マシンであるLingは、ほぼ100%BEAMとバイナリ互換です。 Erlang on Xenの図では、Erlang on Xen実装によるErlang Solution Stackが、ERTSスタックとどう異なるかを示しています。 ここで注目すべきは、Erlang on Xenのスタックにはオペレーティングシステムが存在しないという点です。

LingはBEAMの汎用命令セットを実装しているため、OTP層にあるBEAMコンパイラをそのまま利用して、ErlangをLing向けにコンパイルできます。

Erlang on Xen。左側はNode1とNode2がApp、OTP、Ling、EoXの層を積み重ねてXenとHWの上で動き、右側は比較のためNode2とNode3が標準的なApp、OTP、BEAM、ERTSの層をOSとHWまたはVMの上に積み重ねている

Erjang

Erjangは、JVM上で動くErlang実装です。 .beamファイルを読み込み、コードをJavaの.classファイルへと再コンパイルします。 Erjangは(汎用の)BEAMとほぼ100%バイナリ互換です。

Erlang on the JVMの図では、Erjang実装によるErlang Solution Stackが、ERTSスタックとどう異なるかを示しています。 ここで注目すべきは、仮想マシンとしてJVMがBEAMに取って代わっている点と、ErjangがERTSの提供する機能を、JVM上で動くJavaの実装として提供している点です。

Erlang on the JVM。左側はNode1とNode2がApp、OTP、Erjang、JVMの層を積み重ねてOSとHWまたはVMの上で動き、右側は比較のためNode3とNode4が標準的なApp、OTP、BEAM、ERTSの層を同じくOSとHWまたはVMの上に積み重ねている

ここまでで、ERTSを構成する主要な部品と、そのために必要な語彙について、基本的な理解が得られたはずです。 ここからは、各コンポーネントの詳細に踏み込んでいけます。 特定のコンポーネントについてどうしても理解したいのであれば、その章に直接進んでかまいません。 あるいは、特定の問題の解決策をどうしても見つけたいのであれば、第2部の該当する章に進み、システムをチューニングし、調整し、デバッグするためのさまざまな方法を試してみてください。