서버/멀티스레드

16. Reader-Writer Lock

광란의슈가슈가룬 2024. 8. 2. 08:32

이제 서버 엔진부에서 사용할 락을 만들어 보자.

 

지금까지 사용했던 표준 mutex는 일단 재귀적으로 락을 잡을 수 없다. 이부분은 재귀적으로 락을 잡을 수 있도록 하는 recursive_mutex를 사용하면 된다고 할 수도 있지만 다른 문제가 있다.

변하지 않는 데이터가 있고 모든 스레드가 이 데이터를 읽기만 한다면 굳이 락을 걸어서 사용할 필요가 없다. 하지만 굉장히 드물게(예를들어 1주일 또는 1달) 데이터에 변동이 있다면 분명 충돌이 날 것이고 이를 방지하기 위해서는 락을 걸어주어야 하는데 고작 이 1번을 위해서 락을 걸어서 사용해야 하나?라고 생각할 수 있을 것이다.

이때 사용하는 것이 Reader-Writer Lock이다. read할 때에는 락을 걸지 않은 것처럼 사용하다가 write할 때만 락을 걸고 사용하는 것이다.

 

lock 클래스

#pragma once

#include "Types.h"

//----------------------
//	RW SpinLock
//----------------------

//-------------------------------------------------
// [WWWWWWWW][WWWWWWWW][RRRRRRRR][RRRRRRRR]
// W : WritingFlag (Exclusive Lock Owner ThreadID)
// R : ReadFlag (Shared Lock Count)
//-------------------------------------------------

class Lock
{
	enum : uint32
	{
		ACQUIRE_TIMEOUT_TICK = 10000,
		MAX_SPIN_COUNT = 5000,
		WRITE_THREAD_MASK = 0xFFFF0000,
		READ_COUNT_MASK = 0x0000FFFF,
		EMPTY_FLAG = 0x00000000
	};
public:
	void WriteLock();
	void WriteUnlock();
	void ReadLock();
	void ReadUnlock();

private:
	Atomic<uint32> _lockFlag = EMPTY_FLAG;
	uint16 _writeCount = 0;
};

32비트 변수를 사용하여 상위 16비트는 락을 소유하고 있는 스레드의 ID, 하위 16비트는 공유하고 있는 스레드의 개수를 의미한다.

#include "pch.h"
#include "Lock.h"
#include "CoreTLS.h"

void Lock::WriteLock()
{
	// 동일한 스레드가 소유하고 있다면 무조건 성공
	const uint32 lockThreadID = (_lockFlag.load() & WRITE_THREAD_MASK) >> 16;

	if (LThreadId == lockThreadID)
	{
		_writeCount++;
		return;
	}

	// 아무도 소유 및 공유하고 있지 않을 때, 경합 후 소유권 획득(EMPTY_FLAG일 때)
	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(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 uint32 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++)
		{
			// WriteLock을 아무도 잡지 않았을 때
			uint32 expected = (_lockFlag.load() & READ_COUNT_MASK);

			if (_lockFlag.compare_exchange_strong(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");
}

RW 스핀락 구현부이다.

 

WriteLock은 데이터를 쓰기 위해 락을 잡는 것이므로 아무도 쓰거나 읽고 있지 않을 때 락을 획득해야한다. 이미 자신이 락을 획득한 상황이라면 _writeCount를 올리는 것으로 마무리한다. 락을 획득하는데 성공했다면 lockFlag의 상위 16비트를 자신의 스레드ID를 저장한다.

 

WriteUnlck은 쓰고난 후 락을 푸는 것으로 자신이 여러번 락을 잡을 수 있기 때문에 _writeCount을 확인하여 0이 되면 lockFlag를 초기화한다.

 

ReadLock은 데이터를 읽기 위해 락을 잡는 것이다. 자신이 Write하는 상황을 제외하면 어떠한 스레드도 Write를 하고 있지 않을 때 읽을 수 있다. 여러 스레드가 동시에 데이터를 읽을 수 있으므로 락을 획득하면 lockFlag의 하위 16비트(ReadFlag)를 1 올린다.

 

ReadUnlock은 읽고난 후 락을 푸는 것으로 그냥 lockFlag를 1 줄이면 된다. 만약 이미 lockFlag가 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;
};

락가드 객체가 생성됨과 동시에 락을 잡고 소멸되면서 락을 해제한다.

 

편하게 락을 사용할 수 있도록 메크로도 만들어주자

 

CoreMacro.h

//------------------
//	Lock
//------------------

#define USE_MANY_LOCKS(count)	Lock _locks[count];
#define USE_LOCK		USE_MANY_LOCKS(1);
#define READ_LOCK_INDEX(index)	ReadLockGuard readLockGuard_##index(_locks[index]);
#define READ_LOCK		READ_LOCK_INDEX(0);
#define WRITE_LOCK_INDEX(index)	WriteLockGuard writeLockGuard_##index(_locks[index]);
#define WRITE_LOCK		WRITE_LOCK_INDEX(0);

 

Main.cpp

#include "pch.h"
#include "ThreadManager.h"

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 < 2; i++)
	{
		GThreadManager->Launch(ThreadRead);
	}

	GThreadManager->Join();
}

메크로를 활용하여 간단하게 코드를 작성해보았다. 실행해보면 전에 사용했던 뮤텍스와 별 다를 것이 없어보이지만 위에서는 read만 할 경우 단순히 readCount만 올려주면서 경합 없이 더 효율적으로 작동한다.

'서버 > 멀티스레드' 카테고리의 다른 글

17. DeadLock 탐지  (0) 2024.08.04
15. ThredManager  (0) 2024.08.01
14. Lock-Free Stack #2  (0) 2024.07.30
13. Lock-Free Stack #1  (0) 2024.07.29
12. Lock-Based Queue/Stack  (0) 2024.07.26