[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버 - Lock 기초

Jangmanbo·2023년 7월 9일
0

공유데이터가 컨테이너라면?

vector<int32> v;	// 공유 데이터가 컨테이너

void Push()
{
	for (int32 i = 0; i < 10000; i++)
	{
		v.push_back(1);
	}
}


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

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

	cout << v.size() << endl;
}

20000이 출력되어야 할 것 같지만, 실제로는 크래시가 발생한다.


크래시가 발생한 이유

vector의 경우 계속 push하면서 size가 늘다가 capacity를 넘어가면 capacity를 늘리기 위해, 늘어난 capacity만큼 동적할당을 하고 원래 있던 원소를 복사한 후 기존에 할당된 메모리를 해제한다.
참고: [C++] vector container 정리 및 사용법


step1
[1][2][3][4]

step2
[1][2][3][4]
[1][2][3][4][][][][]

step3
[1][2][3][4][][][][]

t1에서 capacity를 늘리기 위해 step3까지 가서 기존의 메모리가 해제되었을 때 t2도 동일하게 capacity를 늘리기 위해 step1에 진입하면 이미 해제된 메모리를 참조하면서 크래시가 발생한 것이다. (=double-free)

미리 capacity를 늘렸다면?

int main()
{
	v.reserve(20000);

	std::thread t1(Push);
	std::thread t2(Push);

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

	cout << v.size() << endl;
}


20000이 출력되어야 할 것 같지만, 그렇지 않다.


[1][2][3][4][][][][]
이는 한 쓰레드가 이미 4개의 원소가 있음을 알고 index=4에 5를 push할 때 다른 쓰레드도 동시에 index=4에 5를 push하는 경우가 있기 때문이다.

Lock

앞서 언급한 문제들을 해결하기 위해서는 Lock을 사용한다.
(지난 시간에 배웠던 Atomic은 vector에 한 쓰레드만 접근할 수 있도록 하는 기능을 제공하지 않는다.)

#include <mutex>

mutex m;

void Push()
{
	for (int32 i = 0; i < 10000; i++)
	{
		m.lock();	// 다른 쓰레드는 접근 불가
		v.push_back(1);
		m.unlock();	// 다른 쓰레드도 접근 가능
	}
}

mutex는 일종의 자물쇠라고 생각하면 된다.
lock을 하면 자물쇠를 걸어 다른 쓰레드는 접근이 불가능하다.
따라서 다른 쓰레드가 m.lock()에 도달하면 lock을 한 쓰레드가 unlock하기 전까지는 기다리게 된다.

따라서 실제로 for문의 내부 코드는 싱글쓰레드로 동작하게 되며 capacity를 미리 할당하지 않더라도, 정상적으로 20000이 출력되는 것을 볼 수 있다.

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

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

	cout << v.size() << endl;
}

Lock의 특징 : Mutex Exclusive(상호배타적)
쓰레드 A가 lock을 획득하면 다른 쓰레드는 접근할 수 없다.



RAII (Resouce Acquisition Is Initialization) 디자인 패턴

void Push()
{
	for (int32 i = 0; i < 10000; i++)
	{
		m.lock();
		v.push_back(1);

		if (i == 5000)
		{
        	// unlock하지 않고 for문을 빠져나옴
			break;
		}

		m.unlock();
	}
}

Lock을 수동으로 제어한다면 다음과 같이 unlock을 하지 않아 다른 쓰레드가 영원히 lock을 기다리는 상황이 발생할 수 있다.

이를 방지하기 위해 RAII패턴을 사용한다.

// RAII
template<typename T>
class LockGuard
{
public:
	LockGuard(T& m)
	{
		_mutex = &m;
		_mutex->lock();
	}
	~LockGuard()
	{
		_mutex->unlock();
	}
private:
	T* _mutex;
};

RAII는 생성자에서 자원을 할당하고 소멸자에서 자원을 해제한다.

void Push()
{
	for (int32 i = 0; i < 10000; i++)
	{
		LockGuard<std::mutex> lockGuard(m);
		v.push_back(1);

		if (i == 5000)
		{
			break;
		}
	}
}

이렇게 RAII 패턴을 사용한 LockGuard 객체를 사용한다면 수동으로 lock을 제어하지 않아도, for문이 한 번 실행될 때마다 unlock을 호출한다.


참고로 이번에 구현한 LockGuard는 이미 std에서 제공하고 있는 클래스이다.
std::lock_guard<std::mutex> lockGuard(m);

uniquc_lock

void Push()
{
	for (int32 i = 0; i < 10000; i++)
	{
		std::unique_lock<std::mutex> uniqueLock(m, std::defer_lock);

		uniqueLock.lock();

		v.push_back(1);

		if (i == 5000)
		{
			break;
		}
	}
}

객체를 생성할 때 lock을 걸지 않고 나중에 원하는 타이밍에 걸 수 있다. 물론 unlock은 uniqueLock 객체가 소멸될 때 호출된다.


코드

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>
#include <atomic>
#include <mutex>

vector<int32> v;
mutex m;

// RAII
template<typename T>
class LockGuard
{
public:
	LockGuard(T& m)
	{
		_mutex = &m;
		_mutex->lock();
	}
	~LockGuard()
	{
		_mutex->unlock();
	}
private:
	T* _mutex;
};

void Push()
{
	for (int32 i = 0; i < 10000; i++)
	{
		std::unique_lock<std::mutex> uniqueLock(m, std::defer_lock);

		uniqueLock.lock();

		v.push_back(1);

		if (i == 5000)
		{
			break;
		}
	}
}


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

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

	cout << v.size() << endl;
}

0개의 댓글