安っぽいタイトルを付けてしまい大変申し訳ありません。
Hot code loading とは、データはそのままでコードだけ最新のやつに差し替える Erlang VM の機能です。
うまくやると無停止で運用できるので嬉しかったりしますが、ちゃんとわかって使わないと謎の挙動に苦しむことになるのでちょっと調べてみました。
概要
まずは 公式ドキュメント を見て、一部動作を予想しつつまとめてみました。最低限、これだけ覚えておけば勝つる。
■関数の実体
ある関数の実体が所属する世界は current, old の2種類ある.
■関数の実体の解決方法
「Fully qualified function calls」や「Fully qualified functionが入った変数」は常に current のやつを指す.
そうでないものは現在実行中の関数が所属する世界のやつを指す.
■コード更新時の処理
初回起動時は全ての関数が current に配置される.
code update すると,
1. stack frame 中に「old に所属する関数」が1個でも存在するプロセスは kill される. (ここは doc では明記されていない)
2. current に所属する関数の実体がすべて old に移動される.
3. current に新しい関数がロードされる.
(↑ここまでで 3 分のつもり)
というわけでこれを確かめるべく、いくつか試してみます。結論としては「上記が確かめられた」です。
(注意: 試してOKだったとしても証明されたわけではないので、厳密に確かめるなら erlang のソースを追っていくと良いと思います。どなたかに期待。)
やってみた
hot code loading の動作確認をするちょっとしたコードを書いてみました。
基本的には、reload/1
というコード更新をする関数を用意し、いろんな関数でループを書きながら reload/1
を呼んで挙動を見る、という流れになっています。
さっそく、結果です。
erl -boot start_clean -eval 't:a(3).' -s init stop
Eshell V6.3 (abort with ^G)
1> {"a() - fully qualified call -> OK",3,
["t:stacktrace/0","t:a/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"a() - fully qualified call -> OK",2,
["t:stacktrace/0","t:a/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"a() - fully qualified call -> OK",1,
["t:stacktrace/0","t:a/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1>
↑ a/1
内で t:a(I-1)
という fully qualified な呼び出しをしています。この呼び出しは常に current にある関数を指すので何回 reload されても OK
ついでに言うと末尾再帰なので stack frame を消費しないという点もとても大事です。(後述)
erl -boot start_clean -eval 't:b(3).' -s init stop
Eshell V6.3 (abort with ^G)
1> {"b() - not fully qualified call -> death",3,
["t:stacktrace/0","t:b/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"b() - not fully qualified call -> death",2,
["t:stacktrace/0","t:b/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"init terminating in do_boot",killed}
Crash dump was written to: erl_crash.dump
init terminating in do_boot (killed)
↑ b/1
内で b(I-1)
と fully qualified でない呼び出しをしています。この呼び出しは現在の環境を指します。
つまり 1 回 reload されたあとは old にある b/1
が呼ばれ, 2 回 reload するときに現在の呼び出しは old に所属するので死にます。
erl -boot start_clean -eval 't:ca(3).' -s init stop
Eshell V6.3 (abort with ^G)
"ca() - pass fully qualified fun to c0 -> OK"
1> {"c0() - call Fun -> OK if Fun is fully qualified",3,
["t:stacktrace/0","t:c0/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c0() - call Fun -> OK if Fun is fully qualified",2,
["t:stacktrace/0","t:c0/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c0() - call Fun -> OK if Fun is fully qualified",1,
["t:stacktrace/0","t:c0/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1>
↑ fun t:c0/2
という fully qualified な関数を c0/2
に渡して c0/2
では最後にその関数を呼ぶことでループするという例です。
これは常に current にある関数を指すのでOK
erl -boot start_clean -eval 't:cb(3).' -s init stop
Eshell V6.3 (abort with ^G)
"cb() - pass not fully qualified exported fun to c0 -> death"
1> {"c0() - call Fun -> OK if Fun is fully qualified",3,
["t:stacktrace/0","t:c0/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c0() - call Fun -> OK if Fun is fully qualified",2,
["t:stacktrace/0","t:c0/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c0() - call Fun -> OK if Fun is fully qualified",1,
["t:stacktrace/0","t:c0/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"init terminating in do_boot",killed}
Crash dump was written to: erl_crash.dump
init terminating in do_boot (killed)
↑ c0/2
は export されていますが、fun c0/2
は fully qualified じゃないので死にます。
erl -boot start_clean -eval 't:cc(3).' -s init stop
Eshell V6.3 (abort with ^G)
"cc() - pass anonymous fun to c1 -> death"
1> {"c1() [Not exported] - call Fun -> OK if Fun is fully qualified",3,
["t:stacktrace/0","t:c1/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c1() [Not exported] - call Fun -> OK if Fun is fully qualified",2,
["t:stacktrace/0","t:c1/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c1() [Not exported] - call Fun -> OK if Fun is fully qualified",1,
["t:stacktrace/0","t:c1/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"init terminating in do_boot",killed}
Crash dump was written to: erl_crash.dump
init terminating in do_boot (killed)
↑ 匿名関数もだめよ。
erl -boot start_clean -eval 't:cd(3).' -s init stop
Eshell V6.3 (abort with ^G)
"cd() - pass not fully qualified not exported fun to c1 -> death"
1> {"c1() [Not exported] - call Fun -> OK if Fun is fully qualified",3,
["t:stacktrace/0","t:c1/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c1() [Not exported] - call Fun -> OK if Fun is fully qualified",2,
["t:stacktrace/0","t:c1/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"c1() [Not exported] - call Fun -> OK if Fun is fully qualified",1,
["t:stacktrace/0","t:c1/2","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"init terminating in do_boot",killed}
Crash dump was written to: erl_crash.dump
init terminating in do_boot (killed)
↑ fun c1/2
は fully qualified じゃないので死にます。
erl -boot start_clean -eval 't:d(t, d, 3).' -s init stop
Eshell V6.3 (abort with ^G)
1> {"d() - fully qualified call -> OK",3,
["t:stacktrace/0","t:d/3","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"d() - fully qualified call -> OK",2,
["t:stacktrace/0","t:d/3","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"d() - fully qualified call -> OK",1,
["t:stacktrace/0","t:d/3","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1>
↑ apply/3
を使っても大丈夫。
erl -boot start_clean -eval 't:x(3).' -s init stop
Eshell V6.3 (abort with ^G)
"x() - will call a, consume no stack frame -> OK"
1> {"a() - fully qualified call -> OK",3,
["t:stacktrace/0","t:a/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"a() - fully qualified call -> OK",2,
["t:stacktrace/0","t:a/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"a() - fully qualified call -> OK",1,
["t:stacktrace/0","t:a/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1>
↑ x/1
→ a/1
と呼ばれて a/1
でずっとループするので x
は古くなって死にそうに見えますが、実は stacktrace を見ると分かるように
t:x/1
という stack frame がありません。
tail call なので go to に置き換えるという最適化がかかっていると思われます。
なので死にません。お... おう。
お... おう。
erl -boot start_clean -eval 't:y(3).' -s init stop
Eshell V6.3 (abort with ^G)
"y() - will call a, consume a stack frame -> death"
1> {"a() - fully qualified call -> OK",3,
["t:stacktrace/0","t:a/1","t:y/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"a() - fully qualified call -> OK",2,
["t:stacktrace/0","t:a/1","t:y/1","erl_eval:do_apply/6","init:start_it/1",
"init:start_em/1"]}
1> {"init terminating in do_boot",killed}
Crash dump was written to: erl_crash.dump
init terminating in do_boot (killed)
↑ tail call じゃなくすると... t:y/1
が stacktrace に現れました。すると y は古くなるので死にます。
動作が「実際のスタックフレームがどうなっているか」に依存するので、気をつけないと思わぬ挙動に悩むことになりそうです。
その他
Hot code swapping pitfalls in Erlang
... try-catchを使った時、末尾再帰になってるからいいやと思ってたらなってなくて死んだでござる、みたいな話もあったりするので要注意です。
まとめ
ということで、「概要」と「やってみた」を書いてみましたがいかがだったでしょうか。
ハマりどころとしては、reload/1
内で単に code:purge/1
, code:load_file/1
だけ呼んでも実際の関数本体が更新されていないと更新されない、という動作をするケースがあったので、
ダミーの timer:sleep/1
を呼んでその引数を sed で書き換えることで2種類のソースとそれに対応する .beam を用意しておいて、load 時に切り替えるという面倒なことをやるハメになってしまいました.....
さてこの続きは @sile さんが書いてくれるんじゃないかなと思っています!
ではでは〜
追記
@sile さんが書いてくれました。
ホットコードローディング時のプロセスクラッシュについて
どうやら、古い関数(匿名関数や完全修飾でない関数が古くなったもの)を保持しているだけでそのプロセスが死ぬようです。
匿名関数や完全修飾でない関数は長期間保持するべからず、ということで。