パート9: Deferred 再入門

コールバックのさらなる影響

コールバックについてもう一度考えるために、一息休憩を入れましょう。 Twisted 風に簡単な非同期プログラムを書くのに十分なほど遅延オブジェクトのことは理解できてきましたが、 Deferred はもっと複雑な設定をすればさらに多くの機能を提供してくれます。 ということで、もっと複雑な設定をいくつか練り上げて、コールバックを伴ったプログラミングでそれらがどのような種類の挑戦に直面するかを考えます。 それらにどうやって立ち向かっているかをみていきましょう。

議論を盛り上げるために、私たちの詩のクライアントに仮想的な機能を追加することにしましょう。 コンピュータ・サイエンスの教授が詩に関連する新しいアルゴリズム、”Byronification Engine”、を考え出すときに直面する問題を想像してみてください。 この無限アルゴリズムは入力としてひとつの詩を受け取って、元の詩に似ていますが Lord Byron 風に記述された新しい詩を生成します。 さらには、私たちの先生は親切なことに Python でお手本となる実装を提供してくれています。こんなインターフェイスです。

class IByronificationEngine(Interface):

    def byronificate(poem):
        """
        Return a new poem like the original, but in the style of Lord Byron.

        Raises GibberishError if the input is not a genuine poem.
        """

たいていの最先端のソフトウェアのように、この実装にはいくつかのバグがあります。文書化された例外に加えて、 byronificate メソッドはときどきランダムな例外を送出します。教授が対処し忘れた滅多に発生しない状況に嵌ったときです。

また、reactor の扱いに悩まされることなくメインスレッドで単純に呼び出せるくらいに、このエンジンが十分高速に実行できると仮定しましょう。私たちのプログラムには以下のように動作して欲しいのです。

  1. 詩のダウンロードを試みなさい。

  2. ダウンロードに失敗したら、詩を取得できなかったとユーザに伝えなさい。

  3. 詩を取得できたら、Byronification エンジンを使って変換しなさい。

  4. エンジンが GibberishError を送出したら、詩を取得できなかったとユーザに伝えなさい。

  5. エンジンがその他の例外を送出したら、元の詩をそのままにしておきなさい。

  6. 詩を持っていれば出力しなさい。

  7. プログラムを終了しなさい。

ここでの考え方では、 GibberishError は私たちが実際の詩をまったく取得できなかったことを意味します。このため、ユーザにダウンロードが失敗したとだけ伝えることになるでしょう。これはデバッグするときには全く持って役に立ちませんが、ユーザは詩を得られたかどうかを知りたいだけなのです。他方で、もしもエンジンが他の理由で失敗したら、サーバから取得した詩をそのまま使うことになるでしょう。結局のところ、たとえそれが Byron 式の特徴をもっていなくても、ある詩が他のものより劣っているなんてことはないのです。

同期バージョンのコードはこんな感じになります。

try:
    poem = get_poetry(host, port) # synchronous get_poetry
except:
    print >>sys.stderr, 'The poem download failed.'
else:
    try:
        poem = engine.byronificate(poem)
    except GibberishError:
        print >>sys.stderr, 'The poem download failed.'
    except:
        print poem # handle other exceptions by using the original poem
    else:
        print poem

sys.exit()

いくらかリファクタリングすればプログラムの概要をもっと簡単にできるでしょう。しかし、この状態はロジックの流れをとても明確に表しています。 これと同じ枠組みを実装して、一番直近の詩のクライアント (遅延オブジェクトを使うものです) を更新したいのです。 しかし、パート10まではやりません。 今のところはかわりに クライアント 3.1 でどうやるのかを想像してみましょう。これは遅延オブジェクトを全く使っていない最後のクライアントです。 例外を扱うのに悩まなかったと仮定してみると、代わりに get_poem コールバックを次のように変更するだけでした。

def got_poem(poem):
    poems.append(byron_engine.byronificate(poem))
    poem_done()

byronificate メソッドが GibberishError かその他の例外を送出すると何が起きるでしょうか?パート6の”図11:詩のコールバック”を見ると、以下に挙げることが分かります。

  1. ファクトリ内の poem_finished コールバックまで例外は広がるでしょう。このメソッドが実際にコールバックを呼び出しています。

  2. poem_finished は例外を捕捉しませんので、プロトコルの poemReceived まで呼び出しが続くでしょう。

  3. そして connectionLost まで続きます。これもプロトコルの中です。

  4. Twisted 自身の内部まで続いていくと、最終的には reactor で終了します。

ここまで学んできたように、reactor はクラッシュせずに例外を捕捉してログを出力します。 しかし、ユーザに詩をダウンロードできなかったとは伝えてくれないでしょう。 reactor は詩のことも GibberishError のことも知りません。全ての種類のネットワーキングで使われる汎用的なコードだからです。詩には関係ないネットワーキングでさえも使われるのです。

上で述べた4つのそれぞれのステップで、例外はひとつ前のステップよりも一般的な目的のコードに移ることに気をつけましょう。 そして、 私たちがこのクライアントに望むような特定の方法でエラーを扱うコード片の中の例外には、 get_poem の後にステップがありません。 この状況は同期コードで例外が伝播していくのとは基本的に真逆です。

図15を見てください。同期版の詩のクライアントで目にするコールスタックを表しています。

_images/p09_sync-exceptions1.png

図15:同期コードと例外

main 関数は高コンテキストです。なぜそこに存在するのか、どうやって振舞うべきかをプログラム全体に渡って知っているということです。 典型的に main はユーザがプログラムにどうやって動いて欲しいか (そしておそらく、問題が生じたときに何をすべきか) を指示するコマンドラインオプションにアクセスするでしょう。 また、非常に限定された目的も持ちます。コマンドラインの詩のクライアントが動く方法です。

他方で、ソケットの connect メソッドは低コンテキストです。 何らかのネットワークアドレスに接続すると考えられていることしか知りません。 接続先が何であるかや、なんでそれに今すぐ接続する必要があるのかを知りません。 しかし、 connect は極めて汎用的な目的を持っています。あなたが接続しようとしているサービスが何であっても、それを使えるのです。

get_poetry はその中間にあります。 何らかの詩を取得していることを知っています。それが唯一のできることです。けれども、もしそれができないときは何が起きるべきでしょうか。

connect が投げた例外は、低コンテキストで汎用的なコードから高コンテキストで特定の目的のコードに向かってスタックを登っていくでしょう。何かおかしいことが起きたときに何をすべきかを知っている (もしくは Python インタープリタを引っぱたいてプログラムをクラッシュさせてしまう) 十分なコンテキストを持つ何らかのコードに到達するまでです。

もちろん例外は、文字通り高コンテキストのコードを探しながらというよりは、それが何であれ単にスタックを上がっていくだけです。 典型的な同期プログラムでは、「スタックを上がる」と「高コンテキストに向かう」は同じ方向性のことです。

ここで、上のクライアント 3.1 に対する理論的な変更を思い出してください。 私たちが分析したコールスタックは図16で図にしました。いくつかの関数は簡易表記にしてあります。

_images/p09_async-exceptions4.png

図16:非同期コールバックと例外

問題点は明確ですね。 コールバックの中で、低コンテキストのコード (reactor) が高コンテキストのコードを呼び出しています。 このため、例外が発生したときに、それが即座に処理されないで発生したのと同じスタックフレームで閉じられないと、処理されない例外になってしまいます。 例外がスタックを上がっていくたびに低コンテキストなコードに移動していきますので、それが何をしているのか分かりにくくなります。

例外が Twisted の中心的な部分に立ち入ってしまうと、もうどうしようもありません。 例外を処理することはできず、記録されるだけになるでしょう (reactor が最終的にクラッシュさせます)。 このため、私たちが「昔ながらの簡易な」コールバック (遅延オブジェクトを使わないもの) を使ってプログラムを書くときは、Twisted 固有の部分に入り込んでしまう前に全ての例外を逃さないように注意しなくてはなりません。少なくとも、自分たちのルールに沿ってエラーを扱う機会を持ちたいならば。 私たち自身のバグによって引き起こされた例外も含みます。

バグは私たちのコードの至る所に存在しえますので、 try/except 節のもう一段階「外の層」ですべてのコールバックを包む必要があります。これによって、打ち間違いから起こる例外も同様に処理されるようになります。 同じことはエラー用コールバックにも当てはまります。エラーを処理するコードにも同様にバグの可能性がありますから。

それにしても、これはあまり良いやり方ではありませんね。

遅延オブジェクトを使った良い構造

Deferred クラスはこの種の問題を解決することを助けてくれることが分かりました。 遅延オブジェクトがコールバックかエラー用コールバックを呼び出すときはいつでも、引き起こされる可能性のあるいかなる例外も捕捉してくれます。 言い換えると、遅延オブジェクトは try/except 節の「外の層」のように動作してくれます。遅延オブジェクトを使っている限りは私たちはその層に関して全く記述する必要がありません。 といっても、遅延オブジェクトは自分が捕捉した例外に対して何をするのでしょうか?簡単です。チェーンの次のエラー用コールバックに例外 (Failure 形式になっています) を渡していくだけです。

遅延オブジェクトに私たちが付け足した最初のエラー用コールバックは、遅延オブジェクトの errback(err) メソッドが呼ばれたときに通知されるいかなるエラーの状態も処理するために、そこで待ち受けてくれます。 けれども二番目のエラー用コールバックは、最初のコールバックか最初のエラー用コールバックによって引き起こされた全ての例外を処理するでしょう。 後続のものも順次そうなります。

図12:遅延オブジェクト を思い出してください。チェーンにいくつかのコールバックとエラー用コールバックを持つ遅延オブジェクトの視覚的表現です。 最初のコールバックとエラー用コールバックのペアをステージ0、次のペアをステージ1、と順番にそのように呼ぶことにしましょう。

あるステージ N において、コールバックかエラー用コールバックのどちらか (実行された方です) が失敗したとすると、適切な Failure オブジェクトを伴ってステージ N+1 のエラー用コールバックが呼ばれます。ステージ N+1 のコールバックは呼び出されません。

チェーンを辿りながらコールバックによって引き起こされた例外を渡すことによって、遅延オブジェクトは「より高コンテキスト」の方向に例外を移動させます。 遅延オブジェクトの callbackerrback メソッドを呼び出すことは、呼び出し元にとって (遅延オブジェクトを一回しか呼び出さない限り!) 決して例外という結果にはなりませんので、より低レベルのコードは、例外を捕捉することに関して心配することなく、安全に遅延オブジェクトを発火させることができます。 その代わりに、より高レベルのコードは遅延オブジェクトにエラー用コールバックを付け加える (addErrback などで) ことで例外を捕捉します。

この同期コードでは、捕捉されるとすぐに例外は伝播することを止めます。 エラー用コールバックはどうやってそれがエラーを「捕まえた」という合図を送るのでしょうか?これもまた簡単なことです。例外を送出しないことでそうなります。 そしてこの場合は、実行しているものはコールバック上で切り替わります。 あるステージ N において、もしもコールバックかエラー用コールバックのどちらかが成功する (つまり例外を出さないということです) と、ステージ N からの戻り値を伴ってステージ N+1 のコールバックが呼ばれます。ステージ N+1 のエラー用コールバックは呼ばれません。

遅延オブジェクトが作動するパターンについて分かったことをまとめてみましょう。

  1. 遅延オブジェクトは順序付けられたコールバックとエラー用コールバックのペア (ステージ) からなるチェーンを含みます。ペアは、それが遅延オブジェクトに付け加えられた順番通りに管理されます。

  2. ステージ 0、最初のコールバックとエラー用コールバックのペアです、は遅延オブジェクトが発火されたときに呼び出されます。遅延オブジェクトが callback メソッドで発火されるとステージ 0 のコールバックが呼ばれます。 errback メソッドで発火されるとステージ 0 のエラー用コールバックが呼ばれます。

  3. ステージ N が失敗すると、ステージ N+1 のエラー用コールバックが例外 (Failure でラップされています) を第一引数として呼ばれます。

  4. ステージ N が成功すると、ステージ N+1 のコールバックがステージ N の戻り値を第一引数として呼ばれます。

このパターンを図示したのが図17です。

_images/p09_deferred-2.png

図17:遅延オブジェクト内の制御の流れ

緑の線はコールバックかエラー用コールバックが成功したときに起こることを示し、赤い線は失敗したときを示します。 これらの線は制御の流れと例外および戻り値の流れの両方がチェーンを辿っていく様子を表します。 図17は遅延オブジェクトが通るかもしれない全ての可能性のパスを表しています。しかし、ある特定の場合に辿るのはたった一つのパスだけです。 図18は作動する可能性のあるひとつのパスを表します。

_images/p09_deferred-31.png

図18:遅延オブジェクトが作動するパターンのひとつ

図18では遅延オブジェクトの callback 関数が呼ばれます。それはステージ 0 のコールバックを呼び出します。そのコールバックは成功し、制御 (それと、ステージ 0 からの戻り値) はステージ 1 のコールバックに渡されます。しかし、ここでのコールバックは失敗 (例外を発生させます) し、ステージ 2 ではエラー用のコールバックに制御が移ります。エラー用コールバックはエラーを処理 (例外を発生させません) し、制御は通常のコールバックチェーンに戻ります。そして、ステージ 2 のエラー用コールバックの結果を伴ってステージ 3 のコールバックが呼び出されます。

図17で描けるどんなパスもチェーンのそれぞれのステージを通りますが、どのステージでもコールバックとエラー用コールバックのペアのどちらか片方しか呼び出されないことに気をつけてください。

図18では、ステージ 3 から緑の矢印を引っ張ることで、そのコールバックが成功しているように図示しています。しかし、この遅延オブジェクトにはそれ以降のステージがありませんので、ステージ 3 の結果は実際にはどこにも行きません。コールバックが成功すれば問題はありません。 しかし、そうでない場合はどうなるでしょうか? もしも遅延オブジェクトの最後のステージで失敗してしまうと、それを捕捉するための errback がありませんので、その失敗は捕捉されなかったと言います。

同期版のコードでは捕捉されない例外はインタープリタをクラッシュさせてしまうでしょう。いわゆる普通の非同期コードでは、捕捉されない例外は reactor に捕まえられてログに出力されます。 遅延オブジェクト内の捕捉されない例外はどうなるでしょうか? とりあえずやってみましょう。 twisted-deferred/defer-unhandled.py にあるサンプルコードを見てください。 このコードは、いつも例外を投げるコールバックをひとつ持つ遅延オブジェクトを発火させます。 プログラムからの出力は次のようになります。

Finished
Unhandled error in Deferred:
Traceback (most recent call last):
  ...
--- <exception caught here> ---
  ...
exceptions.Exception: oops

いくつか注意することがあります。

  • 最後の print は実行されますので、例外によってプログラムがクラッシュしてしまうわけではありません。

  • トレースバックが出力されるだけで、インタープリタをクラッシュさせるものではないことを意味します。

  • トレースバックのテキストは遅延オブジェクト自身が例外を捕らえた場所を教えてくれます。

  • “Unhandled” というメッセージは “Finished” の後に出力されています。

よって遅延オブジェクトを使うとき、コールバックで捕捉されない例外には、デバッグを目的として、引き続き注意が払われます。しかし、たいていはプログラムをクラッシュさせることにはなりません(実際、それらの例外は reactor に作用すらせず、遅延オブジェクトが真っ先に捕まえるでしょう)。 ところで “Finished” が最初にやってくる理由は、遅延オブジェクトがガベージコレクタに回収されるまで “Unhandled” メッセージが実際には出力されないからです。 その理由は先々のパートで見ていきましょう。

さて、同期コードでは引数無しで raise キーワードを使うことで例外を「再送出 (re-raise)」できます。そうすることで、扱っていた元々の例外を投げ、完全に処理することなくエラーに対していくつかの操作を実行できます。 エラー用コールバックで全く同様のことができますね。 もしも次の条件のどちらかを満たすなら、遅延オブジェクトはコールバックおよびエラー用コールバックが失敗したとみなすでしょう。

  • コールバックかエラー用コールバックが何らかの種類の例外を発生させる。

  • コールバックかエラー用コールバックが Failure オブジェクトを返す。

エラー用コールバックの最初の引数は常に Failure なので、エラー用コールバックはそこで実行したいことを実施した後に最初の引数を戻り値とすることで例外を「再送出 (re-raise)」できます。

コールバックとエラー用コールバック、2の2乗

以上の議論から明らかなことのひとつは、遅延オブジェクトにコールバックとエラー用コールバックを追加する順番には、遅延オブジェクトがどうやって発火するかで大きな違いがあることです。 遅延オブジェクトについてもうひとつ明らかなことは、コールバックとエラー用コールバックはいつもペアで起こる、ということです。 Deferred クラスには、チェーンにペアを追加するために使える四つのメソッドがあります。

  1. addCallbacks

  2. addCallback

  3. addErrback

  4. addBoth

名前からも明らかですが、最初と最後のメソッドはチェーンにペアを追加します。 しかし、真ん中の二つのメソッドもコールバックとエラー用コールバックのペアを追加します。 addCallback メソッドは明示的なコールバック (メソッドに渡す引数) と、暗黙的な「何もしない (pass-through)」エラー用コールバックを追加します。 何もしない関数とは、最初の引数を返すだけのダミー関数です。 エラー用コールバックへの第一引数はいつも Failure なので、何もしないエラー用コールバックは常に失敗し、チェーンの次のエラー用コールバックにエラーを送ります。

あなたが間違いなく思った通りに、 addErrback 関数は明示的なエラー用コールバックと暗黙的な何もしないコールバックを追加します。 コールバックへの第一引数は Failure ではありませんので、何もしないコールバックはチェーンの次のコールバックにその結果を送ります。

遅延オブジェクトのシミュレータ

遅延オブジェクトがコールバックとエラー用コールバックを実行させる方法に慣れていくのは良いことです。 twisted-deferred/deferred-simulator.py の Python スクリプトは「遅延オブジェクトのシミュレータ (deferred simulator)」です。遅延オブジェクトが発火する様子を探っていくための小さな Python プログラムです。 スクリプトを実行すると、コールバックとエラー用コールバックのペアの一覧を一行ずつ入力するように促されます。 それぞれのコールバックもしくはエラー用コールバックは次のいずれかです。

  • 与えられた値を返すもの (成功)

  • 与えられた例外を発生させるもの (失敗)

  • 引数をそのまま返すもの (何もしない)

シミュレートしたい全ての組み合わせを入力するとスクリプトが出力を生成します。高解像度のアスキー・アートで、ダイアグラムがチェーンの内容と、 callback および errback メソッドで発火されるパターンを表します。 全てを正確に見るために、ターミナルを開いているウィンドウをできるだけ広く使いたくなるでしょう。 ダイアグラムをひとつずつ出力させるためには --narrow オプションを使うこともできます。 しかし、横同士に出力させた方がそれらの関係を確認しやすいでしょう。

もちろん実際のコードでは、コールバックが毎回同じ値を返すことはありませんし、ある関数は成功したり失敗したりします。 しかしこのシミュレータは、あるコールバックとエラー用コールバックの設定において、通常の結果と失敗の組み合わせの場合に何が起こるかを図示してくれます。

まとめ

コールバックを使ったプログラミングは低コンテキストと高コンテキストのコードの間にある通常の関係を反転させますので、コールバックについてより深く考えた後では、コールバックに例外をスタックに積み上げさせても同様には動作しないことに気付きます。 そして Deferred クラスは例外を捕捉し、その例外を上位コンテキストの reactor に受け渡すのではなくチェーンの下位コンテキストに送っていくことで、この問題に取り組んでくれます。

通常の結果 (値を返します; return) が同様にチェーンを下っていくことも学びました。 この二つの事実の組み合わせは、往来する発火パターン (原文; criss-cross firing pattern) の一種ということになります。 遅延オブジェクトがそれぞれのステージにおける結果次第で、コールバックとエラー用コールバックで繋がれた線上を行ったりきたりするからです。

この知識を身に付けて、”パート10: 変換された詩”では、私たちが作っている詩のクライアントをいくつかの詩の変換ロジックで書き換えていきましょう。

おすすめの練習問題

  • Deferred における四つそれぞれのメソッドの実装を調査しましょう。コールバックとエラー用コールバックを追加するものです。全てのメソッドがコールバックのペアを追加することを確認してください。

  • このコードの違いを調査するために遅延オブジェクトのシミュレータを使ってください。
    deferred.addCallbacks(my_callback, my_errback)
    

    もうひとつはこのコードです。

    deferred.addCallback(my_callback)
    deferred.addErrback(my_errback)
    

    後のふたつのメソッドはペアのうちの片方のメンバーとして暗黙的に関数を受け渡すことを思い出してください。