이제 서버 엔진부에서 사용할 락을 만들어 보자.
지금까지 사용했던 표준 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 |