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

Modern Effective C++ item 20 : 댕글링 포인터엔 weak_ptr

by 김짱쌤 2015. 4. 14.

댕글링 체크엔 std::weak_ptr

weak_ptr은 shared_ptr과는 달리 참조 횟수에 영향을 받지 않는 스마트 포인터이다. shared_ptr에서 고민했던 내용들을 생각해보자. 어떤 포인터가 주소를 가져오는데, 참조 횟수에 영향을 미치지 않는다면, 공유 자원의 일관성을 유지하기 위해서 실제 포인터가 할 수 있는 기능들을 대부분 포기해야할 것이다. 실제로 이 포인터는 역참조가 안되고, nullptr체크도 안된다. 게다가 참조 카운터를 고려안하므로 댕글링상태에 빠지기도 쉬울 것이다.(후후..) 스스로는 뭔가 제대로된 역할을 하기 힘든 포인터이다. 이런걸 smart하다고 말하기 어려울 수 있지만, shared_ptr과 함께 쓰일때 그 진정한 스마트함을을 갖출 수 있다. 결론부터 말하자면 이 포인터는 댕글링을 체크하는데 그 스마트함을 보여준다!

weak_ptr의 스마트함

앞에서 말했지만 weak_ptr은 댕글링 상태를 체크할 수 있다. 방법을 아래의 코드에 기술한다.

//rc : control block의 reference count
//shared_ptr 최초 생성 rc 1
auto spw = std::make_shared<Widget>();  
//weak_ptr 생성 rc 여전히 1
std::weak_ptr<Widget> wpw(spw);         
//rc 0 Widget객체 해제되고 wpw는 dangling
spw = nullptr; 
//expired() 호출로 댕글링 체크!
if(wpw.expired())                     
...

하지만 이 코드에도 문제는 있다. 첫 번째 문제는 weak_ptr만으로는 자원을 역참조해서 가져올 수가 없다는 점이다. 이건 weak_ptr과 쌍이 되는 shared_ptr을 사용한다면 어느정도 해결이 가능하다. 하지만 문제는 여기서 끝나지 않는다. 두 번째 문제는 공유된 자원이 경쟁상태에서 참조 회수가 변경된다면, expired 체크를 벗어난 이후에 참조횟수가 0이 되어서 댕글링된 포인터를 역참조할 가능성이 있다는 거다! 그럼 이제 우리에게 필요한 것은 atomic하게 expired 체크를 하는 동시에 포인터가 가리키는 자원을 바로 역참조해서 가져오는 동작이다. weak_ptr은 이 동작을 두 가지 방법으로 지원한다.

//방법 1.
//weak_ptr의 lock 메소드를 호출
//expired되었다면 null을 리턴하고 
//사용가능하면 해당 자원의 shared_ptr을 리턴!
std::shared_ptr<Widget> spw1 = wpw.lock(); 
//auto로 사용 가능
auto spw2 = wpw.lock(); 

//방법 2.
//shared_ptr의 weak_ptr을 사용한 생성자를 호출
//expired된 weak_ptr인 경우 std::bad_weak_ptr예외가 throw
std::shared_ptr<Widget> spw3(wpw);

weak_ptr의 스마트함 in 캐싱

이제 weak_ptr의 위력을 느꼈는지? scott meyers님은 아직 잘 모르겠다고 생각하는 사람들을 위해서 실전적인 예를 들어주신다. 고유한 ID를 입력하면 그에 매핑되는 읽기 전용 const Widget의 포인터를 리턴하는 함수 loadWidget을 생각해보자. 포인터의 팩토리함수라고 생각하면, 리턴형식은 item 18의 조언에 따라 unique_ptr로 해야겠지.

std::unique_ptr<const Widget> loadWidget(WidgetID id);

이 함수를 한번 호출할때 I/O를 사용해서 비용이 많이 들며, 같은 ID로 여러번 반복된 호출이 발생한다고 가정해보자. 이 경우 cache를 사용하는 것이 좋은 대안이 될 것이다. 한번이라도 요청된 Widget에 대해서 캐싱을 해서 저장해놓고 필요할 때 저장된 Widget을 돌려주면 성능 문제를 해결할 수 있다. 또한 더이상 사용하지 않는 캐쉬된 Widget은 삭제를 해준다면 최적화에 도움이 될 것이다. 이런 상황에서 unique_ptr을 고집하는건 좋은 선택이 아니다. 기본적으로 캐싱하기위해서 자원을 공유할 수 있어야하는데, 이런 점에서는 shared_ptr을 사용하는것이 훨씬 효과적이다. 그리고 팩토리 함수 내부적으로도 shared_ptr이 여전히 사용중인지 체크할 수 있어야하므로, weak_ptr을 사용하여 자원의 가용 여부(댕글링)를 체크하는 것이 훌륭할 것이다.

std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
   //캐싱을 위한 hash_map : ID별 weak_ptr을 가지고 있다.
   static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
   //weak_ptr에서 lock을 통해 expired검사와 shared_ptr 리턴을 동시에!
   auto objPtr = cache[id].lock();

   //없으면 새로 만들고 캐싱한다.
   if(!objPtr){
      objPtr = loadWidget(id); //unique_ptr을 받아서 share_ptr로 저장
      cache[id] = objPtr;  //shared_ptr로 weak_ptr을 만들어 캐싱
   }
   //결과를 리턴
   return objPtr;
}

만약 weak_ptr이 없었더라면 이 코드가 얼마나 난해하고 복잡해졌을지를 생각해보자. 멀티쓰레드 이슈까지 처리해주는 weak_ptr 짱짱 ptr. 이번 예시에서만큼은 shared_ptr과 weak_ptr은 무적의 조합이 아닌가라는 생각이 든다. (scott meyers님은 여전히 최적화할 이슈가 남아있다고 하지만..)

weak_ptr의 스마트함 in Observer 패턴

이제 당신은 weak_ptr님을 믿게 되었는가? 아직 수줍어하는 중생들을 위해 scott meyers님이 또 다른 믿음의 탄환을 준비해 두셨다. 당신은 지금 옵저버 패턴을 구현해야되는 상황이다. 이 패턴을 구성하는 주된 두 요소는 상태가 변하는 subject들과 변한 상태를 통지받는 Observer들이다. 대부분의 옵저버 패턴은 subject들이 이벤트에 대한 통지를 날리기 쉽게 하기 위해서 observer의 포인터를 가지고 있다. 이때 subject가 observer에게 통지하기 전에 댕글링여부를 체크하는 것이 안전하고 쓸데없는 작업을 줄일 수 있다. 따라서 observer를 weak_ptr로 가지고 있다면 쉽고 효율적으로 옵저버 패턴을 구현할 수 있을 것이다.

weak_ptr의 스마트함 in 순환 참조



위 그림은 A, C가 B를 shared_ptr로 참조하는 상황이다. 이때 빨간 선과 같이 B가 A의 주소가 필요한 상황이 발생했다. 이때 B가 A를 참조하는 방식은 어떻게 되어야 할까? (물론 답은 정해져있지만) 세가지 방법을 생각해 볼 수 있다.

  • raw pointer로 참조한다.
    A가 삭제되었을때 B가 A가 댕글링 상태라는 것을 알 방법이 없다. 이때 B가 A를 역참조하려고 하면 미정의 동작이 수행된다.

  • shared_ptr로 참조한다.
    A와 B가 shared_ptr이고 서로서로를 shared_ptr로 가지고 있다. 이게 바로 순환참조문제다. 다른 어떤 객체도 A와 B를 참조하고 있지 않아도 둘은 서로서로를 참조하니까 절대로 rc가 0이 될 수 없다. 서로서로만을 참조하여 rc가 1인 상황이라면 이건 메모리 누수가 발생한거다. 왜냐하면 이 둘에 접근할 수 있는 방법이 없기 때문에!

  • weak_ptr로 참조한다. 위에서 발생하는 모든 문제들을 해결한다. 일단 댕글링을 체크할 수 있고, rc에 관여하지 않으므로 순환참조 문제도 발생하지 않는다. 완벽한 포인터가 아닐 수 없다.

하지만 weak_ptr을 써서 share_ptr의 순환참조를 막는 상황은 자주 발견되지는 않는다. 트리와 같은 엄격한 계층구조를가진 자료구조의 경우 부모노드가 자식노드를 가지고 있고, 자식노드도 부모노드를 가지고 있긴하다. 자식이 있는데 부모가 없는 경우는 없으므로 댕글링이 발생할 일이 없고 자식은 부모를 raw pointer로 갖고 있으면 된다. 그렇다면 부모가 자식의 포인터를 뭘로 가지고 있건 순환참조는 발생하지 않을 것이다. 하지만의 하지만, 모든 자료구조가 트리처럼 엄격한 계층구조로 구성되지 않을 수도 있고, 앞서 말한 캐싱이나 옵저버 패턴의 경우 weak_ptr을 사용하는게 좋다.

성능에 있어서 weak_ptr은 거의 share_ptr과 유사하다. shared_ptr과 같은 컨트롤 블록을 사용하므로 포인터의 크기도 일반 포인터의 두배이다. 그리고 컨트롤 블록에서 weak 참조 횟수를 체크하기 때문에 count 조작도 거의 같다. 이부분은 Item 21에서 더 자세하게 다루기로 한다.