3流プログラマのメモ書き

元開発職→社内SE→派遣で営業支援→開発戻り浦島太郎状態の三流プログラマのIT技術メモ書き。 このメモが忘れっぽい自分とググってきた技術者の役に立ってくれれば幸いです。

(C#)Task.Run,await/asyncを使ったマルチスレッドの簡単例

昔は Thread や BackgroundWorker を使ってマルチスレッド処理を書いていましたが、久しぶりにC#開発をやると、async/await (非同期メソッド) という方法が今は最新のようだということを知りました。 既存のコードにもあまり変更を加える必要もなく、処理も分散しないので見やすくもなるメリットがあるようです。
とはいえ、初見でちょっと勘違いをして理解が遅くなったのでまとめと例を覚書しておきたいと思います。

Task.Run / await でマルチスレッド化する時のお作法まとめ

  • 別スレッドで動かすにはTask.Run を使うか、内部で非同期で動くよう実装されている(Task.Runを使っている)非同期メソッドを呼び出す。
    なお、非同期メソッドはメソッド名に ~Async と付くよう推奨されていますし、戻り値が Task or Task となるので、それで判断付くかと思います。)

  • 非同期処理の完了を待つには、await を使う。
    await を使うと、それより後のコードはTask.Run が終わるまで実行されません。

  • await を使っているメソッドは メソッドにasyncキーワードを付与する。
    asyncキーワードが付いたメソッドは非同期メソッドと呼ばれます。が、そのメソッドの内容全てが非同期=別スレッドで動くわけじゃありません。あくまで、Task.Runを使っている部分あるいは、内部で非同期で動くよう実装されているメソッドが別スレッドで動きます。紛らわしいですよね。。

  • 戻り値が無いメソッドの場合、void を使いますが非同期メソッドだと Task 型を返すようにする。
    voidも書けるんですが、呼び出しのもとになるイベントハンドラ以外では void を使ってはいけないことになっています。なぜなら、void だと別スレッドで動き出した後、投げっぱなしで呼び出し元で終了や例外を検知できないからです。コンパイルエラーにならないので要注意です。

  • どこかで非同期メソッドを呼び出したら、呼び出し元を遡ってasync / Task が必要。
    普通開発してると、イベントハンドラメソッド→メソッドA→メソッドB→メソッドC というように呼び出しがネストしますが、メソッドCを別スレッドで動くようにした場合、呼び出しもとからイベントハンドラまで遡って非同期メソッドとする = async / Taskをつけるよう必要があります。
    (上述のようにイベントハンドラだけは async voidでOK)。

  • Task.Wait() , Task.Resultは使わない
    マルチスレッドには、Task.Run が欠かせませんが、そこで出てくる Task 型の注意です。
    Task には Task.Wait() や Task.Result といった処理を待つようなメソッドがありますが、GUIアプリではデッドロックを起こすために使用しません。
    処理を待つには、await を使います。

このあたりのお作法詳細は以下を参照。
Microsoft:Async/Await - 非同期プログラミングのベスト プラクティス
Microsoft:async と await を使ったタスク非同期プログラミング (TAP) モデル - C#
neue cc - asyncの落とし穴Part3, async voidを避けるべき100億の理由

例1.マルチスレッドではない普通の同期処理

フォームのボタンのイベントハンドラメソッドから、時間がかかるメソッドを呼び出す処理を例に考えていきます。
フォーム上に別のボタンも用意し、時間のかかる処理をしている際にどうなるかという点も検証します。

まずは、マルチスレッドではない普通の同期処理の場合、以下のようになります。

//通常の同期処理呼び出し元(シングルスレッド)
private void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    WorkNormal();
    Console.WriteLine($"Complate");
}

//時間のかかる処理(戻り値なし)
private void  WorkNormal()
{
    Console.WriteLine($"Work thread ID : " + Thread.CurrentThread.ManagedThreadId);
    for ( int i=0; i<5; i++)
    {
        Thread.Sleep(200);
        Console.WriteLine($"i:{i}");
    }
}

//割り込み処理
private void button_interrupt_Click(object sender, EventArgs e)
{
    Console.WriteLine($"interrupt");
}

button1 を押下してすぐ button_interrupt を押下すると以下のような結果になります。なお、WorkNormalメソッド実行中はUIは応答しません。

Main thread ID : 1
Start
Work thread ID : 1
i:0
i:1
i:2
i:3
i:4
Complate
interrupt

例2.Task.Run / await で戻り値無しのメソッドを非同期処理(別スレッド)化例

上記の戻り値がない WorkNormal()を非同期処理(別スレッド化)するには以下のようにします。

//Task.RunでWorkNormal(戻り値無し)を非同期処理(別スレッド化)する呼び出し元。
private async void button2_Click(object sender, EventArgs e)
{
    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    await Task.Run( ()=> WorkNormal());  //別スレッドで実行する。別スレッドが終わるまで待つ。
    Console.WriteLine($"Complate");

}

//割り込み処理
private void button_interrupt_Click(object sender, EventArgs e)
{
    Console.WriteLine($"interrupt");
}

変更点は、WorkNormal()の呼び出しかたですね。Task.Runで別スレッド実行するようを指定し、await でその処理を待つようにしてます。
また、await を使っているので、button2_Click に async をつけます。(button2_Clickはイベントハンドラなので void のままでOKです。)
WorkNormal() は何も変えていません。

button2 を押下してすぐ button_interrupt を押下すると以下のような結果になります。
WorkNormalメソッド実行中もUIはちゃんと応答し、別のボタンのイベントハンドラと並行して動いていることがわかります。
スレッドIDも出力しているようにしていますが、そこからも別スレッドだとわかりますね。

Main thread ID : 1
Start
Work thread ID : 3
i:0
i:1
interrupt
i:2
i:3
i:4
Complate

なお、待つ必要がなければ await をつけずに Task.Run だけすればいいのですが、そうすると例外発生や終了検知ができないので、工夫が必要そうです。(今回は割愛)
Visual Studioでも以下警告がでます。

警告   CS4014  この呼び出しを待たないため、現在のメソッドの実行は、呼び出しが完了するまで続行します。呼び出しの結果に 'await' 演算子を適用することを検討してください。

例3.await で戻り値無しの非同期メソッドを呼び出す場合

ライブラリ等で既にメソッド内で非同期処理が実装され、非同期メソッドとなっているものもあります。
そういうものを呼び出す例です。
以下例では、WorkCoreAsync() が非同期メソッドです。
(例として自作メソッドであげていますが、外部ライブラリの非同期メソッドと考えてもらってもいいです。例えば、WebView2.ExecuteScriptAsync()等々。非同期メソッドはメソッド名が~Asyncとなっており、戻り値がTaskなので、それで判断がつきます。)

//イベントハンドラ(非同期メソッドの大もとの呼び出し元)
private async void button3_Click(object sender, EventArgs e)
{
    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    await WorkAsync();
    Console.WriteLine($"Complate");

}

//非同期メソッドを直接呼び出すメソッド。
private async Task WorkAsync()
{
    Console.WriteLine($"WorkAsync thread ID : " + Thread.CurrentThread.ManagedThreadId);
    await WorkCoreAsync(); //非同期メソッド呼び出し
    Console.WriteLine($"WorkAsync Complate");
}

//時間のかかる処理。非同期メソッド。今回は例として自作メソッドだけど、外部ライブラリとかを想定。戻り値無し。
private async Task WorkCoreAsync()
{
    await Task.Run(() => {
        Console.WriteLine($"WorkCoreAsync thread ID : " + Thread.CurrentThread.ManagedThreadId);
        for (int i = 0; i < 5; i++)
        {
            Thread.Sleep(200);
            Console.WriteLine($"i:{i}");
        }
    });
}

//割り込み処理
private void button_interrupt_Click(object sender, EventArgs e)
{
    Console.WriteLine($"interrupt");
}

ポイントは、非同期メソッドWorkCoreAsync() を呼び出す、WorkAsync() メソッドや、それを呼び出す button3_Click() イベントハンドラも、非同期メソッド = async / await にする必要があるということです。
また、async が付いたvoidだったメソッドは戻り値が Task になります。(イベントハンドラのみ void でOK)。

button3 を押下してすぐ button_interrupt を押下すると以下のような結果になります。
WorkCoreAsync() だけ別スレッドで動いていることがわかります。

Main thread ID : 1
Start
WorkAsync thread ID : 1
WorkCoreAsync thread ID : 3
i:0
i:1
interrupt
i:2
i:3
i:4
Complate

例4.Task.Run / await で戻り値があるのメソッドを非同期処理(別スレッド)化例

これまでは戻り値がないメソッドの例でした。ここからは戻り値があるメソッドの例です。
例2.を戻り値があるメソッドにするとこうなります。

//Task.WorkNormalReturn(戻り値あり)を非同期処理(別スレッド化)する呼び出し元。
private async void button4_Click(object sender, EventArgs e)
{
    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    string res =  await Task.Run( () => WorkNormalReturn());  //別スレッドで実行する。別スレッドが終わるまで待つ。
    Console.WriteLine($"Complate return:{res}");

}

//時間のかかる処理(戻り値有)
private string WorkNormalReturn()
{
    Console.WriteLine($"Work thread ID : " + Thread.CurrentThread.ManagedThreadId);
    int i = 0;
    for ( i = 0; i < 5; i++)
    {
        Thread.Sleep(200);
        Console.WriteLine($"i:{i}");
    }
    return i.ToString();
}

//割り込み処理
private void button_interrupt_Click(object sender, EventArgs e)
{
    Console.WriteLine($"interrupt");
}

上記のように、Task.Run の結果をそのままメソッドの戻り値の型に受け取るだけでOKです。
難しいことを考えなくても直感的に書けるようになっていますね。 (本来、戻り値は Task型で返るのですが、await を使うことで、型で返してくれます)。

button4 を押下してすぐ button_interrupt を押下すると結果は以下のようになります。

Main thread ID : 1
Start
Work thread ID : 3
i:0
i:1
i:2
i:3
interrupt
i:4
Complate return:5

例5.await で戻り値がある非同期メソッドを呼び出す場合

例3.の戻り値があるメソッド版です。

///非同期メソッド(戻り値有)の呼び出し元
private async void button5_Click(object sender, EventArgs e)
{
    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    var res = await WorkReturnAsync();  //別スレッドで実行する。別スレッドが終わるまで待つ。
    Console.WriteLine($"Complate return:{res}");

}

//戻り値がある場合(非同期メソッドを呼び出す場合)
//時間のかかる処理。
private async Task<String> WorkReturnAsync()
{
    Console.WriteLine($"WorkAsync thread ID : " + Thread.CurrentThread.ManagedThreadId);
    string res = await WorkReturnCoreAsync(); //非同期メソッド呼び出し
    Console.WriteLine($"WorkAsync Complate return:{res}");
    return res;
}

//非同期メソッド(戻り値有)。今回は例として自作メソッドだけど、外部ライブラリとかを想定。
private async Task<String> WorkReturnCoreAsync()
{
    int i = 0;
    await Task.Run(() => {
        Console.WriteLine($"WorkCoreAsync thread ID : " + Thread.CurrentThread.ManagedThreadId);
        //int i;
        for (i = 0; i < 5; i++)
        {
            Thread.Sleep(200);
            Console.WriteLine($"i:{i}");
        }
    });
    return i.ToString();
}

//割り込み処理
private void button_interrupt_Click(object sender, EventArgs e)
{
    Console.WriteLine($"interrupt");
}

ポイントは、戻り値を返すメソッドの型ですね。
戻り値がある場合は、Task を返す型となります。(イベントハンドラだけvoid)

button5 を押下してすぐ button_interrupt を押下すると結果は以下のようになります。

Main thread ID : 1
Start
WorkCoreAsync thread ID : 3
i:0
i:1
i:2
interrupt
i:3
i:4
WorkAsync Complate return:5
Complate return:5

例6.非同期メソッドからプログレスバー更新(Eventを使う方法)

時間のかかる非同期処理をしていると、UI側に処理進捗を表示したいというのは多々あります。 で、その方法ですが、イベントを使う方法とProgressを使う方法があります。
推奨はProgressを使う方法です。
が、それを知る前に先にイベントで実装してしまったのでその例を紹介します。

/// メインUIスレッドのコンテキスト(別スレッドからUI処理するときに利用)
private SynchronizationContext _uiContext;


//非同期メソッドからプログレス更新(Eventを使う方法)
private async void button6_Click(object sender, EventArgs e)
{
    //メインスレッドのコンテキスト取得
    _uiContext = SynchronizationContext.Current;
    //イベントハンドラ設定
    this.Progress += ProgressHandler;

    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    string res = await WorkReturnEventCoreAsync();
    Console.WriteLine($"Complate return:{res}");

}

//イベントハンドラ
private void ProgressHandler(object sender,TestEventArgs e)
{
    Console.WriteLine($"ProgressHandler thread ID : " + Thread.CurrentThread.ManagedThreadId);
    //別スレッドで動くので、UIスレッドにディスパッチしてフォームコントロールにアクセス
    _uiContext.Post(_ =>
    {
        progressBar1.Value = e.ProgressPercent;
        label1.Text = e.Time.ToString();
    }, null);
}

//デリケートとイベント定義
public delegate void TestEventHandler(object sender, TestEventArgs e);
public event TestEventHandler Progress;


//非同期メソッド(戻り値有)プログレス対応(イベント)。今回は例として自作メソッドだけど、外部ライブラリとかを想定。
private async Task<String> WorkReturnEventCoreAsync()
{
    int i = 0;
    await Task.Run(() => {
        Console.WriteLine($"WorkCoreAsync thread ID : " + Thread.CurrentThread.ManagedThreadId);
        //ストップウォッチ
        var sw = new System.Diagnostics.Stopwatch();
        sw.Start();
        //int i;
        for (i = 0; i < 5; i++)
        {
            Thread.Sleep(200);
            Console.WriteLine($"i:{i}");
            //イベントハンドラに渡すイベント引数作成
            var e = new TestEventArgs( (i+1) * 100 / 5, sw.Elapsed);
            //イベント発火
            Progress(this, e);

        }
    });
    return i.ToString();
}

//割り込み処理
private void button_interrupt_Click(object sender, EventArgs e)
{
    Console.WriteLine($"interrupt");
}

イベント引数に使ってるクラスは以下の通りです。

public class TestEventArgs:EventArgs
{
    public bool Cancel { get; set; }
    public int ProgressPercent { get; set; }
    public TimeSpan Time { get; set; }
    public TestEventArgs(int percent, TimeSpan time)
    {
        ProgressPercent = percent;
        Time = time;
    }
}

基本は普通のイベント処理の実装ですが、ポイントは非同期メソッドで発生したイベントを処理するイベントハンドラ ProgressHandler() も別スレッドで動くという点です。
なので、イベントハンドラ=別スレッドからUIを操作する際には、一工夫必要です。
今回は、UIスレッドのコンテキストを取得し、そこにディスパッチ( _uiContext.Post ) して、別スレッドからUIにアクセスしています。

button6 を押下してすぐ button_interrupt を押下すると結果は以下のようになります。 イベントハンドラが別スレッドで動いているのがわかると思います。

Main thread ID : 1
Start
WorkCoreAsync thread ID : 3
i:0
ProgressHandler thread ID : 3
i:1
ProgressHandler thread ID : 3
i:2
ProgressHandler thread ID : 3
interrupt
i:3
ProgressHandler thread ID : 3
i:4
ProgressHandler thread ID : 3
Complate return:5

例7.非同期メソッドからプログレスバー更新(Progressを使う方法)

例6の内容をProgressを使う方法に置き換えた例です。
こっちの方がコードがシンプルですね。推奨されてるのもわかります。

//非同期メソッドからプログレス更新(Progress<T>を使う方法)
private async void button7_Click(object sender, EventArgs e)
{
    // Progressクラスのインスタンスを生成
    var p = new Progress<TestEventArgs>(ShowProgress);

    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    string res = await WorkReturnProgressCoreAsync(p);
    Console.WriteLine($"Complate return:{res}");

}

//プログレス表示メソッド
private void ShowProgress(TestEventArgs e)
{
    Console.WriteLine($"ShowProgress thread ID : " + Thread.CurrentThread.ManagedThreadId);
    progressBar1.Value = e.ProgressPercent;
    label1.Text = e.Time.ToString();

}

//非同期メソッド(戻り値有)プログレス対応(イベント)。今回は例として自作メソッドだけど、外部ライブラリとかを想定。
private async Task<String> WorkReturnProgressCoreAsync(IProgress<TestEventArgs> progress)
{
    int i = 0;
    await Task.Run(() => {
        Console.WriteLine($"WorkCoreAsync thread ID : " + Thread.CurrentThread.ManagedThreadId);
        //ストップウォッチ
        var sw = new System.Diagnostics.Stopwatch();
        sw.Start();
        //int i;
        for (i = 0; i < 5; i++)
        {
            Thread.Sleep(200);
            Console.WriteLine($"i:{i}");
            //IProgressに渡す進捗報告のインスタンス作成
            var e = new TestEventArgs((i + 1) * 100 / 5, sw.Elapsed);
            //報告
            progress.Report(e);

        }
    });
    return i.ToString();
}

//割り込み処理
private void button_interrupt_Click(object sender, EventArgs e)
{
    Console.WriteLine($"interrupt");
}

button6 を押下してすぐ button_interrupt を押下すると結果は以下のようになります。
以下のように、イベントを処理するShowProgressがUIスレッドで動いているがわかります。
イベントを使った方法では別スレッドからUIにアクセスしていましたが、こちらの方が安心ですね。

Main thread ID : 1
Start
WorkCoreAsync thread ID : 4
i:0
ShowProgress thread ID : 1
i:1
ShowProgress thread ID : 1
i:2
ShowProgress thread ID : 1
interrupt
i:3
ShowProgress thread ID : 1
i:4
ShowProgress thread ID : 1
Complate return:5

例8.非同期処理をキャンセル

時間のかかる非同期処理をしていると、処理進捗の表示と共にキャンセルもたいてい必要になります。
キャンセルには、CancellationTokenを使うのですが、非同期メソッド側がCancellationTokenの引数を受け取りキャンセル処理ができるように実装されていることが必要になってきます。
また、キャンセル時はTaskCanceledException例外(OperationCanceledExceptionの派生)を用います。

もし、呼び出し先の非同期メソッドが CancellationToken を渡せないのであれば CancellationToken.ThrowIfCancellationRequested() を呼び出すこともできる用です。(詳細は参考サイト参照。。)

以下はキャンセル処理を実装した例です。

//キャンセルトークン(メンバ変数)
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
//キャンセルボタン押下
private void cancelButton_Click(object sender, EventArgs e)
{
    //キャンセル依頼
    cancellationTokenSource.Cancel();
}

//キャンセル対応 & 非同期メソッドからプログレス更新(Progress<T>を使う方法)
private async void button8_Click(object sender, EventArgs e)
{
    var p = new Progress<TestEventArgs>(ShowProgress);
    Console.WriteLine($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
    Console.WriteLine($"Start");
    try
    {
        string res = await WorkReturnProgressCancelCoreAsync(cancellationTokenSource.Token, p);
        Console.WriteLine($"Complate return:{res}");
    }
    catch (TaskCanceledException ex)
    {
        Console.WriteLine("キャンセル発生");
    }
}

//非同期メソッド(戻り値有)プログレス対応(イベント)。今回は例として自作メソッドだけど、外部ライブラリとかを想定。
private async Task<String> WorkReturnProgressCancelCoreAsync(CancellationToken token, IProgress<TestEventArgs> progress)
{
    int i = 0;
    await Task.Run(() => {
        Console.WriteLine($"WorkCoreAsync thread ID : " + Thread.CurrentThread.ManagedThreadId);
        //ストップウォッチ
        var sw = new System.Diagnostics.Stopwatch();
        sw.Start();
        //int i;
        for (i = 0; i < 5; i++)
        {
            Thread.Sleep(200);
            Console.WriteLine($"i:{i}");
            //IProgressに渡す進捗報告のインスタンス作成
            var e = new TestEventArgs((i + 1) * 100 / 5, sw.Elapsed);
            //報告
            progress.Report(e);

            //キャンセル処理
            if (token.IsCancellationRequested)
            {
                throw new TaskCanceledException("キャンセル発生");
            }
        }
    });
    return i.ToString();
}

ポイントはまず、非同期メソッドはキャンセルトークンを引数で受け取れるようにします。
そして、トークンのフラグをチェックして、キャンセルフラグが立っているならTaskCanceledExceptionを発生させます。
キャンセルする側は、CancellationTokenSource.Cancel()を呼び出すだけです。

マルチスレッド間で変数を共有するには?

いくつか方法があるようですが、lockを使うのが一般的なようです。lockを使うことで、排他制御がかかり処理を抜けるまで他のスレッドはアクセスできないようになります。

// スレッドセーフのためのロックオブジェクト(メンバ変数)
private object _lockObj = new object();
// スレッド間で共有させたい変数
private bool _hoge = true;

private void method(){
lock (_lockObj)
    {
        _hoge = false;
    }
}

参考:
async/await を完全に理解する
async/await を完全に理解してからもう少し理解する
.NET開発における非同期処理の基礎と歴史(2/2) - @IT
WPF/Windowsフォーム:時間のかかる処理をバックグラウンドで実行するには?(async/await編)[C#/VB]:.NET TIPS - @IT
初心者のためのTask.Run(), async/awaitの使い方 #C# - Qiita
C#のマルチスレッド処理でのロック制御:SionGames 技術Blog
できる!C#で非同期処理(Taskとasync-await) – kekyoの丼
【C#】async/awaitのキャンセル処理まとめ #C# - Qiita