👌

async/awaitは「できること」より「できないこと」の方が大事

2022/04/11に公開

皆さんawaitしてますか?

私はC#を使う機会が多いのでawaitしまくってますが、実は最近までこの構文の価値を正しく理解していませんでした。

  • async/awaitという糖衣構文はそれなりに複雑なことをやっていて、それを理解できる人にはそんな糖衣構文要らないのでは?

と長い間考えていたのです。私自身、Taskクラスを直接取り回さないと書けない処理を書いたり、C++でTask.ContinueWithに相当する機能を作ったりしたこともあり、それなりに非同期処理については理解している自負がありました。その慢心が、この構文の意図を理解する妨げになっていたなぁという反省をこめて、この記事を書いています。

非同期処理を「手続き的に書けるようにする」のではなく「手続き的にしか書けなくする」

これが今回気付いたことのほぼ全てです。どういうことか、async/awaitを使ったコードとTask.ContinueWithを使ったコードを対比しながら考えてみます。

void async HogeAsync()
{
    var result = await Task.Run(() => VeryHeavyMethod());
    var a = result.GetA();
    var b = result.GetB();
    ProcWithResult(a, b);
}

これをasync/awaitを使わずに書くなら

void Hoge()
{
    Task.Run(() => VeryHeavyMethod()).ContinueWith((result) =>
    {
        var a = result.GetA();
        var b = result.GetB();
        ProcWithResult(a, b);
    });
}

こんな感じでしょうか。

どちらも行数は大差ないですし、ContinueWithはコールバックとして後続処理を書くのでネストが深くなって嫌だなぁと思うかもしれませんが、まぁそこまで目くじら立てるほどでもないかな、と思っていました。

ですが、後者のコードは非常に不具合を埋め込みやすいという認識を持たねばなりません。

void Hoge()
{
    Task.Run(() => VeryHeavyMethod()).ContinueWith((result) =>
    {
        var a = result.GetA();
        var b = result.GetB();
        ProcWithResult(a, b);
    });
    // ここに処理が書けてしまう
    // 別スレッドに処理を投げた後に「並列で動く処理」が書けてしまう
    // 大問題!
}

マルチスレッドのメリットの1つに「重たい処理の裏でも別の処理が動かせる」ことがありますが、これは「同時に走っている処理が競合しない」ことが大前提です。メソッドの中でワーカースレッドに処理を投げた後で、その直後に並列で動作する処理を記述できてしまったら、高確率で競合すると考えるべきでしょう。

いやいや、自分は大丈夫ですよ?せっかくワーカースレッドに投げるんだから、投げた後並列に動く処理も書きたいじゃん?と思いますか?私は思ってました。ですが、大丈夫!と思って書いた処理がガッツンガッツンぶつかるんです。

    Hoge();
    Huge();  // ここから先はいつ競合してもおかしくないデンジャラスゾーン

呼び出す側から見ても、もはや罠を埋め込んでるとしか思えないメソッドです。まさか内部でワーカースレッドに処理を投げているとは思わず、これを呼び出した以降はすべて競合の危険性を孕むブロックとなります。

ではasync/await構文で、この危険性がどう緩和されているのかを考えてみます。

void async HogeAsync()
{
    var result = await Task.Run(() => VeryHeavyMethod());
    // awaitする処理の後には完了後の処理しか書けない
    var a = result.GetA();
    var b = result.GetB();
    ProcWithResult(a, b);
}

このメソッド内に、並列に動く可能性のあるコードはなくなっています。awaitした後にはContinueWithする処理しか書けなくなっているとも言えます。

    await HogeAsync();
    Huge();  // 当然呼び出し側においてもawaitした以降は必ず完了後の処理になる

awaitするためには、そのメソッドもまたasync修飾する必要があります。当然、await後には完了後の処理しか書けない、という性質が伝搬します。これを徹底することで、プログラマーが注目しているスコープ内で並列に動くコードを書けなくすることが、この構文の真の目的だったのです。

asyncメソッドの呼び出し元辿っていくと、どこかでawaitせずにワーカーに投げている部分があるわけですが、そこは非同期処理のスペシャリストが作ったであろうライブラリのコア部分になるはずです。ライブラリのユーザーは極力async/await構文のみを利用して、自分ではTaskやThreadを触らないようにすることで、ライブラリが管理している並列処理の方針に乗っかることができるわけですね。

Discussion