본문 바로가기
언리얼_엔진_게임개발_공부/C++

[shared_ptr] enable_shared_from_this / control block / aliasing constructor / make_shared 의 placement new

by jaboy 2025. 4. 27.

shared_ptr 는 객체에 대한 소유권을 여러 곳에서 공유 가능하게 하며, 참조 카운트를 추적하여 0이 될 때 자동으로 메모리를 해제하는 스마트 포인터이다.

왜 필요한가?

- 클래스 내부에서 자기 자신에 대한 shared_ptr 를 생성해야 하는 경우

- 특히 객체에 대한 소유권을 다른 곳에서 객체의 멤버 함수를 통해 공유하고자 하는 경우.

- shared_ptr<MyClass>(this) 를 사용하면 별도의 참조 카운트를 가진 새로운 shared_ptr 를 생성하므로

해당 객체를 관리하는 shared_ptr 가 여러 개가 되어 별도의 참조 카운트가 생기므로 중복으로 메모리 해제할 위험이 있다.

이중 delete 위험이 있는 잘못된 예시

#include <memory>

class MyClass {
public:
    std::shared_ptr<MyClass> GetPtrWrong() {
        // ❌ 새로운 shared_ptr를 별도로 생성하게 됨
        return std::shared_ptr<MyClass>(this);
    }
};

auto ptr1 = std::make_shared<MyClass>();
// ❌ 별도의 shared_ptr 가 생성되어 메모리 이중 delete 위험
auto ptr2 = ptr1->GetPtrWrong();

 

이러한 경우 안전하게 자기 자신에 대한 shared_ptr 인스턴스를 생성하기 위해서 사용하는 것이 enable_shared_from_this 이다.

클래스가 enable_shared_from_this 를 상속받아 shared_from_this() 멤버 함수를 통해 객체 자신에 대한 shared_ptr 를 안전하게 반환하도록 한다.

 

shared_from_this() 활용한 예시

#include <memory>

class MyClass : public enable_shared_from_this<MyClass> {
public:
    std::shared_ptr<MyClass> GetSharedPtr() {
        // 원래 객체를 관리하는 shared_ptr 와 소유권 공유하는 shared_ptr 반환
        return shared_from_this();
    }
};

auto ptr1 = std::make_shared<MyClass>();
// 별도의 참조 카운트를 생성하지 않고 ptr1 과 공유하는 shared_ptr 
auto ptr2 = ptr1->GetSharedPtr();

 

shared_from_this() 작동 방식

1. shared_ptr 생성 시 객체를 weak_ptr 멤버 변수로 참조한다.

2. shared_from_this() 호출 시 위의 변수로부터 shared_ptr 를 생성하여 반환한다. 

*주의* 객체가 shared_ptr 로 관리되지 않는 상태에서 shared_from_this 가 호출되면 예외가 발생한다.

enable_shared_from_this 클래스의 구현 예시

* 실제 구현은 라이브러리에 따라 다르며 공개되어 있지 않음

#include <memory>
#include <stdexcept> // For std::logic_error

template <typename T>
class enable_shared_from_this {
protected:
	// preventing external code to instantiate
    enable_shared_from_this() = default;
    enable_shared_from_this(const enable_shared_from_this&) = default;
    enable_shared_from_this& operator=(const enable_shared_from_this&) = default;
    ~enable_shared_from_this() = default;

public:
    // Non-const version of shared_from_this
    std::shared_ptr<T> shared_from_this() {
        auto sharedPtr = weak_this.lock(); // lock weak_ptr to get shared_ptr
        if (!sharedPtr) {
            throw std::logic_error("shared_from_this() called before object was managed by shared_ptr");
        }
        return sharedPtr;
    }

    // Const version of shared_from_this
    std::shared_ptr<const T> shared_from_this() const {
        auto sharedPtr = weak_this.lock();
        if (!sharedPtr) {
            throw std::logic_error("shared_from_this() called before object was managed by shared_ptr");
        }
        return sharedPtr;
    }

private:
    // The weak_ptr to hold a reference to the object
    std::weak_ptr<T> weak_this;

    // Friend declaration allows std::shared_ptr to call _set_weakptr
    friend class std::shared_ptr<T>;

    // Function to set the weak_ptr when the object is managed by a shared_ptr
    // --> called internally upon shared_ptr construction
    void _set_weakptr(const std::shared_ptr<T>& ptr) {
        weak_this = ptr;
    }
};

 

즉 핵심 플로우는 아래와 같다:

- enable_shared_from_this 를 상속받는 클래스의 인스턴스가 shared_ptr 로 관리될 때

- 내부적으로 위의 _set_weakptr 와 같은 방식으로 weak_ptr 참조를 만듦

** shared_ptr 클래스에서 _set_weakptr 함수를 호출할 수 있도록 friend 로 등록한다.

- 이후 shared_from_this() 가 호출될 때 weak_ptr 로 참조한 데이터를 lock() 하여 shared_ptr 반환

- 반환된 shared_ptr 는 원래의 shared_ptr 와 소유권을 공유하는 shared_ptr 이다.

 

여기서 내가 눈여겨본 지점은 _set_weakptr 가 호출되는 시점이다.

weak_ptr 참조를 하기 위해서는 shared_ptr 가 초기화된 이후여야 하므로 shared_ptr 의 생성이 완료되기 전까지는 _set_weakptr 를 호출하면 안된다. 그러면 이것을 라이브러리에서 대체 어떻게 구현할 것인지 궁금해졌다.

이는 우선 shared_ptr 가 갖고 있는 Control Block 을 살펴보아야 한다.

Control Block

참고:

 

[Effective Modern C++] Chapter 4: Smart Pointers

raw pointer의 단점 1. 포인터의 선언에서 하나의 객체를 가리키는지 배열을 가리키는지 나타내지 않는다. 2. 포인터가 가리키는 객체에 대한 사용이 끝났을 때, 포인터가 가리키는 것을 파괴해야

dev-tuesberry.tistory.com

 

Control Block 은 shared_ptr 가 생성될 때 객체에 대한 참조, 참조 카운트 등을 담고 있다.

아래와 같은 규칙을 통해 생성된다.

1. std::make_shared 는 control block 을 새로 생성한다.
2. unique-ownership pointer 로부터 std::shared_ptr 이 생성될 때 control block이 생성된다.
3. std::shared_ptr 생성자가 raw pointer와 호출될 때, control block이 생성된다.
(출처: 위 블로그 포스트)

 

글 초반의 잘못된 예시가 3번에 해당한다. shared_ptr<T>(this) 와 같이 호출하면 객체가 make_shared로 생성되어 shared_ptr 로 관리될 때 함께 생성되는 control block 과는 별도로 control block 이 생성되기에 문제가 된 것이다. 같은 객체를 가리키고 있으나 control block 이 별도이기 때문에 참조 카운트도 별도이고, 그렇기 때문에 이중 delete 가 발생할 수 있는 것이다.

 

control block 을 고려했을 때 shared_ptr 로 관리되는 객체의 생성은 아래와 같은 흐름으로 실행된다.

1. make_shared 나 shared_ptr<T>([object]) 등을 통해 shared_ptr 개체의 생성자 호출

2-1. make_shared : 객체 생성하여 shared_ptr 내부 포인터 변수에 저장 & control block 생성

2-2. shared_ptr(raw 포인터) : 매개변수로 받은 raw 포인터를 shared_ptr 내부 포인터 변수에 대입 & control block 생성

3. *shared_ptr 의 생성 완료 후* control_block 내 weak_ptr 변수 set

 

이러한 플로우를 모두 포괄하는 로직을 아래와 같이 코드로 직접 작성해보았다.

shared_ptr, weak_ptr, control block, shared_from_this 를 포함한 예시

template <typename T>
class ControlBlock {
public:
	int strong_count; // shared_ptr 참조 카운트
    int weak_count; // weak_ptr 참조 카운트
    T* object; // 객체 포인터 - 이 로직에서는 weak_ptr 의 lock 활용 시 필요
    /****note: 실제로 control block은 객체 포인터를 갖고 있다. 아래 추가1) 참고****/
    
    ControlBlock(T* ptr)
    	: strong_count(1), weak_count(0), object(ptr)
    { /*code*/ }
    ~ControlBlock() { /*code*/ }
};

template <typename T>
class shared_ptr {
public:
    // raw pointer 매개변수로 받는 생성자
    // - 내부 멤버변수에 객체 주소값 대입 및 control block 생성
    explicit shared_ptr(T* ptr)
    	: ptr_(ptr), control(new ControlBlock<T>(ptr))
    {
        /***********weak_ptr 변수 set 시점!***************//
        // 실질적으로 shared_ptr 의 객체 포인터와 control block 초기화가 끝난 상태이므로
        // 객체가 enable_shared_from_this 타입인 경우 weak_this를 자신(shared_ptr)으로 set
        if constexpr (std::is_base_of<enable_shared_from_this<T>, T>::value) {
            static_cast<enable_shared_from_this<T>*>(ptr_)->_set_weakptr(*this);
        }
    }
    
    // weak_ptr lock() 용도
    explicit shared_ptr(ControlBlock<T>* _control)
    	: control(_control)
    {
    	if (control) {
        	ptr_ = control->object;
        	control->strong_count++;
        }
    }

    ~shared_ptr() {
    	if (control) {
        	if (--control->strong_count == 0) {
            	delete ptr_;
                if (control->weak_count == 0) {
                	delete control;
                }
            }
        }
    }
    
    T* get() const { return ptr_; }

private:
    T* ptr_; // 관리되는 객체
    ControlBlock<T>* control;
};

// make_shared
// - 객체 생성하여 내부 변수에 저장 및 control block 생성
/**** note: make_shared 의 실제 구현은 T와 ControlBlock 메모리를 1회만 할당한다. 아래 추가2) 설명 참고****/
template<typename T>
shared_ptr<T> make_shared() {
    T* obj = new T();
    return shared_ptr<T>(obj);
}

template<typename T>
class weak_ptr {
public:
    weak_ptr(const shared_ptr<T>& shared)
        : control(shared.control)
    {
        if (control) {
            control->weak_count++;
        }
    }
    
    ~weak_ptr() {
        if (control) {
            if (--control->weak_count == 0 && control->strong_count == 0) {
                delete control;
            }
        }
    }
    
    shared_ptr<T> lock() const {
    	if (control && control->strong_count > 0) {
        	return shared_ptr<T>(control);
        }
        return shared_ptr<T>(nullptr);
    }
    
private:
    ControlBlock<T>* control;
};

template <typename T>
class enable_shared_from_this {
protected:
    enable_shared_from_this() { /*code*/ }
    ~enable_shared_from_this() { /*code*/ }
public:
    shared_ptr<T> shared_from_this() {
        // lock() 으로 weak pointer --> shared pointer 전환
        auto sharedPtr = weak_this.lock();
        if (!sharedPtr.get()) {
            throw std::logic_error("weak_this.lock() failed");
        }
        return sharedPtr;
    }

    void _set_weakptr(const shared_ptr<T>& ptr) {
        // shared_ptr 생성될 때 호출
        weak_this = weak_ptr<T>(ptr);
    }

private:
	// 객체 자신 가리키는 weak pointer
    weak_ptr<T> weak_this;
};

class MyObject : public enable_shared_from_this<MyObject> {
public:
    MyObject() { /*code*/ }
    ~MyObject() { /*code*/ }
    
    shared_ptr<MyObject> GetSharedPtr() { return shared_from_this(); }
};

int main() {
    shared_ptr<MyObject> sharedPtr1 = make_shared<MyObject>();
    auto sharedPtr2 = sharedPtr1.get()->GetSharedPtr();
}

 

궁금하다고 생각했던 지점에 대한 코드는 shared_ptr 의 생성자 부분에 있다.

_set_weakptr 함수에 *this, 즉 shared_ptr 객체 (T 객체가 아닌) 자기 자신을 인자로 넣어 호출한다.

글 초반을 작성할 때에는 weak_ptr 구현까지 들여다보지 않았기에 불가하다고 생각했는데, T 객체와 control block 이 모두 생성된 상태이므로 shared_ptr 의 생성자는 실행을 마치지 않았지만 shared_ptr 의 '생성' 자체는 실질적으로 완료되었기 때문에 weak_ptr 생성에도 문제가 없다...!

(이것 때문에 2시간동안 씨름했는데... 계속 잘못된 답변 2가지를 번갈아 이야기하는 chatGPT 한테도 엄청 성질내고...)

 

추가1) Control Block 과 shared_ptr 가 별도로 객체 포인터를 갖는 경우

 

Why are two raw pointers to the managed object needed in std::shared_ptr implementation?

Here's a quote from cppreference's implementation note section of std::shared_ptr, which mentions that there are two different pointers(as shown in bold) : the one that can be returned by get(), an...

stackoverflow.com

The pointer held by the shared_ptr directly is the one returned by get(), while the pointer or object held by the control block is the one that will be deleted when the number of shared owners reaches zero. These pointers are not necessarily equal.

 

즉, shared_ptr 에 get() 또는 operator-> 을 호출하면 위 예시 코드와 같이 shared_ptr 가 관리하고 있는 객체 포인터를 반환한다.

반면 control block 이 갖고 있는 포인터 또는 객체의 경우, 객체 소유권 카운트가 0이 되면 삭제되는 객체 포인터이다.

이 두 포인터는 보통 같지만 아래와 같은 경우에 다를 수 있다.

 

Advanced users often require the ability to create a shared_ptr instance p that shares ownership with another (master) shared_ptr q but points to an object that is not a base of *q. *p may be a member or an element of *q, for example. This section proposes an additional constructor that can be used for this purpose.

 

shared_ptr p 는 shared_ptr q (main) 와 소유권을 공유하나, *q 와는 또 다른 base 의 객체를 가리켜야 하는 경우에 위에서 말한 것처럼 포인터가 별도로 관리되어야 할 것이다. 예를 들어, *p 는 *q 의 멤버 또는 요소, 즉 작은 부분일 수 있다. 이를 위한 "aliasing" 생성자가 별도로 존재하며, 아래와 같은 생성자로 이해해볼 수 있다.

 

Aliasing Constructor 

// r 과 소유권을 공유하나, get()은 r 과 관계 없이 ptr 를 반환
template< class Y >
shared_ptr( const shared_ptr<Y>& r, T *ptr );

 

 

 

std::shared_ptr<T>::shared_ptr - cppreference.com

constexpr shared_ptr() noexcept; (1) (2) template< class Y > explicit shared_ptr( Y* ptr ); (3) template< class Y, class Deleter > shared_ptr( Y* ptr, Deleter d ); (4) template< class Deleter > shared_ptr( std::nullptr_t ptr, Deleter d ); (5) template< cla

en.cppreference.com

cppreference 를 보면 해당 생성자를 아래와 같이 설명한다.

The aliasing constructor: constructs a shared_ptr which shares ownership information with the initial value of r,
but holds an unrelated and unmanaged pointer ptr. If this shared_ptr is the last of the group to go out of scope,
it will call the stored deleter for the object originally managed by r. However, calling get() on this shared_ptr will
always return a copy of ptr. It is the responsibility of the programmer to make sure that this ptr remains valid as
long as this shared_ptr exists, such as in the typical use cases where ptr is a member of the object managed
by r or is an alias (e.g., downcast) of r.get()

 

즉, ptr 자체는 shared_ptr 에 의해 관리되지 않는다. 만일 위 생성자로 생성된 shared_ptr 가 마지막으로 scope 를 벗어나 메모리 해제가 일어나는 경우, 오리지널 shared_ptr 인 r 이 관리하는 객체의 deleter 가 호출된다는 것이다.

가장 흔한 케이스는 ptr 이 r 관리 객체의 멤버 또는 부분인 경우, 아니면 r.get() 의 alias (downcast 등) 인 경우이다.

get() 의 포인터 주소는 다르지만 컨트롤 블록은 같은 것을 보여주는 StackOverflow 예시

std::shared_ptr<Base2> creator()
{
	std::shared_ptr<Derived> p(new Derived());
	std::shared_ptr<Base2> q(p, static_cast<Base2*>(p.get()));
	std::cout << "p.get(): " << p.get() << std::endl;
	std::cout << "q.get(): " << q.get() << std::endl;
	std::cout << "p use_count() : " << p.use_count() << std::endl;
	std::cout << "q use_count() : " << q.use_count() << std::endl;
	return q;
}

int main() {
	creator();
}

위와 같이 p 와 q 의 get() 은 각각 다른 주소값을 반환하지만, 컨트롤 블록이 관리하는 use_count() 는 똑같이 2이다. 즉:

- p 와 q 의 shared_ptr 에 등록된 객체 포인터는 별개

- p 와 q 의 shared_ptr 에 등록된 컨트롤 블록은 동일

- 메모리 해제 시 처음 shared_ptr 가 생성될 때 (p) 등록된 Derived 의 소멸자 호출

raw pointer 로 alias 생성 시 주의할 점을 보여주는 나의 예시

int main() {
	std::shared_ptr<Derived> r = std::make_shared<Derived>();
	std::shared_ptr<Base2> s(r, static_cast<Base2*>(r.get()));
	Base2* s_raw = s.get();
	std::cout << "r.get(): " << r.get() << std::endl
		<< "s.get(): " << s.get() << std::endl
		<< "s_raw: " << s_raw << std::endl
		<< "r use_count() : " << r.use_count() << std::endl
		<< "s use_count() : " << s.use_count() << std::endl;

	std::cout << ">>> s reset" << std::endl; s.reset();
	std::cout << "r: " << r << " / r use_count() : " << r.use_count() << std::endl;
	std::cout << "s: " << s << " / s use_count() : " << s.use_count() << std::endl;
	std::cout << "s_raw->b: " << s_raw->b << std::endl;

	std::cout << ">>> r reset" << std::endl; r.reset();
	std::cout << "r: " << r << " / r use_count() : " << r.use_count() << std::endl;
	std::cout << "s: " << s << " / s use_count() : " << s.use_count() << std::endl;
	std::cout << "s_raw->b: " << s_raw->b << std::endl;
	
	std::cout << "----- end of main -----" << std::endl;
	
	return 0;
}

 

- s가 reset 되어도 r이 관리하는 객체를 유지시키므로 s_raw 는 유효하다.

- r 이 reset 될 때 관리하던 객체가 delete 되면서 s_raw 는 dangling pointer 가 된다.

- s->b 는 undefined behavior 를 일으켜 쓰레기 값이 출력되었다.

즉, shared_ptr 사용 시 cast 등으로 객체의 alias 가 필요하다면 raw pointer 가 아니라 위와 같은 aliasing constructor 를 활용해 객체를 유지시키는 것이 안전할 것 같다.

추가2) make_shared 의 메모리 할당은 객체와 컨트롤 블록을 합쳐 1회만 일어난다.

C++ 라이브러리에서 구현하는 make_shared 함수는 T 타입의 객체 메모리 할당, 컨트롤 블록 객체 메모리 할당 - 이렇게 2번에 걸쳐 heap allocation 을 하는 것이 아니라 T 객체와 컨트롤 블록 객체를 연속된 하나의 메모리 블록으로 1회 할당한다.

 

핵심 아이디어 : placement new - 새롭게 힙 메모리 할당하지 않고 지정된 메모리 공간에 객체 생성하는 방식

1) 컨트롤 블록 안에 T 객체를 저장할 메모리 버퍼를 만들고,

2) 해당 메모리 버퍼에 placement new 통해 T 객체를 생성하는 것

 

컨트롤 블록 : T 객체 저장할 메모리 할당 / 해당 메모리에 객체 생성

template <typename T>
class ControlBlock {
public:
    int strong_count;
    int weak_count;
    
    /**************************************************/
    alignas(T) unsigned char object_buffer[sizeof(T)]; // T 객체 저장할 버퍼
    /**************************************************/
    
    ControlBlock(T* ptr)
        : strong_count(1), weak_count(0)
	{
    /**************************************************/
        new (object_buffer) T(); // placement-new : 할당된 메모리에 객체 생성
    /**************************************************/
    }
    
    ~ControlBlock() {
    /**************************************************/
        get_object()->~T(); // 컨트롤 블록 소멸자에서 T객체 소멸자 직접 호출
    /**************************************************/
    }
    
    /**************************************************/
    // 할당된 메모리 주소값을 T*로 캐스팅하여 반환
    T* get_object() { return reinterpret_cast<T*>(object_buffer); }
    /**************************************************/
};

 

* 객체 접근은 항상 control_block->get_object() 로 해야 한다.

* delete 사용 대신 직접 소멸자를 호출해야 한다.

 

버퍼 만드는 방식

(작동 순서대로)
- unsigned char object_buffer[sizeof(T)] : unsigned char = 1바이트 & sizeof(T) = T 타입 크기 in bytes
i.e. T 의 바이트 수만큼 메모리 공간 할당

- alignas(T) : T 가 필요로 하는 크기 단위로 시작 메모리 주소를 align 한다.
(e.g. T 타입 크기가 8바이트라면 8바이트의 배수가 시작 지점이 되도록 패딩을 준다.)
--> 정렬되지 않은 주소에 위치하는 경우 CPU가 한번에 읽지 못해 두번 읽어 재조합하는 추가 명령을 거쳐야 함
--> 특정 아키텍쳐(e.g. ARM)에서는 이러한 미정렬된 메모리 접근이 금지되어 있음 (Bus error 또는 Segmentation Fault)
--> CPU 캐시 라인 단위로 메모리를 가져올 때 한 개의 객체를 위해 두 개의 캐시 라인을 읽어야 하는 상황이 발생하여 캐시 미스 확률을 증가시키고 결론적으로 메모리 접근 레이턴시를 야기함

 

shared_ptr 생성자와 make_shared 는 컨트롤 블록 생성자만 호출

explicit shared_ptr(ControlBlock<T>* _control)
    : control(_control)
{
    if (control) {
    	// 객체 생성 X, 컨트롤 블록에서 get_object() 로 가져와 저장
        ptr_ = control->get_object();
        control->strong_count++;
    }
}

template<typename T>
shared_ptr<T> make_shared() {
	// 컨트롤 블록 메모리 할당 시 T 객체 메모리 포함됨
    ControlBlock<T>* control = new ControlBlock<T>();
    return shared_ptr<T>(control);
}

 

위에서 볼 수 있듯이 객체가 shared_ptr 에 의해 관리되어야 하는 경우,

new 로 객체 생성 후 shared_ptr<T>(ptr) 로 shared_ptr 를 만드는 것보다

make_shared 로 객체 와 컨트롤 블록의 메모리 할당을 합치는 것이 더 효율적이다.

 

-------------------

 

shared_ptr 의 enable_shared_from_self 에 대해 간단?하게 정리해보려고 시작한 공부였는데..

궁금한 지점들과 연결되어 있는 재밌는 내용이 많아서 하루 종일을 쓰게 되었다 ㅎㅎ

 

컨트롤 블록을 검색하면서 운영체제에서 이야기하는 PCB 등에 대한 내용도 좀 읽어보게 되고,

make_shared 의 메모리 할당에 대해 찾아보다가 캐시 미스까지 언급하게 되었다.

운이 좋았다고 생각한다 (?)

아무래도 공부가 이래야 맛있지~