Windows フォームでの安全でシンプルなマルチスレッド、パート2

 

Chris Sells

2002 年 9 月 2 日

概要: 複数のスレッドを利用して、実行時間の長い操作からユーザー インターフェイス (UI) を分割し、さらにユーザー入力をワーカー スレッドに伝達してその動作を調整する方法について説明します。これにより、堅牢で正しいマルチスレッド処理のためのメッセージ受け渡しスキームが可能になります。 (8ページ印刷)

asynchcaclpi.exe サンプル ファイルをダウンロードします

いくつかの前の列から思い出したように、Windows フォームの安全でシンプルなマルチスレッド、パート1、Windows フォーム、スレッドは、注意すれば良い結果と共に使用できます。 スレッド処理は、次の図 1 に示すように、pi を大量の数字に計算するなど、実行時間の長い操作を実行する優れた方法です。

図 1. Pi アプリケーションの数字

Windows フォームとバックグラウンド処理

最後の記事では、バックグラウンド処理のためにスレッドを直接開始する方法について説明しましたが、非同期デリゲートを使用してワーカー スレッドを開始することに決めました。 非同期デリゲートは、引数を渡すときの構文の利便性を備え、プロセス全体の共通言語ランタイムマネージド プールからスレッドを取得することで、より適切にスケーリングできます。 私たちが遭遇した唯一の本当の問題は、ワーカースレッドが進行状況をユーザーに通知しようとしたときでした。 この場合、UI コントロールを直接操作することは許可されていません (長年の Win32® UI no-no)。 代わりに、ワーカー スレッドは、 Control.Invoke または Control.BeginInvoke を使用して UI スレッドにメッセージを送信または投稿して、コントロールを所有するスレッドでコードを実行する必要があります。 これらの考慮事項により、次のコードが発生しました。

// Delegate to begin asynch calculation of pi
delegate void CalcPiDelegate(int digits);
void _calcButton_Click(object sender, EventArgs e) {
  // Begin asynch calculation of pi
  CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
  calcPi.BeginInvoke((int)_digits.Value, null, null);
}

void CalcPi(int digits) {
  StringBuilder pi = new StringBuilder("3", digits + 2);

  // Show progress
  ShowProgress(pi.ToString(), digits, 0);

  if( digits > 0 ) {
    pi.Append(".");

    for( int i = 0; i < digits; i += 9 ) {
      ...
      // Show progress
      ShowProgress(pi.ToString(), digits, i + digitCount);
    }
  }
}

// Delegate to notify UI thread of worker thread progress
delegate
void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);

void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
  // Make sure we're on the right thread
  if( _pi.InvokeRequired == false ) {
    _pi.Text = pi;
    _piProgress.Maximum = totalDigits;
    _piProgress.Value = digitsSoFar;
  }
  else {
    // Show progress synchronously
    ShowProgressDelegate showProgress =
      new ShowProgressDelegate(ShowProgress);
    this.BeginInvoke(showProgress,
      new object[] { pi, totalDigits, digitsSoFar });
  }
}

2 つのデリゲートがあることに注意してください。 1 つ目の CalcPiDelegate は、スレッド プールから割り当てられたワーカー スレッドで CalcPi に渡される引数をバンドルする際に使用します。 このデリゲートのインスタンスは、ユーザーが pi を計算することを決定したときに、イベント ハンドラーに作成されます。 作業は、 BeginInvoke を呼び出すことによってスレッド プールにキューに入れられます。 この最初のデリゲートは、UI スレッドがワーカー スレッドにメッセージを渡すために実際に使用されます。

2 番目のデリゲート ShowProgressDelegate は、UI スレッドにメッセージを返すワーカー スレッドによって使用されます。具体的には、実行時間の長い操作での進行状況の更新です。 呼び出し元を UI スレッドとのスレッド セーフな通信の詳細から保護するために、 ShowProgress メソッドは ShowProgressDelegate を使用して 、Control.BeginInvoke メソッドを介して UI スレッド上でメッセージをそれ自体に送信します。 Control.BeginInvoke は UI スレッドに対して非同期的にキューを処理し、結果を待たずに続行されます。

Canceling

この例では、世界中で注意を払わずに、ワーカーと UI スレッドの間でメッセージを送受信できます。 UI スレッドは、ワーカー スレッドが完了するのを待つ必要はありません。また、ワーカー スレッドが進行状況を伝えるので、完了時に通知を受け取る必要もありません。 同様に、ユーザーの満足を維持するために、定期的に進行状況メッセージが送信される限り、ワーカー スレッドは UI スレッドに進行状況が表示されるのを待つ必要はありません。 ただし、1 つはユーザーを満足させるものではありません。アプリケーションが実行している処理を完全に制御することはできません。 pi の計算中に UI が応答していても、1,000,001 桁の数字が必要であると判断し、誤って 1,000,000 桁のみを要求した場合、ユーザーは計算をキャンセルするオプションを引き続き望みます。 取り消しが可能な CalcPi の更新された UI を図 2 に示します。

図 2. ユーザーが実行時間の長い操作を取り消す

実行時間の長い操作に対するキャンセルの実装は、複数ステップのプロセスです。 まず、ユーザーに UI を提供する必要があります。 この場合、計算が開始された後、 Calc ボタンが [キャンセル] ボタンに変更されました。 もう 1 つの一般的な選択肢は、進行状況ダイアログです。通常は、現在の進行状況の詳細が含まれます。これには、完了した作業の割合を示す進行状況バーや 、[キャンセル ] ボタンが含まれます。

ユーザーがキャンセルを決定した場合、これはメンバー変数に示され、UI スレッドがワーカー スレッドを停止する必要があることを認識してから、ワーカー スレッド自体が認識し、進行状況の送信を停止する機会が得られるまでの間、少しの間 UI を無効にする必要があります。 この期間が無視された場合、最初のワーカー スレッドが進行状況の送信を停止する前にユーザーが別の操作を開始し、UI スレッドのジョブとして、新しいワーカー スレッドから進行状況が得られるか、またはシャットダウンする予定の古いワーカー スレッドから進行状況が得られるかを判断できる可能性があります。 UI スレッドがこのような整理を維持できるように各ワーカー スレッドに一意の ID を割り当てることは確かですが (また、複数の同時実行時間の操作に直面した場合、これを行う必要がある場合もあります)、多くの場合、UI がワーカー スレッドが停止することを認識するまでの短い時間だけ UI を一時停止する方が簡単です。 ただし、ワーカー スレッドが認識する前に。 単純な pi 計算ツールに実装することは、次に示すように、3 値列挙型を保持する必要があります。

enum CalcState {
    Pending,     // No calculation running or canceling
    Calculating, // Calculation in progress
    Canceled,    // Calculation canceled in UI but not worker
}

CalcState _state = CalcState.Pending;

ここで、現在の状態に応じて、次に示すように、 Calc ボタンを異なる方法で扱います。

void _calcButton_Click(...)  {
    // Calc button does double duty as Cancel button
    switch( _state ) {
        // Start a new calculation
        case CalcState.Pending:
            // Allow canceling
            _state = CalcState.Calculating;
            _calcButton.Text = "Cancel";

            // Asynch delegate method
            CalcPiDelegate  calcPi = new CalcPiDelegate(CalcPi);
            calcPi.BeginInvoke((int)_digits.Value, null, null);
            break;

        // Cancel a running calculation
        case CalcState.Calculating:
            _state = CalcState.Canceled;
            _calcButton.Enabled = false;
            break;

        // Shouldn't be able to press Calc button while it's canceling
        case CalcState.Canceled:
            Debug.Assert(false);
            break;
    }
}

[Calc/キャンセル] ボタンが状態でPending押されると、状態が にCalculating送信され (ボタンのラベルも変更されます)、前と同じように非同期的に計算が開始されます。 状態が Calculating[CalcCancel]\(Calc / キャンセル\) ボタンが押されたときの場合は、状態を にCanceled切り替え、キャンセルされた状態をワーカー スレッドに伝える必要がある限り、UI を無効にして新しい計算を開始します。 操作を取り消す必要があることをワーカー スレッドに伝えたら、UI をもう一度有効にし、状態を に Pending リセットして、ユーザーが別の操作を開始できるようにします。 取り消す必要があることをワーカーに伝えるために、 ShowProgress メソッドを拡張して新しい out パラメーターを含めましょう。

void ShowProgress(..., out bool cancel)

void CalcPi(int digits) {
    bool cancel = false;
    ...

    for( int i = 0; i < digits; i += 9 ) {
        ...

        // Show progress (checking for Cancel)
        ShowProgress(..., out cancel);
        if( cancel ) break;
    }
}

取り消しインジケーターを ShowProgress からのブール値にしたくなるかもしれませんが、キャンセルを意味するかどうか、またはすべてが正常または正常に続行されることを覚えていない true ので、 out パラメーターの手法を使用します。これは私でもまっすぐに保つことができます。

残しておくのは、 ShowProgress メソッドを更新することだけです。これは、ワーカー スレッドと UI スレッドの間の遷移を実際に実行するコードで、ユーザーがキャンセルを要求したかどうかを確認し、それに応じて CalcPi に通知することです。 UI とワーカー スレッドの間でその情報を伝える方法は、使用する手法によって異なります。

共有データとの通信

UI の現在の状態を伝える明らかな方法は、ワーカー スレッドが _state メンバー変数に直接アクセスできるようにすることです。 これを実現するには、次のコードを使用します。

void ShowProgress(..., out bool cancel) {
  // Don't do this!
  if( _state == CalcState.Cancel ) {
    _state = CalcState.Pending;
    cancel = true;
  }
  ...
}

私はあなたがこのコードを見たときにあなたが信じたことを願っています(警告コメントのためだけではありません)。 マルチスレッドプログラミングを行う場合は、2つのスレッドが同じデータ(この場合は_stateメンバー変数)に同時にアクセスできることをいつでもwatchする必要があります。 スレッド間のデータへの共有アクセスにより、あるスレッドが競合状態になり、別のスレッドが更新を完了する前に部分的に最新のデータのみを読み取ることができます。 共有データへの同時アクセスが機能するには、共有データの使用状況を監視して、他のスレッドがデータで動作している間、各スレッドが忍耐強く待機していることを確認する必要があります。 共有データへのアクセスを監視するために、.NET は共有オブジェクトで使用される Monitor クラスを提供し、データのロックとして機能します。C# は便利なロック ブロックでラップします。

object _stateLock = new object();

void ShowProgress(..., out bool cancel) {
  // Don't do this either!
  lock( _stateLock ) { // Monitor the lock
    if( _state == CalcState.Cancel ) {
      _state = CalcState.Pending;
      cancel = true;
    }
    ...
  }
}

これで共有データへのアクセスが適切にロックされましたが、マルチスレッド プログラミングの実行時に別の一般的な問題 (デッドロック) が発生する可能性が非常に高い方法で行いました。 2 つのスレッドがデッドロック状態になると、両方とも作業が完了するまで待ってから続行し、どちらも実際には進行しないようにします。

競合状態とデッドロックのすべての話が懸念を引き起こしている場合は、良いです。 共有データを使用したマルチスレッド プログラミングは困難です。 ここまでは、各スレッドが完全な所有権を取得するデータのコピーを渡してきたため、これらの問題を回避できました。 共有データがないと、同期は必要ありません。 共有データにアクセスする必要がある場合 (つまり、データをコピーするオーバーヘッドが大きすぎるため、スペースや時間の負担が大きすぎる場合)、スレッド間でのデータの共有について調べる必要があります (この領域で好きな学習支援の [参照] セクションチェック)。

ただし、特に UI マルチスレッドに関連するマルチスレッド シナリオの大部分は、これまで使用してきた単純なメッセージパッシング スキームで最適に動作するように見えます。 ほとんどの場合、UI がバックグラウンドで処理されているデータ (印刷中のドキュメントや列挙されるオブジェクトのコレクションなど) にアクセスできないようにします。 このような場合は、共有データを避けるのが最適な選択です。

メソッド パラメーターとの通信

Out パラメーターを含む ShowProgress メソッドは、既に拡張されています。 ShowProgress を UI スレッドで実行しているときに、_state変数の状態をチェックさせません。

void ShowProgress(..., out bool cancel) {
    // Make sure we're on the UI thread
    if( _pi.InvokeRequired == false ) {
        ...

        // Check for Cancel
        cancel = (_state == CalcState.Canceled);

        // Check for completion
        if( cancel || (digitsSoFar == totalDigits) ) {
            _state = CalcState.Pending;
            _calcButton.Text = "Calc";
            _calcButton.Enabled = true;

        }
    }
    // Transfer control to UI thread
    else { ... }
}

_STATE メンバー変数にアクセスするのは UI スレッドだけなので、同期は必要ありません。 ここでは、ShowProgressDelegatecancel out パラメーターを取得するように UI スレッドにコントロールを渡すだけで済みます。 残念ながら、 Control.BeginInvoke を使用すると、この作業が複雑になります。 問題は、 BeginInvoke が UI スレッドで ShowProgress を 呼び出した結果を待機しないため、2 つの選択肢があります。 1 つのオプションは、ShowProgress が UI スレッドから返されたときに呼び出される BeginInvoke に別のデリゲートを渡すことですが、これはスレッド プール内の別のスレッドで行われるので、同期に戻る必要があります。今度は、ワーカー スレッドの間でプールから別のスレッドが呼び出されます。 より簡単なオプションは、同期 Control.Invoke メソッドに切り替えて 、cancel out パラメーターを待機することです。 ただし、次のコードに示すように、これは少し難しいです。

void ShowProgress(..., out bool cancel) {
    if( _pi.InvokeRequired == false ) { ... }
    // Transfer control to UI thread
    else {
        ShowProgressDelegate  showProgress =
            new ShowProgressDelegate(ShowProgress);

        // Avoid boxing and losing our return value
        object inoutCancel = false;

        // Show progress synchronously (so we can check for cancel)
        Invoke(showProgress, new object[] { ..., inoutCancel});
        cancel = (bool)inoutCancel;
    }
}

単にブール変数を Control.Invoke に直接渡して cancel パラメーターを取得するのが理想的ですが、問題があります。 問題は 、bool値データ型であるのに対し、 Invoke はオブジェクトの配列をパラメーターとして受け取り、オブジェクトは 参照データ型です。 違いを説明する書籍の [参照] セクションを確認しますが、アップショットは、オブジェクトとして渡された ブール がコピーされ、実際の ブール 値は変更されず、操作がいつキャンセルされたかはわかりません。 この状況を回避するために、独自のオブジェクト変数 (inoutCancel) を作成し、代わりにコピーを回避して渡します。 Invoke の同期呼び出しの後、オブジェクト変数を bool にキャストして、操作を取り消す必要があるかどうかを確認します。

値と参照型の区別は、intbool などのプリミティブ型、列挙型、構造体などの値型である out パラメーターまたは ref パラメーターを使用して Control.Invoke (または Control.BeginInvoke) を呼び出すたびに、watchする必要があります。 ただし、カスタム参照型 aka クラスとして渡されるより複雑なデータがある場合は、作業を行うために特別な操作を行う必要はありません。 しかし、BeginInvokeを呼び出して/値型を処理する不愉快さでさえ、競合状態/デッドロックのない方法で共有データにアクセスするためのマルチスレッドコードを取得することに比べて薄いので、私はそれを支払う小さな価格だと考えています。

まとめ

もう一度、一見簡単な例を使用して、いくつかの複雑な問題を調査しました。 複数のスレッドを利用して、実行時間の長い操作から UI を分割するだけでなく、さらにユーザー入力をワーカー スレッドに伝達して動作を調整しました。 共有データを使用することもできますが、同期の複雑さを回避するために (上司がコードを試みる場合にのみ発生します)、堅牢で正しいマルチスレッド処理のためのメッセージパッシングスキームにとどまっています。

リファレンス