C Sharpの基礎 - マルチスレッドとGUI

提供:MochiuWiki : SUSE, EC, PCB
ナビゲーションに移動 検索に移動

概要

マルチスレッドにおいて、GUIコントロールを操作することは重要なテーマの1つであり、操作しなければならないケースは次の2つだと考えられる。

  • 処理結果をGUIコントロールに反映する
  • 処理を中止する


処理結果をGUIコントロールに反映するとは、結果をテキストボックスに表示する、または、処理の進捗をプログレスバーに表示する等である。
処理を中止するとは、処理対象のデータに不都合があって処理が不要になる、または、予想より処理時間が長くなり途中で中止する等である。
この場合、キャンセルボタンを押下することで中止の意図をアプリケーションに通知することになる。

処理結果をテキストボックスに表示する

下記のサンプルコードは、ボタンを押下するとTask.Runで並列処理した結果をテキストボックスに表示するプログラムの例である。
ポイントは、TaskSchedulerクラスの静的なFromCurrentSynchronizationContextメソッドを呼び出して、
GUIスレッドに割り当てられたデフォルトのタスクスケジューラを取得する。
GUIコントロール(この場合はテキストボックス)にアクセスするところでGUIスレッド上で動作するように指定する。

 private async void buttonl_Click(object sender, RoutedEventArgs e)
 {
    // UI スレッドのデフォルトのタスクスケジューラ
    var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
 
    textBox.Text = String.Format("UI ThreadId= {0}\r\n", Thread.CurrentThread.ManagedThreadId);
 
    await Task.Run(() =>
    {
       string s = String.Format("index= {0}, ThreadId= {1}\r\n", i, Thread.CurrentThread.ManagedThreadId);

       return s;  // タスクの戻り値
    })
    .ContinueWith(t =>
    {
       textBox.Text += t.Result + "\r\n";
       textBox.Text += String.Format("UI ThreadId= {0}\r\n", Thread.CurrentThread.ManagedThreadId);
    }, taskScheduler); // UI スレッド上で動作することを指定する
 }



Invokeメソッドを使う

コントロールのInvokeメソッドを使用する場合、呼び出し元のオブジェクトのスレッドにおいてデリゲートを実行することができる。

 private async void buttonl_Click(object sender, RoutedEventArgs e)
 {
    textBox.Text = String.Format("UI ThreadId= {0}\r\n", Thread.CurrentThread.ManagedThreadId);
 
    await Task.Run(() =>
    {
       string s = String.Format("index= {0}, ThreadId= {1}\r\n", i, Thread.CurrentThread.ManagedThreadId);
 
       textBox.Invoke(new Action(() => // ← この行に注目
       {
          textBox.Text += s;
       }));
    });
 }


また、コントロールのInvokeメソッドとInvokeRequiredプロパティを組み合わせて使用することもできる。

  • Invokeメソッド
    指定された処理をそのコントロールが生成されたスレッドで実行する。
    多少オーバーヘッドが発生する。
  • InvokeRequiredプロパティ
    コントロールにアクセスする時、Invokeメソッドを使用する必要があるかどうかを判断する。
 // 必要に応じてInvokeするコード
 public partial class Form1 : Form
 {
    // ...略
 
    private async void buttonl_Click(object sender, RoutedEventArgs e)
    {
       InvokeIfRequired(button1, () => button1.Enabled = false);
       InvokeIfRequired(button1, () => button1.Enabled = true);
    }
 
    private void InvokeIfRequired(Control control, Action action)
    {
       if (control.InvokeRequired)
       {
          control.Invoke(action, new object[] { });
       }
       else
       {
          action();
       }
    }
 }


また、以下のようなコントロールに対する拡張メソッドとして定義することにより、簡潔に記述できるようになる。

 public partial class Form1 : Form
 {
    // ...略
 
    private async void buttonl_Click(object sender, RoutedEventArgs e)
    {
       button1.InvokeIfRequired(() => button1.Enabled = false);
       button1.InvokeIfRequired(() => button1.Enabled = true);
    }
 
    // ...略
 }
 
 // コントロールに対する拡張メソッド
 public static class ControlExtensions
 {
    // 戻り値が不要な場合
    public static void InvokeIfRequired(this Control control, Action action)
    {
       if (control.InvokeRequired)
       {
          control.Invoke(action, new object[] { });
       }
       else
       {
          action();
       }
    }
 
    // 戻り値が必要な場合
    public static T InvokeIfRequired<T>(this Control control, Func<T> func)
    {
       if (control.InvokeRequired)
       {
          return (T)control.Invoke(func, new object[] { });
       }
       else
       {
          return func();
       }
    }
 }



処理の進捗をプログレスバーに表示する

以下のサンプルコードは、ボタンを押下するとプログレスバーを進捗させる。
可読性を考慮して、実際に並列処理するコードなどは含めていない。
ポイントは、TaskSchedulerクラスのFromCurrentSynchronizationContextメソッドを呼び出して、
GUIスレッドに割り当てられたデフォルトのタスクスクジューラを取得して、タスクを作成するときのオプション設定に使用する。
また、進捗の対象とするループ内の子タスクとしてプログレスバーを進捗させるタスクを設定する。

 private void button1_Click(object sender, RoutedEventArgs e)
 {
    // UIスレッドへのスケジュール用
    var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();

    // プログレスバーの設定
    progressBar.Visibility = Visibility.Visible;
    progressBar.Minimum = 0;
    progressBar.Maximum = 100;
    progressBar.Value = 0;
 
    var task = Task.Run(() =>
    {
       Parallel.For(0, 100, i =>
       {
          // プログレスバーを進捗させるためのタスクをForループ内におく
          Task.Run(() =>
          {
             progressBar.Value += 1;
             System.Windows.Forms.Application.DoEvents(); // プログレスバーの進捗を表示するため
          }, CancellationToken.None, TaskCreationOptions.None, taskScheduler);
       });
    });
 
    // 継続元のタスクが終了したあとの処理
    var continueTask = task.ContinueWith(t =>
    {
       progressBar.Visibility = Visibility.Hidden;
       progressBar.Value = 0;
    }, taskScheduler);
 }



マルチスレッドのキャンセル処理

 private void btnStart_Click(object sender, RoutedEventArgs e)
 {
    source = new CancellationTokenSource();
    CancellationToken token = source.Token;
 
    // タスクをキャンセルしたときのアクションを登録
    token.Register(() =>
    {
       progressBar.Visibility = Visibility.Hidden;
       progressBar.Value = 0;
       btnCancel.Enabled = false;
       textBox.Text = "キャンセルされました";
    });
 
    // UIスレッドへのスケジュール用
    var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();

    btnCancel.Enabled = true;

    // プログレスバー設定
    progressBar.Visibility = Visibility.Visible;
    progressBar.Minimum = 0;
    progressBar.Maximum = 100;
    progressBar.Value = 0;
 
    var task = Task.Run(() =>
    {
       // キャンセルが要求されていたら、OperationCanceledException例外を発生させる
       token.ThrowIfCancellationRequested();
 
       Parallel.For(0, 100, (n) =>
       {
          Thread.Sleep(200); // n を使う時間のかかる処理のかわり
 
          // プログレスバーを進捗させるためのタスクを For ループ内におく
          var progressTask = Task.Run(() =>
          {
             progressBar.Value += 1;
             System.Windows.Forms.Application.DoEvents(); // プログレスバーの進捗を表示するため
          }, token, TaskCreationOptions.None, taskScheduler);

          try
          {
             await progressTask;  // progressTaskの実行終了を待つ
          }
          catch (AggregateException ae)
          {
             ae.Handle((x) =>
             {
                return true;
             });
          }
       });
    });
 
    // 継続元のタスクが正常に終了したあとの処理
    var continueTask = task.ContinueWith(t =>
    {
       progressBar.Visibility = Visibility.Hidden;
       progressBar.Value = 0;
       btnCancel.Enabled = false;
       textBox.Text = "正常終了しました。";
    }, taskScheduler);
 }
 
 private void btnCancel_Click(object sender, RoutedEventArgs e)
 {
    if (source == null)
    {
       return;
    }
 
    source.Cancel();
 }


以下の例では、DoWorkメソッドの引数にCancellationToken構造体を指定して、
メソッド内でIsCancellationRequestedプロパティまたはThrowIfCancellationRequestedメソッドを使用して、キャンセル処理をしている。

ThrowIfCancellationRequestedメソッドを使用してキャンセルする場合、非同期メソッドのTaskの状態がCanceledの状態になるため、キャンセルされたということが明確になる。(推奨)

await処理のメソッド内でキャンセルする場合、await処理のメソッド内にも再帰的にCancellationToken構造体を渡す。
各非同期メソッドに渡すCancellationToken構造体は、基本的にCancellationTokenSourceクラスから生成する。
CancellationTokenSourceクラスのCancelメソッドを呼ぶことにより、CancellationTokenオブジェクトがCanceledの状態になる。

一般的な非同期ライブラリのAPIであれば、時間を要する可能性がある処理にはCancellationToken構造体を受け入れるオーバーロードが存在する。

 private CancellationTokenSource cts = null;
 
 private async void button_Click(object sender, EventArgs e)
 {
    cts = new CancellationTokenSource();  // CancellationTokenSourceクラスのインスタンスを生成
    int intValue = await DoWork(this.cts.Token);  // CancellationTokenSourceオブジェクトからCancellationTokenを取り出して渡す
 }
 
 private async Task<int> DoWork(CancellationToken token)
 {
    await DoHeavyWork(token);  // 時間の掛かる処理にもCancellationToken構造体を渡す
 
    // キャンセル処理 (IsCancellationRequestedプロパティを使用する場合)
    if (token.IsCancellationRequested)
    {
       return 1;
    }
    // キャンセル処理 (ThrowIfCancellationRequestedメソッドを使用する場合)
    // これは、キャンセルが要求されている場合、例外OperationCanceledExceptionを発生させる
    //token.ThrowIfCancellationRequested();
 
    return 0;
 }
  
 // キャンセルボタン押下
 private void btnCancel_Click(object sender, RoutedEventArgs e)
 {
    if (this.cts == null)
    {
       return;
    }
 
    this.cts.Cancel();  // Cancelメソッドを実行した時、非同期メソッドであるDoWorkメソッドがキャンセルされる
 }



マルチスレッドとプログレスバー

マルチスレッドの実行は、asyncおよびawaitを使用して簡潔に記述できる。
ただし、マルチスレッド処理ではコントロール(ユーザインターフェイス)を直接操作できない。(例外が発生する)

マルチスレッド内でプログレスバー(Progressクラス)を制御する場合、まず、Progressオブジェクトに進捗を表示するためのメソッドを登録する。
マルチスレッドのメソッドの引数には、IProgressインターフェース(System名前空間)を追加する。

マルチスレッド処理内でProgressオブジェクトのReportメソッドを呼ぶと、Progressオブジェクトに登録されているメソッドがUIスレッドで実行される。

また、マルチスレッド処理内でのキャンセル処理も進捗表示と似た方法で行う。
なお、キャンセル処理はCancellationToken構造体(System.Threading名前空間)を使用するが、上記の進捗表示より複雑になることに注意する。

 // 方法 1
 
 private async void button2_Click(object sender, EventArgs e)
 {
    DisableAllButtons();
    toolStripStatusLabel1.Text = "処理中…";
    toolStripProgressBar1.Value = 0;

    // Progressクラスのインスタンスを生成
    var p = new Progress<int>(ShowProgress);
 
    // 時間のかかる処理を別スレッドで開始
    string result = await Task.Run(() => DoWork(p, 100));
 
    // 処理結果の表示
    toolStripStatusLabel1.Text = result;
    toolStripProgressBar1.Value = 100;
    MessageBox.Show("正常に完了");
 
    EableAllButtons();
 }
 
 // 進捗を表示するメソッド(UIスレッドで呼び出される)
 private void ShowProgress(int percent)
 {
    toolStripStatusLabel1.Text = percent + "% 完了";
    toolStripProgressBar1.Value = percent;
 }
 
 // 時間のかかる処理を行うメソッド(進捗付き)
 private string DoWork(IProgress<int> progress, int n)
 {
    // 別スレッドで実行されるため、このメソッドではUI(コントロール)を操作してはいけない
 
    // 時間のかかる処理
    for (int i = 1; i <= n; i++)
    {
       System.Threading.Thread.Sleep(100);
 
       int percentage = i * 100 / n; // 進捗率
       progress.Report(percentage);
    }
 
    return "全て完了";
 }
 
 private void DisableAllButtons()
 {
    button1.Enabled = false;
    button2.Enabled = false;
 }

 private void EableAllButtons()
 {
    button1.Enabled = true;
    button2.Enabled = true;
 }


 // 方法 2 (Invokeメソッドの使用)
 
 private async void button2_Click(object sender, EventArgs e)
 {
    DisableAllButtons();
    toolStripStatusLabel1.Text = "処理中…";
    toolStripProgressBar1.Value = 0;

    // 時間のかかる処理を別スレッドで開始
    string result = await DoWorkAsync(100);
 
    // 処理結果の表示
    toolStripStatusLabel1.Text = result;
    toolStripProgressBar1.Value = 100;
    MessageBox.Show("正常に完了");
 
    EableAllButtons();
 }
 
 // 時間のかかる処理を行うメソッド(進捗付き)
 private async Task<string> DoWorkAsync(int n)
 {
    // 時間のかかる処理
    for (int i = 1; i <= n; i++)
    {
       await Task.Delay(100);
 
       int percentage = i * 100 / n; // 進捗率
 
       // 進捗メッセージの変更
       toolStripStatusLabel1.Invoke(new Action(() =>
       {
          toolStripStatusLabel1.Text  = percentage + "% 完了";
       }));
 
       // プログレスバーの進捗の変更
       toolStripProgressBar1.Invoke(new Action(() =>
       {
          toolStripProgressBar1.Value = percentage;
       }));
    }
 
    return "全て完了";
 }
 
 private void DisableAllButtons()
 {
    button1.Enabled = false;
    button2.Enabled = false;
 }

 private void EableAllButtons()
 {
    button1.Enabled = true;
    button2.Enabled = true;
 }