1. 스마트 포인터
기존의 포인터 연산자(*)를 사용한 포인터는 delete를 하지 않으면 메모리를 점유하여 메모리 누수를 일으키는 원인이 되었었다.
스마트 포인터를 사용하면 참조 횟수를 사용하여 객체를 사용하는(가리키는) 포인터가 없을 때 해당 객체를 delete하여 메모리를 자동으로 관리할 수 있게 해준다.
2. 참조 횟수(Reference Count)
스마트 포인터에서 어떤 객체를 가리키는 포인터의 갯수를 가리킨다.
참조 횟수가 0이 되면 메모리를 해제한다.
3. 수동으로 참조 횟수 구현하기
(1) 객체 내에 포함시키기
참조 횟수를 객체 내에 포함시키는 방법이다.
class RefCountable
{
public:
RefCountable();
virtual ~RefCountable();
int GetRefCount() { return _refCount; }
int AddRef() { return ++_refCount; }
int ReleaseRef()
{
int refCount = --_refCount;
if (refCount == 0)
delete this;
return refCount;
}
protected:
int _refCount = 1;
};
class Knight : public RefCountable
{
public:
};
map<int, Knight*> _knights;
int main()
{
Knight* knight = new Knight();
_knights[100] = knight;
knight->AddRef(); // 참조 횟수 2 (자기 자신 포함)
knight->ReleaseRef();
knight->ReleaseRef();
// 메모리 delete
}
map의 100번은 knight 객체의 포인터를 저장하고 있다.
즉, map[100]이 knight 객체를 가리키고 있으므로, 참조 횟수가 1 증가하여 2가 된다.
이후에 어떤 작업을 수행한 후 더이상 객체를 가리키지 않으면 참조 횟수를 하나씩 감소시키고 이내 참조 횟수가 0이 되면 knight 객체의 메모리를 해제한다.
(2) 멀티 스레드 환경에서 바라보기
_refCount가 int로 선언되어 있기 때문에, 멀티 스레드 환경에서 경쟁 조건이 발생할 수 있다.
따라서 _refCount를 atomic으로 선언해야 한다.
protected:
atomic<int> _refCount = 1;
이제 스레드를 활용하기 위해 knight 객체의 포인터를 받는 함수를 사용하여 객체를 할당하도록 해보자.
void Test(Knight* knight)
{
_knights[100] = knight;
knight->AddRef();
}
int main()
{
Knight* knight = new Knight();
thread t(Test, knight);
knight->ReleaseRef();
// 메모리 delete
t.join();
}
만약 위의 상황에서 경쟁 조건이 발생한다면, 즉 AddRef가 작동되기 전에 공유 자원이 수정되는 경우, 참조 횟수가 증가하기 이전에 객체가 먼저 소멸될 수 있다.
따라서 수동으로 참조 횟수를 관리하는 방법은 위험할 수 있다.
4. 스마트 포인터 구현
참조 횟수를 객체 내에 포함시키는 방법이다.
(1) 코드
template<typename T>
class TSharedPtr
{
public:
TSharedPtr() {}
TSharedPtr(T* ptr) { Set(ptr); }
private:
void Set(T* ptr)
{
_ptr = ptr;
if (ptr)
ptr->AddRef();
}
void Release()
{
if (_ptr != nullptr)
{
_ptr->ReleaseRef();
_ptr = nullptr;
}
}
private:
T* _ptr = nullptr;
};
Set 함수로 _ptr 멤버 변수에 ptr의 포인터를 대입한다.
Release 함수로 _ptr에 포인터가 할당되어 있으면 참조 횟수를 1 감소시키고 _ptr을 null로 초기화한다.
포인터를 받는 버전의 생성자는 ptr을 즉시 Set 함으로써 참조 횟수를 자동으로 1 증가시키기 때문에 수동으로 관리하는 방법에 비해 안전하게 작동하게 된다.
(2) 복사와 이동 구현
public:
TSharedPtr() {}
TSharedPtr(T* ptr) { Set(ptr); }
// 복사
TSharedPtr(const TSharedPtr& other) { Set(other._ptr); }
// 이동
TSharedPtr(TSharedPtr&& other) { _ptr = other._ptr; other._ptr = nullptr; }
스마트 포인터를 복사하여 사용하더라도(다른 객체가 생성되어도) 원본의 참조 횟수가 1 증가한다.
(3) 상속 관계 복사
// 상속 관계 복사
TSharedPtr(const TSharedPtr<U>& rhs) { Set(static_cast<T*>(rhs._ptr)); }
(4) 소멸자
~TSharedPtr() { Release(); }
(5) 연산자 오버로딩
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 == rhs._ptr; }
bool operator!=(const TSharedPtr& rhs) const { return _ptr != rhs._ptr; }
bool operator!=(T* ptr) const { return _ptr != rhs._ptr; }
bool operator<(const TSharedPtr& rhs) const { return _ptr <= = > rhs._ptr; }
T* operator*() { return _ptr; }
const T* operator*() { return _ptr; }
operator T* () const { return_ptr; }
T* operator->() { return _ptr; }
const T* operator->() const { return _ptr; }
bool IsNull() { return _ptr == nullptr; }
복사, 이동, 비교 연산자, 포인터 연산자, 멤버 접근 연산자(->)를 활용하기 위한 연산자 오버로딩이다.
(6) 활용
이제부터 스마트 포인터를 사용할 것이므로, 포인터 연산자(*)를 활용한 포인터는 사용하면 안된다.
아래 코드를 스마트 포인터 클래스 하단에 위치시키고 시작한다.
using KnightRef = TSharedPtr<Knight>;
이제 포인터 연산자가 아니라 KnightRef를 활용한다.
void Test(KnightRef knight)
{
// 사용
}
int main()
{
KnightRef knight(new Knight());
// 복사가 발생
Test(knight);
}
어떤 Test 함수를 사용해 knight를 받아와 사용하는 식의 동작을 해보자.
Test 함수에서 knight를 복사하는 순간에 복사 생성자가 실행되며 참조 횟수가 1 증가하게 된다.
즉, 경쟁 조건이 발생해도 그 이전에 참조 횟수가 증가하기 때문에 객체의 생명 주기가 확보된다.
5. std::shared_ptr
실제 객체와 참조 카운터 블록을 포함시켜(한 메모리상에) 관리하지 않고 따로(다른 메모리 영역에) 관리한다.
(1) 표준 함수
위와 같이 커스텀된 스마트 포인터를 사용할 수 있고, 표준에서 제공되는 스마트 포인터를 사용할 수 있다.
using KnightRef = shared_ptr<Knight>;
(2) 참조 타입
참조 횟수가 atomic<int>로 선언되어 있기 때문에 참조 횟수를 증가시키는 작업이 자원을 소모한다.
따라서 참조 횟수를 증가시키지 않는 방법도 존재한다.
void Test(KnightRef& knight)
{
// 사용
}
객체를 참조 타입으로 받아와 사용하면 된다.
단, 다른 객체에서 해당 포인터를 참조 타입으로 받아오면 참조 횟수가 증가하지 않기 때문에 메모리 오염을 일으킬 수 있다.
(3) 순환 문제
shared_ptr인 두 객체가 서로를 가리키면서(강한 참조 ; Strong Reference) 참조 횟수가 0이 되지 않아 메모리가 해제되지 않는 현상.
- weak_ptr
참조 횟수를 증가시키지 않는 스마트 포인터 방식.
6. 스마트 포인터를 사용할 때 자기 자신을 매개 변수로 넘기는 방법
class Knight
{
public:
void Test()
{
Move(this);
}
void Move(shared_ptr<Knight> k)
{
}
};
Test 함수를 통해 Move 함수를 호출하고, Move 함수의 매개 변수로 자기 자신을 넘기도록 하는 코드이다.
하지만 위와 같은 구조처럼 자신을 넘기는(this) 방식은 오류가 발생해서 쓸 수 없다.
이를 해결하기 위한 여러 방법을 생각해보자.
(1) shared_ptr로 만들어서 넘기기 (X)
class Knight
{
public:
void Test()
{
Move(shared_ptr<Knight>(this));
}
// 복사
void Move(shared_ptr<Knight> k)
{
}
};
이렇게 코드를 작성하면 일반 포인터인 this를 대상으로 스마트 포인터를 만든 셈이 된다.
즉 참조 횟수가 1이고 포인터가 this가 된다.
이런 경우에서는 나중에 해당 포인터를 사용하지 않게 되면 참조 횟수가 줄어들며 this 포인터를 날려버리게 된다.
(Move 함수가 종료되면서 this가 가리키던 주소의 메모리를 해제하게 된다)
(2) 스스로를 가리키는 weak_ptr를 변환하여 사용하기 (O)
class Knight
{
public:
void Test()
{
Move(shared_ptr<Knight>(_wptr));
}
// 복사
void Move(shared_ptr<Knight> k)
{
}
private:
weak_ptr<Knight> _wptr;
};
여전히 참조 카운트 블럭을 관리하며 사용하므로 안전한 방법이다.
(3) enable_shared_from_this로 자기 자신 반환하기
class Knight : enable_shared_from_this<Knight>
{
public:
void Test()
{
Move(shared_from_this());
}
// 복사
void Move(shared_ptr<Knight> k)
{
}
};
자기 자신을 weak_ptr로 만들어서 내부에서 가지고 있다가 반환하는 방식을 사용한다.
그리고 내부적으로 weak_ptr을 shared_ptr로 변환하여 사용한다.
'서버 프로그래밍 > 멀티 스레드' 카테고리의 다른 글
1-8. 생산자-소비자 패턴, 이벤트와 조건 변수(Condition Variable) (0) | 2023.11.28 |
---|---|
1-7. 데드락(Deadlock) (0) | 2023.11.24 |
1-6. CAS(Compare And Swap), 스핀 락(Spin Lock) (0) | 2023.11.24 |
1-5. 락 (Lock), 뮤텍스(mutex), RAII 패턴, lock_guard (0) | 2023.11.24 |
1-4. 공유 자원과 경쟁 조건 (0) | 2023.11.23 |