dh_0e

[C++/Game Server] Reference Counting (in Shared Pointer) 본문

C++/Game Server

[C++/Game Server] Reference Counting (in Shared Pointer)

dh_0e 2026. 3. 7. 16:34

GameServer.cpp

class Wraith
{
public:
	int _hp = 150;
	int _posX = 0;
	int _posY = 0;

private:
};

class Missile
{

public:
	void SetTarget(Wraith* target)
	{
		_target = target;
	}

	void Update()
	{
		int posX = _target->_posX;
		int posY = _target->_posY;

		// TODO : 타겟을 쫓아감
	}

private:
	Wraith* _target = nullptr;
};

int main()
{
	Wraith* wraith = new Wraith();
	Missile* missile = new Missile();
	missile->SetTarget(wraith);

	// 레이스 피격시
	wraith->_hp = 0;
	delete wraith;

	while (true)
	{
		if (missile)
		{
			missile->Update();
		}
	}

	return 0;
}
  • 다음과 같은 상황에서 missile 클래스이미 delete 된 메모리(Dangling Pointer)를 참조하고 있어서 CRASH는 나지 않지만, 엉뚱한 코드를 계속 추출하며 오염된 메모리에 접근 중
  • 참조 카운트(Reference Counting)를 만들어 해당 메모리를 참조하는 객체가 있는지 확인한 뒤, delete를 해줘야 함

 

RefCounting.h

#pragma once
#include "pch.h"
/*----------------
	RefCountable
-----------------*/

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

	virtual ~RefCountable() {}
	// memory leak(메모리 누수)를 예방하기 위해 항상 최상위 클래스에 virtual을 붙임

	int32 GetRefCount() { return _refCount; }

	int32 AddRef() { return ++_refCount; }

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

protected:
	atomic<int32> _refCount;
};

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

template <typename T>
// 포인터의 ref count를 관리하는 스마트 포인터
class TSharedPtr {
public:
	TSharedPtr() {}
	TSharedPtr(T* ptr) { Set(ptr); }

	// 복사
	TSharedPtr(const TSharedPtr& other) { Set(other._ptr); }
	// 이동
	TSharedPtr(TSharedPtr&& other) {
		_ptr = other._ptr;
		other._ptr = nullptr;
	}
	// 상속 관계 복사자
	template <typename U>
	TSharedPtr(const TSharedPtr<U>& other) {
		Set(static_cast<T*>(other._ptr));
	}
	// 만약 Knight 클래스가 Player 클래스를 상속받았다면,
	// TSharedPtr<Knight>를 TSharedPtr<Player>에 넣을 수 있어야함
	// 이 템플릿 생성자가 그 형변환을 가능하게 해줌

	// 소멸
	~TSharedPtr() { Release(); }

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

	bool operator==(const TSharedPtr& other) const { return _ptr == other._ptr; }
	bool operator==(T* ptr) { return _ptr == ptr; }
	bool operator!=(const TSharedPtr& other) const { return _ptr != other._ptr; }
	bool operator!=(T* ptr) const { return _ptr != ptr; }
	bool operator<(const TSharedPtr& other) const { return _ptr < other._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; }
	// get 대신 연산자 오버로딩을 통해 TSharedPtr 클래스를 마치 포인터처럼 사용할 수 있게 함
	// 오버로딩 전: ptr.GetPtr()->hp = 100;
	// 오버로딩 후: ptr->hp = 100;

	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;
};
  • 그냥 Reference Count(RefCountable)만 구현하여 사용해도 되지만 멀티 스레드 환경에선 여러 변수가 발생
    • ex) 아래 GameServer.cpp의 A 부분
  • Shared Pointer(TSharedPtr)를 구현하여 Reference Count를 기반으로 자동으로 메모리를 정리하는 스마트 포인터를 구현

 

GameServer.cpp

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <atomic>
#include <mutex>
#include <Windows.h>
#include <future>
#include "ThreadManager.h"

#include "RefCounting.h"

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

using WraithRef = TSharedPtr<Wraith>;

class Missile : public RefCountable {
public:
	void SetTarget(WraithRef target) {
		_target = target;
		// A) 멀티 스레드 환경에서 이 부분에 다른 스레드의 개입이 가능함
        	// 아래의 AddRef()를 하기 전에 다른 스레드에서 Release를 통해 target을 nullptr로 만들 수 있음
        	// Shared Pointer를 구현한 이유
		// _target->AddRef();
		Test(target);
	}

	void Test(WraithRef& target) {
		// target을 다른 함수에서 잠깐 사용한다고 가정
		// 이 경우엔 참조로 받아와야 쓸데없는 시간 낭비가 발생하지 않음
		// WraithRef를 복사하는 식으로 받아오면 atomic연산을 쓸데없이 2번 해야하기 때문
	}

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

private:
	WraithRef _target = nullptr;
};

using MissileRef = TSharedPtr<Missile>;

int main() {
	WraithRef wraith(new Wraith());
	wraith->ReleaseRef();
	// Wraith를 만들 때 ref count를 1로 초기화, TSharedPtr에서 Set()을 호출하기 때문에 ref count가 2가 되기 때문에,
	// ReleaseRef()를 호출하여 ref count를 1로 초기화

	MissileRef missile(new Missile());
	missile->ReleaseRef();

	missile->SetTarget(wraith);

	// 레이스 피격시
	wraith->_hp = 0;
	// delete wraith;
	// wraith->ReleaseRef();
	wraith = nullptr;

	while (true) {
		if (missile) {
			if (missile->Update()) {
				missile->ReleaseRef();
				missile = nullptr;
			}
		}
	}

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

	return 0;
}
  • Wraith, Missile 클래스를 모두 Shared Pointer로 사용하여 참조 포인터 생성 시 자동으로 Ref count 증가, nullptr 복사 시 Set에서 자연스럽게 Ref count가 감소 nullptr로 초기화
  • Ref count가 0이 되면 자동으로 메모리 삭제

 

Shared Pointer의 한계

  1. RefCountable을 상속 받아야만 사용이 가능하기 때문에, 이미 만들어진 클래스를 대상으로 사용이 불가능
  2. 순환(Cycle) 문제
    • 아래와 같이 서로 참조를 하게 되는 상황들에서 순환 문제가 발생
    • 데드락 문제처럼 서로를 참조하며 안 놔줌
    • Weak Pointer를 사용하여 해결할 수 있음
using KnightRef = TSharedPtr<class Knight>;

class Knight : public RefCountable {
public:
	Knight() { cout << "Knight()" << endl; }
	~Knight() { cout << "~Knight()" << endl; }
	void SetTarget(KnightRef target) { _target = target; }

private:
	KnightRef _target = nullptr;
};

int main() {
	KnightRef k1(new Knight());
	k1->ReleaseRef();

	KnightRef k2(new Knight());
	k2->ReleaseRef();

	k1->SetTarget(k2);
	k2->SetTarget(k1);

	k1 = nullptr;
	k2 = nullptr;

	return 0;
}
using KnightRef = TSharedPtr<class Knight>;
using InventoryRef = TSharedPtr<class Inventory>;

class Knight : public RefCountable {
public:
	Knight() { cout << "Knight()" << endl; }
	~Knight() { cout << "~Knight()" << endl; }
	void SetTarget(KnightRef target) { _target = target; }

	KnightRef _target = nullptr;
	InventoryRef _inventory = nullptr;
};

class Inventory : public RefCountable {
public:
	Inventory(KnightRef knight) : _knight(knight) {}

	KnightRef _knight = nullptr;
	// KnightRef를 참조하면 Ref Count를 증가시켜 순환 참조 발생
    	// weak_ptr<Knight> _knight;
    	// weak_ptr로 Knight 정보를 받아오면 Ref Count를 증가시키지 않아 순환 참조가 발생하지 않음
    
};

int main() {
	KnightRef k1(new Knight());
	k1->ReleaseRef();

	k1->_inventory = new Inventory(k1);
	return 0;
}

 

참고) std::move의 정체: "강제 형변환"

  • std::move(ptr1)를 실행하면 내부적으로는 다음과 같은 일이 일어남
    1. ptr1은 원래 이름이 있는 멀쩡한 변수(L-value)임
    2. std::move가 얘를 TSharedPtr&& 타입으로 강제로 바꿔버림
    3. 대입 연산자(=)를 만났을 때, 컴파일러는 두 가지 선택지 중 고민
      • operator=(const TSharedPtr&) (복사)
      • operator=(TSharedPtr&&) (이동)
    4. 방금 && 타입으로 변신했으니, 당연히 이동 연산자(&&) 쪽 연산자 오퍼레이터 함수를 사용