지우너
[C# 게임 서버] 멀티쓰레드 프로그래밍(3) 본문
나중에 서버를 쌓아올리게 되면 ①핵심적인 코어 부분만 멀티스레드로 만들 것인지 ②게임과 관련된 콘텐츠도 멀티스레드로 만들 것인지 선택해야 한다.
②의 경우 모든 코드에서 다 멀티쓰레드로 돌아갈 수 있다고 하면 난이도가 확연하게 올라간다. 심리스(=경계가 없는) MMORPG(ex. 젤다:야숨, 몬스터헌터:월드 등)를 만들 때 조금 유리하다는 장점이 있다.
그게 아니라 바람의 나라나 뮤 같은 게임을 보면 존 단위로 나뉘어져 있다. 공간이 갈 수 있는 지역이 구분이 되어 있고 그 공간 안에 서 있는 모든 컨텐츠 코드는 싱글쓰레드로 그냥 실행시키면 훨씬 더 생각하기도 쉽고, 버그 확률도 줄일 수 있다. 이럴 때는 굳이 멀티쓰레드로 갈 필요 없이 전체적인 코어만 멀티쓰레드로 돌리고 핵심적인 컨텐츠 코드는 싱글쓰레드로 가는 것도 좋은 방법이다.
1. ReaderWriterLock
lock의 기본 아이디어는 한 번에 한 놈만 들여 보내겠다(=상호배제). 그런데 다른 형태의 lock이 유용한 경우가 있다.
예를 들어 온라인 게임에서 퀘스트 완료 보상에 대한 코드가 있고, 이벤트로 보상을 추가할 수 있는 구조를 만든다고 생각해보자.
class Reward
{
}
static Reward GetRewardById(int id)
{
lock (_lock)
{
}
return null;
}
결국 운영툴로 보상을 추가하는 함수에는 lock이 들어가긴 해야한다. 하지만 일주일에 한 번 정도? 정말 가끔씩만 바뀔 텐데 그걸 위해 lock을 잡는 게 조금 아쉽다.
얘를 오버라이트해서 읽을 때는(get할 때) 동시다발적으로 서로 접근할 수 있다가, 바꿀 때만 상호배제적으로 막을 수 있으면 굉장히 효율적일 것이다. 일반적인 경우에는 lock이 없는 것처럼 왔다갔다 하다가 특수한 경우에만 막아버리도록 만드는 것.
이렇게 특수한 방식으로 작동하는 lock을 ReaderWriterLock(=RWLock)이라고 한다.
1.1. ReaderWriterLockSlim()
C#에는 ReaderWriterLock()과 ReaderWriterLockSlim()이 있는데, Slim이 붙은쪽이 최신이기 때문에 Slim을 사용하면 된다.
ReaderWriterLockSlim Class (System.Threading)
ReaderWriterLockSlim Class (System.Threading)
Represents a lock that is used to manage access to a resource, allowing multiple threads for reading or exclusive access for writing.
learn.microsoft.com
static ReaderWriterLockSlim _rwlock = new ReaderWriterLockSlim();
class Reward
{
}
// 평소에는 이 함수를 호출
static Reward GetRewardById(int id)
{
_rwlock.EnterReadLock();
_rwlock.ExitReadLock();
return null;
}
// 일주일에 한 번 호출될까 말까하는 함수
static void AddReward(Reward reward)
{
_rwlock.EnterWriteLock();
_rwlock.ExitWriteLock();
}
ReaderWriterLockSlim.EnterReadLock/ExitReadLock()을 해주면 WriteLock()에서 잡아둔 게 없으면 자유롭게 다닐 수 있다.
1.2. 구현 연습(재귀 비허용)
구현할 방식은 lock-free 프로그래밍이라는 기법과 굉장히 유사하다.
lock을 만들 때 정책을 몇 개 정해야 한다.
- write lock을 acquire한 상태에서 또다시 재귀적으로 같은 스레드에서 또 acquire을 할 때 그걸 허용할 것인지 아닌지(허용을 안 한 상태로 구현하는 것이 좀 더 쉽다)
- 스핀락을 몇 번 돌릴 것인가(여기서는 5000번 돌린 후 Yield()하는 방식)
1.2.1. 변수/상수
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x70000FFF;
const int MAX_SPIN_COUNT = 5000;
// int = 32비트 우리는 비트를 아래와 같이 사용할 것이다.
// [Unused(1)] [WriteThreadId(15)] [ReadCount(16)]
// WriteThreadId: write lock의 경우 한 번에 한 스레드만 획득이 가능하다고 했다. 그 스레드의 id를 적어둠
// ReadCount는 lock을 획득했을 때, 여러 스레드들이 동시에 얘를 read를 잡을 수 있는데, 그걸 count
int _flag;
Unused 비트를 1개 두는 이유
음수가 될 가능성이 있기 때문에 맨 앞의 비트는 사용하지 않는다.(아래 그림 참고)
const int READ_MASK=0x0000FFFF인 이유
어떤 플래그(_flag)가 있는데 거기서 ReaderCount만 쏙 빼고 싶다고 하면 Unused와 WriteThreadId에 해당하는 비트를 싹 다 0으로 바꾼 후, ReaderCount에 해당하는 비트만 추출하면 된다. 추출하기 위해서는 ReaderCount에 해당하는 비트를 모두 1로 켜둬야 하는데 그래서 const int READ_MASK=0x0000FFFF가 되는 것이다(16진수로 표현한 숫자는 2진수 4개짜리. 2진수 4개를 다 켠상태(1111)가 F인데, 16비트를 모두 켜야하기 때문에 FFFF(=1111 1111 1111 1111)가 된다).
const | readonly |
컴파일타임 상수 (컴파일 시 const 변수의 값을 가져온다.) 선언과 동시 초기화 Stack 영역에 자동으로 할당(단, static으로 선언시 Heap에 할당) |
런타임 상수 (exe 또는 dll을 사용할 때 변수의 값을 가져온다.) 선언시 혹은 생성자에서 초기화 Heap 영역에 할당됨 |
const는 stack에 할당되기 때문에 빠르게 접근할 수 있지만, 컴파일타임상수이기 때문에 값이 변경될 경우 관련 프로젝트를 모두 재컴파일해야 한다. |
선언만 한 뒤 생성자에서 초기화해도 되기 때문에 조금 더 유연하다. |
const int WRITE_MASK=0x7FFF0000인 이유
READ_MASK를 이해했다면 이 부분은 쉽게 이해할 수 있을 것 같다.WriteThreadId를 알고 싶으면 Unused와 ReaderCount에 해당하는 비트를 0으로 바꾼 후, WriteThreadId를 추출한다. 마찬가지로 해당하는 비트를 모두 1로 켜둬야 하는데 그러면 0111 1111 1111 1111 0000 0000 0000 0000이 된다. 이진수 0111은 16진수로 7에 해당하며, 나머지 1111은 위와 동일하게 F가 되므로 7FFF0000이 되는 것이다.
1.2.2. WriteLock()
이제 Lock과 Unlock을 하는 Acquire()/Release()를 만들건데, ReaderWriterLock이므로 Reader버전 한 쌍, Writer버전 한 쌍을 만들 것이다.
public void WriteLock()
{
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
// 아무도 WriteLock 혹은 ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다.
while(true)
{
for(int i = 0; i < MAX_SPIN_COUNT; i++)
{
// 시도해서 성공하면 return
if(_flag == EMPTY_FLAG)
{
_flag = desired;
}
}
Thread.Yield();
}
}
Thread.CurrentThread.ManagedThreadId는 int32로 현재 관리되는 쓰레드의 Id를 리턴한다.
말로는 설명이 어려울 것 같아 PPT를 이용해 desired에 저장되는 과정을 간단히 정리했다.
Thread.CurrentThread.ManagedThreadId = 3이라고 생각해보자
32비트로 표현하면 0000 0000 0000 0000 0000 0000 0000 0011이 된다.
지금 만들고자 하는 것은WriteLock이니까 _flag에 쓰레드의 id를 넣어줘야 하는데, 앞에서 _flag의 비트가 [Unused 1][WriteThreadId 15][ReadCount 16] 를 나타낸다고 말했다. 따라서 ManagedThreadId의 비트를 왼쪽으로 16칸 밀어줘야 WriteThreadId 비트에 위치하게 된다.
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
해당 코드의 &를 &&라고 적었더니 에러가 났다. &와 &&는 모두 AND 연산을 의미한다. 왜 여기서 에러가 났을까...

&(비트연산자) | &&(논리연산자) |
비트단위로 사칙연산과 같은 값의 계산을 위해 사용 |
True / False 를 구분 하기 위해 사용 |
우리가 해야 하는 건 두 비트를 비교하는 비트 연산이지 좌우가 모두 True인지 확인하기 위한 연산이 아니다.
위의 WriteLock 코드는 사실 제대로 작동하지 않는다.
_flag==EMPTY_FLAG; 값을 불러와서 비교하는 부분과 _flag=desired;로 대입하는 부분이 하나의 동작으로 이루어지지 않기 때문이다. 이 부분을 Interlocked계열의 함수로 바꿔줘야 한다.
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
return;
1.2.3. WriteUnlock()
애초에 WriteLock()을 한 쓰레드만 WriteUnlock()을 할 수 있다.
public void WriteUnlock()
{
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
_flag에 지금 lock을 소유하고 있는 쓰레드의 아이디가 들어가 있으니까 0000-으로 싹 밀어주면 Unlock이 된다.
(_flag = EMPTY_FLAG;라고 직접 대입해줘도 상관은 없다! 대입 연산 자체는 64비트가 넘는 struct 등이 아닌 경우 원자적으로 처리가 가능하기 때문이다.)
1.2.4. ReadLock()
아무도 WriteLock을 획득하고 있지 않으면(=WriteThreadId가 비어있으면), ReadCount를 1 늘려준다.
왜 이렇게 하냐면 ReadLock의 경우 여러 스레드가 동시에 잡을 수 있기 때문에, 그냥 1씩 늘려주는 것←ReadLock을 잡고 있는 스레드의 갯수를 세는 이유는 스레드들이 Unlock을 하기도 해야 하고 ReadLock을 잡고 있는 스레드가 있으면 WriteLock을 할 수 없기 때문에 Count를 1 올려주는 것.
public void ReadLock()
{
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
if((_flag & WRITE_MASK) == 0)
{
_flag = _flag + 1;
return;
}
}
Thread.Yield();
}
}
이 코드도 문제가 있다. 왜냐하면 아무도 WriteLock을 획득하고 있지 않아서 if문 안으로 들어와서 _flag+=1;을 해주려고 하는 순간!! 다른 애가 WriteLock을 잡아버리면 WriteLock이 잡혀있는데 ReadLock이 걸리는 이상한 상황이 발생한다.
얘도 동작을 여러 단계로 쪼개면 안 되고, 하나의 동작으로 이루어질 수 있도록 코드를 짜야한다.
public void ReadLock()
{
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();
}
}
ReadLock 설명도 PPT를 이용해 간단히 정리해보았다.
1.2.5. ReadUnlock()
ReadLock이 ReadCount를 1증가시키는 작업. ReadCount는 _flag의 마지막 16비트에 있으므로, ReadUnlock을 할 때는 간단하게 Interlocked.Decrement(ref _flag);를 해주면 된다.
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag);
}
[전체코드]
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x70000FFF;
const int MAX_SPIN_COUNT = 5000;
// int = 32비트 우리는 비트를 아래와 같이 사용할 것이다.
// [Unused(1)] [WriteThreadId(15)] [ReadCount(16)]
// WriteThreadId: write lock의 경우 한 번에 한 스레드만 획득이 가능하다고 했다. 그 스레드의 id를 적어둠
// ReadCount는 lock을 획득했을 때, 여러 스레드들이 동시에 얘를 read를 잡을 수 있는데, 그걸 count
int _flag = EMPTY_FLAG;
public void WriteLock()
{
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
// 아무도 WriteLock 혹은 ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다.
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()
{
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);
}
}
1.3. 구현 연습(재귀 허용)
재귀적 락은 동일한 쓰레드에서 연속해서 Lock을 잡는 경우를 케어해주는 것.
컨텐츠를 만들다 보면 함수가 아주 많아지고, 특정 함수가 다른 함수를 호출하는 경우가 빈번하다. 함수->함수->함수 이렇게 타고 들어가다 보면 굉장히 복잡해지고, 멀티쓰레드라면 이 함수들에서 Lock을 잡아줘야 한다(상호배타)
재귀적 락을 허용하지 않는 경우라면, Lock을 잡는 함수에서 또 Lock을 잡는 함수를 중첩해서 호출하면 절대로 안 된다. 이럴 경우 함수마다 ~Locked 이름을 붙여서 Lock을 잡는 함수인지 아닌지를 구별하면 매우 편하다! (ex. EnterRoomLocked, EnterRoom) 이렇게 구분해서 Locked 함수에서 Locked 함수를 호출하지 않도록 사용하면 상관없지만, 실수의 여지가 많고, 귀찮을 것이다.
만약 중첩락(재귀)을 허용하게 코드를 작업해주면, 이런 부분을 신경쓰지 않고 이미 만들어진 함수에서 다른 함수를 자유롭게 호출이 가능해집니다.
재귀를 비혀용하는 경우에는 WriteLock을 할 때 Write하는 쓰레드가 있는지 없는지 bool로 처리해도 상관없다. 그럼에도 WriteThreadId를 적은 이유는 재귀를 허용할 경우 필요하기 때문이다. 앞의 lock정책을 정할 때 write lock을 acquire한 상태에서 또다시 재귀적으로 같은 스레드에서 또 acquire을 할 때 그걸 허용할 것인지 아닌지 정해야 한다고 했고, 재귀를 허용하지 않는 경우를 먼저 구현했다.
재귀를 허용하는 ReaderWriterLock은 내가 WriteLock을 잡고 있는데, WriteLock을 잡을 수 있고, WriteLock 을 잡고 있으면서 ReadLock을 할 수도 있다. 단, ReadLock을 잡은 상태에서 WriteLock을 잡는 건 말이 안 됨.
ReadLock은 애초에 나만 잡을 수 있는 Lock이 아니라서 그 상태에서 WriteLock을 잡는 건 x
1.3.1. 변수/상수
우선 재귀 비허용 변수/상수 아래에 변수 _writeCount를 추가해준다. 재귀적으로 몇 개의 write를 하고 있는지 관리하는 변수.
int _writeCount = 0;
_writeCount를 _flag 비트에 넣지않아도 되는데, 이는 write라는 행동 자체가 상호배적인 행위. 누군가 WriteLock을 잡았다는 것은 이제 걔만 Write를 할 수 있다는 의미이다. 그래서 멀티 쓰레드 문제가 발생하지 않기 때문에 그냥 별도의 변수로 빼주어도 된다. 쉽게 말해 _writeCount 변수를 쓰는 Thread가 WriteLock을 잡은 애 하나 밖에 없기 때문에 따로 변수를 만들어서 사용해도 상관 없다는 의미이다(_flag는 여러 쓰레드가 접근해서 사용하는 공유변수).
1.3.2. WriteLock()
원래 작성했던 코드에 현재 WriteLock을 갖고 있는애가 WriteLock을 시도하는 건지 확인하는 코드를 넣어주면 된다.
// 동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인
int lockId = (_flag & WRITE_MASK) >> 16; // WriteThreadId를 오른쪽으로 끌고 온다
if(Thread.CurrentThread.ManagedThreadId == lockId)
{
_writeCount++;
return;
}
그리고 아무도 WriteLock을 갖고 있지 않았는데, Lock에 성공했을 경우, 바로 return해주지 않고 _writeCount를 1로 바꿔주고 return하도록 수정해준다.
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
{
_writeCount = 1;
return;
}
1.3.3. WriteUnlock()
public void WriteUnlock()
{
int lockCount = --_writeCount;
if (lockCount == 0)
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
하나의 쓰레드가 Write를 여러 번 불렀을 수 있으니까 --writeCount를 lockCount에 저장해서 모든 WriteLock이 해제된 경우에만 _flag를 비우도록 만든다.
❗여기서 궁금증이 생겼는데, ①writeCount-=1;로 안 해주고, 왜 lockCount 지역변수를 만들어줬는지, ②왜 전위감소(--_writeCount)를 해줬는지 의문이 들었다.
②는 곰곰히 생각해보니 lockCount = _writeCount--;라고 하면 lockCount에 -1을 하기 전 _writeCount값이 들어가고, _writeCount의 값이 1감소한다. 1감소된 값이 0이면 lock을 해제해줘야 하기 때문에 writeCount-1을 lockCount에 넣고 싶어서 전위감소를 한 것 같다.
①의 경우는 음... writeCount는 어차피 WriteLock을 잡은 애만 쓰는 변수였던 거 같은데 왜 지역변수를 만들어서 0인지 체크하는 건지 궁금하다. writeCount-=1; if(writeCount==0){ } 이렇게 해주면 안 되는 걸까
→ 강사님께 여쭤보니 아래와 같이 답변을 주셨다!
writeCount는 스택 변수가 아니라 멤버 변수라서 모든 쓰레드가 접근할 수 있습니다.
물론 위 예제에서는 Interlocked.Exchange(ref flag, EMPTY_FLAG) 코드가 실행되기 전엔 다른 쓰레드가 Write할 수 없어서 상관없긴 하고, 그래서 말씀하신 코드로 대체해도 무방합니다. 하지만 일반적인 멀티쓰레드 관점에서, writeCount이 0이 된 상황에서 바로 다른 쓰레드가 writeCount를 수정한다거나 하는 문제가 발생할 수 도 있습니다.
1.3.4. ReadLock()
ReadLock도 WriteLock과 비슷하게 코드를 추가해준다. writeCount를 늘리는 것이 아니라 _flag값을 1증가시키도록 코드를 수정해주면 된다. WriteLock이 잡힌 상태에서는 WriteLock을 잡은 녀석만 ReadLock을 할 수 있기 때문에 이 점을 유의해줘야 할 것 같다!
// 동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인
int lockId = (_flag & WRITE_MASK) >> 16; // WriteThreadId를 오른쪽으로 끌고 온다
if (Thread.CurrentThread.ManagedThreadId == lockId)
{
Interlocked.Increment(ref _flag);
return;
}
WriteLock → ReadLock을 했다면 풀어주는 순서는 안쪽에서 바깥쪽으로 ReadUnlock→WriteUnlock을 해줘야 한다.
[전체 코드]
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0x7FFF0000;
const int READ_MASK = 0x70000FFF;
const int MAX_SPIN_COUNT = 5000;
// int = 32비트 우리는 비트를 아래와 같이 사용할 것이다.
// [Unused(1)] [WriteThreadId(15)] [ReadCount(16)]
// WriteThreadId: write lock의 경우 한 번에 한 스레드만 획득이 가능하다고 했다. 그 스레드의 id를 적어둠
// ReadCount는 lock을 획득했을 때, 여러 스레드들이 동시에 얘를 read를 잡을 수 있는데, 그걸 count
int _flag = EMPTY_FLAG;
int _writeCount = 0;
public void WriteLock()
{
// 동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인
int lockId = (_flag & WRITE_MASK) >> 16; // WriteThreadId를 오른쪽으로 끌고 온다
if(Thread.CurrentThread.ManagedThreadId == lockId)
{
_writeCount++;
return;
}
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK;
// 아무도 WriteLock 혹은 ReadLock을 획득하고 있지 않을 때, 경합해서 소유권을 얻는다.
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
{
_writeCount = 1;
return;
}
}
Thread.Yield();
}
}
public void WriteUnlock()
{
int lockCount = --_writeCount;
if (lockCount == 0)
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
public void ReadLock()
{
// 동일 쓰레드가 WriteLock을 이미 획득하고 있는지 확인
int lockId = (_flag & WRITE_MASK) >> 16; // WriteThreadId를 오른쪽으로 끌고 온다
if (Thread.CurrentThread.ManagedThreadId == lockId)
{
Interlocked.Increment(ref _flag);
return;
}
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);
}
}
1.4.Test
델리게이트!!!!!!!!! 함수를 만들기 귀찮아서 delegate를 사용해주겠다고 강의에서 말했는데, 뭔가 어렵다...
실행시키면 count = 0이라는 값이 잘 나온다.
class Program
{
static volatile int count = 0;
static Lock _lock = new Lock();
static void Main(string[] args)
{
Task t1 = new Task(delegate ()
{
for (int i = 0; i< 100000; i++)
{
_lock.WriteLock();
count++;
_lock.WriteUnlock();
}
});
Task t2 = new Task(delegate ()
{
for (int i = 0; i < 100000; i++)
{
_lock.WriteLock();
count--;
_lock.WriteUnlock();
}
});
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(count);
}
}
[참고 사이트]
[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버
C#과 유니티로 만드는 MMORPG 게임 개발 시리즈 강의 노트
초등학생도 이해하는 C 언어 - & 와 && 의 차이점
'Programming > Server' 카테고리의 다른 글
[C# 게임서버] 네트워크 프로그래밍(2) (0) | 2024.01.19 |
---|---|
[C# 게임서버] 네트워크 프로그래밍(1) (0) | 2024.01.16 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(4) (0) | 2024.01.14 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(2) (0) | 2024.01.11 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(1) (0) | 2024.01.07 |