ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 2. std::shared_ptr 과 std::weak_ptr
    Modern C++/Smart Pointer 2020. 7. 12. 17:07

    std::shared_ptr<T>

    동적 할당된 객체의 소유권을  공유할 때 std::shared_ptr<T>를 이용해 메모리를 관리한다. 메모리 해제 시점은 동적 객체를 가리키는 모든 공유 포인터가 더 이상 해당 객체를 가리키지 않을 때로 더 이상 공유 포인터가 가리키는 객체의 수명에 대해 신경 쓸 필요가 없어진다. 

    이렇게 메모리가 해제되는 시점은 원자적 참조 횟수(atomic reference count)를 통해 공유 포인터 생성 시 카운트를 증가시키고, 소멸 시 카운트를 감소시킨다. 복사 배정 연산자의 경우 증가와 감소가 모두 일어난다. 참조 횟수는 제어 블록(control block)에서 관리하며 공유 포인터의 대략적인 구조는 아래와 같다.

    위와 같이 공유 포인터는  객체를 가리키는 포인터와 별개로 제어블록을 가리키는 포인터가 필요하고 객체를관리하는 제어블록을 담는 메모리가 동적으로 할당 된다. 또한 참조 회수를 관리하는 카운터의 원자적 연산등의 비용이 든다.

    struct Foo {
    public:
        Foo() { std::cout << "Foo Construct\n"; }
        ~Foo() { std::cout << "Foo Destruct\n"; }
    };
    
    int main() {
    
        auto p0 = std::shared_ptr<Foo>(new Foo); //referece_count = 1;
        auto p1 = p0; //referece_count = 2;
    
        auto p2 = std::make_shared<Foo>();  //referece_count = 1;
        
        p2 = p1; //복사 할당 연산
        //p2가 가리키던 refrece_count = 0 으로 객체 소멸, p0, p1, p2가 가리키는 referece_count = 3;
        std::cout << "ref_count: " << p0.use_count() << std::endl;
        return 0;
    }

    실행 결과

    std::make_shared<T>()

    생 포인터를 직접 생성하고 공유 포인터 생성자에 넘길 경우, 동일한 포인터를 가리키는 공유 포인터를 여러개 성성 할 수 있는 위험이 증가한다. 이런 경우 가리키는 동일한 객체에 대해 여러개의 제어 블록이 생성되고 공유 포인터가 수명을 다하면 가리키는 객체에 여러번 소멸자를 호출하는 경우 가 발생할 수 있다. 

    int main() {
        auto ptr_foo = new Foo();
        auto p0 = std::shared_ptr<Foo>(ptr_foo); //ptr_foo를 가리키는 공유 포인터 생성 
        auto p1 = std::shared_ptr<Foo>(ptr_foo); //동일한 ptr_foo를 가리키는 공유 포인터를 또 다시 생성
        //프로세스 종료 시 두 번의 소멸자가 호출되어 예외 발생 
        return 0;
    }
    

    실행결과

    따라서 이런 불 필요한 코드를 작성하지 않게 하기 위해 shared_ptr에 생 포인터를 넘기는 건 피해야 한다. std::make_shared<T>()를 사용해 공유 객체를 만드는 걸 권장하는 데 sdt::make_shared<T>() 에는 커스텀 삭제자를 지정 할 수 없으므로 이런 경우를 제외한 경우는 make_shared 를 사용하는 게 좋다. 

    std::shared_ptr 생성자를 생 포인터로 호출 할 수 밖에 없는 경우 생포인터의 변수를 거치지 않고 직접 new 결과를 전달해야 한다. 

    std::shared_ptr 생성자를 직접 호출하는 경우 객체와 제어 블럭을 생성하기 위해 각각 두번 동적 할당이 일어나는 데 make_shared<T>()는 한 번의 동적 할당을 통해 이를 해결하기 때문에 직접 호출보다 성능이 더 뛰어나다. 

    int main() {
        auto p0 = std::shared_ptr<Foo>(new Foo(), &custom_deleter);
        auto p1 = std::make_shared<Foo>();
        return 0;
    }

    enable_shared_form_this<T>

    렌더링하는 텍스쳐는 생성 시 많은 리소스가 필요하기 때문에 텍스쳐를 생성하고 이를 관리할 캐쉬를 두어 생성한 텍스쳐를 공유한다고 가정하면 아래와 같이 구현 가능하다. get_texture 람다 함수는 해당 Texture  객체가 이미 만들어진 경우 만들어진 객체를 반환하고 캐쉬에 없는 경우 새로 만들어 이를 반환한다. 

    #include <iostream>
    #include <memory>
    #include <unordered_map>
    class Texture;
    struct TextureCache {
    public:
        static TextureCache& instance() {
            static TextureCache cache;
            return cache;
        }
        void set_texture(std::shared_ptr<Texture> tex);
        std::shared_ptr<Texture> get_texture(int id);
    
    protected:
        TextureCache() = default;
    public:
        //TextureCache 는 싱글톤 객체로 프로세스 당 하나만 존재
        TextureCache(const TextureCache&) = delete;
        TextureCache& operator = (const TextureCache&) = delete;
        TextureCache(TextureCache&&) = delete;
        TextureCache& operator = (TextureCache&& ) = delete;
    private:
        std::unordered_map<int, std::shared_ptr<Texture>> textures;
    };
    
    
    class Texture{
    public:
        Texture(int id);
        void register_in_cache();
        friend std::ostream& operator << (std::ostream& os, const Texture& rhs);
    private:
        int id;
        friend class TextureCache;
    };
    
    Texture::Texture(int id): id{id} {
    }
    
    void Texture::register_in_cache() {
        //Texture의 공유 포인터를 등록 
        TextureCache::instance().set_texture(std::shared_ptr<Texture>(this));
    }
    
    std::ostream& operator << (std::ostream& os, const Texture& rhs) {
        return os << "texture: " << rhs.id << std::endl;
    }
    
    void TextureCache::set_texture(std::shared_ptr<Texture> tex) {
        textures[tex->id] = tex;
    }
    
    std::shared_ptr<Texture> TextureCache::get_texture(int id) {
        auto e = textures.find(id);
        if(e == textures.end()) {
            return nullptr;
        }
        return e->second;
    }
    
    auto get_texture = [] (int id) {
        auto texture = TextureCache::instance().get_texture(id);
        if(texture == nullptr) {
            texture = std::make_shared<Texture>(id);
            texture->register_in_cache();
        }
        return texture;
    };
    
    int main() {
        auto texture0 = get_texture(0);
        auto texture1 = get_texture(0);
        std::cout << texture0.use_count() << std::endl;
        std::cout << texture1.use_count() << std::endl;
        return 0;
    }
    

    위는 실행 결과를 보여주는 데 우리가 원하는 데로 동작하지 않음을 알 수 있다. texture0과 texture1은 동일한 객체를 가리키고 있는데도 참조 카운터 값이 틀리고 동일할 객체를 여러 번 소멸 시 발생한 에러 메시지를 확인할 수 있다. 왜 이런 경우가 발생할까? 

    이는 서로 다른 공유 포인터가 동알한 객체를 가리킬 때 발생하는 데 Texture 등록 시 TextureCahce에 해당 객체의 공유 포인터를 등록할 때 또 다른 제어 블록을 가지는 공유 포인터를 등록하기 때문에 발생한다. 

    void Texture::register_in_cache() {
        //this를 가리키는 제어블록을 갖는 새로운 std::shared_ptr<Texture> 객체 생성
        TextureCache::instance().set_texture(std::shared_ptr<Texture>(this));
         //TextureCache::instance().set_texture(shared_from_this());
        /* textures[id] = std::shared_ptr<Texture>(this); 과 같이
         * 결과적으로 자신의 생 포인터 this를 가리키는 새로운 공유 포인터가 만들어지고 이를 등록하게 된다. */
    }

    이 처럼 해당 객체를 외부에서 공유 포인터로 사용할 것 이 확실하고 해당 객체 내부 멤버 함수에서 std::shared_ptr<T>(this)와 같이 공유 포인터를 사용하는 경우는 enable_shared_from_this<T>를 상속하고 자신의 객체를 공유포인터로 반환할 경우 shared_from_this()를 사용한다. shared_from_this()는 새로운 제어 블록을 생성하지 않고 이미 생성된 객체에 대한 제어 블록을 이용해 공유 포인터를 사용하게 한다. 

    class Texture : public std::enable_shared_from_this<Texture>{
    public:
        Texture(int id);
        void register_in_cache();
        friend std::ostream& operator << (std::ostream& os, const Texture& rhs);
    private:
        int id;
        friend class TextureCache;
    };
    
    Texture::Texture(int id): id{id} {
    }
    
    void Texture::register_in_cache() {
         TextureCache::instance().set_texture(shared_from_this());
    }
    

    위는 수정된 Texture 객체이다.

     

    enable_shared_from_this<T>는 특수한 경우에만 사용되는 데 

    1. 해당 객체가 외부에서 공유포인터로 사용되고 

    2. 객체 멤버 함수에서 자신의 공유 포인터를 반환 할 때 이다. 

     

    공유 포인터의 순환 참조 문제

    클래스 Foo 가 클래스 Bar를 가리키는 공유 포인터를 가지고 있고 클래스 Bar도 클래스 Foo를 참조하고 있는 공유 포인터를 갖고 있는 경우

    메모리 누수가 발생한다. 이를 공유 포인트의 순환 참조라 한다. 

    struct Foo;
    struct Bar {
        Bar() { std::cout << "Bar 객체 생성\n"; }
        ~Bar() { std::cout << "Bar 객체 소멸\n"; }
        std::shared_ptr<Foo> f;
    };
    
    struct Foo {
        Foo() { std::cout << "Foo 객체 생성\n"; }
        ~Foo() { std::cout << "Foo 객체 소멸\n"; }
        std::shared_ptr<Bar> b;
    };
    
    int main() {
        {
            auto foo = std::make_shared<Foo>();
            auto bar =std::make_shared<Bar>();
            foo->b = bar;
            bar->f = foo;
            std::cout << "foo 참조 카운터 수: " << foo.use_count() << std::endl;
            std::cout << "bar 참조 카운터 수: " << bar.use_count() << std::endl;
        }
        return 0;
    }

     위 코드를 수행한 결과 생성된 Foo 객체와 Bar 객체는 소멸되지 않고 메모리 누수가 발생한 걸 확인 할  수 있다. 

    Foo와 Bar를 생성하고 foo->b 에  bar의 공유 포인터를 할당하고 bar->f에 foo의 공유 포인터를 할당하면 아래와 같은 상황이 된다. 

    foo가 블록 범위를 벗어나면 std::shared_ptr<Foo>객체가 소멸되면서 ① 에 해당하는 포인터를 해제하게되고 foo 제어 블록의 공유 참조 카운터가 1로 변경된다. 

    bar도 마찬가지로 블록 범위를 벋어나면 객체가 소멸되고 bar 제어 블록의 공유 참조 카운터가 변경 된다. 

     

    두 공유 포인터가 소멸되었지만 공유 포인터 foo와 bar가 가리키던 객체 내부의 공유 포인터들이 서로를 가리키고 있으므로 공유 참조 카운트는 0이 되지 못한다. 이는 foo와 bar 공유 포인터가 범위를 벗어나기 전 해당 멤버 변수 foo->b 나 bar->f를 해제해 주면 정상적으로 메모리를 해제할 수 있다.  

    int main() {
        {
            auto foo = std::make_shared<Foo>();
            auto bar =std::make_shared<Bar>();
            foo->b = bar;
            bar->f = foo;
            std::cout << "foo 참조 카운터 수: " << foo.use_count() << std::endl;
            std::cout << "bar 참조 카운터 수: " << bar.use_count() << std::endl;
            std::cout << "===========================\n";
            std::cout << "Foo 객체의 내부 공유 포인터 해제" << std::endl;
            foo->b = nullptr;
            std::cout << "Bar 객체의 내부 공유 포인터 해제" << std::endl;
            bar->f = nullptr;
            std::cout << "===========================\n";
            std::cout << "foo 참조 카운터 수: " << foo.use_count() << std::endl;
            std::cout << "bar 참조 카운터 수: " << bar.use_count() << std::endl;
        }
    
        return 0;

    이런 방식은 번거럽고 실수 할 여지가 충분해 이렇게 문제를 해결하는 것은 임시방편에 불과하다. 이를 근본적으로 해결하기 위해서는 std::weak_ptr<T>를 사용해야 한다. 

     

    std::weak_ptr<T> 

    약한 포인터(std::weak_ptr<T>)는 공유 포인터가 가리키는 객체에 임시적인 소유권을 갖는 포인터로 공유 포인터가 생성한 제어 블록의 공유 참조 카운터를 증가시키지 않고 해당 지칭 객체에 접근하기 위해서는 특별한 방법이 필요하다. 

    struct Foo {
        Foo() { std::cout << "Foo 객체 생성\n"; }
        ~Foo() { std::cout << "Foo 객체 소멸\n"; }
        void print() {
            std::cout << "Hello Foo\n";
        }
        std::shared_ptr<Bar> b;
    };
    
    
    int main() {
    
        std::weak_ptr<Foo> weak_foo;
        {
            auto shared_foo = std::make_shared<Foo>();
            weak_foo = shared_foo;
            std::cout << "weak_ptr을 foo에 할당 후 참조 카운터 수: " << shared_foo.use_count() << std::endl;
            if(!weak_foo.expired()) {
                std::cout << "foo 객체가 만료되지 않음" << std::endl;
                auto temp_shared_foo = weak_foo.lock();
                std::cout << "lock()을 이용해 공유 포인터 생성 후 foo 참조 카운터 수: " << shared_foo.use_count() << std::endl;
                temp_shared_foo->print();
            }
            std::cout << "===========================\n";
        }
    
        if(weak_foo.expired()) {
            std::cout << "foo 객체는 존재하지 않음\n";
            std::cout << "foo 참조 카운터 수: " << weak_foo.use_count() << std::endl;
        }
        auto expried_weak_foo = weak_foo.lock();
        
        if(expried_weak_foo == nullptr) {
            std::cout << "weak_foo를 통해 공유 포인터를 얻지 못함\n";
        }
        return 0;

    std::weak_ptr<T>는 지칭 객체의 유효 여부를 판단하기 위해 expired() 함수를 지원한다. 위의 예제처럼 shared_foo를 할당한 weak_foo는 지칭 객체가 유효하므로 true를 반환한다. 

    약한 참조는 객체의 생명 주기에 관여 할 수 없으므로 필요한 경우에 lock() 함수를 통해  공유 포인터를 생성 후 이를 이용해 객체에 접근해야 한다. lock() 함수는 해당 객체가 존재하면 해당 객체에 대한 공유 포인터를 반환하고 해당 객체가 존재하지 않으면 nullptr 값을 반환한다. 

     

    이제 weak_ptr을 통해서 순환 참조를 해결해 보자.

    struct Bar;
    struct Foo {
        Foo() { std::cout << "Foo 객체 생성\n"; }
        ~Foo() { std::cout << "Foo 객체 소멸\n"; }
        void print() {
            std::cout << "Hello Foo\n";
        }
        std::shared_ptr<Bar> b;
    };
    
    struct Bar {
        Bar() { std::cout << "Bar 객체 생성\n"; }
        ~Bar() { std::cout << "Bar 객체 소멸\n"; }
        void print_in_foo() {
            auto shared_foo = f.lock();
            if(shared_foo != nullptr) {
                shared_foo->print();
                std::cout << "lock()호출 후 참조 카운터 수: " << shared_foo.use_count() << std::endl;
            } else {
                std::cout << "더 이상 존재하지 않는 객체 입니다.\n";
            }
        }
        std::weak_ptr<Foo> f;
    };
    
    
    
    
    int main() {
        {
            auto shared_foo = std::make_shared<Foo>();
            auto shared_bar = std::make_shared<Bar>();
            shared_foo->b = shared_bar;
            shared_bar->f = shared_foo;
            shared_bar->print_in_foo();
            std::cout << "shared_foo가 가리키는 참조 블록의 참조 카운터 수: " << shared_foo.use_count() << std::endl;
        }
        return 0;
    }
    

    위 처럼 두 객체의 공유 포인터 중 하나만 weak_ptr로 대체해도 순환 참조 문제를 해결할 수 있다. 

     

    'Modern C++ > Smart Pointer' 카테고리의 다른 글

    스마트 포인터 참조 가이드(Smart Pointer Referece Guide)  (0) 2020.07.12
    1. std::unique_ptr  (0) 2020.07.02

    댓글

Designed by Tistory.