반응형

Reader-Writer lock 은 쓰거나 읽거나 둘중 하나만 동작하게 하여 동작하게 하는 방식인데

 

보통 주로 읽는 용도로 사용되다가 어쩌다 한번 쓰게 되는용도로 사용 될때 즉

보통 읽는 처리가 대부분인 상황에서 어쩌다가 간혹가다가 쓸때가 있다면

 

Reader-Writer lock 을 고려해 볼 수 있고 이것은 상호배타적으로 동작한다

쓸때 읽는게 안된다거나 읽을때 쓰는게 안된다거나..

 

여기서 병합영역을 최소화 하는것이 중요한데

뮤택스를 사용하지 않고 빠르게 처리하기 위해 CAS 를 사용해 non block thread 로 처리를 할 수있다

 

Reader-Writer lock

정리하자면 대부분 읽는 처리를 할건데 어쩌다 한번 쓰는것 때문에 뮤택스를 써서 읽을때마다 병합을 처리해야 하는 비용을 줄인다는것

 

방식은 spinlock 방식으로 사용한다 : 잠시만 데이터를 참조했다가 놔주는 형태가 될것임으로

 

 

 

Lock 컨셉 설명

Lock 를 커스터마이징하여 사용하는데

_lockFlag 를 상위 16 bit는 write 할 스레드 ID 를 저장하는 용도이고 하위 16 bit 는 Read 할때 Read 된 스레드 만큼 카운트를 증가 하는 방식이다 

 

단 write 할때의 상위 16 비트는 한번에 한 쓰레드만이 쓸수 있으며 누적해서 쓸때의 카운트는 _lockFlag  에 누적시키는 것이 아닌 별도의 카운트 변수로 사용한다

 

 

* _lockFlag :  상위를 Write 상태인지, 하위를 Read 상태인지로 보겠다는 것
* 비어 있다면 끼어들어서 Lock 을 차지 할수 있고 상위에 자신의 threadID 를 넣어 획득/소유권을 얻을 수 있다
* [WWWWWWWW][wwwwwwww][RRRRRRRR][rrrrrrrr])
* W : writeFlag (Exclusive Lock Onwer THreadID   //독립적인 스레드 ID TLS 에 아이디 생성
* R : ReadingFlag (Shared Lock Count)

 

 

 

 

 

lock 함수 설명 요약

 

 

WriteLock()

  • 쓸때는 _lockFlag 에 쓰레드 ID 를 쓰는데 현재 _lockFlag를 소유한 스레드만이 쓰기를 할수 있고 다른 쓰레드는 
    현재 쓰레드가 소유권을 포기 하기 전까지 소유권 획득 불가로 소유권은 한 스레드만 갖게 한다
  • 동일한 스레드가 소유권을 갖으려고 할대 별도의 변수 _writeCount를 두어 이 값이 증가하게 처리한다
    즉 동일 스레드가 재귀적으로 획득 하려고 할때 

writeUnlock() :

  • 소유했던 스래드의 일처리가 끝난다음 _writeCount 를 하나씩 감소시키고 
    _writeCount 가 0 이 되면(코드상0이 되기 직전에) _lockFlag   값을 0 으로 밀어준다

 

readLock()

  • 아무도 소유하고 있지 안을때(다시말해 write 를 하고 있지 안을때)는 경합해서 공유 카운트를 올리게 한다
  • 동일한 스레드가 소유하고 있다면 _lockFlag(공유 카운트)   값을 증가시켜준다
    => CAS 를 사용하게 될텐데 이때 비교는 _lockFlag 에 누구도 write 하지 않은 상태에서(소유권이 없는 상태에서)
    readCount(_lockFlag의 하위 16비트) 가 같다면 _lockFlag 값을 1 증가 시키겠다는 얘기

readUnlock() :

  • _lockFlag.fetch_sub(1) 을 하여 하위 16비트에서 1 씩을 빼준다
  • _lockFlag 이 되면 다른 스레드에서 소유권을 획득하여 쓰기를 하거나 또는 읽기스레드가 먼저 읽어 카운트를 올리거나 할 수 있다

 

 

 

 

 

 

 

/*
* _lockFlag :  상위를 Write 상태인지, 하위를 Read 상태인지로 보겠다는 것
* 비어 있다면 끼어들어서 Lock 을 차지 할수 있고 상위에 자신의 threadID 를 넣어 획득/소유권을 얻을 수 있다
* [WWWWWWWW][wwwwwwww][RRRRRRRR][rrrrrrrr])
* W : writeFlag (Exclusive Lock Onwer THreadID   //독립적인 스레드 ID TLS 에 아이디 생성
* R : ReadingFlag (Shared Lock Count)
*/

class Lock
{
public :

	enum : uint32
	{
		EMPTY_FLAG = 0x0000'0000,
		ACQUIRE_TIMEOUT_TICK = 10000,			//최대로 기다려줄 tick 시간
		MAX_SPIN_COUNT = 5000,						//스핀 카운트를 최대 몇번돌것인가
		WRITE_THREAD_MASK = 0xFFFF'0000,			//비트 플래그에서 상위 16 를 뽑아오기 위한 마스크
		READ_COUNT_MASK = 0X0000'FFFF
	};


public :

	void writeLock();
	void writeUnlock();
	void readLock();
	void readUnlock();

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

 

 

 

 


한가지 정책사항이 있는데 

동일한 스레드 내에서 즉  write 스래드내에서 다시 write 하는 경우에는 ok 즉 
 write -> write 하는 경우 OK
 Write -> read 하는경우도 OK

 

 하지만 동일한 스레드내에서 
 Read -> Write 하는 경우는 막는다

 

현 예제에선 나오지 않지만 구문을 작성하다보면 쓰면서 읽거나 읽으면서 쓰거나를 해야 되는 상황이 발생할수 있는데 이Read->Write 상황은 허용하지 않겠다는것

 

 



코드 설명

  • _lockFlag에 자신의 아이디를 썼을때(소유)  _lockFlag 를 해제 하기 전까지 다중 스레드의 Read 불가
    _lockFlag 가 아무도 소유되지 않은 상위 16바이트가 0 일때 , 즉 아무도 안소유한 상태일때, _lockFlag 다중 스레드들로부터 Read 가 가능
  • 아무도 소유하고 있지 않고 다중스레드로부터 Read 하는 _lockFlag 가 증가하고 있는데, 이떼 write 쓰레드가 들어와서 쓸때는 _lockFlag 에 쓰레드 ID 를 쓰는데 현재 _lockFlag를 소유한 스레드만이 쓰기를 할수 있고 다른 쓰레드는 현재 쓰레드가 소유권을 포기 하기 전까지 소유권 획득 불가 
//쓸때는 _lockFlag 에 쓰레드 ID 를 쓰는데 현재 _lockFlag를 소유한 스레드만이 쓰기를 할수 있고 다른 쓰레드는 
//현재 쓰레드가 소유권을 포기 하기 전까지 소유권 획득 불가
void Lock::writeLock()
{
	//동일한 스레드가 송유하고 있다면 write count 를 증가시켜준다
	const uint32 lockThreadID = (_lockFlag.load() & WRITE_THREAD_MASK) >> 16;
	if (LThreadId == lockThreadID)
	{
		++_writeCount;	
		//이때 _lockFlag 에는 더하지 않는다
		return;
	}
	 
	//아무도 소유 및 공유하고 있지 않다면 경합해서 소유권을 얻는다
	/*
	* 하지칸 코드를 이렇게 작성하면 현재 스레드에서 비교를 하려고 if 문을 방금 통과 했는데 다른 스레드가 치고 나가서 if문 비교뿐만 아니라 _lockFlag 에 값을 써버린다면
	* 현 기존 _lockFlag 그가 망가짐 그래서 아래 처럼 쓰면 안되고 CAS 연산으로 비교 및 할당을 한줄로 처리해야함 원자적 연산으로
	if (_lockFlag == EMPTY_FLAG)
	{
		const uint32 desired = ((LThreadId << 16) & 16 & WRITE_THREAD_MASK);
		_lockFlag = desired;
	}
	*/


	const int64 startTick = ::GetTickCount64();


	

	//desired 변수 상위 16 비트에 현재 스레드 ID를 세팅
	const uint32 desired = ((LThreadId << 16) & WRITE_THREAD_MASK);
	while (true)
	{ 
		for (uint32 spintCount = 0; spintCount < MAX_SPIN_COUNT; ++spintCount)
		{
			//_lockFlag 가 비어 있다면 현재 쓰래드 ID 를  _lockFlag의 상위  WWWWWWWWwwwwwwww 에 write 플래그쪽으로 ID를 넣어준다 => 쓰기가 시작돼다는 기
			//즉 이것은 write 또는 read 둘중 하나만 있는 상태가 된다

			//exptedted 를 while 바깥으로 빼면 안된다 왜냐면이 값은 compare_exchange_strong 가 실행된다음  return 이 false 면 
			//expected 값을 _lockFlag 로 바꾸기 때문에
			uint32 expected = EMPTY_FLAG;	
			//_lockFlag 가 아무도 소유하고 있지 않은 상태에서만 쓰기가 한 쓰레드만 허용 된다는 것
			if (_lockFlag.compare_exchange_strong(expected, desired))
			{
				//이곳은 한 스레드만 들어올 수 있음
				++_writeCount;		//증가 시키는 이유는 재귀적을 write Lock 을 호출하면 증가 시켜주기 위해서
				//이때 _lockFlag 에는 더하지 않는다
				return;
			}
		}

		//소유권 선점 시간이 너무 오래 걸리면 크래쉬를 낸다 => 이것도 문제가 될 수 있기 때문에(dead lock 일 수 있음)
		if (::GetTickCount64() - startTick >= ACQUIRE_TIMEOUT_TICK)
		{
			CRASH("Lock_TimeOUT");
		}

		//실패하면 소유권 선점을 잠시 쉰다
		this_thread::yield();
	}


}

void Lock::writeUnlock()
{
	//ReadLock  다 풀기 전에넌 write unlock 은 불가능하게 할것임
	if ((_lockFlag.load() & READ_COUNT_MASK) != 0)	//write 상태인지 확인 =>wite 상태여야 unlock 하는게 가능
	{
		CRASH("INVALID_writeUnlock");
	}

	const int32 lockCount = --_writeCount;
	if (lockCount == 0)
	{
		_lockFlag.store(EMPTY_FLAG);
	}

}


//TheadID 

//동일한 스레드가 소유하고 있다면 성공하게 할것인데 
//아무도 소유하고 있지 안을때(write 를 하고 있지 안을때)는 경합해서 공유 카운트를 올리게 한다
void Lock::readLock()
{
	//동일한 스레드가 소유하고 있다면 _lockFlag 자체에 count를 증가시켜주고 끝낸다
	const uint32 lockThreadID = (_lockFlag.load() & WRITE_THREAD_MASK) >> 16;
	if (LThreadId == lockThreadID)
	{
		_lockFlag.fetch_add(1);
		return;
	}

	const int64 startTick = ::GetTickCount64();
	while(true)
	{
		for (uint32 spinCount = 0;spinCount < MAX_SPIN_COUNT; ++spinCount)
		{
			uint32 expected = (_lockFlag.load() & READ_COUNT_MASK);
			//Read 부분만 읽어와서 리드 부분이 _lockFlag 과 같다면 +1 증가 , 즉 write 상태가 아니라는 것을 의미
			//=> 실패하게 되는 케이스
			//1. 누군가가 write 로 _lockFlag 을 잡고 있는 경우 => 이땐 증가 불가
			//.2. 이 코드 중간에 다른 스레드가 들어와서 _lockFlag 중 Read 부분의 카운트를 다른 스레드가 증가시킨경우 => 대기해서 다음에 증가 시도처리

			//여기서의 비교는 누구도 write 하지 않은 상태에서 readCount 가 같다면 증가 시키겠다는 얘기
			if (_lockFlag.compare_exchange_strong(expected, expected + 1))
			{
				return;
			}
		}

		//리드 시도 하려는 것이 너무 오래 걸리면 크래쉬를 낸다 => 이것도 문제가 될 수 있기 때문에(dead lock 일 수 있음)
		if (::GetTickCount64() - startTick >= ACQUIRE_TIMEOUT_TICK)
		{
			CRASH("Lock_TimeOUT");

		}
		this_thread::yield();

	}

}

void Lock::readUnlock()
{
	//fetch_sub 은 1을 빽 이전 값을 리턴한다
	if ((_lockFlag.fetch_sub(1) & READ_COUNT_MASK) == 0)
	{
		//만약 이곳에 들어온다면 이전의 read 카운트가 이미 0 이였기 때문에 여기서 더  빼면 안되는 상황이라
		//안전상 크래쉬를 걸어준다
		CRASH("MULTIPLE_UNLOCK");
	}
		
}

 

 

 

 

 

 

ReadLockGuard, WriteLockGuard

Lock 을 std::lock_guard 처럼 사용하기 위한 중간자라고 생각하면 됩니다

 

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;
};

 

 

 

 

 

 

 

 

 

 

 

main 실행 구문

 

ThreadManager 로 스레드를 관리하는데 자세한 사항은 이것을 참고하면 됩니다

https://3dmpengines.tistory.com/2220

 

Threadmanager

thread 생성 하면서 TLS 초기화를 해주는 스레드 메니저 클래스이고 대단한 기능이 있는건 아니고 관리적인 측면의 클래스라 보면 됩니다 tls 는 이글을 참고하면 됩니다 https://3dmpengines.tistory.com/220

3dmpengines.tistory.com

 

 

TestLock 클래스내에 Lock 을 사용하고 있느 이 lock 을 WriteLockGuard 나 ReadLockGuard 를 사용하여 NonBlocking thread 방식으로 처리 하는것을 볼수 있으며 그래서 뮤택스가 없다는 것을 알 수 있습니다

const int32 lockCount = 1;

const int32 lockIndex = 0;

class TestLock
{
	
	Lock _locks[lockCount];

public:
	int32 testRead()
	{
		ReadLockGuard readLockGuard0(_locks[lockIndex]);

		if (_queue.empty())
		{
			return -1;
		}
		return _queue.front();
		
	}

	void testPush()
	{
		WriteLockGuard writeLockGuard0(_locks[lockIndex]);

		_queue.push(rand() % 100);

	}

	void testPop()
	{
		WriteLockGuard writeLockGuard0(_locks[lockIndex]);

		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);
	}


	//this_thread::sleep_for(1ms);


	for (int32 i = 0; i < 5; ++i)
	{
		GThreadManager->launch(threadRead);
	}
	

	GThreadManager->Join();

	return 0;
}

 

 

실행 결과  

실행결과 창에 뜬느 값의 의미가 크게 있진않습니다

 

 

 

한가지 주의사항

 

while(...) 반복문
{
	uint32 expected = EMPTY_FLAG;
	if (_lockFlag.compare_exchange_strong(expected, desired))
}

 

이런 구문이 있다고 했을때 반복문 안의 uint32 expected = EMPTY_FLAG; 이 값을 반복문 바끝으로 빼면

굳이 필요 없는 일을 안해도 될텐데 라는 생각을 할 수도 있는데, 이는 오산입니다

왜냐면 CAS 연산하면서 즉 compare_exchange_strong 이 함수가 실행되고 나면 실패시 expected  값을 내부적으로 바꿔주기 대문에 반복문 안에 저렇게 초기화 해주는것이 로직상 의미를 갖을 수 있기 때문입니다

 

반응형

+ Recent posts