C#でTaskを使用して並列タスクを実行する原理と詳細な例

C#でTaskを使用して並列タスクを実行する原理と詳細な例

C#では、Taskを使用して並列タスクを簡単に実行できます。

最終更新 2023/03/28 20:17
沙漠尽头的狼
読了目安 9 分
カテゴリ
.NET
タグ
.NET C#

本稿はChatGPTとの連続対話によって完成され、コードはすべて検証済みです。

C#では、Taskを使用することで並列タスクを簡単に実行できます。Taskは非同期操作を表すクラスであり、マルチスレッドアプリケーションを作成するためのシンプルで軽量な方法を提供します。

一、Taskによる並列タスク実行の原理

Taskを使用して並列タスクを実行する原理は、タスクを複数の小さなブロックに分割し、各ブロックを異なるスレッドで実行できるようにすることです。そして、Task.Runメソッドを使用してこれらの小さなブロックを異なるタスクとしてスレッドプールに送信します。スレッドプールはスレッドの作成と破棄を自動的に管理し、システムリソースの利用状況に応じてスレッド数を自動調整することで、CPUリソースを最大限活用する効果を実現します。

二、5つのサンプル紹介

サンプル1

以下は、Taskを使用して並列タスクを実行する簡単なサンプルです。

void Task1()
{
    // タスク配列を作成
    var tasks = new Task[10];

    for (var i = 0; i < tasks.Length; i++)
    {
        var taskId = i + 1;

        // Task.Runメソッドでタスクを送信
        tasks[i] = Task.Run(() =>
        {
            Console.WriteLine("タスク {0} はスレッド {1} で実行中", taskId, Task.CurrentId);
            // タスクのロジックを実行
        });
    }

    // すべてのタスクの完了を待機
    Task.WaitAll(tasks);

    Console.WriteLine("すべてのタスクが完了しました。");
    Console.ReadKey();
}

このサンプルでは、長さ10のタスク配列を作成し、Task.Runメソッドで各タスクをスレッドプールに送信しています。タスク実行時にはTask.CurrentIdプロパティで現在のタスクのIDを取得し、表示して観察しやすくしています。最後にTask.WaitAllメソッドですべてのタスクの完了を待機し、完了メッセージを出力します。

実行結果:

タスク 3 はスレッド 11 で実行中
タスク 4 はスレッド 12 で実行中
タスク 8 はスレッド 16 で実行中
タスク 1 はスレッド 9 で実行中
タスク 2 はスレッド 10 で実行中
タスク 5 はスレッド 13 で実行中
タスク 6 はスレッド 14 で実行中
タスク 7 はスレッド 15 で実行中
タスク 9 はスレッド 17 で実行中
タスク 10 はスレッド 18 で実行中
すべてのタスクが完了しました。

注意点として、実際の開発ではタスクのサイズや数を具体的な状況に応じて評価し、並列タスクの効率と信頼性を確保する必要があります。

サンプル2

Taskを使用する別の例として、フィボナッチ数列の計算があります。フィボナッチ数列の各項を1つのタスクと見なし、Task.WaitAllメソッドですべてのタスクの完了を待機します。

void Task2()
{
    static long Fib(int n)
    {
        if (n is 0 or 1)
        {
            return n;
        }
        else
        {
            return Fib(n - 1) + Fib(n - 2);
        }
    }

    const int n = 10; // フィボナッチ数列の最初のn項を計算

    var tasks = new Task<long>[n];

    for (var i = 0; i < n; i++)
    {
        var index = i; // クロージャ内でループ変数を使用する場合は別の変数に代入する必要がある

        if (i < 2)
        {
            tasks[i] = Task.FromResult((long)i);
        }
        else
        {
            tasks[i] = Task.Run(() => Fib(index));
        }
    }

    // すべてのタスクの完了を待機
    Task.WaitAll(tasks);

    // 結果を表示
    for (var i = 0; i < n; i++)
    {
        Console.Write("{0} ", tasks[i].Result);
    }

    Console.ReadKey();
}

このサンプルでは、Task配列を使用してすべてのタスクを格納しています。最初の2項を計算する場合はTask.FromResultで完了したタスクを作成し、それ以外の場合はTask.Runメソッドで非同期タスクを作成してFibメソッドの結果を計算します。すべてのタスクの完了を待機した後、Task配列を走査し、Task.Resultプロパティで各タスクの結果を取得して表示します。

実行結果:

0 1 1 2 3 5 8 13 21 34

注意点として、非同期タスクを作成する際、ループ変数はクロージャ内で値が不定になるため、別の変数に代入してクロージャ内でその変数を使用する必要があります。そうしないと、すべてのタスクが同じループ変数の値を使用して結果が誤る可能性があります。

サンプル3

Task配列ですべてのタスクを格納する代わりに、Task.Factory.StartNewメソッドを使用して並列タスクを作成することもできます。このメソッドはTask.Runメソッドと同様に、非同期タスクを作成してスレッドプールに送信します。

void Task3()
{
    long Factorial(int n)
    {
        if (n == 0) return 1;
        return n * Factorial(n - 1);
    }

    const int n = 5; // 階乗を計算する数

    var task = Task.Factory.StartNew(() => Factorial(n));

    Console.WriteLine("階乗を計算中...");

    // タスクの完了を待機
    task.Wait();

    Console.WriteLine("{0}! = {1}", n, task.Result);
    Console.ReadKey();
}

このサンプルでは、Task.Factory.StartNewメソッドで階乗を計算する非同期タスクを作成し、タスクの完了を待機して結果を表示します。

実行結果:

階乗を計算中...
5! = 120

注意点として、Task.RunとTask.Factory.StartNewメソッドはどちらも非同期タスクを作成できますが、動作が若干異なります。特に、Task.Runメソッドは常にTaskScheduler.Defaultをタスクスケジューラとして使用しますが、Task.Factory.StartNewメソッドはタスクスケジューラやタスクタイプ、その他のオプションを指定できます。そのため、どちらの方法を使用するかは具体的な状況に応じて評価する必要があります。

サンプル4

Taskを使用する別の例として、ファイルの非同期読み取りがあります。このサンプルでは、Task.FromResultメソッドで完了したタスクを作成し、ファイル内容を結果として返します。

void Task4()
{
    const string filePath = "test.txt";

    var task = Task.FromResult(File.ReadAllText(filePath)); // 例示のための簡易コード。本来は File.ReadAllTextAsync(filePath); が望ましい

    Console.WriteLine("ファイル内容を読み込み中...");

    // タスクの完了を待機
    task.Wait();

    Console.WriteLine("ファイル内容: {0}", task.Result);
    Console.ReadKey();
}

このサンプルでは、Task.FromResultメソッドで完了したタスクを作成し、File.ReadAllTextメソッドでファイル内容を読み取って結果として返しています。タスクの完了を待機した後、Task.Resultプロパティでタスクの結果を取得できます。

文中のメモ帳は適宜作成してください。以下がテスト結果です。

ファイル内容を読み込み中...
ファイル内容: Dotnet9、ASP.NET Core Webサイト開発、MAUIクロスプラットフォームアプリ開発、WPFクライアント開発に特化し、https://Dotnet9.com で技術記事を共有しています。交流と学習を歓迎します。

注意点として、実際の開発で大容量ファイルの処理や長時間のI/O操作が必要な場合は、UIスレッドをブロックしないように非同期コードを使用する必要があります。例えば、大容量ファイルの読み取りでは非同期コードを使用することで、アプリケーションのパフォーマンスと応答性を向上させることができます。

サンプル5

最後のサンプルは、Taskとasync/awaitを使用した非同期タスクの実装です。このサンプルでは、時間のかかる操作を非同期メソッドにカプセル化し、async/awaitキーワードを使用してその操作の完了を待機します。

async Task Task5()
{
    async Task<string> LongOperationAsync()
    {
        // 時間のかかる操作をシミュレート
        await Task.Delay(TimeSpan.FromSeconds(3));

        return "完了";
    }

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} 時間のかかる操作を開始...");

    // 非同期メソッドの完了を待機
    var result = await LongOperationAsync();

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} 時間のかかる操作が完了: {result}");
    Console.ReadKey();
}

このサンプルでは、async/awaitキーワードを使用してLongOperationAsyncメソッドを非同期メソッドとして宣言し、awaitキーワードでTask.Delay操作の完了を待機しています。メインプログラムではawaitキーワードを使用してLongOperationAsyncの完了を待機し、その結果を取得しています。

2023-03-28 20:54:09.111 時間のかかる操作を開始...
2023-03-28 20:54:12.143 時間のかかる操作が完了: 完了

注意点として、async/awaitキーワードを使用する際は、非同期メソッド内部でスレッドをブロックする操作を避けるべきです。そうしないと、UIスレッドがブロックされる可能性があります。ブロック操作を実行する必要がある場合は、別のスレッドで実行するか、非同期I/O操作を使用してスレッドのブロックを回避してください。

三、async/awaitキーワード使用時の注意点

async/awaitキーワードを使用する際には、さらに注意すべき詳細があります。あと2つのサンプルを示します。

サンプル1

サンプルコードは以下の通りです。

async Task Task6()
{
    async Task<string> LongOperationAsync(int id)
    {
        // 時間のかかる操作をシミュレート
        await Task.Delay(TimeSpan.FromSeconds(1 + id));

        return $"{DateTime.Now:ss.fff} 完了 {id}";
    }

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} 時間のかかる操作を開始...");

    // 複数の非同期タスクの完了を待機
    var task1 = LongOperationAsync(1);
    var task2 = LongOperationAsync(2);
    var task3 = LongOperationAsync(3);

    var results = await Task.WhenAll(task1, task2, task3);
    var resultStr = string.Join(",", results);

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} 時間のかかる操作が完了: {resultStr}");
    Console.ReadKey();
}

このサンプルでは、Task.WhenAllメソッドで複数の非同期タスクの完了を待機し、Joinメソッドですべてのタスクの結果を連結して最終結果としています。

2023-03-28 21:15:42.855 時間のかかる操作を開始...
2023-03-28 21:15:46.894 時間のかかる操作が完了: 44.888 完了 1,45.883 完了 2,46.893 完了 3

サンプル2

もう1つ注意すべき点は、async/awaitキーワードを使用する際には、可能な限りConfigureAwait(false)メソッドの使用を避けるべきであるということです。このメソッドを使用すると、非同期操作が元のSynchronizationContextに復元する必要がなくなり、スレッド切り替えのオーバーヘッドを減らしてパフォーマンスを向上させることができます。

しかし、特定の状況では、非同期操作の完了後に元のSynchronizationContextに戻る必要がある場合、ConfigureAwait(false)を使用すると呼び出し元が結果を正しく処理できなくなる可能性があります。そのため、元のSynchronizationContextに戻る必要がないと確信できる場合にのみConfigureAwait(false)メソッドを使用することを推奨します。

サンプルコード: コンソールアプリケーションで、2つの非同期メソッドMethodAAsync()とMethodBAsync()があるとします。MethodAAsync()は1秒待機して文字列を返し、MethodBAsync()は2秒待機して文字列を返します。コードは以下の通りです。

async Task<string> MethodAAsync()
{
    await Task.Delay(1000);
    return $"{DateTime.Now:ss.fff}>Hello";
}

async Task<string> MethodBAsync()
{
    await Task.Delay(2000);
    return $"{DateTime.Now:ss.fff}>World";
}

ここで、これら2つのメソッドを同時に呼び出し、その結果を1つの文字列に結合したいとします。以下のようにコードを記述できます。

async Task<string> CombineResultsAAsync()
{
    var resultA = await MethodAAsync();
    var resultB = await MethodBAsync();
    return $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}: {resultA} | {resultB}";
}

このコードは非常にシンプルで明瞭に見えますが、パフォーマンス上の問題があります。CombineResultsAAsync()メソッドを呼び出すと、最初のawait操作によって実行コンテキストが元のSynchronizationContext(つまりメインスレッド)に切り替わるため、非同期操作はUIスレッド上で実行されます。MethodAAsync()から結果が返るまで1秒待機するため、UIスレッドは非同期操作が完了し結果が利用可能になるまでブロックされます。

このような場合、ConfigureAwait(false)メソッドを使用して現在のコンテキストのスレッド実行状態を保持する必要がないことを指定し、非同期操作をスレッドプールのスレッドで実行させることができます。これは以下のコードで実現できます。

async Task<string> CombineResultsBAsync()
{
    var resultA = await MethodAAsync().ConfigureAwait(false);
    var resultB = await MethodBAsync().ConfigureAwait(false);
    return $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}: {resultA} | {resultB}";
}

ConfigureAwait(false)メソッドを使用することで、非同期操作に現在のコンテキストのスレッド実行状態を保持する必要がないことを伝え、非同期操作がUIスレッドではなくスレッドプールのスレッドで実行されるようになります。これにより、UIスレッドがブロックされず、非同期操作が新しいスレッドプールのスレッドで実行されるため、潜在的なパフォーマンス問題を回避できます。

四、まとめ

async/awaitキーワードを使用する際には、コードの可読性、保守性、パフォーマンスを向上させるために、いくつかのベストプラクティスに従うべきです。以下は一般的なベストプラクティスです。

  1. 非同期メソッドは可能な限りTaskまたはTask<TResult>型として宣言し、awaitキーワードで完了を待機できるようにします。非同期メソッドが何も返さない場合は、Task型として宣言します。

  2. 非同期メソッド内部ではスレッドをブロックする操作を可能な限り避け、代わりに非ブロッキング操作を使用して遅延をシミュレートします。ブロック操作を実行する必要がある場合は、別のスレッドで実行するか、非同期I/O操作を使用してスレッドのブロックを回避します。

  3. 非同期メソッド内部で例外をキャッチして即座に処理せず、呼び出し元で例外を処理させるようにします。非同期メソッド内部で例外をキャッチする必要がある場合も、AggregateException例外にラップして呼び出し元に渡すようにします。

  4. ConfigureAwait(false)メソッドを使用する際は注意が必要で、元のSynchronizationContextに戻る必要がないと確信できる場合にのみ使用します。そうしないと、呼び出し元が結果を正しく処理できなくなる可能性があります。

  5. 非同期メソッド内でのスレッドセーフでないスレッドAPI(Thread.SleepThread.Joinなど)の使用は可能な限り避け、コードの移植性と安定性を確保します。遅延をシミュレートするには非ブロッキングの非同期メソッドを使用します。

  6. async/awaitキーワードを使用する際は、非同期メソッドの名前をAsyncで終わらせるなど、同期メソッドと区別しやすい命名規則に従います。

  7. 複数の非同期タスクの完了を同時に待機する必要がある場合は、Task.WhenAllメソッドを使用してすべてのタスクの完了を待機します。いずれか1つのタスクの完了を待機するだけでよい場合は、Task.WhenAnyメソッドを使用します。

  8. 非同期メソッド内部では、時間のかかる操作を別の非同期メソッドにカプセル化し、必要な場所でasync/awaitキーワードを使用して呼び出すことで、コードの可読性と保守性を向上させます。

  9. async/awaitキーワードを使用する際には、lockキーワードやMonitorクラスなどのスレッド同期機構の使用を可能な限り避け、非同期ロックなど他の非ブロッキングなスレッド同期機構を使用します。

以上、Taskとasync/awaitを使用することで非同期プログラミングが大幅に簡素化され、コードの可読性、保守性、パフォーマンスが向上します。ただし、コードの正確性と安定性を確保するために、いくつかの詳細とベストプラクティスに注意する必要があります。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む