😎

C# の Async について少し学んでみた

2022/12/23に公開

Async と sync 混ぜるな危険

という記事を Microsoft Azure Tech Advent Calendar 2022 で読みました。

https://qiita.com/superriver/items/1f1b83d2d2c6b70ba408

んで、C# とか .NET とかにあまり詳しくないのもあり、読んだところでんじゃどうすればいいのかが理解しきれなかったので、ちょっと同僚氏に説明をお願いしまして、それのメモ的な記事です。
たぶん内容あってるはずなんですが、間違ってたらごめんなさい。
あまり自信はないので「らしい」とか「だそうです」のような語尾が多いです。

Sample code と動作の説明

using Microsoft.AspNetCore.Mvc;

namespace BuggyDemoWeb.Controllers;

[ApiController]
[Route("[controller]")]
public class BadController : ControllerBase
{
    [HttpGet]
    public IActionResult SyncOverAsync()
    {
        string val = DoAsync().Result;
        return Ok(val);
    }

    private async Task<string> DoAsync()
    {
        var random = new Random();
        await Task.Delay(random.Next(10) * 100);

        return Guid.NewGuid().ToString();
    }
}

上の記事にも書かれているコードいったんそのままです。
とりあえず手元で Visual Studio で build して、ちょっとつついてみる感じだと、Task.Delay(random.Next(10) * 100); で書いてあるとおり、数百 ms 遅れて GUID っぽい文字列が出てて意図どおり動作しているように見えます。

WebApplication1

ただ、実際には複数クライアントから並列にたたかれるような場合にはこれではダメってのがもともとの話でした。
例えば JMeter でたたいてみるとこんな感じになるっぽいです。

https://zenn.dev/okazuki/articles/do-not-lock-thread

んで、どこがだめかというと、DoAsync().Result の部分が同期的に待ってしまう、というか、実際に DoAsync() が呼ばれてその中の Task.Delay(random.Next(10) * 100); で数百 ms 待つところで thread を離さないという動作になるようです。
thread pool はそのうち増えていく動作をするっちゃあするんですがそれなりに負荷が高く、高スループットを出しにくいという感じ。
本来は Task.Delay(random.Next(10) * 100); の部分は、端的にいえば何もしてないわけで、じゃあその間 thread を開放すればその thread を使って別のクライアントからの処理をさばくこともできるのにね、という感じのようです。

Sample code の改善案

んで、まずは await つけてあげれば良さそうらしいんですね。
string val = await DoAsync(); という感じです。
で、そうすると Visual Studio から警告が上がってきます。

WebApplication1 improvement

public IActionResult SyncOverAsync()public async Task<IActionResult> SyncOverAsync() とすべきらしいです。
await として関数を呼ぶ関数はその定義が async Task<T> でなければならないそうです。
提示されたとおり修正して、結果はこのようになりました。

using Microsoft.AspNetCore.Mvc;

namespace BuggyDemoWeb.Controllers;

[ApiController]
[Route("[controller]")]
public class BadController : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> SyncOverAsync()
    {
        string val = await DoAsync();
        return Ok(val);
    }

    private async Task<string> DoAsync()
    {
        var random = new Random();
        await Task.Delay(random.Next(10) * 100);

        return Guid.NewGuid().ToString();
    }
}

このようにすることで、Task.Delay(random.Next(10) * 100) する数百 ms の間、thread を譲ってくれるようです。
人間にとっては一瞬ではあるのですが、その間にコンピュータとしてはいろいろなことができるわけで、パフォーマンスへの寄与は大きいようです。

元の Qiita の記事の後半、実際にあったケース、として提示されているコードを同様にしようと思うと、コンストラクタ内での Async メソッド呼び出しがあるためためこんな簡単な対応ではないそうです。

おわりに

SQL Server の本を以前読んでたんですが、なんか同じような話をしてるなと感じました。
SQL としてのロックを取得するまでに暇ができることがあり、その間 SQL Server の他のタスクをやるために一旦席を譲るような動きがあります。
詳しくは平山 理さんの書籍を読むといいのですが、ワーカー スレッド プール、ランナブル キュー、ワーク リクエスト キュー、I/O リクエスト リスト、ウェイター リスト などといったキーワードでぐぐるとわかるかもしれません。

https://www.amazon.co.jp/dp/B08CRMBCQG/

なんかプログラミング言語側とかフレームワーク側でいい感じにやってくれれば人間としては楽だし間違いがないのになぁと思いつつ、そうもいかないんですかねぇ。。
2022年末の期間を使って、他のプログラミング言語での async / await (読み方は えいしんく / あうぇいと らしいですよ) とかをもうちょっと学んでいこうと考えています。

参考

https://learn.microsoft.com/aspnet/core/fundamentals/best-practices

https://learn.microsoft.com/azure/architecture/antipatterns/synchronous-io/

Microsoft (有志)

Discussion