matarillo.com

The best days are ahead of us.

C# 5の非同期、パート8: もっと例外

2020-03-31 23:22:20

原文

(この記事では、外因性の(exogenous)例外厄介な(vexing)例外間抜けな(boneheaded)例外致命的な(fatal)例外について話す。 これらの用語の定義については、この記事を参照してもらいたい。

プロセスの中で未処理例外が発生した場合、明らかに何か悪いことや予期していなかったことが起こったことになる。 致命的な例外が発生した場合、プロセスを救うことはできない。未処理のままにしておくか、ログに記録してから投げ直した方が良いだろう。 厄介な例外や外因性の例外であることが予想されていた場合は、それに対応するハンドラが用意されているはずだ。 厄介な例外や外因性の例外が処理されないのはバグだが、 おそらくプログラムのアルゴリズムに論理的な問題があることを示すものではなく、単なる見落としだろう。

しかし、もし間抜けな例外が処理されないならば、それはあなたのプログラムが非常に深刻なバグを持っている証拠であり、その動作を継続できないほどひどいバグだ。 間抜けな例外はそもそも投げられるべきではない。この例外は決して処理してはいけないし、この例外が発生する可能性がないことを確実にしなければならない。 間抜けな例外が投げられた場合、どのようなロックが早期にリリースされたのか、どのような内部状態が壊れているのか、 あるいは矛盾しているのか、などなど、まったくわからないのだ。自信を持って何かをすることはできない。 このような場合の最善の策は、これ以上事態が悪化する前に積極的にプロセスをシャットダウンすることだ。

厄介な例外や外因性の例外にハンドラがないというバグと、 実装が壊れていてプログラムをクラッシュさせてしまったというバグの違いを簡単に見分けることはできない。 最も安全なのは、未処理例外はすべて致命的な例外か、間抜けな例外であると仮定することだ。 どちらの場合も、正しいのは、直ちにプロセスを停止させることだ。

この哲学は CLR における未処理例外の実装の根底にある。 CLR v1.0 の時代には、メインスレッド上の未処理の例外は、プロセスを積極的に停止させるというポリシーがあったが、 ワーカースレッドで処理されていない例外が発生した場合は、単にスレッドを kill してメインスレッドを実行したままにしていた。 (そして、ファイナライザースレッドの例外は無視され、ファイナライザは実行され続けた。) これは悪い選択であることが判明した。 これがもたらすシナリオは、サーバがバグだらけのサブシステムを割り当てて、ワーカースレッドの束に何らかの作業をさせるというものだ。 すべてのワーカースレッドが静かに停止し、ユーザは決して来ない結果を辛抱強く待っているサーバで立ち往生することになる。 なぜなら、結果を出すスレッドがすべて消えてしまったからだ。 このような問題をユーザが診断するのは非常に困難だ。 難しい問題に猛烈に取り組んでいるサーバと、ワーカーが全員死んでしまって何もしていないサーバは、外から見た感じではほとんど同じに見える。 そのため、CLR v2.0 では、ワーカースレッドで処理されていない例外が発生した場合、デフォルトでプロセスがダウンするようにポリシーが変更された。 失敗については、沈黙するのではなく、騒ぎたててもらいたいものだ。

私が属する流派によれば、ソフトウェアデバイスの突然で壊滅的な故障は、もちろん不幸なことではあるが、 多くの場合、 問題に対してソフトウェアが注意を喚起することは、問題を修正できるのでまだ望ましいのだ。 一方、悪い状態をごまかそうとするのは、セキュリティホールを導入したり、途中でユーザデータを破損させたりする可能性がある。 予期せぬ例外に遭遇した時点で終了するソフトウェアは、その欠陥を利用した攻撃者に対して脆弱性が低いソフトウェアだ。 リプリーが言ったように、物事がうまくいかないときは、離陸して軌道上から現場全体を核攻撃すべきだ。それが唯一の方法なのだ。 しかし、この素晴らしい哲学は非同期シナリオにも通用するのだろうか?

前回、私は二つの興味深いシナリオについて述べた。 (1) タスクを返す非同期メソッドが複数のタスクに対して WhenAllWhenAny を行い、そのうちのいくつかのタスクで例外が発生した場合はどうなるのか? そして、(2) void を返す非同期メソッドが異常終了したタスクを待っていた場合はどうなるのか?その例外はどうなるだろうか?

WhenAllは、完了したサブタスクからすべての例外を収集し、それらを集約例外に詰め込む。 すべてのサブタスクが完了すると、集約された例外でそのタスクを異常終了させる。 しかし、少し奇妙な事実だが、デフォルトでは、EndAwait はこれらの例外のうち最初の例外のみを再投入する。 より一般的なシナリオは、“await"を取り囲むtry-catchが特定の例外のセットをキャッチするというものだ。 そのため、集約された例外をアンパックするコードを常に書かなければならないのは、負担が大きいように思える。 これは少し奇妙に見えるかもしれない; なぜこれが合理的な考えなのかについての詳細はJon Skeetの最近の投稿を参照してほしい

WhenAny の場合も同様だ。 最初のサブタスクが正常でも異常でも完了したとする。 これにより、WhenAny タスクは正常または異常のいずれかで完了する。 追加のサブタスクの 1 つが異常終了したとする。 その例外はどうなるだろうか? WhenAny は完了している: それはすでに完了していて、その継続を呼び出している。 継続は、まだ実行されていない場合は、何らかのワークキューで実行されるようにスケジュールされている。

WhenAllとWhenAnyの両方のケースで、WhenAllやWhenAnyタスクの作成者に「監視されない」例外が発生する可能性がある。 つまり、これらのケースでは、例外がスローされ、自動的にキャッチされ、キャッシュされ、二度とスローされない可能性があり、 同等の同期コードではプロセスが停止することになる。

これは潜在的に悪いことのように思える。非同期的に待ち受けていたタスクからの観察されない例外は、同等の同期コードがそうであるように、プロセスを停止させるべきだろうか?

観察されていない例外がプロセスを停止させるべきだと決めたとしよう。 それはいつ起こるだろうか? つまり、例外が実際に再スローされなかったことが明確にわかるのはいつだろうか? 結果が観測されることなくタスクオブジェクトがファイナライズされた場合にのみ、それがわかる。 結局のところ、異常終了した「生きている」タスクオブジェクトは、将来いつでもその続きが実行される可能性がある。 その継続がいつスケジュールされるのかを知ることはできない。 このスレッド上には、いくつものタスクがキューに入れられ、タスクが異常終了してからその結果が要求されるまでの間に実行される可能性がある。 タスクオブジェクトが生きている限り、その例外が観測される可能性がある。

OK、それはいいとしよう。 タスクがファイナライズされていて、異常終了した場合は……どうすればいいだろうか? その例外をファイナサイザースレッドに投げる?そうだ!そうすればプロセスが停止するね? CLR v2.0 以降では、スレッド上の未処理例外はプロセスを停止させる。 しかし、一歩下がってみよう。 思い出してみよう、なぜ観測されない例外でプロセスを停止させたいのだろうか? 哲学的な理由を述べると:それは、これが潜在的に恐ろしい、セキュリティに影響を与えるような状況を示していて、 直ちに終了させる必要があることを示している間抜けな例外なのか、 それとも単に予期せぬ外因性の例外のためのハンドラが欠けていた結果なのか、私たちにはわからないからだ。 安全なのは、これはセキュリティに影響を与えるような間抜けな例外であったのだと言って、直ちにプロセスを停止させることだ。 これは今私たちがやろうとしていることとはまったく違う! タスクがガベージコレクタに回収されるのを待ってから、ファイナスイザースレッドでプロセスを停止させようとしているからだ。 しかし私たちの方法では、タスクに例外が記録されてからファイナライザが例外を観測するまでの間に、 潜在的にさらに何十ものタスクを実行し続けていたことになるし、 そのどれもが、間抜けな例外によって引き起こされる矛盾した状態を利用していた可能性があるのだ。

さらに、現実的なコードで例外を投げる非同期タスクのほとんどは、「Nullリファレンスを参照した」とか「空のスタックから要素を取り出そうとした」というような間抜けな例外ではなく、実際には「このウェブサービスのパスワードが間違っている」とか「このファイルを読む権限がない」とか「この操作がタイムアウトした」といった外因性の例外を投げることになると予想している。 このような現実的なケースでは、何らかの理由でタスクが異常終了しても誰もその結果を見ようとしない場合、それは非同期の作業単位が放棄されたからだと言う方がはるかに信憑性が高いように思える。それなら、ウェブサーバへの接続に問題が発生したなどのサブタスクは安全に無視できる。

要するに、確定されたタスクからの観察されない例外は、誰も気にしないものであり、おそらく無害なものであり、もしそれが有害であったならば、それ以上の害を防ぐために行動を起こすのをすでに遅らせすぎているということになる。 どちらにしても、無視したほうがいいかもしれない。

このことは、非同期プログラミングが新しい種類のセキュリティ脆弱性を導入していることを示している。 もし、通常はプロセスを停止させるバグによって引き起こされるセキュリティ脆弱性があるとして、しかもそのコードを非同期に書き換え、その結果バグのあるタスクが例外を観測せずに放棄されたとしたら、そのバグは現在脆弱なプロセスを積極的に破壊する結果にはならないかもしれない。 また、最終的に例外が観測されたとしても、バグが脆弱性を導入してから例外が観測されるまでの間には時間の幅があるかもしれない。 その幅は、攻撃者が成功するのに十分な大きさになるかもしれない。 これは、物事がねじ曲がって連鎖しており、間違った方向に進み続けなければ起こらないことのように聞こえるかもしれないが(実際にそうなのだが)、攻撃者は手に入るものは何でも手に入れるだろう。 彼らは狡猾で、時間はいくらでもあり、一度だけ成功すればいいのだから。

タスクを待つけれども戻り値がvoidのメソッドがどうなるかはまだ何も言っていなかった。 これは、“fire and forget"メソッドのようなものと考えることができる。 おそらく voidを返すボタンクリックのイベントハンドラは、非同期的にデータを取得してユーザーインターフェースを更新するのを待っているのだろう。 イベントハンドラの「呼び出し元」はタスクを保持することを気にすることはないし、その結果を観察することもない。 では、データ取得タスクが異常終了した場合はどうなるだろうか?

その場合、voidを返すメソッド(自分自身を継続として登録したのを覚えているだろうか?)が再び起動すると、タスクが異常終了したかどうかをチェックする。 もし異常終了したのであれば、すぐにその呼び出し元に例外を再スローするが、これはもちろん何らかのメッセージループだろう。 ここでの行動計画は、上で説明した動作と一致していると思う。 そのシナリオでは、非同期メソッドのfire-and-forgetが何らかの良心的な方法で失敗したと仮定して、メッセージループは例外を破棄するだろう。

長年、未処理例外について「軌道上からの核」哲学を提唱してきた私は、感情的には納得できないのだが、タスクベースの非同期で例外を処理するためのこの戦略に対抗する説得力のある議論を展開することができない。 読者の皆さん。あなたはどう思うだろうか?あなたの意見では、タスクの例外が観察されないシナリオで行うべき正しいことは何だろうか?

そして、やや不吉な注意書きになるが、新しいタスク非同期パターンについての話はしばらく休ませてもらう。 CTPをダウンロードして、フィードバックや質問を送り続けてほしい。 この新機能を使って、どのようなことがうまくいくのか、うまくいかないのかを考えてみてほしい。

次回: アメリカの感謝祭の後、さらに素晴らしい冒険をピックアップする。 今年は19人分の七面鳥を料理しているが、それ自体がかなりの冒険になるはずだ。


インデックスへ戻る