asyncの落とし穴Part3, async voidを避けるべき100億の理由

だいぶ前から時間経ってしまいましたが、非同期の落とし穴シリーズPart3。ちなみにまだ沢山ネタはあるんだから!どこいっても非同期は死にますからね!

async void vs async Task

自分で書く場合は、必ずasync Taskで書くべき、というのは非同期のベストプラクティスで散々言われていることなのですけれど、理由としては、まず、voidだと、終了を待てないから。voidだと、その中の処理が軽かろうと重かろうと、終了を感知できない。例外が発生しても分からない。投げっぱなし。これがTaskになっていれば、awaitで終了待ちできる。例外を受け取ることができる。await Task.WhenAllで複数同時に走らせたのを待つことができる。はい、async Taskで書かない理由のほうがない。

んじゃあ何でasync voidが存在するかというと、イベントがvoidだから。はい。button_clickとか非同期対応させるにはvoidしかない。それだけです。なので、自分で書く時は必ずasync Taskで。async voidにするのはイベントだけ。これ絶対。

ASP.NET + async voidで死ぬ

それでもasync voidをうっかり使っちゃうとどうなるでしょう?終了を待てないだけとか、そんなんならいいんですよ、でも、ASP.NETでasync void使うと死にます。文字通りに死にます。アプリケーションが。じゃあ、ASP.NET MVCで試してみましょうか。WebForms?しらね。

public async void Sinu()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
}

public ActionResult Index()
{
    Sinu();

    return View();
}

死にました!警告一切ないのに!って、ああ、そうですね、async="true"にしないとですね、まぁそれはないのですけれど、はい、Task<ActionResult>を返しましょう。そうすればいいんでしょ?

public async void Sinu()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
}

public async Task<ActionResult> Index()
{
    Sinu();

    return View();
}

はい、死にました!非同期操作が保留中に非同期のモジュールとハンドラーが完了しちゃいましたか、しょーがないですね。しょーがない、のか……?

で、これの性質の悪いところは、メソッド呼び出しの中に一個でもasync voidがあると詰みます。

// こんなクソクラスがあるとして
public class KusoClass
{
    public async void Sinu()
    {
        await Task.Delay(TimeSpan.FromSeconds(1)); // 1じゃなく5ね。
    }
}

// この一見大丈夫そうなメソッドを
public async Task Suteki()
{
    // ここでは大丈夫
    await Task.Delay(TimeSpan.FromSeconds(1));

    // これを実行したことにより……
    new KusoClass().Sinu();

    // ここも実行されるし
    await Task.Delay(TimeSpan.FromSeconds(1));
}

// このアクションから呼び出してみると
public async Task<ActionResult> Index()
{
    // これを呼び出してちゃんと待機して
    await Suteki();

    // ここも実行されるのだけれど
    await Task.Delay(TimeSpan.FromSeconds(1));

    return View();
}

死にます。ただし、上で5秒待機を1秒待機に変えれば、動きます。なぜかというと、KusoClass.Sinuを実行のあとに2秒待機があってViewを返してるので、Viewを返すまでの間にKusoClass.Sinuの実行が完了するから。そう、View返すまでに完了してればセーフ。してなければ死亡。まあ、ようするに、死ぬってことですね結局やっぱり。何故かたまに死ぬ、とかいう状況に陥ると、むしろ検出しづらくて厄介極まりないので、死ぬなら潔く死ねって感じ。検出されずそのまま本番環境に投下されてしまったら……!あ、やった人がいるとかいないとかいるらしい気がしますが気のせい。

呼び出し階層の奥底にasync voidが眠ってたら死ぬとか、どーせいという話です。どーにもならんです。なので、共通ライブラリとか絶対async void使っちゃダメ。あるだけで死んでしまうのですから。

FireAndForget

さて、投げっぱなしの非同期メソッドを使いたい場合、どうすればいいんでしょう?

public async Task ToaruAsyncMethod()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    Debug.WriteLine("hoge");
}

public async Task<ActionResult> Index()
{
    // 待機することなく投げっぱなしにしたいのだけど警告が出る!
    ToaruAsyncMethod();

    return View();
}

あー、警告ウザす警告ウザす。その場合、しょうがなく変数で受けたりします。

public async Task<ActionResult> Index()
{
    // 警告抑制のため
    var _ = ToaruAsyncMethod();

    return View();
}

はたしてそれでいーのか。いやよくない。それに、やっぱこれだと例外発生した時に捉えられないですしね。TaskScheduler.UnobservedTaskExceptionに登録しておけば大丈夫ですけれど(&これは登録必須ですが!)。というわけで、以下の様なものを用意しましょう。

// こんな拡張メソッドを用意すると
public static class TaskEx
{
    // ロガーはここではNLogを使うとします
    private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();

    /// <summary>
    /// 投げっぱなしにする場合は、これを呼ぶことでコンパイラの警告の抑制と、例外発生時のロギングを行います。
    /// </summary>
    public static void FireAndForget(this Task task)
    {
        task.ContinueWith(x => 
        {
            logger.ErrorException("TaskUnhandled", x.Exception);
        }, TaskContinuationOptions.OnlyOnFaulted);
    }
}

// こんな投げっぱなしにしたい非同期メソッドを呼んでも
public async Task ToaruAsyncMethod()
{
    await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
    Debug.WriteLine("hoge");
    throw new Exception();
}

public ActionResult Index()
{
    ToaruAsyncMethod().FireAndForget(); // こうすれば警告抑制&例外ロギングができる
            
    return View();
}

いいんじゃないでしょうか?

ところで、ToaruAsyncMethodに.ConfigureAwait(false)をつけてるのは理由があって、これつけないと死にます。理由は、覚えてますか?asyncの落とし穴Part2, SynchronizationContextの向こう側に書きましたが、リクエストが終了してHttpContextが消滅した状態でawaitが完了するとNullReferenceExceptionが発生するためです。

そして、これで発生するNullReferenceExceptionは、FireAndForget拡張メソッドを「通りません」。こうなると、例外が発生したことはUnobservedTaskExceptionでしか観測できないし、しかも、そうなるとスタックトレースも死んでいるため、どこで発生したのか全く分かりません。Oh...。

たとえFireAndForgetで警告が抑制できたとしても、非同期の投げっぱなしは細心の注意を払って、呼び出しているメソッド階層の奥底まで大丈夫であるという状態が確認できていて、ようやく使うことができるのです。うげぇぇ。それを考えると、ちゃんと警告してくれるのはありがたいね、って思うのでした。

まとめ

voidはまぢで死ぬ。投げっぱなしも基本死ぬ。

では何故、我々は非同期を多用するのか。それはハイパフォーマンスの実現には欠かせないからです。それだけじゃなく、asyncでしか実現できないイディオムも山のようにあるので。いや、こんなの全然マシですよ、大袈裟に書きましたがasync void使わないとか当たり前なのでそこ守れば大丈夫なんですよ(棒)。じゃあ何でasync voidなんてあるんだよとか言われると、イベント機構があるからしょうがないじゃん(ボソボソ)、とか悲しい顔にならざるを得ない。

というわけで、弊社は非同期でゴリゴリ地雷踏みたいエンジニアを大募集してます。ほんと。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive