KoreanFoodie's Study

[C++ 게임 서버] 1-4. Lock 기초 (lock_guard, unique_lock) 본문

Game Dev/Game Server

[C++ 게임 서버] 1-4. Lock 기초 (lock_guard, unique_lock)

GoldGiver 2023. 7. 10. 17:07

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 1-1. 멀티쓰레드 개론

핵심 :

1. Critical Section 에 진입할 때는 mutex 등을 활용하여 Lock 을 걸면 Race Condition 을 해소할 수 있다.

2. 일반적으로 직접 lock/unlock 을 해주기 보다, lock_guard 나 unique_lock 을 사용하여 RAII 방식으로 Lock 을 걸어 주는 것이 좋다.

3. unique_lock 의 경우, defer_lock 등의 옵션을 주어 Lock Guard 를 좀 더 유연하게 사용할 수 있다. 다만 lock_guard 에 비해 메모리를 더 많이 차지하고 무거을 것이다.

다음과 같이 여러 쓰레드를 이용해 벡터에 원소를 삽입한다고 해 보자 :

vector<int> vec;

void Push()
{
	for (int i = 0; i < 10'000; ++i)
	{
		vec.push_back(i);
	}
}

int main()
{
	thread t1(Push);
	thread t2(Push);

	t1.join();
	t2.join();

	cout << "vec.size() : " << vec.size() << endl;
}

 

위의 코드를 실행하면, 다음과 같이 바로 크래시가 난다 :

 

vector 의 경우, 갯수가 늘어날 때 메모리를 새로 할당하는데 Race Condition 이 생기면 영 좋지 않은 일이 일어날 수 있을 것이다 😂

따라서 다음과 같이 vector 에 필요한 총 메모리를 미리 확보해 놓으면 크래시는 발생하지 않지만...

vec.reserve(20000);

문제는, vector 에 삽입된 원소들의 갯수가 정확히 20000 개가 되지 않게 된다!

해당 문제는 이전에 배운 atomic 으로도 해결은 되겠지만, 그건 사실 멀티쓰레드 환경에서 우리가 바라는 동작이 아닐 것이다.

 

이런 경우, 간단하게 mutex 를 이용해 Lock 을 걸면 문제를 해결할 수 있다!

mutex m;

void Push()
{
	for (int i = 0; i < 10'000; ++i)
	{
		m.lock();

		vec.push_back(i);

		m.unlock();
	}
}

위와 같이 코드를 조금 수정해 주면 된다!

 

다만 실제로 위와 같이 Lock 을 사용하면 문제가 생길 수 있다. 예를 들어 다음 코드를 보자.

void Push()
{
	for (int i = 0; i < 10'000; ++i)
	{
		m.lock();

		// 어떤 동작을 하다가, 실수로 unlock 을 안하고 return 을 해버린다.
		if (i == 5000)
			break;

		vec.push_back(i);

		m.unlock();
	}
}

따라서 위와 같은 상황을 방지하기 위해, RAII (Resource Aquisition Is Initialization) 를 적용하여, LockGuard 를 만들어 주면 좋다 😉

 

template <typename T>
class LockGuard
{
public:
	LockGuard(T& m)
	{
		_mutex = &m;
		_mutex->lock();
	}

	~LockGuard()
	{
		_mutex->unlock();
	}

private:
	T* _mutex;
};

void Push()
{
	for (int i = 0; i < 10'000; ++i)
	{
		// Lock 을 걸고 싶을 때 LockGuard 를 선언만 해 준다.
		LockGuard<mutex> lockGuard(m);
		
		// Lock 을 걸고 어떤 동작을 할 것이다.
		vec.push_back(i);
		
		// Scope 를 벗어나면 LockGuard 의 소멸자가 자동으로 Lock 을 푼다
		// 이제 unlock 을 명시적으로 해 주지 않아도 된다.
	}
}

위의 코드에서, LockGuard 를 선언만 해 주면 따로 해제할 걱정을 하지 않아도 된다!

 

물론 언어 표준에서 LockGuard 를 지원해 주므로, 우리가 필요할 때마다 일일히 만들 필요는 없다! 우리가 만든 LockGuard 대신 아래와 같이 Lock Guard 를 잡아주면 된다 😊

// 기본적인 LockGuard
std::lock_guard<std::mutex> lockGuard(m);

// 커스터마이징 여지가 더 많은 녀석
std::unique_lock<std::mutex> uniqueGuard(m);

 

위 두개의 LockGuard 는 비슷하지만, unique_lock 의 경우 옵션을 다양하게 줄 수 있고(대신 좀 더 무겁다), Copy 나 Move 가 가능하다.

예를 들어, unique_lock 은 'defer_lock' 인자를 주어 LockGuard 가 생성되는 시점과 실제 Lock 을 거는 시점을 분리할 수 있다! 😮

void foo() {
  std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
  // perform some operations here
  
  lock.lock(); // acquire the lock
  // critical section
  
  lock.unlock(); // release the lock
}

 

unique_lock 에서 사용할 수 있는 tag 는 다음과 같다 :

tag 의미
defer_lock 뮤텍스를 잠그지 않음 (나중에 따로 잠궈야 함)
try_to_lock 뮤텍스를 잠그려고 시도 (이미 잠겨있으면 false 반환)
adopt_lock 뮤텍스가 현재 쓰레드에 의해 잠겨 있다고 가정 (잠그는 것이 아님)

 

Comments