본문 바로가기
C,C++/C++

[C++] 스마트 포인터에 대해 알아보자(2) | 공유 포인터 | std::shared_ptr

by woohyeon 2020. 3. 21.
반응형

유니크 포인터와 참조 카운팅에 대해 모른다면 다음 포스팅을 보고오는 걸 추천한다.

참조 카운팅
https://woo-dev.tistory.com/61

유니크 포인터
https://woo-dev.tistory.com/110


[유니크 포인터의 단점]
먼저 저번에 다루었던 유니크 포인터는 원시 포인터에 대한 소유권을 다른 유니크 포인터와 공유할 수 없었다. 즉 복사 생성과 복사 대입 연산이 불가능하여, 공유를 허용하지 않았다. 이러한 원칙들로 인해 유니크 포인터는 사용자가 메모리 관리에 대해 실수할 여지를 막아주었다.

하지만 함수에 인자로 포인터를 넘겨야 할 경우 get을 통해 원시 포인터를 반환하는 방식으로 넘겨야 했다. 그러나 이는 외부에서 마음대로 지워버릴 수 있기 때문에 위험했으며, 원시 포인터를 반환하여 다른 곳에서 사용한다는 것 자체가 스마트 포인터를 사용하는 이점을 감소시키는 것이기도 했다. 또는 공유가 아닌 이동(move)를 통해 자신의 소유권을 포기하고 다른 유니크 포인터에 넘기는 것도 하나의 방법이였다. 하지만 포인터를 함수에 전달하는 경우는 자주 발생할 수 있기 때문에 제대로 된 대책이 필요했으며, 이러한 용도에 적합한 스마트 포인터가 공유 포인터이다. 

[std::shared_ptr]
공유 포인터는 스마트 포인터의 한 종류로 유니크 포인터와 달리 소유권을 다른 공유 포인터와 공유할 수 있으며, 참조 카운팅 기반으로 동작하는 스마트 포인터이다. 공유 포인터는 멤버 변수로 원시 포인터를 가리키는 포인터 1개참조 카운팅 관리를 위한 포인터 1개를 가진다.  아래 그림에서 _Ptr이 전자이고 _Rep가 후자이다. 공유 포인터는_Ptr 뿐만 아니라 _Rep 또한 서로 공유한다. 

 

소유권을 공유하면서 자체적으로 메모리를 관리하는 원리는 간단하다. 먼저 유니크 포인터와 동일한 방식으로 공유 포인터 개체를 생성한다. 그러면 내부적으로 자신에게 바인딩 된 원시 포인터에 대해 참조 카운트를 1 증가시킨다. 이는 해당 원시 포인터를 가지는 공유 포인터 개체가 1개 있다는 뜻이다. 아래 그림에 1 strong ref(강한 참조)가 참조 카운트 수이다. 참조엔 강한 참조 약한 참조 가 있는데 해당 포스팅에선 강한 참조만 다루고 약한 참조는 weak_ptr에서 소개한다.

 

[shared_ptr 개체 생성하기]
C++ 14 이상이라면 도우미 함수인 std::make_shared 함수를 통해 공유 포인터 개체를 생성한다. C++ 11 이하라면 new 방식으로 생성한다. shared_ptr 클래스는 <memory> 헤더에 정의되어 있다.  new로 전달하는 방식은 지양한다. (유니크 포인터 포스팅 참고)

#include <memory>

class Foo 
{
public:
   Foo(const char* name, unsigned int age);
   ...
};

void MakeSharedPointer()
{
   std::shared_ptr<Foo> s1 = std::make_shared<Foo>("woohyeon", 25); // C++ 14 ~
   std::shared_ptr<Foo> s2(new Foo("woohyeon", 25));                // C++ 11
}

 

[원시 포인터에 접근하기]
원시 포인터의 메모리에 접근하여 값을 read/write 하거나 멤버에 접근하는 방식은 유니크 포인터와 동일하다. 내부적으로 역참조 연산자  * 가 오버로딩 되어 있으며, 멤버에 접근하기 위한  ->  연산자 또한 오버로딩되어 있다.  shared_ptr 자체의 멤버 함수를 호출하기 위해선 평소처럼  . 를 사용한다.

class Foo
{
public:
   Foo(const char* name, unsigned int age) {}
   void DoSomething();
   ...
};

void MakeUniqueExample()
{
   std::shared_ptr<Foo> f = std::make_shared<Foo>("foo", 14);
   
   f->DoSomthing(); // f가 소유한 원시 포인터의 멤버에 접근
   f.reset();       // 공유 포인터의 멤버 함수 
}

 

[원시 포인터 공유]
만약 다음과 같이 공유 포인터 개체를 새 공유 포인터 개체에 복사 생성하였다고 생각해보자. 참고로 공유 포인터는 공유를 허용하므로 당연히 복사 생성과 복사 대입 연산 모두 허용한다. 게다가 원시 포인터의 주소 자체를 공유해야 하기 때문에 깊은 복사가 아닌 얕은 복사를 한다. 따라서 깊은 복사보다 상대적으로 오버헤드가 적다. 

void SharedPtrExample()
{
   std::shared_ptr<int> s1 = std::make_shared<int>(10);
   std::shared_ptr<int> s2 = s1;
   ...
}

 

s2는 이제 내부적으로 s1에 바인딩된 원시 포인터를 가리키게 된다.  해당 원시 포인터를 소유하는 개체가 늘었으므로 참조 카운트를 1 증가시킨다. 

강한 참조 횟수: 2

 

쉽게 이해하기 위해 그림을 보자.
아래 그림을 보면 Diagram 1에서 p1이 MyClass와 참조 카운트에 관한 컨트롤 블럭을 가리킨다. 만약 Diagram 2처럼 MyClass를 참조하는 대상이 증가하면(p2),  p1과 동일한 MyClass를 가리키며 참조 카운트 또한 증가된다. 그리고 참조 카운트 컨트롤 블럭 또한 따로 복사되어 저장되는 것이 아닌 동일한 주소를 가리킴으로써 공유한다.  

https://docs.microsoft.com/ko-kr/cpp/cpp/how-to-create-and-use-shared-ptr-instances?view=vs-2019

 

[함수의 인자로 공유 포인터 전달]
shared_ptr을 함수로 전달하는 몇가지 경우를 살펴보자.

  • 값 형식으로 전달: 인자를 call by value로 전달하면 개체에 대한 복사생성자를 호출하므로 명백한 개체의 복사이며 공유이다. 즉 원시 포인터를 가리키는 개체가 1개 증가한다. 따라서 참조 카운트가 1 증가하게 된다. 값의 전달로 인해 생기는 오버헤드를 기억하자. 기본적으로 복사 생성자에 대한 오버헤드참조 카운트 조작에 대한 오버헤드가 있다. 물론 둘다 적은 오버헤드지만 전달하는 개체 수가 많을 경우를 생각하고 적절하게 사용하자.
  • 참조 형식으로 전달: 인자를 call by reference로 전달하면 값의 복사가 일어나지 않으며 원본을 그대로 참조한다. 따라서 새로운 소유자가 생기지 않으며 참조 카운트 또한 증가하지 않는다. 함수 내에서 원본을 건드릴 수 있으며 원시 포인터에 대한 참조를 끊을 수도 있다. 성능상의 이유로 참조로 전달한다면, 상황에 따라 const로 전달하거나 규칙을 정해서 사용하자.
  • 이 외에도 원시 포인터를 전달하는 방법 등이 있지만 해당 포스팅에선 생략한다.  

 

[원시 포인터 메모리 해제]
원시 포인터에 대한 소유자가 0명이 되어 자동으로 메모리가 해제되는 예제를 보자.

void RawPointerDestructionExample(std::shared_ptr<int>& s1, std::shared_ptr<int>& s2)
{
	s1.reset(); // refCount 1 감소, refCount: 1
	s2.reset(); // refCount 1 감소, refCount: 0
}

int main()
{
	std::shared_ptr<int> s1 = std::make_shared<int>(10); // refCount: 1 
	std::shared_ptr<int> s2 = s1;                        // refCount 1 증가, refCount: 2

	RawPointerDestructionExample(s1, s2);

	return 0;
}


함수가 실행되기 전 main에서 s1이 소유한 원시 포인터에 대한 참조 카운트는 2이다.
함수에 참조로 전달하면 참조 카운트는 증가하지 않는다. reset은 shared_ptr의 멤버 함수로 현재 소유 중인 원시 포인터를 버린다. 해제를 하는 것이 아닌 단순히 참조를 끊는 다는 것에 주의한다. 따라서 참조 카운트가 1 감소한다. s2도 마찬가지로 원시 포인터를 더 이상 참조하지 않기 때문에 참조 카운트가 1 감소하며 0이 된다. 0이 되는 순간 해당 원시 포인터는 더 이상 쓰이지 않는다고 판단되어 delete를 통해 소멸된다. 공유 포인터는 아래와 같이 empty 상태가 되며 그 아래는 원시 포인터의 메모리 사진이다. 

empty

 

해제되기 전 원시 포인터의 값 10(0a)

 

해제된 후 원시 포인터의 값

 

[원시 포인터 참조 끊기, reset 함수1]
reset 멤버 함수를 통해 공유 포인터가 참조하고 있는 원시 포인터를 지울 수 있다. 여기서 지운다는 것은 delete가 아니라 단순히 더 이상 참조하지 않겠다는 뜻이다. 따라서 참조 카운트가 1 감소한다. 만약 여전히 해당 원시 포인터를 누군가 참조하고 있다면 원시 포인터는 살아있을 것이고, 그렇지 않다면 delete를 통해 소멸시킬 것이다. 즉 참조 카운트를 감소시키고 0이라면 지운다. reset 대신 공유 포인터에 nullptr을 대입해도 동일한 기능을 수행한다.  

[원시 포인터 참조 끊기, reset 함수2]
오버로딩된 reset 함수를 통해 다른 원시 포인터로 재설정할 수 있다. 인자로 원시 포인터를 받는다. 이 또한 이전 원시 포인터에 대한 참조 카운트가 0이 된다면 메모리를 해제하고 새 원시 포인터로 설정한다. 

 

[참조 카운팅 방식의 문제점]
shared_ptr가 사용하는 참조 카운팅 방식에 문제점이 하나 있다. 바로 순환 참조(circular dependency)라는 것이다. 

순환 참조란 참조 횟수를 가지는 두 개체가 서로를 참조하고 있어 참조 카운트가 0이 되지 못해 스스로 메모리 해제가 불가능한 상황을 말한다.  

예를 들어 아래와 같이 Parent 클래스와 Child 클래스가 있다고 생각해보자.

class Child;

// Parent class
class Parent final
{
public:
    Parent() = default;
    ~Parent() = default;
    Parent(const char* name) : mName(name) {}

    void SetChild(shared_ptr<Child> child) { mChild = child; }

private:
    shared_ptr<Child> mChild;
    string mName;
};


// Child class
class Child final
{
public:
    Child() = default;
    ~Child() = default;
    Child(const char* name) : mName(name) {}

    void SetParent(shared_ptr<Parent> parent) { mParent = parent; }

private:
    shared_ptr<Parent> mParent;
    string mName;
};

- Parent 클래스는 멤버 변수로 자식을 가리키는 공유 포인터 mChild를 가진다.
- Child 클래스는 멤버 변수로 부모를 가리키는 공유 포인터 mParent를 가진다.
- Set 함수를 통해 각 클래스 개체는 부모와 자식을 설정할 수 있다.


그리고 다음과 같이 각 클래스에 대한 개체를 만들고 각 멤버가 서로를 참조하도록 한다.

int main()
{
	shared_ptr<Parent> parent = make_shared<Parent>("father");  // Parent 개체 생성
	shared_ptr<Child>   child = make_shared<Child>("daughter"); // Child  개체 생성

	parent->SetChild(child);  // parent의 멤버 mChild가  위 지역 개체 child를 공유함
	child->SetParent(parent); // child의  멤버 mParent가 위 지역 개체 parent를 공유함

	return 0;
}

 

위 코드를 그림으로 표현하면 아래와 같다.

아래 그림에서 집중해서 봐야할 것은 Heap 영역의 두 메모리 블럭(인스턴스)이다. 두 블럭은 make_shared를 통해 만들어진 각 클래스의 인스턴스이다. main(Stack)의 지역 변수인 parent와 child가 두 블럭을 각각 가리키기 때문에 참조 카운트가 각각 1이다. 그리고 Set 함수를 통해 mChild는 아래 블럭을, mParent는 위 블럭을 참조하도록 했다. 때문에 각 블럭에 대한 참조 카운트는 1씩 증가하여 2가 된다. 

 

여기서 main 함수가 종료되어 지역 변수인 parent와 child가 소멸됐다고 생각해보자. 그럼 아래와 같이 지역 변수가 소멸되고 지역 변수로부터 참조되던 참조 카운트가 1씩 감소한다. 그러나 heap 영역의 두 블럭은 mChild와 mParent로부터 여전히 서로를 참조되고 있다.  수동으로 두 블럭의 메모리를 해제 해보려고 하지만 더 이상 해당 블럭에 접근할 수 있는 stack의 변수가 없다. 즉 메모리 누수 발생이다. 

이러한 순환 참조를 또 다른 스마트 포인터인 weak_ptr(약한 참조 사용)를 사용하여 해결할 수 있다. weak_ptr는 다음 포스팅에서 알아보도록 한다.

 

[멀티 스레드 환경에서의 문제점]

이는 참조 카운트 방식에서의 문제점이지만 shared_ptr에선 해결이 된다.

참조 카운트는 메모리가 참조될 때마다 참조 카운트를 증가시키고, 참조되지 않을 때마다 감소시킨다. 이는 멀티 스레드 환경에서 문제가 될 수 있다. 예를 들어 현재 참조 카운트가 1인 메모리 M을 스레드 A가 참조하려고 참조 카운트를 1 증가시키려고 한다. 그런데 스레드 B 또한 M을 참조하기 위해 참조 카운트를 1 증가시키려고 한다. 그런데 스레드 A, B 모두 M의 참조 카운트가 1일 때 가져가버렸다. 정상적인 경우라면 카운트는 2 증가하여야 하는데, 두 스레드 모두 1에서 증가시켜 2가 되었다.

이는 멀티 스레드 환경에서 발생할 수 있는 race condtion(경쟁 상태)라는 상태이며 lock 또는 mutex와 같은 함수를 통해 두개 이상의 스레드가 동시에 접근하지 못하도록 막아야 하는데, 이 경우 속도가 느려지게 된다. shared_ptr는 내부적으로 이 문제점을 해결하여 등장하였으며 최적화가 되어 있다. 멀티 스레드 환경에서 shared_ptr를 사용하지 않을 경우, 참조 카운팅 방식은 기존의 C스타일 포인터보다 느리다. 왜냐면 참조 카운트라는 공유 자원을 사용하기 위해선 lock과 같은 함수가 필요하기 때문이다.

 

참고한 사이트

https://docs.microsoft.com/ko-kr/cpp/cpp/how-to-create-and-use-shared-ptr-instances?view=vs-2019




댓글