soranoba
soranoba Author of soranoba.net
programming

let it crashが生んだ誤解

この記事はQiitaに投稿した記事のコピーです。一部システムが対応している表記の都合で変更しています。
投稿日時は上記記事の投稿日で設定しています。


ここ2年程のelixir人気に伴い, BEAM (つまりerlangとelixir) を使う人が増えました.
しかし, let it crashという思想は誤解を残したまま世に広まったように感じています.
郷に入っては郷に従え. let it crashの思想をしっかり理解して実装していきたいものです.

前置き

大層なことを書きましたが, あくまでも個人的な見解であり, ポエムです.
Erlang/OTPチームの見解とは異なる可能性がある点に気をつけてください. また, ご意見があればコメント欄に頂ければ幸いです.

なお, Elixirのタグも付けていますが, 記事中のコードは全てErlangです.
Elixirを書いている人にも知って欲しい, 「届けこの想い!」ということでタグは付けています.

これらの点をご承知起きの上で読んで頂ければ幸いです.m(_ _)m

let it crashの根幹は復旧にあり

多くの言語は防御的な方法を取ります. 1 Javaが典型だと思いますが, 例外が発生しうるものを列挙し, それが起きた場合のハンドリングを書くというものです.
これを人間が正しく実装するのには限界があると考え, アプローチを変えたのがlet it crashの思想です.

let it crashを端的に説明するとすれば,「常にバグは起きうるのだから, その前提で復旧する為の方法を最初に考える思想」であると私は答えます.

E本の作者のfredの記事がとても参考になるので, 合わせて読んでみると良いかもしれません.
印象的だった箇所を1つだけ上げると, 「ロケットを作る際に最悪の際に爆破させられるようにする. これとlet it crashは一緒だ」という趣旨の記述があります. 被害を最小化させる為にcrashするという趣旨の意味です.

あくまでもcrashは手段に過ぎないと私は思っています.

再起動戦略はsupervisorだけにあらず

では, erlangが提供する復旧の仕組みとはなんでしょうか?
復旧する為の方法として, supervision tree (監視ツリー) という単語が使われ, supervisorの仕組みが紹介されるので, 再起動戦略=supervisorと勘違いされがちですが, それは大きな間違いです.

|- NodeA
|   |- Release App
|        |- App1
|        |    |- supervisor
|        |
|        |- App2
|
|- NodeB

システム全体の次の大きな要素はノードです. Failoverの仕組みがあり, 再起動とは少し異なりますが, 復旧の方法が用意されています.
次の単位はRelease Application2です.
その次がライブラリ (OTP Application) です. 一部のOTP Applicationを切り離した状態で動く場合はそれを許容し, そうでなければRelease Application ごと停止します.

現実にはそのようなモデルは中々ないので, 開発用途でしか使われないと思いますが用意されています.
その次の単位がsupervisorです. ここは説明を省きますが, supervisor自身も子が復旧不能な時は停止する点に注意が必要です.

このように, erlangはsupervisorよりも大きな階層まで再起動/Failoverについての仕組みを提供しています.

もちろん, 必ずしも全てにおいて復旧する必要はありません. しかし, supervisorから上の再起動についての考慮はしていますか?

crashする可能性を考慮することは決して簡単ではない

supervisorから上の再起動を考慮する必要なんてあるのかと思うかも知れません.
1つ, よく見かけるBad Patternを紹介しましょう.

  • applicationの起動時に, 他のapplication下にあるsupervisorに子を作成する.
  • この子が存在しないと動かない
    - App1
       |- top level sup
           |- sup1
           |   |- gen_server ← ここ
    - App2
       |- ここから問題のgen_serverを起動

ここで問題となるのは, sup1の再起動が考慮されていないという点です. 「top level sup」が{0, 1} (再起動しない) になっていれば問題ないでしょうが, そうでなければ子が失われた状態で再起動するでしょう.

この状態で気軽にcrashして良いと言えるでしょうか?
crashした状態で状態の不整合 (=期待される子がいない) が起きるかも知れません.
このように, 「crashして良いのはそれを考慮して実装している時に限る」のです.

とはいえ, 人間は完璧ではないので全てcrashしてよい状態にするのは難しいでしょう.
ただ,supervisorに子を追加する前に「これがcrashしたらどうなる?」と考えるようにするだけで, 考え方が大きく変わるのではないでしょうか.
それこそがlet it crashへの第一歩だと思います.
それが難しいと感じるのであれば, let it crashの思想の元, 実装するのは難しいのではないでしょうか.

さぁ準備は万端です. crashしましょう (let it crash!!)

復旧に焦点を当ててきましたが, それが何故let it crashになったのでしょうか.
私は, 復旧のことが考慮されていれば, crashするのが状態が壊れず, リークしない一番確実な方法だからだと思っています.

catchしたら状態が不整合になった

代表的な例を挙げましょう.

    put(K, V) ->
        gen_server:call(?SERVER, {K, V}).
    
    main() ->
        case catch ?MODULE:put(K, V) of
            ok -> ok;
            _Other -> error
        end.

更新処理でgen_serverがtimeoutになった場合, 失敗したと判定する処理があったとします.
さて, これは本当に失敗しているのでしょうか?
もしかしたらメッセージキューが詰まっていて, 10秒後に更新されるかもしれません.3

catchしたらリークした

代表的な例を挙げましょう.

    write(Filename, ReadHandle) ->
        {ok, Handle} = file:open(Filename, [write]),
        try
            ok = write_loop(Handle, ReadHandle),
            ok = file:close(Handle),
            ok = file:close(ReadHandle)
        catch
            _:_ -> error
        end.
    
    write_loop(Handle, ReadHandle) ->
        case file:read(ReadHandle, 1024) of
            {ok, Bin} ->
                ok = file:write(Handle, Bin),
                write_loop(Handle, ReadHandle);
            eof -> ok;
            {error, Reason} ->
                {error, Reason}
        end.

fileを読みながら書くような処理を上記のように書いたとします.
catchされた場合, file descriptorは解放されるでしょうか?4

crashすることで終了処理が呼ばれないのではないか?

ここまでcatchを否定するようなことを書けば, 「crashしたらリークすることだってあるのでは?」と言いたくなるかもしれません. その疑問を否定しません.
gen_serverのterminateに終了処理を書いている場合がその典型でしょう.

しかし, それは「常にバグは起きうるのだから, その前提で復旧する為の方法を最初に考える思想」5に反していないでしょうか?
monitorやlinkを使って終了処理をする方法を検討してみてはいかがでしょうか.

それでもcatchしたいこともある

catchを全否定するつもりはありません.
実際OTPのコードには以下の書き方が散見されます.

    case catch execute(....) of
        {ok, Ret} ->
            ....

関数仕様/状態として, 問題ないと判断された場合はcatchしてください.
例えば, cowboyのhandler上ではresponseを返す為にcatchしたいと思います.
cowboyのhandlerが状態を持つことは恐らくないので, ここは問題ないと言えるでしょう.

まとめ

erlangを書いていると, クライアントや上司の意向でcrashできない場面が多々あるかと思います.
もしかすると, プログラマ間でもcrashとcatchで意見が分かれるかもしれません.
場面場面で選択すればよいと個人的には思いますが, 想定外のエラーをcatchしてしまうと, 何がおきるかは保証できないことは念頭に置くべきだと思います.

例外を正しくハンドリングすれば良いだろうと主張する人がいるかもしれません.
実際, ライブラリ内で全てハンドリングできるに超したことはありません.
しかし, 例外はそもそも想定されていないから発生しているのであり, それを何でもかんでもハンドリングしようとするのは, 防御的な手法に染まっていないでしょうか?

それよりも, 終了処理の漏れや不整合をmonitorやlinkで起きないように気をつけておけば, もっと気楽に実装できませんか?

let it crashは特効薬ではない

最後に. let it crashは決して特効薬ではありません.
しばしばlet it crashが一人歩きしているのを見かけますが, それは大きな間違いです.

let it crashは発想の転換に過ぎません. 従来の防御的手法と対等な立ち位置です.
これによって楽になるケースもあれば不幸になるケースもあるでしょう.

謎の迷信を信じないでください. それは最も危険な行為です.


  1. 何か名前はあるのでしょうか… 

  2. 公式の用語ではありません. 

  3. もちろんこの例は, 単に実装者が関数仕様を把握していなかったことによるバグ (仕様の食い違い) です. 

  4. 本来はtry~catch~afterで終了処理をするべきで, これはあまり良い例ではありません. シンプルに書ける例がすぐには出てこなかったので勘弁してください. 

  5. 最初に述べたlet it crashの私なりの要約です. つまり, ここではlet it crashのことを指します. 

(Updated: )

comments powered by Disqus