[Part 4 게임 서버] 멀티쓰레드 프로그래밍 5(Lock 구현 이론 ~ AutoResetEvent)

<Lock 구현 이론>

1. 어떤 스레드가 임계 구역에 이미 들어가있을때, 다른 스레드는 어떻게 해야하는 가?

 a) 프로세스가 임계 구역에 빠져나올때까지 기다린다. (Spin Lock) 

 -> 임계 구역에 들어간 프로세스가 굉장히 오랫동안 작업을 할 수 있음

 -> 단순히 기다린다고 생각하지만, 프로세스가 임계 구역에 있는지를 계속 확인해야하기 때문에 오랫동안 기다릴 시 CPU 점유율이 확올라간다. 

 b) 제자리로 돌아온 후 나중에 (몇 분 후에 랜덤하게 )다시 접근한다. (Context Switching)

 -> 랜덤하게 상황이 발생, 내가 제자리로 간 직후 다른 프로세스가 임계 구역에 들어갈 수 있다. 내가 다시 접근했을때 바로 임계 구역에 들어갈 수 있을지는 미지수 

 -> CPU의 소유권을 포기하는 의미: 해당 CPU는 다른 작업을 하게 된다. 

 c) 운영체제(커널)한테 부탁하여 Event를 셋팅해서 프로세스가 임계 구역에 빠져나왔을 때 Event를 발생시킨다. (Event)

 

<SpinLock>

위에서 얘기한 Lock 구현 해보기 

 

1. SpinLock 클래스 구현

아래의 SpinLock 클래스로 SpinLock구현할 수 있다.

  a) Acquire 함수는 임계 구역에 들어가기 위해 잠금을 하는 함수로 누군가가 잠갔다면 _locked가 true이기 때문에 계속 while문을 통해 기다린다. 그 후에 누군가 잠금을 해제하면 내가 잠그기위해 _locked = true로 한다.

  b) Release 함수는 임계 구역에 빠져나오기 위해 잠금 해제를 위한 _locked = false를 한다.  

	class SpinLock
    {
        volatile bool _locked = false;
        // 임계 구역에 들어가기 위해 잠금
        public void Acquire()
        {
            while (_locked)
            {
                // 잠김이 풀리기를 기다린다. 
            }

            // 내꺼!
            _locked = true;
        }
        // 임계 구역에 빠져나오면서 잠금 해제 
        public void Release()
        {
            _locked = false;
        }
    }

 c) 직접 실행을 위해 다음과 같이 코드를 짠다. 

결과는 아래와 같이 이상한 값이 나온다. 

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

namespace SeverCore
{
    class SpinLock
    {
        volatile bool _locked = false;
        // 임계 구역에 들어가기 위해 잠금
        public void Acquire()
        {
            while (_locked)
            {
                // 잠김이 풀리기를 기다린다. 
            }

            // 내꺼!
            _locked = true;
        }
        // 임계 구역에 빠져나오면서 잠금 해제 
        public void Release()
        {
            _locked = false;
        }
    }
    class Program
    {
        static int _num = 0;
        static SpinLock _lock = new SpinLock();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _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);
        }
    }
}

2. 위의 문제점 

스레드가 거의 동시에 Acquire 함수에 접근을 하면 둘다 임계 구역에 접근할 수 있다. 

즉, _locked = true를 통해 임계 구역을 잠그기 전에 현재 _locked의 false 값에 접근한 것

=> 임계 구역에 들어간 다음 잠그는 동작이 원자적으로 이뤄져야한다. 

* 아래의 while문과 _locked = true, 대기 상태와 문을 잠그는 부분이 따로 작성되어 있음 스레드가 거의 동시에 들어오면 둘다 while문을 통과하게 된다. 

	public void Acquire()
        {
            while (_locked)
            {
                // 잠김이 풀리기를 기다린다. 
            }

            // 내꺼!
            _locked = true;
        }

3. 위의 문제점 해결을 위해 Interlocked 이용

 a) Interlocked.Exchange를 이용하여 특정값을 원자적으로 바꿀 수 있다. 

이때, _locked 변수를 bool이 아닌 int로 바꿔서 0이면 false, 1이면 true로 해준다.

Exchange 함수는 return값으로 해당 변수의 원래의 값을 반환한다. 만일 original이 1이면 이미 누군가가 들어가 있음을 의미하고 0이면 임계 구역에 아무도 없었음을 의미한다. 

* original은 스택에 들어가있는 변수이므로 아래와 같이 사용해도 되지만, _locked는 공유 변수로 조심히 다뤄져야함

	public void Acquire()
        {
            while (true)
            {
                int original = Interlocked.Exchange(ref _locked, 1);
                if (original == 0) // 이전에 임계 구역에 아무도 없는 지 확인 
                    break;
            }
        }

위의 처럼 수정한 후 실행하면 결과 값 0 이 나온다. 

 

b) Interlocked.CompareExchange함수를 사용한다. 

Interlocked.CompareExchange(ref _locked, 1, 0) -> _locked의 변수를 0과 비교했을 때, 둘이 같으면 _locked 변수를 1로 바꿔준다. 

해당 성공 여부를 알기위해 return값(원래의 값)을 받아와서 아래와 같이 확인한다. 

		while (true)
            {
                // CAS - Compare-And-Swp
                int original = Interlocked.CompareExchange(ref _locked, 1, 0);
                if (original == 0)
                    break;
            }

위에는 가독성이 떨어지기 때문에 아래와 같이 변경

		while (true)
            {
                // CAS - Compare-And-Swp
                int expected = 0;
                int desired = 1;
                if(Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
                    break;
            }

 

<Context Switching>

1. 랜덤한 시간 동안 다른 일은 한 후, 다시 임계 구역에 접근

: 임계 구역에 어떤 스레드가 들어왔는지에 대해 판단하는 것은 Spin Lock과 똑같지만, 그 이후에 어떻게 다시 접근할 건지가 다름

즉, if 아래문이 다르다. 

 a) Thread.Sleep(1) => 무조건 휴식 => 무조건 1ms 쉰다.

 b) Thread.Sleep(0) => 조건부 양보 =>나보다 우선순위가 낮은 애들한테는 양보 불가 => 우선순위가 나보다 같거나 높은 쓰레드가 없으면 다시 본인한테 

* 이때, 우선 순위:  운영체제가 중요도에 따라 먼저 실행해야할 스레드의 순위를 정함 

 c) Thread.Yield() => 관대한 양보 => 조건에 상관없이 지금 실행이 가능한 쓰레드가 있으면 실행, 실행 가능한 쓰레드가 없으면 내가 실행

		while (true)
            {
                // CAS - Compare-And-Swp
                int expected = 0;
                int desired = 1;
                if(Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
                    break;

                // 쉬다 올게
                Thread.Sleep(1); // 무조건 휴식 => 무조건 1ms 정도 쉬고 싶어요
                Thread.Sleep(0); // 조건부 양보 => 나보다 우선순위가 낮은 애들한테는 양보 불가 => 우선 순위가 나보다 같거나 높은 쓰레드가 없으면 다시 본인한테 
                Thread.Yield(); // 관대한 양보 => 조건을 걸지 않고 실행이 가능한 쓰레드가 있으면 실행하세요 => 실행 가능한 애가 없으면 남은 시간 소진
            }

 

2. Context Switching 비용 

 a) 실행되는 프로세스를 바꿀때, 사용자 모드에서 커널 모드로 바뀐 뒤에 우선 순위에 따라 다음 프로세스가 실행된다. 

 b) 프로세스가 실행이 되면 해당 프로세스의 상태, 무엇을 하고 있었는지, 어디까지 진행됐는지 등등의 정보가 RAM에 저장되어 있다. 

실행되는 프로세스가 바뀌게 되면 해당 프로세스에 대한 내용을 RAM에서 가져와서 래지스터에 저장한다. 이러한 것이 Context Switching 

 

=> 경우에 따라 위의 방법처럼 나의 권한을 포기하고 다른 쓰레드에게 권한을 넘기는게 더 효율적인 것처럼 보이지만 Context Switching 비용이 들기 때문에 때로는 User 모드에서 계속 기다리는 Spin Lock이 효율적일수 도 있다. 

 

<AutoResetEvent>

1. Event를 사용 

: 운영체제(커널)에 부탁하여 임계구역에 들어갔던 쓰레드가 임계구역을 빠져나오면 Event를 발생시킨다. 

 a) Auto Reset Event

 톨게이트처럼 차 한대가 들어가면 자동으로 문이 닫히고 다음 차는 대기를 한다.

 b) Manual Reset Event

 방문처럼 문을 수동으로 잠궈야한다. 누군가 문을 닫지 않고 나오면 수많은 쓰레드가 임계 구역에 들어갈 수 있다. 

 

2. Auto Reset Event 사용: 아래의 코드는 Auto Reset Event를 사용한 코드 

 1) AutoResetEvent를 사용: 커널에서 사용하는 bool 느낌으로 임계 구역에 접근이 가능한지 가능하지 않은지를 true와 false로 나타냄

 2) Acquire 함수에서 WaitOne은 입장과 동시에 문을 닫는 행동을 자동으로 한다 -> _available = false로 자동으로 바꾼다. 

 3) Release 함수에서 Set은 flage = true로 바꾼다. 

 4) 이후 전체 코드를 통해 실행하면 한참 걸려서 0이 나온다. -> 커널모드와 User모드가 왔다갔다하기 때문에 오래 걸림

class Lock
    {
        // 커널에서 사용하는 bool 느낌 
        AutoResetEvent _available = new AutoResetEvent(true); // true일시 available 임계구역 이용가능
                                                              // 
        // 임계 구역에 들어가기 위해 잠금
        public void Acquire()
        {
            _available.WaitOne(); // 입장 시도: 문을 닫는 행동을 자동으로 해줌 
        }
        // 임계 구역에 빠져나오면서 잠금 해제 
        public void Release()
        {
            _available.Set(); // flag = true로 
        }
    }

* 전체 코드 

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

namespace SeverCore
{
    class Lock
    {
        // 커널에서 사용하는 bool 느낌 
        AutoResetEvent _available = new AutoResetEvent(true); // true일시 available 임계구역 이용가능
                                                              // 
        // 임계 구역에 들어가기 위해 잠금
        public void Acquire()
        {
            _available.WaitOne(); // 입장 시도: 문을 닫는 행동을 자동으로 해줌 
        }
        // 임계 구역에 빠져나오면서 잠금 해제 
        public void Release()
        {
            _available.Set(); // flag = true로 
        }
    }
    class Program
    {
        static int _num = 0;
        static Lock _lock = new Lock();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _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);
        }
    }
}

3. Manual Reset Event 사용

: 아래와 같이 ManulResetEvent를 사용하면, WaitOne 함수를 사용하면 입장을 시도하는 일만 하고 문을 닫는 행동은 안하기 때문에 Reset 함수를 통해 문을 따로 닫아줘야한다. 하지만, 이렇게 하면 원자적으로 Lock이 안되기 때문에 원하는 값이 안나온다.

-> 언제 사용? 하나의 스레드가 아니라 여러개의 스레드를 막아야할때, 예를 들어 로딩 등의 오래걸리는 작업을 기다릴때 available의 default를 false로 하고 WaitOne 함수만 하여 여러 스레드가 무조건 기다리게 한 후, 어떤 관리하는 스레드가 flag를 true로 바꾸면 여러 스레드가 동작하도록 함

-> 이렇게 커널을 이용한 Event 동작은 굉장히 무거운 비용이 든다. 

class Lock
    {
        // 커널에서 사용하는 bool 느낌 
        ManualResetEvent _available = new ManualResetEvent(true); // true일시 available 임계구역 이용가능
                                                              // 
        // 임계 구역에 들어가기 위해 잠금
        public void Acquire()
        {
            _available.WaitOne(); // 입장 시도
            _available.Reset(); // 문을 닫는 행동
        }
        // 임계 구역에 빠져나오면서 잠금 해제 
        public void Release()
        {
            _available.Set(); // flag = true로 
        }
    }

4. Mutex

: 아래와 같이 Mutex 클래스를 사용하여 바로 적용할 수 있다.

 실행해보면 0이 나오지만, User 모드에서 계속 실행했던 Spin Lock보다 훨씬 느린 것을 확인할 수 있다.

- Auto Reset Event의 공통점: Mutext 또한, Auto Reset Event 처럼 커널에서 처리하는 객체  

- Auto Reset Event의 차이점: 여러 개의 정보를 저장하고 있다. lock 한 count, lock한 ThreadId 등등이 저장되어 있어서 더 비용이 많이 든다. 

* 거의 Auto Reset Event 만을 사용해도 거의 코딩 가능 

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

namespace SeverCore
{
    
    class Program
    {
        static int _num = 0;
        static Mutex _lock = new 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);
        }
    }
}