C#による非同期プログラミングの実践

.NETにおける非同期処理の概要

非同期コードを導入する主なシナリオは以下の2つです:

  • I/Oバウンド処理:ネットワークやディスクからのリソース取得を伴う操作
  • CPUバウンド処理:メモリ内で実行される計算集約型の処理

本セクションでは、それぞれの処理タイプに対してasyncawaitを使用した実際の例を紹介します。外部プロセスの完了待ちやアプリケーション内の計算集約型処理において、非同期コードを利用することでパフォーマンスを向上させることができます。

I/Oバウンド処理

ファイルやネットワーク操作に制限されるI/Oバウンドコードでは、操作完了時にasyncawaitを使用して待機する必要があります。

.NETでネットワークやファイルI/Oを実行するメソッドは非同期対応済みであるため、Task.Runを使用する必要はありません。

例1:テキストファイル読み込み

public async Task<List<string>> ReadFileLinesAsync(string filePath)
{
    using var fileReader = File.OpenText(filePath);
    var content = await fileReader.ReadToEndAsync();
    
    return content
        .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
        .ToList();
}

例2:Webからのデータ取得

public async Task<List<string>> FetchWebContentAsync(string url)
{
    var client = new HttpClient();
    var content = await client.GetStringAsync(url);
    
    return content
        .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
        .ToList();
}

CPUバウンド処理

このケースでは、アプリケーションは外部プロセスの完了を待たず、自身で時間のかかる計算処理を実行し、処理中にアプリケーションが応答性を保つ必要があります。

データモデル定義

[Serializable]
public class JournalRecord
{
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime EntryDate { get; set; }
    public string EntryText { get; set; }
}

XMLデシリアライズ処理

private List<JournalRecord> ParseXmlRecords(List<string> xmlData)
{
    var records = new List<JournalRecord>();
    var serializer = new XmlSerializer(typeof(JournalRecord));
    
    foreach (var xml in xmlData)
    {
        if (xml == null) continue;
        using var reader = new StringReader(xml);
        var record = (JournalRecord)serializer.Deserialize(reader)!;
        if (record == null) continue;
        records.Add(record);
    }
    return records;
}

非同期ラッパーメソッド

public async Task<List<JournalRecord>> ParseRecordsAsync(List<string> xmlData)
{
    return await Task.Run(() => ParseXmlRecords(xmlData));
}

JSONデシリアライズの比較

public List<JournalRecord> ParseJsonRecords(List<string> jsonData)
{
    var records = new List<JournalRecord>();
    foreach (var json in jsonData)
    {
        if (string.IsNullOrWhiteSpace(json)) continue;
        records.Add(JsonSerializer.Deserialize<JournalRecord>(json)!);
    }
    return records;
}

public async Task<List<JournalRecord>> ParseJsonRecordsAsync(List<string> jsonData)
{
    var records = new List<JournalRecord>();
    foreach (var json in jsonData)
    {
        if (string.IsNullOrWhiteSpace(json)) continue;
        
        using var stream = new MemoryStream(Encoding.Unicode.GetBytes(json));
        records.Add((await JsonSerializer.DeserializeAsync<JournalRecord>(stream))!);
    }
    return records;
}

並列処理による最適化

public async Task<List<JournalRecord>> ProcessMultipleRecordsAsync(List<string> jsonData)
{
    var tasks = jsonData.Select(ParseSingleRecordAsync);
    return (await Task.WhenAll(tasks)).ToList();
}

private async Task<JournalRecord> ParseSingleRecordAsync(string json)
{
    if (string.IsNullOrWhiteSpace(json)) 
        return new JournalRecord();
    using var stream = new MemoryStream(Encoding.Unicode.GetBytes(json));
    return (await JsonSerializer.DeserializeAsync<JournalRecord>(stream))!;
}

ネストされた非同期処理

非同期メソッドを使用する際は、実行順序を維持するためにawaitの使用が重要です。また、現在のスレッドのエントリポイントへのawait呼び出しチェーンの維持も重要です。

コンソールアプリケーションでの実装例

public async Task ExecuteOperationsAsync()
{
    Console.WriteLine($"処理開始 in {nameof(ExecuteOperationsAsync)}");
    await FirstOperationAsync();
    await SecondOperationAsync();
    Console.WriteLine($"処理完了 in {nameof(ExecuteOperationsAsync)}");
}

private async Task FirstOperationAsync()
{
    Console.WriteLine($"処理実行中 in {nameof(FirstOperationAsync)}");
    await NestedOperationAsync();
    Console.WriteLine($"処理終了 in {nameof(FirstOperationAsync)}");
}

private async Task SecondOperationAsync()
{
    Console.WriteLine($"処理実行中 in {nameof(SecondOperationAsync)}");
    await Task.Delay(500);
    Console.WriteLine($"処理終了 in {nameof(SecondOperationAsync)}");
}

private async Task NestedOperationAsync()
{
    Console.WriteLine($"処理実行中 in {nameof(NestedOperationAsync)}");
    await Task.Delay(1500);
    Console.WriteLine($"処理終了 in {nameof(NestedOperationAsync)}");
}

誤った実装例

public async Task IncorrectExecutionAsync()
{
    Console.WriteLine($"処理開始 in {nameof(IncorrectExecutionAsync)}");
    FirstOperationAsync(); // awaitがない
    
    await SecondOperationAsync();
    Console.WriteLine($"処理完了 in {nameof(IncorrectExecutionAsync)}");
}

public async Task BlockingExecutionAsync()
{
    Console.WriteLine($"処理開始 in {nameof(BlockingExecutionAsync)}");
    FirstOperationAsync().Wait(); // ブロッキング呼び出し
    
    await SecondOperationAsync();
    Console.WriteLine($"処理完了 in {nameof(BlockingExecutionAsync)}");
}

Taskオブジェクトの活用

既存プロジェクトにスレッドを導入する際、Taskオブジェクトを直接使用することは非常に有効です。asyncawaitを導入する際には、呼び出しスタック全体を更新することが重要ですが、大規模なコードベースでは変更範囲が広くなり、多くの回帰テストが必要になります。

インターフェース定義例

public interface IAsyncProcessor
{
    void ProcessOrders(List<Order> orders);
    Task ProcessOrdersAsync(List<Order> orders);
    
    List<Order> GetOrders(int customerId);
    Task<List<Order>> GetOrdersAsync(int customerId);
}

Taskメソッドの使用例

public void HandleOrders(List<Order> orders, int customerId)
{
    Task<List<Order>> processTask = Task.Run(() => PrepareOrders(orders));
    
    Task labelTask = Task.Factory.StartNew(() => CreateLabels(orders), TaskCreationOptions.LongRunning);
    
    Task sendTask = processTask.ContinueWith(task => SendOrders(task.Result));
    
    Task.WaitAll(new[] { labelTask, sendTask }); 
    
    SendConfirmation(customerId);
}

同期・非同期実行の切り替え

public void ProcessDataSet(object data, bool requiresUiThread)
{
    Task processTask = new(() => DoDataProcessing(data));
    if (requiresUiThread)
    {
        // 現在のスレッドで同期実行(UIスレッド想定)
        processTask.RunSynchronously();
    }
    else
    {
        // バックグラウンドでThreadPoolスレッド上で実行
        processTask.Start();
    }
}

Taskプロパティの確認

StatusプロパティはTaskStatus列挙型を返し、以下の8つの値を取ります:

  • Created (0):タスク作成済みだがThreadPoolにスケジュールされていない
  • WaitingForActivation (1):.NETによるスケジュール待ち
  • WaitingToRun (2):スケジュール済みだが実行開始前
  • Running (3):現在実行中
  • WaitingForChildrenToComplete (4):タスク完了済みだが子タスクが実行中
  • RanToCompletion (5):正常完了
  • Canceled (6):キャンセル確認済み
  • Faulted (7):未処理例外発生

状態確認用ショートカットプロパティ

  • IsCanceled:ステータスがCanceledの場合true
  • IsCompleted:ステータスがRanToCompletionCanceledFaultedの場合true
  • IsCompletedSuccessfully:ステータスがRanToCompletionの場合true
  • IsFaulted:ステータスがFaultedの場合true

例外処理例

Task ordersTask = Task.Run(() => ProcessOrders(orders, 123));
try
{
    ordersTask.Wait();
    Console.WriteLine($"タスクステータス: {ordersTask.Status}");
}
catch (AggregateException)
{
    Console.WriteLine($"タスク例外! エラーメッセージ: {ordersTask.Exception.Message}");
}

同期コードとの相互運用

同期から非同期への呼び出し

public class DataProcessor
{
    private ApiService _apiService;
    
    public DataProcessor()
    {
        _apiService = new ApiService();
    }
    
    public RecordData? GetProcessedData(int recordId)
    {
        RecordData? data = null;
        try
        {
            data = _apiService.FetchRecordAsync(recordId).Result;
        }
        catch (AggregateException ae)
        {
            Console.WriteLine($"データ読み込みエラー: {ae.Flatten().Message}");
        }
        if (data != null)
        {
            data = ProcessAdditionalInfo(data);
            return data;
        }
        else
        {
            return null;
        }
    }
    
    private RecordData ProcessAdditionalInfo(RecordData data)
    {
        // 追加処理
        return data;
    }
}

非同期から同期への呼び出し

public class AsyncDataProcessor
{
    private DataService _dataService = new DataService();
    
    private async Task<RecordData> EnhanceRecordDataAsync(RecordData data)
    {
        await Task.Delay(100);
        // 追加処理
        return data;
    }
    
    public async Task<RecordData?> GetEnhancedDataAsync(int recordId)
    {
        RecordData? data = null;
        try
        {
            data = await Task.Run(() => _dataService.FetchRecord(recordId));
        }
        catch (Exception e)
        {
            Console.WriteLine($"データ読み込みエラー: {e.Message}");
        }
        if (data != null)
        {
            data = await EnhanceRecordDataAsync(data);
            return data;
        }
        else
        {
            return null;
        }
    }
}

複数バックグラウンドタスクの処理

public async Task<Patient> LoadPatientConcurrentAsync(int patientId)
{
    var tasks = new List<Task>
    {
        LoadPatientDetailsAsync(patientId),
        LoadDoctorInfoAsync(patientId),
        LoadMedicationsAsync(patientId)
    };
    
    await Task.WhenAll(tasks.ToArray());
    
    _patient.Medications = _medications;
    _patient.PrimaryCareProvider = _doctor;
    
    return _patient;
}

public Patient LoadPatientSequential(int patientId)
{
    var tasks = new List<Task>
    {
        LoadPatientDetailsAsync(patientId),
        LoadDoctorInfoAsync(patientId),
        LoadMedicationsAsync(patientId)
    };
    
    Task.WaitAll(tasks.ToArray());
    
    _patient.Medications = _medications;
    _patient.PrimaryCareProvider = _doctor;
    return _patient;
}

非同期プログラミングのベストプラクティス

  1. 常にasyncawaitを優先し、同期メソッドやWait()Resultなどのブロッキング呼び出しを避ける
  2. Task.WhenAllで複数操作を同時に待機する場合を除き、メソッドを直接awaitする
  3. async voidを使用しない。戻り値はTask、Task<TResult>、ValueTask、またはValueTask<TResult>とする
  4. ブロッキングコードと非同期コードを混在させない。呼び出しスタック全体で非同期呼び出しを使用する
  5. 追加引数を渡す必要がない限り、Task.Factory.StartNewではなくTask.Runを使用する
  6. 長時間実行される非同期メソッドはキャンセルをサポートする
  7. 共有データの同期使用を行う。ロックを追加してスレッド間で使用されるオブジェクトのデータが上書きされないようにする
  8. ネットワークやファイルアクセスなどのI/Oバウンド処理には常にasyncawaitを使用する
  9. 非同期メソッドを作成する際は、名前にAsyncサフィックスを追加する
  10. 非同期メソッド内でThread.Sleepを使用しない。固定時間待機が必要な場合はawait Task.Delayを使用する

タグ: C# async-await task-parallel-library .net-core concurrency

6月1日 11:42 投稿