3 분 소요

이 글은 Rookiss님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버를 보고 헷갈리는 부분 위주로 정리한 글입니다. 틀리거나 부족한 부분이 있을 수 있습니다.

Lock

Lock이란 공유 자원에 대한 동시 접근을 제어하는 데 사용되는 기술이다. 보통 멀티 쓰레드 환경에서 작업의 원자성을 보장하기 위해 Lock을 이용한다.

만약 어떠한 어느 한 쓰레드에 의해 공유 자원에 대해 Lock이 걸렸고, 다른 쓰레드가 공유 자원에 접근하고자 할 때 어떤 식으로 대처할지에 따라 여러 방법을 구현될 수 있다.

SpinLock

Lock의 구현 이론중 하나인 SpinLock은 다른 쓰레드가 공유 자원에 Lock을 걸고 있을 때 끝날 때 까지 계속 기다리는 방법(루프를 돌면서 계속 확인)으로 구현된다.

spinlock의 구현은 다음과 같다. 중요한 점은 멀티쓰레드 환경이기 떄문에 공유자원인 _locked 변수를 바꾸는 작업 또한 원자성이 보장되어야 한다. 그렇기 때문에 Interlocked 클래스를 이용한다.
코드의 흐름은 Acquire()함수가 lock에 접근을 시도하여 이미 잠긴 상태라면 while문으로 끝날 때까지 무한정 대기, 열린 상태라면 해당 쓰레드가 사용중이기에 _locked를 잠김 상태로 전환한다.
사용이 끝나면 Release()함수로 다른 쓰레드가 접근할 수 있도록 열림 상태로 전환한다.

class SpinLock
{
    volatile int _locked = 0; // 0은 열려있는 상태, 1은 잠김 상태
    public void Acquire()
    {
        while (true)
        {
            // CAS
            // 비교하여 0이 맞다면 1로 바꾼다, 이전 값을 반환한다.
            if (Interlocked.CompareExchange(ref _locked, 1, 0) == 0)
                // 열림 상태일 경우 루프를 빠져나옴
                break;
        }
    }

    public void Release()
    {
        // 열림
        _locked = 0;
    }
}
internal class Program
{
    static int _num = 0;
    static SpinLock _lock = new SpinLock();

    static void Thread_1()
    {
        for (int i = 0; i < 100000; i++)
        {
            // lock 사용 시도 1. 이미 잠김 상태라면 대기 2. 열림 상태리면 바로 사용
            _lock.Acquire();
            _num++;
            // 사용 끝 
            _lock.Release();
        }
    }

    static void Thread_2()
    {
        for (int i = 0; i < 100000; i++)
        {
            _lock.Acquire();
            _num--;
            _lock.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);
    }
}

추가로 Spinlock을 여기서는 간한하게 직접 구현했지만 이미 구현된 클래스가 존재한다.

일정 시간 뒤에 다시 접근

spinLock이 루프를 돌면서 계속 대기하는 방법이였다면, 일정 시간 마다 확인하는 방법도 구현이 가능하다.

SpinLock과 거의 같지만 쉬는 시간을 Thread.Sleep이나 Yield로 추가하여 구현한다.

class WaitLock
{
    volatile int _locked = 0;
    public void Acquire()
    {
        while (true)
        {
            // CAS
            // 비교하여 0이 맞다면 1로 바꾼다, 이전 값을 반환한다.
            if (Interlocked.CompareExchange(ref _locked, 1, 0) == 0)
                break;

            // 다음과 같은 방법들 중 하나 선택
            Thread.Sleep(1); // 무조건 휴식 => 무조건 1ms정도 쉬고 싶다.
            Thread.Sleep(0); // 조건부 양보 => 나보다 우선순위가 낮은 애들한테는 양보 불가 => 우선순위가 같거나 높은 스레드가 없으면 다시 본인한테
            Thread.Yield();  // 관대한 양보 => 관대하게 양보할테니, 지금 실행 가능한 쓰레드가 있으면 실행 => 실행 가능한 애가 없으면 남은 시간 소진
        }
    }

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

주의해야할 점은 쉬면서 다른 스레드에게 양보한다 해서 무조건 성능이 좋아지는 것은 아니다.

다른 스레드에게 양보했다가 다시 돌아오는 이 과정에서 문맥 교환(Context switching)이라는 비용이 많이 드는 과정이 필요하기 때문에 경우에 따라서는 spinLock보다 성능이 좋지 못할 수 있다.

작업이 끝나면 누군가 알려주기

작업이 끝나는 것을 지켜보는 누군가를 세워 작업이 끝났을 때 접근하는 방법이 있다.

이 방법은 Event를 사용하여 구현한다.

 class EventLock
 {
     // 커널에서의 bool 값이라 생각하면 됨
     AutoResetEvent _available = new AutoResetEvent(true); // true 아무나 들어올 수 잇음, false 닫힌 상태

     public void Acquire()
     {
         _available.WaitOne();   // 입장 시도
         // _available.Reset(); // flag = false,  AutoResetEvent의 WaitOne에는 이미 Reset이 포함되어 있음
     }

     public void Release()
     {
         _available.Set(); // flag = true
     }
 }
 internal class Program
 {
     static int _num = 0;
     static EventLock _lock = new EventLock();

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

     static void Thread_2()
     {
         for (int i = 0; i < 10000; i++)
         {
             _lock.Acquire();
             _num--;
             _lock.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);
     }
 }

AutoResetEvent와 비슷한 클래스로 ManualResetEvent도 있다.
ManualResetEvent는 WaitOne() 함수에서 자동으로 Reset()을 실행 해주지 않기 떄문에 수동으로 Reset()도 해줘야한다.(두 작업의 원자성도 따로 보장해주어야 함)

결론

쓰레드끼리 경합할 때 기다리는 쪽을 어떻게 구현할지는 크게 3가지로 나눌 수 있다.

  1. 무작정 기다림
  2. 일정 시간마다 다시 시도
  3. 작업이 끝났을 때 다시 시도

댓글남기기