はじめに
以前の記事『C#の不毛なコードレビューをゼロに!CSharp_IngeniousAnalyzer 全11ルール徹底解説』では、本アナライザーのルールを1つずつ解説しました。
多くの方にお読みいただき、心より感謝申し上げます!
今回は、v3.3.0 で追加した非同期処理のルール 『ASYNC001』 を紹介します。
ただし本題はルールそのものより、「自動修正(Fix)を提供する予定だったのに、最終的に“あえて提供しない”と判断した」 開発の裏側です。
なお、本アナライザーはプロフィールでも公言しているとおり、Claude Code(AnthropicのAIコーディングエージェント)と二人三脚で開発しています。
人間とAIがどう役割分担したかも、あわせて書いてみます。
ASYNC001: ConfigureAwait(false)の追加によるデッドロック回避
🔹 ASYNC001: ConfigureAwait(false)の追加によるデッドロック回避
- タイトル: ConfigureAwait(false)の追加によるデッドロック回避
-
メッセージ:
'{0}' の呼び出しに ConfigureAwait(false) がありません。ライブラリコードではデッドロック回避のため追加を検討してください。 -
解説:
awaitは既定で、待機前のSynchronizationContext(UIスレッドなど)に戻って続きの処理を実行します。
ライブラリのコードがこの挙動のまま、呼び出し元で.Resultや.Wait()による同期待ちをされると、デッドロック が発生します。
本ルールは、Task/Task<T>/ValueTask/ValueTask<T>をConfigureAwaitなしでawaitしている箇所を検知します。
検知例
public async Task<string> LoadAsync()
{
// ⚠ ASYNC001
var json = await client.GetStringAsync(url);
return json;
}
検知しないケース(誤検知防止)
| ケース | 理由 |
|---|---|
ConfigureAwait(true) / ConfigureAwait(false) が既に付いている |
値に関わらず、書き手の意図表明として尊重する |
async void のメソッド・ラムダ式(イベントハンドラー等) |
UIスレッドに戻る前提のコードであるため |
static Main・トップレベルステートメント |
ライブラリコードではないため |
Task / ValueTask 系ではない awaitable(Task.Yield() 等) |
ConfigureAwait が存在しないため |
アプリケーション側のプロジェクトやテストプロジェクトでノイズになる場合は、.editorconfig で警告レベルを調整してください。
dotnet_diagnostic.ASYNC001.severity = none
本題:「100%安全」なはずのFixを、出さなかった理由
当初の設計
実装前に、ルールのイメージを固めるための仕様書を用意し、Claude Code に渡しました。
その中で、Fix について次のように書いていました。
ASYNC001は機械的に100%安全な変換が可能なため、通常のCodeFixProviderとして実装する。
await xxx; の末尾に .ConfigureAwait(false) を足すだけなので、安全だと考えていたのです。
Claude Code は既存ルールの構成や命名規則を読み込んだうえで、この仕様どおりに Analyzer・CodeFix・テスト・README まで一気に実装し、テストも全部通りました。
立ち止まったきっかけ
実装結果の報告を受けて、私は Claude Code にこう投げかけました。
誤検知リスクは意識出来ていますか
誤検知の可能性がわずかでもあればFixできませんよ
テストのカバレッジは十分ですか
テストケースに不足はありませんか
返ってきた答えは「このFixは動作を変えてしまう。仕様書の『100%安全』という前提を検証せずに信じていた」というものでした。
仕様書を書いた私も、実装したAIも、同じ前提を疑わずに進めていた のです。
何が危険だったのか
ConfigureAwait(false) は構文を整えるだけのリファクタリングではなく、await 以降の処理がどのスレッドで動くかを変える動作変更 です。
// WPF のコードビハインドにある、ごく普通の async Task メソッド
private async Task RefreshAsync()
{
var items = await _repository.LoadAsync(); // ⚠ ASYNC001
// ↑ Fix で .ConfigureAwait(false) が付くと、この行はスレッドプールで実行され、
// 別スレッドからのUI操作として InvalidOperationException で落ちる
ItemsListBox.ItemsSource = items;
}
async void ではないので除外されず、ASYNC001 はここを検知します。
当初のFixを適用すると、コンパイルは通るのに実行時に落ちるコード が出来上がります。
await の後ろのコードがコンテキストに依存しているかは、アナライザー単体では100%判定できません。
「Ctrl + . で一瞬で直せる」ことを売りにしているアナライザーだからこそ、その一瞬で壊れるFixを出すわけにはいきません。
最終的な判断:Ignoreコメント挿入のみを提供
.ConfigureAwait(false) を追記するFixは削除し、CPX001 / CPX002 と同じく // Ignore ASYNC001 コメントを挿入するFixのみ を提供することにしました。
// Fix適用後:Ignoreコメントが挿入され、メソッド内の警告が抑制される
private async Task RefreshAsync()
{
// Ignore ASYNC001
var items = await _repository.LoadAsync();
ItemsListBox.ItemsSource = items;
}
ConfigureAwait(false) の追加は、await 以降がコンテキストに依存していないことを確認したうえで、手動で行っていただく運用です。
テストは「わざと壊して」確かめる
「テストケースに不足はありませんか」を受けて、Claude Code はテストを15ケースから24ケースへ拡充しました。
たとえば「async void メソッドの中にある Func<Task> のラムダは検知する」「list.ForEach(async x => ...) のラムダは Action 系なので検知しない」といった境界のケースです。
ただ、テストが全部パスしていても、それは「テストがバグを検知できる」ことの証明にはなりません。
そこで Claude Code が自発的に行ったのが ミューテーションテスト、つまり除外条件を1つずつわざと壊して、テストが落ちるかを確かめる方法です。
| わざと壊した箇所 | 結果 |
|---|---|
| async void の判定を削除 | ✅ 3件失敗 |
| エントリポイント(Main)の判定を削除 | ✅ 2件失敗 |
| 「直近の関数」ではなく「一番外側の関数」で判定 | ✅ 3件失敗 |
ConfigureAwait 呼び出しの判定を削除 |
❌ 全件パス(壊しても気付けない) |
最後の1つは、後段の「Task系の型か?」という判定で結果的に除外されていたため、壊してもテストが落ちませんでした。
「ConfigureAwait という名前で呼んでいれば尊重する」という仕様そのものがテストで守られていなかったので、専用のテストケースを追加して固定しています。
同じ考え方は、作業中に見つかった既存バグの修正でも役立ちました。
Ignoreコメントを挿入する共通処理に「LFのファイルでも、挿入した行だけCRLFになる」という不具合があり、既存テストの期待値にまでその挙動が固定されていたのです。
修正にあたっては、新しいテストを 修正前の実装でも実行 し、失敗するのが「LFファイルでLFになること」を確認するテストだけであることを確かめてからリリースしました。
直したい箇所以外の振る舞いが変わっていないことを、テストで証明できるわけです。
Claude Codeと開発してみて
今回の役割分担を振り返ると、次のようになります。
- 人間(私): ルールの構想、「誤検知の可能性があればFixは出さない」という品質基準、前提を疑う問いかけ、最終判断
- Claude Code: 既存コードの慣習の読み込み、実装、テストの網羅、ミューテーションテスト、修正前後の差分確認、既存バグの発見
除外条件ごとのテストの網羅や、条件の数だけ繰り返すミューテーションテスト、修正前の実装に戻しての差分確認までを、人間だけで毎回やりきるのは現実的ではありません。
この精度のアナライザーを人間だけで書くことは、ほぼ不可能だと思っています。
一方で、今回いちばん重要だった「そのFixは本当に安全か?」という問いは、人間側から投げかけたものでした。
AIは与えられた前提の上で非常に精度の高い仕事をしてくれますが、前提そのものを問い、品質の線引きを決めるのは人間の役割 だと改めて感じています。
おわりに
ASYNC001 は、検知ロジックそのものよりも 「Fixを出すかどうか」の判断 に一番時間を使ったルールでした。
本アナライザーが大切にしている 「Fixで一瞬で直せる快適さ」 は、「そのFixが絶対に動作を壊さない」 という信頼があってこそ成り立ちます。
少しでも動作を変える可能性があるなら、Fixは出さずにIgnoreで逃げ道を用意する――この線引きは、今後も守っていきます。
なお、await foreach / await using への ConfigureAwait(false) 未使用の検知は、次回以降の対応を予定しています。
ご意見・アイデアがありましたら、ぜひ本記事のコメント欄や、GitHubの Issues / Discussions、NuGetページから気軽にお知らせください!
最後までお読みいただき、ありがとうございました!