[C#/Unity] 게임 서버 #4 - ReaderWriterLock, Thread Local Storage (TLS)

Donghee·2024년 8월 28일
0

본 게시물은 Rookiss님의 '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 강의를 듣고 정리한 내용임을 미리 알립니다.

ReaderWriterLock

락을 통해 특정 구간, 혹은 특정 변수를 상호배타적으로 막는 행위는 멀티스레드 프로그래밍에서 반드시 필요하다. 하지만 값을 읽는 Read 행위는 가능하게 하고, 값을 변형시키는 Write 행위만 상호배타적으로 막을 수 있게 하려면 어떻게 해야할까? 평소에는 락이 없는 것처럼 행위를 막지 않다가, Write 같은 특수한 상황에서만 락을 걸어주면 될 것이다. 이것이 바로 Reader-Writer Lock이다.

ReaderWriterLockSlim

먼저 C#에서 제공하는 클래스를 통해 실행시켜보자.

    class Program
    {
        static ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();

        class Reward
        {

        }

        static Reward GetRewardById(int id)
        {
            _lock.EnterReadLock();

            _lock.ExitReadLock();
            return null;
        }

        static Reward AddReward(int id)
        {
            _lock.EnterWriteLock();

            _lock.ExitWriteLock();
            return null;
        }

        static void Main(string[] args)
        {

        }
    }

C#에는 ReaderWriterLockSlim이라는 클래스로 구현이 되어있다. (그냥 ReaderWriterLock도 있으나, Slim이 붙은 것이 더 최신 버젼이라고 한다.) Read와 Write에 각각 락을 걸어줄 때는, Enter(Read/Write)Lock을, 풀어줄 때는 Exit(Read/Write)Lock을 해주면 된다.

직접 구현해보기

이번엔 직접 구현도 해보자. 먼저 우리는 _flag라는 int 변수를 비트마스킹을 통해 플래그 변수로 만들어줄 것이다.

    class Lock
    {
        const int EMPTY_FLAG = 0x00000000;
        const int WRITE_MASK = 0x7FFF0000;
        const int READ_MASK = 0x0000FFFF;
        const int MAX_SPIN_COUNT = 5000;

        // [Unused(1)] [WriteThreadId(15)] [ReadCount(16)]
        int _flag = EMPTY_FLAG; 
    }

_flag는 int 변수로 32비트이다. 이를 이용해 특정 비트에 특정 정보를 저장한다고 약속을 해놓으면, 한 변수 안에 여러 정보를 저장할 수 있다. 사용하지 않는 첫 비트를 제외하고, 15비트는 Write를 점거한 스레드의 아이디(없다면 0)를, 나머지 16비트는 Read를 하고 있는 스레드의 수를 저장하도록 했다.
또한 이 _flag에서 정보를 쉽게 추출하기 위한 마스크들을 미리 const 변수로 선언해놓았다. 16진수의 F는, 2진수에서 1111에 해당한다는 것을 상기하면, 쉽게 이해할 수 있을 것이다.

이제 이 클래스의 WriteLock, WriteUnlock, ReadLock, ReadUnlock 메소드를 각각 구현해보자.

WriteLock

        public void WriteLock()
        {
            // 아무도 WriteLock or ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다
            int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
            while (true)
            {
                for (int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    // 시도를 해서 성공하면 return
                    if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
                    {
                        return;
                    }
                }

                Thread.Yield();
            }
        }

먼저 WriteLock이다. 저번에 언급했던 Compare-And-Swap을 이용하면 쉽게 구현할 수 있다. 비교와 값 대체가 원자성을 가지며 동시에 이뤄져야하는 것이다.

desired에 현재 스레드 id를 WRITE_MASK에 맞는 형태로 대입해준다. 이후 SpinLock처럼 일정 횟수 돌아가면서 Interlocked.CompareExchange를 이용해 CAS를 수행한다. 비교했을 때 _flag가 EMPTY_FLAG라면 즉, 현재 어떤 스레드도 Read나 Write를 수행하고 있지 않다면, WriteThreadId가 들어갈 부분에 desired를 넣고 종료한다.

WriteUnlock

        public void WriteUnlock()
        {
            // 초기화
            Interlocked.Exchange(ref _flag, EMPTY_FLAG);
        }

WriteUnlock은 간단하다. WriteLock이 되어있는 상태라면 이미 _flag에 WriteThreadId 부분밖에 없을테니, EMPTY_FLAG로 초기화시켜주면 되는 것이다.

ReadLock

        public void ReadLock()
        {
            // 아무도 WriteLock을 획득하고 있지 않을 때, ReadCount를 1 늘린다
            while (true)
            {
                for (int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    int expected = (_flag & READ_MASK);
                    // expected = Write 플래그를 0으로 밀어버린 ReadCount의 값.
                    if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
                        return;
                }
                Thread.Yield();
            }
        }

ReadLock은 아무도 WriteLock을 차지하고 있지 않을 때 이루어져야 한다. expected 변수에는, _flag가 READ_MASK와 AND연산을 진행한 값을 저장한다. 즉, _flag에 있던 ReadCount 값 외에는 어떤 정보도 남지 않은 것이 expected인 것이다. 여기서도 CAS를 진행해, _flag가 expected와 값이 같다면 즉, _flag가 WriteThreadId에 대한 값을 가지고 있지 않았다면, ReadLock을 허락하고 ReadCount를 +1 해준다.

ReadUnlock

        public void ReadUnlock()
        {
            Interlocked.Decrement(ref _flag);
        }

ReadUnlock도 WriteUnlock과 같이 간단하다. ReadCount가 처음 16비트를 차지하는 정보이다보니, 이를 -1만 시켜주면 된다. Interlocked의 Decrement 메소드를 이용했다.

Thread Local Storage (TLS)

지금까지는 락을 통해 멀티스레드 환경에서 자원에 접근하는 법을 알아봤다. 하지만 락을 많이 건다고 꼭 최선이 아니다. 멀티스레드를 사용하는 이유는 한번에 여러 일을 병행적으로 처리하기 위함인데, 락은 결국 다른 스레드의 동작을 막으면서 자원을 독점하는 형태이다보니 과도하게 사용하면 멀티스레드를 사용한 목적의 본질과 멀어지게 된다.

결국 스레드별로 분리된 영역을 통해 자신의 영역 내에서 편하게 작업을 할 수 있는 것이 중요하다. 이미 스택 영역이 있지 않나라고 생각할 수도 있지만, 스택 영역은 이미 메소드에서 사용하고 있고, 메소드가 호출되고 완료될 때마다 값이 생기고 사라지고를 반복하는 다소 불안정한 영역이다. 따라서 별도의 영역이 필요한데, 이런 저장소를 Thread Local Storage라고 한다.

스레드 이름 출력

    class Program
    {
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>();

        static void WhoAmI()
        {
            ThreadName.Value = ($"{Thread.CurrentThread.ManagedThreadId}");

            Thread.Sleep(1000); //1초 대기

            Console.WriteLine(ThreadName.Value);
        }

        public static void Main(string[] args)
        {
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }

우선 스레드를 여러개 실행시켜서, 각각의 이름을 출력시켜보자. ThreadLocal이라는 제네릭 클래스를 이용해 스레드 각각에서 별도로 저장되는 string 변수를 ThreadName이라는 이름으로 선언해준다.

Parallel.Invoke는 처음보는 메소드이지만, 안에 있는 Action들을 별도의 Task로 실행시켜준다고 보면 된다. 이렇게 실행시켜보면, 각각의 다른 메소드들의 이름이 출력되는 걸 알 수 있다. 만약 TLS 형태가 아니었다면, ThreadName에 접근할 때마다 락을 걸었다 풀었다하는 작업을 반복했어야 할 것이다.

이를 응용해, 메인에 static으로 많은 일감들을 올려놓고, 스레드들이 한 뭉텅이로 빼가 TLS에 저장해 알아서 처리하게끔 하는 방법도 있다고 한다.

profile
마포고개발짱

0개의 댓글