前置き
検証:Task + CancellationTokenSourceでは.NET4.0以前のスレッドの実行をブロックするメソッドを中断できないという投稿をしたが、その中で、
(
CancellationTokenSourceで)中断できないメソッドの中には、Thread.Interruptで中断できたメソッド(Monitor.Wait)も含まれる。
そのため、Monitor.Waitを使用したい場合、Threadを使用する意味が依然として存在するように思える。
という記述をした。
しかしながら、事実として.NET4.0以降の環境ではThreadよりもTaskの利用が推奨されている。
では、Taskでどのようにスレッドをブロックする処理を実現すればよいのか。中断できないことを知りつつも、Monitor.Waitを使用するしかないのだろうか。
それとも、代わりのクラスが用意されているのだろうか。
あるいは、小刻みにMonitor.WaitとCancellationToken.ThrowIfCancellationRequestedを繰り返すクラスを自分で実装するべきなのだろうか。
いろいろ調べてみたが、C#や.NETをあつかっている情報のなかには、答えらしきものがみあたらなかった。
そこで、視野を広げて、Javaの情報にあたってみた。
そのため今回の投稿は独自研究の色が強く、C#に詳しい諸氏にとってつっこみどころがある内容になっているはずである。
しかし、間違ったところがあったとしても、それが正されることで、私や同程度の知識のプログラマにとって有益な結果が得られるかもしれず、またそうなってほしいと願っている。
Monitor.Wait/Pulseを置き換えるには
以下はEffective Java 第二版からの引用である。
項目69 waitとnotifyよりコンカレンシーユーティリティを選ぶ
JavaのObject#wait(), notify()はそのままそっくり、.NETのMonitor.Wait(Object), Pulse(Object)にあたる。
java.util.concurrentが提供している高級言語に比べると、waitとnotifyを直接使用することは、「並行性アセンブリ言語」でプログラミングをしているようなものです。新たなコードでwaitとnotifyを使用する理由は、あったとしても、めったにありません。
このように、.NETでいうところのMonitor.Wait(Object), Pulse(Object)の使用をやめるように記述されている。
その代わりに
シンクロナイザー(synchronizer)は、スレッドが他のスレッドを待つことを可能にするオブジェクトであり、複数スレッドの活動を協調させることが可能です。
として、シンクロナイザーの使用をすすめている。
そして、特に使用頻度が高いシンクロナイザーの例として、CountDownLatch,Semaphoreを上げている。
JavaのCountDownLatch, Semaphoreに該当するクラスは.NETではCountdownEvent, Semaphore/SemaphoreSlimになる。
(使用頻度が低いものとして、CyclicBarrier,Exchangerも上げられている。前者は.NETではBarrierとして存在するが、後者はどうも存在しないようだ。)
Semaphore/SempahoreSlimのリソースに同時アクセスするスレッド数を限定する機能は、単純にMonitor.Waitを置き換えるだけと考えるとあきらかに余計である。
だから、どちらかと言えばCountDownLatchが対象候補になるのだろうが、Javaで使うからといって、.NETにCountdownEvent以上に妥当なものが無いとも限らない。
mono(オープンソースの.NET環境)のソースコードを読んでみると、CountdownEventは内部でManualResetEventSlimを使用しているようだ。
CountdownEventのカウントダウンする機能が使用できる場面もあるだろうが、それは毎回ではないのだから、ManualResetEventSlimクラスを使用しよう。
ManualResetEventSlimを使用する
ManualResetEventSlimはSet()によりシグナル状態になり、Reset()により非シグナル状態に戻る性質を持つシンクロナイザーだ。
そして、Waitメソッドにより、シグナル状態になることをスレッドに待ち受けさせる。また、このメソッドは、タイムアウトの時間と処理のキャンセル用オブジェクト(CancellationToken)を受け取ることができる。
実際にこれを使用して、スレッドの実行をブロックするキューをC#で実現してみた。
(今回の例はただのManualResetEventSlim利用サンプルであり、こうした目的にはもちろんBlockingCollection<T>を用いるべきだ)
class BlockingQueue<T>
{
~BlockingQueue()
{
_resetevent.Dispose();
}
private readonly Queue<T> _queue = new Queue<T>();
private readonly ManualResetEventSlim _resetevent = new ManualResetEventSlim();
private readonly object _lock = new object();
public void Put(T data)
{
lock (_lock)
{
_queue.Enqueue(data);
_resetevent.Set();
}
}
public T Take(CancellationToken token)
{
while (true)
{
token.ThrowIfCancellationRequested();
lock (_lock)
{
if (_queue.Any())
{
var val = _queue.Dequeue();
return val;
}
_resetevent.Reset();
}
_resetevent.Wait(Timeout.Infinite, token);
}
}
}
注意が必要だとすれば、ManualResetEventSlim.Waitはlockのブロックの外で実行しなくてはならない点だ。Monitor.Waitと異なり、ManualResetEventSlim.Waitは待ち受け中にロックを解放してはくれないからだ。
それをのぞけば、かなり簡潔にわかりやすく記述できる。
ずいぶんな遠回りをしてしまったが、CancellationTokenSourceで中断可能なブロッキング処理を実現できた。
ManualResetEventSlim.Waitの処理内部
monoのソースコードをざっと斜め読みした際にわかった、ManualResetEventSlim.Waitの処理の内部について述べる。
まず、一定期間、ループ文により、
Thread.Yield()Thread.Sleep(0)Thread.Sleep(1)CancellationToken.ThrowIfCancellationRequested()
を実行しながら(つまり、他のスレッドに処理を渡しながら)、シグナル状態かどうかをチェックする。
その後、スピンウェイトはあきらめて、ループの中で、
Monitor.Wait(Object, INT32)CancellationToken.ThrowIfCancellationRequested()
を実行しながら、シグナル状態かどうかをチェックしている。
この段階では、Monitor.Waitによりスレッドの実行を完全に中断している。
応答性が悪くてもかまわない場合、すなわち、スピンウェイトの期間をできるだけ短くして、即、Monitor.Waitによるスレッドの中断に移りたい場合は、コンストラクタManualResetEventSlim(Boolean, INT32)のふたつめの引数にゼロを指定すればいい。
スピンウェイトの期間をできるだけ引き伸ばしたい場合はその逆におおきな値を指定すること。
正直なところ、これを読むまで、ManualResetEventSlimは延々とスピンウェイトだけをしているのだとばかり思っていた。そのため、Monitor.Waitをこれで置き換えられたとしてもCPUの資源の無駄使いにしかならないと思っていた。ひどい勘違いだった。
結論
.NET4.0以降の環境では、ThreadよりもTaskを用いるとともに、Monitor.Wait/PulseよりもManualResetEventSlimを使用するべきである。
これにより、単純にMonitor.Waitしたときよりも応答性がよく、単純にスピンウェイトで待ち受けしたときよりもCPU資源を有効に活用できる上に、CancellationTokenSourceによる処理の中断を実現できる。
また、ManualResetEventSlim以外に利用できるシンクロナイザーとして、CountdownEvent,SemaphoreSlim,Barrierが存在する。特定の用途では、ManualResetEventSlimよりも簡潔に機能を実現できるだろう。