bdfgdfg
[C++ 11] 스마트 포인터(Smart Pointer) - 2 본문
shared_ptr(공유 포인터)
스마트 포인터 중 하나. 이름에서 알 수 있듯이 원시 포인터가 가리키는 객체가 단 하나만이 가능한 유니크포인터와는 달리 공유포인터는 여러개의 공유포인터가 하나의 객체를 가리킬 수 있으며 아무도 가리키지 않을 때 자동으로 바로 해제 된다.
더 자세히 설명하기전에 메모리 관리기법에 대해 조금 알고있어야한다.
1. 가비지 컬렉션(Garbage Collection, GC) - Java,C#등
2. 참조 카운팅(Reference Counting)
Java나 C#에서 사용되는 가비지 컬렉션은 사용하지 않는 객체를 알아서 판단하여 메모리를 회수한다.
매주기마다 가비지 콜렉션의 루트를 확인하여 힙에 있는 메모리에 루트를 통해 접근할 수 있는지 판단하고 접근할 수 없다면 가비지(쓰레기)로 판단하여 삭제한다.
가비지 컬렉션의 문제는 사용되지 않는 메모리를 즉시 정리하지 않고 가비지 컬렉션이 메모리를 해제를 판단하는 동안 프로그램이 멈추거나 버벅일 수 있다. (찾고 -> 모아서 -> 삭제 == 느림)
-> 그렇기에 보통 메모리 풀 기법을 사용하여 GC에 먹이를 주지 않는다는 말도 있다.
자세히 봐야할 것은 참조 카운팅기법
참조 카운팅
기본적으로 가비지 컬렉션처럼 메모리를 해제한다는점에선 의미가 같다. 다만 그 방법이 다르다.
참조 카운팅 기법은 힙에 존재하는 메모리를 참조하는 객체가 아무도 없을 때 바로 해제된다.
즉 힙에 있는 메모리를 누가 가리키고 있을 때 참조 카운트가 1올라가며 하나씩 가리키지 않다가 아무도 가리키지 않을 때 메모리가 해제되는 것.
매우 간단한 수동 참조 카운팅 기법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
class Ref
{
public:
Ref() : m_ref(1) {}
virtual ~Ref() { --m_ref; }
public:
void IncreaseRef() { ++m_ref; }
void DecreaseRef()
{
--m_ref;
if (m_ref == 0)
{
std::cout << "소멸" << std::endl;
delete this;
}
}
private:
int m_ref = 0;
};
class Player : public Ref
{
public:
int m_player = 0;
};
int main()
{
// 수동 참조 관리 기법
Player* player = new Player();
{
Player* player2 = player;
player2->IncreaseRef();
player->DecreaseRef();
}
player->DecreaseRef();
return 0;
}
|
cs |
물론 이렇게 직접 참조 카운팅을 세는것은 더욱 실수를 유발할 수 있다.
이러한 참조 카운팅을 자동으로 해주는 것이 바로 std::shared_ptr (공유 포인터,범위를 벗어나면 카운팅 -1)
스마트 포인터중 shared_ptr이 의미하는 바는 이게 전부.
다만 이 공유포인터의 문제점이 몇개 있다. 그것을 해결하기 위해 마지막 스마트 포인터인 weak_ptr이 존재한다.
1. 순환 참조의 문제
2. 멀티 쓰레드 환경에서는 100% 안전하지는 않음.
-> Control Block은 안전하지만 그 이외(포인터 교환등)는 thread-unsafe하다고 한다.
먼저 순환 참조의 문제.
-> 객체A -> 객체B 객체B -> 객체A 와 같이 서로가 강한 참조를 한다면 이 둘은 절대 해제되지 않는다.
참조의 종류에는 강한(Strong) 참조와 약한(weak) 참조를 들고 있다.
이러한 강한 참조와 약한 참조를 따로둔 이유는 순환(Circular) 참조의 문제때문.
1. 강한 참조란 어떤 포인터 변수가 객체 B를 참조할 때(카운팅 +1) 객체 B는 절대 소멸되지 않는다.
-> 강한 참조 카운트가 따로 존재한다.
2. 강한 참조 횟수가 0이 될 때 해당 객체는 소멸된다.
3. std::shared_ptr은 강한 참조.
간단한 std::shared_ptr 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class Test
{
public:
Test() { std::cout << "Test 생성자 호출" << std::endl;}
~Test() { std::cout << "Test 소멸자 호출" << std::endl; }
};
int main()
{
{
std::shared_ptr<Test> player = std::make_shared<Test>();
}
}
|
cs |
실행 시 (참고로 make_shared도 C++14에서 생긴 것)
범위를 벗어나면 자동 소멸이 되는것을 알 수 있다.
정확히는 강한 참조 카운팅이 범위를 벗어나면서 -1이 되고, 카운트가 0이 되어 소멸이 된 것.
1
2
3
4
5
6
7
8
9
10
|
int main()
{
// 하나의 객체를 여러 공유 포인터가 가리킬 수 있음.
std::shared_ptr<Test> player1 = std::make_shared<Test>();
{
std::shared_ptr<Test> player2 = player1;
}
std::shared_ptr<Test> player3 = player1;
return 0;
}
|
cs |
여전히 똑같이 잘 소멸된다. 참조 카운팅이 처음 생성 되었을 때 1. 중괄호 안에서 다른 공유포인터가 해당 객체를 가리키면서 +1되었지만 범위를 벗어나자마자 -1. 마지막으로 player3이 가리키면서 +1. 둘 다 메인 함수를 빠져나오면서 -1,-1하여 0이 되어 객체 소멸.
std::shared_ptr은 내부적으로 두 개의 포인터를 소유한다.
-> raw pointer와 유니크 포인터보다 좀 더 무겁고 느리게 동작한다.
바로 데이터를 가리키는 원시 포인터와 제어 블록을 가리키는 포인터 두 개의 포인터를 소유
내부적으로 저렇게 제어블록을 가리키는 포인터가 존재하며 해당 객체의 참조 횟수를 카운팅한다.
위 코드에서 player1,player2,player3에서 메인 함수가 종료되기 직전의 참조 횟수를 확인해보면
강한 참조 횟수(strong refs)가 2인것을 알 수 있으며 ptr이 같은 객체를 가리키고 있다는것을 알 수 있다.
강한 참조 횟수(포인터 소유권 공유)는 shared_ptr이 대입 연산을 통해 다른 shared_ptr의 변수를 넣을 때 증가하게 된다.
shared_ptr에도 reset함수와 nullptr의 기능이 똑같이 존재한다.
유니크 포인터는 단 하나의 객체만을 가리킬 수 있기에(공유 불가능) 바로 소멸이 되지만, 공유 포인터의 경우 참조 횟수에 따라 바로 소멸이 될수도 있고 아닐수도 있다.
-> reset,nullptr을 대입할 시 강한 참조 카운팅 횟수 -1
그리고 강한 참조 횟수를 런타임에서도 알 수 있다.
1
2
3
4
5
6
7
8
9
|
int main()
{
std::shared_ptr<Test> player1 = std::make_shared<Test>();
std::shared_ptr<Test> player2 = player1;
std::cout << player1.use_count(); // 강한 참조 횟수를 반환한다.
return 0;
}
|
cs |
이제 아까 공유 포인터를 사용할 때 발생할 수 있는 순환참조의 예시를 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
class Pet;
class Player
{
public:
Player() { std::cout << "Player 생성자" << std::endl; }
~Player() { std::cout << "Player 소멸자" << std::endl; }
public:
std::shared_ptr<Pet> m_pet;
};
class Pet
{
public:
Pet() { std::cout << "Pet 생성자" << std::endl; }
~Pet() { std::cout << "Pet 소멸자" << std::endl; }
public:
std::shared_ptr<Player> m_owner;
};
int main()
{
std::shared_ptr<Player> player;
std::shared_ptr<Pet> pet;
player = std::make_shared<Player>();
pet = std::make_shared<Pet>();
std::cout << "player Count : " << player.use_count() << " pet Count : " << pet.use_count() << std::endl;
//순환참조 player->m_pet = pet;
pet->m_owner = player;
std::cout << "player Count : " << player.use_count() << " pet Count : " << pet.use_count() << std::endl;
return 0;
}
|
cs |
원래라면 위 코드에서 player와 pet은 중괄호를 벗어나면 소멸이 되어야 한다. 실행해보면
소멸자가 호출되지 않는다.
이유는 스택에 존재하는 player와 pet 공유 포인터가 각각 Player와 Pet객체를 가리키면서 강한 참조 +1.
여기서 Player와 Pet의 각 멤버인 공유 포인터가 서로의 객체를 가리키면서 강한 참조 +1.
main함수를 벗어나면서 스택에 존재하는 player와 pet 공유포인터는 범위를 벗어나기에 강한 참조는 -1되지만,
힙 메모리상에 존재하는 Player와 Pet객체는 아직도 서로를 가리키고 있기때문에 소멸되지 않는다.
여기서 사용할 수 있는게 바로 약한 참조 카운팅인 weak_ptr
바로 결과부터 보자. Player와 Pet클래스의 멤버를 공유포인터가 아닌 weak_ptr로 바꾸고 실행해보면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class Pet;
class Player
{
public:
Player() { std::cout << "Player 생성자" << std::endl; }
~Player() { std::cout << "Player 소멸자" << std::endl; }
public:
std::weak_ptr<Pet> m_pet;
};
class Pet
{
public:
Pet() { std::cout << "Pet 생성자" << std::endl; }
~Pet() { std::cout << "Pet 소멸자" << std::endl; }
public:
std::weak_ptr<Player> m_owner;
};
|
cs |
결과를 보면 Plaeyr와 Pet객체의 멤버의 포인터를 서로를 가리키게 했지만 강한 참조횟수는 증가하지 않았고 main함수를 벗어나니 자동으로 소멸되는 모습.
즉 약한(weak) 참조는 순환 참조 문제의 해결책
- 약한 참조는 원시 포인터가 가리키는 객체의 소멸에 영향을 끼치지 않는다.
- 약한 참조로 참조되는 객체는 강한 참조 카운트가 0이 될 때 자동으로 소멸된다. (약한 참조 카운트는 상관없이)
위 코드에서 보면 알겠지만 shared_ptr과 weak_ptr은 한 세트다.
weak_ptr에 shared_ptr을 대입 가능한 것(이 경우 약한 참조 카운팅이 +1된다)
weak_ptr의 약한 참조 카운팅은 객체 소멸에 관여하지 않으므로 사용을 할때에는 주의를 기울여야 한다.
-> 애초에 weak_ptr은 직접적으로 사용 불가능.
-> 강한 참조가 0이되면 자동으로 사라진다. 즉 weak_ptr을 직접적으로 사용하면 소멸된 객체에 접근해버릴 가능성이 존재한다.
그렇기에 weak_ptr을 사용할 때에는 shared_ptr로 한번 바꾸고 사용해야 한다.
그래서 내부적으로 lock이라는 멤버함수가 존재.
1
2
3
4
5
6
7
8
9
10
|
int main()
{
std::shared_ptr<Player> player = std::make_shared<Player>();
std::weak_ptr<Player> weakPlayer = player;
std::cout << player.use_count() << std::endl;
std::shared_ptr<Player> strongPlayer = weakPlayer.lock(); // shared_ptr을 반환.
std::cout << player.use_count() << std::endl;
return 0;
}
|
cs |
lock을 통해 shared_ptr을 뱉을 시 강한 참조가 1증가하는 모습.
-> weak_ptr을 직접적으로 사용할 때의 문제를 해결.
만약 해당 객체가 이미 소멸된 객체였을 때는 lock이 반환하는것은 nullptr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
std::weak_ptr<Player> Test()
{
std::shared_ptr<Player> player = std::make_shared<Player>();
std::weak_ptr<Player> weakPlayer = player;
return weakPlayer;
}
int main()
{
std::weak_ptr<Player> player = Test();
if (player.lock() == nullptr)
{
std::cout << "이미 소멸된 객체" << std::endl;
}
return 0;
}
|
cs |
정리
1. shared_ptr은 참조 카운팅 기법을 활용하여 메모리를 관리.
2. shared_ptr은 강한 참조 카운트를 증가. weak_ptr은 약한 참조 카운트를 증가. (순환참조의 해결)
3. weak_ptr은 lock을 써서 공유 포인터가 가리키는 객체가 존재하는지를 확인해야 함.
'게임프로그래밍 > C++' 카테고리의 다른 글
[C++ 11] std::function, std::bind (0) | 2022.02.15 |
---|---|
[C++ 11] std::atomic, SpinLock (1) | 2022.02.07 |
[C++ 11] 스마트 포인터(Smart Pointer) - 1 (0) | 2022.01.26 |
[C++ 11] 오른값 참조 및 이동 의미(move semantic) (0) | 2022.01.25 |
[C++ 11] 람다(lambda) 식 (0) | 2022.01.24 |