マルチスレッドで高速な実装を行うにはロックを避けては通れません。
この記事では色々なC#を使ってロック方法の紹介とベンチマーク結果をご紹介します。
ロック方法によるパフォーマンス差1
ロック方法 | 10スレッド | 32スレッド |
---|---|---|
unsafe2 | 58,823,529 / 秒 | 28,571,428 / 秒 |
lock | 2,881,844 / 秒 | 1,042,752 / 秒 |
SemaphoreSlim | 1,912,045 / 秒 | 599,161 / 秒 |
Interlocked | 1,149,425 / 秒 | 351,493 / 秒 |
Mutex | 55,663 / 秒 | 16,410 / 秒 |
Semaphore | 53,381 / 秒 | 16,444 / 秒 |
unsafeはロックを取得していないのでプログラマーの意図した動きにはなりませんが、ロックによるオーバーヘッドを見るために計測しました。
Interlocked > lock > SemaphoreSlim > Semaphore > Mutex の順に早いようです。
lock > SemaphoreSlim > Interlocked > Mutex > Semaphore の順に早いようです。
とはいえロック方法によっては得手不得手があるので、使う状況をそれぞれ見ていきます。
lock
class X {
object lockObject = new object();
public void Work() {
lock(lockObject) {
// ロックの中
}
}
}
こんな感じです。
同じ引数のlock(){ ... }で囲った範囲同士であれば、シングルスレッドと同じ感覚で使えます。
SemaphoreSlim
class X {
SemaphoreSlim sem = new SemaphoreSlim(1, 1);
public async Task Work() {
await sem.WaitAsync().ConfigureAwait(false);
try {
// ロックの中、awaitもok
} finally {
sem.Release();
}
}
}
lock方式だとロック中にawaitが使えないのですが3、こちらは使えます。
lock方式が使えるならそちらを、awaitを使うならこちらを選ぶと良さそうです。
Interlocked
int incrementedValue = Interlocked.Increment(ref intValue); // 安全にインクリメント
こんな感じで使いますが、lock方式の方が良いでしょう。数値を単品で操作するだけであっても。
Semaphore
class X {
Semaphore sem = new Semaphore(1, 1);
public void Work() {
sem.WaitOne();
try {
// ロックの中、awaitもok
} finally {
sem.Release();
}
}
}
Semaphore方式はSemaphoreSlim方式と似てますが、awaitに対応していません。
速度ではlock方式に劣ります。
今回のベンチマークではこれを使うメリットは見い出せませんでした(^^;)
MSDNのSemaphoreSlimには以下の記載がありますので、Semaphoreは過去のものかもしれません(間違ってたらごめんなさい)。
名前付きセマフォとしてプロセス間でリソースをロックする場面でだけ使うと良いかもしれません。4
SemaphoreSlim クラス
リソースまたはリソースのプールに同時にアクセスできるスレッドの数を制限する Semaphore の軽量版を表します。
Mutex
class X {
Mutex mut = new Mutex();
public void Work() {
mut.WaitOne();
try {
// ロックの中
} finally {
mut.ReleaseMutex();
}
}
}
使い方はSemaphoreとよく似ていて、こちらも名前付きにすると他プロセスからも見えるそうです。Semaphoreのように同時実行エントリ数を変更できない(1固定)のに、なぜかSemaphoreより性能が劣ります。
Semaphoreのように同時実行エントリ数を変更できない(1固定)のに、Semaphoreとの大きな性能差は見られませんでした。
所感
lockとSemaphoreSlimが高速かつ高機能。5
基本的にはlockを使い、awaitが必要なところだけSemaphoreSlimを使うと、高速なプログラムになりそうです。特にスレッド数が増えると差も広がる傾向があります。
検証に使ったコード
以下、2015年10月時点のベンチマーク結果です。古い情報ですが、ご参考までに。
OS : Windows 7
CPU : Intel(R) Core(TM) i7-3770 CPU @ 3.40GHz
実行回数: 1,000,000回
ロック方法 | 4スレッド | 32スレッド |
---|---|---|
unsafe2 | 11,494,252 / 秒 | 6,578,947 / 秒 |
Interlocked | 6,896,551 / 秒 | 3,215,434 / 秒 |
lock | 4,000,000 / 秒 | 877,192 / 秒 |
SemaphoreSlim | 1,104,972 / 秒 | 104,602 / 秒 |
Semaphore | 319,284 / 秒 | 48,035 / 秒 |
Mutex | 234,962 / 秒 | 37,199 / 秒 |
-
ベンチマーク環境
CPU : Apple M1 Max 10 Core
実行回数: 1,000,000回 ↩ -
コンパイルエラーになります。[wandboxでの実行結果](http://melpon.org/wandbox/permlink/a17OowyLnMvaWbpv wandboxでの実行結果)
awaitは呼び出す前後でスレッドが異なる可能性があります。lock()はスレッドアフィニティがある(ロック前後で同一スレッドであることを要求)し、SemaphoreSlimはスレッドアフィニティがない(ロック前後でスレッドは変わってもいい)からだそうです。 ↩ -
コメントで指摘を頂き変更しました
名前付きセマフォはSemaphore(int initialCount, int maximumCount, string name)で使えるようです
https://msdn.microsoft.com/ja-jp/library/54wk4yfd(v=vs.110).aspx
laughterさん、教えて頂きありがとうございます! ↩ -
Apple M1 Maxと.net coreの環境でベンチマークを取り直しました。以前にベンチマークを取得したIntel CPUと.net環境下とは異なり、Interlockedのパフォーマンスはいまいちでした。機会があれば、最新のIntel CPUとWindows環境でもう一度ベンチマークを取って紹介したいと思います。 ↩