LoginSignup
19
17

More than 5 years have passed since last update.

ホットコードローディング時のプロセスクラッシュについて

Last updated at Posted at 2015-04-23

『3分で分かる Erlang hot code loading』内で指名されてしまったので、ホットコードローディングに関する補足的なエントリを書きました。
(追記: 補足的なエントリのつもりが無駄に分量が多くなってしまいました...)
(あと全般的に用語の使い方が怪しいです...)

上の記事の内容を前提として書いているところもあるので、未読の方は先にリンク元を一読しておくことを推奨します。

プロセスクラッシュ

Erlangはホットコードローディングに対応しているけど、同じモジュールを何回もロードしていると、それを参照しているプロセスがクラッシュすることがあるので注意が必要。

以降は、その詳細。

概要

コードローディングに付随するプロセスクラッシュに関する概要:

  • モジュールは(内容が更新された)ロードの度にVM上に異なるインスタンスが生成される
  • ErlangVMは同時に二つ(二世代)までのモジュールインスタンスを保持可能
    • currentとold
  • それ以上古いモジュールのインスタンスは破棄される
    • 破棄対象のモジュールインタンスを(何らかの形で)参照しているプロセスがいる場合は、それらも殺される(クラッシュする)
    • 今回は何が参照に当たるのかを詳しく扱っていく

対応策としては(例えば)以下が挙げられる:

  • 特定のモジュールインスタンスへの参照を保持しないようにする
  • 参照を保持している場合でも、次のコードローディング時に最新版への参照に更新されるようにする

モジュールインスタンスへの参照って?

以下の三つでモジュールの特定のインスタンスへの参照が発生する(自分が把握している範囲では):

  • 1. 完全修飾以外の形式での関数への参照
  • 2. 関数の実行
  • 3. モジュール定数への参照

1. 完全修飾以外の形式での関数への参照

fun ...で取得できる関数への参照についての話。

ある関数への参照の生成方法として代表的なものにはfun Module:Function/Arityfun Function/Arityfun (Args) -> Body endの三つの方法があるが、最初の一つ以外ではモジュールの特定のインスタンスへの参照が発生する。

動作を確認するために、まずモジュールを用意。

-module(hoge).

-export([gen_external_fun/0, gen_local_fun/0, gen_anonymouns_fun/0]).
-export([hello/0]).

gen_external_fun() ->  % 公開関数への参照を返す
  fun hoge:hello/0. % これだけが完全修飾形式での参照 (= 複数回のコードローディングを跨いでも安全)

gen_local_fun() -> % ローカル関数への参照を返す
  fun hello/0.

gen_anonymouns_fun() -> % 無名関数への参照を返す
  fun () -> hoge:hello() end.

hello() ->
  ?HELLO. % マクロの値はコンパイル時に指定する

まずは動作例から:

> c(hoge, [{d, 'HELLO', hello}]). % コンパイル

> (hoge:gen_external_fun())().
hello

> (hoge:gen_local_fun())().   
hello

(hoge:gen_anonymouns_fun())().           
hello

次は、それぞれの形式での関数への参照の実体を確認してみる:

%% 1. 'fun Module:Function/Arity'形式の場合
> erlang:fun_info(hoge:gen_external_fun()).  
[{module,hoge}, % erlang:fun_info/1で返される情報は、ほとんど見た目上のそれと同じ
 {name,hello},
 {arity,0},
 {env,[]},
 {type,external}] % type=external

%% 2. 'fun Functoin/Arity'形式の場合
> erlang:fun_info(hoge:gen_local_fun()). % 返される情報が前者に比べてだいぶ増えている
[{pid,<0.47.0>}, 
 {module,hoge}, % 上の関数は、このモジュールの現在のインスタンスを参照している
 {new_index,0}, % 参照先モジュールインスタンスの関数テーブル内でのインデックス
 {new_uniq,<<32,234,39,54,23,31,234,108,60,180,205,165,64,
             8,222,180>>}, % モージュールインスタンスを識別するID (大雑把にいえば)
 {index,0},
 {uniq,17256761},
 {name,'-gen_local_fun/0-fun-0-'},
 {arity,0},
 {env,[]},
 {type,local}] % type=local

%% 3. 'fun (Args) -> Body end'形式の場合
> erlang:fun_info(hoge:gen_anonymouns_fun()).              
[{pid,<0.47.0>},
 {module,hoge}, % 以下の三つの値の意味は、一つ上の場合と同様
 {new_index,1}, % 関数テーブル内でのインデックスが一つ増えている
 {new_uniq,<<32,234,39,54,23,31,234,108,60,180,205,165,64,
             8,222,180>>},
 {index,1},
 {uniq,17256761},
 {name,'-gen_anonymouns_fun/0-fun-0-'},
 {arity,0},
 {env,[]},
 {type,local}] % type=local

最初の完全修飾形式での参照とそれ以外ではerlang:fun_info/1で返される情報が異なっていることが見て取れる。

先に二番目と三番目の結果について話すと、これらはtypeの値がlocalとなっており、かつモジュールの特定のバージョンに依存した情報を保持していることが見て取れる。
各関数の実行コード自体は、参照先モジュールインスタンスが保持しており、実際に関数を実行する場合は
(C風な擬似コードで表現するなら)module_instance->fun_table[new_index](args...)といったようにnew_indexを使ってアクセスしているものと思われる。
この場合、参照先モジュールインスタンスが破棄されれば、当然それが保持する関数の実行コードも破棄され利用不可となるはず。

それに対して完全修飾の場合はtypeの値がexternalとなっており、保持している情報も最小限となっている。
特定のモジュールインスタンスを特定するような情報は何も保持していなく、実際にどのインスタンスの関数を実行するかは、呼び出し時に初めて解決される。(解決の際には常に、その時のcurrentが参照される)
そのため、例えば以下のように存在しない関数を参照しても(参照生成時に)エラーとなることはない。

> HogeFun = fun hogefuga:piyo/0. % 存在しないモジュールの存在しない関数への参照を生成
#Fun<hogefuga.piyo.0>

> HogeFun(). % 実際の呼び出し時に、実体を解決出来ずにエラーとなる
** exception error: undefined function hogefuga:piyo/0

それぞれの関数参照の詳細は分かったので、最後は実際に複数回のコードローディングを行った際の動作例:

%% 1. 完全修飾形式での参照の場合
% まだoldバージョンは存在しない
> erlang:check_old_code(hoge).
false

% 自プロセスがold版を実行していないか確認
> erlang:check_process_code(self(), hoge).
false  % oldのhogeは使っていない

% 完全修飾形式での参照を保持してみる
> Fun1 = hoge:gen_external_fun(). 

% 中身を変更してモジュールの再コンパイル&ロード
> c(hoge, [{d, 'HELLO', hello_world}]).

> erlang:check_old_code(hoge).
true  % old版が存在するようになった

> erlang:check_process_code(self(), hoge).
false % 自プロセスはまだold版への参照を保持していない

% 二回以上のロードを跨いても特に問題なし
> c(hoge, [{d, 'HELLO', hello}]).


%% 2. ローカル形式での参照の場合 (無名関数の場合も同様なのでそちらは省略)
% ローカル形式での関数への参照を取得
> Fun2 = hoge:gen_local_fun(). 

> erlang:check_process_code(self(), hoge).
false % まだold版モジュールインスタンスへの参照は存在しない

% 再コンパイル&ロード
> c(hoge, [{d, 'HELLO', hello_world}]).

> erlang:check_process_code(self(), hoge).
true % old版への参照発生! (Fun2が古いモジュールインスタンスへの参照を保持しているため)

% 再コンパイル&ロード
> c(hoge, [{d, 'HELLO', hello}]).      
*** ERROR: Shell process terminated! ***  % 二回のコード更新を跨げずにプロセスクラッシュ

予想通り、完全修飾形式以外の関数への参照を保持している場合は、そのプロセスが複数のコードローディングを跨いで生存することが出来ずにクラッシュした。

2. 関数の実行 (or 実行コードへの参照)

特定のモジュールインスタンスへの参照が発生するケースの二番目。

あるモジュール(インスタンス)のある関数を実行中は(スタックフレーム内の全ての関数を含めて)、そのインスタンスへの参照が発生する。
特定の関数の実行コード(バイトコード)は、それが紐付くモジュールインスタンスが保持しているので、そのインスタンスが破棄されるならば、実行を継続することはできず、プロセスはクラッシュするしかない(のでコード更新前に殺される)。

短命なプロセスならば通常は気にする必要がないことだが、ループを繰り返すような長命なプロセスを使いたい場合(and ホットコードローディングに対応させたい場合)は、そのプロセスが実行している関数がどのモジュールインスタンスのものなのかを気にかける必要がでてくる。

以下は、その例。
まずはモジュールの準備:

-module(fuga).

-export([spawn_external_loop/0, spawn_local_loop/0, spawn_anonymous_external_loop/0]).

-export([external_loop/0, local_loop/0]). % プロセスのメインループ用

spawn_external_loop() -> % external_loop/0を実行するプロセスを起動
  %% NOTE: ループの度に、その時点での最新のモジュールインスタンスのコードが実行される
  spawn_monitor(?MODULE, external_loop, []).

spawn_local_loop() -> % local_loop/0を実行するプロセスを起動
  %% NOTE: 参照(実行)するモジュールインスタンスは、この時点で固定化される
  spawn_monitor(?MODULE, local_loop, []).

spawn_anonymous_external_loop() -> % 無名関数経由でexternal_loop/0を実行するプロセスを起動
  %% NOTE:
  %% - 無名関数自体は特定のモジュールインスタンスを参照する
  %% - ただし、起動後にすぐに external_loop/0 に処理が移り、無名関数への参照は捨てられるので、
  %%   実質的には spawn_external_loop/0 とほぼ同様の挙動となる
  spawn_monitor(fun () -> ?MODULE:external_loop() end).

external_loop() ->
  timer:sleep(?WAIT),
  ?MODULE:external_loop(). % 完全修飾形式で次のループを呼び出す (コード更新があれば最新のものが使用される)

local_loop() ->
  timer:sleep(?WAIT),
  local_loop(). % ローカル呼び出し (コード更新があっても現在の実行中のバージョンを使い続ける)

実行例:

%% コンパイル
> c(fuga, [{d, 'WAIT', 1}]).

%% プロセス起動
2> fuga:spawn_external_loop(). 
{<0.40.0>,#Ref<0.0.0.78>}

3> fuga:spawn_local_loop(). % このプロセスだけローカル呼び出しでループしている
{<0.42.0>,#Ref<0.0.0.83>}

4> fuga:spawn_anonymous_external_loop().
{<0.44.0>,#Ref<0.0.0.88>}

%% oldを参照しているプロセスがないかの確認: 現時点では全部currentのみを参照している
5> erlang:check_process_code(pid(0,40,0), fuga).        
false
6> erlang:check_process_code(pid(0,42,0), fuga).
false
7> erlang:check_process_code(pid(0,44,0), fuga).
false

%% コード更新: 一回目
> c(fuga, [{d, 'WAIT', 10}]).

%% old確認
9> erlang:check_process_code(pid(0,40,0), fuga).
false
10> erlang:check_process_code(pid(0,42,0), fuga). % fuga:spawn_local_loop/0だけがoldへの参照を保持
true
11> erlang:check_process_code(pid(0,44,0), fuga).
false

%% コード更新: 二回目
c(fuga, [{d, 'WAIT', 1}]). 

> flush(). % fuga:spawn_local_loop/0 で起動したプロセスのダウン通知メッセージが届いている
Shell got {'DOWN',#Ref<0.0.0.83>,process,<0.42.0>,killed}
ok

> erlang:is_process_alive(pid(0,40,0)).
true
> erlang:is_process_alive(pid(0,42,0)).
false % down
> erlang:is_process_alive(pid(0,44,0)).
true

ローカル呼び出しでループしている関数の場合は、実行コードを更新するタイミングが存在しないため二回のコードローディングを跨ぐことが出来ずにクラッシュした。

見ての通り、長期間生存するプロセスの場合は「次のループを完全修飾形式で呼び出す」というのが基本となるが、完全に安全にしようとすると、意外と微妙な問題が絡んでくるので、これに関してはまた後で取り上げる。

3. モジュール定数への参照

beam_bif_load.cのcheck_process_code関数にプロセスが(oldな)モジュール内の定数を参照していないかをチェックしているコードがあったので、一応項目だけは書いておいた。
具体的にどういったケースでこの状況が発生するのかは、現状では不明。

経験上では、これに起因するプロセスクラッシュに(気づいていないだけかもしれないが)遭遇したことはないので、おそらくそこまで気にする必要はなさそう。
(より詳細な情報が判明した場合は、後日に追記する)

気をつけるべきこと

ホットコードローディングをサポートしたいなら、気をつけた方が良いと思うことをつらつらと。

プロセスに関数を渡す場合

プロセス側の処理を抽象化するのに便利なので、関数(の参照)をプロセスに渡したいこと少なくない。

ただし上述の通り、完全修飾形式以外の関数参照は、コードローディング時に問題が発生する可能性がある。
もし、プロセスを安全に長期間生存させたいのならば、以下のいずれかを満たしている必要がある。

  • 関数の参照は、完全修飾形式で生成されたものである
  • 関数はプロセスに長期間保持されない
    • プロセス起動直後に一回呼び出した後は使わない、等であればOK
    • プロセスの生存期間中はずっと保持されるのであればNG

この制約は、クロージャーが気軽に使いにくくなるので、若干不便。

もしどうしても長期間保持されるクロージャ的な状態を保持した関数が使いたいなら、以下のようにして(不格好に)模倣することも不可能ではない。

> List = [1,2,3].

> UnsafeFun = fun () -> lists:length(List) end. % 無名関数なのでモジュールインスタンスへの参照が発生する
> SafeFun = {fun lists:length/1, [List]}. % 完全修飾参照と追加引数をタプルで別々に保持すればOK

> my_apply(UnsafeFun, []).
3.
> my_apply(SafeFun, []).
3.

%% my_apply/2の定義
my_apply({Fun, ExtraArgs}, Args) -> apply(Fun, ExtraArgs++Args);
my_apply(Fun)            , Args) -> apply(Fun, Args).

長期間生存するプロセスのコード更新に関して

完全修飾形式でループ関数を呼び出すだけでは不十分

上で書いた通り、ループを繰り返すような長期間生存するプロセスの場合は、各ループの関数を完全修飾形式で呼び出すことが基本となる。
ではそれだけで十分かといえば、そうではない。
関数を完全修飾形式で呼び出したとしても、関数実行中は特定のモジュールインスタンス(内に保持されているバイトコード)を参照している訳であり、その次のループ呼び出しに到達するまでは、その参照の更新タイミングは存在しない。

具体的には、以下のようなケースで問題となり得る:

-module(piyo).

-export([wait_loop/0, recv_loop/0]).

wait_loop() ->
  %% 一時間スリープしてから、次のループを実行する
  %% => 一時間(sleep)の間に二回このモジュールの更新が行われた場合は、プロセスクラッシュ
  timer:sleep(60 * 60 * 1000),
  ?MODULE:wait_loop().

recv_loop() ->
  receive
    Msg ->
      do_something(Msg), % 何らかの処理を実行

      %% メッセージを受信し、それに対応する処理が完了したら、次のループを実行する
      %% => 受信を挟まずに二回このモジュールの更新が行われた場合は、プロセスクラッシュ
      ?MODULE:recv_loop()
  end.

要するに、確実に安全にしようとするなら、各ループ関数が完全修飾で呼び出されるようにするだけではなく、その呼び出しが適切なタイミング(コードローディングが発生した直後)で行われるようにする必要がある、ということ。

gen_serverを使う

上の例の後者のようなケースの場合、gen_server(あるいは他のOTPビヘイビア)を使うのが最も簡単な解決策となる。
(今回は詳細は扱わないが、前者のようなケースの場合はtimer:sleep/1のようなブロックする関数を使わずにerlang:send_after/3等を使って非同期にウェイト処理を実現する必要がある)

gen_serverを使ったことがある人なら、プロセスを実装する場合に、明示的なメインループを記述する必要がないことはご存知のことと思う。
上で問題となっていたようなreceiveループはgen_serverがよしなに処理してくれるので気にする必要がない。
また適宜呼び出されるModule:handle_call/3等のコールバック関数は、完全修飾呼び出しなので常にcurrentが参照され、実装者は呼び出しがブロックしないようにさえ気をつけていれば、ホットコードローディング(を跨いだ実行コードの参照)に纏わる問題をほぼ気にする必要がなくなる。

そのため、特に理由がないならgen_serverを使ってプロセスを実装するのをお勧めする。

gen_server:code_change/3の注意点

ついでなのでgen_server:code_change/3に関しても軽く触れておく。

この関数の説明文の冒頭には以下のような記述がある。

This function is called by a gen_server when it should update its internal state

要はモジュールのホットコードローディングの際にcode_change/3が呼び出されるということで、これだけを見ればバージョン間での内部状態を差異を吸収するための便利な仕組みに見える。

もう少し長めに引用する。

This function is called by a gen_server when it should update its internal state during a release upgrade/downgrade,
i.e. when the instruction {update,Module,Change,...} where Change={advanced,Extra} is given in the appup file.
See OTP Design Principles for more information.

"during a release upgrade/downrade"や"in the appup file"という文言が出てきているが、つまり単にモジュールをロードしただけではcode_change/3は呼び出されることはなく、もしこの機能を使いたいならOTPのrelease_handlingの仕組みに則って、モジュール(というかシステム全体)の更新を行う必要があることが示唆されている。

また、release_handlingを使って更新を行った場合でも、該当プロセスがアプリケーションのプロセス監視ツリー上に存在しない場合はcode_change/3の呼び出しは行われない模様。
(ただし、これはちゃんと検証した訳ではないので、もしかしたら間違っているかもしれない)

若干手間ではあるのでcode_change/3をちゃんと活用したい場合は注意が必要。

gen_serverを使わない場合はどうするか

かなりまれだとは思うが、例えば性能を追求したい等の理由で、gen_serverを使わずに自前で(長期間生存させたい)プロセスを実装したい場合もあると思う。
その場合にはSys and Proc_Libのページに従い、proc_libとsysを使ってプロセスを起動および実装するようにすれば、gen_serverと同じようにモジュールの更新タイミングにフックできるようになるので、複数回コードローディング問題を回避可能となる。(see: Mod:system_code_change/4)
ただしそのためにはcode_change/3の箇所で書いたような「プロセス監視ツリーへの登録」および「release_handlingを経由での更新」が(おそらく)必要となるので、これも注意しなければならない。

まとめ

長期間生存させたいプロセスがある場合は、以下を気をつける:

  • プロセスに関数を渡す際には気をつける
    • 長期間保持される関数なら、完全修飾形式で参照したものを渡す
  • 生プロセスを使った自前実装は極力避ける
    • 特に理由がないならgen_serverを使っておけば問題ない
    • 自前で実装する特別な理由があるならproc_libおよびsysを使って注意深く実装する
  • OTPの作法にのっとるのが安全
    • gen_server, プロセス監視ツリー、release_handlling、etc

感想

gen_serverを使っておけばだいたいは大丈夫そう。
ただし、ホットコードローディングのことを考慮すると、クロージャをプロセスに渡しにくくなりそうなのは残念。

後は今回取り上げた問題に限らず、Erlangのプロセスは死ぬ時は普通に死ぬので、Erlangでプログラムを組む際には「全てのプロセスが任意のタイミングで死に得る」ということを意識しておくのが個人的には好き。
(それを意識してプログラムが書かれているかどうかで、ホットコードローディングやホットデプロイ時の安心感がだいぶ変わってくるので)

追記: 2015/06/17

複数回のコードローディングに対応した関数オブジェクトを生成可能なpfunというライブラリを作ってみました。

pfun:lambda(fun () -> hello end, []). % pfun:lambda/2で無名関数を囲む
19
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
17