概要
排他ロックはあるコードブロックに対して常に1つだけのスレッドが実行されることを保証するために使用されます。
最も主要な排他ロックとしてlockとMutexがあります。この2つではlockのほうがより高速で使うのが簡単です。
しかしながらMutexは異なるプロセスで動作するアプリケーション間で排他ロックをかけることが可能です。
この章ではまずはlockの使用方法から始めてその後にMutexとSemaphores(非排他ロック)について解説します。
あとでreader/writer locksについても解説します。
.NET4.0からは非常に高負荷な並列処理状態でのロック機能として新たにSpinLockが定義されています。
まずは下のコードから始めてみましょう。
class ThreadUnsafe
{
static int _val1 = 1, _val2 = 1;
static void Go()
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
このクラスはスレッドセーフではありません。
もしGoメソッドが2つのスレッドから同時に実行された場合、ゼロ除算による例外が発生する可能性があります。
片方のスレッドがif文を評価してConsole.WriteLineを実行するまでの間にもう1つのスレッドが_val2をゼロにする可能性があるからです。
lockを使用するとこの問題を解決することが可能です。
class ThreadSafe
{
static readonly object _locker = new object();
static int _val1, _val2;
static void Go()
{
lock (_locker)
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
}
1つのスレッドだけが同期オブジェクトをロックすることが可能です。
他のスレッドはロックが解放されるまで待たされることになり、Ready queueでロックの解放を待機します。
ロックが解放されると基本的には最初に追加されたスレッドに対してロックが与えられて処理が実行されることになります。
(WindowsやCLRは時々この順番を入れ替えたりすることもあるので基本的にはという微妙なニュアンスでの説明になってます。)
排他ロックはロック待ちしているスレッドの重要性に関係なく強制的に処理を直列化して実行するという形で説明されることがあります。
なぜなら別のスレッドが排他ロックを取得して実行中のときはその実行が終わるまでは待っているスレッドが実行されることがないからです。
この例ではGoメソッドの中にある_val1と_val2の値をロックによって保護していることになります。
ロック待ちをしているスレッドのThreadStateはWaitSleepJoinになっています。
スレッドの使い方でブロックされたスレッドが他のスレッドによってどうやって解放もしくは終了されるかを解説しています。
これはスレッドの終了時に使用される極めて重要なテクニックです。
1つのスレッドだけが変数やコードの実行をできるようにする
- lock(Monitor.Enter/Monitor.Exit)
- Mutex
指定した数以下のスレッドが変数やコードの実行をできるようにする
複数スレッドからの読み取りと1つのスレッドからの書き込みを保証する
- ReaderWriterLockSlim
- ReaderWriterLock
lock(Monitor.Enter/Monitor.Exit) |
同じプロセス |
20ns |
Mutex |
違うプロセスも可能 |
1000ns |
SemaphoreSlim |
同じプロセス |
200ns |
Semaphore |
違うプロセスも可能 |
1000ns |
ReaderWriterLockSlim |
同じプロセス |
40ns |
ReaderWriterLock |
同じプロセス |
100ns |
Monitorクラス
C#のlockを使用すると囲んだブロックをコンパイル時に内部的にtry-finallyとMoniter.Enter/Monitor.Exitで
囲んだ形に書き換えてくれます。Goメソッドの内部は少し省略してわかり易く書くと以下のコードのように変換されます。
Monitor.Enter (_locker);
try
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
finally { Monitor.Exit (_locker); }
Monitor.Enterメソッドを呼び出す前にMonitor.Exitメソッドを呼び出すと例外が発生します。
ロック取得処理のオーバーロードについて
これまでに示したコードはC#1.0,C#2.0,C#3.0でコンパイラが生成するコードを元に解説してきました。
しかしながらこれらのコードにはいくつかの脆弱な部分があります。ほとんどあり得ないと思いますが例えば
Monitor.Enterとtryの間で例外が発生した場合を考えて見ましょう。(例えば他のスレッドからAbortが呼ばれた
場合とかOutOfMemoryExceptionが発生した場合など)そのような状況ではもしロックが取得された後の場合には
そのロックは解放されないことになります。その結果ロックがリークして再利用できなくなるという問題が発生
します。
この危険性を避けるためにCLR4.0のデザイナーはMonitor.Enterに以下のオーバーロードを追加しました。
public static void Enter (object obj, ref bool lockTaken);
このメソッドを実行後のlockTakenがfalseの場合例外が発生しそのときにはロックは取得されていないことになります。
このオーバーロードを使用した新しいパターンは以下のようになります(またこれはC#4.0のlockはまさにこれと同じ
形に展開されます)
bool lockTaken = false;
try
{
Monitor.Enter (_locker, ref lockTaken);
// 何か処理
}
finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnter
MonitorはTryEnterというメソッドも提供していてミリ秒単位かTimeSpanでタイムアウトを指定することも可能です。
このメソッドはロックが取得できた場合はtrueを返し、ロックが取得できないもしくはタイムアウトになった場合には
falseを返します。TryEnterはロックオブジェクト引数のみのオーバーロードも定義されていて、メソッド呼び出して直ちにロックが取得
できるかどうかをチェックすることが可能です。
Enterメソッドと同様にlockTaken引数を受け取るオーバーロードバージョンもあります。
同期オブジェクトの選択
同期をさせたい複数のスレッドからアクセスできる変数であれば何でも同期オブジェクトとして使用できます。
ただ1つだけルールがあってそのオブジェクトは参照型である必要があります。
通常は同期オブジェクトはprivate変数として宣言されることが多くこれによってロックロジックをクラスの内部に閉じ込めることが可能です。
同期オブジェクトは保護したいオブジェクトそのものを使用することも可能です。下にその例を示します。
class ThreadSafe
{
List<string> _list = new List<string>();
void Test()
{
lock (_list)
{
_list.Add ("Item 1");
}
}
}
ロックのためのメンバー変数を宣言することでより細かい粒度でのロックの制御が可能です。
thisキーワードによる現在のオブジェクトやTypeオブジェクトも同期オブジェクトとして使用可能です。
lock (this) { ... }
もしくは
lock (typeof (Widget)) { ... }
この方法のデメリットはロックのロジックをカプセル化できないことがあげられます。
結果としてデッドロックや必要以上にロック待ちが発生するのを防止することが難しくなります。
またTypeオブジェクトのロックはアプリケーションドメインを越えて適用されるため注意が必要です。
(同じプロセスで実行されている場合に限ります)
ラムダ式や匿名メソッドでキャプチャされたローカル変数も同期オブジェクトとして使用できます。
同期オブジェクトをロックしてもそのオブジェクト自身へのアクセスが制限されるわけではありません。
言い換えるとx.ToString()メソッドがブロックされることはありません。
なぜならブロックされるのはlock(x)を呼び出したときにブロックされるだけだからです。
ロックすべき状況とは?
基本的なルールとしては共有オブジェクトへの変更処理は全てロックを使用する必要があります。
例え最も単純な場合(1つのフィールドへの値の代入)であっても同期について考える必要があります。
以下のクラスではIncrementメソッド及びAssignメソッドの両方ともスレッドセーフではありません。
class ThreadUnsafe
{
static int _x;
static void Increment() { _x++; }
static void Assign() { _x = 123; }
}
上記のクラスをスレッドセーフに書き直すと以下のようになります。
class ThreadSafe
{
static readonly object _locker = new object();
static int _x;
static void Increment() { lock (_locker) _x++; }
static void Assign() { lock (_locker) _x = 123; }
}
ブロックしない同期の章では上記の現象を詳しく解説します。
またメモリバリアやInterlockedクラスを使用してロックをせずにこれらの要件を満たす方法について解説します。
ロックとアトミック性
もしいくつかの変数グループを同じロックを使用して一括して読み取ったり変更したりしなければならない状況のことをアトミック性と言います。
変数xとyが常にlockerオブジェクトによってロックされ値の取得及び変更が行われているとしましょう。
lock (locker) { if (x != 0) y /= x; }
1つ言えるのはxとyはアトミックにアクセスされるということです。
なぜならこのコードブロックは他のスレッドに割り込みされて値を変更されたりすることがないからです。
こうすることでゼロ除算エラーが発生する可能性は全くなくxとyは常に排他ロックされた状態でアクセスされることになります。
このlockによって実現されるアトミック性ですが例外がロックブロックの内部で発生した場合にはアトミック性が失われる場合があります。
decimal _savingsBalance, _checkBalance;
void Transfer (decimal amount)
{
lock (_locker)
{
_savingsBalance += amount;
_checkBalance -= amount + GetBankFee();
}
}
もしGetBankFee()メソッドで例外が発生した場合、銀行の残高は不正な値になってしまいます。
これを避けるためにはGetBankFee()メソッドをロックの前に呼んで値を取得しておくと回避可能です。
もっと複雑な場合にはロールバック処理をcatch句とfinally句を使って実装する必要があります。
「アトミックに実行をする」というのはいろいろな状況がありどの状況でもコンセプトは似ているのですが細かい意味合いは状況によって異なってきます。
プロセッサ上で不可分に処理が実行されるならばそれはアトミック性をもった実行だと言うことができるでしょう。
入れ子になったロッキング
スレッドは同じオブジェクトに対してlockを取得して処理を実行することができます。
lock (locker)
{
lock (locker)
{
lock (locker)
{
// 何か処理を行う
}
}
}
もしくは
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// Do something...
Monitor.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
これらの場合一番外側のlock句を抜けたタイミングでオブジェクトのロックが解放されます。
もしくはMonitor.Exitの回数がEnterの数と一致した場合解放されます。
入れ子になったlockはあるメソッドが別のlock句のあるメソッドを実行する際などに有用です。
static readonly object _locker = new object();
static void Main()
{
lock (_locker)
{
AnotherMethod();
// We still have the lock - because locks are reentrant.
}
}
static void AnotherMethod()
{
lock (_locker) { Console.WriteLine ("Another method"); }
}
スレッドは一番外側のlock句の部分でブロックされることになります。
デッドロック
デッドロックは2つのスレッドがお互いのリソースを待つような状態になった場合に発生します。
最も簡単な例を下に示します。
object locker1 = new object();
object locker2 = new object();
new Thread (() => {
lock (locker1)
{
Thread.Sleep (1000);
lock (locker2); //デッドロック
}
}).Start();
lock (locker2)
{
Thread.Sleep (1000);
lock (locker1); //デッドロック
}
3つかそれ以上のスレッドが連鎖してもっと複雑なデッドロックが発生する場合もあり得ます。
CLRは標準的な環境の場合、SQLサーバーのように自動でデッドロックを検出してデッドロックを解決する仕組みは提供しません。
ロックのタイムアウトを指定しない限りデッドロックしたスレッドは永久にブロックされます。
(SQLサーバーのSQL CLR環境ではデッドロックは自動的に検出されキャッチ可能な例外としていずれかのスレッド上でスローされます。)
デッドロックはマルチスレッドで発生する問題のうち最も難しいものの一つです。特に相互に関係のあるオブジェクトが多く存在するような場合はなおさらです。
難しいのはどの呼び出し側がデッドロックを発生させているのか特定するのができないのが最大の問題です。
xというクラスを定義してそのクラスのprivateなフィールドxをロックしたとします。気づかないうちにクラスyのフィールドbがロックされていたとします。
一方で他のスレッドがその逆を実行していたとします。そうするとデッドロックが発生します。
皮肉なことにオブジェクト指向のデザインパターンで良く設計されたプログラムほど呼び出しのチェーンの回数が多く実行時までこの問題が発生することに気づけなくなったりします。
デッドロックは
一貫性のある順番でオブジェクトをロックすることで回避できます。
しかしながらこの方法は今示したサンプルには適用が難しく役に立ちません。
もっと良い方法はロックブロックの中で呼び出しているメソッドが内部で自分自身への参照を持っていないかどうかを注意深く見るという方法が挙げられます。
また他のクラスのメソッドを呼び出す際に本当にロックが必要なのかどうかをよく見直してみてください。
(多くの場合はそうせざるを得ない場合が多いですがときには別の方法で実現することができる場合があります)
宣言的な方法、データ並列化、不変な型、ブロックしない同期などの方法などに頼ることでロックの必要性をなくすことができます。
他にもこの問題を理解する手助けとなる方法があります。
もしロックを保持中に他の部分のコードを呼び出しているとき、ロックのカプセル化が微妙に漏れていると言えます。
これはCLRや.NET Frameworkで何かエラーになるというわけではないですが、一般的にはロックをする際に必須の制限事項と言えます。
ロックの問題点に関しては「Software Transactional Memory」を含む様々な研究プロジェクトで研修されています。
他のデッドロックが発生する状況としてはロックを取得しているときにWPFのDispatcher.InvokeやWindowsFormのControl.Invokeを呼び出すと発生します。
もしInvokeに渡したメソッドが取得中のロックと同じ同期オブジェクトでロックをしている場合はまさにこの行でデッドロックが発生します。
これは単純にInvokeの代わりにBeginInvokeを呼ぶようにコードを変更すれば修正可能です。
他の方法としてロックをInvokeの呼び出しの前に解放することも可能ですが、もし呼び出し側でロックを取得している場合はうまく動作しません。
InvokeとBeginInvokeについては
スレッドセーフティで解説しています。
パフォーマンス
ロックはすぐに解放されるべきです。
2010年代のコンピューターではロックの競合がない状態では少なくとも20ナノ秒以内にはロックが解放されることが期待されています。
ロックの競合があるとスレッドがリスケジュールされるまでの時間よりも長いときはコンテキストスイッチが発生し、
間接的な結果としてオーバーヘッドはミリ秒単位に近づくことになります。
もしロックを非常に短い時間だけ保持するようにしていれば、コンテキストスイッチはSpinLockクラスを使用して回避することが可能です。
ロックを長時間保持すると同時実行性を低下させることがあり得ます。またロックの時間が長いとデッドロックの可能性も高まります。
Mutex
MutexはC#のlockのような機能を持ってますが複数のプロセスの間でロックをかけることが可能です。
言い換えるとコンピュータレベルかアプリケーションレベルかという違いがあるとも言えます。
Mutexを取得したり解放したりするには数マイクロ秒の時間がかかります。これはlockを使用するのに比べて50倍ほど低速です。
MutexクラスではWaitOneメソッドでロックを取得し、ReleaseMutexメソッドでロックを解放します。
Closing、Disposing時には自動的にロックは解放されます。lock句と同じようにMutexはそれを取得したスレッドからしか解放することができません。
よくある使用方法としてはアプリケーションの実行時に1つのインスタンスだけが実行されるようにするというのがあります。
class OneAtATimePlease
{
static void Main()
{
// 名前はコンピュータ全体で一意なものにする必要があります。
// 会社名+アプリケーション名+…といった形で命名するようにしてください。
using (var mutex = new Mutex (false, "oreilly.com OneAtATimeDemo"))
{
// 他のプログラムがシャットダウン中の場合を考慮してロックの取得を数秒待機します。
if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false))
{
Console.WriteLine ("Another app instance is running. Bye!");
return;
}
RunProgram();
}
}
static void RunProgram()
{
Console.WriteLine ("Running. Press Enter to exit");
Console.ReadLine();
}
}
ターミナルサービス上で動作している場合、Mutexは同じターミナルサーバーのセッション単位になります。
全てのターミナルサーバーのセッションで共通にしたい場合は名前の始まりを"Global\"にすることで可能です。
セマフォ
セマフォは駐車場のようなものです。駐車場は収容できる台数が決まっていて入り口の機会が人数を管理しています。
満車になった場合はそれ以上車は入れません。外で列を作って中に入れるのを待つことになります。
車が1台駐車場から出て行くと先頭で待っている車が駐車場に入ります。
Semaphoreクラスのコンストラクタは2つの引数を受け取ります。現在利用する台数と台数の最大値です。
セマフォの最大値を1にするとlockやMutexと似たかたちになりますが1つだけ異なる点があります。
セマフォはどのスレッドが所有しているのかといったことを気にしなくて良い点が異なります。
全てのスレッドがSemaphoreクラスのReleaseメソッドを呼び出すことが可能です。
Mutextやlockはそのロックを取得したスレッドからしかロックを解放できません。
セマフォにはSemaphoreクラスとSemaphoreSlimクラスという似たクラスが定義されています。
後者は.NET4.0で新たに追加されたクラスで並列処理で待ち時間が短い状況に最適化されています。
また従来どおりのマルチスレッド環境においてもキャンセル機能などを使いたい場合などは有用です。
しかしながら異なるプロセスの間でのシグナルはできません。
SemaphoreクラスはWaitやReleaseを呼ぶときに1マイクロ秒程度かかります。
SemaphoreSlimはその4分の1程度の時間がかかります。