てくメモ

trivial な notes

【C#】並列実行での数値カウント

マルチスレッドの排他制御に関するある記事で、数値を操作するだけであってもInterlockedクラスよりlockステートメントの方がよい、と書かれていたのが感覚と異なったので、自分でも測ってみる。


単純なインクリメントを、Parallel.Forにより並列実行したベンチマークを示す。(BenchmarkDotNet)

ベンチマークコード折りたたみ

[ShortRunJob]
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[ReturnValueValidator(failOnError: true)]
public class ConcurrentCounterBenchmark
{
    private abstract class ConcurrentCounterBase()
    {
        protected abstract void Increment();
        protected abstract long GetCount();

        // メソッド名:ベンチマーク用に挙動を変えたのに名前を変え忘れて、挙動と名前が合っていない命名になっています(整合性を欠いたりするとよくないのでそのままに)
        public long GetCountPerSecond()
        {
            var options = new ParallelOptions()
            {
                MaxDegreeOfParallelism = 8
            };

            const int N = 1 << 16;
            Parallel.For(0, N, options, _ =>
            {
                Increment();
            });

            return GetCount();
        }
    }

    private sealed class LockClass() : ConcurrentCounterBase
    {
        private long counter;
        private static readonly object obj = new();

        protected override void Increment()
        {
            lock (obj) ++counter;
        }

        protected override long GetCount() => counter;
    }

    private sealed class InterlockedClass : ConcurrentCounterBase
    {
        private long counter;

        protected override void Increment()
        {
            Interlocked.Increment(ref counter);
        }

        protected override long GetCount() => counter;
    }

    private sealed class SpinLockClass : ConcurrentCounterBase
    {
        private long counter;
        private static SpinLock spinLock = new();

        protected override void Increment()
        {
            bool lockTaken = false;
            try
            {
                spinLock.Enter(ref lockTaken);
                ++counter;
            }
            finally
            {
                if (lockTaken) spinLock.Exit(false);
            }
        }

        protected override long GetCount() => counter;
    }

    private sealed class SemaphoreClass : ConcurrentCounterBase
    {
        private long counter;
        private static readonly SemaphoreSlim semaphore = new(1, 1);

        protected override void Increment()
        {
            semaphore.Wait();
            try
            {
                ++counter;
            }
            finally
            {
                semaphore.Release();
            }
        }

        protected override long GetCount() => counter;
    }

    private sealed class MutexClass : ConcurrentCounterBase
    {
        private long counter;
        private static readonly Mutex mutex = new();

        protected override void Increment()
        {
            mutex.WaitOne();
            try
            {
                ++counter;
            }
            finally
            {
                mutex.ReleaseMutex();
            }
        }

        protected override long GetCount() => counter;
    }

    private sealed class ThreadLocalClass : ConcurrentCounterBase
    {
        private readonly ThreadLocal<long> counter = new(true);

        protected override void Increment()
        {
            counter.Value++;
        }

        protected override long GetCount() => counter.Values.Sum();
    }

    [Benchmark(Baseline = true)]
    public long Lock() => new LockClass().GetCountPerSecond();
    [Benchmark]
    public long InterlockedIncrement() => new InterlockedClass().GetCountPerSecond();
    [Benchmark]
    public long SpinLock() => new SpinLockClass().GetCountPerSecond();
    [Benchmark]
    public long Semaphore() => new SemaphoreClass().GetCountPerSecond();
    [Benchmark]
    public long Mutex() => new MutexClass().GetCountPerSecond();
    [Benchmark]
    public long ThreadLocal() => new ThreadLocalClass().GetCountPerSecond();
}

| Method               | Mean         | Ratio  | 
|--------------------- |-------------:|-------:|-
| Lock                 |   2,656.4 μs |   1.00 | 
| InterlockedIncrement |   1,771.7 μs |   0.67 | 
| SpinLock             |  28,084.6 μs |  10.58 | 
| Semaphore            |   7,555.0 μs |   2.84 | 
| Mutex                | 392,752.6 μs | 147.87 | 
| ThreadLocal          |     349.4 μs |   0.13 | 

このケースではInterlocked利用の方が良くなった。

もちろん冒頭で挙げた記事と条件は異なるが、Interlockedが単純にlockに後れるわけでないことは確かめられた。

ThreadLocal<T>

ところで、カウントした値を出せればいいみたいな話ならThreadLocal<T>を使うこともできる。

private sealed class ThreadLocalClass : ConcurrentCounterBase
{
    private readonly ThreadLocal<long> counter = new(true);

    protected override void Increment()
    {
        counter.Value++;
    }

    protected override long GetCount() => counter.Values.Sum();
}

そもそも排他制御を行わず、スレッドごとの値を合計すればそれはカウント、という解法。

ベンチマークとしては条件が揃っていないけれど、実際は適した方法を選びたい。