KoreanFoodie's Study

Effective Modern C++ | 항목 19 : 소유권 공유 자원의 관리에는 std::shared_ptr 를 사용하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 19 : 소유권 공유 자원의 관리에는 std::shared_ptr 를 사용하라

GoldGiver 2022. 10. 26. 09:56

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 19 : 소유권 공유 자원의 관리에는 std::shared_ptr 를 사용하라

핵심 :

1. std::shared_ptr 는 임의의 공유 자원의 수명을 편리하게(쓰레기 수거에 맡길 때만큼이나) 관리할 수 있는 수단을 제공한다.
2. 대체로 std::shared_ptr 객체는 그 크기가 std::shared_ptr 객체의 두 배이며, 제어 블록에 관련된 추가 부담을 유발하며, 원자적 참조 횟수 조작을 요구한다.
3. 자원은 기본적으로 delete 를 통해 파괴되나, 커스텀 삭제자도 지원된다. 삭제자의 형식은 std::shared_ptr 의 형식에 아무런 영향도 미치지 않는다.
4. 생 포인터 형식의 변수로부터 std::shared_ptr 를 생성하는 일은 피해야 한다.


std::shared_ptr 는 참조 횟수(reference count) 를 통해, 한 객체를 여러 객체가 공동으로 관리한다. reference count 가 0 이 되면 가리키고 있던 객체의 자원이 파괴된다. std::shared_ptr 타입의 sp1 과 sp2 가 있다고 했을 때, "sp1 = sp2;" 를 수행하면 sp1 이 가리키던 자원에 대한 참조 횟수가 감소하고 sp2 가 가리키는 자원에 대한 참조 횟수는 증가하게 된다. 참조 횟수 관리는 성능에 다음과 같은 영향을 미친다.

  • std::shared_ptr 의 크기는 생 포인터의 두 배이다 : 자원의 참조 횟수를 가리키는 생 포인터도 저장해야 하기 때문
  • 참조 횟수를 담을 메모리를 반드시 동적으로 할당해야 함 : 객체 자체는 참조 횟수를 알지 못한다.
  • 참조 횟수의 증가와 감소가 반드시 원자적 연산이어야 한다.

단, 이동 생성과 이동 배정의 경우 참조 횟수가 증가하지 않는다.

std::unique_ptr 에서는 커스텀 삭제자의 형식이 스마트 포인터의 형식의 일부였지만, std::shared_ptr 의 설계가 더 유연하다.

// 커스텀 삭제자
auto loggingDel = [](Widget* pw)
{
  makeLogEntry(pw);
  delete pw;
};

...

// 삭제자의 형식이 포인터 형식의 일부
template<typename T>
std::unique_ptr<Widget, decltype(loggingDel)>
upw(new Widget, loggingDel);

// 삭제자의 형식이 포인터 형식의 일부가 아님
template<typename T>
std::shared_ptr<Widget>
spw(new Widget, loggingDel);

// 따라서 다음과 같은 구현도 가능하다
auto customDeleter1 = [](Widget *pw) { ... };
auto customDeleter2 = [](Widget *pw) { ... };

std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

// 다른 삭제자를 가진 shared_ptr 를 같은 컨테이너에 넣을 수 있다
// unique_ptr 은 삭제자별로 형식이 다르므로 같은 컨테이너에 넣을 수 없다!
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

std::unique_ptr 와의 또 다른 차이점은, 커스텀 삭제자를 지정해도 std::unique_ptr 객체의 크기가 변하지 않는다는 점이다. 삭제자와 무관하게 std::shared_ptr 의 객체의 크기는 항상 포인터 두 개 분량이다. 함수 객체를 커스텀 삭제자로 사용할 수 있다는 점과 함수 객체가 임의의 분량의 자료를 담을 수 있다는 점을 조합하면, 커스텀 삭제자가 얼마든지 커질 수 있다는 결론이 나온다.
다만, 커스텀 할당자와 커스텀 삭제자는 참조 횟수가 담긴 제어 블록(control block) 에 그 복사본이 담기게 된다. 이외에도 제어 블록에는 약한 횟수라고 부르는 이차적인 참조 횟수가 포함된다(그 밖에 기타 추가 자료 포함).

std::shared_ptr<T> 객체에 연관된 메모리 도식화

객체의 제어 블록은 그 객체를 가리키는 최초의 std::shared_ptr 가 생성될 때 설정된다. 제어 블록의 생성과 관련된 규칙은 다음과 같다.

  • std::make_shared 는 항상 제어 블록을 생성한다 : 이 함수는 공유 포인터가 가리킬 객체를 새로 생성함
  • 고유 소유권 포인터(즉, std::unique_ptr) 로부터 std::shared_ptr 를 생성하면 제어 블록이 생성된다
  • 생 포인터로 std::shared_ptr 생성자를 호출하면 제어 블록이 생성된다 : std::shared_ptr 나 std::weak_ptr 를 받는 std::shared_ptr 생성자들은 새 제어 블록을 만들지 않는다.

위의 규칙들을 참고해 봤을 때, 하나의 생 포인터로 여러개의 std::shared_ptr 를 생성하면 여러 개의 제어 블록이 만들어져 미정의 행동이 일어날 수 있다는 사실을 알 수 있다.

auto pw = new Widget;

std::shared_ptr<Widget> spw1(pw, loggingDel);
std::shared_ptr<Widget> spw2(pw, loggingDel);

위의 코드는 pw 의 파괴를 두 번 시도할 것이다.

만약 생 포인터를 넘겨주어야 하는 상황이라면 std::make_shared 를 사용해야 한다. 그런데 std::make_shared 는 커스텀 삭제자를 지정할 수 없으므로, 그런 경우에는 new 의 결과를 직접 전달하자.

std::shared_ptr<Widget> spw1(new Widget, loggingDel);

// spw2 는 spw1 과 동일한 제어 블록 사용 (복사 생성자 호출)
std::shared_ptr<Widget> spw2(spw1);


생 포인터를 std::shared_ptr 의 생성자의 인수로 사용할 때, this 가 관여하면 문제가 더 심각해진다. 아래와 같은 코드를 보자.

// 처리가 끝난 Widget 들을 추적하는 자료구조
std::vector<std::shared_ptr<Widget>> processWidgets;

class Widget {
public:
  ...
  void process()
  {
    ...
    processWidgets.emplace_back(this);
  }
};

위 코드에서 this 라는 이름의 생 포인터가 인자로 전달되면서 std::shared_ptr 가 만들어진다. 즉, 제어 블록이 새로 만들어 진다는 것인데, 만약 해당 Widget 을 가리키는 다른 std::shared_ptr 가 있다면 미정의 행동이 발생한다.
이런 상황을 방지하기 위해, std::enable_shared_from_this 라는 템플릿이 존재한다. 위의 경우처럼, 그 클래스의 this 포인터로부터 std::shared_ptr 를 안전하게 생성하려면 이 템플릿을 기반 클래스로 삼으면 된다.

class Widget: public std::enable_shared_from_this<Widget> {
public:
  ...
};
< > 안에 자기 자신을 넣는 패턴은 신기하게 반복되는 템플릿 패턴(curiously recurring template pattern: CRTP) 이라고 한다.

위의 템플릿은 현재 객체를 가리키는 std::shared_ptr 를 생성하되 제어 블록을 복제하지는 않는 멤버 함수 하나(shared_from_this)를 정의한다. 즉, 안전한 구현은 다음과 같다.

class Widget: public std::enable_shared_from_this<Widget> {
public:
  ...
  void process()
  {
    ...
    processWidgets.emplace_back(shared_from_this());
  }
};


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> creat(Ts&&... params);

  void process() { ... }
private:
  ... // 생성자들
};


std::shared_ptr 에 필요한 제어 블록의 크기는 몇 워드 정도이지만, 커스텀 삭제자나 할당자 때문에 그보다 더 커질 수 있다. 하지만 이는 std::shared_ptr 가 제공하는 기능에 비하면 합리적인 비용이며, 역참조의 비용도 생 포인터의 역참조 비용보다 크지 않다. 또한 std::unique_ptr 에서 std::shared_ptr 로의 '업그레이드'도 용이하다(그 역은 불가능).
std::shared_ptr 는 배열 관리가 불가능하다. std::shared_ptr 의 API 는 단일 객체를 가리키는 포인터를 염두에 두고 만들어 졌기 때문이다(std::shared_ptr<T[]> 같은 것은 없음)!

Comments