奇想曲 in C#

2010-08-07

BackgroundWorkerの再利用

BackgroundWorkerクラスは、Threadを明示的に使用せずに非同期タスクを記述できる便利なコンポーネントであって、想定されるパターンに従って普通に使う限りにおいては特に困ることはないのだが、裏には複雑なスレッドモデルインフラストラクチャが隠されており、時にそれが表面化することがある。


例えば次のようにボタン(button1)とRichTextBoxを1個づつ持っているFormがあって、

  • ボタンを押すと何か外部のコンポーネントがStartする。
  • そのコンポーネントからStream経由で非同期にデータが上がってくる。
  • BackgroundWorkerを用いてそのStreamからのデータ読み込みとRichTextBoxへの表示を行う。
  • ボタンを再度押すと、現在のStreamは捨て、別の外部コンポーネントをStartして同様に非同期データのRichTextBoxへの表示を行う。このとき、BackgroundWorkerを新たに作るのは無駄なので、一旦Cancelして再起動させる

というアプリケーションを考える。

f:id:toshi_m:20100807002734j:image:w300

この仕様であれば、button1のClickハンドラは次のように書くことになる。(説明を簡単にするため、Streamの代わりにQueue<string>を用いている。)

void button1_Click(object sender, EventArgs e)
{
    // BackgroundWorkerを停止.
    this.backgroundWorker1.CancelAsync();
    // データの受け渡し用のQueue.
    this._queue = new Queue<string>();
    // BackgroundWorkerを再起動する.
    this.backgroundWorker1.RunWorkerAsync();
    // データを送ってくる外部コンポーネントをエミュレートするThread.
    ThreadPool.QueueUserWorkItem(SenderProc, this._queue);
}

しかし、これを動かすと、RunWorkerAsync()の所でInvalidOperationException (

BackgroundWorker は現在ビジー状態であるため、複数のタスクを同時に実行できません。)といわれてしまう。CancelAsync()は、BackgroundWorkerをすぐに再利用できる状態にはしてくれないのである。

それではというので、BackgroundWorkerのIsBusyプロパティを探し当てて次のように書いたとする。

void button1_Click(object sender, EventArgs e)
{
    // BackgroundWorkerを停止.
    this.backgroundWorker1.CancelAsync();
    while (this.backgroundWorker1.IsBusy)
    {
        Thread.Sleep(1);
    }
    // データの受け渡し用のQueue.
    this._queue = new Queue<string>();
    // BackgroundWorkerを再起動する.
    this.backgroundWorker1.RunWorkerAsync();
    // データを送ってくる外部コンポーネントをエミュレートするThread.
    new Thread(SenderProc).Start(this._queue);
}

しかし、意に反してIsBusyはいつまで待ってもfalseにならない。結果アプリケーションはフリーズしてしまう。


このあたりでWebをあたることになるだろう。色々な情報がある。

  • Thread.Sleep()ではなく、Application.DoEvents()を呼べばよい
  • RunWorkerCompletedイベントのハンドラでBackgroundWorkerを再開すればよい
  • そもそもIsBusyでBackgroundWorkerの状態をチェックしてはいけない

確かにIsBusyがクリアされるまでDoEvents()を何度か呼ぶことで問題は解消する。しかし理由がはっきりしない。DoEvents()はどうも機能範囲が明確でない(メッセージポンプを処理する以上のことをやっている)ので、あまり頼りたくはない。また、RunWorkerCompletedで処理を分けるのはロジックが煩雑になるのでできればやりたくない。


このモヤモヤをはっきりさせるには、BackgroundWorkerの裏で実際のスレッド処理を行っているAsyncOperationクラスや、さらにそこで使用されているスレッドモデルを表現するSynchronizationContextクラスについて理解するのが最もよいのだが、それらの説明はJ. Richterの"CLR via C# 3rd Edition"などの本に任せるとして(ただしリクター自身はBackgroundWorkerのようなEvent-based Asynchronous Pattern全般を嫌っている)、ここではより直接的な説明を試みたい。


まず、RunWorkerAsync()メソッドがExceptionを発生する直接の原因は、内部状態である実行中フラグ(IsBusyでアクセスできる)がOnであるためであり、この点でIsBusyをチェックするのは何も悪いことではない。

次にCancelAsync()が何を行うかであるが、WinFormアプリケーションの場合は、実行中フラグをOffにするコールバックをUI ThreadのキューにPostする

これではっきりするであろう。上のプログラムのボタンClickハンドラは、UI Threadから呼ばれて、その中でCancelAsync()とRunWorkerAsync()をメッセージポンプに制御を返すことなく連続で呼び出している。このため、Postされたコールバックはこの中では動作する機会が与えられない。いつまでたってもIsBusyフラグがクリアされないわけである。

さらに、DoEvents()は強制的にメッセージポンプを回してくれるので、コールバックが呼ばれIsBusyはクリアされる。つまり、ここでのDoEvents()の使用には正当な理由があるといえる。


BackgroundWorkerは、AsyncOperationの助けを借りることにより、WinForm以外のさまざまなスレッドモデルに対応できるようになっている(例えばコンソールアプリケーション、WPFアプリケーションなど)。

WinFormで使う場合はこのように(マニュアルにはないが)適切な粒度でUI Threadに制御が戻ることが前提とされているので、今回取り上げた例で何が悪かったかと問うならば、「仕様とBackgroundWorkerの相性が悪い」ということができると思う。

つまり、ボタンに状態を持たせてStart後はStop動作をさせるようにするとか、もうひとつStopボタンを設けて役割を分けるとかの仕様の方が明らかにBackgroundWorkerの設計に適していると考えられる。


とは言ってもどうしても最初のような仕様にしなければならない場合もあるかもしれない。例えば外部コンポーネントがいつ送信をやめるかプログラム的にはわからないのだが、ユーザーはデータが止まったことで暗黙的に止まったとみなしている、という状況を考えると、ユーザーに改めてStopボタンを押させるのでは操作感が悪くなってしまうだろう。このような場合は堂々とDoEvents()を用いればよいし、さもなければ、BackgroundWorkerをやめてThreadを直接制御してもたいした手間ではない(さらなる別解は次の記事を参照)。

Script EngineとそのコンソールをFormで作る場合などによくこのような状況になる。Engine-Consoleパターンと名付けたいくらいである。


さて、これで解決としたいところであるが、まだ引っかかる点がある。先に書いたようにBackgroundWorkerはConsoleのスレッドモデルもサポートしている。しかしConsoleではUI Threadなどは存在しない。この場合はどうなるのであろうか。

答えは、同じ(動作中フラグをOffにする)コールバックをThreadPoolを用いてキューイングする、というものである。

ThreadPoolなのでこちらは譲って待っていればすぐにIsBusyフラグは解除される。


なお、ProgressChangedイベントも同じ方式でキューイングされるので、ThreadPoolを用いる関係上、コンソールアプリではその順序が保持されない可能性がある(スレッドのスケジューリングに従う)。

WinFormアプリではキューが直列化されるので順序は保持される。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証

Connection: close