Dispatcher とか Queue とかメッセージループとか
GUIのコードを書いていると、時間のかかる処理をやりたくなることは多々あると思うんですが、それを普通に実行しちゃうとGUIが固まりますよね。
で、C#には、Taskという比較的簡単に使える道具がありまして、例えば、適当なスレッドで実行して、その結果だけを非同期で待つって感じのコードなら、
var result = await Task.Run(() => SomeHeavyCalculation());
という感じで、長時間ブロックしちゃう SomeHeavyCalculation なんて処理も簡単にバックグラウンドで実行出来ちゃいます。まぁ、単発の処理ならこれでほとんど解決。何にも問題なし。
ところが、次のような何らかのオブジェクトに対して処理を行う場合にはいろいろと注意が必要になります。
SomeSharedObject obj;
async Task ProcessAsync()
{
// これ、安全かなぁ?
await Task.Run(() => obj.SomeHeavyCalculation());
}
何が危険なのかといえば、このオブジェクトが、スレッドセーフ(複数のスレッド間で共有してOKかどうか)がどうかが怪しいかもしれないからです。
Task.Run は、その時その時で、スレッドプール(いくつかのスレッドをあらかじめ作っておいて、いつでもすぐ使えるようにしておく)内のいずれかのスレッドで実行を開始する感じなので、obj.SomeHeavyCalculation()はいつも同じスレッドで実行されるとも限りませんし、また、await中は、処理が別のメソッドに明け渡されるので、ProcessAsync中に、またProcessAsyncが呼ばれることは普通にあり得ます。つまり、二つのobj.SomeHeavyCalculation()が同時に動いてしまう可能性もあります。
これはヤバい。下手すると一発クラッシュ。そうじゃなくても、データが破壊される可能性があります。
これを適当に解決しようとすると、例えば、
SomeSharedObject obj; async Task ProcessAsync() { await Task.Run(() => { // obj へのアクセスを排他的にする(一度に一つしか実行できない) lock (obj) { obj.SomeHeavyCalculation(); } }); }
みたいなコードを書く羽目になる。lock(モニター)を使って、この処理が同時に複数個動作しないようにしちゃうわけです。
まぁ、これが一か所なら別に大した問題にもならないでしょう。
ただ、この場合、あまりにも多くのProcessAsyncが呼ばれると、スレッドプール内のスレッドの枯渇という恐ろしい事態が待っている可能性があります。スレッドプールにいくつのスレッドが入っているかは環境依存ですが、例えば、16個しかスレッドがなかった場合、この関数が短い間に16回呼ばれると、すべてのスレッドを使い果たしてしまい、他の処理がまったく実行されないという事態に陥る可能性があります。
また、別の側面で考えると、この処理は、同時には一つしか動かないわけで、普通に考えて、スレッドの無駄遣いにもなるでしょう。どう考えてももったいない。
ディスパッチャー (Dispatcher)
さて、こういう問題を解決するには、昔ながらの定石があります。
それは、キュー(FIFO:First-In,First-Out)とか、イベントループとか、ディスパッチャーとかと言われるものです。
仕組みは簡単です。仕事をお願いする方は、キューにお願いしたい仕事を投入して、仕事を請け負う側は、キューに入っている仕事を古いものから順番に処理していくっていう、とってもローテクな方法です。
基本的に、Windowsにしろ、macOSにしろ、iOSにしろ、現代的なGUIのシステムは、GUI処理用に、このキューを持っていて、それをメッセージループだの、イベントループだの、あるいは、COMだと、STA(Single Thread Apartment)とか、そういう名前で呼んでいます。GUIは、基本的には、他のプログラムから投げられたメッセージを一つずつ解析して処理していくことを繰り返すだけです。
そのため、当然、一つのメッセージの処理に時間がかかったり、あるいは、このメッセージ処理の流れをせき止められたりすれば、GUIは固まってしまいます。最初に書いた話ですね。
じゃぁ、なんでそれが便利なのか。
上に書いた、別々のスレッドで同時に動かされてしまうかもしれない問題が解決するからです。一つずつしか処理しないので、当然ながら、同時に処理が動くなんてことはありません。
要は、先ほどの例でいえば、SomeSharedObject に対する処理を専任で行うディスパッチャーを一つ作ってしまえばいいんですね。作りましょう。それ。
で、実はこの手のものは、さっきも書いたとおり、OSが既に実装しています。Windowsなら、メッセージループがそれですし、WPFならば、Dispatcher というそのものズバリの名前のものがあります。
WPF の Dispatcher を使って実装してみる
要は、専用のスレッドを一つ立てて、そこに Dispatcher を置いておけば、さっきの SomeSharedObject に対する処理は全部、それでやれちゃうよねっていう感じです。
で、いきなり、SomeSharedObjectの実装の話になっちゃいますが、SomeHeavyCalculationを直接呼べないようにして、それをディスパッチャー経由で実行するのを、awaitできるようにしちゃいます。
class SomeSharedObject { Dispatcher dispatcher; public SomeSharedObject() { ... // スレッドを起動して、そこで dispatcher を実行する var dispatcherSource = new TaskCompletionSource<Dispatcher>(); var thread = new Thread(new ThreadStart(() => { dispatcherSource.SetResult(Dispatcher.CurrentDispatcher); Dispatcher.Run(); })); thread.Start(); dispatcher = dispatcherSource.Task.Result; // メンバ変数に dispatcher を保存 } // 重い処理: 外からは直接呼べないようにしちゃう private void SomeHeavyCalculation() {...} // 外の人にはこっちを呼び出してもらおう public async Task SomeHeavyCalculationAsync() { await dispatcher.InvokeAsync(() => SomeHeavyCalculation()); } }
これで、使う人は、余計なスレッドとか考えなくてよくなりました。SomeHeavyCalculationAsync を await するだけで良いので、かなり簡単です。
内部的にも、SomeHeavyCalculationを無茶な呼ばれ方をしたりしなくなるので、心配事がかなり減りました。SomeHeavyCalculationは、必ず、単独でしか実行されません。スレッドの競合とか、面倒な話を考えなくて良くなりました。
みんな幸せ。
WPFのDispatcherの闇
まぁ、このコードには、ほとんど説明してないコンストラクタのコードにヤバい部分があります。
WPFのDispatcherの設計ミスとも言えるのですが、スレッド内で実行する Dispatcher を確実にメンバ変数に保存する部分に闇があります。 Task.Result とかいうあんまり使わない方が良いヤバい奴がいます。詳しくは「ConfigureAwait」とかで調べてみてくださいな。TaskCompletionSourceっていう謎の奴が出てきていますが、こちらもどうしても知りたければ調べてください。
このコンストラクタは、なるべく、await を使ってない、プログラムの最初の方で呼び出してね。さもなくば、プログラムが固まっちゃう(デッドロックしちゃう)っていう問題があるんです。悩ましい。
そして、もう一つ、特大級の闇があります。
WPFのプログラムは、そのプログラム内で実行しているすべての Dispatcher が完全に停止するまで、終了しません。つまり、この Dispatcher を殺さないと、プログラムを[X]ボタンとかで終了しようとしても終了できないのです。なんということだ・・・。
こちらには有効な解決策があります。簡単です。GUIのスレッドというか、フォアグラウンド・ディスパッチャーというか、呼び出し元の表で動いているDispatcherを監視して、そいつが終了するタイミングで、こちらのDispatcherも終了すればいいだけです。
これは、次のようにコンストラクターに書き足せばいいだけです。
class SomeSharedObject { Dispatcher dispatcher; public SomeSharedObject() { ... // スレッドを起動して、そこで dispatcher を実行する var dispatcherSource = new TaskCompletionSource<Dispatcher>(); var thread = new Thread(new ThreadStart(() => { dispatcherSource.SetResult(Dispatcher.CurrentDispatcher); Dispatcher.Run(); })); thread.Start(); dispatcher = dispatcherSource.Task.Result; // メンバ変数に dispatcher を保存 // 表のディスパッチャーが終了するタイミングで、こちらのディスパッチャーも終了する Dispatcher.CurrentDispatcher.ShutdownStarted += (s, e) => dispatcher.BeginInvokeShutdown(DispatcherPriority.Normal); } ... }
iOS/macOSのGrand Central Dispatch (GCD)
iOS/macOSにも、Grand Central Dispatchといわれる同様の仕組みがあります。なんか、仰々しい名前ですが、やってることはまるで同じです。こちらの方がWPFよりも便利なのは、スレッド込みで作りこまれているものが用意されていることです。
class SomeSharedObject { DispatchQueue dispatcher = new DispatchQueue("SomeSharedObjectQueue"); ... // 重い処理: 外からは直接呼べないようにしちゃう private void SomeHeavyCalculation() {...} // 外の人にはこっちを呼び出してもらおう public async Task SomeHeavyCalculationAsync() { await InvokeAsync(() => SomeHeavyCalculation()); } // DispatchQueueをawaitできるようにするラッパー(戻り値を返すことのできるバージョン) public async Task<T> InvokeAsync<T>(Func<T> func) { var tcs = new TaskCompletionSource<T>(); dispatcher.DispatchAsync(() => { try { tcs.SetResult(func()); } catch (Exception e) { tcs.SetException(e); // ここで例外をちゃんとキャッチしておく(※下の追記を参照) } }); return await tcs.Task; } // DispatchQueueをawaitできるようにするラッパー(Action用) public async Task InvokeAsync(Action action) { await InvokeAsync(() => { action(); return 0; }); } }
全体的には、WPF版よりも簡単なコードであることがわかるでしょう。
※追記(2016/08/13) GCD における例外の扱い
重要なことを書き忘れていたので追記しておきます。
まぁ、例のごとく、上にあるコードは、全部、サンプルなので、いろいろ現実的なことを言い出せば追記することはたくさんあるのですが、
XamarinのDispatchQueue.DispatchAsyncは、内部的には、例外をそのままディスパッチャー内にぶちまけちゃう実装になっているんですが、これについては、
https://developer.apple.com/library/ios/documentation/Performance/Reference/GCD_libdispatch_Ref/developer.apple.com
には、以下のように、例外とか投げたら知らんぜって書かれています。
IMPORTANT GCD is a C level API; it does not catch exceptions generated by higher level languages. Your application must catch all exceptions before returning from a block submitted to a dispatch queue.
試しに、上記の InvokeAsync で呼び出すActionの中で例外を放ったりすると、
================================================================= Got a SIGABRT while executing native code. This usually indicates a fatal error in the mono runtime or one of the native libraries used by your application. =================================================================
みたいな感じでアプリごと死にます。
なので、例外の処理は明示的にやってやる必要があります。