본문 바로가기

Game Server

[C++/게임서버] Referencing Count

지금까지 열심히 멀티스레딩 관련 공부를 진행했다. 이젠 메모리에 대한 정리를 진행하겠다. C++의 경우 다른 언어와 달리 직접적으로 메모리를 관리를 해줘야 하는 언어이기 때문에, 메모리에 대한 정책 또한 제대로 알고 갈 필요가 있다.

 

예로 들어 스타크래프트와 같은 게임을 개발한다고 가정하면 다음과 같이 코드를 짤 것이다.

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

class Missile
{
public : 
    void SetTarget(Wraight* target) 
    {
    	_target = target;
    }
    
    void Update()
    {
    	int posX = target->_posX;
    	int posY = target->_posY;
        
        // TODO : 쫒아가기
    }
    
    Wraight * _target = nullptr;
};

int main()
{
    Wraight* wraight = new Wraight();
    Missile* missile = new Missile();
    missile->SetTarget(wraight);
    
    while(true)
    {
    	if(missile)
        {
        	missile->Update();
        }
    }    
}

이 때 보통 new를 했다면 delete를 할 것이다. 문제는 사용하기 전에 delete를 해버리게 되는 것이다. 이런 경우 크래시가 나면 오히려 문제가 나지 않는다. 진짜 문제는 delete를 했을 때 접근하면 안 될 메모리에 접근해버리는 경우다. (보통 0xcccccccc일 것이다.) 즉 메모리를 오염시킨다는 것이다. 

쓰레기값을 접근해서 건들이고 있는 굉장히 나쁜 장면이다.

 

문제는 MMORPG 개발 도중에 골드를 관리하는 메모리인데 이를 건들이게 되면 굉장히 큰일이 난다는 것이다. 이 말은 메모리를 관리하고 만약 허락된 메모리 외적으로 건들이기 않게 하는 것이 중요하다는 것이다.

 

먼저 메모리를 생성했을 경우 나를 참조하는 객체가 없어지는 경우 객체를 자동으로 delete할 수 있게 만들어보자. 이를 위해서 프로그래밍 언어론에서 제공되는 개념 중 하나인 참조 카운트(reference count)란 물건이 있다.

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

		int32 GetRefCount() { return _refCount; }
		int32 AddCount() { return ++_refCount; }

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

그리고 기존의 객체들은 다음과 같이 선언이 된다.

class Wraight : class RefCountable;
class Missile : class RefCountable;

그리고 만약 다음과 같은 객체를 사용하면 다음과 같이 사용을 해야 한다.

void SetTarget(Wraight* target) 
{
    _target = target;
    _target->AddRef();
}

그리고 만약 사용이 끝나면 사용을 안 하겠다는 뜻이니, 다음과 같이 레퍼런스 카운터를 1 줄여줘야 한다.

void Update()
{
    if(_target == nullptr) return;
    
    int posX = target->_posX;
    int posY = target->_posY;
        
    // TODO : 쫒아가기
    if(target->_hp == 0)
    {
        _target->ReleaseRef();
        _target = nullptr;
    }        
}

즉 이젠 우리가 직접 delete를 하는 것이 아니라 refCount가 0이 되면 자동으로 delete가 되게 만드는 식으로 만들어줘야 한다는 것이다. 즉 사용한 뒤 만약 refCount를 체크하고 0이 된다면 nullptr로 밀어주고 있기 때문에, delete 이후 쓰레기값을 수정할 일이 없다는 것이다. 

 

이 개념은 굉장히 중요한 개념이다. 하지만 지금까지 코드는 refCount를 수동으로 관리하고 있는데 만약 내가 Add나 Release를 하지 않았다면 사실상 쓰나마나 한 문제가 발생한다.

 

그리고 싱글 스레드에는 문제가 없지만, 멀티스레딩에서는 문제가 발생하며 만약 멀티스레딩에서 돌아간다면 중간에 다른 스레드의 개입으로 인해 refCount가 엉망진창이 될 수 있다는 점이다. 이는 컨텐츠 단에서도 마찬가지다. 만약 내가 Add를 하려는데 이미 그 전에 다른 스레드가 Release를 해서 delete가 된 상태라면 굉장히 골치아파진다. 즉 자동적으로 할 수 있게 만드는 장치가 필요하다. 즉 멀티스레딩이 보장된 스마트 포인터를 만드는 편이 좋아보인다.

 

먼저 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 IsNull() { return _ptr == nullptr; }

	// 다양한 상황에 대한 operator mapping
	bool operator==(const TSharedPtr& rhs) const
	{
		_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;
	}

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

즉 내가 직접 Add, Release를 하는 것이 아니라 포인터에서 대신 관리해준다. 그리고 실제 shared_ptr에 가깝게 디자인을 했기 때문에 실제 포인터처럼 사용할 수 있다.

 

using WraightRef = TSharedPtr<Wraight>;
using MissileRef = TSharedPtr<Missile>;

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

만약 내가 필요없다면 nullptr로 밀어주기만 하면 된다.

wraight = nullptr;

이 코드는 다음과 같은 효과를 가진다.

wraight = WraightRef(nullptr);

이유는 복사 생성자를 통해서 만약 nullptr로 받아준다면 이는 Release를 밀어준 뒤 Set(nullptr)이 되며 레퍼런스 카운터가 줄어드는 방식이다.이제 refCount를 확인해보자.

시작은 new Wraight를 통해서 생성되는 refCount 1 그리고 wraight 객체가 생성되고 획득되면서 늘어나는 refCount 1 총 2개를 가지고 시작한다. 하지만 new Wraight 객체는 우리가 직접 관리하는 것이 아닌 wraight 객체를 이용해서 관리를 해줄 것이기 때문에 ReleaseRef를 해준다. 

이후 missile이 wraight를 참조하면 _refCount가 1 증가한 것을 확인할 수 있다.이제 이런 경우 missile이 target을 관리해줄 것이기 때문에 wraight에 nullptr을 넣어 refCount를 줄여준다.

이후 열심히 미사일로 공격을 진행한다. 만약 _target이 되는 wraight가 체력이 0이 된다면 다음과 같이 _refCount를 줄여주게 될 것이다. 

이 때 _refCount가 0이 되면 nullptr로 밀어주게 된다.

 

지금까지 한 작업은 실제 C++에서 제공하는 스마트 포인터인 SharedPtr에서도 구현이 되어있는 부분이다. 

 

아쉬운 부분은 메서드를 타고 타다보면 결국 이 방식의 포인터는 어느 정도 자원을 계속 소모할 수 밖에 없으며(refCount를 고려해야 하다보니), 지금은 RefCountable을 만들어서 사용을 했지만 외부 라이브러리의 경우 이런 것을 붙여가며 사용할 수 없기 때문에 이런 클래스를 최상위 객체로 사용하는 경우는 거의 없다.