.NET非同期処理で例外を安全に対処する

.NETの非同期処理で例外を処理する方法について、重要な点を簡潔にまとめました。

ついつい例外処理を汚く書いてしまうとか、手を抜いてしまう、またはGUI環境で例外処理が悩みの種、という人向けです(一部、やや深い話をしていますが、Level 200~300ぐらいです)。async voidへの対処で悩んでいる方にも、方法を示します。

コード例はC#で書いています。ターゲットはすべての.NET処理系、つまり、.NET Framework, .NET Core, .NET 5, mono, Xamarin, Unityなど、全てです。


基本的な例外処理

例外を補足するには、try…catchを使用します:

public static FooBar Fetch()
{
    try
    {
        var body = webClient.DownloadString("https://example.com/foobar");
        return JsonConvert.DeserializeObject<FooBar>(body);
    }
    catch (IOException ex)
    {
        Trace.WriteLine($"Log: {ex}");
        throw;
    }
}

catch句には、そのブロックで受信すべき例外の種類を型名で指定出来ます。上の例では、IOException、またはそれを継承したクラスがスローされると、catchブロックのTrace.WriteLine(…)が実行されます。
注意すべき点は以下の通りです:

  • 再スロー、つまり例外を呼び出し元に伝搬させる場合は、throw句を単独で使います。throw exのように書いてしまうと、例外中に保存された、発生元を示すスタックトレースの情報が失われ、throw exを書いた場所からにリセットされてしまいます。
  • catch句に、例外の型を指定しない事もできます:
try
{
    // ...
}
catch // 受信する型を書かない
{
    Trace.WriteLine("Log: Caught uninterpreted exception.");
    throw;
}

これは、どのような例外をも受信し、catchブロックを実行するというものです。一見すると、catch (Exception ex)のように、Exceptionクラスを指定するのと同じに見えるかもしれません(.NETの例外は、すべてExceptionクラスを継承するため)。しかし、「すべての例外」とは、.NETでは補足されない(出来ない)ような例外を含む、という意味です。

代表的な例として、SEH (Structured Exception Handling:構造化例外処理) と呼ばれる、Windows環境の例外を挙げます。これは.NETだけではなく、Win32のネイティブコード実行時に発生する「非同期例外」という種類の例外です。名前が非常に紛らわしいのですが、.NETの非同期処理で扱われる例外とSEHの非同期例外は、まったく関係ありません。

(なお、都合でWindows環境のネイティブコードをx86と呼称していますが、arm64を含む全てのWindows環境に当てはまります。)

.NETのマネージ例外と構造化例外を対比させてみます:

  • .NETマネージ例外: 私たちに馴染みのある例外。IOException, ArgumentException, …
  • Structured Exception (構造化例外)
    * ハードウェア例外: CPU由来の例外や、外部割込みによって発生するもの。代表的な例外に、0除算エラーがある。
    * ソフトウェア例外: 意図して発生させる構造化例外。.NETマネージ例外をスローする事に似ている。Win32のRaiseException APIで発生させる。

構造化例外の説明を見て、色々疑問が湧くかもしれません。


構造化例外と.NETマネージ例外の位置づけ

0除算エラーとDivideByZeroException

広義の0除算エラーといえば、こういうコードです:

public static int Div(int a, int b)   // bに0を与える...
{
    try
    {
        return a / b;
    }
    catch (DivideByZeroException ex)
    {
        Trace.WriteLine($"Log: {ex}");
        throw;
    }
}

.NETでは、0除算エラーは、DivideByZeroExceptionクラスで受信、つまりcatchする事が出来ます。

SEHでは、EXCEPTION_INT_DIVIDE_BY_ZEROというコードを使用して、RaiseExceptionを呼び出し… ません。ネイティブコードであっても、0除算エラーが発生するような計算命令を実行するだけで、(明示的に判定するようなコードを書かなくても)、自動的に、このコードのSEH例外が発生します。

ネイティブコードで発生する0除算エラーは、.NETのランタイムによって自動的にDivideByZeroException例外に変換されて、マネージ例外として再スローされます。そのため、私たちは普段、内部でSEH例外が発生したということは意識しません(これは、JITがCIL(MSIL)を直接x86の除算命令に置き換えても、まったく同じ方法で0除算を検出することに符合します)。

しかし、0除算エラーのような代表的な例外ではなく、カスタムコードを持つSEH例外が発生する可能性もあるので、そのような例外は.NETランタイムが変換できません。そういった場合でも、冒頭の、型を指定しないcatch句であれば受信できるというわけです。

SEH例外が「非同期例外」と呼ばれる理由を補足すると、意図しないタイミングで発生する可能性があるから、という意味を含みます。0除算エラーの場合、x86のdiv命令を実行中に発生するのですが、CILのDivからすると、div命令を含むx86 2~3命令に対応します。そのため、どのx86命令までが正常に実行できて、どこで実際のSEH例外が発生したのか、を正確に判断するのが難しくなります。0除算エラーを含むいくつかのSEH例外は、.NETランタイムから見て保障可能なものに限り、DivideByZeroExceptionのようなマネージ例外に自動的にマッピング出来ているのです。

最初の脇固め

そのようなわけで、全ての例外的事象に対応して、何らかの処理を行わなければならない場合は、catch (Exception ex) { … } ではなく、catch { … } を使いましょう。但し、後者は例外が発生したという事実だけで、例外の内容を識別する情報が得られません。そこで、このようなboilerplateが必要になります:

try
{
    // ...
}
// すべての.NETマネージ例外
catch (Exception ex)
{
    Trace.WriteLine($"Log: {ex}");
    throw;
}
// すべての例外
catch
{
    Trace.WriteLine("Log: Caught uninterpreted exception.");
    throw;
}

これは… まあ… めんどくさいですね…


本題

構造化例外なんて、Linuxで.NET CoreやってるAさんや、Xamarin iOSやってるBさんや、Unity for AndroidやってるCさんには何も関係ないので、気にする必要ないと思うかも知れません。実際、私もそう思うのですが、ここからが本題です。

私たちは、結構な頻度でこのようなコードを書きがちです:

// WPF, Windows Forms, Xamarinなどのボタンクリックイベント
public async void Button_Click(object sender, EventArgs e)
{
    // 非同期で取得
    var body = await httpClient.GetStringAsync("https://example.com/foobar");
    var foobar = JsonConvert.DeserializeObject<FooBar>(body);

    // 表示する
    textBox.Text = $"${foobar.Price}";
}

これの何が問題なのかと言うと、サーバーへのアクセスに失敗したときの例外の処理が何もないことです。いや、Applicationクラスのグローバル例外ハンドラで処理しているから問題ないと思うかもしれません:

public void Main(string[] args)
{
    // WPFの場合、Applicationクラスのイベントをフックすることで、未処理の例外を捕捉できる
    Application.DispatcherUnhandledException += (s, e) =>
        Trace.WriteLine($"Log: {e.Exception}");

    // ...
}

あるいは、コンソールアプリケーションやXamarin Formsなら、AppDomainの例外ハンドラを使うこともできます:

public void Main(string[] args)
{
    // TIPS: ExceptionObjectがobject型なのは、SEHを意識しているからです
    AppDomain.CurrentDomain.UnhandledException += (s, e) =>
        Trace.WriteLine($"Log: {e.ExceptionObject}");

    // ...
}

他の環境でも、大体似たようなグローバル例外ハンドラが用意されている筈です。

しかし、残念ながら、上記のようなasync voidのシグネチャを持つ非同期メソッドで発生した例外は、これらの方法では受信できません。

思っていたのと違う原因

問題は3点あります:

  • 非同期処理の例外をフックする方法が異なる: 非同期処理で発生した未処理の例外は、TaskScheduler.UnobservedTaskExceptionを使えば受信できます。但し…
  • 例外が発生したタイミングで受信できない。
  • 補足できても対処可能なことが限られる。

非同期処理中に発生した例外は、上記の通りTaskSchedulerクラスのUnobservedTaskExceptionで同様にフックする事が出来ます(これを推奨している記事も結構あるのですが、ここでは敢えて参照しません):

public void Main(string[] args)
{
    Application.DispatcherUnhandledException += (s, e) =>
        Trace.WriteLine($"Log: {e.Exception}");

    // 非同期処理の未処理例外も補足する
    TaskScheduler.UnobservedTaskException += (s, e) =>
        Trace.WriteLine($"Log: {e.Exception}");       // <-- (A)
}

ところが、この (A) の箇所が呼ばれるタイミングが、実際に例外が発生したタイミングよりもかなり遅れます。まず、async voidとして実装したメソッドの等価実装を見て下さい:

// ボタンクリックイベント
public void Button_Click(object sender, EventArgs e)
{
    // 非同期処理を呼び出し、そしてTaskを捨てる
    var task = this.OnClickAsync(sender, e);
}

// 実処理 (Taskを返す)
private async Task OnClickAsync(object sender, EventArgs e)
{
    // 非同期で取得
    var body = await httpClient.GetStringAsync("https://example.com/foobar");
    var foobar = JsonConvert.DeserializeObject<FooBar>(body);

    // 表示する
    textBox.Text = $"${foobar.Price}";
}

このように、内部的には非同期処理を示すTaskが捨てられた格好になります。Taskのインスタンスは誰も保持していないため、例外で処理が中断した後はしばらくメモリ上に残り、その後、ガベージコレクタが回収しようとします。ここで、Taskのファイナライザーが(ファイナライザースレッドから)呼び出されます。

Taskのファイナライザーは、自身が保持している未処理の例外を、TaskScheduler.UnobservedTaskExceptionを呼び出して処理させます。このような動作なので、例外が発生したタイミングと、ハンドラが呼び出されるタイミングがずれるのです。

そして、(A) の時点のスレッドは、ファイナライザースレッドなので:

  • ハンドラ内からユーザーインターフェイスを操作できない。
  • 何なら他の操作もできないか、または制限がある。

事になります。上のコードで示したようにコンソールに出力するだけであれば、実害はないかもしれません。しかし、一般的なライブラリは、ファイナライザースレッドから呼び出される事を想定していないと思われるので、ファイルにアクセスしたり、DBにログを出力したり、別のサーバーに要求を投げたりすると、もしかしたら更に別の問題を引き起こして頭を悩ませるかもしれません。

(注: ファイナライザースレッドから呼び出されるという事は、少なくともマルチスレッドでアクセスすることが可能である必要があり、かつ、ファイナライザースレッドの厳しい環境が回避可能でなければなりません。大半のクラスライブラリはこれを満たしません。実際のところ、Trace.WriteLine()が安全かどうかも疑わしいです…)

また、例外が発生したタイミングを追跡しにくい、という問題が残ります。自分の周りでは、いつの間にかUnobservedTaskExceptionがデバッガのコンソールやログファイルに(意図しないタイミングで)表示され、これの原因をどうやって特定しようか、という悩みをちょくちょく観測します。


対処方法

結局、例外が未処理で放置されないように、根元で受信することです:

public async void Button_Click(object sender, EventArgs e)
{
    try
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // 表示する
        textBox.Text = $"${foobar.Price}";
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine($"Log: {ex}");
        // 再スローしない
    }
}

おっと、そういえばSEHの件がありましたね:

public async void Button_Click(object sender, EventArgs e)
{
    try
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // 表示する
        textBox.Text = $"${foobar.Price}";
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine($"Log: {ex}");
        // 再スローしない
    }
    // SEHも逃さない
    catch
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine("Log: Caught uninterpreted exception.");
        // 再スローしない
    }
}

他のプラットフォームの例として、Unityのハンドラも挙げておきます。と言っても、メソッドのシグネチャが違うだけで、やることは全く変わりません:

// UnityでUX buttonを使った時のクリックハンドラ
public async void UXButton_OnClick()
{
    try
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // ...
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Debug.LogError($"Log: {ex}");
        // 再スローしない
    }
    // SEHも逃さない
    catch
    {
        // ここなら比較的安全にいろんなことができる...
        Debug.LogError("Log: Caught uninterpreted exception.");
        // 再スローしない
    }
}

めんどくさい…

まあそうですよね。SEHのリカバリーを捨てたとしても、毎回(忘れないように)catch句を書くのは非常にだるいです。なので、boilerplateを発展させましょう:

// 非同期処理で発生する例外を安全に処理する
public static async void SafeAsyncBlock(Func<ValueTask> action)
{
    try
    {
        // 非同期処理を実行
        await action();
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine($"Log: {ex}");
        // 再スローしない
    }
    // SEHも逃さない
    catch
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine("Log: Caught uninterpreted exception.");
        // 再スローしない
    }
}

こういうものを作っておくと、このように書き直せます:

public void Button_Click(object sender, EventArgs e) =>
    SafeAsyncBlock(async () =>
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // 表示する
        textBox.Text = $"${foobar.Price}";
    });

かなり楽に書けるようになりました。ValueTaskではなくTaskでも構いません。ValueTaskにしておけば、アロケーションコストを削減できる可能性があります。

Level 400~500: 非常に細かいことで、普通は考慮する必要はないと思いますが、SEHを非同期処理の例外として伝搬し、継続させることはできません。従って、catch句で受信できるSEHは、最初のタスクコンテキストスイッチが発生する以前のSEHのみとなります。一度タスクコンテキストスイッチが発生した後の非同期継続処理でSEHが発生した場合は、この方法では対処できません。恐らくはThreadPoolから割り当てられたワーカースレッドの根元に伝搬するか、SynchronizationContextがホストするスレッドの根元に伝搬します。AppDomainでフックする以外に、良い方法は、ないかも知れません…


まとめ

.NETの非同期処理で例外に安全に対処する方法をまとめました。この方法なら、冒頭に示した通り、あらゆる.NET環境で例外を安全に対処できます。

ですが、私としては、これらの対処が行われている補助ライブラリやフレームワークを利用することをお勧めします。恐らく、SafeAsyncBlockのようなboilerplateを見て「これだ!」と思った方は、全て自前で実装しようとしていると思いますが、抜け漏れは往々にして発生しますし、細かい考慮点はほかにもいくつかあります。

XAML環境では、私が作ったEpoxyでは当然このような対処を行っています(ICommandのハンドラを非同期処理にできますが、内部では同様の実装を行っています)。また、他の著名なライブラリも対処していると推測するので、検討してみると良いでしょう。

それではまた。