164

マルチスレッドで高速なC#を書くためのロック戦略

最終更新日 投稿日 2015年09月11日

マルチスレッドで高速な実装を行うにはロックを避けては通れません。
この記事では色々な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 / 秒
  1. ベンチマーク環境
    CPU : Apple M1 Max 10 Core
    実行回数: 1,000,000回

  2. だめな例です 2

  3. コンパイルエラーになります。[wandboxでの実行結果](http://melpon.org/wandbox/permlink/a17OowyLnMvaWbpv wandboxでの実行結果)
    awaitは呼び出す前後でスレッドが異なる可能性があります。lock()はスレッドアフィニティがある(ロック前後で同一スレッドであることを要求)し、SemaphoreSlimはスレッドアフィニティがない(ロック前後でスレッドは変わってもいい)からだそうです。

  4. コメントで指摘を頂き変更しました
    名前付きセマフォはSemaphore(int initialCount, int maximumCount, string name)で使えるようです
    https://msdn.microsoft.com/ja-jp/library/54wk4yfd(v=vs.110).aspx
    laughterさん、教えて頂きありがとうございます!

  5. Apple M1 Maxと.net coreの環境でベンチマークを取り直しました。以前にベンチマークを取得したIntel CPUと.net環境下とは異なり、Interlockedのパフォーマンスはいまいちでした。機会があれば、最新のIntel CPUとWindows環境でもう一度ベンチマークを取って紹介したいと思います。

新規登録して、もっと便利にQiitaを使ってみよう

  1. あなたにマッチした記事をお届けします
  2. 便利な情報をあとで効率的に読み返せます
  3. ダークテーマを利用できます
ログインすると使える機能について

コメント

この記事にコメントはありません。
あなたもコメントしてみませんか :)
新規登録
すでにアカウントを持っている方はログイン
164

Qiitaにログインして、便利な機能を使ってみませんか?

あなたにマッチした記事をお届けします

便利な情報をあとから読み返せます