Reader-Writer Lock

원래벌레·2022년 7월 14일
0

Reader-Writer Lock의 구현하여 사용하는 이유

경우에 따라서 Lock을 걸기엔 아까운 경우가 발생하기도 한다.
예를 들어서 데이터의 변경이 0.0001퍼센트로 일어난다고 할 때, 이 경우를 위해서 Read하는 경우를 한명씩 들여보내는 것은 매우 비효율적이다. 이를 해결하기 위해서 표준의 Lock을 사용하는 것이 아닌 직접 만든 Lock을 사용하게된다.

💎 소스코드

/*--------------------
     R/W SpinkLock
--------------------*/

/*--------------------
     [WWWWWWWW][WWWWWWWW][RRRRRRRR][RRRRRRR]
     W : WrieteFlag (Exclusive Lock Owner ThreadId)
     R : ReadFlag ( Shared Lock Count)
--------------------*/

//동일 쓰레드에서
// W -> W (O)
// W -> R (O)
// R -> W (X)

class Lock
{
	enum : uint32
    {
    	ACQUIRE_TIMEOUT_TICK = 10000,
    	MAX_SPIN_COUNT = 5000,
        WRITE_THREAD_MASK = 0xFFFF'0000,
        READ_COUBT_MASK = 0x0000'FFFF,
        EMPTY_FLAG = 0x0000'0000
    }
    
public:
	void WriteLock();
    void WriteUnlock();
    void ReadLock();
    void ReadUnlock();
private:
	Atomic<uint32> _lockFlag;
    uint16 _writeCount = 0;
};

/*----------------------
 LockGuards
 ---------------------*/
 
 class ReadLockGuard
 {
 public:
 	ReadLockGuard(Lock& lock) : _lock(lock) { _lock.ReadLock(); }
    ~ReadLockGuard() { _lock.ReadUnlock(); }
 
 private:
 	Lock& _lock;

};


class WriteLockGuard
{
public:
	WirteLockGuard(Lock& lock) : _lock(lock) {_lock.WriteLock();}
    ~WriteLockGuard() { _lock.WriteUnlock();}
    
private:
	Lock& _lock;


void Lock::WriteLock()
{
	// 동일한 쓰레드가 소유하고 있다면 무조건 성공.
    const uint32 lockThreadId = (_lockFlag.load() & WRITE_THREAD_MASK) >> 16;
    if(LThreadId == lockThreadId)
    {
    	_writeCount++;
        return;
    }
	
	// 아무도 소유 및 공유하고 있지 않을 때, 경합해서 소유권을 얻는다.
    if(_lockFlag == EMPTY_FLAG)
    {
    	const uint32 desired = ((LThreadId << 16) & WRITE_THREAD_MASK);
        _lockFlag = desired;
    }
    
    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");
}

💎 중요부분 해설

  • 위의 소스에서 중요한 부분은 제일 먼저 Read와 Write Lock에 관련한 모든 경우에 대하여 데이터의 참조 또는 변경을 가능하게 할 것인가? 또는 아니게 할 것인가를 정의를 해주어야 한다.

  • 위의 내용에 따라서
    Write -> Read (o)
    Write -> Write (같은 쓰레드인 경우만 o 아니면 x)
    Read -> Read (o)
    Read -> Write (x)

  • 위의 경우에 맞춰서 Write와 Read의 Lock과 Unlock을 구현해준다. 여기서 먼저 WriteLock을 먼저 설명하면 WriteLock의 경우는 _lockFlag의 값이 EmptyFlag 인 경우 즉 값이 0인 경우에 WriteLock을 획득한다. 이 쓰레드가 WriteLock을 획득한 상태는 현재 ReadLock이 하나도 걸려있지 않은 상태이다. 그런데 만약에 같은 쓰레드가 Unlock이 되기전에 다시 한번 더 WriteLock을 얻으려고 시도를 하게 되면, 이경우에는 WriteLock을 잡는 것을 허용해준다. 허용해주었다는 표현으로 WriteCount의 값을 하나 올려준다. 반면에 따른 쓰레드가 WriteLock을 잡으려고하면 이는 허용하지 않는다. 그 후 쓰레드가 WriteUnlock을 하려고 할 때는 WriteCount 값을 이용한다. 만약에 WriteCount 값이 0이라면 현재 쓰레드가 쓰기명령을 다 진행한 상태이기 때문에 unlock을 진행한다. 하지만 만약에 0이 아니라면 현재쓰레드가 다른 쓰기명령이 남아있는 상태이기 때문에 WriteCount의 값을 -1 해주기만 하고 unlock은 진행하지 않는다.

  • 다음은 ReadLock이다. ReadLock은 위에서도 말했듯이 Write한 상태에서 Read가 가능하고, Read한 상태에서 Read가 가능하다. 그렇듯 먼저 Write한 상태에서 Read로 넘어가는 과정은 현재 WriteLock이 잡혀있다면 _lockFlag 에 앞 16비트에 ThreadId 값이 존재 할 것이다. 그렇기 때문에 해당 ThreadId 값을 가져와서 현재 쓰레드의 값 LThreadI와 값을 비교하여 만약에 같다면 _lockFlag의 값을 +1 해준다. 이렇게 +1 해주는 이유는 _lockFlag의 뒷자리 16비트는 ReadLock의 count를 뜻하기 때문이다. 다음은 Read -> Read로 가는 부분이다. 이 부분 같은 경우는 ReadLock을 무조건 통과하기 때문에 단순히 CAS연산을 수행해주면 된다.

  • 이렇게 ReadLock과 WriteLock을 나눠주는 것을 통하여 비용이 많이드는 WriteLock과 ReadLock이 공존하면서 따로 돌게끔 해주기 때문에 시스템의 처리속도를 올려주는데에 큰 역할을 해준다.
    cf) 여기서 WriteLock이 비용이 많이 드는 이유는 WriteLock은 Lock을 잡는 경우가 적기 때문에 CAS 연산을 하는 while문이 너무 많이 돌게 된다.
profile
학습한 내용을 담은 블로그 입니다.

0개의 댓글