본문 바로가기
컴퓨터/Modern Effective C++ 정리

Modern Effective C++ item 19 : 공유자원은 std::shared_ptr

by 김짱쌤 2015. 4. 14.

공유하는 자원을 사용할 때 std::shared_ptr

std::unique_ptr은 사용권을 양도하는 식으로 자원을 관리했다. 하지만 실제로 우리가 할당된 자원(포인터)을 사용할 때 unique한 방식보다는 여러 곳에서 그 자원을 공유하는 방식으로 사용하게 된다. 하지만 처음 할당한 자원을 여기저기에서 참조하고 있으면 dangling 포인터와 같은 자원관리 문제가 발생하기 쉽다. Java에는 Garbage Collection이라는 자동 자원관리 방식을 지원해서 공유된 자원에 유저가 신경쓸 필요없이 편하게 프로그래밍이 가능하다. 대신 유저가 자원이 해제되는 시점에 대해서 알기 어렵다는 단점이 있다. C++ 11부터는 shared_ptr을 사용하여 두가지 목표(자동 자원관리, 예측 가능한 해제시점)를 모두 달성할 수 있게 해준다.

shared_ptr의 동작 방식

shared_ptr은 이름처럼 할당된 자원을 소유하지 않고 그저 가리키고 있는 포인터이다. 대신 자원이 더 이상 필요하지 않은 적절한 시점을 파악하고 알아서 자원을 해제해주는 방식으로 자원을 관리한다. garbage collection의 경우 내부적인 복잡한 규칙에 따라서 이 시점을 찾아 해제하지만, share_ptr은 참조횟수를 추적하여 참조횟수가 0이 되면 삭제하는 단순한 룰로 동작한다.(대신 여러 민감한 이슈들(순환 참조등)은 유저가 알아서 처리해야하겠지만...)

shared_ptr이 참조 횟수를 관리하는 방법은 다음과 같다. shared_ptr의 생성자(default or 복사)가 호출되면 가리키는 object의 참조 횟수를 증가시킨다. 소멸자가 호출되면 가리키는 object의 참조횟수를 감소시킨다. 복사 할당자가 호출되면, sp1 = sp2; 이 상황에서 sp1가 가리키던 object의 참조 횟수는 감소되고 sp2가 가리키는 object의 참조 횟수는 증가한다. move 생성/할당에 대해서는 기존의 포인터를 제거하고 그대로 이전하는 것이므로 참조횟수를 변경시키지 않는다. 참조 횟수 증가 감소는 atomic한 연산으로 동작해야한다. 참조횟수가 0이 되는 순간 자원을 해제해야하는데 참조횟수를 증가하고 감소시키는 연산이 경쟁상태에 돌입하게 된다면, shared_ptr이 가리키는 객체의 상태가 undefine될 것이기 때문이다.

앞에서 말한것처럼 shared_ptr은 일반 raw pointer와 달리 참조 횟수를 관리해야한다. 참조 횟수는 같은 객체를 가르키는 shared_ptr끼리 공유하는 자원이기 때문에 참조 횟수를 관리하는 객체는 하나의 shared_ptr에 있을 수는 없다. 그래서 shared_ptr이 다루는 참조 횟수는 별개로 할당된 객체에게 관리되어야한다. 참조 횟수를 관리하는 객체의 자원도 참조횟수가 0이 되면 share_ptr이 가리키는 객체의 자원과 함께 해제될 것이므로 자원관리의 문제는 없다. 어쨌든 shared_ptr은 가리키는 대상의 주소 뿐만아니라 참조 횟수를 관리하는 객체의 주소도 가지고 있어야한다. 그래서 가리키는 객체의 포인터 + 참조횟수 관리 객체 포인터 해서 2배의 크기를 갖는다. (그래봤자 8바이트 in 32bit 지만)

정확히 말하면 참조 횟수'도' 관리하는 객체로 표현해야하겠다. 이 객체는 일반 참조 횟수뿐 아니라, weak 참조 횟수(item 21에서 설명), unique_ptr에서 봤던 custom deleter(item 18참조) 및 allocator 그리고 기타등등에 해당하는 정보를 가지고 있다. 그래서 shared_ptr은 unique_ptr 처럼 custom deleter에 따라서 새로운 타입을 만들어낼 필요가 없다.

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

std::unique_ptr<Widget, delctype(loggingDel)>   //deleter가 ptr 타입의 부분이된다.
                  upw( new Widget, loggingDel);

std::shared_ptr<Widget> spw( new Widget, loggingDel); //deleter랑 상관없는 ptr 타입

deleter가 달라도 타입이 같다는 것은 매우 큰 강점이다. 우선은 쓰기가 쉽고(^^), 같은 컨테이너(벡터, 리스트)에 같이 담을 수도 있고, 하나의 패러미터 타입에 전달할 수도 있다.

auto customDeleter1 = [](Widget* pw){...} //서로 다른 deleter 생성
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}; //같은 벡터에 담을수도 있고

void func(std::shared_ptr<Widget> arg);
func(pw1); //함수의 인자로 똑같이 전달할 수 있다.
func(pw2);

item18에서 말한것 처럼 만약 람다로 이루어진 deleter가 엄청나게 많은 state을 갖게 된 경우를 생각해보면, unique_ptr은 점점 무거워 질 것이다. 하지만 shared_ptr은 이런 deleter들을 따로 갖고 있는것이 아니라 control block에 저장해 두기 때문에, 용량이 늘어나지도 않는다.

참조 횟수를 관리하는 객체 Control Block

control block 덕분에 share_ptr은 쉽고 간단하게 사용할 수가 있었다. 실제로 control block은 share_ptr이 가리키는 객체의 자원관리에 있어서도 많은 역할을 한다. 사실상 shared_ptr이 아니라 이 객체가 할당한 자원을 관리한다고 봐도 무방할 것이다. 그렇다면 자원할당된 하나의 객체에 하나의 control block만 있는 것이 이상적이다. 이제 이 객체를 언제 생성하느냐에 대한 문제로 넘어가게 된다. 원칙적으로 shared_ptr에 의해 특정 객체가 처음 referencing 되었을 때 이 객체를 생성해야할 것이다. shared_ptr은 다음의 룰에 따라서 control block을 생성한다.

  • std::make_shared (item 21에서 자세히다룸)으로 shared_ptr을 생성할 때마다 control block이 생성된다. 이 함수는 아직 공유되지 않은 특정 자원(객체)을 공유하겠다고 선언하는 것과 같다. shared_ptr이 이 함수의 생성만으로 만들어지면 좋겠지만 현실은 그렇지 않기에 다음의 룰들이 추가된다.

  • unique한 소유권이 보장된 포인터(ex: unique_ptr, auto_ptr)로 부터 shared_ptr이 생성된 경우 control block이 생성된다. 위의 내용과 다르지 않다. 아직 공유되지 않았다는것이 확실하므로, 이제부터 공유 자원으로 등록하는 의미에서 control block을 새로 만들어 낸다.

  • raw pointer로 shared_ptr이 만들어졌을 때 control block을 생성한다. 이 조건에는 해당 raw pointer가 다른 shared_ptr 또는 weak_ptr(item 20에서 설명)에 의해서 따로 공유된 적이 없다는 전제가 깔려있다. 이미 공유된 raw pointer를 다시 새로운 control block으로 공유한다면, 같은 포인터에 대해서 여러개의 control block이 생기는 불상사가 발생할 것이다. 그러므로 이미 공유된 자원은 raw pointer가 아니라 반드시 shared_ptr 또는 weak_ptr로 다루어야 할 것이다.

중복된 Control Block을 피하는 방법

세 번째 룰은 매우 위태위태해 보인다. 이 룰에 의해서 같은 자원(raw 포인터)에 대해 다수의 control block이 생성된다면 같은 자원에대해 레퍼런스 카운터가 별도로 동작하고, 그래서 각각 0이 된 시점이 달라 별도로 해제하고, 그러면 댕글링 포인터문제가 발생할 수밖에 없다. 쉬운 예시를 통해 문제가 발생하는 경우를 알아보자.

//자원할당된 raw pointer
auto pw = new Widget; 
...
//raw로 만드니 control block도 같이 할당 
std::shared_ptr<Widget> spw1(pw, loggingDel); 
... 
//raw로 만드니 control block도 같이 할당2
std::shared_ptr<Widget> spw2(pw, loggingDel);

위 예제만 보면 문제가 바로 보이지만, 중간에 코드가 길다면 누구나 쉽게 할 수 있는 실수이다. 그러니까 shared_ptr을 생성할때 가능하면 raw pointer를 쓰지 않는게 좋다. 가장 좋은 방법은 std::make_shared를 명시적으로 사용하는 것이다. 하지만 make_shared는 custom deleter를 지정할 수 없다는 문제가 있다. 위 예제에서 std::make_shared를 쓸 수 없는 이유가 이것이다. 그러면 같은 raw pointer를 다시 사용할 수 없게 만들어 버리는 방법도 있다.

//생성자를 사용해서 바로 shared_ptr을 만들자!
std::shared_ptr<Widget> spw1(new Widget(), loggingDel); 
...
//raw pointer대신 shared_ptr로 생성 (컨트롤 블록 만들어지지 않는다.)
std::shared_ptr<Widget> spw2(spw1); 

shared_ptr에서 자원을 중복해서 공유하는 많은 케이스는 this 포인터를 shared_ptr로 만드는 경우에서 발생한다. this도 분명한 raw Pointer이므로 this로 shared_ptr을 만들면 새로운 control block이 생겨난다. 하지만 외부에서 해당 객체를 shared_ptr로 만드는 코드가 있다면 여기서 문제가 발생한다. 이것은 한눈에 알아보기 쉽지 않기때문에, 찾기 어려운 버그가 될 수 있다. 다음의 예제를 보면서 더 이야기를 해보겠다.

//작업 완료된 위젯들을 저장하는 벡터
std::vector<std::shared_ptr<Widget>> processedWidget; 

class Widget{
public:
   ...
   void process(){
      ...
      //작업 끝나고 벡터에 this를 집어넣는다.
      //이러면 this가 shared_ptr<Widget>으로 변환되어 저장. 
      //raw pointer로 shared_ptr을 생성하므로 control block이 할당
      //이 함수 호출될 때마다 새로운 블럭이 생성되는 미친 코드
      //한번만 호출되는 것이 보장된다고 해도 외부에서 이 객체를 shared_ptr로 만들면 문제발생
      processedWidgets.emplace_back(this);
   }
}

std::shared_ptr을 만든 사람들은 이런 문제에대한 예방책을 미리 마련해 두었다. 이름이 좀 길긴하지만, std::enable_shared_from_this를 사용하면 이 문제를 미연에 방지할 수 있다. std::enable_shared_from_this를 상속받은 클래스는, 그 이름처럼 this를 가지고 shared_ptr을 안전하게 만들 수 있는 멤버함수를 상속받는다. shared_from_this()를 호출하게 되면 this를 가지고 shared_ptr를 생성할때, 컨트롤 블록을 새로 생성하지 않고, 기존의 컨트롤 블록을 가진 새로운 shared_ptr을 생성하여 반환한다.

class Widget : public std::enable_shared_from_this<Widget>
{
public:
   ...
   void process()
   {
      ...
      //this의 shared_ptr을 받아서 사용가능!?
      processedWidgets.emplace_back(shared_from_this());
   }
}

이 해결방안에는 숨겨진 전제가 있다. this로 shared_ptr을 한번은 만들어서 컨트롤 블록을 생성해놔야 된다는 것이다. 만약 this에 대한 컨트롤 블록이 없다면 shared_from_this()함수는 예외를 throw하거나 정의되지 않은 동작을 수행할 것이다. 이런 이유에서 enable_shared_from_this를 상속받는 클래스는 보통 생성자 대신 생성자 역할을 하는 함수를 만들고 생성자를 호출한뒤에 shared_ptr로 만들어 컨트롤 블록등 미리 등록하는 방법을 사용한다.

class Widget : public std::enable_shared_from_this<Widget>
{
public:
   //생성자에게 perfect-forwarding을 하는 팩토리 함수
   //그리고 this의 shared_ptr을 리턴하여 
   //인스턴스가 각각 반드시 하나의 컨트롤 블럭을 갖을 수 있도록
   template<typename ... Ts>
   static std::share_ptr<Widgetr> create(Ts&& ... params);
   ...
   void process()
   {
      ...
      //완전 문제없이 this의 shared_ptr을 받아서 사용가능!
      processedWidgets.emplace_back(shared_from_this());
   }
private:
   Widget();    //생성자를 private로 만들어서 외부에서 접근불가능하게 만든다.
}

shared_ptr은 비싸다?

지금까지 shared_ptr에 대한 여러 내용을 알아보았는데, shared_ptr을 운영하는데 필요한 자원이 적지 않아보인다. 우선 control block에 들어가는 수많은 정보를 저장하려면 많은 메모리가 사용될 것같다. 그리고 control block이 하는 일들을 구현하기 위한 atomic 연산들과 제대로 해제하기 위해서 가상함수들의 비용이 만만치 않아보인다. 하지만 scott meyers가 말하길 shared_ptr은 모든 자원관리에 대한 해답은 아니지만, 그것이 제공하는 편의에 대해서 매우 정직한, 이유있는 비용을 사용한다고 한다.

우선 default 소멸자나 할당자를 사용하는 shared_ptr을 make_shared로 할당하는 경우(대부분의 경우), control block은 3 word의 크기면 충분하고, 컨트롤 블럭을 할당하는데 드는 비용은 거의 공짜다.(이후 item 21에서 자세하게 설명한다). 그릐고 shared_ptr을 역참조하는데 드는 비용도 기존의 raw pointer와 동일하다. 참조 횟수를 관리하는 두개의 atomic 연산의 경우 분명 non-atomic한 연산보다는 비싸겠지만, 각각의 동작이 하나의 CPU 명령어(instruction)에 매핑되어서 매우 빠르게 동작할 수 있다. 가상함수의 경우 오직 소멸자 호출에서 한번씩만 사용되므로, shared_ptr 한개당 한번씩만 호출된다고 생각하면 그렇게 큰 비용이 든다고 할 수 없다.

이런 비용을 지불해서 자동적으로 관리되는 자원 체계를 얻는 것은 분명한 이득일 것이다. 대부분의 경우 shared_ptr을 사용하는 것이 직접 자원을 관리하는 것보다 훨씬 선호된다. 그렇다고 마냥 shared_ptr을 사용하라는 말은 아니다. 만약 공유되는 자원이 필요없는 경우라면 unique_ptr을 사용하는 것이 더 좋은 선택이 된다. unique_ptr의 비용은 거의 raw pointer와 다를 바 없기 때문이다. 그리고 unique_ptr은 shared_ptr로 변환가능하지만, 그 반대는 불가능하다.

unique_ptr과는 달리 shared_ptr은 배열을 다룰 수 없다. std::shared_ptr<T[]> 는 없는 타입이다. 물론 배열의 첫번째 포인터를 shared_ptr로 만들고 deleter를 delete[]로 설정하는 방법으로 사용할수도 있겠다. 하지만 shared_ptr은 []연산자를 지원하지 않기 때문에, 이런 배열을 사용하는 것은 이상한 문법이 될것이다. 대신 c++ 11에서 지원하는 배열타입들을 shared_ptr로 관리하는 것이 더욱 현명한 방법이 될 것이다.