본문 바로가기
PROGRAMMING/Effective Modern C++

19. 소유권 공유 자원의 관리에는 std::shared_ptr을 사용하라.

by La-KanTo 2021. 3. 9.

- std::shared_ptr는 임의의 공유 자원의 수명을 편리하게(가비지 컬렉션에 맡길 때만큼이나) 관리할 수 있는 수단을 제공한다.
std::shared_ptr가 할 수 없는 일로는 배열 관리가 있다. std::unique_ptr와는 다르게, std::shared_ptr의 API는 단일 객체를 가리키는 포인터만 염두에 두고 설계되었다.

std::shared_ptr<T>로 배열을 가리키되, 배열 삭제를 수행하는 커스텀 삭제자를 지정하면 되지 않겠느냐는 발상을 할 수도 있다. 그런 코드가 컴파일되긴 하지만, 그리 좋은 발상은 아니다.

한 가지 이유는, std::shared_ptr는 operator[]를 제공하지 않는다는 것이다. 또 다른 이유로, std::shared_ptr가 지원하는 파생 클래스 포인터에서 기반 클래스 포인터로의 변환은 단일 객체에는 합당하지만, 배열에 적용하면 타입 시스템에 구멍이 생긴다(그래서 std::unique_ptr<T[]> API는 그러한 변환을 금지한다).

가장 중요하게는, C++11은 내장 배열에 대한 다양한 대안들(이를테면 std::array나 std::vector, std::string)을 제공하며, 따라서 멍청한(dumb) 배열을 가리키는 스마트 포인터를 선언한다는 것은 거의 항상 나쁜 설계의 징조이다.

 

- 대체로 std::shared_ptr 객체는 그 크기가 std::unique_ptr 객체의 두 배이며, 제어 블록에 관련된 추가 부담을 유발하며, 원자적 참조 카운트 조작을 요구한다.
어떤 객체를 가리키는 마지막 std::shared_ptr가 객체를 더 이상 가리키지 않게 되면, 그 std::shared_ptr는 자신이 가리키는 객체를 파괴한다.

그런데 std::shared_ptr는 자신이 객체를 가리키는 최후의 공유 포인터임을 어떻게 알까? 그 비결은 바로 자원의 참조 카운트(reference count)에 있다. 참조 카운트는 관리되는 자원에 연관된 값으로, 그 자원을 가리키는 std::shared_ptr들의 개수에 해당한다.

이러한 참조 카운트 관리는 성능에 다음과 같은 영향을 미친다.

1. std::shared_ptr의 크기는 생 포인터의 두 배이다. 내부적으로 자원을 가리키는 생 포인터뿐만 아니라 자원의 참조 카운트를 가리키는 생 포인터도 저장해야 하기 때문이다.
2. 참조 카운트를 담을 메모리를 반드시 동적으로 할당해야 한다. 개념적으로는 참조 카운트는 공유 포인터가 가리키는 객체에 연관된 것이지만, 그 객체 자제는 참조 카운트를 전혀 알지 못한다. 따라서 객체는 참조 카운트를 담을 장소를 따로 마련하지 않는다. 항목 21에서 설명하겠지만, std::make_shared를 이용해서 std::shared_ptr를 생성하면 동적 할당의 비용을 피할 수 있다. 그러나 std::make_shared를 사용할 수 없는 상황들도 존재한다. 어떤 경우이든, 참조 카운트는 동적으로 할당된 데이터로서 저장된다.
3. 참조 카운트의 증가와 감소가 반드시 원자적 연산이어야 한다. 여러 스레드가 참조 카운트를 동시에 읽고 쓰려 할 수 있기 때문이다. 예를 들어 어떤 자원을 가리키는 어떤 std::shared_ptr의 소멸자가 한 스레드에서 실행되는(그래서 그것이 가리키는 자원의 참조 카운트를 감소하는) 도중에 다른 어떤 스레드에서 같은 자원을 가리키는 std::shared_ptr가 복사될(그래서 같은 참조 카운트가 증가할) 수도 있다. 대체로 원자적 연산은 비원자적 연산보다 느리므로, 비록 참조 카운트가 워드 하나 크기라고 해도 그것을 읽고 쓰는 연산이 비교적 느릴 것이라고 가정해야 마땅하다.
보통 std::shared_ptr의 생성자는 피지칭 객체의 참조 카운트를 1 만큼 증가시키는데, 그렇지 않은 경우도 있다. 바로 이동 생성이 일어날 때이다.

이것은 이동 대입이 일어날 때에도 마찬가지인데, 참조 카운트를 조작하지 않기 때문에(즉, 원자적 연산을 수행하지 않기 때문에) 복사 연산보다 이동 연산이 더 빠르다.

 

- 자원은 기본적으로 delete를 통해 파괴되나, 커스텀 삭제자도 지원된다. 삭제자의 타입은 std::shared_ptr의 타입에 아무런 양향도 미치지 않는다.

삭제자를 지원하는 구체적인 방식은 std::unique_ptr의 것과 다르다. std::unique_ptr에서는 삭제자의 타입이 스마트 포인터의 타입의 일부였지만, std::shared_ptr에서는 그렇지 않다.

auto loggingDel = [](Widget* pw)
{                        
    makeLogEntry(pw);
    delete pw;
}

// 삭제자의 타입이 포인터 타입의 일부임
std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);

// 삭제자의 타입이 포인터 타입의 일부가 아님
std::shared_ptr<Widget> spw(new Widget, loggingDel);

std::shared_ptr의 설계가 더 유연하다. 사용하는 커스텀 삭제자의 타입이 서로 다른 두 pw1과 pw2는 같은 타입이므로, 그 타입의 객체들을 담는 컨테이너 안에 집어넣을 수 있다.

std::vector<std::shared_ptr<Widget> > vpw{ pw1, pw2 }

또한, 하나를 다른 하나에 대입할 수도 있고, 둘 다 std::shared_ptr<Widget> 타입의 매개변수를 받는 함수에 넘겨줄 수 있다.
그러나, 커스텀 삭제자 타입이 다른 두 std::unique_ptr에서는 이런 일이 불가능하다.

 

std::unique_ptr와의 또 다른 차이점은, 커스텀 삭제자를 지정해도 std::shared_ptr 객체의 크기가 변하지 않는다는 점이다. 커스텀 삭제자는 std::shared_ptr 객체가 아니라, 제어 블록에 담긴다.

앞에서 std::shared_ptr 객체가 자신이 가리키는 객체에 대한 참조 카운트를 가리키는 포인터도 담는다고 했다. 그 말이 틀린 것은 아니지만, 오해의 소지가 있다. 사실 참조 카운트는 제어 블록(control block)이라고 부르는 더 큰 자료구조의 일부이다.

std::shared_ptr가 관리하는 객체당 하나의 제어 블록이 존재한다. std::shared_ptr 생성 시 커스텀 삭제자를 지정했다면, 참조 카운트와 함께 그 커스텀 삭제자의 복사본이 제어 블록에 담긴다.

커스텀 할당자를 지정했다면 그 할당자의 복사본도 제어 블록에 담긴다. 그 외에도 제어 블록에는 약한 카운트(항목 21 참고)라고 부르는 이차적인 참조 카운트가 포함되며 그밖의 추가 데이터가 포함될 수 있으나, 일단 지금은 그런 추가 데이터의 존재를 무시하기로 한다.

 

- 생 포인터 타입의 변수로부터 std::shared_ptr를 생성하는 일은 피해야 한다.
제어 블록의 생성 여부에 관해 다음과 같은 규칙들을 유추할 수 있다.

- std::make_shared(항목 21 참고)는 항상 제어 블록을 생성한다. 이 함수는 공유 포인터가 가리킬 객체를 새로 생성하므로, std::make_shared가 호출되는 시점에서 그 객체에 대한 제어 블록이 이미 존재할 가능성은 전혀 없다.
- 고유 소유권 포인터(즉, std::unique_ptr나 std::auto_ptr)로부터 std::shared_ptr 객체를 생성하면 제어 블록이 생성된다. 고유 소유권(unique-ownership) 포인터는 제어 블록을 사용하지 않으므로, 피지칭 객체에 대한 제어 블록이 이미 존재할 가능성은 전혀 없다(생성 과정에서 std::shared_ptr 객체는 피지칭 객체의 소유권을 획득하므로, 고유 소유권 포인터는 널로 설정된다).
- 생 포인터로 std::shared_ptr 생성자를 호출하면 제어 블록이 생성된다. 이미 제어 블록이 있는 객체로부터 std::shared_ptr를 생성하고 싶다면, 생 포인터가 아니라 std::shared_ptr나 std::weak_ptr(항목 20 참고)를 생성자의 인수로 지정하면 된다. std::shared_ptr나 std::weak_ptr를 받는 std::shared_ptr 생성자들은 새 제어 블록을 만들지 않는다. 전달된 스마트 포인터들이 이미 필요한 제어 블록을 가리키고 있을 것이기 때문이다.

이 규칙에서 비롯되는 한 가지 결과는, 하나의 생 포인터로 여러 개의 std::shared_ptr를 생성하면 피지칭 객체에 여러 개의 제어 블록이 만들어지므로, 의문의 여지 없이 미정의 행동이 된다는 점이다.

제어 블록이 여러 개라는 것은 참조 카운트가 여러 개라는 뜻이며, 참조 카운트가 여러 개라는 것은 해당 객체가 여러 번 파괴된다는 뜻이다(참조 카운트마다 한 번씩).

auto pw = new Widget; // pw는 생 포인터
std::shared_ptr<Widget> spw1(pw, loggingDel); // *pw에 대한 제어 블록이 생성됨
std::shared_ptr<Widget> spw2(pw, loggingDel); // *pw에 대한 두 번째 제어 블록이 생성됨

이러한 잘못된 용법에서 배울 점이 두 가지 있다.
첫째로, std::shared_ptr 생성자에 생 포인터를 넘겨주는 일은 피하라는 것이다.
흔히 쓰이는 대안은 std::make_shared (항목 21 참고)를 사용하는 것이다.
하지만 지금 예에서처럼 커스텀 삭제자를 사용하는 경우에는 std::make_shared를 사용할 수 없다.
둘째로, std::shared_ptr 생성자를 생 포인터로 호출할 수밖에 없는 상황이라면, 생 포인터의 변수를 거치지 말고 new의 결과를 직접 전달하라는 것이다.

앞의 예제 코드의 첫 부분을 다음과 같이 고친다면,

// new를 직접 사용
std::shared_ptr<Widget> spw1(new Widget, loggingDel);

같은 생 포인터로 또 다른 std::shared_ptr를 생성하려는 유혹에 빠질 염려가 없다.
spw1이 가리키는 Widget 객체를 가리키는 std::shared_ptr를 만들고 싶다면 다음과 같이 하면 된다.

std::shared_ptr<Widget> spw2(spw1); // spw2는 spw1과 동일한 제어 블록을 사용한다.

생 포인터 변수를 std::shared_ptr 생성자의 인수로 사용했기 때문에 제어 블록이 여러 개 만들어지는 문제에 this 포인터가 관여하면 특히나 놀라운 결과가 빚어진다.
std::shared_ptr들을 이용해서 Widget 객체들을 관리한다고 하자. 그리고 처리가 끝난 Widget 들을 다음과 같은 자료구조를 이용해서 추적한다고 하자.

std::vector<std::shared_ptr<Widget> > processedWidgets;

더 나아가서, Widget에 어떤 처리 작업을 수행하는 process라는 멤버 함수가 있다고 하자.

class Widget {
public:
    void process(void);
};

void Widget::process(void)
{
    // 이 Widget을 처리된 Widget들의 목록에 추가 잘못된 방식임!
    processedWidgets.emplace_back(this);    
}                                            

이 코드는 컴파일 되지만, 생 포인터(this)를 std::shared_ptr들의 컨테이너에 넘겨준다는 문제점이 있다.
이에 의해 std::shared_ptr 객체가 생성되면서, 피지칭 Widget 객체(즉 *this)에 대한 새 제어 블록이 만들어진다.

만일 멤버 함수 바깥에 이미 그 Widget을 가리키는 다른 std::shared_ptr들이 있다면, 필연적으로 미정의 행동이 발생한다.
이런 상황들에 대처하기 위해 std::shared_ptr API 에는, std::enable_shared_from_this라는 템플릿이 포함되어 있다.

std::shared_ptr로 관리하는 클래스를 작성할 때, 그 클래스의 this 포인터로부터 std::shared_ptr를 안전하게 생성하려면 이 템플릿을 그 클래스의 기반 클래스로 삼으면 된다.

class Widget : public std::enable_shared_from_this<Widget> {
public:
    void process(void);
};

파생 클래스가 파생 클래스 자신을 타입 인수로 해서 인스턴스화한 기반 클래스를 상속 받고 있다.
이러한 방식을 "묘하게 되풀이되는 템플릿 패턴 (Curiously Recurring Template Pattern, CRTP)" 라고 한다(이에 관해 더 알고싶다면 웹을 검색해 보길 바란다).

std::enable_shared_from_this는 현재 객체를 가리키는 std::shared_ptr를 생성하되 제어 블록이 중복되지 않도록 하는 멤버 함수 shared_from_this를 정의한다.

다음은 이를 사용한 Widget::process의 안전한 구현이다.

void Widget::process(void)
{
    // 현재 객체를 가리키는 std::shared_ptr를 processedWidgets에 추가
    processedWidgets.emplace_back(shared_from_this());
}

내부적으로 shared_from_this는 현재 객체에 대한 제어 블록을 조회하고, 그 제어 블록을 지칭하는 새 std::shared_ptr를
생성한다.

 

이는 곧, 현재 객체를 가리키는 기존의 std::shared_ptr가 반드시 존재한다는 가정에 해당한다.

그런 std::shared_ptr가 존재하지 않는다면 함수의 행동은 정의되지 않는다.
std::shared_ptr가 유효한 객체를 가리키기도 전에 클라이언트가 shared_from_this를 호출하는 일을 방지하기 위해, std::enable_shared_from_this를 상속받은 클래스는 자신의 생성자들을 private로 선언한다.

그리고 클라이언트가 객체를 생성할 수 있도록, std::shared_ptr를 돌려주는 팩터리 함수를 제공한다.

class Widget : public std::enable_shared_from_this<Widget> {
public:
    // 팩터리 함수; 인수들을 전용 생성자에 완벽하게 전달한다.
    template <typename... Ts>
    static std::shared_ptr<Widget> create(Ts&&... params);

    void process(void);
};

std::unique_ptr에 비해 std::shared_ptr가 더 많은 비용이 들지만, 그리 크지 않은 비용을 치르는 대신, 동적 할당 자원의 수명이 자동으로 관리된다는 이득이 생긴다.

독점 소유권으로도 충분하거나, 심지어는 반드시 충분하지는 않더라도 충분할 가능성이 있다면, std::unique_ptr가 더 나은 선택이다. std::unique_ptr의 성능은 생 포인터의 것에 가까우며, 언제라도 std::unique_ptr를 std::shared_ptr로 '업그레이드'하기도 쉽다.

하지만 반대로, std::shared_ptr를 std::unique_ptr로 바꾸는 것은 불가능하다.

댓글