Post List

2015년 4월 12일 일요일

item 19. 공유자원 관리엔 std::shared_ptr을 사용하자. (Study PT Version)

작가
Meyers, Scott
출판
O'ReillyMedia
발매
2014.12.12
평점









Effective Modern C++ - Scott Meyers
Item 19: Use std::shared_ptr for shared-ownership resource management.

item 19. 공유자원 관리엔 std::shared_ptr을 사용하자.

* Intro

가비지 컬렉터(garbage collection)를 사용하면 자원관리를 개발자가 할 필요없이 스스로 처리해준다.
하지만 그만큼 전체적인 프로그램 성능을 개발자 마음대로 하지 못한다.
C++ 개발자들은 생성된 자원을 원하는 시점에 해제하는 것을 선호하는 편이다.
이 두가지를 모두 만족 시킬수 있는 방법은 없을까 ?
가비지 컬렉터 같이 자동으로 동작하면서 C++ 소멸자(Destructor) 같이 원하는 시점에 자원을 해제할 수 있는 방법은 없을까 ?

C++ 11의 std::shared_ptr이 이 2가지를 모두 해결해준다.
std::shared_ptr이 직접적으로 특정 개체(Object)를 소유하지는 않는다.
여러 개의 std::shared_ptr이 하나의 개체를 공유하고 있다가,
그 중 마지막을 관리하던 std::shared_ptr이 더 이상 개체를 가리키지 않을 때 해당 자원은 해제된다.
(해제된다던지, 다른 개체를 가리키게 될때)
가비지 컬렉터와 마찬가지로 개체의 수명에 대해서 신경 쓸 필요가 없으며,
소멸자와 같이 개체를 원하는 시점에 해제 할 수 있다.

* Reference Count

std::shared_ptr참조카운트(reference count) 값으로 자신이 마지막 포인터 인지 판단한다.
그 값은 해당 개체를 참조하고 있는 std::shared_ptr의 개수를 나타낸다.
std::shared_ptr의 생성자는 참조카운트를 증가시키고 소멸자는 감소시킨다.
복사대입 연산자는 둘 다 수행한다.

서로 다른 개체를 가리키고 있는 std::shared_ptr SP1SP2가 있는 경우
SP1 = SP2;가 실행되면,
SP1은 SP2의 개체를 가리키게 된다.
이때 SP1이 가리키던 개체의 참조카운트는 감소하고, SP2개체의 참조카운트는 증가한다.
std::shared_ptr이 참조카운트 감소를 수행 후 값이 0이 되면 해당 개체는 해제한다.

하지만, 참조카운트는 성능상의 이슈가 있다.

std::shared_ptr은 pointer보다 2배의 크기를 차지한다 : 개체에 대한 pointer + 참조카운터에 대한 pointer

- 참조카운트는 메모리를 동적으로 할당해야 한다.
  참조카운트는 해당 개체와 연관이 있지만, 그 개체 입장에서는 참조카운트가 필요없다.
  따라서 개체에는 참조카운트가 저장되지 않는다.
  std::make_shared를  사용하여 std::shared_ptr를 만들때는 동적할당 cost를 피할 수 있다고 item 21에서 설명하겠지만,
std::make_shared를 사용할 수 없는 경우도 있다.

- 참조카운트의 증가, 감소 연산은 atomic해야 한다.
  서로 다른 쓰레드에서 동시에 참조카운트를 제어 할 수도 있기 때문이다.
  참조카운트의 크기가 word 밖에 되진 않지만, 그래도 atomic 연산은 non-atomic 연산보다 느리다.

std::shared_ptr 생성자는 대개의 경우 참조카운트를 증가시킨다.
대개 ? 그럼 아닌 경우도 있나 ? 물론 있다.
NULL을 가리키고 있던 std::shared_ptr을 다른 std::shared_ptr로 Move 생성하는 경우,
원래도 참조카운트가 없었고, 생성자로 다른 std::shared_ptr을 만들면서도 참조카운트를 증가시키지 않았다.
다른 정상적인 std::shared_ptr을 Move 생성하는 경우도 마찬가지로 참조카운트를 증가시키진 않는다.
std::shared_ptr을 Move 하는 것은 Copy 하는 것보다 빠르다. 왜냐면 참조카운트를 증가시킬 필요가 없기 때문이다.
생성자뿐만 아니라 대입도 마찬가지이다.
Copy 생성자보다는 Move 생성자가 더 빠르고, Copy 대입보다는 Move 대입이 더 빠르다.

* Custom Deleter

std::unique_ptr처럼 (item 18  참조) std::shared_ptr도 개체를 해제할 때는 delete를 사용한다.
물론 custom deleter도 지원하는데, std::unique_ptr과는 다른 점이 있다.
std::unique_ptr는 custom deleter 가 타입의 일부였는데, std::shared_ptr는  아니다.

auto LogDel = [](T *pw)
{
    makeLog(pw);
    delete pw;
};

std::unique_ptr<T, decltype(LogDel)> UPW(new T, LogDel); // deleter type is     part of ptr type
std::shared_ptr<T> SPW(new T, LogDel);                   // deleter type is not part of ptr type

std::shared_ptr은 디자인이 훨씬 더 유연하다.
서로 다른 deleter를 가진 std::shared_ptr<T>가 가능하다.

auto CustomDeleter1 = [](T* pw) { };
auto CustomDeleter2 = [](T* pw) { };

std::shared_ptr<T> SP1(new T, CustomDeleter1);
std::shared_ptr<T> SP2(new T, CustomDeleter2);

SP1와 SP2는 같은 타입이라서 하나의 컨테이너에 들어 갈 수 있다.

std::vector<std::shared_ptr<T>> VPW{ SP1, SP2 };

서로 대입도 가능하고, std::shared_ptr<T>를 매개변수로 가지는 함수에 전달도 가능하다.
서로 다른 deleter를 가진 std::unique_ptr에서는 안된다. 왜냐면 deleter가 타입의 일부이기 때문이다.

std::unique_ptr와 달리 std::shared_ptr는 custom deleter가 std::shared_ptr 개체 크기를 변화시키지 않는다.
std::shared_ptr의 크기는 custom deleter 여부에 상관없이 무조건 pointer 2개의 크기이다.
훌륭하다. 하지만 그렇게만 생각하고 편안해하면 안된다.
custom deleter는 함수 개체(function object)이다.
함수 개체는 임의의 데이터를 포함 할 수도 있다. 즉, 크기가 커질 수도 있다.
std::shared_ptr가 어떤 메모리도 사용하지 않고 custom deleter를 참조할 수 있을까 ?

당연히 불가능하다.
메모리를 어딘가엔 사용해야 한다.
다만, std::shared_ptr 개체의 일부가 아니라는 거다.
그럼 어디에 있을까 ?

* Control Block

앞에 참조카운트에 대해서 이야기 한 적이 있다.
참조카운트는 제어블록(control block)이라 불리는 큰 자료 구조의 구성원 중 하나이다.
제어블록에는 참조카운트와 Weak Count (item 21 참조)가 있다.
그리고 필요한 경우 추가적인 데이터를 포함 할 수있다.
custom deleter가 있는 경우 그것의 복사본을 가지고 있으며,
custom allocator가 있는 경우 그것의 복사본을 가지고 있다.



제어블록은 std::shared_ptr에 의해서 만들어진다.
당연히 다른 std::shared_ptr가 가리키는 개체로 부터 만들어지는 std::shared_ptr은 제어블록을 만들지 않는다.
제어블록 생성에는 다음의 규칙들이 적용된다.

std::make_shared는 항상 제어블록을 만든다. (item 21 참조)
  해당 함수는 새로운 개체를 생성하는데, 그때는 제어블록이 없는 상태이다.

- 독점(unique-ownership) pointer (std::unique_ptrstd::auto_ptr)로 부터 std::shared_ptr이 생성될 때 제어블록을 만든다.
  독점 pointer에는 제어블록이 없다.
  (std::shared_ptr이 생성되면서 std::unique_ptr은 NULL이 된다.)

- 원시 pointer로 std::shared_ptr이 생성될 때 제어블록을 만든다.
  이미 제어블록이 있는 개체로 부터 std::shared_ptr을 만들려면 (item 20 참조) std::weak_ptr나 std::shared_ptr을 생성자의 매개변수로 하여 만들어야 한다.
  스마트 포인터로 부터 std::shared_ptr을 만들 경우에는 필요한 제어블록도 같이 전달받게 된다.

위 규칙들에 의하면, 원시 pointer로부터 여러 개의 std::shared_ptr을 만들 수 있다.
그러면 제어블록도 여러개 만들어 질 것이고, 참조카운트도 여러 개가 될꺼고,
해당 개체를 여러 번 해제할 수 있다. 엥 ?
그니깐 아래와 같은 저런 code는 아주 bad, bad, bad 하다.

auto P = new T;                    // raw ptr
std::shared_ptr<T> SP1(P, LogDel); // create     control block for *P
std::shared_ptr<T> SP2(P, LogDel); // create 2nd control block for *P

원시 pointer P로 개체를 동적으로 생성하는 것은 안좋은 방법이다.
그건 현재 챕터 (Raw Pointer보다 Smart Pointer를 더 선호하자.)의 내용과 정반대되는 행동이다.
P를 생성하는건 정말 가증스러운 짓이다. 뭐 그래도 저게 제대로 동작안하는건 아니지만 말이다.

Raw Pointer P로부터 SP1이 만들어 졌다. 제어블록과 참조카운트도 같이 만들어졌다.
이까진 아무 문제없다.
어라 ? 그런데, P로부터 SP2가 만들어 졌다. 역시나 제어블록과 참조카운트도 만들어졌다.
이제 *P의 참조카운트는 2개가 되었다.
이 프로그램은 언젠간 참조카운트 2개가 다 0으로 될 것이다.
*P를 한번 해제한 후에 다른 std::shared_ptr로 부터 참조하면 어떻게 될까 ?
또, 2번째로 해제를 시도할땐 어떻게 될까 ?
결국 이상한 짓(undefined behavior)을 하게 될 것이다.

std::shared_ptr를 사용하려면 최소 2가지 정도는 꼭 지키자.

1. Raw Pointer로 부터 std::shared_ptr를 생성하지 말자.
   일반적인 방법은 std::make_shared를 사용하는 것인데, custom deleter를 사용할 경우는 std::make_shared를 사용 할 수가 없다.

2. 어쩔수 없이 Raw Pointer로부터 std::shared_ptr를 만들 경우 포인터를 변수에 담지 말고 바로 new로 생성한 R-Value를 쓰도록 하자.

std::shared_ptr<T> SP(new T, LogDel); // direct use of new

이렇게 사용하면 동일한 Raw Pointer로 부터 다른 std::shared_ptr를 만들 수가 없다.
다른 std::shared_ptr에서 공유하고자 한다면 SP를 인자로 하여 만들면 된다. (복사 생성자)
그럼 아무런 문제가 없다.

std::shared_ptr<T> SP2(SP); // SP2 uses same control block as SP

* std::enable_shared_from_this<T>

프로그램에서  개체를 관리하는 여러개의 std::shared_ptr을 사용한다고 하자.
그리고 처리된 T의 이력을 저장하는 자료구조를 유지해야 한다고 할 때,
아래와 같은 code를 생각해 볼 수 있다.

std::vector<std::shared_ptr<T>> T_History;

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

void T::process()
{
T_History.emplace_back(this); // this is wrong
}

주석에 보면 틀렸다고 써놨다.
emplace_back을 잘못 썼다는게 아니라 this가 잘못된 것이다.
위 code는 제대로 컴파일 된다.
하지만 std::shared_ptr로 this라는 Raw Pointer가 계속 전달 된다.
즉, *this에 대한 제어블록이 계속해서 만들어 질 것이다.
해당 T개체에 대해 만들어진 std::shared_ptr이 없다면 뭐 그리 큰 문제는 아니게 보이지만, 그래도 좋은 방법은 아니다.

그래서 std::shared_ptr API에는 이런 상황에 대비한 기능을 포함하고 있다.
바로 std::enable_shared_from_this<T>라는 template class 이다. (이름이 좀 이상하긴 하다.)
사용하자고 하는 class에서 이것을 상속받아서 사용하면 된다.

std::vector<std::shared_ptr<T>> T_History;

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

void T::process()
{
T_History.emplace_back(shared_from_this());
}

근데 모양이 좀 낮설다.
파생 클래스에서 파생 클래스의 템플릿 클래스를 상속받는다.
머리 아프게 생각하지 말자.
이 코드는 완전 이상없으며, 이렇게 쓰는 디자인 패턴이 이미 정리되어 있다.
The Curiously Recurring Template Pattern (CRTP) 이라고 한다. (자세히 알아보고 싶으면 검색 고고싱)

std::enable_shared_from_this<T>는 현재 개체로 부터 std::shared_ptr을 만들지만 제어블록을 복사하지 않는 멤버 함수를 가지고 있다.
그 함수가 바로 shared_from_this()이며 this라는 pointer로 부터 만들고 싶을 때 사용하면 된다.

shared_from_this()는 현재 게체의 제어블록을 찾아서 그것을 참조하는 std::shared_ptr을 새로 만든다.
그런데 이미 해당 개체의 std::shared_ptr이 있는 경우에만 정상적으로 동작한다.
그렇지 않다면 제어블록이 없는 상태여서 예외를 발생시킨다.

어 ? 그럼 std::shared_ptr이 만들어지기 전에 shared_from_this()를 실행하면 안되자나. 어떻하지 ?
디자인 패턴을 공부한 적이 있다면 팩토리 함수라는 말을 들어봤을 것이다.
그것을 이용하여 std::shared_ptr을 반환하도록 구현하면 된다.
(물론 생성자는 private에 선언해줘야 겠지.)

class T : public std::enable_shared_from_this<T>
{
public:
    template<typename... Ts>
    static std::shared_ptr<T> create(Ts&&... params);
private:
    template<typename... Ts>
    T(Ts&&... params);
};

* Cost of control block

제어블록의 크기는 기껏해야 few words 정도밖에 되지 않는다.
비록 custom deleter 와 allocator가 좀 더 크게 할 수는 있지만 신경안써도 될 정도이다.
제어블록의 구현은 생각보다 꽤나 정교하다.
상속도 사용되며, 심지어 가상 함수도 있다. (원 개체가 제대로 해제되게 하기 위해서 사용된다.)
std::shared_ptr를 사용하면 제어블록에서 가상함수를 사용하는 만큼 cost가 증가된다고 생각할 수도 있다.

- 동적으로 할당되는 제어블록
- 그래도 크기가 커 질수 있는 custom deleter와 allocator
- 가상 함수
- atomic 연산이 이루어져야하는 참조카운트

어휴~ 이거 std::shared_ptr를 쓰란 말인지 말란 말인지...
(물론 모든 자원관리문제에 std::shared_ptr이 최선책은 아니다.)
그래도 쓰자.

std::shared_ptr의 cost는 기능대비 꽤나 합리적이다.

일반적인 경우 (default deleter와 allocator를 사용하고, std::make_shared로 std::shared_ptr를 만들 경우) 제어블록은 3 word 크기 밖에 안되며 생성되는데 cost도 거의 없다.
(Pointer가 가리키는 개체에 대한 메모리 할당은 item 21에서 자세히 다룬다.)

std::shared_ptr를 역참조 하는거와 Raw Pointer를 역참조하는 cost는 같다.

참조카운트를 조작하는 경우 (복사생성자, 복사연산, 소멸자) 1,2개의 atomic 연산이 일어난다. 당연히 non-atomic연산보다는 느리겠지만, 기계어적인 관점에서 거의 1 cycle에 끝난다.

제어블록의 가상함수는 개체가 소멸될 때 딱 1번만 수행된다.

이정도 cost만 감안한다면 동적 할당되는 개체에 대한 수명관리를 자동으로 해준다.
대부분의 경우 직접 수명관리를 하는 것보다는 std::shared_ptr를 사용하게 더 바람직하다.

소유권이 독점되어야 하거나, 그렇게 될 가능성이 있는 경우는 std::unique_ptr이 더 좋은 선택이다.
std::unique_ptr의 성능은 Raw Pointer에 훨씬 더 가깝고,
std::unique_ptr에서 std::shared_ptr로 바꾸는 것이 가능하다.
그 반대는 안된다.
한번 std::shared_ptr으로 바뀌면 그 참조카운트가 1이라고 할지라도 std::unique_ptr로 바꿀수 없다.
std::shared_ptr는 죽을때까지 개체와 그 운명을 같이한다.

* Demerit  of std::shared_ptr

std::shared_ptr는 배열에 대해서는 제대로 동작하지 않는다.
std::unique_ptr와 다르게 std::shared_ptr API는 단일 개체에 대한 포인터로만 설계되어 있다.
std::shared_ptr<T[]> 이렇게는 사용못한다.

자신이 아주 똑똑하다고 생각하는 몇몇 개발자들은 또 delete []를 custom deleter로 정의해서라도 std::shared_ptr<T>가 배열의 포인터를 관리하도록 하려 하겠지.
컴파일은 되겠지만 정말 끔찍한 생각이다.
std::shared_ptr에는 []연산이 없다. 배열의 요소를 찾아갈때는 포인터의 연산으로 표현해야 한다.
그리고 std::shared_ptr는 단일 개체에 대한 derived-to-base 포인터 변환을 지원한다. 배열에 적용할 때는 시스템적으로 구멍이 있다.
(이런 이유로 std::unique_ptr<T[]> API는 변환을 금지시킨다.)

정말 중요한건데, C++11의 built-in 배열들 (std::arraystd::vectorstd::string)을 Smart Pointer로 선언하는 것은 나쁜 디자인이다.

Things to Remember

std::shared_ptr는 개체에 대한 수명관리를 garbase collection 만큼 편리하게 해준다.
std::unique_ptr에 비해 std::shared_ptr는 개체가 2배 더 크고, control block에 대한 오버해드가 발생하고, reference count에 대해서 atomic 연산을 해야 한다.
* default 자원 해제는 delete 지만 custom deleter도 지원한다. deleter의 type은  std::shared_ptr의 type에 영향을 미치지 않는다.
* Raw Pointer를 변수로 만들어서 std::shared_ptr로 만들지 마라.

* Slide


댓글 없음:

댓글 쓰기