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

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

by COCO1337 2020. 7. 13.

공유 포인터 즉 std::shared_ptr를 통해서 접근되는 객체의 수명은 공유포인터가 shared ownership 의미론을 통해서 관리한다. 특정한 하나의 std::shared_ptr이 객체를 소유하는 것이 아니다. 그 대신 모든 std::shared_ptr는 객체가 더 이상 필요하지 않게 된 시점에서 객체가 파괴됨을 보장하려 한다. 객체가 가리키던 마지막 std::shared_ptr가 객체를 더 이상 가리키지 않게 되면 자신이 가리키는 객체를 파괴한다.

이를 위해 std::shared_ptr는 자원에 연관된 값을 참조 횟수로서 제어블록에 담아 관리한다. 보통 생성시 참조 횟수를 증가시키며, 소멸자는 감소시킨다. 복사 배정 연산자는 둘 모두 수행한다. 이동 생성의 경우 기존 std::shared_ptr는 null이 되며, 새 std::shared_ptr는 참조 횟수를 증가시킬 필요가 없다.

참조 횟수 관리는 성능에 다음과 같은 영향을 미친다.

- std::shared_ptr의 크기는 raw pointer의 두배이다. (표준은 아니지만 대부분 raw pointer + 자원의 참조 횟수를 가리키는 raw pointer 형식이다)

- 참조 횟수를 담을 메모리를 반드시 동적으로 할당해야 한다. 객체에 연관된 것이지만 참조 횟수를 알지 못하기 때문에 동적으로 할당해야 한다. std::make_shared를 사용해서 동적할당을 피할 수 있지만 어떤 경우에도 참조 횟수는 동적으로 할당된 자료로서 저장된다.

- 참조 횟수의 증가와 감소가 반드시 원자적 연산이어야 한다. 여러 스레드가 동시에 읽고 쓰려 할 수 있기 때문이다.


std::shared_ptr은 std::unique_ptr처럼 delete를 기본적으로 사용한다. 커스텀 삭제자 또한 지원하지만 std::unique_ptr처럼 smart pointer의 형식이 아니다.

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);

auto customDeleter1 = [](Widget *pw){...};	// 서로 다른 삭제자
auto customDeleter2 = [](Widget *pw){...};

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

std::vector<std::shared_ptr<Widget>> vpw{pw1, pw2};	// 같은 형식이기 때문에 가능

std::unique_ptr과의 또 다른 차이점은 커스텀 삭제자를 지정해도 std::shared_ptr 객체의 크기는 변하지 않는다. std::shared_ptr의 삭제자에 필요한 추가 메모리는 힙에서 할당된다. std::shared_ptr객체 하나당 하나의 제어 블록이 존재하며 참조 횟수와 약한 횟수, 그외의 자료들을 담는데 사용한다. std::shared_ptr이 raw pointer 크기의 두배인 이유도 std::shared_ptr이 각각 T 객체와 제어블록을 가리키는 포인터로 이루어져 있기 때문이다.

객체의 제어블록은 그 객체를 가리키는 최초의 std::shared_ptr가 생성될 때 생성된다. 일반적으로 shared_ptr를 생성하는 코드에서 그 객체가 가리키는 다른std::shared_ptr가 이미 존재하는지 알아낼 수는 없지만 제어블록 생성 여부에 관해 다음 규칙들을 유추할 수 있다.

- std::make_shared는 항상 제어블록을 생성한다.

- 고유 소유권 포인터(std::unique_ptr, std::auto_ptr)로부터 std::shared_ptr객체를 생성하면 제어블록이 생성된다.

- raw pointer로 std::shared_ptr 생성자를 호출하면 제어블록이 생성된다.

이 규칙에 대한 결과로 하나의 raw pointer로 여러개의 std::shared_ptr를 생성하면 피지칭 객체에 여러개의 제어 블록이 만들어지므로 미정의 행동이 된다.

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

위의 예제대로라면 하나의 객체에 2개의 제어블록이 생성되게 되는데 최종적으로 *pw에 두번의 파괴가 시도된다. 첫번째에서 *pw가 파괴되고 나면 두번째 파괴는 미정의 행동의 원인이 된다. 그렇기 때문에 std::shared_ptr 생성자에 raw pointer를 넘겨주는 것은 피해야 한다. 대신 new의 결과를 직접 전달하거나 spw2를 생성하는 코드의 작성자가 spw1을 초기화 인수로 사용하는 식으로 사용할 수 있다.


또한 raw pointer 변수를 std::shared_ptr 생성자의 인수로 사용할 때 제어블록이 여러 개 만들어지는 문제에 this 포인터가 관여하면 문제가 생길 수 있다. 만약 this 포인터를 사용한다면 std::shared_ptr 객체가 생성될 때 피지칭 객체(*this)에 대한 새 제어블록이 만들어진다. 그 자체는 괜찮지만 함수 바깥에 이미 그 객체를 가리키는 다른 std::shared_ptr가 있다면 미정의 행동이 발생한다.

이런 상황을 위해 std::enable_shared_from_this라는 템플릿이 있다. std::shared_ptr로 관리하는 클래스를 작성할 때 그 클래스의 this 포인터로부터 std::shared_ptr를 안전하게 생성하려면 이 템플릿을 base class로 삼으면 된다.

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

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

내부적으로 shared_from_this는 현재 객체에 대한 제어 블록을 조회하고 그 제어블록을 지칭하는 새 std::shared_ptr를 생성한다. 이는 이미 제어블록이 있다는 가정하에 이루어지며 만약 존재하지 않다면 함수의 행동은 정의되지 않는다. 그것을 방지하기 위해 자신의 생성자는 private으로 선언하고 클래스가 객체를 생성할 수 있도록 팩터리 함수를 제공한다.


기본 삭제자와 기본 할당자를 쓰는 std::shared_ptr를 std::make_shared로 생성하는 경우 제어블록의 크기는 워드 세개 정도이고 할당은 본질적으로 무료이며 std::shared_ptr의 역참조 비용은 raw pointer의 역참조 비용보다 크지 않다. 참조 횟수 조작을 하는 연산에서 원자적 연산 한두개가 더 소비되지만 명령 하나 차이이다. 또한 제어 블록을 위한 가상 함수 메커니즘은 일반적으로 std::shared_ptr가 관리하는 객체당 한번만 객체가 파괴될 때 쓰인다.

이런 코스트가 있지만 동적 할당 자원의 수명이 자동으로 관리되며 소유권 공동 객체의 수명을 직접 관리하는것보다 std::shared_ptr를 사용하는 것이 나은 선택이다. 만약 독점 소유권으로 충분하다면 std::unique_ptr이 더 나은 선택이다

std::unique_ptr은 좀더 raw pointer에 가깝고 std::shared_ptr로 변환이 쉽다.

std::shared_ptr는 단일 객체를 가리키는 포인터만 염두에 두고 설계되었기 때문에 배열에 적용하기보다는 내장배열에대한 다양한 대안들을 사용하는 것이 낫다.


Conclusion

- std::shared_ptr는 임의의 공유 자원의 수명을 편리하게 관리할 수 있는 수단을 제공한다(GC와 비슷)

- 대체로 std::shared_ptr 객체는 그 크기가 std::unique_ptr 객체의 두 배이며, 제어 블록에 관련된 추가 부담을 유발하고 원자적 참조 횟수 조작을 요구한다.

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

- raw pointer 형식의 변수로부터 std::shared_ptr를 생성하는 일은 피해야 한다.


Reference

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

반응형

댓글