[C#/Unity] 게임 서버 #2 - 경합 조건, 락, 데드락

Donghee·2024년 8월 15일
0
post-thumbnail

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

경합 조건 (Race Condition)

경합 조건은 스레드들끼리 차례를 지키지 않고 같은 메모리에 작업을 진행해 순서와 결과가 예측이 불가능한 것을 뜻한다. 간단한 식당 주문 예시를 하나 보자.

주문 현황에 콜라가 등장했다. 이것을 본 직원들 3명이 동시에 반응해, 3개의 콜라를 각자 들고 간다.

결국 손님은 콜라 1개를 시켰지만, 직원들끼리 서로 순서를 지키지 않아 3개를 받게 된다. 이런 일이 벌어지지 않으려면 어떻게 해야 했을까? 콜라 주문을 확인하고, 먼저 누가 대응할 것인지 순서를 진행해야 했을 것이다. 이제 직원을 스레드로 생각해보자. 스레드들끼리 코드가 특정 메모리에 접근하는 우선순위를 따로 정해놓지 않아 발생하는 것이 경합 조건이다. 코드가 원자성(atomic)을 띈다면, 한 번에 실행되는 것이 보장되어 결과가 예측 가능할 것이다.

경합 조건 예제

코드로 발생할 수 있는 간단한 예제를 보자.

namespace ServerCore
{
    class Program
    {
        static volatile int number = 0;


        static void Thread_1()
        {
           for (int i = 0; i < 100000; i++)
            {
            	number++;
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
            	number--;
            }
        }

        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(number);
        }

    }
}

두 Task 객체를 활용해 number int형 변수를 10만번씩 증감시키는 코드다. 결과를 예상해보면, 어떤 순으로 작동되든 어차피 10만번씩 동작했으니, 0이 나올 것이라고 예상이 된다.

하지만 결과를 보면 23787, 25921 등 전혀 예상하지 못한 숫자가 나온다. 심지어 volatile 키워드를 붙여서 가시성을 확보해줬는데도 말이다. 서로 메모리의 최신 갱신 값을 가져왔는데도 결과가 예상치 못한 값이 나온 것이다. 여기는 증감연산자(++, --)가 원자성을 띄는 것처럼 보이는데, 왜 이런 결과가 일어났을까?
사실 증감연산자는 컴파일러가 한 명령어로 생각하지 않고, 3개의 과정으로 이루어진다. (Disassembly를 통해 어셈블리 코드 확인 가능)
이를 C#식으로 풀어쓰면,
1. 메모리에서 값을 가져옴 (int temp = number;)
2. CPU 레지스터에서 1을 증가시킴 (temp += 1;)
3. 메모리에 값을 저장함 (number = temp;)
다음과 같은 과정으로 이루어진다.

이 과정이 두 스레드에서 한 번씩 이뤄진다고 생각해보자. 순서는 우리가 정해놓은 게 없으니 임의로 정해진다.
number++의 1번 과정(0을 가져옴) -> number--의 1번 과정(0을 가져옴) -> number++의 2,3번 과정(number에 1을 저장) -> number--의 2,3번 과정(number에 -1을 저장)
이렇게 이루어지면, number++와 number--는 한번씩 진행되었지만, number에 0이 아닌 -1이 저장되었다. 결국 1,2,3번의 과정이 한번에 이루어지는 원자성이 요구되는 것이다.

Interlocked

이럴 때는 Interlocked 클래스의 메소드들을 활용할 수 있다.

설명에도 멀티스레드 환경에서 쓰이는 변수를 원자성있게 처리한다고 쓰여있다. 여기서 Interlocked.Increment(ref int);, Interlocked.Decrement(ref int);를 활용하면 증감연산자를 원자성있게 쓸 수 있다. 또한 Interlocked 클래스의 메소드들은 메모리 배리어를 간접적으로 활용하고 있기 때문에, 굳이 volatile 키워드로 가시성을 표시해줄 필요가 없다. 따라서 우리가 예상한대로 동작하도록 바꾼 코드는 다음과 같다.

        static int number = 0;
        static void Thread_1()
        {
           for (int i = 0; i < 100000; i++)
            {
                Interlocked.Increment(ref number);
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                Interlocked.Decrement(ref number);
            }
        }

중요한 것은 Interlocked 메소드들이 실행될 때, 원자성이 보장되어 실행 순서가 보장된다는 점이다. 한 스레드에서 Increment를 진행하면 그것이 끝날 때까지는 number에 다른 처리를 할 수 없고, Decrement도 마찬가지이다. 메모리 자체를 접근하기 때문에 ref 키워드가 활용되는 것이다.

락 (Lock)

위의 경합 조건 예제처럼 서로 스레드들이 동시에 접근하면 문제가 되는 코드 영역들을 임계 영역이라고 한다. 이런 임계 영역을 해결하는 가장 흔하고 범용적인 방식이 락 (Lock)이다. C#에서는 Monitor.Enter(Object), Monitor.Exit(Object)로 특정 상황에 대한 락을 생성하고 풀고 할 수 있다.

        static int number = 0;
        static object _obj = new object();

        static void Thread_1()
        {
           for (int i = 0; i < 100000; i++)
            {
                Monitor.Enter(_obj); 
                // Mutual Exclusive 상호배제. 범위를 지정해 싱글스레드의 형태로 활용할 수 있음.
                number++;
                Monitor.Exit(_obj);
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                Monitor.Enter(_obj); 
                    number--;
                Monitor.Exit(_obj);
            }
        }

Thread_1이던, Thread_2던, 먼저 _obj에 대한 락을 걸어두는 메소드의 스레드가 _obj에 대한 영역을 장악한다. Monitor.Exit(_obj)로 락을 풀기 전까지 다른 스레드들은 Monitor.Enter(_obj)로 인한 영역 점거를 할 수 없다. 이렇게 임계 구역을 한 스레드만 사용할 수 있게 하는 것을 상호 배제 (Mutual Exclusive)라고 한다. Monitor.Enter(Object), Monitor.Exit(Object)로 인한 락 관리는 사용자가 직접 걸고, 푸는 것을 해야하기 때문에 다소 불편하다. 락을 걸었다가 안 풀면, 혹은 빠른 return으로 인해 못 푸는 상황이 되면 모두가 그 임계 구역에 들어갈 수 없다.

따라서 Monitor 대신, lock이라는 편한 형태의 락이 존재한다.

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                lock(_obj)
                {
                    number++;
                }
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                lock(_obj)
                {
                    number--;
                }
            }
        }

다음과 같이 lock(Object){} 로 임계구역과 락을 지정해줄 수 있다.

데드락 (Dead Lock)

다음과 같이 락이 두개 있다고 가정해보자. 두 스레드가 각각 자물쇠 1과 자물쇠 2를 점거하고 있다. 이 상황에서 서로의 자물쇠를 차지하려고 한다. 그래야만 서로의 자물쇠를 해제하는데, 정작 서로의 자물쇠를 차지할 수가 없어서 누구도 상황을 진전시킬 수 없다.

이렇게 서로의 락을 쟁취하려고 하는데 서로 맞물려있어 아무것도 못하는 상황을 데드락 (Dead Lock) 이라고 한다.
데드락은 일반적인 상황에서는 발생하지 않다가, 유저가 급격히 몰려 처리가 느려진 경우에만 맞물려 발생하는 경우도 꽤 있다. 데드락을 방지하기 위해 락마다 고유의 id를 부여해 락 사이에 우선순위를 부여하는 방법도 있으나, 이것은 예상할 수 있는 상황에서만 잘 동작하는 것이다. 따라서 예상치 못한 데드락이 발생했을 때엔 결국 원초적인 해결방법이 되지는 못하는 것이다.

락 구현 방식

락은 차지하려는 락을 다른 스레드가 이미 차지하고 있을 때의 대처법에 따라 구현 방식이 나뉜다.
1. 스핀락 (Spin Lock): 다른 스레드가 락을 소유하고 있다면, 그 스레드가 락을 풀어줄 때까지 계속 기다린다. 늦게 끝나면 스레드를 낭비하는 셈이 되어, CPU 점유율이 상승하는 문제점이 있다.
2. 컨텍스트 스위칭 (Context Switching): 일정 간격으로 한번씩 와서 락이 끝났는지 체크한다. 이 방식은 다소 랜덤성이 있어서 다른 스레드가 먼저 차지할수도 있다.
3. AutoResetEvent: 이벤트를 이용해 락 소유가 끝났는지 운영체제에게 통보 받는다.

이걸 구현하는 건 다음 시간에 해보도록 하자.

profile
마포고개발짱

0개의 댓글