Unity

[인프런 강의 정리] ReaderWriterLock, 구현연습, Thread Local Storage

wny0320 2025. 3. 25. 22:20

이 글은 아래 강의를 정리한 글이다

 

[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버 강의 | Rookiss - 인프런

Rookiss | 네트워크/멀티쓰레드/운영체제 등 핵심 전공 지식을 공부하고 게임 서버를 바닥부터 만들어보면서 MMORPG 기술을 학습하는 강의입니다., MMORPG 개발에 필요한 모든 기술, C# + Unity로 Step By St

www.inflearn.com

해당 정리글을 한번에 보고 싶다면 아래 링크를 참조하길 바란다

 

[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버 | Notion

서버OT

mesquite-prune-8c9.notion.site

Lock과 관련된 이야기라 Thread Local Storage까지 한 번에 정리해서 다루려고 한다.


ReaderWriterLock

기본적으로 쓸 때만 락을 걸고자 하는 방향에서 만든 락이 ReaderWriterLock

 

예를 들어 WriterLock이 걸려있지 않다면 Reader는 Lock이 걸려있지 않을 것처럼 작동한다.

 

아래는 ReaderWriterLock을 직접 구현한 것이다.

 

ReaderWriterLock 구현

더보기

 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ServerCore
{
    // 락을 만들기 전에 규칙을 미리 정해야함
    // 재귀적 락을 허용할지 (Yes) WriteLock -> WriteLock OK, WriteLock -> ReadLock OK, ReadLock -> WriteLock NO
    // 스핀락 정책 (5000번 -> Yield)
    class Lock
    {
        const int EMPTY_FLAG = 0x00000000;
        const int WRITE_MASK = 0x7FFF0000;
        const int READ_MASK = 0x0000FFFF;
        const int MAX_SPIN_COUNT = 5000;
        // 32bit
        // [Unused(1bit)] 음수가 될 가능성이 있기 때문에 사용하지 않음
        // [WriteThreadId(15bit)] 한 번에 한 쓰레드만 획득 가능, 그 쓰레드를 기록 bool이 아닌 Id를 넣는 이유는 재귀적 락 때문
        // [ReadCount(16bit)] 여러 쓰레드들이 Read를 잡는 것을 Counting
        int _flag = EMPTY_FLAG;
        // 플래그에 넣지 않아도 되는 이유
        // Write가 상호 배타적이었기 때문
        // 다시 말해 다른 쓰레드가 WriteLock을 잡았으면 그 쓰레드만 잡을 수 있기 때문
        int _writeCount = 0;

        // 주의사항) WriteLock 이후 ReadLock을 한 경우 ReadLock을 먼저 풀고 WriteLock을 풀어야함
        public void WriteLock()
        {
            // 동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인
            int lockThreadId = (_flag & WRITE_MASK) >> 16;
            if(Thread.CurrentThread.ManagedThreadId == lockThreadId)
            {
                _writeCount++;
                return;
            }

            // 아무도 WriteLock or ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다
            int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
            // 15비트만 들어가야하므로 16비트만큼 시프트 연산으로 밀고 WRITE_MASK로 &연산을 해서 다른 부분에 대한 비트값을 0으로 바꿈(쓰레기값 대비) 
            while(true)
            {
                for(int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
                    {
                        _writeCount = 1;
                        return;
                    }
                    // 시도해서 성공하면 return
                    //if (_flag == EMPTY_FLAG)
                    //    _flag = desired;
                    // 위와 같이 플래그 비교와 값 대입을 하는 것이 나눠져 있으면 멀티 쓰레드에서 문제가 있음
                }

                Thread.Yield();
            }
        }
        public void WriteUnlock()
        {
            int lockCount = --_writeCount;
            if(lockCount == 0)
                Interlocked.Exchange(ref _flag, EMPTY_FLAG);
            // flag를 EMPTY로 밀어버림
        }
        public void ReadLock()
        {
            int lockThreadId = (_flag & WRITE_MASK) >> 16;
            if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
            {
                Interlocked.Increment(ref _flag);
                // 나머지 부분을 터치하지 않고 맨 끝단에 있는 ReadCount를 +1할 것
                return;
            }

            // 아무도 WriteLock을 획득하고 있지 않으면 ReadCount를 1 늘린다
            // ReadCount는 여러 쓰레드가 획득할 수 있기 때문
            while (true)
            {
                for(int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    int expected = (_flag & READ_MASK);
                    // 내가 필요로 하는 값이 WRITE_MASK 부분이 0인 것이기 때문에 READ_MASK 부분을 빼고 0으로 밀어버려서 비교하기 위한 값 생성
                    // ReadLock을 여러 쓰레드가 동시에 들어오게 된다면 처음 들어온 쓰레드는 성공하지만
                    // 다음으로 들어온 쓰레드는 예상값이 +1로 바뀌어서 유효하지 않아 실패하고 다시 시도하게 된다
                    // ex) A 쓰레드 B 쓰레드 동시에 ReadLock
                    // A 쓰레드 expected = 0
                    // B 쓰레드 expected = 0
                    // A 쓰레드가 B 쓰레드보다 먼저 실행 됐다는 가정
                    // A 쓰레드 입장에서 조건 만족, expected + 1 실행
                    // B 쓰레드 입장에서 _flag가 expected + 1이 되었기에 조건에 맞지 않아 실행되지 않음
                    if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
                        return;
                    //if((_flag & WRITE_MASK) == 0)
                    //{
                    //    // flag의 WRITE_MASK 부분이 0이라면
                    //    _flag = _flag + 1;
                    //    return;
                    //}
                    // 위에서 서술했듯이 대입과 비교가 나눠질 경우 멀티 쓰레드 환경에서 문제가 생길 수 있음
                }
                Thread.Yield();
            }
        }
        public void ReadUnlock()
        {
            // 1을 늘렸기 때문에 줄이면 됨
            Interlocked.Decrement(ref _flag);
        }
    }
}

Thread Local Storage(TLS)

Thread Local Storage의 예시는 식당에 비교하여 알 수 있다.

위의 사진과 같이 있을 때 Lock을 통해 직원들이 한 곳에 몰리는 것을 막고 일손을 잘 배치해야한다.

 

멀티 쓰레드도 마찬가지이다.

 

해당 비유를 게임으로 다시 적자면 아래와 같다.

 

하지만 이렇게 되는 경우 한쪽에 몰리는 경우 처리가 어렵다

 

예를 들어 대규모 MMORPG의 경우 유저간의 전쟁이 일어난다고 가정하면 같은 장소로 패킷이 몰리게 된다.

 

아래의 사진이 그 상황이다.

 

따라서 멀티 쓰레드를 이용해서 쓰레드 하나씩 락에 진입해서 같은 패킷을 처리하게 되면 오히려 싱글 쓰레드보다 성능이 낮아지기도 한다.

 

그 이유는 락을 걸고 푸는 작업에도 리소스가 소모되기 때문이다.

 

따라서 다음과 같은 문제가 발생하였을 때 최대한 효율적으로 하기 위하여 Thread Local Storage라는 공간이 필요하다.

 

 

Heap 영역과 데이터 영역은 쓰레드끼리 공유의 영역이고 스택은 쓰레드 각각의 영역인 것은 맞지만 임시 저장 공간(휘발성)으로 사용하기 불안정하다.

 

따라서 TLS가 존재하며 사용하는 것이다.

 

TLS 사용해보기

더보기
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        // 그냥 static은 모든 쓰레드가 전역으로 사용하게 되기 때문에 ThreadLocal<string>으로 사용
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>();

        static void WhoAmI()
        {
            // ThreadName을 TLS 영역에 할당
            ThreadName.Value = $"My Name Is {Thread.CurrentThread.ManagedThreadId}";

            // 다른 쓰레드가 간섭하는지 알아보기 위해 1초간 Sleep
            Thread.Sleep(1000) ;

            // 해당 쓰레드의 이름을 출력
            Console.WriteLine(ThreadName.Value);
        }
        static void Main(string[] args)
        {
            // 안에 있는 함수를 쓰레드 풀에서 알아서 가져가서 사용하는 함수
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }
}

다음과 같이 코드를 작성하여 진행하면 아래의 결과가 나온다.

다른 쓰레드에 간섭받지 않고 자신의 ThreadName을 출력하는 것을 볼 수 있다.

 

반면에 static string으로 선언하게 된다면

다음과 같이 전역으로 선언되었기 때문에 간섭을 받는 것을 볼 수 있다.

 

하지만 위의 코드에서는 같은 쓰레드가 작업을 한 경우 ThreadName을 할당했음에도 다시 할당하는 경우가 생긴다.

 

따라서 인자에 람다식을 통해 할당되지 않은 경우에만 할당을 하게 한 후 작동해보았다.

 

쓰레드가 반복 호출됐는지 확인하는 코드 

더보기
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        // 그냥 static은 모든 쓰레드가 전역으로 사용하게 되기 때문에 ThreadLocal<string>으로 사용
        // 인자에 람다식으로 할당하게 된다면 값이 할당되어있지 않을 경우만 할당하게 된다.
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>(()=> { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; });


        static void WhoAmI()
        {
            // 이미 만들어졌으면 True, 만들어지지 않았다면 False를 반환하는 함수
            bool repeat = ThreadName.IsValueCreated;
            if (repeat)
                Console.WriteLine(ThreadName.Value + " (repeat)");
            else
                Console.WriteLine(ThreadName.Value);
        }
        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(3, 3);
            // 안에 있는 함수를 쓰레드 풀에서 알아서 가져가서 사용하는 함수
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI);
        }
    }
}

결과는 아래와 같다.

 

강의 내용을 보면 내 결과는 심하게 치우쳐서 나온것 같다.