matarillo.com

The best days are ahead of us.

C# 5の非同期、パート7: 例外

2020-12-15 09:37:20

原文

ちょっと中断していたが、中断していたところから再開しよう(非同期タスクだけにね。わはは)。 コルーチンに似た非同期メソッドのような"再開可能な"メソッドでの例外処理は、ちょっと変どころではない。 どのように奇妙なのかを理解するには、まず、イテレータブロックの設計に関する最近の連載、 特に「プッシュ」モデルと「プル」モデルの違いについての投稿を思い出してほしい。簡単に説明しよう。

通常のコードブロックでは、普通の同期呼び出し処理を囲むtryブロックは、呼び出し内で発生した例外をすべて観測する。

try { Q(); }
catch { ... }
finally { ... }

Q()が例外を投げた場合はcatchブロックが実行され、制御が通常のあるいは例外的な方法で Q() から離れた場合は finallyブロックが実行される。 ここでは何も変わったことはない。

ここで、列挙子のMoveNextメソッドに書き換えられるイテレータブロックを考えてみよう。 これが呼び出されると、それは同期的に呼び出される。 もし例外を投げたなら、その例外は呼び出しスタック上の最も近いtryで守られた領域で処理される。 しかし、イテレータブロック自体が tryで守られた領域を持っていて、その中で呼び出し元に制御を返している とする。

try { yield return whatever; }
catch { ... }
finally { ... }

yield文は呼び出し元に制御を返すが、finallyブロックを起動 しない 。 また、 呼び出し元で 例外が投げられた場合、MoveNext()はもうスタック上にない。その例外ハンドラは 消えてしまった 。 イテレータブロックの例外モデルはかなり奇妙だ。 finallyブロックが実行されるのは、制御が MoveNext()メソッドの中にあり、tryで守られた領域から yield return 以外の何らかのメカニズムで離脱したときだけだ。 あるいは、列挙子が早期に破棄されたときにも実行される。それは、呼び出し元が例外を投げてfinallyブロックを起動し、その中で列挙子を破棄した場合に発生する可能性がある。 要するに、制御を返した何らかのものに例外が発生して、列挙子を反復処理しているループから抜け出した場合、イテレータの finallyブロックはおそらく実行されるが、catchブロックは実行さない。 これは変だ。 これが、catchブロックを持つtryブロックでyieldすることを違法にした理由だ。

では、“await"を含むメソッドをどうすればいいのだろうか? この状況はイテレータブロックの状況に似ているが、もちろん非同期タスクは例外を投げることができるので、さらに奇妙な状況になっている。

async Task M()
{
  try { await DoSomethingAsync(); }
  catch { ... }
  finally { ... }
}

catchのあるtryブロックで何かをawaitすることは合法であるべきと考える。 DoSomethingAsyncがタスクを返す前に例外を投げるとしよう。これには問題はない。 M はまだスタック上にあるので、catchブロックは実行される。 では、DoSomethingAsync がタスクを返すとしたらどうだろう。 M は自身の残りの部分をタスクの続きとして登録し、すぐに別のタスクを呼び出し元に返す。 DoSomethingAsyncによって返されたタスクに関連付けられたジョブが実行されるようにスケジュールされていて、そのジョブが例外を投げるとどうなるだろうか? 論理的にはDoSomethingが同期呼び出しであった場合と同じように、Mはまだ “スタック上” にあり、catchとfinallyが実行されることが望ましい。 (イテレータブロックとは異なる: 私たちはcatchも実行したいのであって、finallyを実行するだけではない!) しかし、Mはとっくにいなくなっている。 タスクの継続とみなせるコードを含むデリゲートを登録したが、Mとそのtryブロックは消えてしまった。 しかも、タスクはMを実行していたスレッド上では実行されていないかもしれない。 さらに言えば、タスクが実際に “クラウド上” のサービスプロバイダに外注されている場合、同じマシン上で実行されていないかもしれない。 どうすればいいのだろうか?

私は数話前に、継続渡しスタイルでの例外処理は簡単だと言った。 例外的な状況用と通常の状況用の2つの継続を渡すだけだ。 しかし、ここでは実際にはそうではない。 代わりに、私たちがすることは次のようなことだ。 ジョブが例外を投げた場合、例外はキャッチされ、その例外はタスクに保存される。 そして、タスクは失敗に終わったことを通知する。 タスクの継続が再開されたら、tryブロックの途中に(なんとかして)“goto"をして、タスクが爆発したかどうかを確認する。 もし爆発したのであれば、その場で例外を再投入することができ、今回は例外を処理できる try-catch-finally がある。

しかし、例外を処理しない場合はどうなるだろうか。 catchブロックが一致していないかもしれない。 その場合はどうすればいいのだろうか? Mの元の呼び出し元は、またしても、とっくに消えている。 継続はおそらく、どこかのトップレベルのメッセージポンプから呼び出されているのだろう。 では、どうすればいいのだろうか? M がタスクを返したことを思い出してほしい。 そのタスクの例外を再びキャッシュして、そのタスクが失敗に終わったことを通知する。 このようにして、責任は呼び出し元に渡される。例外を投げるということはもちろんこういうことだ。 つまり、呼び出し元に自分の混乱を解決する仕事をさせるということだ。

要するに、M()はこのような疑似C#のようなものとして生成される。

Task M()
{
  var builder = AsyncMethodBuilder.Create();
  var state = State.Begin;
  Action continuation = ()=>
  {
    try
    {
      if (state == State.AfterDoSomething) goto AfterDoSomething;
      try
      {
        var awaiter = DoSomethingAsync().GetAwaiter;
        state = State.AfterDoSomething;
        if (awaiter.BeginAwait(continuation))
          return without running the finally;

AfterDoSomething:
        awaiter.EndAwait(); // throws an exception if the task completed unsuccessfully
        builder.SetResult();
        return;
      }
      catch { ... }
      finally { ... }
    }
    catch (Exception exception)
    {
      builder.SetException(exception); // signal this task as having completed unsuccessfully
      return;
    }
    builder.SetResult();
  };
  continuation();
  return builder.Task;
}

(もちろんここにも問題がある。 tryブロックの途中にgotoができなかったり、ラベルがスコープ外になっていたり、などだ。 しかしわしらは動作するILをコンパイラに生成させることができるのじゃよ。 合法的なC#である必要はない。 これは単なるスケッチなのだ。)

EndAwaitが非同期操作からキャッシュされた例外を投げた場合、catchブロックと最終ブロックは正常に動作する。 内側のcatchブロックがそれを処理しなかったり、別の例外を投げたりすると、外側のcatchブロックがそれを取得してタスクにキャッシュし、タスクが異常終了したことを通知する。

この簡単なスケッチでは、いくつかの重要なケースを無視している。例えば、メソッド M が void を返す場合はどうだろうか? この状況では M のために構築されたタスクは存在しないので、失敗して完了したことを示すものは何もないし、例外をキャッシュする場所もない。 DoSomethingAsync が 10 個のサブタスクに対して WhenAll を行い、そのうちの 2 個が例外を投げるとどうなるだろうか? 同じシナリオを WhenAny で実行した場合はどうだろうか?

次回 はこれらのケースについて少し話し、一般的な例外処理の哲学について考え、その哲学が良い指針を与えているかどうかを聞いてみる。 その後、アメリカの感謝祭のために少し休憩してから、非同期以外の話題を取り上げる。


インデックスへ戻る