KoreanFoodie's Study

[C++ 게임 서버] 1-22. Reader-Writer Lock 본문

Game Dev/Game Server

[C++ 게임 서버] 1-22. Reader-Writer Lock

GoldGiver 2023. 8. 7. 22:39

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[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 를 시켜 읽거나 쓰는 동작을 쓰레드가 하지 못하도록 만드는 것이다! 😆

Comments