KoreanFoodie's Study
[C++ 게임 서버] 1-22. Reader-Writer Lock 본문
[C++ 게임 서버] 1-22. Reader-Writer Lock
핵심 :
1. 읽는 경우가 쓰는 경우보다 훨씬 많을 경우, Read Lock 과 Write Lock 을 나누어서 사용할 수도 있다.
2. Write Lock 의 경우 쓰레드의 ID 를 기준으로 소유권을 경합하고, Read Lock 의 경우 공유 카운트를 올리는 방식으로 작동한다.
3. Write Lock 을 잡은 상태에서 Read Lock 을 잡는 것은 가능하지만, Read Lock 을 잡은 상태에서 바로 Write Lock 을 잡는 것은 불가능하도록 설계한다.
만약 어떤 컨텐츠에서, 내용을 읽는 경우는 1초에 100번 일어나는데, 실제로 내용을 쓰는 경우는 일주일에 한 번 일어난다고 가정해 보자.
이때, 사실 내용을 읽을 때마다 쓰레드별로 락을 매번 잡아서 작업을 수행하게 한다면, 사실 이는 낭비가 아닐 수 없다.
따라서, Read 와 Write 각각의 경우를 구분하여 Lock 을 따로 구현할 수 있는데, 다음과 같이 간단히 Read-Write Lock 을 구현할 수 있다.
Lock.h
#pragma once
#include "Types.h"
/*----------------
RW SpinLock
-----------------*/
/*--------------------------------------------
[WWWWWWWW][WWWWWWWW][RRRRRRRR][RRRRRRRR]
W : WriteFlag (Exclusive Lock Owner ThreadId)
R : ReadFlag (Shared Lock Count)
---------------------------------------------*/
class Lock
{
enum : uint32
{
ACQUIRE_TIMEOUT_TICK = 10000,
MAX_SPIN_COUNT = 5000,
WRITE_THREAD_MASK = 0xFFFF'0000,
READ_COUNT_MASK = 0x0000'FFFF,
EMPTY_FLAG = 0x0000'0000
};
public:
void WriteLock();
void WriteUnlock();
void ReadLock();
void ReadUnlock();
private:
Atomic<uint32> _lockFlag = EMPTY_FLAG;
uint16 _writeCount = 0;
};
/*----------------
LockGuards
-----------------*/
class ReadLockGuard
{
public:
ReadLockGuard(Lock& lock) : _lock(lock) { _lock.ReadLock(); }
~ReadLockGuard() { _lock.ReadUnlock(); }
private:
Lock& _lock;
};
class WriteLockGuard
{
public:
WriteLockGuard(Lock& lock) : _lock(lock) { _lock.WriteLock(); }
~WriteLockGuard() { _lock.WriteUnlock(); }
private:
Lock& _lock;
};
일단 32bit 길이의 flag 를 활용할 것이다.
Lock 클래스의 enum 을 보면 각 변수의 역할을 명확히 알 수 있다.
일단 ACQUIRE_TIMEOUT_TICK 의 경우, 타임아웃이 걸리는 Tick 수를 의미하며, MAX_SPIN_COUNT 는 SpinLock 에서 Lock 을 잡기 위해 쓰레드가 루프를 몇 번 까지 돌지를 의미한다.
WRITE_THREAD_MASK 와 READ_COUNT_MASK 는 각각 Write Lock 과 Read Lock 에 쓰일 플래그의 마스크 부분을 의미하는데, LThreadId 라는, Thread Local Storage 에 담길 쓰레드 각각의 Id 값을 Write Lock 에 활용할 것이다.
좀 더 구체적으로 말하면, 쓰레드의 Id 값을 16 bit 위쪽으로 shift 하여 Write Lock 과 And (&) 연산을 시켜 현재 Write Lock 의 소유권이 누구에게 있는지 판별할 것이다.
Read 의 경우에는 실제로 Lock 을 걸 필요까지는 없으므로, 공유 카운트(혹은 참조 카운트)를 늘리는 방식으로 READ_COUNT_MASK 를 활용할 것이다.
이제 실제 구현 코드를 보자 :
Lock.cpp
void Lock::WriteLock()
{
// 동일한 쓰레드가 소유하고 있다면 무조건 성공.
const uint32 lockThreadId = (_lockFlag.load() & WRITE_THREAD_MASK) >> 16;
if (LThreadId == lockThreadId)
{
_writeCount++;
return;
}
// 아무도 소유 및 공유하고 있지 않을 때, 경합해서 소유권을 얻는다.
const int64 beginTick = ::GetTickCount64();
const uint32 desired = ((LThreadId << 16) & WRITE_THREAD_MASK);
while (true)
{
for (uint32 spinCount = 0; spinCount < MAX_SPIN_COUNT; spinCount++)
{
uint32 expected = EMPTY_FLAG;
if (_lockFlag.compare_exchange_strong(OUT expected, desired))
{
_writeCount++;
return;
}
}
if (::GetTickCount64() - beginTick >= ACQUIRE_TIMEOUT_TICK)
CRASH("LOCK_TIMEOUT");
this_thread::yield();
}
}
void Lock::WriteUnlock()
{
// ReadLock 다 풀기 전에는 WriteUnlock 불가능.
if ((_lockFlag.load() & READ_COUNT_MASK) != 0)
CRASH("INVALID_UNLOCK_ORDER");
const int32 lockCount = --_writeCount;
if (lockCount == 0)
_lockFlag.store(EMPTY_FLAG);
}
void Lock::ReadLock()
{
// 동일한 쓰레드가 소유하고 있다면 무조건 성공.
const uint32 lockThreadId = (_lockFlag.load() & WRITE_THREAD_MASK) >> 16;
if (LThreadId == lockThreadId)
{
_lockFlag.fetch_add(1);
return;
}
// 아무도 소유하고 있지 않을 때 경합해서 공유 카운트를 올린다.
const int64 beginTick = ::GetTickCount64();
while (true)
{
for (uint32 spinCount = 0; spinCount < MAX_SPIN_COUNT; spinCount++)
{
uint32 expected = (_lockFlag.load() & READ_COUNT_MASK);
if (_lockFlag.compare_exchange_strong(OUT expected, expected + 1))
return;
}
if (::GetTickCount64() - beginTick >= ACQUIRE_TIMEOUT_TICK)
CRASH("LOCK_TIMEOUT");
this_thread::yield();
}
}
void Lock::ReadUnlock()
{
if ((_lockFlag.fetch_sub(1) & READ_COUNT_MASK) == 0)
CRASH("MULTIPLE_UNLOCK");
}
WriteLock 의 경우, 유일무이한 '소유권'을 얻기 위해 SpinLock 안에서 루프를 돌며(MAX_SPIN_COUNT 까지) 경합을 한다. 이때, 소유권을 갖고 있는지를 LThreadId 와 WRITE_THREAD_MASK 를 이용해 판별하고 있다. Write Lock 을 잡기 위해서는 소유 및 공유를 아무도 하고 있지 않아야 하는데, 이를 EMPTY_FLAG 를 이용해 판단하는 것이다! (위쪽 16비트는 소유권 체크, 아래쪽 16비트는 공유권 체크)
그리고 WriteUnLock 에서 쓰는 것을 마쳤을 때, 다시 _lockFlag 를 EMPTY_FLAG 로 바꾸고 나가게 된다.
반면 Read Lock 의 경우, Write 를 하려는 녀석이 없을 경우(즉, 아무도 소유하고 있지 않을 경우)에 경합하여 공유 카운트를 올린다.
그 과정이 아래 코드로 표현이 되어 있는데,
uint32 expected = (_lockFlag.load() & READ_COUNT_MASK);
if (_lockFlag.compare_exchange_strong(OUT expected, expected + 1))
return;
expected 를 위와 같이 READ_COUNT_MASK 를 이용하여 계산하면 Write Lock 에 해당하는 플래그들이 전부 0이 될 것이다.
그 다음, _lockFlag 와 비교를 해서 일치하면... 일치한다는 것은, _lockFlag 의 상위 16비트 부분도 실제 0 이라는 뜻이므로, 아무도 Write Lock 을 잡고 있지 않다는 뜻이 된다! 😮
따라서 그 경우에 안전하게 _lockFlag 에 +1 을 해 줘서, 공유 카운트만 올린 뒤, 빠져 나오면 된다 😊
물론 ReadUnlock 에서, 다시 _lockFlag 에 -1 을 해 주게 된다!
이제 위 Lock 클래스를 편하게 활용할 수 있는 매크로를 조금 정의할 것이다.
CoreMacro.h
#define OUT
/*---------------
Lock
---------------*/
#define USE_MANY_LOCKS(count) Lock _locks[count];
#define USE_LOCK USE_MANY_LOCKS(1)
#define READ_LOCK_IDX(idx) ReadLockGuard readLockGuard_##idx(_locks[idx]);
#define READ_LOCK READ_LOCK_IDX(0)
#define WRITE_LOCK_IDX(idx) WriteLockGuard writeLockGuard_##idx(_locks[idx]);
#define WRITE_LOCK WRITE_LOCK_IDX(0)
Lock 을 몇개 잡을지 일일히 쓰기보다, 위의 매크로로 쓰는 것이 편할 것이다.
참고로, ## 은 그 위의 idx 를 문자열을 실제 붙인 것처럼 사용할 수 있게 도와준다. 약간의 꼼수..? 라고 이해하자 😅
맨 위의 #define OUT 은, 문자 그대로 OUT 을 아무 의미도 없는 것으로 정의하겠다는 뜻이다. 개발 편의성 측면에서, OUT 이라고 되어 있으면 변경되는 output 매개변수라고 이해하면 된다. 😄
이제 실제 위 클래스들과 매크로를 활용해 보자 :
GameServer.cpp
class TestLock
{
USE_LOCK;
public:
int32 TestRead()
{
READ_LOCK;
if (_queue.empty())
return -1;
return _queue.front();
}
void TestPush()
{
WRITE_LOCK;
_queue.push(rand() % 100);
}
void TestPop()
{
WRITE_LOCK;
if (_queue.empty() == false)
_queue.pop();
}
private:
queue<int32> _queue;
};
TestLock testLock;
void ThreadWrite()
{
while (true)
{
testLock.TestPush();
this_thread::sleep_for(1ms);
testLock.TestPop();
}
}
void ThreadRead()
{
while (true)
{
int32 value = testLock.TestRead();
cout << value << endl;
this_thread::sleep_for(1ms);
}
}
int main()
{
for (int32 i = 0; i < 2; i++)
{
GThreadManager->Launch(ThreadWrite);
}
for (int32 i = 0; i < 5; i++)
{
GThreadManager->Launch(ThreadRead);
}
GThreadManager->Join();
}
위에서, Write 쓰레드는 큐에 랜덤한 숫자를 넣고 1ms 동안 잠들고, Read 쓰레드는 큐의 첫 항목을 읽고 다시 1ms 잠든다.
실행시켜 보면, 크래시가 나지 않고 잘 돌아가는 것을 확인해 볼 수 있다. 물론, 쓰레드가 과도하게 많아지거나 읽는데 시간이 너무 오래 걸리는 경우에는, 크래시가 날 것이다.
물론 위 코드에서, 그럴 일은 거의 없을 것이다.
다만... 락을 못 잡았는데 값을 읽거나 쓸 수도 있지 않을까? 라는 생각이 들 수 있는데, 그것을 방지하기 위해 아래 코드가 있는 것이다 😉
this_thread::yield();
while 루프 안에서, MAX_SPIN_COUNT 만큼 돌았는데도 Lock 을 획득하지 못하는 경우, 쓰레드를 바로 yield 를 시켜 읽거나 쓰는 동작을 쓰레드가 하지 못하도록 만드는 것이다! 😆
'Game Dev > Game Server' 카테고리의 다른 글
[C++ 게임 서버] 1-24. 연습문제 (소수의 갯수 구하기) (0) | 2023.08.08 |
---|---|
[C++ 게임 서버] 1-23. DeadLock 탐지 (0) | 2023.08.08 |
[C++ 게임 서버] 1-21. ThreadManager (0) | 2023.07.27 |
[C++ 게임 서버] 1-20. Lock-Free Queue (0) | 2023.07.26 |
[C++ 게임 서버] 1-17, 18, 19. Lock-Free Stack (0) | 2023.07.26 |