Start Debugging

レガシーな C# コードベースでブロッキングな .Result/.Wait() 呼び出しを全面的な非同期へ移行する

既存の .NET コードベースから sync-over-async を取り除くための段階的な手順書です。アナライザーで棚卸しし、ThreadPool の枯渇を計測し、呼び出しチェーンを 1 本ずつ変換して、.NET 11 で件数をゼロまで削り込みます。

実際のコードベースから sync-over-async を取り除く作業は、検索と置換では終わりません。数十万行規模のサービスなら 1 スプリントから 3 スプリントを見込み、作業は 1 本の巨大な PR ではなく、一連の垂直スライスの形になると考えてください。壊れるのは主にシグネチャです。ブロックをやめたメソッドはすべて Task を返す必要があり、それがインターフェース、コンストラクター、Disposelock ブロック、そして公開 API の表面まで上へ波及します。負荷時に ThreadPool の枯渇が見えている場合や、UI スレッドで深刻なデッドロックが起きている場合には取り組む価値があります。逆に、ブロッキング呼び出しが 1 回実行して終了するコマンドラインツールの中にある場合は後回しにしてかまいません。本稿は .NET 11 (Microsoft.NET.Sdk 11.0.0、C# 14) を対象にしています。紹介するツールはすべて .NET 6 以降で動作しますが、ランタイムのトレース手順だけは .NET 9 以降が必要です。

ブロッキング呼び出しを取り除くべき理由

非同期化で壊れるもの

領域変更内容深刻度
公開 API の表面T Get()Task<T> GetAsync() になり、利用側にとってソースおよびバイナリの破壊的変更になります
自分の管理下にないインターフェースサードパーティやフレームワークのインターフェースのメソッドに Task の戻り値型は与えられません
コンストラクター、プロパティのゲッターどちらも async にできないため、処理はファクトリメソッドか遅延初期化子へ移します
lock ステートメントlock の中の await はコンパイルエラー CS1996 になり、SemaphoreSlim が必要です
例外処理AggregateException が現れなくなるため、catch (AggregateException) が黙って一致しなくなります
TransactionScopeTransactionScopeAsyncFlowOption.Enabled を指定して生成しない限り await をまたいで流れません
IDisposableDispose での非同期クリーンアップには IAsyncDisposableawait using が必要です
テストスイート非同期になったコードを呼ぶ同期テストメソッドは async Task になります

深刻度が高い行が作業順序を決めます。それ以外は機械的な作業です。

事前チェックリスト

移行手順

  1. 棚卸しは grep ではなくアナライザーで行います。

    grep -r "\.Result" は Result という名前のあらゆるプロパティアクセスを拾い、同期 I/O は完全に見落とします。このパターンを本当に理解している 2 つのルールを有効にしてください。

    # .editorconfig -- .NET 11 SDK 11.0.0
    [*.cs]
    # Avoid problematic synchronous waits (.Result, .Wait(), GetAwaiter().GetResult())
    dotnet_diagnostic.VSTHRD002.severity = warning
    # Call async methods when in an async method
    dotnet_diagnostic.VSTHRD103.severity = warning
    # Built-in equivalent; off by default through .NET 10
    dotnet_diagnostic.CA1849.severity = warning

    レガシーなコードベースではこの違いが効いてきます。CA1849Task を返すメソッドの内側でしか発火しないため、まだ何も非同期になっていないコードではほとんど何も報告しません。VSTHRD002 はブロッキング呼び出しがどこにあっても発火し、これこそが数えたい母集団です。

    確認: ソリューションをビルドし、出力中の VSTHRD002 の行数を数えます。その数値を保存してください。それがバーンダウンチャートになります。

  2. 1 行も変更する前に、負荷をかけたベースラインを取ります。

    負荷テストを実行し、プールを観察します。

    dotnet-counters monitor -n YourApp System.Runtime

    .NET 9 以降で読むべきカウンターは dotnet.thread_pool.thread.countdotnet.thread_pool.queue.lengthdotnet.thread_pool.work_item.count です。枯渇のサインは、CPU 使用率が 100% を大きく下回ったままスレッド数がじりじり増えていくことです。プロセッサー数のおよそ 3 倍を超えた値で安定している場合は、コードがプールのスレッドをブロックしており、ランタイムがスレッドを増やして補っていることを意味します。

    確認: 安定したスレッド数、p95 レイテンシ、1 秒あたりのリクエスト数を記録します。検証手順でこれらと比較します。

  3. ソースコード解析では見えないブロッキング呼び出しを見つけます。

    アナライザーは File.ReadAllTextSqlCommand.ExecuteReader、ソースを持っていない依存関係の奥に埋もれた SemaphoreSlim.Wait() を指摘できません。.NET 9 はまさにこのために WaitHandleWait イベントを追加しました。

    dotnet trace collect -n YourApp --clrevents waithandle --clreventlevel verbose --duration 00:00:30

    生成された .nettrace ファイルを PerfView かコミュニティ製の .NET Events Viewer で開き、WaitHandleWaitStart のスタックを展開します。底部のフレームに ThreadPoolWorkQueue.DispatchWorkerThread.WorkerThreadStart が現れるスタックは、ブロックされているプールのスレッドであり、待機の 1 つ上のフレームがあなたのメソッドを指しています。

    確認: トレース中のすべてのスタックが、手順 1 の棚卸しにすでにある呼び出し箇所と対応しているか、棚卸しに追加されるかのどちらかになります。

  4. ファイル単位ではなく、呼び出しチェーンを端から端まで変換します。

    手順 3 で最もホットだった単一のエントリポイントを選びます。リーフ (実際に HttpClient や EF Core を呼ぶメソッド) から始めて非同期版の双子を用意し、呼び出し元を 1 段ずつ変換しながらスタックを上っていき、自分自身に呼び出し元を持たずに await できるメソッドに到達するまで続けます。コントローラーのアクション、BackgroundService.ExecuteAsync、イベントハンドラー、Main などがそれにあたります。

    // .NET 11, C# 14 -- before: the block is three frames below the controller
    public IActionResult GetOrder(int id)
    {
        var order = _repository.Get(id);          // sync wrapper
        return Ok(order);
    }
    
    // after: no wrapper, no block, Task all the way to the framework
    public async Task<IActionResult> GetOrderAsync(int id, CancellationToken ct)
    {
        var order = await _repository.GetAsync(id, ct);
        return Ok(order);
    }

    この経路では、中途半端な変換は何もしないより悪い結果になります。同期区間のどこかに .Result が 1 つ残っているだけで、デッドロックも止まったスレッドも復活します。スライスはエントリポイントに到達して初めて完了です。

    確認: そのエンドポイントだけを対象に手順 3 のトレースをやり直します。そのスタックについて、プールのスレッド上の WaitHandleWait イベントがゼロになります。

  5. 同期版の双子は残さずに削除します。

    誘惑される近道は、Get()GetAsync().GetAwaiter().GetResult() として残し、ほかを何も変えなくて済むようにすることです。それは Stephen Toub が Should I expose synchronous wrappers for asynchronous methods? で否定している同期ラッパーであり、移行の途中では積極的に有害です。ラッパーは残ったブロッキング呼び出しの隠れ場所になり、呼び出し元がこの作業を永久に回避できるようにしてしまいます。

    同期の利用側と非同期の利用側が本当に両方あり、どちらも捨てられない場合は、ラッパーではなく BCL が使っているフラグ引数のパターンを使ってください。

    // .NET 11, C# 14 -- one implementation, two entry points, no sync-over-async
    public int Read(byte[] buffer) => ReadCoreAsync(buffer, sync: true).GetAwaiter().GetResult();
    public Task<int> ReadAsync(byte[] buffer) => ReadCoreAsync(buffer, sync: false);
    
    private async Task<int> ReadCoreAsync(byte[] buffer, bool sync)
    {
        // Every I/O call inside branches on `sync`, so the synchronous path
        // never awaits an incomplete task and cannot deadlock.
        return sync ? _stream.Read(buffer) : await _stream.ReadAsync(buffer);
    }

    確認: 同期のエントリポイントは未完了のタスクを待つことがないため、WaitHandleWait のトレースに現れなくなります。

  6. 本当に非同期にできない継ぎ目を処理します。

    どの移行でも 3 種類が出てきます。コンストラクターは async にできないため、初期化は静的ファクトリ (public static async Task<Foo> CreateAsync()) か、呼び出し元が待機する Lazy<Task<T>> フィールドへ移します。非同期のクリーンアップを行う DisposeIAsyncDisposable を実装し、await using で消費すべきです。新たな非同期処理を含む lock ブロックは CS1996 でコンパイルに失敗します。モニターは取得したのと同じスレッドで解放しなければならないからです。

    // .NET 11, C# 14 -- lock cannot span an await; SemaphoreSlim can
    private readonly SemaphoreSlim _gate = new(1, 1);
    
    public async Task<Config> LoadAsync(CancellationToken ct)
    {
        await _gate.WaitAsync(ct);
        try { return _cached ??= await FetchAsync(ct); }
        finally { _gate.Release(); }
    }

    確認: プロジェクトが CS1996 なしでコンパイルでき、イベントハンドラー以外に新しい async void がないことを確かめます。

  7. シグネチャを開いているうちに CancellationToken を通します。

    どのみち変更するシグネチャに CancellationToken ct = default を足すコストはゼロですが、後から後付けするのは苦痛です。CancellationToken を非同期メソッド越しに伝播させるのルールに従い、最も外側だけでなくチェーン内のすべての非同期呼び出しに渡してください。

    確認: 処理の途中でリクエストをキャンセル (クライアント接続を切断) し、データベース呼び出しが最後まで走り切るのではなく実際に中断されることを確かめます。

  8. 件数が減る方向にしか動かないよう、アナライザーをラチェットで固定します。

    プロジェクトがゼロに到達したら固定します。

    <!-- Directory.Build.props -- .NET 11 SDK 11.0.0 -->
    <PropertyGroup>
      <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
      <WarningsAsErrors>$(WarningsAsErrors);VSTHRD002;CA1849</WarningsAsErrors>
    </PropertyGroup>

    まだ移行の途中にあるプロジェクトでは、ルールを warning のままにして、あらゆる警告ではなく件数の増加で CI を落とします。古い負債を減らしながら新しい負債だけを止めるラチェットが、チームが実際に維持できる唯一の形です。

    確認: 変換済みのプロジェクトにわざと .Result を入れて、ビルドが失敗することを確かめます。

移行が本当に効いたかを検証する

シグネチャがコンパイルできることは証拠になりません。手順 2 と同じ負荷テストを実行し、4 つの数値を比較します。

その後で機能面の確認を行います。dotnet test が 1 件も失敗しないこと、クライアントの切断で下流の呼び出しが中断されることを示すキャンセルのテスト、そして触れたコード内の catch (AggregateException) ブロックの手動レビューです。ブロッキング呼び出しがなくなった後は、それらはもう何にも一致しません。

ロールバックの方針

スライス単位であれば、この移行はきれいに戻せます。垂直スライスはそれぞれ自己完結した PR であり、revert すればブロッキング呼び出しとそのシグネチャが復元されます。これがレイヤーではなく呼び出しチェーンで切るべき主な理由です。

きれいに戻せないのは公開済みのライブラリです。T Get()Task<T> GetAsync() に変えることは、旧アセンブリに対してコンパイルしたすべての利用側にとってバイナリ破壊的変更です。したがって NuGet パッケージにとってはメジャーバージョンの移行であり、取り消しは git revert ではなく新しいリリースになります。パッケージが 1 つのメジャーバージョンのあいだ両方の API 表面を提供するのか (その場合は手順 5 のフラグ引数パターンを使い、同期ラッパーは決して使いません)、それとも一度に破壊するのかを、着手前に決めてください。

時間を取られた落とし穴

async void はラムダ経由で戻ってきます。 Action 型のパラメーターに渡したラムダは async void になるため、その中の例外はタスクに現れずプロセスを落とします。List<T>.ForEach(async x => ...) と、非同期の本体を渡した Parallel.ForEach がよくある媒介です。デリゲートのケースは VSTHRD101 が検出します。正当な使い方と壊れた使い方の境界はasync void が正しいときと罠になるときにまとめています。

.Select(async x => ...) が返すのは IEnumerable<Task> であって結果ではありません。 コンパイルは通り、変換済みに見え、誰も待機しません。後ろに await Task.WhenAll(...) を付けるか、列挙自体を IAsyncEnumerable に切り替えてください。

TransactionScope は黙って流れなくなります。 既定のコンストラクターはアンビエントトランザクションを await をまたいで流さないため、最初の await 以降のコードはエラーひとつ出さずにトランザクションの外で実行されます。TransactionScopeAsyncFlowOption.Enabled を指定して生成してください。

ASP.NET Core は移行が終わる前に例外を投げます。 外側の層を変換すると、AllowSynchronousIO の既定値が false であるために、さらに下層の同期的な Stream.Read から InvalidOperationException: Synchronous operations are disallowed が表に出てくることがあります。この例外は残作業の地図であって、スイッチを戻す理由ではありません。詳細は synchronous operations are disallowed を解決するにあります。

ValueTask をブロックするのは遅いだけでなく未定義動作です。 変換したリーフが ValueTask<T> を返し、上流の呼び出し元がまだブロックしている場合、そこでの .Result はデッドロックのリスクという以前に未定義動作です。呼び出し元の変換が終わるまでは、その境界で .AsTask() を使って変換し、ValueTask のコストにある制約を読んでください。

ConfigureAwait(false) を作業完了の代替にしないでください。 自分が所有するライブラリの内側でデッドロックを無力化はしますが、止まったスレッドについては何もしませんし、ASP.NET Core にはそもそも降りるべきコンテキストがありません。これは変更できないコードのための緩和策であって、移行の戦略ではありません。

成功の尺度はアナライザーの件数がゼロになったことではありません。負荷をかけてもプールのスレッド数が増え続けなくなったこと、そしてキャンセルしたリクエストが実際に何かをキャンセルするようになったことです。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る