본문 바로가기

Game Server

[C++/서버] Lock

결론부터 말하면, interlock이나 atomic은 기본 자료형에 대해서 효과적이고 적절한 기능이기 때문에, 컨테이너나 함수 등에서는 우리는 Lock이라는 것을 사용하여 멀티스레드 상에서 상호 배제를 구현해낼 수 있으며, 대표적인 것으로는 mutex가 있다.

 

하나의 벡터가 있다고 가정해보자. 즉 하나의 컨테이너가 공유자원이 되는 것이다.

// FrokEngine::int32 -> __int32;
std::vector<FrokEngine::int32> v;

void push()
{
	for (size_t i = 0; i < 100000; i++)
	{
		v.push_back(i);
	}
}


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

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

	return 0;
}

이 결과값으로 vector의 사이즈가 당연히 100000개가 될 것이라고 생각한다.

문제는 바로 이런 식으로 크래시가 나버린다는 점이다. 즉 이런 컨테이너들은 멀티스레드 환경에서 사용한다고 가정하고 만들어진 물건이 아니라는 것이다. capacity를 생각해봐야 한다. 예로 들어 4개의 capacity 공간을 가지고 있고 다 찼다고 가정하자.

이 때 두 스레드가 push를 하면 기존의 내용물을 더 커진 배열 공간에 복사를 하고 기존 배열 공간을 날릴 것이다. 이 때 다른 스레드가 이 삭제된 공간에 접근을 하고 복사 한 뒤 지우려고 하면 결국 지워진 공간을 다시 지우려고 하는 것이다. 즉double free 문제가 발생하는 것이다.

 

그렇다면 처음부터 capacity를 reserve 메서드를 통해서 20만 정도로 잡고 실행한다면 안 나지 않을까?

물론 크래시는 나지 않는다. 하지만 실질적인 배열 공간 내 원소 갯수를 보면 전부 실행이 되지 않았다는 점을 알 수 있게 된다. 즉 멀티스레드 환경은 이런 연산에 대해 친절환 환경이 아닌 것이다. 그리고 이런 프로그래밍은 오히려 에러를 더 만들어내기 때문에 절대적으로 지양해야 한다.

 

그럼 우리가 저번에 배운 interlock이나 atomic을 이용하면 되지 않을까라는 생각이 든다. 하지만 그 두 기능은 하나의 기본 자료형 변수에 사용할만한 물건이지 이런 자료구조에 쓸만한 물건은 아니다. 즉 이런 것을 위해서 lock이란 것을 사용한다.

 

#include <mutex>

std::mutex m;

mutex의 동작 방식은 화장실에 비유할 수 있다. 만약 내가 화장실에 들어가서 변기를 사용하게 된다면(좀 지저분한 이야기지만 이 이야기가 효과적이다. ㅠㅠ), 바깥에서는 빨간불로 뜨면서 사용중이 뜰 것이다. 그리고 이제 내가 나온다면 다른 사람도 이 화장실 변기를 쓸 수 있는 것이다.

 

마찬가지로 mutex 존에 어떤 스레드가 들어가게 된다면, 다른 스레드는 그 동안 기다렸다가 그 스레드가 나온다면 그 존에 들어갈 수 있는 방식인 것이다. 이 방식은 마치 싱글 스레드가 도는 것처럼 느려질 수 있지만 확실히 변수나 컨테이너의 상호 배제를 통한 원자성이 지켜질 수 있다.

 

이제 mutex를 이용해서 함수를 수정하고, 결과를 보자.

void push()
{
	for (size_t i = 0; i < 100000; i++)
	{
		m.lock();
		v.push_back(i);
		m.unlock();
	}
}

하지만 mutex를 개발할 땐 유의해야 될 점은 꽤 있다.

lock은 재귀적으로 걸 수 있는가(즉 다른 함수로 넘어가도 lock이 유지가 되는가) 굉장히 중요한 부분이다. 결론부터 말하면, 안 된다. 그래서 recursive_lock을 하는 것도 따로 존재한다. 그리고 MMO 개발을 한다고 가정할 때 굉장히 이 메서드가 다른 메서드를, 그리고 그 메서드가 또 다른 메서드를 호출하는 경우가 많기 때문에, 보통은 이런 함수를 넘어가다보면 lock을 굉장히 많이 걸 수 있고, 이런 것을 허용해주는 방향으로 개발해주는 편이 좋다.  

문제는 이런 상황에서 unlock이 되지 않는 경우다. 다시 화장실 예시로 돌아가면, 마치 화장실의 문을 사용하지 않고 창문으로 도망가는(즉 화장실 문은 계속 잠겨있는) 상황이다. 그럼 다른 스레드는 영영 접근할 수 없게 된다. 예로 들어보자.

void push()
{
	for (size_t i = 0; i < 100000; i++)
	{
		m.lock();
		v.push_back(i);

		if (v.size() == 50000) break;

		m.unlock();
	}
}

이 경우는 다른 함수로 넘어가진 않지만 lock을 건 상황에서 break가 되는 경우이다. 즉 unlock이 되지 않았다. 이런 경우 프로그램이 영영 끝나지 않고 영원히 지속될 것이다. 그럼 저 break를 하기 전에 unlock을 하는 식으로 개발해도 되긴 하다. 문제는 이런 경우 코드가 굉장히 복잡해지고 피곤해진다. 그리고 MMO같은 경우 복잡한 함수는 몇 천줄까지 되는데, 수동으로 lock을 걸고 풀고 하는 경우 꽤나 나쁜 습관이다. 

그럼 이런 경우 어떻게 하는 것이 좋을까?라고 한다면 C++의 패턴 중 하나인 RAII(Resource Acquisition Is Initialization)이란 것을 사용하는 것도 좋다. (실제로 shared_ptr 등등이 제일 유명한 예시이다.)

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

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

private : 
	T* _mutex;
};

그리고 이 코드를 적용하면 다음과 같이 변화한다.

void push()
{
	for (size_t i = 0; i < 100000; i++)
	{
		LockGuard<std::mutex> lockGuard(m);
		v.push_back(i);
		if (i == 50000) break;
	}
}

LockGuard 객체는 for 루프 안이 존재하는 code scope기 때문에, 나가게 되더라도 알아서 unlock을 해준다. 물론 이 클래스는 std::lock_guard라고 해서 제공을 해준다.

void push()
{
	for (size_t i = 0; i < 100000; i++)
	{
		std::lock_guard<std::mutex> lockGuard(m);
		v.push_back(i);
		if (i == 50000) break;
	}
}

이런 식으로 RAII 패턴을 이용해서 lock을 걸고 풀면 unlock에 대한 걱정을 덜 수 있다. 

이 외에도 unique_lock이란 것도 있다. 이 클래스는 옵션을 줄 수 있다. 예로 들어 std::defer_lock을 주면 당장 락을 잠구지 않고 인터페이스만 준비해준 뒤, 명시적으로 lock을 하면 그 때부터 lock을 하는 방식이다. 즉 lock 시점을 뒤로 미룰 수 있게 되는 것이다. 

void push()
{
	for (size_t i = 0; i < 100000; i++)
	{
		std::unique_lock<std::mutex> lockGuard(m, std::defer_lock);
        lockGuard.lock();
		v.push_back(i);
		if (i == 50000) break;
	}
}

 

'Game Server' 카테고리의 다른 글

[C++/서버] Lock-2  (0) 2022.07.07
[C++/서버] DeadLock  (0) 2022.07.07
[C++/서버] Atomic  (0) 2022.07.04
[서버] 스레드 생성  (0) 2022.07.04
[서버] 멀티스레드  (0) 2022.07.04