Reader-Writer Lock

원래벌레·2022년 7월 14일
0
post-custom-banner

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
학습한 내용을 담은 블로그 입니다.
post-custom-banner

0개의 댓글