プロセス
この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-Processes
翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22)
軽量プロセスという概念こそがErlangとBEAMの本質であり、BEAMを他の仮想マシンから際立たせている最大の特徴である。BEAMの動作を理解するにはプロセスの詳細を知る必要があり、それによって初めてBEAMの中心概念、つまり何が簡単で安く、何が難しく高くつくのかが見えてくる。
BEAMのほとんどすべての要素はプロセスという概念と結びついており、本章ではこれらのつながりについてさらに詳しく学ぶ。イントロダクションの章で触れたメモリ管理やメッセージパッシング、そして特にスケジューリングについて、より深く掘り下げていく。
Erlangのプロセスは、OSのプロセスとよく似ている。プロセスは独自のアドレス空間を持ち、シグナルとメッセージを介して他のプロセスと通信し、その実行はプリエンプティブなスケジューラによって制御される。
ErlangやElixirで書かれたシステムでパフォーマンス上の問題が起きるとき、その原因は多くの場合、特定の1つのプロセスの内部か、プロセス間の負荷の偏りにある。もちろんアルゴリズムの不出来やメモリ関連の問題など、他の章で扱う原因もある。それでも、問題を引き起こしているプロセスを特定できることは常に重要であり、そのためにErlangランタイムシステムが備えるプロセス調査のためのツールを見ていく。
本章では、プロセスとスケジューラがどのように動作するかを説明しながら、その過程でツールを1つずつ紹介していく。そして章の最後に、それらのツールを一通り使う演習を用意している。
What is a Process?
プロセスは、コードの実行が隔離された形で行われる実体である。プロセスは、コードの誤りによる影響を実行中のプロセスだけに閉じ込めることで、システムをその誤りから守る。
ランタイムには、プロセスを調査するための数多くのツールが用意されている。これらはボトルネックや問題、リソースの過剰な使用を見つけ出し、問題のあるプロセスを特定して調べる助けになる。
Listing Processes from the Shell
まずは実際に動かしてみよう。稼働中のシステムにどのようなプロセスがあるかを見る一番簡単な方法は、Erlangシェルを起動し、シェルコマンドi()を実行することである。Elixirではshell_defaultモジュールの関数として:shell_default.iを呼び出せる。
$ erl
Erlang/OTP 19 [erts-8.1] [source] [64-bit] [smp:4:4] [async-threads:10]
[kernel-poll:false]
Eshell V8.1 (abort with ^G)
1> i().
Pid Initial Call Heap Reds Msgs
Registered Current Function Stack
<0.0.0> otp_ring0:start/2 376 579 0
init init:loop/1 2
<0.1.0> erts_code_purger:start/0 233 4 0
erts_code_purger erts_code_purger:loop/0 3
<0.4.0> erlang:apply/2 987 100084 0
erl_prim_loader erl_prim_loader:loop/3 5
<0.30.0> gen_event:init_it/6 610 226 0
error_logger gen_event:fetch_msg/5 8
<0.31.0> erlang:apply/2 1598 416 0
application_controlle gen_server:loop/6 7
<0.33.0> application_master:init/4 233 64 0
application_master:main_loop/2 6
<0.34.0> application_master:start_it/4 233 59 0
application_master:loop_it/4 5
<0.35.0> supervisor:kernel/1 610 1767 0
kernel_sup gen_server:loop/6 9
<0.36.0> erlang:apply/2 6772 73914 0
code_server code_server:loop/1 3
<0.38.0> rpc:init/1 233 21 0
rex gen_server:loop/6 9
<0.39.0> global:init/1 233 44 0
global_name_server gen_server:loop/6 9
<0.40.0> erlang:apply/2 233 21 0
global:loop_the_locker/1 5
<0.41.0> erlang:apply/2 233 3 0
global:loop_the_registrar/0 2
<0.42.0> inet_db:init/1 233 209 0
inet_db gen_server:loop/6 9
<0.44.0> global_group:init/1 233 55 0
global_group gen_server:loop/6 9
<0.45.0> file_server:init/1 233 79 0
file_server_2 gen_server:loop/6 9
<0.46.0> supervisor_bridge:standard_error/ 233 34 0
standard_error_sup gen_server:loop/6 9
<0.47.0> erlang:apply/2 233 10 0
standard_error standard_error:server_loop/1 2
<0.48.0> supervisor_bridge:user_sup/1 233 54 0
gen_server:loop/6 9
<0.49.0> user_drv:server/2 987 1975 0
user_drv user_drv:server_loop/6 9
<0.50.0> group:server/3 233 40 0
user group:server_loop/3 4
<0.51.0> group:server/3 987 12508 0
group:server_loop/3 4
<0.52.0> erlang:apply/2 4185 9537 0
shell:shell_rep/4 17
<0.53.0> kernel_config:init/1 233 255 0
gen_server:loop/6 9
<0.54.0> supervisor:kernel/1 233 56 0
kernel_safe_sup gen_server:loop/6 9
<0.58.0> erlang:apply/2 2586 18849 0
c:pinfo/1 50
Total 23426 220863 0
222
ok
i/0関数は、システム内のすべてのプロセスの一覧を出力する。各プロセスにつき2行の情報が表示される。出力の最初の2行はヘッダーで、各列の意味を示している。見てのとおり、プロセスID(Pid)と、名前が付いていればプロセスの名前、さらにそのプロセスが起動されたコードと現在実行中のコードの情報が得られる。ヒープとスタックのサイズ、リダクション数、メッセージ数についての情報も得られる。本章の残りの部分では、スタック、ヒープ、リダクション、メッセージとは何かを詳しく学んでいく。ここではひとまず、ヒープサイズの数値が大きければそのプロセスは多くのメモリを使っており、リダクション数が大きければそのプロセスは多くのコードを実行してきた、とだけ理解しておけばよい。
i/3関数を使うと、プロセスをさらに詳しく調べられる。code_serverプロセスを見てみよう。先ほどの一覧から、code_serverのプロセス識別子(pid)が<0.36.0>であることがわかる。pidの3つの数値を渡してi/3を呼び出すと、次の情報が得られる。
2> i(0,36,0).
[{registered_name,code_server},
{current_function,{code_server,loop,1}},
{initial_call,{erlang,apply,2}},
{status,waiting},
{message_queue_len,0},
{messages,[]},
{links,[<0.35.0>]},
{dictionary,[]},
{trap_exit,true},
{error_handler,error_handler},
{priority,normal},
{group_leader,<0.33.0>},
{total_heap_size,46422},
{heap_size,46422},
{stack_size,3},
{reductions,93418},
{garbage_collection,[{max_heap_size,#{error_logger => true,
kill => true,
size => 0}},
{min_bin_vheap_size,46422},
{min_heap_size,233},
{fullsweep_after,65535},
{minor_gcs,0}]},
{suspending,[]}]
3>
ここでもたくさんの情報が得られたが、これらの項目の大半についても本章の残りで詳しく学んでいく。1行目は、このプロセスにcode_serverという名前が付けられていることを示す。続いて、プロセスが現在実行中か、あるいは中断中の関数(current_function)と、プロセスが実行を開始した関数の名前(initial_call)がわかる。
また、このプロセスがメッセージを待って中断している({status,waiting})ことと、メールボックスにメッセージが1件もない({message_queue_len,0}、{messages,[]})こともわかる。メッセージパッシングがどう動作するかは、本章の後半で詳しく見ていく。
priority、suspending、reductions、links、trap_exit、error_handler、group_leaderの各フィールドは、プロセスの実行やエラー処理、IOを制御する。Observerを紹介する際に、これらについてもう少し詳しく見ていく。
残りのいくつかのフィールド(dictionary、total_heap_size、heap_size、stack_size、garbage_collection)は、プロセスのメモリ使用状況についての情報を与えてくれる。プロセスのメモリ領域については、メモリ管理の章で詳しく見ていく。
プロセスについての情報を得るもう1つの、さらに踏み込んだ方法は、BREAKメニューが提供するプロセス情報を使うことである。Ctrl+c p [enter]と入力すればよい。ただしBREAK状態にある間は、ノード全体が停止する点に注意してほしい。
Programmatic Process Probing
シェル関数はプロセスの情報を単に表示するだけだが、この情報はデータとしても取得できるので、プロセスを調べる独自のツールを書くこともできる。erlang:processes/0を使えばすべてのプロセスの一覧を取得でき、erlang:process_info/1を使えば特定のプロセスについての情報を取得できる。また、whereis/1関数を使えば名前からpidを得られる。
1> Ps = erlang:processes().
[<0.0.0>,<0.1.0>,<0.4.0>,<0.30.0>,<0.31.0>,<0.33.0>,
<0.34.0>,<0.35.0>,<0.36.0>,<0.38.0>,<0.39.0>,<0.40.0>,
<0.41.0>,<0.42.0>,<0.44.0>,<0.45.0>,<0.46.0>,<0.47.0>,
<0.48.0>,<0.49.0>,<0.50.0>,<0.51.0>,<0.52.0>,<0.53.0>,
<0.54.0>,<0.60.0>]
2> CodeServerPid = whereis(code_server).
<0.36.0>
3> erlang:process_info(CodeServerPid).
[{registered_name,code_server},
{current_function,{code_server,loop,1}},
{initial_call,{erlang,apply,2}},
{status,waiting},
{message_queue_len,0},
{messages,[]},
{links,[<0.35.0>]},
{dictionary,[]},
{trap_exit,true},
{error_handler,error_handler},
{priority,normal},
{group_leader,<0.33.0>},
{total_heap_size,24503},
{heap_size,6772},
{stack_size,3},
{reductions,74260},
{garbage_collection,[{max_heap_size,#{error_logger => true,
kill => true,
size => 0}},
{min_bin_vheap_size,46422},
{min_heap_size,233},
{fullsweep_after,65535},
{minor_gcs,33}]},
{suspending,[]}]
プロセスの情報をデータとして取得できるので、そのデータを自由に解析したり並べ替えたりするコードを書ける。システム内のすべてのプロセスを取得し(erlang:processes/0)、各プロセスのヒープサイズについての情報を取得すれば(erlang:process_info(P, total_heap_size))、pidとヒープサイズの組のリストを作り、ヒープサイズでソートできる。
1> lists:reverse(lists:keysort(2,[{P,element(2,
erlang:process_info(P,total_heap_size))}
|| P <- erlang:processes()])).
[{<0.36.0>,24503},
{<0.52.0>,21916},
{<0.4.0>,12556},
{<0.58.0>,4184},
{<0.51.0>,4184},
{<0.31.0>,3196},
{<0.49.0>,2586},
{<0.35.0>,1597},
{<0.30.0>,986},
{<0.0.0>,752},
{<0.33.0>,609},
{<0.54.0>,233},
{<0.53.0>,233},
{<0.50.0>,233},
{<0.48.0>,233},
{<0.47.0>,233},
{<0.46.0>,233},
{<0.45.0>,233},
{<0.44.0>,233},
{<0.42.0>,233},
{<0.41.0>,233},
{<0.40.0>,233},
{<0.39.0>,233},
{<0.38.0>,233},
{<0.34.0>,233},
{<0.1.0>,233}]
2>
多くのプロセスのヒープサイズが233ワードになっていることに気づくかもしれない。これはプロセスが最初に持つヒープサイズのデフォルト値だからである。
process_info/2から得られる情報の完全な一覧については、erlangモジュールのドキュメントを参照してほしい。https://erlang.org/doc/apps/erts/erlang.html#process_info/2 process_info/1関数がプロセスについて得られる情報のごく一部しか返さないのに対し、process_info/2関数を使えば追加の情報を取得できることに注目してほしい。例として、先ほどのcode_serverプロセスのbacktraceを取得するには、次のようにする。
3> process_info(whereis(code_server), backtrace).
{backtrace,<<"Program counter: 0x00000000161de900 (code_server:loop/1 + 152)\nCP: 0x0000000000000000 (invalid)\narity = 0\n\n0"...>>}
上のバイナリの末尾に3つの点があるのに気づいただろうか。これは出力が途中で打ち切られていることを示している。値全体を見るための便利な方法は、上の関数呼び出しをrp/1関数でラップすることである。
4> rp(process_info(whereis(code_server), backtrace)).
別の方法として、io:put_chars/1関数を使うこともできる。以下のとおりである。
5> {backtrace, Backtrace} = process_info(whereis(code_server), backtrace).
{backtrace,<<"Program counter: 0x00000000161de900 (code_server:loop/1 + 152)\nCP: 0x0000000000000000 (invalid)\narity = 0\n\n0"...>>}
6> io:put_chars(Backtrace).
出力が長くなるため、4>と6>の実行結果はここには載せていないが、ぜひErlangシェルで自分で試してみてほしい。
Using Observer to Inspect Processes
プロセスを調べる3つ目の方法は、Observerを使うことである。Observerは、Erlangランタイムシステムを調査するための本格的なグラフィカルインターフェースである。本書ではこの先も、システムのさまざまな側面を調べるためにObserverを使っていく。
observerアプリケーションとwxアプリケーションが利用できる状態でなければならない。本番環境のシステムではこの状態は避けたいところだが、Observerはそれを前提に作られている。サポート付きでビルドされたErlangからObserverを起動すれば、「Nodes」メニューから「Connect node」を選んで、ローカルノードの代わりに接続先のノードを観察できる。ここでは、まずErlangシェルから次のようにObserverを起動してみよう。
7> observer:start().
あるいはElixirのシェルでは:observer.startとする。稼働中のシステムをデバッグする場合は、Observerを動かすためだけの一時的なErlangノードを、検査対象のノードから見えないようにする-hiddenフラグを付けて起動するとよいだろう。
$ erl -hidden -sname observer -run observer
Observerを起動すると、システムの概要が表示される。次のスクリーンショットを見てほしい。
図: Observerが表示するシステム概要
この情報の一部については、本章と次の章で詳しく見ていく。ここではまず、稼働中のプロセスを見るためにObserverを使う。最初に、稼働中のシステムのsupervisionツリーを示すApplicationsタブを見てみよう。
図: Applicationsタブに表示されるsupervisionツリー
ここでは、プロセスがどのようにリンクされているかをグラフィカルに見られる。システムがどう構成されているかを一目で把握するのに非常に優れた方法である。プロセスが孤立した実体として空間に浮かびながら、リンクを通じて互いにつながっている様子も感じ取れるはずだ。
プロセスについて実際に役立つ情報を得るには、Processesタブに切り替える。
図: Processesタブに表示されるプロセス一覧
このビューで得られる情報は、シェルのi/0とほぼ同じである。pid、登録名、リダクション数、メモリ使用量、メッセージ数、現在実行中の関数がわかる。
行をダブルクリックすれば、そのプロセスの中身も見られる。たとえばcode serverをダブルクリックすると、process_info/2で得られるのと同種の情報が表示される。
図: プロセス情報ウィンドウに表示されるcode_serverの詳細
この情報がすべて何を意味するかについては今は説明しないが、この先を読み進めていけば、いずれすべて明らかになる。
Observerを有効にする
Rebar3でアプリケーションをビルドしていて、ビルドにObserverアプリケーションを含めたい場合は、rebar.configのrelxセクションにあるアプリケーション一覧にobserverとwxを追加する必要があるかもしれない。
以上で、プロセスとは何か、そしてシステム内のプロセスを見つけて調べるための基本的なツールについて理解できた。次はさらに踏み込んで、プロセスがどのように実装されているかを見ていく。
Processes Are Just Memory
プロセスは基本的に4つのメモリブロックからなる。スタック、ヒープ、メッセージ領域、そしてプロセス制御ブロック(PCB)である。
スタックは、戻りアドレスを保存してプログラムの実行を追跡したり、関数に引数を渡したり、ローカル変数を保持したりするために使われる。リストやタプルのような大きな構造体はヒープに置かれる。
メッセージ領域、別名メールボックスは、他のプロセスから送られてきたメッセージを保存するために使われる。プロセス制御ブロックは、プロセスの状態を管理するために使われる。
プロセスをメモリとして表した図として、プロセスの基本的なメモリ構成を見てほしい。
図: プロセスのメモリ構成(基本形)
このプロセス像はかなり単純化したものであり、この先何度も改訂を重ねながら、より正確な描像に近づけていく。
スタック、ヒープ、メールボックスはいずれも動的に確保され、必要に応じて伸び縮みする。この仕組みについては後の章で詳しく見ていく。一方PCBは静的に確保され、プロセスを制御するための多数のフィールドを持つ。
組み込みの調査用の呼び出しを使えば、こうしたメモリ領域の一部を調べられる。たとえばerlang:process_info/2を使うと、プロセスにstack_size、heap_size、total_heap_sizeや、生のbacktraceバイナリを尋ねられる。一方current_stacktraceの項目は、スタックを{M,F,A,Location}というタプルの構造化されたリストとして返すので、パターンマッチしたり自分で整形して表示したりできる。シェルのヘルパーc:bt(Pid)は、erlang:process_display(Pid, backtrace)をラップした単なる便利関数である。process_infoのmemoryやreductionsといった項目と組み合わせれば、プロセスのスタック、ヒープ、PCBに相当するフィールドの様子が見えてくる。
さらにバイト単位まで踏み込んで調べたいときのために、ランタイムにはEmulator Toolbox for Pathologists(ETP)という、一時停止したVMやコアダンプの中で稼働中のBEAMのデータ構造をたどるGDBマクロ群(etp-stackdump、etp-heapdump、etp-process-infoなど)が同梱されている。
ETPのサポート付きでErlangランタイムシステムのデバッグ版をビルドする方法については、ERTSのビルドを参照してほしい。
プロセスのスタックの内容は、erlang:process_display/2で確認できる。
1> erlang:process_display(self(), backtrace).
Program counter: 0x00007fdf9baa2e78 (erlang:self/0 + 32)
0x00007fdedf247480 Return addr 0x00007fdf9baa2118 (erlang:process_display/2 + 128)
y(0) []
y(1) backtrace
y(2) <0.89.0>
0x00007fdedf2474a0 Return addr 0x00007fdf9bbb7644 (erl_eval:do_apply/7 + 308)
y(0) none
y(1) []
0x00007fdedf2474b8 Return addr 0x00007fdf9bf0e430 (shell:exprs/7 + 536)
y(0) []
y(1) []
y(2) []
y(3) []
y(4) cmd
y(5) []
y(6) {value,#Fun<shell.5.46532814>}
y(7) {eval,#Fun<shell.23.46532814>}
y(8) #Ref<0.908573933.3456499713.143140>
y(9) []
0x00007fdedf247510 Return addr 0x00007fdf9bf0dc60 (shell:eval_exprs/7 + 152)
y(0) []
y(1) []
y(2) []
y(3) []
y(4) <0.88.0>
y(5) Catch 0x00007fdf9bf0dd0e (shell:eval_exprs/7 + 326)
0x00007fdedf247548 Return addr 0x00007fdf9bf0d87c (shell:eval_loop/4 + 564)
y(0) #Ref<0.908573933.3456499713.143140>
y(1) #Ref<0.908573933.3456499713.143160>
y(2) <0.88.0>
0x00007fdedf247568 Return addr 0x00007fdf9ba2d1f8 (<terminate process normally>)
true
スタックとヒープの値については、型システムの章で詳しく見ていく。
RTPとBEAMのデバッグ版を使えば、プロセスのヒープを調べられる。
デバッグ版がビルド済みだとして進める。
$ERL_TOP/bin/cerl -debug -rgdb
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
...
Reading symbols from beam.debug.smp...
%---------------------------------------------------------------------------
% Use etp-help for a command overview and general help.
%
% To use the Erlang support module, the environment variable ROOTDIR
% must be set to the toplevel installation directory of Erlang/OTP,
% so the etp-commands file becomes:
% $ROOTDIR/erts/etc/unix/etp-commands
% Also, erl and erlc must be in the path.
%---------------------------------------------------------------------------
etp-set-max-depth 20
etp-set-max-string-length 100
--------------- System Information ---------------
OTP release: 24
ERTS version: 12.3.2.17
Arch: x86_64-pc-linux-gnu
Endianness: Little
Word size: 64-bit
BeamAsm support: yes
--Type <RET> for more, q to quit, c to continue without paging--
SMP support: yes
Thread support: yes
Kernel poll: Supported
Debug compiled: yes
Lock checking: yes
Lock counting: no
System not initialized
--------------------------------------------------
続いてBEAMをrunコマンドで起動する。
(gdb) run
Starting program: /home/happi/hh/theBeamBook/otp24/bin/x86_64-pc-linux-gnu/beam.debug.smp -- -root /home/happi/hh/theBeamBook/otp24 -progname /home/happi/hh/theBeamBook/otp24/bin/cerl -debug -- -home /home/happi -- -emu_type debug
This GDB supports auto-downloading debuginfo from the following URLs:
<https://debuginfod.ubuntu.com>
Enable debuginfod for this session? (y or [n]) y
Debuginfod has been enabled.
To make this setting permanent, add 'set debuginfod enabled on' to .gdbinit.
warning: could not find '.gnu_debugaltlink' file for /lib/x86_64-linux-gnu/libtinfo.so.6
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7fffb6baf6c0 (LWP 2185)]
...
[New Thread 0x7fff9f4fa6c0 (LWP 2232)]
Erlang/OTP 24 [erts-12.3.2.17] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit] [type-assertions] [debug-compiled] [lock-checking]
Eshell V12.3.2.17 (abort with ^G)
1>
ヒープ上に動的なデータを持つプロセスを起動してみよう。
-module(mini_proc).
-export([wait/0]).
wait() ->
X = {timestamp, erlang:timestamp()},
receive _ -> X end,
X.
シェルの中からデバッガ経由でこのモジュールをコンパイルし、プロセスを起動する。
1> c(mini_proc).
{ok,mini_proc}
9> P3 = spawn(mini_proc, wait, []).
<0.101.0>
Ctrl+cを押すとデバッガに戻る。
Thread 1 "beam.debug.smp" received signal SIGINT, Interrupt.
[Switching to Thread 0x7ffff79b3c40 (LWP 2170)]
(gdb)
スケジューラのスレッドにいないとETPのコマンドがうまく動かない問題があったので、スケジューラスレッドに切り替えてプロセス一覧を表示する。
(gdb) thread 7
[Switching to thread 7 (Thread 0x7fffb40b96c0 (LWP 2191))]
(gdb) etp-processes
---
Pix: 0
Pid: <0.0.0>
State: prq-prio-normal | usr-prio-normal | act-prio-normal
Flags: trap-exit
Registered name: init
Current function: unknown
I: #Cp<init:loop/1+0x64>
Heap size: 987
Old-heap size: 987
Mbuf size: 38
Msgq len: 0 (inner=0, outer=0)
Msgq Flags: on-heap
Parent: []
Pointer: (Process*)0x5555561301c8
...
---
Pix: 808
Pid: <0.101.0>
State: prq-prio-normal | usr-prio-normal | act-prio-normal
Flags:
Current function: unknown
I: #Cp<0x7fffb53711dc>
Heap size: 233
Old-heap size: 0
Mbuf size: 0
Msgq len: 0 (inner=0, outer=0)
Msgq Flags: on-heap
Parent: <0.93.0>
Pointer: (Process*)0x7fff9ed6e030
---
Pix: 816
Pid: <0.102.0>
State: prq-prio-normal | usr-prio-normal | act-prio-normal
Flags:
Current function: unknown
I: #Cp<io:execute_request/3+0x1a4>
Heap size: 233
Old-heap size: 0
Mbuf size: 0
Msgq len: 0 (inner=0, outer=0)
Msgq Flags: on-heap
Parent: <0.79.0>
Pointer: (Process*)0x7fff9ed6dce8
---
(gdb)
<0.101.0>プロセスのアドレスがPointerフィールドに見える。etp-stackdumpとetp-heapdumpコマンドを使えば、そのプロセスのスタックとヒープを調べられる。etp-stackdumpコマンドはプロセスへのポインタを引数に取る。
(gdb) set $p = ((Process *) 0x7fff9ed6e030)
(gdb) etp-stackdump $p
% Stacktrace (3)
I: #Cp<0x7fffb53711dc>.
0: {timestamp,{1747,147859,212377}}.
1: #Cp<0x7fffb4c14a78>.
2: #Cp<0x7fffb4c14a78>.
(gdb) etp-heapdump $p
% heapdump (7):
0x7fff9eaf8278: | H: 3-tuple | I: 1747 | I: 147859 | I: 212377 | H: 2-tuple | timestamp | B:0x9eaf827a
(gdb)
予想どおり、スタックとヒープには3-タプルのタイムスタンプを含む2-タプルがある。{timestamp, {1747,147859,212377}}である。
The PCB
プロセス制御ブロックは、プロセスの振る舞いと現在の状態を制御するすべてのフィールドを持つ。この節と本章の残りの部分で、その中でも重要なフィールドを見ていく。実行やトレースに関わるいくつかのフィールドは本章では扱わず、BEAMの章で扱う。
プロセスへのポインタが指す先の値を出力するだけで、PCBのほとんどのフィールドを見られる。
print *((Process *) 0x7fff9ed6e030)
$2 = {common =
{id = 3470333576787,
refc = {atmc = {counter = 1}, sint = 1},
tracer = 59, trace_flags = 167772160,
timer = {counter = 0},
u = {alive = {started_interval = 263, reg = 0x0, links = 0x0,
lt_monitors = 0x0, monitors = 0x0},
release = {later = 263, func = 0x0, data = 0x0, next = 0x0}}},
htop = 0x7fff9eaf82b0,
stop = 0x7fff9eaf89a8,
fcalls = 3998,
freason = 0,
fvalue = 59,
heap = 0x7fff9eaf8278,
hend = 0x7fff9eaf89c0,
abandoned_heap = 0x0,
heap_sz = 233,
min_heap_size = 233,
min_vheap_size = 46422,
max_heap_size = 3,
arity = 0,
arg_reg = 0x7fff9ed6e0f8,
max_arg_reg = 6,
def_arg_reg = {588235, 322955, 59, 14178673876263027908, 14178673876263027908, 4000},
i = 0x7fffb53711dc <mini_proc:wait/0+140>,
catches = 0, rcount = 0, schedule_count = 0,
reds = 6, flags = 0, group_leader = 2267742733347, ftrace = 59, next = 0x555556142888,
uniq = 0,
sig_qs = {first = 0x0, last = 0x7fff9ed6e170, save = 0x7fff9ed6e170, cont = 0x0,
cont_last = 0x7fff9ed6e188,
nmsigs = {next = 0x0, last = 0x0},
recv_mrk_blk = 0x0, len = 0, flags = 2},
bif_timers = 0x0, dictionary = 0x0,
seq_trace_clock = 0, seq_trace_lastcnt = 0, seq_trace_token = 59,
u = {real_proc = 0x8f9cb, terminate = 0x8f9cb,
initial = {module = 588235, function = 322955, arity = 0}},
current = 0x0, parent = 3195455669715, static_flags = 0,
high_water = 0x7fff9eaf8278,
old_hend = 0x0,
old_htop = 0x0,
old_heap = 0x0,
gen_gcs = 0,
max_gen_gcs = 65535,
off_heap = {first = 0x0, overhead = 0},
mbuf = 0x0, live_hf_end = 0xfffffffffffffff8, msg_frag = 0x0, mbuf_sz = 0,
psd = {counter = 0},
bin_vheap_sz = 46422, bin_old_vheap_sz = 46422, bin_old_vheap = 0,
sys_task_qs = 0x0, dirty_sys_tasks = 0x0,
state = {counter = 42}, dirty_state = {counter = 0},
sig_inq = {first = 0x0, last = 0x7fff9ed6e2a8, len = 0,
nmsigs = {next = 0x0, last = 0x0}},
trace_msg_q = 0x0,lock = {flags = {counter = 0},
queue = {0x0, 0x0, 0x0, 0x0, 0x0},
locked = {{counter = 0}, {counter = 0}, {counter = 0},
{counter = 0}, {counter = 0}}},
scheduler_data = 0x0, run_queue = {counter = 140736249294720},
last_htop = 0x7fff9eaf8298, last_mbuf = 0x0, heap_hfrag = 0x0, last_old_htop = 0x0,
debug_reds_in = 4000
}
この章で扱う以上に深く調べたいのであれば、C言語のソースコードを見るとよい。PCBは、OTP19向けならerl_process.h、最新版向けならerl_process.hというファイルの中で、processという名前のC構造体として実装されている。
idフィールドにはプロセスID(PID)が入っている。id = 3470333576787は2進数で0b110010100000000000000000000000011001010011である。プロセスIDはErlangの項であり、そのためタグ付けされている(型システムの章を参照)。つまり下位4ビットがタグ(0011)である。コードのセクションには、Erlangの項を調べるためのモジュールがある(オンライン付録のshow.erlを参照)。これは型の章で扱うが、タグ付けされたワードの型を調べるために今使ってみよう。
4> show:tag_to_type(3470333576787).
pid
5>
htopフィールドとstopフィールドは、ヒープとスタックの先頭、つまり次に空いているスロットを指すポインタである。heapフィールド(開始位置)とhendフィールドは、ヒープ全体の開始位置と終了位置を指し、heap_szフィールドはヒープのサイズをワード単位で示す。64ビットマシンではhend - heap = heap_sz * 8、32ビットマシンではhend - heap = heap_sz * 4となる。
min_heap_sizeフィールドは、ヒープが最初に持つサイズであり、それより縮まないサイズをワード単位で示す。デフォルト値は233である。
PCBの中の、ヒープの形を制御するフィールドを使って、プロセスヒープの描像を改訂できる。ヒープの構造を見てほしい。
図: ヒープの構造。全体の大きさ(hend - heap)はheap_szワード、htopからheapまでの使用中領域の大きさがmin_heap_sizeに相当する
しかし、ヒープには開始位置と終了位置があるのに、スタックにはなぜ開始位置と終了位置がないのだろうか。それは、BEAMが領域とポインタを節約するためのある工夫として、ヒープとスタックを1つにまとめて確保しているからである。ここで、プロセスをメモリとして表した図の最初の改訂版を見てみよう。ヒープとスタックは実際には1つのメモリ領域にすぎない。これはヒープとスタックを合わせたメモリ構成に示すとおりである。
図: プロセスのメモリ構成(ヒープとスタックを合わせた形)
スタックはメモリアドレスの小さい方向へ、ヒープは大きい方向へ伸びていく。そのため、スタックトップを指すポインタを図に加えることで、ヒープの描像もさらに改訂できる。ヒープとスタックの構造を見てほしい。
図: ヒープとスタックの構造。全体の大きさ(hend - heap)はheap_szワード、htopからheapまでの使用中領域の大きさがmin_heap_sizeに相当する
htopポインタとstopポインタが出会うと、プロセスは空きメモリを使い果たしたことになり、メモリを解放するためにガベージコレクタを呼び出さなければならない。
The Garbage Collector (GC)
ヒープのメモリ管理方式には、プロセスごとのコピー方式による世代別ガベージコレクタが使われている。ヒープ(あるいはスタック。両者は確保されたメモリブロックを共有しているため)にこれ以上空きがなくなると、ガベージコレクタが動き出してメモリを解放する。
GCは、to spaceと呼ばれる新しいメモリ領域を確保する。続いてスタックを調べて生きているルートをすべて見つけ出し、各ルートをたどりながらヒープ上のデータを新しいヒープへコピーしていく。最後にスタックも新しいヒープへコピーし、古いメモリ領域を解放する。
GCは、PCBの次のフィールドによって制御される。
Eterm *high_water;
Eterm *old_hend; /* Heap pointers for generational GC. */
Eterm *old_htop;
Eterm *old_heap;
Uint max_heap_size; /* Maximum size of heap (in words). */
Uint16 gen_gcs; /* Number of (minor) generational GCs. */
Uint16 max_gen_gcs; /* Max minor gen GCs before fullsweep. */
ガベージコレクタは世代別であるため、ほとんどの場合はヒューリスティックによって新しいデータだけを見るようにしている。つまりマイナーコレクションと呼ばれる処理では、GCはスタックの上部だけを見て、新しいデータを新しいヒープへ移す。high_waterより下のヒープ上に確保された古いデータ(プロセスのメモリ構成(GC)を参照)は、オールドヒープと呼ばれる特別な領域へ移される。
図: ガベージコレクション後のプロセスのメモリ構成
たいていの場合、各プロセスにはもう1つ別のヒープ領域、オールドヒープがあり、PCBのold_heap、old_htop、old_hendの各フィールドによって管理される。これによって、プロセスを4つのメモリ領域として捉えていた元の描像にほぼ戻ってくる。プロセスのメモリ構成(GC)に示すとおりである。
プロセスが起動した時点ではオールドヒープは存在しないが、新しいデータが古いデータへと成熟し、ガベージコレクションが発生すると、オールドヒープが確保される。オールドヒープがガベージコレクトされるのは、メジャーコレクション、別名フルスイープが発生したときである。ガベージコレクションがどのように動作するかの詳細についてはメモリ管理の章を参照してほしい。その章では、メモリに関する問題を突き止めて修正する方法も見ていく。
Mailboxes and Message Passing
プロセス間の通信はメッセージパッシングによって行われる。プロセスの送信は、送信側のプロセスが自分のヒープから受信側プロセスのメールボックスへメッセージをコピーする形で実装されている。
Erlang創成期には、並行性はErlang自身のスケジューラによるタスク切り替えだけで実現されていた。スケジューラについては本章の後半で詳しく述べるが、ここでは、Erlangの最初のバージョンには並列性がなく、同時に実行できるプロセスは1つだけだったことに触れておく。当時のバージョンでは、送信側のプロセスはロックを取ることなく、受信側プロセスのヒープへ直接データを書き込んでいた。
Sending Messages in Parallel
マルチコアシステムが登場し、Erlangの実装が複数のスケジューラでプロセスを並列に実行できるように拡張されると、受信側のメインロックを取らずに他プロセスのヒープへ直接書き込むことは、もはや安全ではなくなった。この時期にm-buf(ヒープフラグメントとも呼ばれる)という概念が導入された。m-bufは、プロセスのヒープの外側にあるメモリ領域で、他のプロセスが安全にデータを書き込める場所である。送信側のプロセスがロックを取得できない場合、代わりにm-bufへ書き込む。メッセージのすべてのデータがm-bufへコピーされると、そのメッセージはメールボックスを介してプロセスにリンクされる。このリンク処理(erl_message.hのLINK_MESSAGE)は、メッセージを受信側のメッセージキューへ追加する。
その後ガベージコレクタが、メッセージをプロセスのヒープへコピーする。GCへの負荷を減らすため、メールボックスは既読メッセージのリストと新着メッセージのリストの2つに分かれている。新着メッセージはまだメールボックスの中にあり生き残ることがわかっているため、GCはそれらを見る必要がなく、コピーの手間を省ける。
Lock Free Message Passing
Erlang 19では、プロセスごとの新しい設定message_queue_dataが導入され、on_heapかoff_heapのいずれかの値を取れるようになった。on_heapに設定されている場合、送信側のプロセスはまず受信側のメインロックを取得しようとし、成功すればメッセージは受信側のヒープへ直接コピーされる。これができるのは、受信側が中断していて、かつ他のプロセスが同じ送信先へのロックを取っていない場合に限られる。送信側がロックを取得できない場合は、ヒープフラグメントを確保し、そこへメッセージをコピーする。
フラグがoff_heapに設定されている場合、送信側はロックを取得しようとせず、直接ヒープフラグメントへ書き込む。これによりロック競合は減るが、ヒープフラグメントの確保はすでに確保済みのプロセスヒープへ直接書き込むよりコストが高く、メモリ使用量が増える可能性もある。大きな空のヒープが確保されているのに、新しいメッセージは新しいフラグメントへ書き込まれ続ける、ということも起こりうる。
on_heapによる確保では、ヒープに直接確保されたメッセージも、ヒープフラグメント内のメッセージも、すべてGCによってコピーされる。メッセージキューが大きく、多くのメッセージが処理されないまま生きている状態が続くと、それらはオールドヒープへ昇格し、プロセスヒープのサイズが増加してメモリ使用量が高くなる。
メッセージは、受信側のプロセスへコピーされた時点で連結リスト(メールボックス)に追加される。メッセージが受信側プロセスのヒープへコピーされた場合、そのメッセージは内部メッセージキュー(あるいはseenメッセージ)にリンクされ、GCの対象になる。off_heap確保方式では、新しいメッセージは「外部」のメッセージ入力キューに置かれ、GCからは無視される。
Memory Areas for Messages
プロセスを4つのメモリ領域として捉えた描像を、もう一度改訂できる。プロセスのメモリ構成(メッセージ)に示すように、いまやプロセスは5つのメモリ領域(2つのメールボックス)と、可変個のヒープフラグメント(m-buf)からなる。
図: メッセージを扱うプロセスのメモリ構成
各メールボックスは、長さと2つのポインタからなる。内部キューはmsg.len、msg.first、msg.lastの各フィールドに、外部の入力キューはmsg_inq.len、msg_inq.first、msg_inq.lastの各フィールドに保存されている。選択的受信を実装するために、次に見るべきメッセージへのポインタ(msg.save)も存在する。
The Process of Sending a Message to a Process
ここでは分配(ディストリビューション)のケース、つまりErlangノード間で送られるメッセージについては考えない。P1とP2という2つのプロセスを考える。プロセスP1がプロセスP2へメッセージ(Msg)を送りたいとする。メッセージパッシングの手順1に示すとおりである。
図: メッセージパッシングの手順1。P1がP2 ! Msgを実行する直前の状態
プロセスP1は続いて次の手順を踏む。
Msgのサイズを計算する。- (前述のとおり、
P2のヒープの上か外かに)メッセージのための領域を確保する。 MsgをP1のヒープから確保した領域へコピーする。- メッセージを包む
ErlMessage構造体を確保し、値を埋める。 ErlMessageをErlMsgQueueまたはErlMsgInQueueのいずれかにリンクする。
プロセスP2が中断していて、他のどのプロセスもP2へメッセージを送ろうとしておらず、ヒープに空きがあり、かつ確保方式がon_heapであれば、メッセージは直接ヒープに置かれる。メッセージパッシングの手順2のとおりである。
図: メッセージパッシングの手順2。on_heap確保でロックを取得できた場合
P1がP2のmain lockを取得できない場合、あるいはP2のヒープに十分な空きがなく、かつ確保方式がon_heapである場合、メッセージはm-bufに置かれるが、内部メールボックスからリンクされる。メッセージパッシングの手順3のとおりである。
図: メッセージパッシングの手順3。ロックを取得できずm-bufへ書き込んだ場合
GCの後、メッセージはヒープへ移される。
確保方式がoff_heapである場合、メッセージはm-bufに置かれ、外部メールボックスからリンクされる。メッセージパッシングの手順4のとおりである。
図: メッセージパッシングの手順4。off_heap確保の場合
GCの後もメッセージはm-bufに残ったままである。メッセージが受信され、ヒープ上か、スタックにある他のオブジェクトから到達可能になるまでは、GCの際にそのメッセージがプロセスヒープへコピーされることはない。
Receiving a Message
Erlangは選択的受信をサポートしている。つまり、マッチしなかったメッセージは、後の受信のためにメールボックスに残しておける。また、どのメッセージもマッチしない場合、メールボックスにメッセージが残ったままプロセスを中断させることもできる。msg.saveフィールドには、次に見るべきメッセージへのポインタへのポインタが入っている。
後の章では、m-bufの詳細と、ガベージコレクタがメールボックスをどう扱うかを扱う。BEAMにおける受信の実装の詳細についても、後の章で見ていく。
Tuning Message Passing
Erlang 19で導入された新しいmessage_queue_dataフラグを使えば、実行時間とメモリをこれまでとは違った形でトレードオフできる。受信側プロセスが過負荷になっていてmain lockを握り続けている場合は、off_heap確保を使い、送信側プロセスがすばやくメッセージをm-bufへ書き込めるようにするのがよい戦略になりうる。
2つのプロセスの間にプロセスロックの実質的な競合がない、うまく釣り合ったプロデューサー・コンシューマー関係がある場合は、受信側のヒープへ直接確保する方が高速で、メモリ使用量も少なくなる。
受信側が滞留していて、処理できる以上のメッセージを受け取っている場合、メッセージがヒープへコピーされてオールドヒープへ移されていくにつれて、かえって多くのメモリを使い始めることがある。未読のメッセージは生きていると見なされるため、ヒープは大きくならざるを得ず、より多くのメモリを使うことになる。
自分のシステムにとってどちらの確保戦略がよいかを知るには、ベンチマークを取り、実際の挙動を測定する必要がある。まず一番手軽に試せるのは、システム起動時のデフォルトの確保戦略を変えてみることである。ERTSのフラグ+hmqdは、デフォルトの戦略をoff_heapかon_heapのいずれかに設定する。このフラグを指定せずにErlangを起動すると、デフォルトはon_heapになる。ベンチマークをErlangが+hmqd off_heap付きで起動するように組んでおけば、すべてのプロセスがoff heap確保を使った場合にシステムの挙動が良くなるか悪くなるかを検証できる。そのうえで、ボトルネックになっているプロセスを見つけ出し、そのプロセスだけ確保戦略を切り替えて試してみるとよいだろう。
The Process Dictionary
プロセスの中でErlangの項を保存できるメモリ領域が、実はもう1つある。プロセス辞書である。
プロセス辞書(PD)は、プロセスに閉じたキー・バリューストアである。その利点の1つは、キーと値がすべてプロセスのヒープ上に保持されるため、そこに保存する項をメッセージパッシングやETSテーブルのようにどこかへコピーする必要がない点である。
プロセスの描像に、PD、つまりプロセス辞書というもう1つのメモリ領域を加えて更新できる。プロセスのメモリ構成(プロセス辞書)のとおりである。
図: プロセス辞書を含めたプロセスのメモリ構成
プロセス辞書は、きわめて素直なハッシュテーブルの実装である。実のところPDのメモリ領域は単なるバケット配列であり、各スロットは、バケットが空であればNIL(空リスト)であり、そうでなければヒープ上のデータへのタグ付きポインタである。バケットにエントリが1つしかなければ単純なタプル{Key, Value}、バケットにN個のエントリがあればNIL終端のリスト[{Key1, Value1}, …, {KeyN, ValueN}]になる。実装はerl_process_dict.cというファイルにある。
この仕組みのおかげで、動的に伸び縮みするバケットのリストを管理するための複雑さが追加で発生することはなく、バケット配列そのものを除けば、これらのデータ構造のための独立したメモリ管理も要らない。すべてはヒープ上でのタプルとリストの通常の生成と解放によって扱われる(これはすでに十分効率的である)。これはまた、ガベージコレクタが他のErlangのデータと同じようにハッシュテーブルを簡単にたどれることも意味する。ガベージコレクションがどのように動作するかの詳細はメモリ管理の章で見ていく。
一方で欠点として、PDに入っている項は常に生きているデータとして扱われ、(コピー方式の)GCによって動かされ続けなければならない。そのため、大量のデータをPDに保存することは、辞書が初期の成長段階を終えた後にGCへどれだけ負荷がかかっているかによっては、エントリの出し入れにコピーが必要になるとしても、独立したETSテーブルを使うより悪い選択になりうる。大きなPDを構築するとわかっているなら、spawn_opt(…, [{min_heap_size, Size}])のように大きな初期ヒープサイズでプロセスを起動しておき、GCの負荷が大きい作業は別のプロセスで行うようにするとよい。
どんなハッシュテーブルでもそうだが、PDに要素を保存する処理は完全に無料というわけではない。キーと値のエントリのために余分なタプルが1つ、バケットが空でなければ余分なconsセルが1つ生じ、それがガベージコレクションの引き金になることもある。バケット配列のリサイズが必要になることもある。辞書内の既存のキーを更新すると、古いヒープから新しいヒープへのポインタが残らないよう、そのバケットのリスト全体が再確保される。ただし、これらのリストは平均すればごく短いはずである。
Dig In
本章では、プロセスがどのように実装されているかを見てきた。とりわけ、プロセスのメモリがどう構成されているか、メッセージパッシングがどう動くか、そしてPCBに含まれる情報について見た。また、erlang:process_info/2やETPのデバッグマクロなど、プロセスを調査するためのツールもいくつか見てきた。
erlang:processes/0とerlang:process_info/1,2を使って、システム内のプロセスを調べてみよう。試してみるとよい関数をいくつか挙げる。
1> Ps = erlang:processes().
[<0.0.0>,<0.3.0>,<0.6.0>,<0.7.0>,<0.9.0>,<0.10.0>,<0.11.0>,
<0.12.0>,<0.13.0>,<0.14.0>,<0.15.0>,<0.16.0>,<0.17.0>,
<0.19.0>,<0.20.0>,<0.21.0>,<0.22.0>,<0.23.0>,<0.24.0>,
<0.25.0>,<0.26.0>,<0.27.0>,<0.28.0>,<0.29.0>,<0.33.0>]
2> P = self().
<0.33.0>
3> erlang:process_info(P).
[{current_function,{erl_eval,do_apply,6}},
{initial_call,{erlang,apply,2}},
{status,running},
{message_queue_len,0},
{messages,[]},
{links,[<0.27.0>]},
{dictionary,[]},
{trap_exit,false},
{error_handler,error_handler},
{priority,normal},
{group_leader,<0.26.0>},
{total_heap_size,17730},
{heap_size,6772},
{stack_size,24},
{reductions,25944},
{garbage_collection,[{min_bin_vheap_size,46422},
{min_heap_size,233},
{fullsweep_after,65535},
{minor_gcs,1}]},
{suspending,[]}]
4> lists:keysort(2,[{P,element(2,erlang:process_info(P,
total_heap_size))} || P <- Ps]).
[{<0.10.0>,233},
{<0.13.0>,233},
{<0.14.0>,233},
{<0.15.0>,233},
{<0.16.0>,233},
{<0.17.0>,233},
{<0.19.0>,233},
{<0.20.0>,233},
{<0.21.0>,233},
{<0.22.0>,233},
{<0.23.0>,233},
{<0.25.0>,233},
{<0.28.0>,233},
{<0.29.0>,233},
{<0.6.0>,752},
{<0.9.0>,752},
{<0.11.0>,1363},
{<0.7.0>,1597},
{<0.0.0>,1974},
{<0.24.0>,2585},
{<0.26.0>,6771},
{<0.12.0>,13544},
{<0.33.0>,13544},
{<0.3.0>,15143},
{<0.27.0>,32875}]
9>