References
- 씹어먹는 C++ (https://modoocode.com/252)
Contents
- shared_ptr, make_shared
- enable_shared_from_this
- weak_ptr
2021.08.02 - [C & C++] - [C++] 스마트 포인터(Smart Pointer) - (1)
이전 게시글에서 리소스 관리를 유용하게 해주는 스마트 포인터, unique_ptr과 RAII 개념에 대해 알아봤습니다.
unique_ptr은 객체를 유일하게 소유하게 해주며, 대부분의 경우에는 하나의 리소스는 한 개의 스마트 포인터에 의해 소유되는 것이 바람직하고, 그 이외의 접근은 그냥 일반 포인터로 처리하는 것이 좋습니다.
shared_ptr
하지만, 때에 따라서는 여러 객체에서 하나의 리소스를 사용하는 경우가 있습니다. 이 리소스를 사용하고 난 뒤에 해제하기 위해서는 이 리소스를 사용하는 모든 객체들이 소멸되어야 하는데, 어떤 객체가 먼저 소멸되는지 알 수 없기 때문에 이 리소스 역시 언제 해제시켜야할 지 알 수 없는 경우가 발생합니다.
따라서 이 경우, 특정 리소스를 몇 개의 객체에서 가리키는지 추적하고, 참조하는 객체의 수가 0이 되어야만 리소스를 해제해주는 방식의 포인터가 필요합니다.
바로 이러한 역할을 제공해주는 스마트 포인터가 바로 shared_ptr 입니다.
유일하게 객체를 소유하는 unique_ptr과는 다르게, shared_ptr로 객체를 가리킬 경우에는 다른 shared_ptr 역시 그 객체를 가리킬 수 있습니다.
아래의 예시를 살펴봅시다.
#include <iostream>
#include <memory>
#include <vector>
class A {
int* data;
public:
A() {
data = new int[100];
std::cout << "Get Resources\n";
}
~A() {
std::cout << "Call Destructor\n";
delete[] data;
}
};
int main(void) {
std::vector<std::shared_ptr<A>> vec;
vec.push_back(std::shared_ptr<A>(new A()));
vec.push_back(std::shared_ptr<A>(vec[0]));
vec.push_back(std::shared_ptr<A>(vec[1]));
std::cout << "Delete the 1st element\n";
vec.erase(vec.begin());
std::cout << "delete the 2nd element\n";
vec.erase(vec.begin());
std::cout << "delete the last element\n";
vec.erase(vec.begin());
std::cout << "end of program\n";
return 0;
}
컴파일 후에 실행하면 위의 출력을 확인할 수 있습니다.
위 코드는 shared_ptr<A>을 원소로 가지는 벡터 vec를 정의하고, vec[0], vec[1], vec[2]가 모두 같은 객체를 가리키는 shared_ptr을 생성하였습니다.
그리고, vec의 첫 번째 원소부터 차례대로 지우는데, shared_ptr은 객체를 가리키는 모든 스마트 포인터들이 소멸되어야지 객체가 소멸되므로, 두번째 원소까지는 소멸되지 않다가 마지막 원소가 소멸될 때 A 객체의 소멸자가 호출됩니다.
즉, A 객체를 참조하는 포인터의 수가 3이 었다가 2,1,0 순으로 줄어들면서, 0이되는 순간 객체가 소멸됩니다.
이때, shared_ptr의 참조 개수는 use_count 함수를 통해서 확인할 수 있습니다.
int main(void) {
std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);
std::cout << p1.use_count() << std::endl;
std::cout << p2.use_count() << std::endl;
return 0;
}
이처럼 각 shared_ptr들은 참조 개수가 몇 개 인지 알고 있어야 하고, 같은 객체를 가리키는 shared_ptr 끼리 그 개수가 동기화되어 있습니다. 하지만, 이 정보는 shared_ptr 내부에 저장하지 않습니다.
만약 참조 개수 정보를 shared_ptr 내부에 저장한다면,
std::shared_ptr<A> p3(p2);
이런 경우에 p2의 참조 카운트 개수는 증가시킬 수 있을지라도, p1의 참조 카운트 개수는 건들일 수가 없게 됩니다.
이런 경우를 방지하기 위해서 처음부터 실제 객체를 가리키는 shared_ptr이 제어 블록(control block)을 동적으로 할당하고, shared_ptr 들이 이 제어 블록에 필요한 정보를 공유하는 방식으로 구현됩니다.
shared_ptr은 복사 생성할 때마다 해당 제어 블록의 위치만 공유하고, 소멸할 때마다 블록의 참조 개수를 줄이고, 생성할 때마다 하나씩 늘리는 방식으로 동작합니다.
make_shared
unique_ptr을 make_unique 함수로 생성하는 것처럼 shared_ptr도 make_shared로 생성할 수 있습니다.
std::shared_ptr<A> p1(new A());
사실 위와 같은 shared_ptr 생성은 바람직하지 않습니다. 우선 A를 생성하기 위해서 동적 할당(new A())이 한 번 수행되어야 하고, 그 다음 shared_ptr의 제어 블록 역시 동적으로 할당해야 하기 때문입니다. 즉 두 번의 동적 할당이 발생합니다.
동적 할당은 꽤 코스트가 큰 연산이기 때문에, 두 번 수행하는 것보다는 두 개 합친 크기를 한 번에 할당하는 것이 훨씬 빠릅니다. 따라서 아래와 같이 수행하는 것이 더 바람직합니다.
std::shared_ptr<A> = std::make_shared<A>();
make_shared 함수는 A의 생성자의 인자들을 파라미터로 전달받아서 이를 통해 객체 A와 shared_ptr의 제어 블록을 한 번에 동적 할당한 후, std::shared_ptr을 리턴합니다. (위 경우에는 생성자의 인자가 없기 때문에 아무것도 전달되고 있지 않습니다.)
shared_ptr 사용 시 주의사항
shared_ptr을 생성할 때 주의해야할 사항이 있는데, 이는 바로 생성할 때 인자로 주소값이 전달되면 안됩니다.
shared_ptr의 인자로 객체의 주소값이 전달되면, shared_ptr은 자신이 해당 객체를 처음 소유하는 것처럼 행동합니다.
int main(void) {
A* a = new A();
std::shared_ptr<A> p1(a);
std::cout << p1.use_count() << std::endl;
std::shared_ptr<A> p2(a);
std::cout << p2.use_count() << std::endl;
return 0;
}
출력 결과를 보면 하나의 객체에 대해서 소멸자가 두 번 불린 것을 볼 수 있습니다. 따라서, 위 코드는 종료되면서 객체 A의 소멸자가 두 번 호출되고, 두 번째 소멸자에서 이미 해제된 data를 다시 해제하면서 런타임 에러가 발생하게 됩니다.
위와 같은 상황을 방지하려면, shared_ptr을 주소값을 통해서 생성하는 것을 지양해야 합니다.
enable_shared_from_this
위에서 shared_ptr을 주소값을 통해서 생성하는 것을 지양한다고 했으나, 어쩔 수 없는 상황도 존재합니다.
바로 객체 내부에서 자기 자신을 가리키는 shared_ptr을 만드는 경우가 이 경우에 속하게 되는데, 아래 코드를 살펴보도록 합시다.
#include <iostream>
#include <memory>
class A {
int* data;
public:
A() {
data = new int[100];
std::cout << "Get Resources\n";
}
~A() {
std::cout << "Call Destructor\n";
delete[] data;
}
std::shared_ptr<A> get_shared_ptr() {
return std::shared_ptr<A>(this);
}
};
int main(void) {
A* a = new A();
std::shared_ptr<A> p1 = std::make_shared<A>();
std::shared_ptr<A> p2 = p1->get_shared_ptr();
std::cout << p1.use_count() << std::endl;
std::cout << p2.use_count() << std::endl;
return 0;
}
위와 같은 코드도 이전과 같이 런타임 에러가 발생하게 됩니다. get_shared_ptr 함수에서 shared_ptr을 생성할 때, 이미 그 객체를 가리키는 shared_ptr의 존재를 모른 채 새로운 제어 블록을 생성하기 때문입니다.
위 문제는 enable_shared_from_this 를 통해서 해결할 수 있습니다.
방금 코드처럼 this를 사용해서 shared_ptr을 생성하고 싶은 클래스가 있다면, enable_shared_from_this 를 상속받으면 됩니다. 아래 예시를 살펴보겠습니다.
#include <iostream>
#include <memory>
#include <vector>
class A : public std::enable_shared_from_this<A> {
int* data;
public:
A() {
data = new int[100];
std::cout << "Get Resources\n";
}
~A() {
std::cout << "Call Destructor\n";
delete[] data;
}
std::shared_ptr<A> get_shared_ptr() {
return shared_from_this();
}
};
int main(void) {
A* a = new A();
std::shared_ptr<A> p1 = std::make_shared<A>();
std::shared_ptr<A> p2 = p1->get_shared_ptr();
std::cout << p1.use_count() << std::endl;
std::cout << p2.use_count() << std::endl;
return 0;
}
5 line에서 enable_shared_from_this<A>를 상속받고, 이제 get_shared_ptr 함수 내에서 shared_from_this 함수를 리턴하고 있습니다. shared_from_this 함수는 enable_shared_from_this 클래스에 정의된 멤버 함수인데, 이 함수는 이미 정의되어 있는 제어 블록을 사용해서 shared_ptr을 생성합니다.
따라서, 이전처럼 같은 객체에 두 개의 다른 제어 블록이 생성되는 것을 막을 수 있습니다.
한 가지 주의해야할 점은 shared_from_this 함수가 정상적으로 동작되기 위해서 해당 객체의 shared_ptr이 먼저 정의되어 있어야합니다. (없는 제어 블록을 만들지는 않습니다.)
A* a = new A();
std::shared_ptr<A> p1 = a->get_shared_ptr();
따라서 위 코드는 에러가 발생하게 됩니다.
서로 참조하는 shared_ptr의 경우
shared_ptr은 객체를 참조하는 개수가 0이되면 가리키는 객체를 메모리에서 해제시킵니다. 하지만, 객체를 더 이상 사용하지 않음에도 불구하고 참조 개수가 0이 될 수 없는 경우가 있습니다.
바로, 서로 참조하는 shared_ptr의 경우입니다.
위의 경우에는 각 객체가 shared_ptr을 하나씩 가지고 있는데, 이 shared_ptr이 다른 객체를 가리키고 있습니다. 객체1이 소멸되기 위해서는 shared_ptr의 참조 개수가 0이 되어야하는데, 이는 객체2가 소멸되어야지 가능합니다. 반대로 객체2가 소멸되기 위해서는 객체1이 소멸되어야 가능합니다.
#include <iostream>
#include <memory>
class A {
int* data;
std::shared_ptr<A> other;
public:
A() {
data = new int[100];
std::cout << "Get Resources\n";
}
~A() {
std::cout << "Call Destructor\n";
delete[] data;
}
void set_other(std::shared_ptr<A> o) {
other = o;
}
};
int main(void) {
A* a = new A();
std::shared_ptr<A> p1 = std::make_shared<A>();
std::shared_ptr<A> p2 = std::make_shared<A>();
p1->set_other(p2);
p2->set_other(p1);
return 0;
}
위 코드를 실행시키면 아래의 출력 결과를 확인할 수 있습니다.
즉, 소멸자가 정상적으로 호출되지 않았습니다.
이 문제는 shared_ptr 자체의 문제이기 때문에 shared_ptr을 통해서 이 문제를 해결할 수는 없습니다. 이러한 순환 참조 문제를 해결하기 위해서는 weak_ptr 을 사용해야 합니다.
weak_ptr
순환 참조의 예로 트리 구조가 있으며, 아래와 같은 구조를 갖습니다.
위와 같은 구조를 자료 구조로 나타내면 기본적으로 다음과 같이 나타낼 수 있습니다.
class Node {
std::vector<std::shared_ptr<Node>> children;
std::vector<std::weak_ptr<Node>> parent;
public:
Node() {};
void addChild(std::shared_ptr<Node> node) {
children.push_back(node);
}
};
부모는 여러 개의 자식 노드들을 가지므로, shared_ptr들의 벡터로 나타낼 수 있고, 그 노드 역시 부모 노드가 있으므로 부모 노드를 가리키는 포인터를 가집니다.
이때, 부모 노드를 가리키는 포인터의 타입을 shared_ptr로 하게 된다면, 앞서 봤던 순환 참조 문제가 발생하게 됩니다. 부모와 자식이 서로를 가리키기 때문에 참조 개수가 절대로 0이 될 수 없고, 따라서, 이 객체들은 프로그램이 끝날 때까지 절대로 해제되지 않습니다. (일반 포인터는 메모리 해제를 잊어먹거나, 예외가 발생하여 적절하게 자원이 해제되지 않을 수 있는 가능성이 존재합니다.)
weak_ptr은 일반 포인터와 shared_ptr의 중간쯤에 위치한 스마트 포인터로써, 스마트 포인터처럼 객체를 안전하게 참조할 수 있게 해주지만, shared_ptr과는 다르게 참조 개수를 증가시키지 않습니다.
따라서, weak_ptr이 어떤 객체를 가리키고 있더라도, 다른 shared_ptr들이 가리키고 있지 않다면 이미 메모리에서 해제되었을 것입니다. 이 때문에 weak_ptr은 그 자체로는 원래 객체를 참조할 수 없고, 반드시 shared_ptr로 변환해서 사용해야 합니다. (이미 메모리에서 해제된 객체를 사용할 수 있는 가능성이 존재합니다.) 이때 weak_ptr이 가리키는 객체가 이미 소멸되었다면 빈 shared_ptr로 변환되고, 아닌 경우에는 해당 객체를 가리키는 shared_ptr로 변환됩니다.
weak_ptr은 아래처럼 사용할 수 있습니다.
#include <iostream>
#include <memory>
#include <vector>
#include <string>
class A {
std::string s;
std::weak_ptr<A> other;
public:
A(const std::string& s) : s(s) {
std::cout << "Get Resources\n";
}
~A() {
std::cout << "Call Destructor\n";
}
void set_other(std::weak_ptr<A> o) {
other = o;
}
void access_other() {
std::shared_ptr<A> o = other.lock();
if (o) {
std::cout << "access : " << o->name() << std::endl;
}
else {
std::cout << "already destroyed\n";
}
}
std::string name() {
return s;
}
};
int main(void) {
std::vector<std::shared_ptr<A>> vec;
vec.push_back(std::make_shared<A>("Resource 1"));
vec.push_back(std::make_shared<A>("Resource 2"));
vec[0]->set_other(vec[1]);
vec[1]->set_other(vec[0]);
vec[0]->access_other();
vec.pop_back();
vec[0]->access_other();
return 0;
}
위 코드를 실행하면 아래의 출력 결과를 확인할 수 있습니다.
우선 set_other 멤버 함수부터 살펴보면, 이 함수는 weak_ptr<A>를 파라미터로 전달받고 있습니다. 여기서 main문 40, 41 line에서 shared_ptr을 파라미터로 전달하고 있습니다. 즉, weak_ptr은 생성자로 shared_ptr이나 다른 weak_ptr을 전달받습니다. 또한, shared_ptr과는 다르게, 이미 제어 블록이 만들어진 객체만이 의미를 가지므로, 평범한 포인터 주소값으로 weak_ptr을 생성할 수 없습니다.
그리고, access_other 멤버 함수를 살펴보겠습니다. 이 함수에서 weak_ptr을 사용하기 위해서 shared_ptr로 변환하고, weak_ptr에 접근하게 됩니다. (weak_ptr은 그 자체로는 원소를 참조할 수 없고, shared_ptr로 변환해야 합니다.)
이 변환 작업은 lock 함수를 통해서 수행할 수 있습니다. weak_ptr에 정의된 lock 함수는 weak_ptr이 가리키는 객체가 아직 메모리에 살아 있다면(참조 개수가 0이 아니라면), 해당 객체를 가리키는 shared_ptr을 반환하고, 이미 객체가 메모리 해제 됬다면 아무것도 가리키지 않는 shared_ptr을 반환합니다.
따라서, 46 line에서는 아직 vec[1] 객체가 살아있기 때문에 접근이 가능하여 access : Resource 2를 출력했지만, 49 line에서는 vec[1]이 삭제되었기 때문에 이미 해제됬다는 메세지가 출력됩니다.
shared_ptr을 설명하면서 제어 블록에 몇 개의 shared_ptr가 객체를 참조하는지에 대한 개수가 저장된다고 하였습니다. 그리고 참조 개수가 0이되면 해당 객체를 메모리에서 해제하게 됩니다.
이때, 제어 블록 역시 메모리에서 해제해야 할까요?
정답은 '아니다' 입니다.
만약 어느 객체를 가리키는 shared_ptr이 모두 소멸되어 0이지만, 아직 weak_ptr은 그 객체를 가리키고 있다고 가정해봅시다. 이 상황에서 객체는 이미 소멸되었겠지만, 만약 제어 블록까지 해제해버린다면, weak_ptr은 제어 블록에서 참조 카운트가 0이라는 사실을 알 수 없게 됩니다.
즉, 제어 블록을 메모리에서 해제하기 위해서는 이를 가리키는 weak_ptr 역시 0개 이어야 합니다. 따라서 제어 블록에는 참조 개수뿐만 아니라 약한 참조 개수(weak count)를 기록하게 됩니다.
'프로그래밍 > C & C++' 카테고리의 다른 글
[C++] mutex (0) | 2021.08.07 |
---|---|
[C++] thread (0) | 2021.08.07 |
[C++] 스마트 포인터(Smart Pointer) - (1) (3) | 2021.08.02 |
[C++] 가상 함수 (virtual, override 키워드) (0) | 2021.07.30 |
[C++] 가상 소멸자 (0) | 2021.07.28 |
댓글