Lock?
이전에 포스팅한 Interlocked는 좋은 기능이지만 아쉬운점이 정수를 증가시키거나 감소시키는 것 이외의 기능은 힘들다. 우리가 원하는 건 특정한 코드블럭이 어떤 쓰레드에서 실행되는 동안 다른 쓰레드에서는 잠시 멈추는 기능을 원하는 것이다. 그것에 대해 알아보자. 물론 Interlock도 많이 사용한다.
모든 쓰레드가 데이터를 읽기만 한다면 그것은 크게 중요하지 않다. 어차피 데이터는 변하지 않았을 거고 그 값만 가져오는건 문제가 되지 않는다. 중요한건 다른 쓰레드에서 wirte를 하거나 그 후에 read하는 것은 문제가 될 수 있다. 이러한 코드를 critical 세션이라고 한다.
해결방안으로 다음과 같은 코드가 있다.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static int number = 0;
static object _obj = new object();
static void Thread1()
{
for (int i = 0; i < 10000; i++)
{
Monitor.Enter(_obj);
number++;
Monitor.Exit(_obj);
}
}
static void Thread2()
{
for (int i = 0; i < 10000; i++)
{
Monitor.Enter(_obj);
number--;
Monitor.Exit(_obj);
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
그럼 Monitor.Enter는 무엇일까. 바로 문을 잠구는 행위이다. 화장실이라고 생각하면 편하다. 문을 잠궜기 때문에 다른 쓰레드들은 잠시 밖에서 대기해야하는 것이다. 각 언어마다 인터페이스가 크게 다르진 않지만 동일하지도 않다.
만약 Monitor.Enter를 통해 락을 걸었는데 Exit을 통해 문을 열어주지 않으면 어떻게 될까?? 모든 쓰레드들은 대기 상태로 하염없이 계속 기다릴 것이다.
이러한 상황을 DeadLock이라고 한다.
그런데 매 상황마다 Monitor를 이용해서 락을 만들어야해야하고 나가는 경우도 계속 만들어주다보니 매우 코드가 더러워보이고 안좋아보인다. 그래서 C#에서는 lock 키워드를 통해 그 부분을 해결할 수 있다.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static int number = 0;
static object _obj = new object();
static void Thread1()
{
for (int i = 0; i < 10000; i++)
{
lock (_obj)
{
number++;
}
}
}
static void Thread2()
{
for (int i = 0; i < 10000; i++)
{
lock (_obj)
{
number--;
}
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
lock 이라는 키워드도 결국엔 내부적으로 Monitor.Enter와 Exit로 구성되어 있다.
DeadLock
위의 상황에서 데드락이 무엇인지 잠깐 알아봤다. 데드락은 화장실에 한명이 들어가서 자물쇠로 문을 잠군 후 풀어주지 않는다면 다른 손님들은 전부 기다려야하는 상황이다.
이것은 아주 사소한 상황에서의 데드락 발생 상황이다. 이렇게 코드를 짰다고 하면 100% 프로그래머 잘못이다.
데드락은 조금 더 고차원적인 상황에서 많이 일어난다.
예를 들어 만약 화장실에 자물쇠가 2개가 있다고 해보자. 두개의 자물쇠를 열어야만 화장실에 들어갈 수 있다고 하자.
이 때, 손님 2명이 동시에 화장실을 간다고 한다. 그래서 손님1은 자물쇠1을 손님2는 자물쇠2를 열었다. 그 후에 각 손님은 나머지 하나를 추가적으로 열려고 할텐데 열수가 없을 것이다. 왜냐하면 손님들이 하나씩 자물쇠를 나눠가졌기 때문이다.
이러한 경우도 데드락이라고 한다.
이런 문제가 발생한 이유는 하나이다. 자물쇠를 여는 순서를 정하지 않았기 때문이다. 데드락 문제를 해결하기 위해서는 어떤 규약이든 존재하면 좋다.
코드에서 확인해보자.
using System;
using System.Threading;
namespace ServerCore
{
class UserManager
{
static object _lock = new object();
public static void Test()
{
lock (_lock)
{
SessionManager.TestSession();
}
}
public static void TestUser()
{
lock (_lock)
{
}
}
}
class SessionManager
{
static object _lock = new object();
public static void Test()
{
lock (_lock)
{
UserManager.TestUser();
}
}
public static void TestSession()
{
lock (_lock)
{
}
}
}
class Program
{
static int number = 0;
static object _obj1 = new object();
static void Thread1()
{
for (int i = 0; i < 10000; i++)
{
UserManager.Test();
}
}
static void Thread2()
{
for (int i = 0; i < 10000; i++)
{
SessionManager.Test();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
어떤 상황에서 유저가 방을 나갈 수도 있고 방에서 유저의 정보를 얻을려는 상황이 있을 것이다. 그 경우에 서로에게 접근해야하는데 락을 이용해서 가시성과 원자성을 확보하려고 했으나 프로그램이 무한루프에 빠지는 코드이다.
쓰레드 1에서 UserManager의 lock을 획득하고 SessionManager의 TestSeesion을 호출할 것이다. 그리고 그와 동시에 쓰레드 2에서 SessionManager의 lock을 획득하고 TestUser를 호출할 것이다. 그런데 TestUser를 호출하려고 보니 lock이 걸려있어 대기를 하고 있다. 쓰레드 1에서 획득은 UserManager의 lock을 아직 해제하지 않았기 때문이다. 이렇게 순환구조로 계속 루프에 빠지게된다.
데드락을 예방하는건 사실 쉽지 않다. 최대한 많은 테스트를 통해서 프로그램이 멈췄을 때, 호출스택을 따라가 고치는 방법이 제일 효율이 좋을 수도 있다.
Lock 구현 연습(SpinLock)
락은 다양한 방법이 있는다.
1. 무작정 기다리기.
2. 잠시 포기하고 나중에 다시 와보기(랜덤)
3. 이벤트가 망을 보고 사용 가능할 때 알려주기.
이 중 먼저 SpinLock 즉, 1번 방법대로 구현해보자.
using System;
using System.Threading;
namespace ServerCore
{
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 Thread1()
{
for(int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread2()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
이렇게 spinlock을 구현해 봤다. 실행해보면 결과가 제대로 나오지 않는 모습을 확인할 수 있다. 우리는 이전 싱글쓰레드 코드에 너무 익숙해진 나머지 문제를 빠르게 확인할 수 없을 것이다. 만약 이 코드를 보고 어디가 문제인지 안다면 멀티쓰레드 감각에 뛰어난 사람일 것이다.
문제는 자물쇠를 획득할 때의 코드가 동시에 실행 됐기 때문이다.
즉, 자물쇠를 획득하고 잠구는 행동까지 스텝이 나눠지면 안되고 하나여야만 문제가 해결된다.
using System;
using System.Threading;
namespace ServerCore
{
class SpinLock
{
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0)
break;
}
}
public void Release()
{
_locked = 0;
}
}
class Program
{
static int _num = 0;
static SpinLock _lock = new SpinLock();
static void Thread1()
{
for(int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread2()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
Interlock을 이용해서 원자성을 보장할 수 있게 된다.
코드가 그닥 엄청 직관적이지 않다. 그래서 다른 버전은 다음과 같다.
public void Acquire()
{
while (true)
{
int original = Interlocked.CompareExchange(ref _locked, 1, 0);
if (original == 0)
break;
}
}
'Unity > 온라인 RPG' 카테고리의 다른 글
[게임 서버] 네트워크 프로그래밍 - 네트워크 기초 및 소켓 프로그래밍 (0) | 2024.01.17 |
---|---|
[게임 서버] 멀티쓰레드 프로그래밍 - Context Switching, Event등 Lock의 다양한 기법 (1) | 2024.01.11 |
[게임 서버] 멀티쓰레드 프로그래밍 - 캐시, 메모리 배리어, Interlocked (1) | 2024.01.09 |
[게임 서버] 멀티쓰레드 프로그래밍에 대해 알아보자. (1) | 2024.01.09 |
[게임 제작] 온라인 rpg 제작 준비 (2) | 2024.01.08 |