🍣

【C#】非同期処理の基本

に公開

C#の非同期処理は「完全に理解した」→「なんも分からん」を繰り返しがちです。
この記事では、基本的な話をまとめることにします。

C#の非同期処理におけるTaskって何??awaitするとどうなるの??

  1. Task は「誰かに頼んだ仕事」というのイメージすると理解できる。

    • 「このファイルを読み込んでおいて」「この計算をしておいて」と誰かに仕事を頼んだ状態。
    • 頼まれた人は裏で作業を進めている。
  2. 時間のかかる処理を「自分でやる」のではなく、「誰かに任せてその仕事の進捗を管理する」。

    • Task = 「今やってもらっている仕事」
    • await = 「その仕事が終わるまで待って、結果を受け取る」
  3. await を使うと、待ち時間の間スレッドが解放されるので、解放されたスレッドで他の仕事ができる。

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("メインスレッド: コーヒーを淹れるよう頼みます");
        
        // Task = 「誰かに仕事を頼んだ」状態
        Task<string> coffeeTask = MakeCoffeeAsync();
        
        Console.WriteLine("メインスレッド: コーヒーができるのを待っている間、他のことができる");
        Console.WriteLine("メインスレッド: 新聞を読んでいます...");
        
        // await = 「仕事が終わるまで待って、結果を受け取る」
        string coffee = await coffeeTask;
        
        Console.WriteLine($"メインスレッド: {coffee} を受け取りました!");
    }
    
    // 時間のかかる処理(非同期メソッド)
    static async Task<string> MakeCoffeeAsync()
    {
        Console.WriteLine("  作業者: コーヒーを作り始めました");
        
        // 3秒かかる作業をシミュレート
        await Task.Delay(3000);
        
        Console.WriteLine("  作業者: コーヒーができました");
        return "ホットコーヒー";
    }
}
メインスレッド: コーヒーを淹れるよう頼みます
  作業者: コーヒーを作り始めました
メインスレッド: コーヒーができるのを待っている間、他のことができる
メインスレッド: 新聞を読んでいます...
(3秒待機)
  作業者: コーヒーができました
メインスレッド: ホットコーヒー を受け取りました!

同期処理 vs 非同期処理(逐次) vs 非同期処理(並行)

  1. 同期処理
    同期処理では、スレッドがブロックされてしまう。

    static void SynchronousExample()
    {
        Console.WriteLine("開始");
        
        // スレッドが3秒間ブロックされる
        Thread.Sleep(3000);
        string coffee = "コーヒー";
        
        // スレッドが2秒間ブロックされる
        Thread.Sleep(2000);
        string toast = "トースト";
        
        // スレッドが2秒間ブロックされる
        Thread.Sleep(2000);
        string egg = "卵";
        
        Console.WriteLine($"{coffee}, {toast}, {egg} ができました");
        // 合計: 7秒、スレッドはずっと占有される
    }
    
  2. 非同期処理(逐次)
    非同期処理(逐次)では、スレッドは解放されるので待機中に他の処理を実行できるが、処理は順番に実行されるので同期処理と同じ実行時間が必要。

    static async Task AsynchronousSequentialExample()
     {
         Console.WriteLine("開始");
         
         // await中はスレッドが解放される
         string coffee = await MakeCoffeeAsync(); // 3秒
         
         // await中はスレッドが解放される
         string toast = await MakeToastAsync(); // 2秒
         
         // await中はスレッドが解放される
         string egg = await CookEggAsync(); // 2秒
         
         Console.WriteLine($"{coffee}, {toast}, {egg} ができました");
         // 合計: 7秒、でもawait中はスレッドが解放される
     }
    
  3. 非同期処理(並行)
    非同期処理(並行)では、Task.WhenAllを使う。スレッドはもちろん解放され、並行処理なので実行時間も短縮される。

    static async Task AsynchronousParallelExample()
    {
        Console.WriteLine("開始");
        
        // 3つの処理を同時にスタート
        Task<string> coffeeTask = MakeCoffeeAsync();
        Task<string> toastTask = MakeToastAsync();
        Task<string> eggTask = CookEggAsync();
        
        // 全部終わるまで待つ(await中はスレッドが解放される)
        string[] results = await Task.WhenAll(coffeeTask, toastTask, eggTask);
        
        Console.WriteLine($"{results[0]}, {results[1]}, {results[2]} ができました");
        // 合計: 3秒(最も遅い処理の時間)、await中はスレッドが解放される
    }
    

デッドロックが起こるときはどんなときか?

非同期処理ではawaitを実行する際にスレッドを解放し、実行完了後に再び元のスレッドに戻るという挙動をするので、デッドロックが起こる可能性をはらんでいる。
要するに、「同じスレッドで2つの処理を実行しようとしたが、そのスレッドが既にブロックされていて実行できない」ということが起こりうる。

  • デッドロックする例
    メインスレッドが.Resultによりブロックされるが、await実行後にメインスレッドに戻ってくることを期待する(ことがある)ので、デッドロックが発生する。

    public class DataService
    {
        // ライブラリの同期的なメソッド
        public string GetData()
        {
            // 内部で非同期メソッドを同期的に待つ
            return GetDataInternalAsync().Result; // ← これは危険
        }
        
        private async Task<string> GetDataInternalAsync()
        {
            await Task.Delay(1000);
            return "データ";
        }
    }
    
    // 使う側
    static void Main()
    {
        var service = new DataService();
        
        // 呼び出し元が同期コンテキストを持っていたらデッドロック
        string result = service.GetData();
    }
    
  • 安全な書き方

    1. 最後まで非同期
    static async Task Main() // C# 7.1以降
    {
        Console.WriteLine("処理開始");
        string result = await GetDataAsync(); // ← 安全
        Console.WriteLine(result);
    }
    
    1. ConfigureAwait(false)
    static void Main()
    {
        Console.WriteLine("処理開始");
        string result = GetDataAsync().Result; // ← これでも動く
        Console.WriteLine(result);
    }
    
    static async Task<string> GetDataAsync()
    {
        // 元のコンテキストに戻らない
        await Task.Delay(1000).ConfigureAwait(false);
        return "データ取得完了";
    }
    

Task.Runって非同期処理とどう違うの?

Task.Runは非同期処理と混同されやすいので別途説明しておく。
結論から書くと、Task.Runは非同期処理というよりは、同期処理をスレッドを分けて並行処理したいときに使う。
つまり、Task.Run = 「同期処理を別スレッドで実行するためのラッパー」ともいえる。

  • 正しい使用例

    // これは同期処理(重い計算)
    static int HeavyCalculation()
    {
        Thread.Sleep(3000);
        return 42;
    }
    
    // Task.Runで別スレッドに逃がす
    static async Task Main()
    {
        // 別スレッドでHeavyCalculationを実行
        int result = await Task.Run(() => HeavyCalculation());
        Console.WriteLine(result);
    }
    
  • NG例(非同期処理をTask.Runでラップ)

    • File.ReadAllTextAsyncは既に非同期(つまり、スレッドを消費しない)にもかかわらず、Task.Runでラップすると、無駄にスレッドを1つ使ってしまう。

    • I/O処理(ファイル、DB、HTTP)は 元々非同期なのでTask.Runは不要

      // これは無駄!二重にスレッドを使っている
      static async Task<string> BadExample()
      {
          return await Task.Run(async () =>
          {
              // 既に非同期なのにTask.Runでラップしている
              return await File.ReadAllTextAsync("file.txt");
          });
      }
      
      // 正しくはこう!
      static async Task<string> GoodExample()
      {
          // 既に非同期なので直接awaitする
          return await File.ReadAllTextAsync("file.txt");
      }
      

スレッドプール

  • C#では、あらかじめスレッドプールとしてOSスレッドを確保しておき、リクエストが来るとThreadPool から1本スレッドを取り出してリクエスト処理を実行する仕組みになっている。
  • ちなみに、Golangは全く別の思想で設計されている。

Discussion