SionGames 技術Blog

C#やUnityを触っているサーバープログラマーが色々と書いていく所

C#のマルチスレッド処理でのロック制御

C#なゲームサーバーアプリケーションを扱っているとプレイヤーデータがマルチスレッドにアクセスされる事が良くあります。
そこで必ず必要となるのがロック絡みのお話。
自分の忘備録も兼ねて様々なロック制御方法について紹介したいと思います。

マルチスレッドとロックについて説明


そもそもマルチスレッドとかロックについてよく知らない方向けにマルチスレッドとロックの関係について説明します。
スレッドAとスレッドBで同一変数に対して操作を行うと後から実行された方のスレッドに結果が上書きされて値がおかしくなることがあります。

f:id:SionGames:20190408012327p:plain
lock制御が無いと変数valueがおかしくなる図

上記の図の例だとスレッドAの結果が無かったことにされてしまっています。
ロック制御を行うとスレッドAの処理の終了を待ってからスレッドBの処理を開始するという具合で排他制御を行うことが出来ます。

様々なロック制御方法


C#でロック制御を行う際の処理を実際のソースコードを使用しながら解説します
コードの実行環境は.Net Core2.1のC#コンソールアプリケーションになります。
ProcessBaseクラスは記事の最後に全コードを載せますが、
ひとまず簡易に必要な所だけ記載しています。

public abstract class ProcessBase
{
    protected volatile int value = 0;
    //以下記事の最後に掲載しているので省略。
}

1. Lock無し(正しくない実装)

まずはロックを行わない正しくない実装から入ります。
AddProcess関数が複数スレッド上で実行されると、
変数valueがおかしな数値になります。

/// <summary>
/// ロック制御をしない(値がおかしくなる実装)
/// </summary>
public class NoLockProcess : ProcessBase
{
    public override void AddProcess(int loopCount)
    {
        for (int i = 0; i < loopCount; ++i)
        {
            value++;
        }
    }
}

2. InterLocked

変数単体の排他制御で良ければこのクラスを利用しましょう。
最も高速に動作する排他制御です。
ただしInterlocked.Add()などはInt型とlong型でのみ利用可能です

メリット デメリット
最も高速な排他制御 変数の書き換え用途のみ
お手軽 演算の排他制御はint,long型のみ
2つ以上の処理を同一Lockに出来ない
/// <summary>
/// InterLockedクラスを使用して排他制御する
/// </summary>
public class InterLockedProcess : ProcessBase
{
    public override void AddProcess(int loopCount)
    {
        for (int i = 0; i < loopCount; ++i)
        {
            Interlocked.Increment(ref value);
        }
    }
}

3. lock構文

簡単に利用できる排他制御です。
lockの{}に包まれた範囲内で排他制御ができます。
デメリットは何か書こうと思っても思いつきませんでした。

メリット デメリット
高速な排他制御
お手軽
簡単
/// <summary>
/// lock()構文を使用して排他制御する
/// </summary>
public class LockProcess : ProcessBase
{
    private object lockObj = new object();
    public override void AddProcess(int loopCount)
    {
        for (int i = 0; i < loopCount; ++i)
        {
            lock (lockObj)
            {
                value++;
            }
        }
    }
}

4. ReaderWriterLockSlim

書き込み処理のみを排他制御させつつ、読み込み処理を出来るだけ並列に動作可能にするクラスです。
ゲームサーバーでは読み込みが並列実行できるメリットが大きく、相性が良いのでよく利用されています。
UpgradeableReadLockは後で書き換えが発生する事がある読み込み処理で利用します。

メリット デメリット
高速な排他制御 ラッパークラスを用意しないと使用するのが手間
読み込みが並列実行可
/// <summary>
/// ReaderWriterLockSlimを使用して排他制御する。
/// ReadLock同士は重複して実行可能。読み込み処理だけに使用できる
/// UpgradeableReadLock同士は重複できないが、ReadLockは重複可能。WriteLockに昇格可能。書き換え前の読み込み処理に使用できる。
/// WriteLockは全てのロックモードで重複不可。書き換え発生時に使用する。
/// </summary>
public class ReaderWriterLockSlimProcess : ProcessBase
{
    private ReaderWriterLockSlim lockObj = new ReaderWriterLockSlim();
    public override void AddProcess(int loopCount)
    {
        for (int i = 0; i < loopCount; ++i)
        {
            lockObj.EnterWriteLock();
            try
            {
                value++;
            }
            finally
            {
                lockObj.ExitWriteLock();
            }
        }
    }
}

try finallyを書くのが面倒なのでusing句で囲って使用可能にすると便利です。

/// <summary>
/// ReaderWriterLockSlimのUpgradeableReadLockをUsing句で楽に使えるようにしたもの
/// </summary>
public struct WriteLock : IDisposable
{
    ReaderWriterLockSlim obj;
    public WriteLock(ReaderWriterLockSlim lockObj)
    {
        obj = lockObj;
        lockObj.EnterWriteLock();
    }
    public void Dispose()
    {
        obj.ExitWriteLock();
    }
}

/// <summary>
/// ReaderWriteLockSlimをusing句で簡単に利用できるようパッキングを施した物
/// </summary>
public class ReaderWriterLockSlimProcess2 : ProcessBase
{
    private ReaderWriterLockSlim lockObj = new ReaderWriterLockSlim();
    public override void AddProcess(int loopCount)
    {
        for (int i = 0; i < loopCount; ++i)
        {
            using(var wl = new WriteLock(lockObj))
            {
                value++;
            }
        }
    }
}

5. SemaphoreSlim

ロック取得スレッドとロック開放スレッドが異なっていても使用可能です。
つまりロック中にasync/awaitが存在しても使用可能な排他制御です。

メリット デメリット
ロック中にasync/awaitが使用可能 ラッパークラスを用意しないと使用するのが手間
/// <summary>
/// SemaphoreSlimを使用して排他制御する
/// ロック中にasync/awaitが使用できる。つまりロック開始スレッドとロック終了スレッドが別でも良いのが特徴
/// </summary>
public class SemaphoreSlimProcess : ProcessBase
{
    private SemaphoreSlim lockObj = new SemaphoreSlim(1, 1);
    public override void AddProcess(int loopCount)
    {
        for (int i = 0; i < loopCount; ++i)
        {
            lockObj.Wait();
            try
            {
                value++;
            }
            finally
            {
                lockObj.Release();
            }

        }
    }
}

6. MutexProcess

プロセス間で利用可能な排他制御です。
ただ非常に重い排他制御です。

メリット デメリット
プロセス間で排他処理ができる ラッパークラスを用意しないと使用するのが手間
非常に重い
public class MutexProcess : ProcessBase
{
    private Mutex lockObj = new Mutex();
    public override void AddProcess(int loopCount)
    {
        for (int i = 0; i < loopCount; ++i)
        {
            lockObj.WaitOne();
            try
            {
                value++;
            }
            finally
            {
                lockObj.ReleaseMutex();
            }

        }
    }
}

各ロック処理の時間計測結果


詳細なソースコードは記事の最後に載せています。
Core i7 6700HQ(定格2.6GHz, 計測時大体3.1GHz)なマシンで
100万回の加算を4スレッドで分割して回した結果を10回平均したものです。

ロック機構名 100万回の処理時間(ms)
NoLock 9ms
InterLocked 19ms
lock構文 56ms
ReaderWriterLockSlim 43ms
ReaderWriterLockSlim usingVer 50ms
SemaphoreSlim 113ms
Mutex 5077ms

予想よりReaderWriterLockSlimが早かったです。

検証コード全文


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace LockTest
{
    class Program
    {
        const int ThreadNum = 4;
        //Execute実行後のvalue値
        const int TargetValue = 1000000;
        const int ExecuteCount = 10;

        static void Main(string[] args)
        {
            Console.WriteLine("Main 開始");

            //スレッドプールのスレッド数を固定しておく
            ThreadPool.SetMinThreads(ThreadNum, ThreadNum);
            ThreadPool.SetMaxThreads(ThreadNum, ThreadNum);

            var processList = new List<ProcessBase>();

            processList.Add(new NoLockProcess());
            processList.Add(new InterLockedProcess());
            processList.Add(new LockProcess());
            processList.Add(new ReaderWriterLockSlimProcess());
            processList.Add(new SemaphoreSlimProcess());
            processList.Add(new MutexProcess());
            processList.Add(new ReaderWriterLockSlimProcess2());
            
            for (int i = 0; i < ExecuteCount; ++i)
            {
                Console.WriteLine("Execute Count: " + (i + 1).ToString());
                for (int j = 0; j < processList.Count; ++j)
                {
                    processList[j].Execute(ThreadNum, TargetValue);
                }
            }

            //実行結果表示
            Console.WriteLine("");
            for (int j = 0; j < processList.Count; ++j)
            {
                processList[j].ResultAverage();
            }

            Console.WriteLine("Main 終了");
            Console.ReadKey();
        }
    }

    public abstract class ProcessBase
    {
        protected volatile int value = 0;
        private List<Tuple<int, long>> ExecutedInfoList = new List<Tuple<int, long>>();

        public void Execute(int processThreadNum, int targetValue)
        {
            Console.WriteLine("Start.    ThreadNo:" + Thread.CurrentThread.ManagedThreadId + " " + GetName());
            value = 0;
            var taskList = new List<Task>();
            var loopCount = targetValue / processThreadNum;
            var stopWatch = Stopwatch.StartNew();

            //引数指定の数だけスレッドを立ち上げる
            for (int i = 0; i < processThreadNum; ++i)
            {
                var task = Task.Run(() => { AddProcess(loopCount); });
                taskList.Add(task);
            }

            //全スレッドの終了を待機する
            //Task.Wait()はデッドロックの可能性があるので気を付ける事。
            //今回はコンソールアプリケーションなのでWait()でも問題ないので使用しています。
            Task.WhenAll(taskList).Wait();

            stopWatch.Stop();

            //Valueを表示
            ExecutedInfoList.Add(new Tuple<int, long>(value, stopWatch.ElapsedMilliseconds));
            Console.WriteLine("End. value:" + value + " Time(MS):" + stopWatch.ElapsedMilliseconds + "    ThreadNo:" + Thread.CurrentThread.ManagedThreadId + " " + GetName());
        }


        /// <summary>
        /// 派生先で異なる実装。複数スレッドでの処理
        /// </summary>
        public abstract void AddProcess(int loopCount);
        public abstract string GetName();

        /// <summary>
        /// 複数回実行したときの結果を出す
        /// </summary>
        public void ResultAverage()
        {
            long valueAve = 0;
            long timeAve = 0;
            for (int i = 0; i < ExecutedInfoList.Count; ++i)
            {
                valueAve += ExecutedInfoList[i].Item1;
                timeAve += ExecutedInfoList[i].Item2;
            }
            if (ExecutedInfoList.Count > 0)
            {
                valueAve = valueAve / ExecutedInfoList.Count;
                timeAve = timeAve / ExecutedInfoList.Count;
            }
            Console.WriteLine("Average value: " + valueAve + " Time(MS): " + timeAve + "    " + GetName());
        }
    }

    /// <summary>
    /// ロック制御をしない(値がおかしくなる実装)
    /// </summary>
    public class NoLockProcess : ProcessBase
    {
        public override void AddProcess(int loopCount)
        {
            for (int i = 0; i < loopCount; ++i)
            {
                value++;
            }
        }
        public override string GetName()
        {
            return "NoLock";
        }
    }

    /// <summary>
    /// InterLockedクラスを使用して排他制御する
    /// </summary>
    public class InterLockedProcess : ProcessBase
    {
        public override void AddProcess(int loopCount)
        {
            for (int i = 0; i < loopCount; ++i)
            {
                Interlocked.Increment(ref value);
            }
        }
        public override string GetName()
        {
            return "InterLocked";
        }
    }

    /// <summary>
    /// lock()構文を使用して排他制御する
    /// </summary>
    public class LockProcess : ProcessBase
    {
        private object lockObj = new object();
        public override void AddProcess(int loopCount)
        {
            for (int i = 0; i < loopCount; ++i)
            {
                lock (lockObj)
                {
                    value++;
                }
            }
        }
        public override string GetName()
        {
            return "Lock";
        }
    }

    /// <summary>
    /// ReaderWriterLockSlimを使用して排他制御する。
    /// ReadLock同士は重複して実行可能。読み込み処理だけに使用できる
    /// UpgradeableReadLock同士は重複できないが、ReadLockは重複可能。WriteLockに昇格可能。書き換え前の読み込み処理に使用できる。
    /// WriteLockは全てのロックモードで重複不可。書き換え発生時に使用する。
    /// </summary>
    public class ReaderWriterLockSlimProcess : ProcessBase
    {
        private ReaderWriterLockSlim lockObj = new ReaderWriterLockSlim();
        public override void AddProcess(int loopCount)
        {
            for (int i = 0; i < loopCount; ++i)
            {
                lockObj.EnterWriteLock();
                try
                {
                    value++;
                }
                finally
                {
                    lockObj.ExitWriteLock();
                }
            }
        }
        public override string GetName()
        {
            return "ReaderWriterLockSlim";
        }
    }
    /// <summary>
    /// ReaderWriteLockSlimをusing句で簡単に利用できるようパッキングを施した物
    /// </summary>
    public class ReaderWriterLockSlimProcess2 : ProcessBase
    {
        private ReaderWriterLockSlim lockObj = new ReaderWriterLockSlim();
        public override void AddProcess(int loopCount)
        {
            for (int i = 0; i < loopCount; ++i)
            {
                using(var wl = new WriteLock(lockObj))
                {
                    value++;
                }
            }
        }
        public override string GetName()
        {
            return "ReaderWriterLockSlim PackVer";
        }
    }

    /// <summary>
    /// SemaphoreSlimを使用して排他制御する
    /// ロック中にasync/awaitが使用できる。
    /// つまりロック開始スレッドとロック終了スレッドが別でも良いのが特徴
    /// </summary>
    public class SemaphoreSlimProcess : ProcessBase
    {
        private SemaphoreSlim lockObj = new SemaphoreSlim(1, 1);
        public override void AddProcess(int loopCount)
        {
            for (int i = 0; i < loopCount; ++i)
            {
                lockObj.Wait();
                try
                {
                    value++;
                }
                finally
                {
                    lockObj.Release();
                }

            }
        }
        public override string GetName()
        {
            return "SemaphoreSlimLock";
        }
    }

    public class MutexProcess : ProcessBase
    {
        private Mutex lockObj = new Mutex();
        public override void AddProcess(int loopCount)
        {
            for (int i = 0; i < loopCount; ++i)
            {
                lockObj.WaitOne();
                try
                {
                    value++;
                }
                finally
                {
                    lockObj.ReleaseMutex();
                }

            }
        }
        public override string GetName()
        {
            return "Mutex";
        }
    }
}

ReaderWriterLockのusing句便利パックはこちら

using System;
using System.Threading;

namespace LockTest
{
    // 構造体の理由はメモリ消費を抑えるため。
    // デフォルトコンストラクタを選択できてしまうのが嫌な場合はクラスに変更する

    /// <summary>
    /// ReaderWriterLockSlimのReadLockをUsing句で楽に使えるようにしたもの
    /// </summary>
    public struct ReadLock : IDisposable
    {
        ReaderWriterLockSlim obj;
        public ReadLock(ReaderWriterLockSlim lockObj)
        {
            obj = lockObj;
            lockObj.EnterReadLock();
        }
        public void Dispose()
        {
            obj.ExitReadLock();
        }
    }
    /// <summary>
    /// ReaderWriterLockSlimのUpgradeableReadLockをUsing句で楽に使えるようにしたもの
    /// </summary>
    public struct UpgradeableReadLock : IDisposable
    {
        ReaderWriterLockSlim obj;
        public UpgradeableReadLock(ReaderWriterLockSlim lockObj)
        {
            obj = lockObj;
            lockObj.EnterUpgradeableReadLock();
        }
        public void Dispose()
        {
            obj.ExitUpgradeableReadLock();
        }
    }
    /// <summary>
    /// ReaderWriterLockSlimのUpgradeableReadLockをUsing句で楽に使えるようにしたもの
    /// </summary>
    public struct WriteLock : IDisposable
    {
        ReaderWriterLockSlim obj;
        public WriteLock(ReaderWriterLockSlim lockObj)
        {
            obj = lockObj;
            lockObj.EnterWriteLock();
        }
        public void Dispose()
        {
            obj.ExitWriteLock();
        }
    }
}