Coding Memo
Reference Counting 본문
본 포스팅은 인프런에 등록되어 있는 Rockiss 님의 강의를 보고 간단하게 작성한 글입니다.
아래 코드를 살펴보자
#pragma once
#include <iostream>
#include <thread>
using namespace std;
class A
{
public:
int v = 10;
};
class B
{
public:
B() {}
void SetRef(A* a)
{
_ref = a;
}
void DoSomethingWithRef()
{
_ref->v += 1;
}
private:
A* _ref = nullptr;
};
int main()
{
A* a = new A();
B* b = new B();
b->SetRef(a);
// 어떤 이유로 A가 소멸됨
delete a;
while (true)
{
if (b)
{
b->DoSomethingWithRef();
this_thread::sleep_for(100ms);
}
}
}
보통 위 코드를 실행시켜도 에러가 나지 않을 것이다. 하지만 확실히 문제가 보인다. 위 코드에서 문제점은 무엇일까?
delete로 a를 메모리에서 삭제(free) 했는데 b에서 '_ref->v += 1' 로 a의 v값에 계속 접근하고 있다는 것이다.
a가 삭제되었는데 v에 접근할 수 있는 것 부터가 이상해보이긴 한다.
breakpoint를 걸어 v의 값을 확인해본다면 원래는 10이 있어야 할 것 같지만,
-572662307 이라는 이상한 값이 들어가 있는 것을 확인 할 수 있다.
delete a 를 통해 더 할당된 메모리를 free 해 주었지만 v에 해당하는 값에 대한 메모리 주소는 여전히 존재하고 있기 때문에 그 메모리 값을 읽어 들인다.
이는 치명적인 문제를 가져올 수 있다. 삭제된 객체 내부의 참조값을 읽을 수 있는 것부터가 문제이고 이 값을 통해 다른 작업을 실행시킨다면 위와 같은 쓰레기 값에 대해 작업하는 것이기 때문에 큰 문제가 발생된다.
그렇다면 다른 클래스에서도 참조를 하고 있다면 delete를 하지 않고 참조하는 클래스가 아무도 없을 때 delete를 해주는 방식을 이용하면 어떨까?
참고로, 위 문제를 해결하기 위해서는
1. 참조하고 있는 메모리를 다른 메모리에서 더 이상 참조하지 않을 때 그제서야 delete를 해준다.
2. 한번 delete 된 객체에는 접근조차 못하게 막는다.
이런 방법이 있다. (미리 말하지만 '스마트 포인트'를 사용하면 전부 해결된다.)
1번에 대한 해결책으로... 스마트 포인터의 useCount(=refCount)만을 가지고 있는 클래스를 직접 구현한다.
위와 같은 문제가 발생할 가능성이 있는 클래스에게 RefCountable 이란 클래스를 만들고 이를 부모 클래스로 만든다.
구현 요점
참조하고 있는 메모리의 개수를 변수로 둔다. (refCount)
객체가 생성될 때 +1을 해주고 다른 객체에서 참조할 때마다 +1을 해준다.
마찬가지로 참조가 끝났으면 -1을 해주는데, _refCount 값이 0이 된다면 그제서야 delete를 해준다.
#pragma once
class RefCountable
{
public:
RefCountable() : _refCount(1) {}
virtual ~RefCountable() {}
int32 GetRefCount() { return _refCount; }
int32 AddRef() { return ++_refCount; }
int32 ReleaseRef()
{
int32 refCount = --_refCount;
if (refCount == 0) // 참조가 0이 되면 실질적으로 delete 실행
{
delete this;
}
return refCount;
}
protected:
atomic<int32> _refCount;
};
SharedPointer 구현 (객체를 SharedPointer로 감싼다.)
참조될 가능성이 있는 부분을 오버라이딩 하여 _refCount 값을 증가시키거나 감소시키도록 한다.
#pragma once
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;
};
다시 원래 코드로 되돌아가서 문제를 해결해보자
#pragma once
#include <iostream>
#include <thread>
#include "RefCounting.h"
using namespace std;
class A : public RefCountable
{
~A()
{
cout << "~A()" << endl;
}
public:
int v = 10;
};
using ARef = TSharedPtr<A>;
class B : public RefCountable
{
public:
~B()
{
cout << "~B()" << endl;
}
void SetRef(ARef a)
{
_ref = a;
}
void DoSomethingWithRef()
{
_ref->v += 1;
}
bool CheckA_V()
{
return _ref->v == 100;
}
private:
ARef _ref;
};
using BRef = TSharedPtr<B>;
int main()
{
ARef a(new A);
a->ReleaseRef();
BRef b(new B);
b->ReleaseRef();
b->SetRef(a); // ref 지만 실제 b 처럼 사용 가능
// a를 없앴다고 가정
a = nullptr;
while (true)
{
if (b)
{
b->DoSomethingWithRef();
this_thread::sleep_for(50ms);
if (b->CheckA_V())
{
break;
}
}
}
b = nullptr;
}
a의 v 값이 100이 되면 break 문이 되면서 (대략 90*50=4500ms가 걸린다) 소멸자가 언제 실행되는지도 알아보는 코드를 추가 했다.
먼저 아까와 같이 DoSomethingWithRef에 breakpoint를 잡고 실행시키면
a가 아직 소멸되지 않고 v=10인것을 확인 할 수 있다.
다음으로, main 함수의 a=nullptr과 b=nullptr 구문에 breakpoint를 잡고 F11을 통해 하나하나 실행 과정을 풀어 나가면 원리를 이해하기 쉬울 것이다.
a 생성 시에 refcount가 2가 된다. (A 생성으로 1로 초기값이 정해지고 ARef로 1이 추가로 더해진다.)
실질적으로 refCount는 1이 되어야 하므로 a->Release()를 해준다. (a._refCount = 1)
b도 마찬가지이다. (b._refCount = 1)
SetRef에서 a에 대한 복사 생성자가 호출된다. 따라서 a의 refCount가 1 증가한다. (a._refCount = 2)
a=nullptr에서 a의 _refCount가 1줄어들어 1이된다. 즉, ARef 메모리는 delete 되지만 참조하고 있는 a 자체는 아직 살아있다. (a._refCount = 1)
a를 가리키는 포인터가 살아있으므로 v에도 당연히 정상적으로 접근하고 읽고 쓸 수 있다.
b=nullptr에서 b의 _refCount가 0이되어 소멸자가 호출된다. 이후 참조하고 있던 b의 ref(ARef)도 nullptr가 되어 Release 된다. (b._refCount = 0 -> delete, a._refCount = 1 - 1 = 0 -> delete)
결과도 아래와 같다.
클래스를 위 처럼 직접 구현해도 되지만 아래와 같은 문제점이 있다.
1. 기존에 이미 있는 클래스에 RefCountable을 상속할 수 없다.
2. delete 되었지만 여전히 접근하여 사용할 수 있다. (Use-After-Free 문제)
사실 위 내용 + alpha로 '스마트포인터'를 사용하는 좋은 방법이 있다.
'Game Server (C++)' 카테고리의 다른 글
Memory Pool (0) | 2022.10.09 |
---|---|
메모리 할당 - STL Allocator (0) | 2022.10.09 |
메모리 할당 - Stomp Allocator (0) | 2022.10.06 |
메모리 할당 - Allocator (0) | 2022.10.06 |
Lock 구현 (Read-Write Lock) (1) | 2022.09.26 |