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

[C++] 스마트 포인터에 대해 알아보자(3) | 약한 포인터 | std::weak_ptr

by woohyeon 2020. 3. 21.
반응형

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

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

공유 포인터
https://woo-dev.tistory.com/111


weak_ptr는 unique_ptr이나 shared_ptr 와는 다르게 단독으로 혼자 사용할 수 있는 포인터가 아니다.  다음은 weak_ptr 이 무엇인지 이해하기 위해 알아야 하는 것들이다.

[강한 참조]
먼저 공유 포인터는 참조 카운팅을 사용하여 현재 소유하고 있는 원시 포인터가 참조되는 횟수를 추적한다는 것을 기억해야 한다. 또한 참조의 종류엔 강한 참조(strong reference)와 약한 참조(weak reference)가 있고, 아직까진 약한 참조가 무엇인지 모르지만 공유 포인터는 강한 참조를 한다고 했다. 즉 한 개의 원시 포인터를 3개의 공유 포인터가 소유하고 있다면 참조 카운트가 3이지만 엄밀히 말하면 강한 참조 카운트(strong ref)가 3이 되는 것이다. 만약 3개의 공유 포인터 중 1개가 사라지면 strong ref는 1이 감소하고 증가하면 1이 증가한다.

 

[weak_ptr]
weak_ptr는 나머지 스마트 포인터들과 사용 개념이 좀 달라서 미리 사용 예시를 보는 것이 이해가 더 잘될 수 있다.

처음에 weak_ptr은 단독으로 사용될 수 없다고 했다. unique_ptr 또는 shared_ptr은 원시 포인터를 건네받아 내부적으로 참조하게 했다면, weak_ptr는 원시 포인터 대신 shared_ptr를 건네받아 shared_ptr가 소유한 원시포인터를 참조하도록 한다. 즉 weak_ptr는 unique_ptr 또는 shared_ptr처럼 원시 포인터를 직접적으로 인자로 받을 수 없으며 * 또는 -> 연산자를 통해 포인터처럼 사용할 수도 없다. weak_ptr는 다음과 같이 미리 생성된 shared_ptr 개체를 대입하여 생성한다.

class Foo {...};

void MakeWeakPointerExample()
{
   shared_ptr<Foo> strong_f = make_shared<Foo>();
   weak_ptr<Foo> weak_f = strong_f;
   // weak_ptr<Foo> weak_f(strong_f) // same
   
   // compile error
   //weak_ptr<Foo> isNotWorking(new Foo()); 
}


weak_ptr는 make_shared와 같은 도우미 함수도 없으며, new를 통해 원시 포인터를 할당할 수도 없다. 위와 같이 shared_ptr를 통해 weak_ptr를 초기화하면 내부적으로 shared_ptr가 소유한 원시 포인터를 소유하게 된다.
즉 weak_ptr가 shared_ptr가 소유한 원시 포인터를 참조하므로 참조 횟수가 1 증가하게 되는데, 이때 증가하는 참조 횟수가 약한 참조 횟수이며 weak_ptr가 원시 포인터를 참조하는 것을 약한 참조(Weak reference)라고 한다.

weak_ptr는 아래와 같이 shared_ptr와 동일한 멤버 변수를 가지며 _Ptr이 원시 포인터를 가지고 _Rep가 참조 횟수에 관한 테이블(컨트롤 블럭)의 주소를 가진다.

_Ptr(원시 포인터), _Rep(참조 테이블)

 

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

shared_ptr과 weak_ptr이 두 개체를 공유

strong_f가 공유포인터 개체이며 weak_f는 약한포인터 개체이다. 각 멤버 변수의 _Ptr은 원시 포인터를 가리키는데 그림에서 보듯이 동일한 메모리를 참조한다. 단 공유 포인터는 강한 참조로 참조하기 때문에 strong ref count가 1 증가할 것이고, 약한 포인터는 약한 참조로 참조하기 때문에 weak ref count가 1 증가한다. _Rep는 참조 테이블을 가리키며, 두 개체 모두 동일한 테이블을 공유한다. 

 

[강한 참조와 약한 참조의 차이]
강한 참조와 약한 참조의 차이는 각 참조의 횟수가 메모리 해제에 영향을 미치느냐 안 미치느냐이다. 공유 포인터에선 참조 횟수가 0이 되면 알아서 소멸자를 호출하여 메모리를 해제한다고 했다. 이때의 참조 횟수는 바로 강한 참조에 대한 이야기였다. 즉 약한 참조 횟수는 메모리 해제랑 관련이 없다.
약한 참조 횟수가 0이든 1이든 100이든 상관없이 강한 참조 횟수가 0이 되면 원시 포인터의 메모리는 해제된다.

weak_ptr는 자신이 참조하고 있는 원시 포인터의 강한 참조 횟수가 0이 되면 "expired"라는 상태가 되는데, 이는 원시 포인터의 (강한)참조 횟수가 0이 되어 메모리가 해제되었으므로, 이 원시 포인터를 소유한 weak_ptr는 "더 이상 유효하지 않다"라는 의미이다. 약한 포인터의 주요 용도는 힙 메모리 상에서 두 개체를 서로 강한 참조하여, 자동으로 해제되지 못하는 순환 참조를 해결하는 것이며, 마지막에 알아본다.

[weak_ptr 사용하기]
weak_ptr는 unique_ptr 및 shared_ptr 처럼 직접 원시 포인터에 접근하는 개념이 아니다. 왜냐하면 weak_ptr는 나머지 스마트 포인터와 달리 원시 포인터의 라이프 사이클에 관여를 안 하는데 직접적으로 사용이 가능하면 해당 원시 포인터를 참조하는 shared_ptr이 더 이상 존재하지 않는데도 여전히 직접적으로 사용할 수 있기 때문이다.
때문에 안전하고 간접적인 방법을 사용한다. weak_ptr의 멤버 함수 중 lock이라는 멤버 함수가 있는데, 이 함수는 해당 원시 포인터를 참조하는 shared_ptr을 만들어서 반환해준다. 우리는 이 반환된 shared_ptr를 통해 원시 포인터를 사용할 수 있다.

shared_ptr<Foo> lockedPtr = weak_f.lock();


lock 함수를 통해 원시 포인터를 가리키는 새로운 공유 포인터를 만들었으므로 (강한)참조 카운트는 1 증가한다. 즉 lock은 최소한 원시 포인터에 대한 1만큼의 강한 참조 횟수는 확보함으로써 다른 공유 포인터들이 모두 소멸되더라도 안전하게 원시 포인터를 사용할 수 있도록 해준다.

그러나 lock을 하기도 전에 사용자의 실수로 이미 원시 포인터가 소멸된 경우도 있다. 또는 멀티 스레드 환경에서 lock을 하려고 하는 순간, 다른 공유 포인터들이 모두 소멸되어 원시 포인터가 소멸되었을 수도 있다. 즉 weak_ptr가 "expire" 상태일 경우이다. 이때 lock 함수는 empty 상태인 공유 포인터를 반환한다. 

따라서 멀티 스레드 환경에서 반환된 공유 포인터를 다음과 같이 사용할 수 있다. 

shared_ptr<Foo> lockedPtr = weak_f.lock();
if(lockedPtr)
{
   ....
}

// or

if(!lockedPtr)
{
   ...
}

// Do something

 

[expired 멤버 함수]
그리고 weak_ptr의 멤버 함수 중 expired라는 함수가 존재한다. 이는 해당 weak_ptr가 expired 상태인지 확인하는 함수이다. 즉 현재 참조하는 원시 포인터가 소멸되었다면 "expired" 상태이므로 true를 반환하고, 존재한다면 false를 반환한다.

class Foo {...};

void MakeWeakPointerExample()
{
   shared_ptr<Foo> strong_f = make_shared<Foo>();
   weak_ptr<Foo> weak_f = strong_f;
   
   ...
   
   // 원시 포인터가 해제되지 않았다면
   if(!weak_f.expired())
   {
      ...
   }
}

 

위와 같이 expired를 기반으로 원시 포인터의 사용 가능 여부를 판단할 수 있는데, 한가지 문제점이 있다. 예를 들어 멀티 스레드 환경에서 위처럼 사용할 경우 문제가 생길 수 있다. 만약 위 조건문을 만족하여 scope안으로 들어갔는데 다른 스레드가 모든 공유 포인터를 지워버린 것이다. 현재 스레드는 해당 조건을 만족하여 원시 포인터가 살아있는 줄 알고 사용하려 했는데 없어져서 크래시가 나는 상황이 발생할 수 있다. 따라서 위 조건을 기반으로 로직을 작성하는 것은 위험할 수 있고, 멀티 스레드 환경에선 lock()을 통해 최소한의 참조 카운트를 확보해놓고 사용하는 것이 좋다.

[reset을 통해 참조 끊기]
다른 스마트 포인터와 마찬가지로 reset 함수를 통해 현재 소유 중인 원시 포인터를 버릴 수 있다. 역시 메모리 해제가 아닌 단순히 일방적으로 참조를 끊는 것이며 strong ref가 아닌 weak ref(count)가 감소한다. 아까도 말했지만 weak ref가 0이 된다고 원시 포인터가 해제되지 않는다. 원시 포인터를 버린 weak_ptr 개체는 empty 상태가 된다.

class Foo {...};

void MakeWeakPointerExample()
{
   shared_ptr<Foo> strong_f = make_shared<Foo>();
   weak_ptr<Foo> weak_f = strong_f;
   
   weak_f.reset(); // empty
   
   if(weak_f)
   {
      ... // 실행되지 않음
   }
}

하지만 weak_ptr 에서는 reset을 통한 재설정을 허용하지 않는다. 내가 생각하기 론 reset을 통해 재설정하려면 원시 포인터를 인자로 받아야 하는데 weak_ptr는 원시 포인터를 인자로 못 받고, shared_ptr를 통해 생성되므로 그런 것 같다.

 

[shared_ptr의 순환 참조 해결]
weak_ptr의 약한 참조를 통해 순환 참조 문제를 해결할 수 있다. 순환 참조에 대해 모른다면 처음에 링크했던 공유 포인터에 대한 포스팅을 보고 오자.

아래 코드는 공유 포인터에서 사용했던 코드를 그대로 가져와 멤버 변수만 살짝 변경하고 주석으로 표시해 놓았다.

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:
    weak_ptr<Parent> mParent; // 변경: shared_ptr -> weak_ptr 
    string mName;
};

순환 참조를 해결하는 방법은 서로를 참조하는 포인터 중 하나를 weak_ptr로 만드는 것이다.  위 코드에선 Child 클래스의 Parent 클래스 개체를 가리키는 mParent의 타입만 shared_ptr에서 weak_ptr로 변경하였다.

즉 참조 관계가 다음과 같이 된다.

Parent 인스턴스(위)의 mChild는 여전히 강한 참조로 Child 인스턴스를 가리키고 있고, Child 인스턴스(아래)의 mParent는 약한 참조로 Parent 인스턴스를 가리키고 있다. 현재 두 인스턴스에 대한 참조 카운트는 다음과 같다.

- Parent 인스턴스: 1 strong ref, 1 weak ref
- Child    인스턴스: 2 strong ref  

그리고 다음과 같이 main(Stack)영역에서 벗어나 지역 변수인 parent와 child가 사라졌다고 생각해보자.

지역 변수 parent가 더 이상 Parent 인스턴스를 참조하지 않으므로 strong ref가 1 감소한다. child 또한 Child 인스턴스를 참조하지 않으므로 strong ref가 1 감소하여 다음과 같이 된다.

- Parent 인스턴스: 0 strong ref, 1 weak ref
- Child    인스턴스: 1 strong ref  

Parent 인스턴스는 여전히 weak_ptr로부터 참조되고 있지만 strong ref가 0이므로 상관없이 자동으로 소멸된다. Parent 인스턴스가 소멸되면서 Parent의 멤버로 있던 mChild라는 공유 포인터도 함께 소멸된다. 결국 Child 인스턴스는 strong ref가 1 감소하여 0이 되고 더 이상 참조되지 않는다고 판단하여 소멸된다. 일종의 연쇄작용이라 볼 수 있다. 이렇게 순환 참조 문제가 해결된다.

weak_ptr는 거의 순환 참조를 해결하는 용도로만 사용된다고 한다. 최대한 유니크 포인터를 사용하고, 소유권 공유가 필요할 경우에만 공유 포인터를 사용하자.




댓글