KoreanFoodie's Study

[C++ 게임 서버] 2-1. Reference Counting 본문

Game Dev/Game Server

[C++ 게임 서버] 2-1. Reference Counting

GoldGiver 2023. 8. 21. 19:07

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

[C++ 게임 서버] 1-25. Reference Counting

핵심 :

1. 멀티 쓰레드 환경에서, 생 포인터를 사용하는 것은 언제나 위험하다. 의도치 않게 delete 된 포인터를 사용하려고 할 수도 있기 때문이다.

2. 일반적으로는 Smart Pointer 를 통해 이런 문제를 해결하는데, 핵심적인 부분은 결국 Reference Count 를 체크하여 nullptr 에 접근하는 것을 막는 것이다! 물론 SharedPtr 까지 제대로 써 주어야 문제를 막을 수 있다.

멀티쓰레드 프로그래밍을 할 때, 생 포인터를 사용하게 되면 의도치 않게 nullptr 에 접근하는 경우를 맞딱뜨리게 된다.

아래의 예시를 보자. 레이스와 터렛이 있다고 하고, 터렛이 레이스를 타겟팅하여 격추 시키는 것을 상상해 보자! 😆

미사일과 터렛의 클래스는 다음과 같다 :

class Wraight : public RefCountable
{
public:
	int _hp = 150;
	int _posX = 0;
	int _posY = 0;
};

using WraightRef = TSharedPtr<Wraight>;

class Missile : public RefCountable
{
public:
	void SetTarget(WraightRef target)
	{
		_target = target;
		// 중간에 개입 가능
		//target->AddRef();
	}

	bool Update()
	{
		if (_target == nullptr)
			return true;

		int posX = _target->_posX;
		int posY = _target->_posY;

		// TODO : 쫓아간다

		if (_target->_hp == 0)
		{
			//_target->ReleaseRef();
			_target = nullptr;
			return true;
		}

		return false;
	}

	WraightRef _target = nullptr;
};

using MissileRef = TSharedPtr<Missile>;

위에서 레이스와 미사일은 RefCount 를 상속받는데.. 이 녀석은 다음과 같다 :

 

/*---------------
   RefCountable
----------------*/

class RefCountable
{
public:
	RefCountable() : _refCount(1) { }
	virtual ~RefCountable() { }

	int32 GetRefCount() { return _refCount; }

	int32 AddRef() { return ++_refCount; }
	int32 ReleaseRef()
	{
		int32 refCount = --_refCount;
		if (refCount == 0)
		{
			delete this;
		}
		return refCount;
	}

protected:
	atomic<int32> _refCount;
};

 간단히 말해서, RefCountable 이라는 클래스를 상속받은 녀석은, _refCount 를 통해 객체의 수명이 관리되게 된다.

그리고 _refCount 는 atomic 하게 관리되는데...

 

이제 실제로 미사일 터렛이 레이스를 격추하는 main 함수를 보자.

int main()
{	
	WraightRef wraight(new Wraight());
	wraight->ReleaseRef();
	MissileRef missile(new Missile());
	missile->ReleaseRef();

	missile->SetTarget(wraight);

	// 레이스가 피격 당함
	wraight->_hp = 0;
	//delete wraight;
	//wraight->ReleaseRef();
	wraight = nullptr;
	
	while (true)
	{
		if (missile)
		{
			if (missile->Update())
			{
				//missile->ReleaseRef();
				missile = nullptr;
			}
		}
	}

	//missile->ReleaseRef();
	missile = nullptr;
	//delete missile;
}

여기서, 미사일이 레이스를 SetTarget 하는 부분을 보면...

 

만약 기존 코드에서, WraightRef 가 TSharedPtr<Wraight> 가 아니어서, 아래와 같았다면 어떤 일이 발생할까?

void SetTarget(Wraight target)
{
    _target = target;
    // 중간에 개입 가능
    target->AddRef();
}

미사일은 레이스 target 을 참조하니, 레이스의 포인터인 target 의 포인터가 하나 더 늘어나야할 것이다.

그런데, _target = target 이후에, 다른 쓰레드가 target 을 delete 해 버린다면, target->AddRef() 에서는 크래시가 발생할 것이다!

 

이런 문제를 막기 위해, TSharedPtr 를 사용하면 문제를 해결할 수 있다. 일단 자체적으로 만든 TSharedPtr 의 정의를 보자 😄

/*---------------
   SharedPtr
----------------*/

template<typename T>
class TSharedPtr
{
public:
	TSharedPtr() { }
	TSharedPtr(T* ptr) { Set(ptr); }

	// 복사
	TSharedPtr(const TSharedPtr& rhs) { Set(rhs._ptr); }
	// 이동
	TSharedPtr(TSharedPtr&& rhs) { _ptr = rhs._ptr; rhs._ptr = nullptr; }
	// 상속 관계 복사
	template<typename U>
	TSharedPtr(const TSharedPtr<U>& rhs) { Set(static_cast<T*>(rhs._ptr)); }

	~TSharedPtr() { Release(); }

public:
	// 복사 연산자
	TSharedPtr& operator=(const TSharedPtr& rhs)
	{
		if (_ptr != rhs._ptr)
		{
			Release();
			Set(rhs._ptr);
		}
		return *this;
	}

	// 이동 연산자
	TSharedPtr& operator=(TSharedPtr&& rhs)
	{
		Release();
		_ptr = rhs._ptr;
		rhs._ptr = nullptr;
		return *this;
	}

	bool		operator==(const TSharedPtr& rhs) const { return _ptr == rhs._ptr; }
	bool		operator==(T* ptr) const { return _ptr == ptr; }
	bool		operator!=(const TSharedPtr& rhs) const { return _ptr != rhs._ptr; }
	bool		operator!=(T* ptr) const { return _ptr != ptr; }
	bool		operator<(const TSharedPtr& rhs) const { return _ptr < rhs._ptr; }
	T*			operator*() { return _ptr; }
	const T*	operator*() const { return _ptr; }
				operator T* () const { return _ptr; }
	T*			operator->() { return _ptr; }
	const T*	operator->() const { return _ptr; }

	bool IsNull() { return _ptr == nullptr; }

private:
	inline void Set(T* ptr)
	{
		_ptr = ptr;
		if (ptr)
			ptr->AddRef();
	}

	inline void Release()
	{
		if (_ptr != nullptr)
		{
			_ptr->ReleaseRef();
			_ptr = nullptr;
		}
	}

private:
	T* _ptr = nullptr;
};

위에서 처럼, TSharedPtr<Wraight> 를 사용하게 되면, 아까 SetTarget 에서 있던 문제가 사라진다.

void SetTarget(WraightRef target)
{
    _target = target;
    // 중간에 개입 가능
    //target->AddRef();
}

 

왜일까..? 복사 생성자를 잘 보면 답을 알 수 있다.

missile->SetTarget(wraight);

위 함수가 호출되면, SetTarget 이 호출되기 위해 wraight 의 임시 객체가 생성된다. 즉, 아래 함수가 불린다!

// 복사
TSharedPtr(const TSharedPtr& rhs) { Set(rhs._ptr); }

rhs 가 만들어지고 나면, _ptr 은 자연스럽게 nullptr 가 되는데...

private:
	T* _ptr = nullptr;

그 다음에, 바로 Set 함수가 불리게 된다.

private:
	inline void Set(T* ptr)
	{
		_ptr = ptr;
		if (ptr)
			ptr->AddRef();
	}

 

위의 Set 함수를 통해, rhs 로 맨 처음 들어왔던 ptr (즉, SetTarget 에서 타겟이었던 레이스) 은 Reference Count 가 하나 더 올라가게 된다.

void SetTarget(WraightRef target)
{
    // target 의 _refCount 는 2 인 상태
    _target = target;
    // 중간에 개입하여 target 의 _refCount 를 1 빼더라도, 현재 함수에서 1을 더했기 때문에...
    // target에 접근해도 crash 는 발생하지 않는다!
    //target->AddRef();
}

따라서 refcount 와 관련된 크래시는 예방할 수 있다! 😉

아 물론, 이 경우에는 SetTarget 을 통해 RefCount 가 1 올라간 상태로 종료해 버리는 문제는 있지만...

지금은 SharedPtr 의 구현이 완벽한 것은 아니니, 나머지는 차후에 보충하도록 하겠다 😅

Comments