C#入門編

C#入門編(17)非同期処理(async, await, Task) ~複数の処理を並行して実行~

今回は「非同期処理」について解説します。

非同期処理は、複数の処理を同時並行で効率的に実行するための仕組みです。

実は、これまで入門編で作ってきたプログラムは全て同期処理で動作しています。

同期処理と非同期処理の違いをみてみましょう。

同期処理では処理は逐次的に実行されます。一方、非同期処理では複数の処理が並行して行われるのです。

これによって、時間のかかる処理(例:ファイルのダウンロード)を行いつつ、ユーザ入力の受付処理も行いアプリの応答性を保つといったことが可能になります。

本記事では以下について説明します。

  • 非同期処理の基本概念と必要性
  • async/awaitキーワードとTaskクラスの使い方
  • 非同期処理を用いたプログラムの実装方法
プロ太

非同期処理はモダンなC#開発において必須の要素です。

応答性が高くユーザビリティの高いアプリを作るために、ぜひマスターしましょう!

演習のコードはGitHubにあります。

講義1:非同期処理の基本

非同期処理の必要性

プログラムの実行時間が長くなる処理(例:ファイル操作、ネットワーク通信、データベースアクセスなど)を同期的に行うと、以下のような問題が発生する可能性があります。

  1. アプリの応答性が低下する
  2. 複数の処理を効率的に行えない

非同期処理によってこれらの問題が解決します。

①アプリの応答性を向上

冒頭で紹介した例を同期的に行ってしまうと、ファイルのダウンロードをしている間はユーザ入力が受け作られなくなるため画面が一時的にフリーズし、応答性が著しく低下します。

以下がイメージです。

プロ美

アプリが応答しなくなったら、何か問題が発生したのかなと思っちゃうね…。

非同期処理によって応答性を保ったまま、実行時間のかかる処理をバックグラウンドで実行できるわけですね。

②複数の処理を効率よく行う

実行時間のかかる処理を同期的に順番に行うと、全体として時間がかかってしまう場合があります。

以下のように、非同期処理を使ってこれらの処理を同時並行に行うことで効率よく処理を行うことが可能です。

C#における非同期処理のコード例

簡単なサンプルコードをみながら、非同期処理の基本的な仕組みを学びましょう。

次の処理を同期処理/非同期処理でそれぞれ行うコードをみてみましょう。

  • (ア)ユーザ名入力
  • (イ)ファイルダウンロード(中身はダミーでウェイト処理)
  • (ウ)入力されたユーザ名とダウンロードしたファイルサイズを出力

同期処理では(ア)、(イ)、(ウ)を逐次で行い、非同期処理では(ア)と(イ)について並行に行い、その後(ウ)を行います。

class Program
{
    static async Task Main()
    {
        Console.WriteLine("ユーザ名入力とファイルダウンロードを行います");
        Console.WriteLine("同期処理と非同期処理を比較しましょう");

        // 1. 同期処理の例
        Console.WriteLine("\n1. 同期処理:");
        SequentialDownloadAndInput();

        // 2. 非同期処理の例
        Console.WriteLine("\n2. 非同期処理:");
        await ConcurrentDownloadAndInputAsync();
    }

    static void SequentialDownloadAndInput()
    {
        int fileSize = SimulateFileDownload(); //(ア)
        string userName = GetUserName(); //(イ)
        // (ウ)
        Console.WriteLine($"こんにちは、{userName}さん!ダウンロードしたファイルのサイズは {fileSize} バイトです。");
    }

    static async Task ConcurrentDownloadAndInputAsync()
    {
        Task<int> downloadTask = SimulateFileDownloadAsync(); //(ア) 開始
        string userName = GetUserName(); //(イ)
        int fileSize = await downloadTask; //(ア) 結果待ち
        //(ウ)
        Console.WriteLine($"こんにちは、{userName}さん!ダウンロードしたファイルのサイズは {fileSize} バイトです。");
    }

    static string GetUserName()
    {
        Console.WriteLine("ユーザー名を入力してください:");
        string userName = Console.ReadLine() ?? "名無し";
        Console.WriteLine($"ユーザー名は {userName} です。");
        return userName;
    }

    static int SimulateFileDownload()
    {
        Console.WriteLine("ファイルのダウンロードを開始します...");
        System.Threading.Thread.Sleep(5000); // ダウンロードに5秒かかると仮定
        Console.WriteLine("ファイルのダウンロードが完了しました。");
        return 123456789; // ファイルサイズを返す
    }

    static async Task<int> SimulateFileDownloadAsync()
    {
        Console.WriteLine("ファイルのダウンロードを開始します...");
        await Task.Delay(5000); // ダウンロードに5秒かかると仮定
        Console.WriteLine("ファイルのダウンロードが完了しました。");
        return 123456789; // ファイルサイズを返す
    }
}

同期処理では、ダウンロード中にユーザ入力を行えません。非同期処理ではダウンロード中にユーザ入力可能です。

このコードの動作イメージと実行結果例は以下になります。

実行結果の例は以下のようになります。非同期処理ではダウンロード中にユーザ名を入力可能です。

ユーザ名入力とファイルダウンロードを行います
同期処理と非同期処理を比較しましょう

1. 同期処理:
ファイルのダウンロードを開始します...
ファイルのダウンロードが完了しました。
ユーザー名を入力してください:
Prota
ユーザー名は Prota です。
こんにちは、Protaさん!ダウンロードしたファイルのサイズは 123456789 バイトです。

2. 非同期処理:
ファイルのダウンロードを開始します...
ユーザー名を入力してください:
Prota
ユーザー名は Prota です。
ファイルのダウンロードが完了しました。
こんにちは、Protaさん!ダウンロードしたファイルのサイズは 123456789 バイトです。

このコードで、非同期処理の仕組みとして重要な部分はasync、await、Taskです。

async

async キーワードは、メソッドが非同期であることを示します。このキーワードを使用すると、メソッド内で await キーワードを使用できるようになります。

この例では、ConcurrentDownloadAndInputAsync、SimulateFileDownloadAsync、Task.Delay(そして、Mainも)が非同期メソッドですね。

プロ太

非同期メソッドを作成する際は、メソッド名の末尾に「Async」サフィックスを付けることが強く推奨されています。

メソッドの非同期性が明確になりコードの可読性が高まるため、この命名規則に従いましょう!

awaitとTask

awaitとTaskは常にセットで使います。

例として、ConcurrentDownloadAndInputAsyncメソッドをみてみましょう。

static async Task ConcurrentDownloadAndInputAsync()
{
    Task<int> downloadTask = SimulateFileDownloadAsync(); //★(a)
    string userName = GetUserName(); //★(b)
    int fileSize = await downloadTask; //★(c)
    Console.WriteLine($"こんにちは、{userName}さん!ダウンロードしたファイルのサイズは {fileSize} バイトです。");
}

(a)で、SimulateFileDownloadAsync() メソッドは Task を返します。

この 「Task」は「将来完了する作業」を表現しています。この時点で、ダウンロード作業はバックグラウンドで開始されますが、メソッドの実行はブロックされません。

プロ太

Taskはその作業の状態(実行中、完了、失敗)や結果を追跡する機能を持っています。

Task が作成された後、メソッドは次の行(b)に進み、ユーザー入力を受け付けます。この間、ダウンロード作業は並行して続行されています。

(c)で、「await」キーワードを使用して、downloadTaskの完了を待ちます。もし Task がまだ完了しない場合、メソッドはここで一時停止し、Task が完了すると再開されます。

awaitとTaskの重要ポイントは以下です。

  • 「Task」を作成した時点では、メソッドはブロックされません。「await」を使用するまで、他の処理を続行できます。
  • 「await」は必ずしも 「Task」の作成直後に使用する必要はありません。他の処理を行った後に 「await」を使用できます。
プロ太

非同期処理を理解するうえで、awaitとTaskが核心部分といえるでしょう。

以下がサンプルコードにおけるawaitとTaskの連携イメージです。

ちなみに、この例ではダウンロード処理のタスクに戻り値(ファイルサイズ)はありますが、戻り値をなしにすることもできます。

async Task SomethingMethodAsync(){
   …
   return;
}

…

await SomethingMethodAsync();

サンプルコードで使っているSleepとDelayは両方とも処理を一時停止させます。

Sleepは現在の実行フロー全体を止めてしまうのに対し、Delayは他の作業を続けながら待つことができます。

この例では、両方とも単に時間がかかる処理を模倣していると考えてください。

非同期処理については、Microsoftの記事も参考にしてください。

演習:キャンセル可能なメニュー画像生成 ~非同期処理の実践~

作る機能

これまでの入門編で使ってきたレストランメニュー生成プログラムを拡張し、各メニュー項目に対応する画像をAIで生成(途中キャンセル可能)機能を作ります。

プロ太

・・・といいつつAIで画像を作る部分は今回「ダミー」です。

時間のかかる処理(例:AIで画像を作る)を非同期処理で行い、「途中でユーザがキャンセル可能」にすることがこの演習の主眼です。

「途中キャンセル」はアプリの応答性を高めるために重要な機能ですね。

非同期処理を使って実装してみましょう!

一応、これまで作ってきたレストランメニュー表生成プログラムを土台としています。

とはいえ、AIでメニューの画像を作る部分はダミーなので、非同期処理部分に着目してコードをみてもらえればと思います。

演習コード

演習コードは以下になります。ユーザがコンソールで’e’を入力すると、画像生成を途中でキャンセルしてアプリは終了します。

using Restaurant.Menus;

namespace Restaurant
{
    class Program
    {
        static async Task Main(string[] args)
        {
            List<Menu> menus = GenerateMenuList();
            using (var cancellationTokenSource = new CancellationTokenSource())
            {

                Console.WriteLine("メニュー画像生成を開始します。'e'キーを押すと終了します。");

                // 非同期で画像生成とユーザー入力の監視を開始
                Task generationTask = GenerateImagesAsync(menus, cancellationTokenSource.Token);
                Task inputTask = MonitorUserInputAsync(cancellationTokenSource);

                // 画像生成の完了を待機
                await generationTask;

                // ユーザー入力の監視をキャンセル
                // (ユーザ入力タスクが完了していない場合にはキャンセルされる)
                cancellationTokenSource.Cancel();
                await inputTask;

                Console.WriteLine("アプリケーションを終了します。");
            }
        }

        static List<Menu> GenerateMenuList()
        {
            return new List<Menu>
            {
                new MainMenu("黒毛和牛ステーキ", "ジューシーで柔らかなステーキです。", 2500, false),
                new MainMenu("ベジタブルカレー", "野菜をたっぷりと使った、スパイシーなカレーです。", 1500, true),
                new DrinkMenu("ホットコーヒー", "丁寧に焙煎されたコーヒー豆を使用しています。", 300, false)
            };
        }

        static async Task GenerateImagesAsync(List<Menu> menus, CancellationToken cancellationToken)
        {
            try
            {
                for (int i = 0; i < menus.Count; i++)
                {
                    var menu = menus[i];
                    cancellationToken.ThrowIfCancellationRequested();

                    Console.WriteLine($"{i}: AI画像生成を開始: {menu.Name}");
                    // ダミーでウェイト、実際の画像生成APIを呼び出す
                    await Task.Delay(2000, cancellationToken);
                    Console.WriteLine($"{i}: AI画像生成を完了: {menu.Name}");

                    // ダミーでウェイト、ここで実際にファイルを保存する
                    Console.WriteLine($"{i}: 画像保存を開始: {menu.Name}.jpg");
                    await Task.Delay(1000, cancellationToken);
                    Console.WriteLine($"{i}: 画像保存を完了: {menu.Name}.jpg");
                }

                Console.WriteLine("すべてのメニュー項目の画像生成が完了しました。");
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("画像生成処理がキャンセルされました。");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"エラーが発生しました: {ex.Message}");
            }
        }

        static async Task MonitorUserInputAsync(CancellationTokenSource cancellationTokenSource)
        {
            while (!cancellationTokenSource.Token.IsCancellationRequested)
            {
                if (Console.KeyAvailable)
                {
                    var key = Console.ReadKey(true);
                    if (char.ToLower(key.KeyChar) == 'e')
                    {
                        cancellationTokenSource.Cancel();
                        Console.WriteLine("キャンセル要求が送信されました。処理を停止します...");
                        break;
                    }
                }
                await Task.Delay(100);
            }
            Console.WriteLine("ユーザー入力の監視を終了します。");
        }
    }
}

概要

このコードでは以下の3つのタスクが登場し、これらが非同期的に並行動作します。

  • メインタスク
  • 画像生成タスク (GenerateImagesAsyncメソッド)
  • キャンセル用ユーザ入力受付タスク (MonitorUserInputAsyncメソッド)

そして、このプログラムの動作は大きく以下の2つのシナリオがあります。

  • 【1】画像生成が全て完了(途中キャンセルはしない)
  • 【2】画像生成を途中でキャンセル

動作のイメージは以下になります。

ポイントを解説

さきほどの動作イメージを頭におきつつ、コードのポイントを解説します。

メインタスク

Main メソッドは async キーワードを使って非同期メソッドとして定義されています。これにより、メソッド内で await キーワードを使用できます。

static async Task Main(string[] args)
{
    ...
}

CancellationTokenSource は、非同期操作をキャンセルするための機能を提供します。using ステートメントで囲むことで、使用後に適切にリソースを解放します。

using (var cancellationTokenSource = new CancellationTokenSource())
{
    ...
}

usingステートメントとリソース解放については、Microsoftの記事も参考にしてください。

画像生成タスクとユーザー入力監視タスクを同時に開始します。これらは Task オブジェクトとして表現され、非同期に実行されます。

Task generationTask = GenerateImagesAsync(menus, cancellationTokenSource.Token);
Task inputTask = MonitorUserInputAsync(cancellationTokenSource);

そして、awaitでgenerationTask、inputTaskの完了を待機し、両方とも完了したらアプリを終了します。

画像生成タスク(GenerateImagesAsyncメソッド)

このメソッドは、各メニュー項目に対して画像を生成します。実際のアプリケーションでは、ここで画像生成APIを呼び出すことになります。以下がポイントです。

  • foreach ループ内で cancellationToken.ThrowIfCancellationRequested() を呼び出し、キャンセル要求があれば例外を発生
  • Task.Delay を使用して、時間のかかる処理をシミュレート
    (実際のアプリでは、ここで実際の画像生成や保存処理を実施)
  • try-catch ブロックで OperationCanceledException をキャッチして処理
キャンセル用ユーザ入力受付タスク (MonitorUserInputAsyncメソッド)

このメソッドは、ユーザーの入力を非同期に監視します。‘e’キーが押されたらキャンセル要求を送信します。以下がポイントです。

  • while ループ内で Console.KeyAvailable をチェックし、キー入力があるかどうかを確認します。
  • ‘e’キーが押された場合、cancellationTokenSource.Cancel()でキャンセル命令を送信し、ループから抜けて自身のタスクを終了します。

アプリを実行

画像生成を全て完了・途中でキャンセルという2つのシナリオでそれぞれでアプリを実行してみましょう。それぞれの実行例を示します。

【1】画像生成が全て完了(途中キャンセルはしない)

メニュー画像生成を開始します。'e'キーを押すと終了します。
0: AI画像生成を開始: 黒毛和牛ステーキ
0: AI画像生成を完了: 黒毛和牛ステーキ
0: 画像保存を開始: 黒毛和牛ステーキ.jpg
0: 画像保存を完了: 黒毛和牛ステーキ.jpg
1: AI画像生成を開始: ベジタブルカレー
1: AI画像生成を完了: ベジタブルカレー
1: 画像保存を開始: ベジタブルカレー.jpg
1: 画像保存を完了: ベジタブルカレー.jpg
2: AI画像生成を開始: ホットコーヒー
2: AI画像生成を完了: ホットコーヒー
2: 画像保存を開始: ホットコーヒー.jpg
2: 画像保存を完了: ホットコーヒー.jpg
すべてのメニュー項目の画像生成が完了しました。
ユーザー入力の監視を終了します。
アプリケーションを終了します。

【2】画像生成を途中でキャンセル

メニュー画像生成を開始します。'e'キーを押すと終了します。
0: AI画像生成を開始: 黒毛和牛ステーキ
0: AI画像生成を完了: 黒毛和牛ステーキ
0: 画像保存を開始: 黒毛和牛ステーキ.jpg
0: 画像保存を完了: 黒毛和牛ステーキ.jpg
1: AI画像生成を開始: ベジタブルカレー
1: AI画像生成を完了: ベジタブルカレー
1: 画像保存を開始: ベジタブルカレー.jpg
キャンセル要求が送信されました。処理を停止します...
ユーザー入力の監視を終了します。
画像生成処理がキャンセルされました。
アプリケーションを終了します。
プロ美

ユーザーはいつでも処理をキャンセルできるから、
アプリの応答性をきちんと確保できてるってことだね!

発展的な内容になりますが、この演習コードでは非同期処理を活用して複数の処理を効率よく行うようにできる余地もあります。

GenerateImageAsyncタスク内における画像生成処理は、現状では同期的にひとつずつ行われていますね。

このような画像処理についても、それぞれTaskを作成して同時並行に動作させると、全体の処理時間を短縮できる可能性もあります。

講義2:非同期処理の基本(補足)

前回の講義と演習で、非同期処理の基本的な部分を学びました。今回は、非同期処理を実践的に使う上で必要になるポイントをいくつか補足説明します。

I/OバウンドとCPUバウンド

非同期処理を使う際、処理の種類によって適切な方法が異なります。主に以下の2つに分類されます。

  • I/Oバウンド処理
  • CPUバウンド処理

I/Oバウンド処理は、ファイル操作、ネットワーク通信、データベースアクセスなど処理の大部分が入出力待ちで、CPUをあまり使用しません。
(Task.DelayもCPUを使わないため、I/Oバウンド処理に分類されます)

これまでの例や演習でみてきたものは全てI/Oバウンド処理です。非同期処理の実現方法はこれまでみてきた通りです。

CPUバウンド処理は、複雑な計算や大量のデータ処理など CPUを集中的に使用します。

CPUバウンド処理はTask.Runを使用して新しいスレッドで実行します。(正確にはスレッドプールにあるスレッドが割り当てられます)

以下が例です。

public async Task<List<Point>> FindPathAsync(Grid grid, Point start, Point goal)
{
    return await Task.Run(() =>
    {
        //経路探索を行う (計算量が多い処理)
        var pathFinder = new PathFinder(grid);
        return pathFinder.FindPath(start, goal);
    });
}

「スレッド」はプログラムの実行単位です。C#の非同期処理においてスレッドは主に自動で管理されています。

I/Oバウンドの非同期処理では、多くの場合、追加のスレッドを使用せずに効率的に実行されます。

CPUバウンドの処理では、Task.Runを使用することで追加のスレッドを用いて、そこでCPUを使った計算を行います。

本記事は非同期処理の入門的な位置づけのため、スレッドの詳細には触れません。詳しく知りたい方は、Microsoftの記事を参照してください。

プロ太

作るアプリにもよりますが、非同期処理は「I/Oバウンド」で使う場面が多いかと思うので、まずはそちらをしっかり押さえるとよいでしょう。

そして次のステップとして、CPUバウンドやスレッドの理解を深めていくと良いと思います。

例外処理

非同期メソッド内で発生した例外処理は以下のように、awaitの箇所でキャッチできます。

async Task ExceptionHandlingExample()
{
    Task task = SomethingAsync();

    try
    {
        await task;
    }
    catch (Exception e)
    {
        Console.WriteLine($"エラーが発生しました: {e.Message}");
    }
}

この方法で、SomethingAsync()メソッド内で発生した例外を捕捉できます。

複数の非同期処理の制御

複数の非同期処理を制御するさまざまなメソッドがあります。

例えばTask.WhenAllメソッドを使うと、すべてのタスクが完了するまで待機します。

Task task1 = DoSomethingAsync();
Task task2 = DoSomethingElseAsync();

await Task.WhenAll(task1, task2);
Console.WriteLine("両方のタスクが完了しました");

非同期処理を制御するメソッドにどのようなものがあるかは、Taskクラスのドキュメントも参考にしてください。

まとめ

非同期処理は、応答性の高く効率的なアプリを作成するために不可欠な技術です。

asyncawaitキーワード、Taskクラスを使用することで、簡潔に非同期処理を記述できます。

演習では、実行時間がかかる処理を途中でキャンセルできる機能を作り、実際に応答性の高いアプリを作りました。

また、I/OバウンドとCPUバウンド非同期処理における例外ハンドリングの方法などについても学びました。

非同期処理を扱うプログラミングは、最初は複雑に感じるかもしれませんが、使っているうちにだんだんと理解できるようになるかと思うので、頑張りましょう!

プロ太

引き続き、C#やプログラミングの考え方を一緒に学んでいきましょう!

ABOUT ME
プロ太
プログラミングを勉強している人へ情報を発信していきます! ・情報工学分野で博士(工学)の学位取得 ・言語:C# 、Java、C/C++、Python、JavaScript/TypeScript等 ・仕事は主に上流工程(WF開発・Agile開発、OSS開発経験あり) ・趣味で開発:3Dゲーム、Webアプリ、言語処理系等

ご依頼・ご相談について

プログラミング学習のご相談、お仕事のご依頼については、
こちらのお問い合わせページをご確認ください。