[C#/Unity] 게임 서버 #3 - 스핀락, 컨텍스트 스위칭, AutoResetEvent

Donghee·2024년 8월 25일
0

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

스핀락 (Spin Lock)

스핀락은 저번 글에서 얘기했던 것처럼, 다른 스레드가 락을 소유하고 있다면, 그 스레드가 락을 풀어줄 때까지 계속 기다리는 락이다. 이는 소유하고 있는 락을 늦게 풀어주면 하나의 스레드를 낭비하는 셈이 되어, CPU 점유율이 상승한다는 문제점이 존재한다.

다음의 SpinLock 클래스를 보자.

    class SpinLock
    {
        volatile bool _locked = 0;

        public void Acquire()
        {
            while (_locked)
            {
            	// 대기
            }
			_locked = true;
        }

        public void Release()
        {
            _locked = false;
        }
    }

Acquire 메소드를 이용해 독점권 취득을 시도하고, Release 메소드로 독점권을 반환한다. 이는 겉보기엔 크게 문제가 없어보인다.
하지만 멀티스레드 환경에서 실행을 한다면, 이상하게 나온다.


    class Program
    {
        static SpinLock _locked = new SpinLock();

        static int _num = 0;

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _locked.Acquire();
                _num++;
                _locked.Release();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                _locked.Acquire();
                _num--;
                _locked.Release();
            }
        }

        static void Main(string[] args) 
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);
            Console.WriteLine(_num);
        }
    }

다음과 같이 Thread_1과 Thread_2로 멀티스레드 환경을 만들어 실행해보자. 우리가 예상하는 출력값은 0이 될 것이다. 확인해보자.

굉장히 이상한 값이 나오고 있다. _locked 변수를 volatile 키워드를 사용해줬음에도 불구하고 제대로 된 스핀락이 구현되지 않는 이유가 뭘까?

위의 그림을 보자. 두 스레드가 동시에 락에 접근하는 장면이다. 만약 지금 아무도 락을 건들지 않아 _locked 변수가 false인 상태라고 가정해보자. 스레드 1이 먼저 Acquire 메소드에서는 먼저 _locked가 true인지 확인한다. 그 이후 스레드 2도 Acquire 메소드를 실행해 _locked가 true인지 확인하면, 두 상황에서의 _locked는 모두 false이기 때문에 다음 명령으로 넘어가진다.

결국 _locked가 확인되면서 동시에 바뀌는 원자성이 보장돼야 하는데, 그러지 못한 것이 지금 SpinLock의 문제인 것이다. 이를 해결해보자.

Interlocked.Exchange

먼저 Interlocked.Exchange 메소드를 이용하는 코드이다. Interlocked.Exchange(ref 변수, 바뀔 값) 메소드는, 변수를 넣으면 바뀔 값으로 변수의 값을 바꿔주고, 기존의 들어있던 값을 반환해준다.

    class SpinLock
    {
        volatile int _locked = 0;

        public void Acquire()
        {
            while (true)
            {
                // 1: Interlocked.Exchange
                // _locked에 1을 넣고 기존에 들어있던 값을 original에 대입한다.
                int original = Interlocked.Exchange(ref _locked, 1);
                if (original == 0)
                {
                    break;
                }


                // 2: Interlocked.CompareExchange. Compare-And-Swap(CAS)
                int expected = 0;
                int desired = 1;
                if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
                        break;
                // _locked가 expected이면 desired로 바꾸는 처리를 한번에.

            }

        }

        public void Release()
        {
            _locked = 0;
        }
    }

이 코드를 보면 _locked에 1을 넣고, 기존에 들어있던 값을 original로 대입해 확인한다. original이 0인지 확인하는 부분이 늦게 실행되더라도, 이미 _locked에 들어있던 값이라는 것이 확실하므로, 원자성을 보장받는다.

Interlocked.CompareExchange

일명 Compare-And-Swap(CAS) 방식이다. 말 그대로 비교 후 변환이다. Interlocked.CompareExchange(ref 변수, desired, expected) 메소드는, 변수에 들어있던 값을 expected와 비교해 같다면, desired 값을 변수에 대입한다. 이후 Interlocked.Exchange와 동일하게 변수에 들어있던 기존 값을 반환해준다.

다음 코드를 보자.

    class SpinLock
    {
        volatile int _locked = 0;

        public void Acquire()
        {
            while (true)
            {
                // 2: Interlocked.CompareExchange. Compare-And-Swap(CAS)
                int expected = 0;
                int desired = 1;
                // _locked가 expected라면, desired로 교체한다. 기존의 값이 expected라면, while 문을 break한다.
                if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
                        break;

            }

        }

        public void Release()
        {
            _locked = 0;
        }
    }

Acquire 메소드에 있는 if문을 유의깊게 보도록 하자. _locked가 expected라면, desired로 교체한다. 기존의 값이 expected라면, while 문을 break한다.
이처럼 한 명령어를 통해 비교와 변수 값 바꾸기를 원자성을 가지고 동시에 실행하는 행위를 Compare-And-Swap(CAS) 이라고 한다.

Interlocked.Exchange이든, Interlocked.CompareExchange이든 어떤 버젼이든 원자성이 보장되기 때문에, 결과를 보면 0이 출력되는 걸 알 수 있다.

컨텍스트 스위칭 (Context Switching)

컨텍스트 스위칭은 프로세스에서의 버젼, 스레드에서의 버젼이 있다. 컨텍스트는 프로세스/스레드의 상태를 의미한다. 스레드 컨텍스트 스위칭은 일정 간격으로 한번씩 와서 락이 끝났는지 체크한다. 이 방식은 다소 랜덤성이 있어서 다른 스레드가 먼저 차지할 수도 있다는 단점이 있다.
이는 스핀락 Acquire 메소드의 확인하는 과정에서, 스레드를 잠시 휴식시키는 것으로 구현이 가능하다.

    class SpinLock
    {
        volatile int _locked = 0;

        public void Acquire()
        {
            while (true)
            {
                // 2: Interlocked.CompareExchange. Compare-And-Swap(CAS)
                int expected = 0;
                int desired = 1;
                // _locked가 expected라면, desired로 교체한다. 기존의 값이 expected라면, while 문을 break한다.
                if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
                        break;
                Thread.Sleep(1); // 무조건 1ms 휴식
                Thread.Sleep(0); // 조건부 양보 > 나보다 우선 순위가 낮은 애들한테는 양보 불가
                Thread.Yield(); // 관대한 양보 > 지금 실행이 가능한 스레드가 있다면 실행
            }

        }

        public void Release()
        {
            _locked = 0;
        }
    }

스레드를 잠시 휴식하는 메소드 3가지, Thread.Sleep(1), Thread.Sleep(0), Thread.Yield()를 살펴보자.

  • Thread.Sleep(1): 스레드를 무조건 1ms(숫자만큼) 휴식
  • Thread.Sleep(0): 자신보다 우선 순위가 낮은 스레드들에게는 양보 불가. 이외엔 양보
  • Thread.Yield(): 지금 실행 가능한 다른 스레드가 있다면 양보

이렇게만 보면 한 스레드를 낭비하는 스핀락보다 훨씬 효율적인 방법 같아 보인다. 하지만 컨텍스트 스위칭, 즉 스레드를 왔다갔다하며 스위칭하는 과정에는 생각보다 많은 비용이 든다.

위의 사진을 보자. 코어가 스레드에 빙의되어 실행될 때엔, 컨텍스트라는 모든 프로그램 정보를 복원시켜줘야 한다. 즉, 램에 있는 프로그램 관련 정보를 레지스터와 프로그램으로 불러와야 하는 것이다. 이 과정 자체가 큰 비용이 든다고 한다.
다시 한번 정리해보면, 스레드를 스위칭할 때엔 레지스터의 정보를 램으로 저장시켰다가, 코어가 다시 커널로 돌아갔다가, 스위칭하는 스레드에 빙의해 관련 정보들을 램에서 불러온다. 이런 과정의 비용들 때문에, 경우에 따라 스핀락이 더 효율적일 수도 있다.

AutoResetEvent

이외에도 커널에게 이벤트를 직접 요청해 락의 독점권이 해제되었는지를 알아내는 방법도 있다. AutoResetEvenet가 그것인데, 이는 직접 커널 레벨로 들어가 락이 열렸는지 확인하는 요청을 한다. 이를 이용해 Lock 클래스를 만들어보자.

    class Lock
    {
        AutoResetEvent _available = new AutoResetEvent(true);
        // AutoResetEvent는 문이 열려 점유한 뒤 문이 자동으로 닫히도록 되어 있음.
        //ManualResetEvent _available = new ManualResetEvent(true);

        public void Acquire()
        {
            _available.WaitOne(); // 입장 시도. 성공하면 자동으로 문을 닫음. Reset 필요 X
            //_available.Reset(); // Reset()은 문을 닫는 작업으로써 이미 AutoResetEvent의 작업과 중복됨.
        }

        public void Release()
        {
            _available.Set();
        }
    }

AutoResetEvent의 객체는 커널 레벨에서의 bool 변수라고 생각하면 편하다. 상태가 true면, 독점권을 점유할 수 있다는 것이다. 따라서 우리는 처음 _available의 상태를 true로 초기화시켜준다.
이후 독점권을 획득하기 위해 Acquire 메소드를 실행시켜보자. AutoResetEvent.WaitOne을 하게 되면, 독점권이 해제될 때까지 기다리고 bool 변수를 false로 만들어준다고 생각하자. AutoResetEvent의 친구인 ManualResetEvent는 Reset 메소드를 호출해줘야 값이 변하지만, AutoResetEvent는 이를 자동으로 진행시켜준다. 이후 Release 메소드에서는 Set 메소드를 통해 bool 변수를 다시 true로 만들어준다.

하지만 이렇게 AutoResetEvent를 활용하게 되면 커널단에 직접 왔다갔다 하는 것이기 때문에 굉장한 시간이 소요된다. 연속해서 10000번만 실행해도 결과가 굉장히 늦게 나오는 것을 볼 수 있다.

Mutex

커널단과의 소통을 통해 해결하는 방법은 Mutex라는 클래스도 존재한다.

    class Program
    {
        static int _num = 0;
        static Mutex _lock = new Mutex();
        // Mutex는 커널에서 지원해주는 것이기에 속도가 굉장히 느림.

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.WaitOne();
                _num++;
                _lock.ReleaseMutex();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.WaitOne();
                _num--;
                _lock.ReleaseMutex();
            }
        }

        static void Main(string[] args) 
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);
            Console.WriteLine(_num);
        }
    }
}

다음은 Mutex 객체를 락으로 이용한 코드다. 이도 AutoResetEvent와 비슷하게 WaitOne을 이용해 독점권 해제를 기다리고, 이후 등록해 작업을 진행한다. 이후 ReleaseMutex 메소드를 통해 독점권을 해제한다. 이는 AutoResetEvent처럼 커널 레벨로 직접 들어가 작업을 수행하는 것이기 때문에, 큰 비용이 소모된다.

profile
마포고개발짱

0개의 댓글