• 카테고리

    질문 & 답변
  • 세부 분야

    프로그래밍 언어

  • 해결 여부

    해결됨

(2:15) 메모리 누수의 이유를 이해하지 못했습니다.

21.01.13 11:09 작성 조회수 687

5

Q1) "클래스 Person을 지우려 할 때, 맴버 변수인 m_partner도 지우려고 시도할텐데, 문제는 count가 되서 지울수가 없다." 라고 말씀해주셨는데, 그 count라는 게 어떤 걸 말씀하시는 건가요...?

추론1) shared_ptr의 특성상 내부적을 자신이 가리키고 있는 주소의 포인터가 몇 군데인지 세고 있다는 것에 대한 count를 말씀하시는 건가요? 만약 그렇담 그 count가 어째서 문제가 되나요..?

추론2) 혹시 라이브러리 <memory>에서 작동하는 원리에서 count라는 것이 있는데 그 곳에서 문제가 발생된다는 것이라면 아직 제 단계에서는 이해하기 어려워요..

좀 더 쉽게 설명해주실 수 있으실까요?

Q2) 좌우지간, 그 count라는 문제로 인하여 순환이 되지 않으니 weak_ptr를 써야한다는 것이 이번 강의의 내용인거죠?

답변 4

·

답변을 작성해보세요.

20

안소님의 프로필

안소

2021.01.13

안녕하세요.

추론 1) 그 말씀이 맞습니다. shared_ptr의 카운팅이 어떻게 문제가 되는지를 보여주신 것이 바로 이번 15.7 강의입니다. 제가 밑에서 다시 설명 드리겠지만 15.6, 15.7강의 한번 더 공부하시는 것을 추천드려요.

추론2) 밑에서 설명이 될 것 같습니다.

Q2) 아닙니다ㅠ 순환 참조를 하는 경우에는 이 shared_ptr의 카운팅이 메모리 누수 문제를 일으킬 수 있습니다. 순환 참조를 하는 경우에도 이러한 문제를 일으키지 않기 위해서 weak_ptr로 대체해 사용하는 것입니다. 순환 참조로 인한 문제를 막기 위해서 순환 참조가 일어날 수 있는 경우에는 weak_ptr을 사용한다는 것입니다. 

1. shared_ptr 에 대한 이해

unique_ptr에 대해서도 앞에서 공부하셨을겁니다. unique_ptr은 하나의 객체에 대한 소유권을 오로지 하나의 포인터에서만 가질 수 있음을 보장하는 포인터입니다. 즉, 한 객체 주소를 2개 이상의 포인터에 담을 수 없습니다. 하나의 객체는 하나의 포인터에서만 가리킬 수 있습니다. 그래서 복사와 대입이 가능하지 않았었죠.

이와 달리 shared_ptr은 하나의 객체에 대해 여러 포인터가 소유하는 것을 허락하는 포인터입니다. 즉 하나의 객체의 주소를 여러 포인터가 가지고 있고 그 객체를 여러 군데의 포인터에서 가리킬 수가 있어요. 15챕터에서 배우셨겠지만 이렇게 소유권이 분배되면 생길 수 있는 문제는 하나의 포인터로 delete가 되서 객체 메모리가 소멸되면 다른 포인터들에서 이를 접근할 수 없어진다는 문제가 됩니다. ptr1, ptr2, ptr3 에서 A 라는 객체의 주소를 가지고 있다고 가정해봅시다. delete ptr3 해버리면 A 객체가 소멸해버리겠죠. 그럼 ptr1과 ptr2은 사라져버린 A가 있었던 공간을 가리키는게 되버립니다. 이게 큰 문제가 될 수 있는 상황들이 있겠죠. 

그래서 shared_ptr은 내부적으로 지금 참조하고 있는 이 객체의 주소를 가지고 있는 다른 shared_ptr들이 몇 개인지도 저장을 합니다. delete ptr3 이 되더라도, ptr1 과 ptr2이 A 객체를 참조 중이라면, 즉 A 객체를 여러 포인터에서 지금 참조 중이라면 A 객체를 소멸하지 않도록 하기 위한 것입니다. 예를 들어 ptr3 은 사라지더라도 count 값은 여전히 2 이기 때문에, 즉 여전히 A 객체에 대한 소유권자가 2명 (ptr1, ptr2)이 남아있기 때문에 A 객체는 사라지지 않게 하는 것입니다. shared_ptr은 count 값이 0 이 되었을 때 비로소 참조중인 객체를 소멸킵니다. 그럼 앞에서 설명드린 위험한 문제를 막을 수 있겠죠.

2. shared_ptr의 내부 구조. count는 무엇인가.

shared_ptr도 클래스입니다. memory 헤더에서 제공하는 클래스일뿐이에요. 강의에 등장하는 shared_ptr인 lucy, ricky도 이 shared_ptr 클래스에서 생성한 인스턴스일 뿐이에요. unique_ptr 은 다른 포인터가 내가 참조 중인 객체의 주소를 가지지 못하도록 기능하도록 짜여진 클래스이고 shared_ptr 은 내부적으로 내가 참조 중인 객체를 참조 중인 다른 shared_ptr이 몇 개인지를 세고 있으며 이 갯수가 0 개가 될 때 비로소 내가 참조 중인 객체를 해제할 수 있는 기능을 하도록 짜여진 클래스일 뿐입니다. 포인터처럼 동작하도록 *나 -> 연산자들도 오버로딩 다 되어있고.. 아무튼 포인터처럼 동작하게끔 만들어져 제공되는 클래스들입니다.

shared_ptr 클래스는 멤버 변수로 참조 중인 객체의 주소를 저장하는 포인터와, Control Block 의 주소를 가지는 포인터를 가집니다. 이 control block 또한 동적할당으로 받는 것이며(이 control block도 클래스에서 동적으로 만든 인스턴스라고 볼 수 있겟죠) 이 control block 안에 1. 참조 중인 객체와 2. 이 객체를 참조 중인 shared_ptr의 갯수 카운트 3. 이 객체를 참조 중인 weak 포인터의 갯수 카운트  를 멤버로 가집니다. 이 control block 인스턴스를 같은 객체를 참조하는 여러 shared_ptr에서 공유합니다. 카운팅은 이렇게 shared_ptr 의 담당 제어블록에서 관리한다고 보시면 됩니다.

shared_ptr인 lucy와 ricky의 내부입니다. ptr에 객체의 주소를 저장하고 control block 주소를 가지는데 이 control block 에서는 1. 객체와 2. _Uses 3. _Weaks 를 볼 수 있네요. 이 _Uses 가 바로 count에요. 동일한 객체를 참조 중인 shared_ptr 를 세는 count 입니다. lucy의 카운트값은 현재 1 인 것을 볼 수 있죠. Person("Lucy") 객체는 오로지 lucy 포인터만 참조 중이기 떄문입니다. 

3. 순환 참조는 메모리 누수를 일으킬 수도 있다.

Person 클래스와 parnerUp 함수의 코드 내용은 강의에서 확인해주세요. 

순환 참조는 객체의 멤버로 shared_ptr을 가지고 있을 때 생길 수 있습니다.

partnerUp 함수로 인하여 Person("Lucy") 객체의 shared_ptr 멤버 m_partner 은 Person("Ricky")객체를 참조하게 되었습니다. 그리고 동시에 Person("Ricky")객체의 shared_ptr 멤버 m_partner 은 Person("Lucy") 객체를 참조하게 되었습니다.  A객체에 속한 shared_ptr이 B객체를 가리키게 되었고, B 객체에 속한 shared_ptr이 A 객체를 가리키게 된 것입니다. 이게 바로 순환 참조입니다.

만약 A 객체를 유일하게 참조 중이던 포인터가 scope를 벗어나 수명을 잃어 사라진다고 해봅시다. 그럼 A 객체는 소멸해야죠. A객체가 소멸되지 않으면 메모리 낭비죠!  A 객체를 참조하던 유일한 포인터가 사라지면 A 객체를 접근할 수 있는 길이 없어지기 때문입니다. 이게 바로 메모리 누수이죠. 집 주소를 잃어버려서 다시는 찾을 수 없게 된채로 앞으로 사용될 일이 영원히 없을 텐데 메모리만 차지하고 있는 집이될 수도 있기 때문에 없애주어야합니다.

근데 A 객체가 없어져야 하는데 B 객체의 shared_ptr 멤버 m_partner 에서 이를 참조하고 있기 때문에 A 객체가 사라지지 않습니다. 얘 떄문에 count가 1->0이 되는 것이 아닌 2->1가 되는 것이기 떄문이죠. 그렇단 얘기는 A 객체가 사라지려면 B 객체가 사라져야 한다는 것입니다. 근데 반대로도 생각해보면 B 객체가 없어져야 한다면 A 객체의 shared_ptr 멤버 m_partner 에서 이를 참조하고 있기 때문에 B 객체가 사라질 수 없습니다. 그래서 결국엔 A객체, B객체 이 둘을 참조하는 포인터들이 사라졌음에도 불구하고 A객체 B 객체 이 둘은 이러지도 저러지도 못한 채로 사라지지 못하고 메모리에 자리만 차지하고 있는 누수가 발생하게 됩니다. 

순환 참조가 없을 때는 두 객체 다 멀쩡하게 잘 해제 되는 것을 확인할 수 있지만

순환 참조가 이루어지니 두 객체가 해제 되지 못 한것을 확인할 수 있습니다.

내부적으로 위와 같은 상황이 생긴 것입니다. 그래서 lucy가 사라져도 count는 2에서 1이 될테니 0이 아니라 Person("Lucy") 객체가 삭제 되지 않을테고.. 두 객체는 실행 내내 소멸 되지 않은채로 남아있겠죠. 너무 낭비죠.

4. 순환 참조에서 발생할 수 있는 문제를 해결할 수 있는 포인터 weak_ptr

weak_ptr 스마트 포인터는 shared_ptr과 달리 카운팅을 하지 않는 포인터입니다. 말그대로 좀 약한 버전인 우회적으로 사용하는 shared_ptr 이라고 생각하시면 됩니다. weak_ptr도 제어 블록에서 카운팅이 되기는 하는데(위의 제 그림과 디버깅 보시면 알 수 있습니다.) 이 카운팅은 객체 생성과 해제에 전혀 영향을 주지 않습니다. 그래서 불가피하게 순환 참조를 해야 한다면 멤버를 shared_ptr로 하지 않고 weak_ptr로 하면 순환 참조 문제를 해결할 수 있게 됩니다. 

partnerUp 함수 호출을 통해서 Person("Lucy") 객체의 weak_ptr 멤버인 m_partner은 'ricky' shared_ptr을 대입 받아 Person("Ricky")객체를 참조하게 되고, Person("Ricky") 객체의 weak_ptr 멤버인 m_partner은 'lucy' shared_ptr을 대입 받아 Person("Lucy") 객체를 참조하게 됩니다. 즉 순환참조를 하게 되었습니다. 카운팅 값인_Uses를 보면 1 값인 것을 확인할 수 있습니다. 즉 두 객체를 참조하는건 각각 main에서 생성한 shared_ptr인 lucy와 ricky 밖에 없기 떄문입니다. 카운팅 값이 1 이라는 얘기는 lucy 와 ricky가 각각 scope를 벗어나면 count 값이 1->0 이 되어 무사히 두 객체도 각각 잘 소멸될 수 있다는 얘기가 되죠. 

이렇게 될 수 있는 이유는 weak_ptr 참조는 객체 해제에 판단 기준이 되는 _Uses 카운팅에 포함이 되지 않기 때문입니다. 즉 Person("Lucy") 객체의 weak_ptr 멤버인 m_partner가 Person("Ricky")객체를 참조하는 것은 카운팅에 고려하지 않는다는 것이에요. 사실 순환 참조이긴 하지만 이 덕분에 순환 참조가 아니라고 판단되어 소멸 될 수 있는 것이에요.

이제 weak_ptr을 쓰니 두 객체가 무사히 잘 소멸된 것을 확인할 수 있습니다. 

이렇게 순환 참조가 일어날 수 있거나 카운팅 되면 안되는 특수한 상황에서는 weak_ptr로 대체해서 쓰고 그 밖에는 shared_ptr 쓰는 것이 좋습니다.

po127992님의 프로필

po127992

2021.09.05

우와.. 좋아요 10개를 박아도 아깝지 않은 글이네요

Learn님의 프로필

Learn

2022.02.14

설명 보고 감동받았습니다. 감사합니다.

7

안소님의 프로필

안소

2021.01.14

"m_partner 1개의 포인터가 p1, p2 모두에게 공유" 라는 표현이 뭔가 애매해서 다시 정리를 드리자면

- m_partner 가 shared_ptr인 경우

Person("Lucy") 객체를 가리키는 shared포인터 : 1. lucy (p1)  2. ricky(p2)가 가리키는 Person("Ricky") 객체의 m_partner

👉 이렇게 2개

Person("Ricky") 객체를 가리키는 shared포인터 : 1. ricky (p2)  2. lucy(p1)가 가리키는 Person("Lucy") 객체의 m_partner

👉 이렇게 2개

그래서 lucy와 ricky가 둘 다 scope를 벗어나 없어지더라도 각각의 두 제어블록(객체가 속해있는)은 사라지지 않습니다. 둘 다 사라지더라도 count 가 2->1이 되므로 객체 및 제어블록은 사라지지 않습니다. lucy과 ricky로 각각 두 제어블록에 접근할 수 있는 방법이 영영 사라졌는데 제어블록들은 그대로 메모리에 자리를 차지하고 있눈 메모리 누수 현상이 발생합니다.

- m_partner 가 weak_ptr인 경우

Person("Lucy") 객체를 가리키는 shared포인터 : 1. lucy (p1)  

👉 이렇게 1개

Person("Ricky") 객체를 가리키는 shared포인터 : 1. ricky (p2)  

👉 이렇게 1개

그래서 lucy와 ricky가 scope를 벗어나 없어지면 count 가 1->0이 되므로 두 객체 및 제어블록은 잘 소멸됩니다. weak_ptr은 참조 카운팅에 반영되지 않기 때문입니다. (객체 해제 시점을 판별하는 카운팅에 반영되는건 shared_ptr 뿐입니다.)

제가 그린 그림을 보시면서 두 객체가 각각 어떤 포인터들에 의해 참조되고 있는지를 유심히 봐주세요! 

0

홍길동님의 프로필

홍길동

질문자

2021.01.14

감사합니다!

0

홍길동님의 프로필

홍길동

질문자

2021.01.14

우문현답이십니다. joy님!

이 정성스러운 답변을 달아주시기 위해 캡쳐도 해주시고, 그림까지 그려주시면서 설명해주시는 joy님의 열성에 댓글로나마 정말 싼 회신이지만 진심으로 매번 감사합니다. 복받으실 겁니다~!!!

-------------------------------------------------------------------------

드디어 이해가 됬습니다!

여기서 문제가 되었던거군요!? 제가 이해한 걸 정리해볼게요.

1. p1->m_partner에 p2를 대입하게 되고, 마찬가지로 p2->m_partner에 p1를 대입하게 되서 둘은 순환참조가 된다.

2. 이 때 m_partner가 shared_ptr일 경우 :

m_partner 1개의 포인터로 p1, p2 모두에게 공유하기에 count는 2개가 되고 p1, p2 둘 중 하나만 delete하게 되더라도 count는 여전히 1개가 남기에 메모리 누수가 발생한다.

다시 말해, {}영역을 벗어나더라도 p1가 사라지면 p2가 남고, p2가 사라지면 p1이 남기때문이다.

3. m_partner가 weak_ptr일 경우 :

m_partner 1개의 포인터가 p1, p2 모두에게 공유되더라도 카운팅을 하지 않는다. 그러기에 p1는 본인의 count만 가지게 되고, p2도 동일하기에 {}를 벗어나면 각각의 소멸자가 정상적으로 작동한다.