前置き
検証: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
よりも簡潔に機能を実現できるだろう。