bdfgdfg

[C++ 11] 스마트 포인터(Smart Pointer) - 2 본문

게임프로그래밍/C++

[C++ 11] 스마트 포인터(Smart Pointer) - 2

marmelo12 2022. 1. 26. 20:48
반응형

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와 유니크 포인터보다 좀 더 무겁고 느리게 동작한다.

바로 데이터를 가리키는 원시 포인터제어 블록을 가리키는 포인터 두 개의 포인터를 소유

출처 -&amp;amp;amp;amp;amp;amp;amp;amp;nbsp;https://stonzeteam.github.io/Shared_Ptr/

내부적으로 저렇게 제어블록을 가리키는 포인터가 존재하며 해당 객체의 참조 횟수를 카운팅한다.

위 코드에서 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을 써서 공유 포인터가 가리키는 객체가 존재하는지를 확인해야 함.

 

반응형
Comments