Open9

【C#】非同期処理の基本をおさらい(Thread/Task/async/await)

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

スレッドを直接生成、実行するケース

  • スレッド...スレッドは、メモリ空間を共有しながら、処理を分離する仕組み
実験コード(ただしThreadを直接利用するのはよくない)
public class Program
{
    static void Main()
    {
        var t1 = new Thread(Count);
        var t2 = new Thread(Count);
        var t3 = new Thread(Count);
        
        t1.Start("1");
        t2.Start("2");
        t3.Start("3");
        
        t1.Join();
        t2.Join();
        t3.Join();
        
        Console.WriteLine("Done!");
    }

    static void Count(object? str)
    {
        for (int i = 0; i < 50; i++)
        {
            Console.WriteLine($"Thread {str}: {i}");
        }
    }
}

出力(Threadが入れ混じりながら出力されていることが分かる)

Thread 2: 0
Thread 2: 1
Thread 2: 2
Thread 2: 3
Thread 2: 4
Thread 2: 5
Thread 2: 6
Thread 2: 7
Thread 2: 8
Thread 2: 9
Thread 2: 10
Thread 2: 11
Thread 2: 12
Thread 2: 13
Thread 2: 14
Thread 2: 15
Thread 2: 16
Thread 2: 17
Thread 2: 18
Thread 3: 0
Thread 2: 19
Thread 3: 1
Thread 3: 2
Thread 3: 3
Thread 3: 4
Thread 3: 5
Thread 3: 6
Thread 3: 7
Thread 2: 20
Thread 2: 21
Thread 2: 22
Thread 1: 0
Thread 1: 1
Thread 1: 2
Thread 1: 3
Thread 3: 8
Thread 3: 9
Thread 3: 10
Thread 3: 11
Thread 1: 4
Thread 1: 5
Thread 1: 6
Thread 1: 7
Thread 1: 8
Thread 1: 9
Thread 1: 10
Thread 3: 12
Thread 3: 13
Thread 3: 14
Thread 3: 15
Thread 3: 16
Thread 3: 17
Thread 3: 18
Thread 3: 19
Thread 1: 11
Thread 1: 12
Thread 3: 20
Thread 1: 13
Thread 1: 14
Thread 1: 15
Thread 1: 16
Thread 1: 17
Thread 1: 18
Thread 1: 19
Thread 1: 20
Thread 1: 21
Thread 1: 22
Thread 1: 23
Thread 1: 24
Thread 1: 25
Thread 1: 26
Thread 1: 27
Thread 1: 28
Thread 1: 29
Thread 1: 30
Thread 2: 23
Thread 2: 24
Thread 2: 25
Thread 2: 26
Thread 2: 27
Thread 2: 28
Thread 2: 29
Thread 2: 30
Thread 2: 31
Thread 2: 32
Thread 2: 33
Thread 2: 34
Thread 2: 35
Thread 2: 36
Thread 3: 21
Thread 2: 37
Thread 3: 22
Thread 3: 23
Thread 2: 38
Thread 2: 39
Thread 2: 40
Thread 2: 41
Thread 2: 42
Thread 2: 43
Thread 2: 44
Thread 2: 45
Thread 2: 46
Thread 2: 47
Thread 2: 48
Thread 2: 49
Thread 1: 31
Thread 1: 32
Thread 1: 33
Thread 1: 34
Thread 1: 35
Thread 1: 36
Thread 1: 37
Thread 1: 38
Thread 1: 39
Thread 1: 40
Thread 1: 41
Thread 1: 42
Thread 1: 43
Thread 1: 44
Thread 1: 45
Thread 1: 46
Thread 1: 47
Thread 1: 48
Thread 1: 49
Thread 3: 24
Thread 3: 25
Thread 3: 26
Thread 3: 27
Thread 3: 28
Thread 3: 29
Thread 3: 30
Thread 3: 31
Thread 3: 32
Thread 3: 33
Thread 3: 34
Thread 3: 35
Thread 3: 36
Thread 3: 37
Thread 3: 38
Thread 3: 39
Thread 3: 40
Thread 3: 41
Thread 3: 42
Thread 3: 43
Thread 3: 44
Thread 3: 45
Thread 3: 46
Thread 3: 47
Thread 3: 48
Thread 3: 49
Done!
  • t1.Join()だけをコメントアウトすると、Thread 1の出力だけがDone!のあとに表示される。(待っていないので)
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

タスク

  • スレッドの生成は重い処理。いったん生成したスレッドをすぐに破棄せずに保持して、再び利用する仕組みがスレッドプール。
  • スレッドプールを利用するのがタスク
実験コード
public class Program
{
    static void Main()
    {
        var t1 = Task.Run(() => Count(1));
        var t2 = Task.Run(() => Count(2));
        var t3 = Task.Run(() => Count(3));
        
        t1.Wait();
        t2.Wait();
        t3.Wait();

        Console.WriteLine("Done!");
    }

    static void Count(int n)
    {
        for (int i = 0; i < 50; i++)
        {
            Console.WriteLine($"Thread {n}: {i}");
        }
    }
}

出力

Task 3: 0
Task 3: 1
Task 1: 0
Task 3: 2
Task 3: 3
Task 3: 4
Task 3: 5
Task 3: 6
Task 1: 1
Task 3: 7
Task 1: 2
Task 1: 3
Task 1: 4
Task 1: 5
Task 3: 8
Task 3: 9
Task 1: 6
Task 1: 7
Task 3: 10
Task 3: 11
Task 3: 12
Task 3: 13
Task 3: 14
Task 3: 15
Task 1: 8
Task 1: 9
Task 3: 16
Task 3: 17
Task 1: 10
Task 1: 11
Task 1: 12
Task 1: 13
Task 1: 14
Task 1: 15
Task 3: 18
Task 3: 19
Task 3: 20
Task 1: 16
Task 1: 17
Task 1: 18
Task 1: 19
Task 3: 21
Task 1: 20
Task 1: 21
Task 1: 22
Task 1: 23
Task 3: 22
Task 1: 24
Task 1: 25
Task 1: 26
Task 1: 27
Task 1: 28
Task 1: 29
Task 1: 30
Task 2: 0
Task 2: 1
Task 2: 2
Task 2: 3
Task 2: 4
Task 1: 31
Task 1: 32
Task 1: 33
Task 1: 34
Task 1: 35
Task 1: 36
Task 1: 37
Task 1: 38
Task 2: 5
Task 2: 6
Task 2: 7
Task 2: 8
Task 2: 9
Task 2: 10
Task 2: 11
Task 2: 12
Task 2: 13
Task 2: 14
Task 2: 15
Task 2: 16
Task 2: 17
Task 2: 18
Task 2: 19
Task 2: 20
Task 3: 23
Task 2: 21
Task 2: 22
Task 2: 23
Task 2: 24
Task 2: 25
Task 2: 26
Task 2: 27
Task 2: 28
Task 2: 29
Task 2: 30
Task 2: 31
Task 2: 32
Task 2: 33
Task 2: 34
Task 2: 35
Task 2: 36
Task 2: 37
Task 2: 38
Task 2: 39
Task 2: 40
Task 2: 41
Task 2: 42
Task 3: 24
Task 3: 25
Task 3: 26
Task 3: 27
Task 3: 28
Task 3: 29
Task 3: 30
Task 3: 31
Task 3: 32
Task 3: 33
Task 3: 34
Task 1: 39
Task 3: 35
Task 3: 36
Task 3: 37
Task 3: 38
Task 3: 39
Task 1: 40
Task 1: 41
Task 1: 42
Task 1: 43
Task 1: 44
Task 1: 45
Task 1: 46
Task 1: 47
Task 1: 48
Task 1: 49
Task 2: 43
Task 2: 44
Task 2: 45
Task 2: 46
Task 2: 47
Task 2: 48
Task 2: 49
Task 3: 40
Task 3: 41
Task 3: 42
Task 3: 43
Task 3: 44
Task 3: 45
Task 3: 46
Task 3: 47
Task 3: 48
Task 3: 49
Done!
  • t1.Wait()をコメントアウトすると、Task 1の出力だけがDone!のあとに表示される(されないこともある)
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

排他制御

  • 複数のタスクが同じメモリにあるインスタンスを見て同時に作業を行うことによって、変更に競合が発生する
  • lockブロックを用いることでこの問題を解決することができる
  • lockすると、割り込もうとしたタスクが待つことになるため、処理時間はlockしない場合よりも長くなる。
排他制御が出来ていない実験コード
public class Program
{
    static void Main()
    {
        const int TaskNum = 500000; // 50万
        var tasks = new Task[TaskNum];
        var counter = new Counter();
        
        for (int i = 0; i < TaskNum; i++)
        {
            tasks[i] = Task.Run(() => { counter.Count++; });
        }
        
        for (int i = 0; i < TaskNum; i++)
        {
            tasks[i].Wait();
        }
        
        Console.WriteLine(counter.Count);
    }
}

public class Counter
{
    public int Count { get; set; }
}

出力(本来は50万になってほしい)

472597
  • この処理の実行時間は5回平均で145ミリ秒
排他制御を行う実験コード
        object lockObj = new object();
        // 途中は同じ       
        for (int i = 0; i < TaskNum; i++)
        {
            tasks[i] = Task.Run(() =>
            {
                lock (lockObj)
                {
                    counter.Count++;
                }
            });
        }

出力

500000
  • この処理の実行時間は5回平均で202ミリ秒
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

async / await

実験コード
public class Program
{
    public static void Main()
    {
        var t = RunAsync(); // tの型推論はTask型
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] t: {t}");
        t.Wait();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] done");
    }

    private static async Task RunAsync()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [RunAsync] starting");
        await Task.Run(Count); // サブスレッド実行。呼び出し元へ処理を一度戻す
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [RunAsync] done");
    }

    private static void Count()
    {
        for (int i = 0; i < 1000; i++)
        {
            Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} Counting: {i}");
        }
    }
}

出力

1 [RunAsync] starting
1 [Main] t: System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[System.Threading.Tasks.VoidTaskResult,Program+<RunAsync>d__1]
5 Counting: 0
5 Counting: 1
...略...
5 Counting: 999
5 [RunAsync] done
1 [Main] done
  • [Main] tが少し遅くなるケース
1 [RunAsync] starting
6 Counting: 0
6 Counting: 1
...略...
6 Counting: 21
1 [Main] t: System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[System.Threading.Tasks.VoidTaskResult,Program+<RunAsync>d__1]
6 Counting: 22
...略...
6 Counting: 999
6 [RunAsync] done
1 [Main] done
  • Main()メソッド内
    • RunAsync()がawaitされていないため、[RunAsync] doneが表示される前にMain()内の次のログ([Main] t: )が表示されている(呼び出し先のRunAsyncから、呼び出し元のMainに処理が戻されている)
    • t.Wait()が記述されているため、[RunAsync] doneが表示されてから[Main] doneが表示されている
  • RunAsync()メソッド内
    • Task.Run(Count)がawaitされているので、[RunAsync] doneは全てのカウントが完了してから表示される
  • [RunAsync] startingはメインスレッドで呼ばれるが、 [RunAsync] doneはサブスレッドで呼ばれている。同じメソッドの中でも、途中でサブスレッドに移動した場合はその後もサブスレッドで呼ばれる模様。
t.Wait()をコメントアウトした場合 => [Main] doneは待たれず呼ばれる。カウントの途中でプロセスが終了することもある。
    public static void Main()
    {
        var t = RunAsync();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] t: {t}");
        // t.Wait();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] done");
    }

出力

1 [RunAsync] starting
1 [Main] t: System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[System.Threading.Tasks.VoidTaskResult,Program+<RunAsync>d__1]
1 [Main] done
5 Counting: 0
5 Counting: 1
...略...
5 Counting: 998
5 Counting: 999
5 [RunAsync] done
  • 少し順番が揺らぐケース
1 [RunAsync] starting
4 Counting: 0
...略...
4 Counting: 7
1 [Main] t: System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1+AsyncStateMachineBox`1[System.Threading.Tasks.VoidTaskResult,Program+<RunAsync>d__1]
4 Counting: 8
4 Counting: 9
1 [Main] done
4 Counting: 10
4 Counting: 11
...略...
4 Counting: 999
4 [RunAsync] done
  • [RunAsync] doneが呼ばれる前に、[Main] doneが呼ばれている。RunAsync()の完了が待たれなくなっている。
  • 1000程度ならカウントは最後まで終了したが、もっと桁数を大きくするとカウントの途中で終了する。Mainメソッドが終了して、メインスレッドが閉じたためと思われる。
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

async / awaitで値を返す(Task<T>)

  • Task<T>を利用
  • t.Resultやawaitで値を取り出す
実験コード
    public static void Main()
    {
        var t = RunAsync();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] processing");
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] t: {t.Result}");
        t.Wait();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] done");
    }

    private static async Task<int> RunAsync()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [RunAsync] starting");
        await Task.Run(Count);
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [RunAsync] done");
        return 10;
    }
  
    // Countメソッドは先程と同じ

出力

1 [RunAsync] starting
5 Counting: 0
5 Counting: 1
...略...
5 Counting: 485
1 [Main] processing
5 Counting: 486
...略...
5 Counting: 998
5 Counting: 999
5 [RunAsync] done
1 [Main] t: 10
1 [Main] done
t.Wait()やt.Resultをコメントアウトした場合
  • 必ず最後まで(1000000など桁数をあげても)カウントされた。

t.Wait()だけをコメントアウトした場合 => 最後まで待たれる

  • t.Wait()をしていないが、必ず[Main] doneは最後に出力された。
    • t.Resultにアクセスすることで、タスクのcompleteを待ってくれている模様。
  • Resultの定義元を見に行くと以下のように記述されていた

The get accessor for this property ensures that the asynchronous operation is complete before returning. Once the result of the computation is available, it is stored and will be returned mmediately on later calls to Result.
(DeepL日本語訳)このプロパティのgetアクセサは、戻る前に非同期処理が完了していることを保証します。計算結果が利用可能になると、それは保存され、後でResultを呼び出すとすぐに返されます。

t.Resultだけをコメントアウトした場合 => 最後まで待たれる

  • 必ず最後まで(1000000など桁数をあげても)カウントされた。

t.Resultとt.Wait()を両方コメントアウトした場合 => 途中で終了する

    public static void Main()
    {
        var t = RunAsync();
        Console.WriteLine("[Main] processing");
        // Console.WriteLine($"[Main] t: {t.Result}");
        // t.Wait();
        Console.WriteLine("[Main] done");
    }

出力

最初の方は消えてしまっていてわからない
5 Counting: 27764
5 Counting: 27765

Process finished with exit code 0.
awaitを使って待つ場合
  • Mainメソッドをasync Taskとする。
  • await演算子によって、Task<T>型に含まれるTを取得することができる。
  • 最後の方の[Main]とつく出力も、サブスレッドで行われていることに注意!awaitの後はサブスレッドに移行する模様!
    public static async Task Main()
    {
        var t = await RunAsync(); // tはint推論
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] processing");
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] t: {t}");
        // 以下2行はコンパイルエラー
        // Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] t: {t.Result}");
        // t.Wait();
        Console.WriteLine("[Main] done");
    }

出力

  • 出力順は確定でこの順番
1 [RunAsync] starting
5 Counting: 0
5 Counting: 1
...略...
5 Counting: 999999
5 [RunAsync] done
5 [Main] processing
5 [Main] t: 10
5 [Main] done
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

色々なバリエーションで動作検証をしてみる

ただの同期メソッド

ただの同期メソッドを呼ぶ

  • ThreadIdは同じ。固まってると思われる。
public class Program
{
    public static void Main()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] Start");
        HeavyMethod();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] End");
    }

    private static void HeavyMethod()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [HeavyMethod] Start");
        Thread.Sleep(2000);
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [HeavyMethod] End");
    }
}

出力

1 [Main] Start
1 [HeavyMethod] Start
1 [HeavyMethod] End ← 前の2秒後に出力
1 [Main] End

ただの同期メソッドを5回連続で呼んでみる

    // ほかは省略

    private static void RunHeavyMethodSync()
    {
        for (var i = 0; i < 5; i++)
        {
            HeavyMethod();
        }
    }

出力

1 [Main] Start
1 [HeavyMethod] Start
1 [HeavyMethod] End ← 前の2秒後に出力
1 [HeavyMethod] Start
1 [HeavyMethod] End ← 前の2秒後に出力
1 [HeavyMethod] Start
1 [HeavyMethod] End ← 前の2秒後に出力
1 [HeavyMethod] Start
1 [HeavyMethod] End ← 前の2秒後に出力
1 [HeavyMethod] Start
1 [HeavyMethod] End ← 前の2秒後に出力
1 [Main] End
Taskにしてみる

Taskにしたがawaitしない場合

  • メインメソッドを抜けてしまい、途中で終了している。
public class Program
{
    public static void Main()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] Start");
        RunHeavyMethodAsync();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] End");
    }

    private static async Task RunHeavyMethodAsync()
    {
        for (var i = 0; i < 5; i++)
        {
            await Task.Run(() => HeavyMethod());
        }
    }
}

出力

1 [Main] Start
1 [Main] End
4 [HeavyMethod] Start

Taskをawaitした場合

    public static async Task Main()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] Start");
        await RunHeavyMethodAsync();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] End");
    }

    private static async Task RunHeavyMethodAsync()
    {
        for (var i = 0; i < 5; i++)
        {
            await Task.Run(() => HeavyMethod());
        }
    }

出力

1 [Main] Start
5 [HeavyMethod] Start
5 [HeavyMethod] End ← 前の2秒後に出力
8 [HeavyMethod] Start
8 [HeavyMethod] End ← 前の2秒後に出力
10 [HeavyMethod] Start
10 [HeavyMethod] End ← 前の2秒後に出力
8 [HeavyMethod] Start
8 [HeavyMethod] End ← 前の2秒後に出力
10 [HeavyMethod] Start
10 [HeavyMethod] End ← 前の2秒後に出力
10 [Main] End

戻り値をTaskではなくvoidにした場合

  • async voidは待てないため、メインメソッドが終了してしまい、RuunHeavyMethodAsync2も途中で終了している。
    public static void Main()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] Start");
        RunHeavyMethodAsync2();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] End");
    }

    private static async void RunHeavyMethodAsync2()
    {
        for (var i = 0; i < 5; i++)
        {
            await Task.Run(() => HeavyMethod());
        }
    }

出力

1 [Main] Start
3 [HeavyMethod] Start
1 [Main] End
並行でいくつも動かす時

戻り値がvoidなので、動かすけど待てないとき

    public static void Main()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] Start");
        RunHeavyMethodParallel();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] End");
    }

    private static void RunHeavyMethodParallel()
    {
        for (var i = 0; i < 5; i++)
        {
            Task.Run(() => HeavyMethod());
        }
    }

出力

1 [Main] Start
1 [Main] End
4 [HeavyMethod] Start
9 [HeavyMethod] Start
10 [HeavyMethod] Start
11 [HeavyMethod] Start
12 [HeavyMethod] Start

全てのTaskの完了を待つ時

    public static async Task Main()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] Start");
        await RunHeavyMethodParallel2();
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} [Main] End");
    }

    private static Task RunHeavyMethodParallel2()
    {
        var tasks = new List<Task>();
        for (var i = 0; i < 5; i++)
        {
            var task = Task.Run(() => HeavyMethod());
            tasks.Add(task);
        }
        return Task.WhenAll(tasks);
    }

出力

1 [Main] Start
4 [HeavyMethod] Start
9 [HeavyMethod] Start
10 [HeavyMethod] Start
11 [HeavyMethod] Start
12 [HeavyMethod] Start
9 [HeavyMethod] End ← 前の2秒後に出力
12 [HeavyMethod] End
11 [HeavyMethod] End
10 [HeavyMethod] End
4 [HeavyMethod] End
4 [Main] End

awaitで待ってから返しても同じ

    private static async Task RunHeavyMethodParallel3()
    {
        var tasks = new List<Task>();
        for (var i = 0; i < 5; i++)
        {
            var task = Task.Run(() => HeavyMethod());
            tasks.Add(task);
        }
        await Task.WhenAll(tasks);
    }

出力

1 [Main] Start
3 [HeavyMethod] Start
9 [HeavyMethod] Start
11 [HeavyMethod] Start
12 [HeavyMethod] Start
13 [HeavyMethod] Start
3 [HeavyMethod] End ← 前の2秒後に出力
9 [HeavyMethod] End
11 [HeavyMethod] End
12 [HeavyMethod] End
13 [HeavyMethod] End
9 [Main] End
  • 最後2つのメソッドの挙動の違いについて聞く