はじめに

 前回は、if文地獄の解決策として、インターフェースを使ったストラテジーパターンを学びました。第2回のテーマは「画面凍結」です。
 ボタンを押したら画面が固まって「応答なし」――デスクトップアプリを作っていると、一度はぶつかる問題です。原因の多くは、時間のかかる処理を同期的に実行してしまっていること。そこで登場するのが、C#のasync/awaitという非同期処理のしくみです。とはいえ、入門書を読んだだけでは、async/awaitを見ても結局なにが起きているか判然としない、モヤモヤする人が多いでしょう。
 今回も、AIとの対話を通じて、このモヤモヤを攻略していきましょう。前回と同様、テキストの回答例は主にChatGPT、図解の生成にはGeminiと、画像モデルが刷新されたChatGPTを使用しています。

まずはコードを見せて「何が問題?」と聞く

 あなたは、天気予報を表示するデスクトップアプリを作っています。ボタンを押すとWeb APIから予報データを取得して画面に表示する、シンプルなアプリです。

private static readonly HttpClient _httpClient = new();

// ボタンクリック時の処理
private void Button_Click(object sender, EventArgs e)
{
    // Web APIから予報データを取得(時間がかかる)
    string result = GetWeatherData();
    label.Text = result;
}
private string GetWeatherData()
{
    // APIの応答を待っている間、画面が固まる
    string json = _httpClient.GetStringAsync("https://api.example.com/weather").Result;
    return json;
}

 一見動きそうなコードですが、実行すると、ボタンを押した瞬間に画面が固まります。APIの応答が返るまで、ウィンドウをドラッグすることすらできません。

AIに問題点を聞く

 前回と同じように、もっとも問題になりそうなことをひとつに絞って聞きます。

次のC#コード(Windows Formsアプリ)を見てください。ボタンを押すと画面が固まってしまいます。もっとも問題になりそうなことをひとつ教えてください。
(さきほどのコードをそのまま入力)

 ChatGPTでは、次のような回答でした。

もっとも問題なのは、UIスレッドを同期的に止めていることです。
Button_Click は Windows Forms のUIスレッドで動きますが、その中で GetWeatherData() を呼び、さらにその中で GetStringAsync(...).Result を使っているため、APIの応答が返るまでUIスレッドがずっと待たされます。
直す方向は、同期待ちをやめて async/await にすることです。
(以下、略)

 AIは、UIスレッドという言葉を使って説明してくれました。少しわかりにくいので、さらに聞いてみましょう。

「もっとわかりやすく」と深掘りする

 技術用語のモヤモヤは、たとえ話に変換してもらって解消しましょう。

「UIスレッドを同期的に止めている」とはどういうことですか? 身近な場面にたとえて説明してもらえますか?

 ChatGPTでは、カフェでたとえてくれました(適宜要約しています)。手元のAIでも試してみてください。レストランやコンビニなど、別のたとえが返ってくることもあります。

店員=UIスレッド、ドリップコーヒー=時間のかかる処理(Web API)。
客が「ハンドドリップでお願いします」と注文。店員は自分でコーヒーをセットし、抽出が終わるまでその場でずっと見ている。その間は次の客の注文もレジも止まり、店全体が止まったように見える――これが、UIスレッドを同期的に止めている状態です。

 店を止めないにはどうすればよいのでしょう? 続けてAIに聞いてみます。

では、店が止まらないようにするには、どうすればいいですか?

 ChatGPTでは、次のような回答でした(適宜要約しています)。

店員(=UIスレッド)がドリップの抽出を別のバリスタに任せること。その後は次の客の注文やレジに戻り、抽出が終わったら戻ってきて受け取る――この“待っている間に止まらない”のが「非同期(asynchronous)」です。C#のasync/awaitは、この動きをコードで実現するしくみです。

 言葉だけだとちょっとわかりにくいですよね。そこで、図解にしてみましょう。Gemini(思考モード)にも依頼してみます。

カフェのレジカウンターのたとえを使って、「同期処理(画面が固まる場合)」と「非同期処理(画面が固まらない場合)」の違いがわかる図解をシンプルなイラストで作成してください。2つの図を上下に並べて比較してください。
  • 同期処理と非同期処理の比較イメージ(ChatGPT Images 2.0の生成)

    同期処理と非同期処理の比較イメージ(ChatGPT Images 2.0の生成)

 Geminiなら、次のような図になりました。

  • 同期処理と非同期処理の比較イメージ(Geminiの生成)

    同期処理と非同期処理の比較イメージ(Geminiの生成)

 一目瞭然ですね。上(同期処理)はレジ担当が自分でコーヒーを淹れてレジが止まり、下(非同期処理)はドリップをバリスタに任せてレジが動いています。

「コードで書くとどうなる?」とAIに生成させる

 図解でイメージがつかめたので、次はコードを見てみましょう。これまでの対話でAIが挙げてくれたasync/awaitが、具体的にどう書かれるのかを確かめます。

コードを生成させる

 第1回と同じように、最小限のコードで改善してもらいます。

最初の問題コードを、async/awaitを使って画面が固まらないように改善してください。最小限のコードでお願いします。

 ChatGPTでは、次のようなコードを生成してくれました。

private static readonly HttpClient _httpClient = new();

// ボタンクリック時の処理(UIスレッドで実行される)
private async void Button_Click(object sender, EventArgs e)
{
    // await を使うことで、
    // ここで一旦処理を中断し、結果が返ってきたら続きを実行できる
    string result = await GetWeatherDataAsync();

    // await の後は、元のUIスレッドに戻ってくるので安全にUI(label)を更新できる
    label.Text = result;
}

// 天気データを非同期で取得するメソッド
private async Task<string> GetWeatherDataAsync()
{
    // 非同期でHTTPリクエストを送る
    // この時点で「通信中」という Task が返る
    // await によって、通信完了まで待つが、UIスレッドはブロックしない
    return await _httpClient.GetStringAsync("https://api.example.com/weather");
}

async/await/Taskとカフェの対応関係を確認する

 短いコードですが、async、await、Taskと新しいキーワードが一気に登場しています。それぞれがカフェのたとえで何に当たるのか、整理してもらいましょう。

このカフェのたとえで、C#のasync、await、Taskはそれぞれ何に対応しますか?

 ChatGPTでは、次のような回答でした(適宜要約しています)。

Task → 「コーヒーができるまでの“進行中の注文”」
async → 「この店員は“ながら作業できる人”ですよ、という宣言」
await → 「その注文ができあがるタイミングで受け取る」

・Task:まだ完成していないコーヒーの“注文票”です。結果(コーヒー)はまだないけれど、「進行中」という状態は持っている。
コードでいうと:Task<string> task = GetWeatherDataAsync();

・async:「この店員は、待ち時間に他の仕事をします」という働き方の宣言。コーヒー抽出中にボーっとせず、次の客の注文を取ったり、レジを動かしたりします。
コードでいうと:async Task<string> GetWeatherDataAsync()

・await:「コーヒーができたタイミングで、ちゃんと受け取る」動き。ドリップ開始(Task発行)→他の客の対応→できあがった瞬間に戻ってきて受け取る、という流れです。
コードでいうと:string result = await GetWeatherDataAsync();

 asyncの「ながら作業できる」というのは、ドリップを見守るのではなく、ドリップをまかせて、できたらawaitで受け取る、というわけですね。

改善前と改善後を比較する

 対応関係がつかめたところで、改善前のコードと見比べてみましょう。どこが変わったのかを正確に押さえておきたいところです。

改善前のコードと改善後のコードで、何が変わりましたか? 変更点と、処理の流れがどう変わったのかを時系列で比較して説明してください。

 ChatGPTでは、次のような回答でした。

コードの違いを一言でいうと、
改善前: Result でその場に立ち止まって待つ
改善後: await でいったん処理を返し、終わったら続きから再開する

です。 (以下、時系列の回答)

シーケンス図を依頼する

 文章だけではピンとこないので、改善後のコードの処理の流れを視覚的に確認してみましょう。先ほどのコードをAIに見せた上で、シーケンス図を依頼します。

改善後のasync/awaitコードの処理の流れを、シーケンス図(時系列の流れ図)で描いてください。

 Gemini(思考モード)では、Mermaid記法のコードだけが出力されました(*)。ChatGPTでもコードが出力されましたが、次のようなプレビュー画像も表示できました。

*)Mermaid記法がピンと来なければ、そのまま「Mermaid記法って何?」とAIに聞いてみましょう。テキストで図を描ける記法であることを教えてくれるはずです。

  • 非同期処理のシーケンス図(ChatGPTでのプレビュー画像)

    非同期処理のシーケンス図(ChatGPTでのプレビュー画像)

 シーケンス図を見ると、APIの応答を待っている間もUIスレッドが解放され、画面の操作を受け付けられる状態になっていることが一目でわかりますね。

エラーハンドリングを聞く

 ところで、Web APIはネットワークの問題で失敗することもあります。そんなときはどうなるのでしょうか? AIに聞いてみましょう。

改善後のコードで、Web APIの呼び出しが失敗した場合はどうなりますか? エラーを処理するにはどう書けばいいですか?

 ChatGPTでは、次のような回答でした。

改善後のコードでは、Web API呼び出しに失敗すると、await _httpClient.GetStringAsync(...) のところで例外が発生します。
今回の形だと Button_Click は async void なので、そこで例外を捕まえないと、アプリ側では「イベント処理中の未処理例外」になってしまいます。特に async void の例外は非常に厄介で、エラーログすら残らずにアプリがいきなり強制終了してしまうこともあります。なので、イベントハンドラ側で必ず try-catch するのが基本です。

 そして次のようなコードを示してくれました。

private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        string result = await GetWeatherDataAsync();
        label.Text = result;    // 成功したら画面に表示
    }
    catch (Exception ex)
    {
        label.Text = "天気情報の取得に失敗しました";
        MessageBox.Show(ex.Message, "エラー");
    }
}

 ふだんのtry-catchがそのまま使えるのは安心です。

よくあるミスとその対処法

 async/awaitの基本がわかったところで、初学者がやりがちなミスをAIに聞いてみましょう。

C#のasync/awaitで、初心者がやりがちなミスを3つ教えてください。それぞれ、なぜ問題なのかも教えてください。

 ChatGPTでは、次のような回答でした(適宜要約しています)。

C#のasync/awaitで初心者がやりがちなミスは、とくに次の3つです。

1. asyncを付けたのにawaitしない
GetWeatherDataAsync(); のようにawaitを書き忘れると、通信が終わる前に次の行へ進んでしまい、label.Textが空のままになります。ドリップを注文したのに、できあがりを待たずに「受け取り完了」と言っている状態ですね。

2. async voidを乱用する
async voidはイベントハンドラ(Button_Clickなど)専用と考えると安全です。通常の非同期メソッドは、完了を待てるようにasync Task/async Task<T>を返してください。

3. .Resultや.Wait()で同期的にブロックする
string result = GetWeatherDataAsync().Result; のように無理やり待つと、UIスレッドを止めて画面のフリーズやデッドロックの    原因になります。基本は string result = await GetWeatherDataAsync(); のようにawaitで待ちましょう。

async/awaitでは「どこで自然に待つか」を意識することが大切です。

 まずは1つ目と2つ目のミスについて、もうちょっとつっこんで聞いてみましょう。

さきほどの「awaitを書き忘れる」「async voidを乱用する」について、もう少し補足してください。
・戻り値を受け取らずに呼び出すとどうなりますか? コンパイラは教えてくれますか?
・async voidが「イベントハンドラ専用」と言われるのはなぜですか?

 ChatGPTでは、次のような回答でした(適宜要約しています)。

戻り値を受け取らずに呼び出すとどうなるか:
Task<string>を返すメソッドの戻り値をstringとして受け取ろうとすると、コンパイルエラーになります(string result = GetWeatherDataAsync();)。正しくはawaitして中身のstringを取り出します(string result = await GetWeatherDataAsync();)。
戻り値をまったく受け取らずに GetWeatherDataAsync(); とだけ書くとコンパイルは通りますが、CS4014の警告が「この呼び出しは待機されないため、完了を待たずに次へ進みます」と教えてくれます。通信のように結果が必要な処理では、基本的にawaitしましょう。

async voidがイベントハンドラ専用と言われる理由:
Windows Formsのボタンクリックイベントは void 戻り値を要求するため、Taskを返せず、ここだけは例外的にasync voidを使います。
通常の非同期メソッドをasync voidにすると呼び出し元がawaitできなくなるので、イベントハンドラはasync void、それ以外はasync Task/async Task<T>と覚えておくとよいでしょう。

 コンパイラが警告を出してくれる、というのは心強いですね。警告は、await忘れのサインと言えそうです。

デッドロックの原因を図解させる

 先ほどのよくあるミスで、.Resultを使うとデッドロックが起きることがある、という話がありました。具体的にはどういう状況で、なぜそんなことが起きるのでしょうか。カフェのたとえで聞いてみましょう。

.Resultを使うとデッドロックが起きる場合があるとのことですが、なぜデッドロックが起きるのか、カフェのたとえで説明してもらえますか?

 ChatGPTでは、次のような回答でした(適宜要約しています)。

.Resultでデッドロックが起きる理由は、カフェのたとえで考えるとイメージしやすいです。

string result = await GetWeatherDataAsync(); ならレジ担当(UIスレッド)はいったん手を空け、できあがりに戻ってきます。
ところが、string result = GetWeatherDataAsync().Result; と書いたとたん、レジ担当は「結果が返るまで自分は完全に止まります」と宣言した状態になり、画面の更新もクリック対応もできません。
さらに、非同期処理が終わったあとはawaitの続きをUIスレッドに戻して処理したい――たとえば取得した天気をlabel.Textに表示する処理――ところが、UIスレッドは.Resultで止まったまま。

つまり、
・レジ担当は、バリスタの完了を待っている
・バリスタは、レジ担当が空くのを待っている
という相互待ちです。これがデッドロック。エラーも出ず画面が固まるだけなので、気づきにくいのも厄介です。

Windows Formsでは.Resultや.Wait()で止めず、基本はawait――レジ担当を止めない、が鉄則ですね。

 デッドロックとは、UIスレッドの店員が、自分で自分の帰り道をふさいでしまうようなものですね。図解でも確認してみましょう。

今の説明を図解にしてください。レジ担当とバリスタがお互いを待っている状態を、シンプルなイラストで描いてください。
  • デッドロックの図解イメージ(ChatGPT Images 2.0の生成)

    デッドロックの図解イメージ(ChatGPT Images 2.0の生成)

 Geminiなら、次のようなイラストになりました。

  • デッドロックの図解イメージ(Geminiの生成)

    デッドロックの図解イメージ(Geminiの生成)

 お互いが相手を待っている状態が、一目でわかります。

おわりに

 今回は、async/awaitを使った非同期処理を、カフェのたとえで攻略しました。前回と同じ「違和感をぶつける→たとえ話→図解→改善コード」という流れで進めましたが、今回はシーケンス図での確認や、よくあるミスを教えてという対話を加えてみました。
 特に非同期処理のような「目に見えない裏側の動き」を把握するには、AIにシーケンス図を描かせて可視化するアプローチはとても有効です。「正しい書き方」や「やってはいけないこと」も、図解とセットにすることで理解がぐっと深まります。
 次回は、デリゲートとラムダ式を取り上げます。「メソッドを変数に入れる?」というモヤモヤを、AIとの対話で解消していきましょう。

WINGSプロジェクト 髙江 賢(著)山田祥寛(監修)
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティ(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手がける。現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
RSS
X:@WingsPro_info(公式)@WingsPro_info/wings(メンバーリスト)
Facebook

<著者について>
パソコン黎明期からプログラミングの進化を追い、Web・モバイル・IoT・AIまで多様な開発現場を駆け抜ける。
現在、株式会社気象工学研究所で気象×ITの最前線に立ちつつ、執筆コミュニティ『WINGSプロジェクト』のメンバーとして活動中。