C#でマルチスレッド(Task.Run , async , await , Lock)

WPFアプリ(C#)でTask.Runを使用して別スレッドで処理をする方法と、その処理経過を画面のプログレスバーに表示するための方法をまとめてサンプルソフトを作りました。
サンプルソフトはPrismを使用したMVVMスタイルのWPFアプリです。
[Visual Studio 2017、Prism7.1、.NET4.5.2]

概要

WPFアプリ(C#)で重い処理を行うと画面がフリーズしてしまいます。
その為、スレッドを長時間占有してしまうような処理は別スレッドで行う必要があります。しかしながら、画面のオブジェクトには別スレッドからアクセスができない為、プログレスバー等で進捗を表示しようとした場合には Dispatcher.BeginInvoke を使うという面倒な処理が必要になります。
スレッドの起動から画面部品へのアクセスまでを簡略化するために BackgroundWorker がありましたが、最近ではTaskを使う方法が主流の様です。
Taskにはスレッドの起動と途中結果を元のスレッドに戻す方法 IProgress(これで画面のオブジェクトにアクセスできます)、そして処理を途中で中断する為の方法、処理が終わるまで起動元の処理を待たせておく方法が備わっています。
とはいえ、MVVMスタイルでバインディングを介して画面のオブジェクトを更新すれば、このような面倒なことは生じないですし、コードビハインドを使ってアニメーション処理をする場合でも、専用の仕組みが組み込まれているので、IProgress が必要な機会はあまりないのかもしれません。

スレッド起動(Task.Run)

下記の様に書くだけで Task1( ) メソッドを別スレッドで実行する事ができます。
別スレッドでの実行になるので、このスレッドはすぐに次の行の実行に移ることになります。
ですので、同時に2つのスレッドが動くことになります。

Task.Run(() => Task1());

タスク終了待ち(async & await)

別スレッドで処理を実行したいが、その終了を待ってから次に移りたいという時には、Task.Run の前に await を付けます。
これで Task1( ) の終了を待ってから次の行に移ります。
await を使ったメソッドには、内部に await があるという印である async を付ける必要があります。

async void ExecuteCommandTask1()
{
    await Task.Run(() => Task1());
    //Task1()終了後ここに来る        
}

画面部品へのアクセスと処理キャンセルの方法(IProgress , CancellationToken)

画面の部品へのアクセスは別スレッドからは出来ない事になっているので、Task.Run で起動したメソッドから直接操作する事ができません。
なので、Task1の引数に、処理中に実行してほしいメソッドと、キャンセル用のトークンを追加して、Task1(IProgress p, CancellationToken cancelToken) とします。
このような書き方は、コードビハインドで直接画面の部品を操作する場合に必要となります。
MVVMスタイルで、コードビハインド以外で処理して画面の部品とバインディングで繋ぐ場合には、必要ありません。

private CancellationTokenSource cancelTokensource1;

別スレッドで実行したいメソッド

int percent = 0;

public bool Task1(IProgress<int> p, CancellationToken cancelToken)
{
    bool ret = true;
    lock(lockObj)
    {
        while (percent < 100)
        {
            //ダミー負荷用ウエイトms スレッドを止める
            Thread.Sleep(30);
            //状況の報告
            percent++;
            p.Report(percent);
            //キャンセルリクエストの確認
            if (cancelToken.IsCancellationRequested)
            {
                ret = false;
                break;
            }
        }
    }
    return ret;
}

途中経過を表示するためのメソッドは次のような感じです。
Text.Box を操作するので別スレッドから実行すると例外が生じてアプリが落ちてしまいます。

public void SetText(int percent)
{
    textBox1.Text = percent.ToString();
    progressBar1.Value = percent;
}

Task1実行用のボタンクリック処理は次のように、途中経過表示用の処理とキャンセル用のトークンを引数としてTask1を起動します。

private async void Task1_button_Click(object sender, RoutedEventArgs e)
{
    var p = new Progress<int>(SetText);
    cancelTokensource1 = new CancellationTokenSource();
    var cToken = cancelTokensource1.Token;
    bool ret = await Task.Run(() => Task1(p, cToken));
    cancelTokensource1.Dispose();
    cancelTokensource1 = null;
}

Task1キャンセル用のボタン処理で、cancelTokensource1.Cancel( )とするとTask1の処理ループの中の if 文で拾う事ができます。

private void Task1_cancel_button_Click(object sender, RoutedEventArgs e)
{
    if (cancelTokensource1 != null) cancelTokensource1.Cancel();
}

排他処理(Lock)

マルチスレッドで処理をすると、同じものに対して同時に処理が進むと困る事があります。
その時には、他のスレッドに待ってもらう為の lock 機能があります。
Object のインスタンスを作り lock(lockObj){処理}とすることで、同じObjectを持つ処理は一度に一つしか実行できないように排他処理がなされます。

Object lockObj = new Object();
lock(lockObj)
{
    //処理
}

コマンドボタンの有効/無効

ボタンを押した時の処理を別スレッドで実行すると、実行が終わらなくても直ぐに再度ボタンを押すことが可能になります。
通常その様なことは好ましくない為、ボタンを無効化する事が必要となります。
コードビハインドで処理する時には、ボタンの IsEnabled を false にすることで無効化できます。

task1_button.IsEnabled = false;

MVVMスタイルで、ViewModel から処理する時には、CanExecuteCommand の戻り値を false にすることで無効化できます。
状態が変化したことを知らせるためには、RaiseCanExecuteChanged() を実行する必要があります。

private DelegateCommand commandTask1;
public DelegateCommand CommandTask1 =>
    commandTask1 ?? (commandTask1 = 
        new DelegateCommand(ExecuteCommandTask1, CanExecuteCommandTask1));

async void ExecuteCommandTask1()
{
    Task1run = true;
    await Task.Run(() => Task1());
    Task1run = false;
}

bool CanExecuteCommandTask1()
{
    return !Task1run;
}

RaiseCanExecuteChanged() はプロパティー内で実行すると簡単です。

private bool task1run = false;
public bool Task1run
{
    get { return task1run; }
    set { SetProperty(ref task1run, value);
        CommandTask1.RaiseCanExecuteChanged();
    }
}

サンプルアプリの説明

ソースコードはここに置いてあります。
github.com
実行すると下記のようなアプリケーションが起動します。
f:id:feynman911:20190328223513j:plain

Task1 は、値を1プラスする処理、Task2 は値を1マイナスする処理です。
この処理が同時に実行されると 喧嘩してしまうので、Lockで一度に動かないようにしています。
View と書かれている領域の Task1,Task2 はコードビハインドでの処理、ViewModel の Task1, Task2 は ViewModel で処理され、View にバインドされています。
以上、簡単にまとめてみました。

[おまけ]
どのスレッドで実行されているかを見たい時には、下記のように書いてスレッド番号を取得する事ができます。
int threadId = System.Threading.Thread.CurrentThread.ManagedThreadId;