지우너
[C# 게임 서버] 멀티쓰레드 프로그래밍(2) 본문
임계영역(Critical Section)
동시다발적으로 Thread들이 접근을 하면 문제가 되는 코드 영역을 의미한다. 해당 영역에서 race condition이 발생한다.
race condition
멀티쓰레드 프로그래밍(1)에서 둘 이상의 입력 또는 조작의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 말한다. 입력 변화의 타이밍이나 순서가 예상과 다르게 작동하면 정상적인 결과가 나오지 않게 될 위험이 있는데 이를 경쟁 위험이라고 한다.(Wikipedia)
이를 해결하기 위한 방법 중 하나가 interlocked였다.
Interlocked.Increment(ref num);
interlocked계열은 성능도 굉장히 빠른 편이고, 우수하긴 한데 앞서 봤던 것처럼 정수만 사용할 수 있다는 치명적인 단점이 존재한다. 그렇기 때문에 결국에는 어떤 신호를 줘서 블록 안에 있는 어떤 부분은 하나의 Thread에서만 실행이 되고, 나머지는 기다리라는 명령을 할 수 있는 도구가 필요하다.
1. Lock 기초
1.1. Monitor.Enter(obj)/Exit(obj)
Monitor Class (System.Threading)
가장 기초적인 방법부터 살펴보자
static int number = 0;
static object _key = new object();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
Monitor.Enter(_key);
number++;
Monitor.Exit(_key);
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
Monitor.Enter(_key);
number--;
Monitor.Exit(_key);
}
}
위 코드에서 Monitor.Enter()와 Monitor.Exit()으로 감싼 부분은 해당 쓰레드가 Exit하기 전까지 다른 쓰레드가 접근할 수 없다. interlocked에서와 다르게 긴 코드의 범위를 지정해서 다른 쓰레드가 들어올 수 없게 잠가줄 수 있는 것.
상호배제(mutual exclusive)
둘 이상의 프로세스가 동시에 임계영역critical section에 진입하는 것을 막는 것. 한 프로세스가 임계구역에 들어가면 다른 프로세스는 임계구역에 들어갈 수 없다.
C++로 가면 CriticalSection이라는 이름으로 존재하고 그게 아니라 C++표준으로 가면 std::mutex라는 이름으로 구현되어 있다.
1.1.1. 데드락(DeadLock)
두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태
Enter()와 Exit()으로 감싸줘야 하기 때문에 그 사이에 코드를 작성하다가 실수로 return / break 등으로 인하여 Exit()을 못하게 될 수도 있다. 이렇게 프로그램을 실행시키면 무한루프에 빠지게 된다.
이런 상황을 어떻게 해결할 수 있을까.
가장 단순한 방법은 어떤 조건을 달성할 때마다 아래와 같이 Exit()을 해주면 된다.
하지만 조건이 다양해질 수록 코드가 더러워질 것이고, 우리가 하나하나 입력해줘야 한다는 점이 매우 귀찮다. 그리고 더 큰 문제는 우리가 예상치 못한 부분에서 예외가 발생하여 Exit()이 되지 않을 수도 있다는 것이다.
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
try
{
Monitor.Enter(_key);
number++;
}
finally
{
Monitor.Exit(_key);
}
}
}
try{} finally{}를 이용하면 exception과 관계없이 finally를 반드시 1번 실행시키기 때문에(try 구문에 return을 하더라도 finally 부분은 무조건 실행됨) 이것이 하나의 방법이 될 수 있을 것이다.
사실 이 방법도 꽤 번거롭기 때문에 거의 사용하지 않는다.
1.2. lock(obj)
lock도 위에 적은 코드처럼 내부적으로 Monitor.Enter()/Exit()과 try{} finally{}를 이용하여 구현되어 있다.
1.2.1 사용법
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
lock (_key)
{
number++;
}
}
}
2. 데드락(DeadLock)
앞서 1.1.1에서 데드락이 발생하는 가장 기초적인 상황을 살펴봤었다.
MMO서버 같이 큰 규모의 프로젝트를 다루게 되면 lock같은 경우에는 사실 클래스 안에 들어가게 되는 경우가 많다. ex) 세션 매니저, 유저 매니저(인게임에 있는 유저를 관리) 등의 매니저가 각자 자신의 lock을 갖고 있게 된다.
2.1. lock이 2개인 상황
class SessionManager
{
static object _lock1 = new object();
public static void Test()
{
lock (_lock1)
{
UserManager.TestUser();
}
}
public static void TestSession()
{
lock (_lock1)
{
}
}
}
class UserManager
{
static object _lock2 = new object();
public static void Test()
{
lock (_lock2)
{
SessionManager.TestSession();
}
}
public static void TestUser()
{
lock (_lock2)
{
}
}
}
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 10000; i++)
{
SessionManager.Test();
}
}
static void Thread_2()
{
for (int i = 0; i < 10000; i++)
{
UserManager.Test();
}
}
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);
}
}
2.2. 해결책
2.2.1. Monitor.TryEnter(obj, TimeSpan)
너무 오래 걸릴 경우 lock을 포기하도록 하기
몇 초 동안 시도해보다가, lock을 얻는 데 실패하면 포기하도록 만드는 것.
→그런데 이런 상황(lock을 얻는 데 몇 초 이상 걸림)이 발생했다는 것 자체가 lock 구조에 문제가 있다는 것을 의미
구조에 문제가 있는데, 실패 처리를 해서 실패했을 때의 코드를 이중으로 짜두는 것이 현명해 보이지는 않는다.
2.2.2. Thread.Sleep() 실행시간이 살짝 어긋나도록 만들어주기
t1과 t2를 실행시킬 때 둘 사이에 0.1초 간격(Thread.Sleep(100);)을 주면 데드락이 발생하지 않는다.
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
Thread.Sleep(100);
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
+) DeadLock은 QA중에도 잘 발생하지 않다가, 실제 트레픽이 늘어나며 원인을 발견하는 경우가 잦다.
3. Lock 구현 이론
lock이 잠겨있을 때(=lock을 얻는 데에 실패했을 때), 3가지 선택을 할 수 있다. ①무조건 기다리거나(SpinLock) ②일정 시간이 지난 후, 다시 lock 시도(context switching) ③lock이 해제되었을 때 알려달라고 커널에게 맡기는 방법
3.1.SpinLock
3.1.1. 개념
lock이 풀릴 때까지 계속 대기하는 것.
3.1.2. 구현 (문제점O)
인터페이스에 무엇이 있어야 할지 생각해보면, Monitor.Enter()/Exit()에 해당하는 함수가 필요할 것이다. 그리고 잠긴 상태인지 아닌지를 의미하는 변수가 있어야 할 것이다.
using System;
using System.Threading;
namespace ServerCore
{
class SpinLock
{
volatile bool _locked = false;
public void Acquire()
{
while (_locked)
{
// lock이 false가 되기를 기다림
}
// lock이 false가 되어 반복문을 나오게 되면 lock (이제 이 코드를 실행하는 애가 lock의 주인이 됨)
_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을 건다
_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);
}
}
}
해당 코드의 문제
public void Acquire()
{
while (_locked)
{
// lock이 false가 되기를 기다림
}
// lock이 false가 되어 반복문을 나오게 되면 lock (이제 이 코드를 실행하는 애가 lock의 주인이 됨)
_locked = true;
}
비어있는 lock에 동시에 접근해서 해당 lock이 비어있는 상태인 줄 알고 thread가 같이 들어간 상태가 되는 것이다.
lock에 접근하여 잠그는 행위까지가 1개의 동작으로 이루어져야 하는데(원자성 Atomic), 2개의 동작으로 쪼개져 있기 때문에 문제가 생기는 것.
<해결책>
While 문으로 lock을 대기하는 부분과 lock을 자신의 것으로 만드는 과정이 하나의 동작으로 이루어지도록 만들어 주어야 한다. (num++을 해줬을 때와 비슷한 상황) num++은 interlocked 계열의 함수 Interlocked.Increment()/Decrement()를 사용해서 해결해줬었다.
이 상황에서는 Interlocked.CompareExchange와 Interlocked.Exchange를 사용할 수 있다.
3.1.3. 해결책(Interlocked.Exchange)
우선 Interlocked.Exchange를 사용해서 위의 상황을 해결해보자. C++버전에서는 bool 버전의 함수까지 모두 마련되어 있지만, 우리는 아니기 때문에 아래 버전의 함수를 사용하고자 한다. 여기서는 레퍼런스로 우리의 변수를 하나 넣어주고, 두 번째 인자로 세팅할 값을 넣어주게 된다. 그러면 출력으로 우리가 value에 넣어주기 전 location의 원본 값을 return해준다.
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
int origin = Interlocked.Exchange(ref _locked, 1);
if(origin == 0)
break;
}
}
쉽게 설명하자면 lock이 비어있는 상태이면 _locked = 0인 상태이다. lock을 획득하기 위해 Acquire() 함수에서 무한루프를 돌게 되는데, 이때 Interlocked.Exchange(ref _locked, 1)을 해주면 _locked 에 1을 대입해주게 된다. 그리고 return값으로 원래 _locked의 값(=0)을 return하여 origin=0이 된다. origin이 0이라면 원래 lock을 아무도 갖지 않은 상태였다는 의미이기 때문에 lock을 걸고 lock을 대기하면서 돌고 있었던 while 무한루프를 빠져나온다.
lock을 누가 차지하고 있는 상태라면 _locked =1 인 상태이다. lock을 획득하기 위해 Acquire() 함수에서 무한루프를 돌게 되고, 이때 Interlocked.Exchange(ref _locked, 1)을 해주면 _locked 에 1을 대입해주게 된다. 그리고 return값으로 원래 _locked의 값(=1)을 return하여 origin=1이 된다. origin이 1이라면 누군가 lock을 걸고 어떤 작업을 하고 있는 상태이다. origin=1이기 때문에 반복문을 나가지 못하게 되고, lock이 풀릴 때까지 해당 작업을 반복한다.
+) _locked는 공유해서(다른 쓰레드들과 경합하여) 사용하는 변수이기 때문에 함부로 값을 읽어서 사용하면 안 된다고 말했다. 위에서 원래 _locked의 값을 return하여 origin에 저장하고 이를 0과 비교한 것은 문제가 되지 않을까?
origin 변수는 스택에 저장된(즉, 경합하지 않는) 하나의 쓰레드에서만 사용하는 변수이다. 그렇기 때문에 origin의 값을 읽어서 0과 비교하는 작업을 해도 문제가 생기지 않는 것이다.
위의 코드는 아래 같은 과정을 거친다고 말할 수 있다.
int origin = _locked;
_locked = 1;
if (origin == 0) break;
그런데 origin = _locked와 _locked =1이 위와 같이 2가지 동작으로 실행되면 문제가 생기기 때문에, 이 부분이 한 번에 실행되도록 묶어준 게 Interlocked.Exchange다.
3.1.3. 해결책(Interlocked. CompareExchange )
int origin = _locked;
_locked = 1;
if (origin == 0) break;
사실 위 처럼 코드를 적는 것 보다 아래 처럼 코드를 적는 게 코드가 좀 더 깔끔한 느낌이 든다. 위와 같은 방식으로 작동하는 것이 Exchage()함수이고, 아래와 같이 작동하는 함수가 CompareExchange()이다.
이 예제에서는 Exchange를 사용해도 무방했으나, 좀 더 범용적으로 사용되는 함수는 Interlocked.CompareExchange이다. 위의 코드처럼 무작정 _locked에 1을 대입하는 것은 경우에 따라 위험할 수도 있고, 예상치 못한 방식으로 동작할 수도 있다.
if (_locked == 0) _locked = 1;
첫 번째 인자로는 우리가 조작하기를 원하는 값을 넣어주고, 비교하고자 하는 값을 3번째 인자comparand에 넣어준다. 그리고 그 값이 같으면 location1(첫 번째 인자)에 두번째 인자로 넣은 value를 넣어주는 것이다.
Exchange와 마찬가지로 원래 location1에 들어가 있던 값을 return해준다.
public void Acquire()
{
while (true)
{
int origin = Interlocked.CompareExchange(ref _locked, 1, 0);
if (origin==0) break;
}
}
Interlocked.CompareExchange(ref _locked, 1, 0); ←이렇게 만 코드를 적어주면 lock이 성공했는지 실패했는지 알 수 없으므로 아까와 같이 origin에 값을 저장하고 0과 비교하여 break한다.
위처럼 코드를 짜면 1일 때 0을 _locked에 대입하는 건지, 0일 때 _locked에 1을 대입하는 건지 헷갈릴 수 있다. while 문 안에 있는 코드를 아래와 같이 바꿔주면 어떤 값과 비교하여 어떤 값을 _locked에 넣고 싶은지 좀 더 명확하게 알 수 있다. 예상값(expected)과 _locked의 값이 같으면 내가 원하는 값(desired)을 넣어준다는 의미.
int expected = 0;
int desired = 1;
if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected) break;
이렇게 비교 후 대입하는 계열의 함수를 CAS(Compare And Swap) 계열이라고 부른다.
+) Release()에서 _locked에 0을 그냥 대입해도 괜찮을까?
public void Release()
{
_locked = 0;
}
위에서 Acquire()의 함수만 수정해주었는데, 코드가 예상대로 잘 작동하였다. Release()에서 _locked에 그냥 0을 넣어주는 것은 수정해주지 않아도 괜찮을까?
→애당초 Acquire()에서 while문을 통과했다는 것은 현재 _locked를 한 애만이 유일하게 lock을 소유하고 있다는 의미이기 때문에 아무런 처리 없이 lock을 해제해줘도 상관이 없다.
[요약]
SpinLock은 while문을 돌면서 CAS(Compare And Swap)연산을 수행하여 lock을 얻는 방식으로 구현
[전체코드]
class SpinLock
{
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
int expected = 0;
int desired = 1;
if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected) break;
}
}
public void Release()
{
_locked = 0;
}
}
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을 건다
_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.2. Context Switching
사실 아래의 부분 자체가 SpinLock은 아니다. 3을 시작할 때 이야기 했던 것처럼 Lock을 얻는 데 실패했을 때 어떤 행동을 취하느냐에 따라 다르게 부르는 것이다.
int expected = 0;
int desired = 1;
if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected) break;
해당 코드가 끝난 후 바로 무한 루프를 도는 것이 아니라 일정 시간이 지난 후 다시 lock 획득을 시도하는 방법이 context switching이라고 할 수 있다.
3.2.1 개념
사실 위에 말한 것처럼 일정 시간이 지난 후 다시 lock 획득을 시도하는 방법이 context switching이라고 말하면 굉장히 쉽지만, 여러 OS 지식이 들어간 개념인 것 같아서 어디서부터 설명해야할지 모르겠다. 글을 읽으면서 이게 context switching이지 라고 생각하는 건 가능한데, OS 복습을 한 번하고 내 말로 정리해봐야 제대로 설명할 수 있을 것 같다.
프로세스들은 context라고 하는 현재 어떤 작업을 어디까지 하는지 등의 정보를 PCB에 저장해둔다. CPU가 한 프로세스를 실행하다가 interrupt가 발생하면 현재 작업상황을 저장하고, 다른 프로세스로 전환하게 되는데 이걸 Context Switc라고 일단은 적어두겠다...(틀린 부분이 있다면 언제든 고쳐주시길 바랍니다...)
3.2.2. 구현
lock을 얻는 데 실패했을 경우 일정시간 쉬었다가 다시 lock을 시도하는 방법이 context switching이라고 했다. 그러면 우리는 일정 시간 쉬는 코드를 구현해주면 된다는 의미이다. 해당 부분을 구현할 때 3가지 방법이 존재한다.
Thread.Sleep(1); // 무조건 양보: 지정된 시간(여기서는 1 millisecond) 동안 현재 스레드를 일시 중단
Thread.Sleep(0); // 조건부 양보: 자신보다 우선순위가 낮은 애들에게는 양보 불가능
Thread.Yield(); // 지금 실행 가능한 쓰레드가 있으면 바로 양보
Thread.Sleep(1) 의 경우 우리가 임의의 숫자 1을 넣어줬지만, 실제 몇 초를 대기시킬지는 운영체제에서 스케줄러가 관리하여 결정해준다.
3가지 중 어떤 것이 더 좋다고 말하기는 애매하다. 테스트 환경에 따라 어떤 구조인지에 따라 달라질 수 있으므로, 여러 방법을 테스트한 후 결정하는 것이 좋다.
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
int expected = 0;
int desired = 1;
if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected) break;
Thread.Yield();
}
}
public void Release()
{
_locked = 0;
}
3.2.3. 장점
코드의 결과는 Thread.Yield(); 의 유무에 상관없이 동일하다. 우리가 단순히 num++이 아니라 굉장히 무겁고 부담이 되는 작업을 할 경우, 무한 루프를 돌면서 if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected) break; 해당 작업을 하는 것이 굉장히 부담이 될 수 있다.
while문에서 time.sleep의 중요성 해당 블로그에서 while문이 1초에 몇 번 반복되는지 알아본 코드이다. Interlocked.CompareExchange 같은 함수를 1초에 저렇게 많이 반복하여 작업하는 것은 당연히 부담이 될 수밖에 없을 것 같아 보인다.
Thread.Yield();를 넣어줬을 때의 장점은 lock이 풀릴 때까지 해당 코드를 계속 실행하는 것이 아니라 적절하게 한 번 쉬어준다는 것이다. SpinLock구조에서 오래 걸릴 것이 예상되는 코드를 작성할 때 반복하여 획득을 시도하지 않고, 몇 초를 쉬어주는 것은 나쁘지 않은 방법처럼 보인다.
3.2.4. 단점
이 부분도 context에 대한 그런 복습이 완료되어야 정확하게 기술할 수 있을 것 같은데, 간단히 설명하자면,
어떤 쓰레드가 작업 중이던 정보(context) 를 저장하고, 다른 쓰레드가 작업 중이던 정보(context)를 가져오는 것이 사실 비용이 많이 드는 작업이기 때문에 SpinLock처럼 계속 lock을 얻기 위해 시도하는 것이 더 효율적일 수도 있다.
(사실 이 부분이 잘 이해가 되지 않는다. 어차피 작업 중인 쓰레드를 교체?하면 context switch가 반드시 일어나는 게 아닌가? 작업을 완료하고 lock을 해제하는데, lock을 얻지 못했을 때 어떤 방법을 이용할 것인지의 차이라고 했는데 음.... 몇 초 후 lock이 해제되었는지 확인할 때 context가 교체되는 건 아닐 것 같은데 왜 비용이 많이 든다고 하는지 이 부분이 잘 이해가 되지 않는 것 같다.)
3.3.Event
커널에게 lock이 사용가능해지면 알려달라고 요청하는 것.
AutoResetEvent _available = new AutoResetEvent(false);
매개변수로 true나 false를 넣어주는데, true를 넣어주면 아무나 들어올 수 있고, false를 넣으면 누구도 들어올 수 없는(=>문이 닫힌 상태)가 된다. 톨게이트를 연 상태로 시작할 것인지, 닫힌 상태로시작할 것인지 정해주는 것이라고 생각하면 된다.
3.3.1. AutoResetEvent구현
AutoResetEvent Class (System.Threading)
class Lock
{
AutoResetEvent _available = new AutoResetEvent(true);
public void Acquire()
{
_available.WaitOne(); // 입장 시도
// _available.Reset(); // 이 부분은 생략되어 있음
}
public void Release()
{
_available.Set();
}
}
자동문이기 때문에 내가 닫아주지 않아도 자동으로 닫힌다!
커널레벨까지 가서 요청하는 것이기 때문에 속도가 상당히 느리다.
3.3.2. ManualResetEvent구현
class Lock
{
ManualResetEvent _available = new ManualResetEvent(true);
public void Acquire()
{
_available.WaitOne(); // 입장 시도
_available.Reset();
}
public void Release()
{
_available.Set();
}
}
수동이라서 내가 닫아줘야 하는데, 위에서 계속 말했지만 입장하는 행동과 닫는 행동이 2개로 나뉘어져 있으면 안 된다.
ManualResetEvent의 예제 및 사용법은 아래 사이트를 확인하자...(일단 lock 구현에는 적합하지 않아 보임)
3.3.3 Mutex 구현
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();
}
}
- AutoResetEvent랑 Mutex는 그럼 뭐가 다를까?
Mutex는 조금 더 많은 정보를 담고 있다. 얘를 몇 번이나 잠궜는지 카운팅하고 있다. 또, ThreadID를 갖고 있어서 자신을 lock한 애가 누군인지 기억하고 있다가 엉뚱한 애가 Release하면 에러를 잡아주는 등 여러 역할을 해준다(당연히 그만큼 비용이 들긴 한다).
그래서 사실 우리의 경우는 AutoResetEvent로 충분하다.
[참고 사이트]
[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버
C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의 노트
OS는 할껀데 핵심만 합니다. 8편 Critical section (임계 구역)
운영체제 6 : Process Synchronization and Mutual Exclusion (1)
OS는 할껀데 핵심만 합니다. 10편 Deadlock(교착상태)1, Deadlock의 정의와 필수 조건
OS - Context Switch(컨텍스트 스위치)가 무엇인가?
'Programming > Server' 카테고리의 다른 글
[C# 게임서버] 네트워크 프로그래밍(2) (0) | 2024.01.19 |
---|---|
[C# 게임서버] 네트워크 프로그래밍(1) (0) | 2024.01.16 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(4) (0) | 2024.01.14 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(3) (0) | 2024.01.12 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(1) (0) | 2024.01.07 |