Context Switching
이전 포스팅에서 SpinLock을 직접 구현해봤다. SpinLock은 결국에 무한 대기를 하는 것인데 그렇게 되면 계속 lock을 체크하는 것이 엄청 부담될 것이다. 그러니 체크 후에 만약 lock을 획득하지 못했다면 조금 그 쓰레드를 멈추거나 쉬게하는 방법에 대해 살펴보자. 이전 포스팅에서 2번째로 기술된 내용이다.
Thread.Sleep(1);
Thread.Sleep(0);
Thread.Yield();
크게 3가지가 있다. 각각의 의미가 좀 다르다.
Sleep(1) 같은 경우는 무조건 휴식을 의미한다. 주어진 시간동안 쓰레드를 멈추는 것이다. 주어진 시간 이라고 하면 운영체제에서 스케줄링을 통해 결정하게된다. 만약 사용자가 숫자를 넣어 작동할 경우 운영체제가 그 숫자를 최대한 맞춰서 멈춘다.
Sleep(0) 같은 경우는 조건부 양보에 해당된다. 나보다 우선순위가 낮은 애들한테는 양보를 하지 않겠다는 것이다. 즉 우선순위가 나보다 같거나 높은 쓰레드가 없으면 다시 본인 쓰레드가 작동하겠다는 의미이다.
Yield() 는 관대한 양보라고 보면된다. 관대하게 양보할테니, 지금 실행이 가능한 쓰레드가 있으면 실행하라는 의미이다. 실행 가능한 쓰레드가 없으면 남은 시간을 소진하고 실행된다.
그렇다면 3개의 방법이 무조건 장점만 있을까??? 특정 쓰레드에서 다음 쓰레드로 넘어가는 것이 그냥 뿅하고 넘어가지 않는다. 일단 특정 쓰레드에서 다음 쓰레드로 넘어가기 위해 그것들의 관리자(코어, 운영체제에서 관리 등)에게 CPU가 연산을 해야하고 그제서야 다음 쓰레드에게 넘어간다. 벌써부터 조금 이해가 가겠지만 부하가 심하다는 의미이다.
그리고 단순히 쓰레드를 실행시킨다는 것은 없다. 쓰레드를 실행시켜주기 위해서는 Context를 전부 복원해줘야 한다.
이게 무슨 의미인가? 쓰레드들이 가지고 있는 데이터들은 자신들한테 종속하지 않는다. RAM에게 전부 저장을 해두고 자신의 역할이 끝나면 전부 휘발하게된다. 그렇기 때문에 쓰레드를 다시 재 활성화 하기 위해서는 그 데이터들을 전부 복원해줘야한다. 즉, RAM에서 쓰레드에 관한 정보를 레지스터에 복원해야한다.
만약 이전에 작동했던 쓰레드가 같은 프로그램을 동작하던 쓰레드라면 가상테이블은 계속해서 이용할 수 있지만 그게 아니라면 가상테이블도 복원해야한다.
결론적으로 이야기하면 Context Switching을 할 때마다 어마어마한 부하를 일으킬 수 있다는 것이다.
Event
마지막 방법으로 갑집 메타라고 불리던 만약 락이 걸려있다면 쓰레드가 휴식을 하는데 락이 끝나면 코어가 다시 그 쓰레드를 불러오는 형식이다. 스위칭이 일어나기 때문에 속도가 매우 느리지만 락을 아주 오래 잡고있는다고 하면 이득을 보는 것이다.
using System;
using System.Threading;
namespace ServerCore
{
class Lock
{
AutoResetEvent _available = new AutoResetEvent(true);
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 Thread1()
{
for(int i = 0; i < 10000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread2()
{
for (int i = 0; i < 10000; 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);
}
}
}
100000만번을 실행할 경우 프로그램이 아주 늦게 종료된다. 그 이유는 앞에서도 말했다싶이 코어에서 명령을 내려주는거 즉 스위칭이 매우 느리기 때문이다.
지금까지 세가지 방식에 대해 봤는데 3가지 방식중 제일 좋은 방식은 없다 상황에 따라 좋은 lock 기법이 있을 뿐이다.
ReaderWriterLock
만약 이런 경우가 있다고 생각해보자. 어떤 던전을 클리어하면 고정 보상을 3개를 준다. 그래서 모든 유저들은 던전을 돌고 보상 3개를 받아가고 있었다. 그리고 그 함수를 Reward 라고 하자. 그런데 운영자가 특정 던전의 참여율이 낮아서 던전 보상을 추가하는 함수 AddReward를 호출했다고 하자.
이 상황에서 일단 lock을 이용해서 우리가 보상이라고 하는 데이터를 멀티쓰레드에서 가져오게된다. 만약 모든 유저가 보상을 동시에 달라고 했다면, lock 덕분에 한명씩 순차적으로 처리하게 될것이다. 이는 매우 비효율적이다.
그렇다고 lock을 사용하지 않는다고 해보자.
그렇게 생기는 문제는 바로 보상을 추가하는 것이다. 만약 누군가 3개의 보상을 얻을 차례에 운영자가 보상을 2개를 늘렸다고 하면 특정유저는 3개가 아닌 5개를 얻을 것이다. 그렇기 때문에 추가된 기능인 RWLock이라고 부르는 ReaderWriterLock이 있다.
ReaderWriterLock는 데이터를 읽을 때는 Lock이 없는 것 처럼 행동하고 Write할 때만 Lock을 하는 기능이다.
만약 누군가 Write를 한다고 하면 Read하는 쓰레드들을 전부 기다린 후에 Wirte에서 lock을 획득하는 것이다.
실제로 이것을 구현해보자.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
// 재귀적 Lock을 허용하지 않음.
// 스핀락 정책 (5000번 -> Yield)
internal class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000;
// [Unused(1)] [WriteThreadId(15)] [ReadCount(16)]
// ReadCount는 읽는 쓰레드가 몇개있는지 판별
// WriteThreadId는 지금 현재 Lock하고 있는 쓰레드 ID
int _flag = EMPTY_FLAG;
public void WriteLock()
{
// 아무도 WriteLock or ReadLock을 획득하고 있지 않을때,
// 경합해서 소유권을 얻는다.
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
while (true)
{
for(int i =0; i < MAX_SPIN_COUNT; i++)
{
if(Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
{
return;
}
}
Thread.Yield();
}
}
public void WriteUnlock()
{
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
public void ReadLock()
{
// 아무도 WriteLock을 획득하고 있지 않다면 ReadCount를 1 증가
while (true)
{
for(int i = 0; i < MAX_SPIN_COUNT; i++)
{
int expected = (_flag & READ_MASK);
if(Interlocked.CompareExchange(ref _flag, expected +1 , expected) == expected)
{
return;
}
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag);
}
}
}
'Unity > 온라인 RPG' 카테고리의 다른 글
[게임 서버] 네트워크 프로그래밍 - Listener와 Session (0) | 2024.01.19 |
---|---|
[게임 서버] 네트워크 프로그래밍 - 네트워크 기초 및 소켓 프로그래밍 (0) | 2024.01.17 |
[게임 서버] 멀티쓰레드 프로그래밍 - Lock에 대해서 (1) | 2024.01.10 |
[게임 서버] 멀티쓰레드 프로그래밍 - 캐시, 메모리 배리어, Interlocked (1) | 2024.01.09 |
[게임 서버] 멀티쓰레드 프로그래밍에 대해 알아보자. (1) | 2024.01.09 |