🔄

[C# 12, .NET 8] C# の非同期処理における効果的なキャンセルトークンの使用方法について

2024/08/10に公開

はじめに

C# 12(.NET 8)を使ってライブラリを個人開発する過程で、非同期プログラミング[1]におけるキャンセル機能の実装が必要となりました。具体的には、以下の2点について調査して理解する必要がありました:

  1. キャンセルトークン[2]の使用方法
  2. 効果的なキャンセル要求の実装方法

この記事では、これらの点について調査した内容をまとめています。

async/awaitキーワード

C#では、asyncキーワードとawaitキーワード[3]を使用して非同期プログラミングを実現します。

public async Task<string> GetDataAsync()
{
    var result = await FetchDataFromServerAsync();
    return ProcessData(result);
}

この例では、awaitキーワードを使用してサーバーからのデータ取得を待機しています。Task[4]を実行しデータ取得中、プログラムは他の処理を実行できるため、効率的です。

キャンセルトークンの基本

キャンセルトークン(CancellationToken)は、非同期操作をキャンセルするための仕組みを提供します。長時間実行される処理や、ユーザーの介入が必要な操作を適切に制御するのに役立ちます。

基本的な使用方法

基本的な使い方は以下のようになります:

public async Task DoLongRunningWorkAsync(CancellationToken cancellationToken)
{
    for (int i = 0; i < 1000; i++)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            Console.WriteLine("Operation was canceled.");
            return;
        }
        await Task.Delay(100); // 何らかの作業をシミュレート
    }
}

キャンセル要求のチェック

この例では、ループの各イテレーションでIsCancellationRequestedプロパティをチェックしています。キャンセルが要求された場合、処理を中断して関数を終了します。

キャンセルトークンを効果的に使用するための実装

1. メソッドパラメータとしてのキャンセルトークン

非同期メソッドを設計する際は、可能な限りCancellationTokenをパラメータとして受け取るようにすることが推奨されています。

public async Task DoWorkAsync(CancellationToken cancellationToken = default)
{
    // メソッドの実装
    await Task.Delay(1000, cancellationToken); // 1秒待機
    // 何らかの処理
}

メリット

このアプローチには以下のような利点があります:

  1. メソッドの呼び出し元がキャンセル動作を制御できる
  2. デフォルト値を設定することで、キャンセルが不要な場合も同じメソッドを使用できる
  3. メソッドのシグネチャーを見るだけで、キャンセル可能であることが分かる

2. 定期的なキャンセル要求のチェック

長時間実行される処理では、定期的にキャンセルが要求されていないかチェックすることが重要です。

public async Task LongRunningTaskAsync(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        await DoSomeWorkAsync();
        cancellationToken.ThrowIfCancellationRequested();
    }
}

チェック方法

この例では、以下の2つの方法でキャンセルをチェックしています:

  1. whileループの条件でIsCancellationRequestedをチェック
  2. ループ内でThrowIfCancellationRequested()メソッドを呼び出し

ThrowIfCancellationRequested()メソッドは、キャンセルが要求されている場合にOperationCanceledException[5]をスローします。これにより、キャンセルが要求された場合に適切に例外処理を行うことができます。

3. 複数のキャンセルトークンの組み合わせ

複数のキャンセル条件を組み合わせる必要がある場合、CancellationTokenSource.CreateLinkedTokenSource[6]が便利です。

public async Task CombinedCancellationExample()
{
    using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    using var cts2 = new CancellationTokenSource();
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts1.Token, cts2.Token);

    try
    {
        await DoWorkAsync(linkedCts.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Operation was canceled.");
    }
}

組み合わせの利点

この例では:

  1. cts1は10秒後に自動的にキャンセルされるトークンソース
  2. cts2は手動でキャンセルできるトークンソース
  3. linkedCtscts1cts2のいずれかがキャンセルされた場合にキャンセルされるトークンソース

これにより、タイムアウトや手動キャンセルなど、複数の条件でキャンセルを制御できます。

4. ConfigureAwait(false)の使用

UI以外のコンテキストで実行される非同期メソッドでは、ConfigureAwait(false)[7]を使用することでパフォーマンスを向上させられる可能性があります。

public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    var result = await SomeAsyncMethod().ConfigureAwait(false);
    // 結果を処理
}

使用上の注意点

ConfigureAwait(false)は、非同期操作の完了後に元の実行コンテキストに戻る必要がない場合に使用します。これにより、コンテキスト切り替えのオーバーヘッドを減らすことができます。ただし、UI操作を行う場合など、元のコンテキストが必要な場合は使用しないようにする必要があります。

5. キャンセル後のリソースクリーンアップ

キャンセルされた場合でも、適切にリソースをクリーンアップすることが重要です。

public async Task DoWorkWithResourceAsync(CancellationToken cancellationToken)
{
    var resource = AcquireResource();
    try
    {
        await UseResourceAsync(resource, cancellationToken);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Operation was canceled.");
    }
    finally
    {
        await CleanupResourceAsync(resource);
    }
}

クリーンアップの重要性

この例では:

  1. tryブロックで非同期操作を実行
  2. catchブロックでキャンセル時の処理を記述
  3. finallyブロックで、操作が成功してもキャンセルされても必ずリソースをクリーンアップ

この方法により、キャンセルされた場合でもリソースリークを防ぐことができます。

キャンセルトークンの処理フロー

ここまでのキャンセルトークンを使用した非同期メソッドの基本的な処理フローを以下の図で示します。この図は、メソッドの開始からキャンセル処理または正常終了までの流れを視覚化しています。

この図の通り、キャンセルトークンを使用する際は以下の点に注意する必要があります。

  1. メソッド開始時にCancellationTokenを受け取る
  2. 処理中は定期的にキャンセル要求をチェックする
  3. キャンセル要求があった場合は、適切にリソースをクリーンアップする
  4. キャンセル時はOperationCanceledExceptionをスローして呼び出し元に通知する

キャンセル要求のチェック方法とタイミング

キャンセル要求のチェック方法とタイミングは、アプリケーションのパフォーマンスと応答性に大きく影響するため、慎重に検討する必要があります。

1. 頻繁なチェック

軽量な操作を繰り返し行う場合、各イテレーションでキャンセルをチェックすることが効果的です。

public async Task FrequentCheckExample(CancellationToken cancellationToken)
{
    for (int i = 0; i < 1000; i++)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await DoLightworkAsync();
    }
}

適切な使用シーン

この方法は、以下の場合に適しています:

  • 各イテレーションが短時間で完了する場合
  • キャンセルに素早く応答する必要がある場合
  • リソース使用量が少ない処理の場合

2. 定期的なチェック

重い処理や長時間実行される処理の場合、一定間隔でキャンセルをチェックすることが推奨されています。

public async Task PeriodicCheckExample(CancellationToken cancellationToken)
{
    var periodicTimer = new PeriodicTimer(TimeSpan.FromSeconds(1));
    while (await periodicTimer.WaitForNextTickAsync(cancellationToken))
    {
        await DoHeavyWorkAsync();
    }
}

使用シーンと利点

この例では、PeriodicTimerを使用して1秒ごとにキャンセルをチェックしています。この方法は以下の場合に適しています:

  • 各処理が長時間かかる場合
  • リソース使用量が多い処理の場合
  • 即時のキャンセル応答が不要な場合

3. 協調的なキャンセル

外部リソースを使用する場合、キャンセルトークンを外部リソースの操作に渡すことが効果的です。

public async Task CooperativeCancellationExample(
    CancellationToken cancellationToken)
{
    // サンプルのため new 演算子でインスタンス化しているが、
    // 本来は IHttpClientFactory から取得して
    // 接続リソースの管理をするのが適切
    using var httpClient = new HttpClient();

    try
    {
        var response = await httpClient.GetAsync(
            "https://api.example.com",
            cancellationToken);

        var content = await response.Content.ReadAsStringAsync(
            cancellationToken);

        // コンテンツを処理
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("HTTP request was cancelled");
    }
}

メリットと使用方法

この例では、HttpClientのメソッドに直接CancellationTokenを渡しています。

  1. HTTPリクエストのキャンセルを.NET側で適切に処理できる
  2. ネットワーク操作中もキャンセルに応答できる
  3. リソースの無駄遣いを防げる

現実世界のシナリオ

ここまでの内容を元に、ファイルダウンロードサービスを例に、キャンセルトークンの実践的な使用方法の例を示します。この例では、複数のファイルをダウンロードし、進捗状況を報告しながら、ユーザーがいつでも操作をキャンセルできるようにします。

良い実装例

public class FileDownloadService
{
    private readonly HttpClient _httpClient;

    public FileDownloadService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    // 良い実装例
    public async Task<byte[]> DownloadFileGoodAsync(string url, IProgress<int> progress, CancellationToken cancellationToken)
    {
        using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
        response.EnsureSuccessStatusCode();

        long contentLength = response.Content.Headers.ContentLength ?? -1L;
        byte[] buffer = new[8192];
        long bytesRead = 0L;
        long totalRead = 0L;
        MemoryStream content = new();

        using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);

        while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken)) > 0)
        {
            await content.WriteAsync(buffer.AsMemory(0, (int)bytesRead), cancellationToken);
            totalRead += bytesRead;
            progress?.Report((int)((totalRead * 100) / contentLength));
        }

        return content.ToArray();
    }
}

この実装の特徴:

  1. キャンセルトークンを受け取り、HttpClientのGetAsyncメソッドに渡しています。
  2. ストリームを使用して少しずつデータを読み込んでMemoryStream[8]に書き込み、その都度キャンセルをチェックしています。
  3. Progress[9]で進捗状況を報告しています。
  4. キャンセルトークンを使用することで、長時間のダウンロード中でもユーザーが操作をキャンセルできます。

悪い実装例

public class FileDownloadService
{
    private readonly HttpClient _httpClient;

    public FileDownloadService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<byte[]> DownloadFileBadAsync(string url, IProgress<int> progress)
    {
        var response = await _httpClient.GetAsync(url);
        response.EnsureSuccessStatusCode();

        var content = await response.Content.ReadAsByteArrayAsync();
        progress?.Report(100);

        return content;
    }
}

この実装の問題点:

  1. キャンセルトークンを使用していないため、一度ダウンロードが始まるとキャンセルできません。
  2. 全てのコンテンツを一度にメモリに読み込むため、大きなファイルの場合メモリ不足になる可能性があります。
  3. 実際の進捗状況を報告していません。

使用例

using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

// サンプルのため new 演算子でインスタンス化しているが、
// 本来は IHttpClientFactory から取得して
// 接続リソースの管理をするのが適切
using HttpClient httpClient = new();
FileDownloadService downloadService = new(httpClient);

var urls = 
[
    "https://example.com/file1.zip",
    "https://example.com/file2.zip",
    "https://example.com/file3.zip"
];

using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromSeconds(30)); // 30秒後にキャンセル

try
{
    await DownloadFilesAsync(downloadService, urls, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Download operation was canceled.");
}

async Task DownloadFilesAsync(
    FileDownloadService downloadService,
    string[] urls,
    CancellationToken cancellationToken)
{
    Progress<int> progress = new(percent => 
        Console.WriteLine($"Download progress: {percent}%"));

    foreach (string url in urls)
    {
        try
        {
            var fileContent = await downloadService.DownloadFileGoodAsync(
                url, progress, cancellationToken);

            await File.WriteAllBytesAsync(
                $"downloaded_{Guid.NewGuid()}.file",
                fileContent,
                cancellationToken);

            Console.WriteLine($"File from {url} downloaded successfully.");
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine($"Download of {url} was canceled.");
            break;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error downloading {url}: {ex.Message}");
        }
    }
}

良い実装では、ユーザーエクスペリエンスが向上し、リソースの効率的な使用が可能になります。
一方、悪い実装では、アプリケーションの応答性が低下し、リソースの無駄遣いにつながる可能性があります。

まとめ

キャンセルトークンの効果的な使用は、アプリケーションの品質と応答性を大きく向上させることができます。適切なタイミングでキャンセルをチェックし、リソースを適切に管理することで、より堅牢で効率的な非同期プログラミングが実現できます。

主要ポイント

キャンセルトークンの適切な使用について再度まとめます。

  1. メソッドの性質に合わせてキャンセルチェックの頻度を調整する
  2. 外部リソースを使用する場合は、可能な限りキャンセルトークンを渡す
  3. キャンセル後のクリーンアップを忘れずに行う
  4. 複雑なキャンセルロジックが必要な場合は、CancellationTokenSourceの組み合わせを検討する

参考資料

以下の資料を参考にしました。


脚注
  1. 非同期プログラミング:プログラムの実行を一時的に中断し、他の処理を行いながら結果を待つプログラミング手法。アプリケーションの応答性を向上させ、リソースを効率的に利用できる。 ↩︎

  2. キャンセルトークン(CancellationToken):非同期操作をキャンセルするための仕組みを提供するオブジェクト。長時間実行される処理のキャンセルに使用される。 ↩︎

  3. async/await:C#で非同期プログラミングを簡潔に記述するためのキーワード。asyncはメソッドが非同期であることを示し、awaitは非同期操作の完了を待機する。 ↩︎

  4. Task:非同期操作を表すオブジェクト。操作の状態や結果を管理する。 ↩︎

  5. OperationCanceledException:操作がキャンセルされたことを示す例外。CancellationTokenによってスローされる。 ↩︎

  6. CancellationTokenSourceCancellationTokenを生成し、キャンセル操作を制御するためのクラス。 ↩︎

  7. ConfigureAwait:非同期操作の完了後の継続をどのコンテキストで実行するかを制御するメソッド。falseを指定すると、元のコンテキストに戻らない。 ↩︎

  8. MemoryStream:メモリ上にバイトの配列として保存されるストリーム。一時的なデータ保存に使用される。 ↩︎

  9. IProgress<T>:進行状況を報告するためのインターフェース。長時間実行される操作の進捗を通知するのに使用される。 ↩︎

Discussion