이번장에서는 c++ 11부터 제공되는 안전하고 개선된 스마트 포인터 중에 하나인 shared_ptr에 대해서 설명 드립니다.
shared_ptr의 특징
shared_ptr은 자원(포인터)을 참조 카운팅을 통해서 관리합니다.
shared_ptr는 내부적으로 자원의 주소와 참조 카운팅을 수행할 control block을 가집니다.
그 덕분에 하나 이상의 shared_ptr이 자원을 소유 할 수 있습니다. 자원을 소유한 shared_ptr의 객체 수가 0이 되어
참조 카운트가 0이 될 때 소멸자를 통해서 자원을 할당 해제 합니다.
empty상태의 shared_ptr은 자원과 control block을 가지지 않습니다.
객체 할당 및 할당 해제를 위한 함수객체를 커스텀해서 전달할 수 있습니다.
shared_ptr의 참조 카운트에 대한 수정 작업은 스레드 세이프하게 동작합니다.
(내부적으로 vs2019에서는 WIN API인 InterLock함수를 통해서 스레드 안전성을 보장합니다. 병목이 될 수 있음!)
struct Sample {
int content_;
Sample(int content) : content_(content) {
std::cout << "Constructing Sample(" << content_ << ")" << std::endl;
}
~Sample() {
std::cout << "Deleting Sample(" << content_ << ")" << std::endl;
}
void print(){
std::cout << "Sample Print (" << content_ << ")" << std::endl;
}
};
shared_ptr의 원활한 설명을 위해서 Sample이라는 구조체를 사용합니다.
make_shared를 사용한 shared_ptr 생성
shared_ptr을 안전하게 생성하는 것을 지원하기 위해서 make_shared 함수를 지원합니다.
make_unique와 마찬가지로 make_shared도 exception에 안전하고 하나의 호출로 control_block과 자원(포인터)을
할당 하여 생성 오버헤드를 줄일 수 있습니다.
shared_ptr을 생성할 때는 make_shared를 사용 할 것을 권장드립니다.
// make_shared 함수를 사용해서 안전하게 shared_ptr을 생성합니다.
std::shared_ptr<Sample> pSample = std::make_shared<Sample>(1);
// 이렇게도 사용할 수 있지만
// 위 방식보다는 조금 비 효율적입니다.
std::shared_ptr<Sample> pSample2(new Sample(1));
위의 예제와 같이 shared_ptr을 두 가지 방법으로 생성을 했을 경우에 디버깅을 해보면 pSample과 pSample2의
구조가 다른 것을 확인할 수 있습니다. pSample은 control block으로 Ref_count_obj2를 사용하고
pSample2은 control block으로 Ref_count를 사용하는 것을 확인 할 수 있습니다.
(내부적으로 보면 Ref_count와 Ref_count_obj2는 _Ref_count_base를 상속받아서 구현되어 있습니다. )
이것을 그림으로 그려보면 위와 같이 동작하는데 make_shared를 사용하면 한 번의 힙 메모리 할당(new Ref_count_obj2)
으로 생성이 가능하고 연속된 메모리로 할당이 가능합니다.
(참조 카운트와 할당 자원은 같이 수정될 가능성이 높기 때문에 cpu 캐싱이나 메모리 페이지를 통해서 효율적으로
접근이 될 가능성이 높습니다. )
shared_ptr 생성자를 통한 객체 생성은 할당 자원과 Ref_count가 개별적인 힙 메모리 할당을 진행해야 하기 때문에
make_shared로 객체를 할당 하는 것 보다 성능이 떨어집니다.
shared_ptr의 복사 생성자 및 대입 연산자
아래의 코드와 같이 shared_ptr객체를 복사 생성자나 복사 대입 연산자로 객체를 생성할 수 있습니다.
{
// shared_ptr 생성
std::shared_ptr<Sample> pSample = std::make_shared<Sample>(1);
{
// 복사 생성자를 통한 shared_ptr 생성
std::shared_ptr<Sample> pSample2(pSample);
// 복사 대입 연산자를 통한 shared_ptr 생성
std::shared_ptr<Sample> pSample3;
pSample3 = pSample;
}
}
// 최종적으로 모든 shared_ptr이 소멸되는 이시점에서
// 할당된 자원 및 control block을 할당 해제 합니다.
예제의 코드를 수행해보면 최종적으로 control block의 _Use(참조 카운트)가 3으로 변경된 것을 확인할 수 있습니다.
그림과 같이 복사로 객체가 생성되면 shared_ptr의 자원과 control_block을 공유하는 것을 확인할 수 있습니다.
shared_ptr의 이동 생성자 및 대입 연산자
shared_ptr은 이동 연산자를 지원합니다. 이동 연산을 하면 원본은 empty가 되고 참조 카운트가 늘어나지 않은 채
새로운 객체를 생성합니다. 이 상태에서는 원본에 더 이상 접근하면 안 됩니다.
// shared_ptr의 이동 생성자를 통한 객체 생성 예제
std::shared_ptr<Sample> pSample = std::make_shared<Sample>(1);
std::shared_ptr<Sample> pSample2(std::move(pSample));
shared_ptr의 할당 해제
참조된 모든 shared_ptr의 개수가 0이 되면 할당된 자원을 시스템에 반환합니다.
shared_ptr의 참조 카운트 내부 동작
shared_ptr의 참조 카운트는 실제로 어떻게 수정되고 있을까요?
shared_ptr의 내부 구조를 간단하게 정리하면 아래 그림과 같이 구성되어 있습니다.
해당 구조의 클래스의 소스 코드를 추적해서 자세히 알아봅니다.
// [shared_ptr class]
// shared_ptr의 구현 부분을 확인해보면 _Ptr_base를 상속받도록 하여 구현됩니다.
// weak_ptr도 _Ptr_base를 상속받습니다.
template <class _Ty>
class shared_ptr : public _Ptr_base<_Ty> {
//... 내부 코드 내용들
}
shared_ptr 클래스 구현 코드로 이동해보면 _Ptr_base 클래스를 상속받아서 구현되어 있는 것을 확인할 수 있습니다.
// [_Ptr_base class]
// @설명 : _Ptr_base 클래스는 shared_ptr과 weak_ptr의 공통된 부분을 정의하기
// 위한 클래스입니다.
template <class _Ty>
class _Ptr_base {
//... 내부 코드 내용들
private:
// 자원(포인터) 정보 주소
element_type* _Ptr{nullptr};
// 참조 카운트 정보 주소
_Ref_count_base* _Rep{nullptr};
}
_Ptr_base 클래스로 이동해보면 앞에서 설명한 자원(_Ptr) 및 참조 카운트를 관리하는 변수(_Rep)를 찾을 수 있습니다.
// [_Ref_count_base class]
// @설명 : _Ref_count_base는 참조 카운트를 관리하기 위한 다양한 객체들의 공통 부분을 정의하기
// 위한 클래스입니다.
// make_shared에서 설명한 _Ref_count_obj2와 _Ref_count도 해당 클래스를 상속받아 구현합니다.
class __declspec(novtable) _Ref_count_base
{
// common code for reference counting
private:
//... 내부 코드 내용들
// 참조 카운트로 사용되는 변수
_Atomic_counter_t _Uses = 1;
// Weak 카운트로 사용되는 변수
_Atomic_counter_t _Weaks = 1;
public :
// @설명 : 참조 카운트를 증가 시킵니다.
void _Incref() noexcept { // increment use count
// _MT_INCR은 InterLockedIncreament로 구현되어 있습니다.
_MT_INCR(_Uses);
}
// @설명 : 참조 카운트를 감소 시킵니다.
void _Decref() noexcept { // decrement use count
// _MT_INCR은 InterLockedDecrement로 구현되어 있습니다.
if (_MT_DECR(_Uses) == 0) {
_Destroy();
_Decwref();
}
}
//... 내부 코드 내용들
}
#define _MT_INCR(x) _INTRIN_RELAXED(_InterlockedIncrement)(reinterpret_cast<volatile long*>(&x))
#define _MT_DECR(x) _INTRIN_ACQ_REL(_InterlockedDecrement)(reinterpret_cast<volatile long*>(&x))
_Ref_count_base 클래스로 이동해보면 _Uses와 _Weaks 변수를 찾을 수 있습니다.
참조 카운트를 관리하는 변수는 _Uses입니다.
해당 변수를 수정하는 함수를 살펴보면이전에 Window 챕터에서 설명한 InterLocked 함수를 통해서 수정되는 것을
확인할 수 있습니다. InterLocked 함수는 단일 객체에 대한 수정에 대해서 스레드 동기화를 지원하기 때문에
shared_ptr에서 참조 카운트 관련 작업은 스레드 세이프하게 동작합니다.
주의할 것은 실제 사용하는 자원(포인터)에 대해서는 스레드 세이프하지 않습니다.
Shared_ptr 사용 시 주의할 점
// @설명 : 매개변수를 통해서 shared_ptr를 복사합니다.
void SampleCopyPrint(std::shared_ptr<Sample> copy)
{
copy->print();
}
// @설명 : 매개변수를 shared_ptr를 참조합니다.
void SampleReferencePrint(std::shared_ptr<Sample>& ref)
{
ref->print();
}
위에 구현된 SampleCopyPrint함수와 SampleReferencePrint함수 둘 중 어떤 것이 더 좋아 보이시나요?
참조 카운트의 동작을 이해하신 분이라면 SampleReferencePrint함수가 더욱 효율적이란 것을 이해하실 수 있습니다.
참조 카운트의 증가는 스레드 동기화 비용을 수반합니다.(참조 카운트는 InterLocked 함수 사용)
일반적으로는 비용이 크지 않지만 경합이 많이 발생되는 shared_ptr의 경우 동기화 비용이 기하급수적으로
증가할 수 있습니다.(스레드 동기화의 고질적 문제)
이러한 이유 때문에 shared_ptr 사용 시에는 객체의 복사가 최소한이 되도록 설계하는 것이 좋습니다.
shared_ptr의 순환 참조 문제
shared_ptr의 순환 참조가 발생할 수 있는데 간단히 설명하면 shared_ptr이 소유한 클래스(자원)가 멤버변수로
같은 형태의 shared_ptr을 가지는 경우에 발생할 수 있습니다.
자세한 내용은 weak_ptr 글에 놓았습니다. 참조 부탁드리겠습니다.
자주 사용하는 멤버 함수
get 함수
// 소유한 포인터를 리턴합니다.
_NODISCARD element_type* get() const noexcept {
return _Ptr;
}
소유한 자원(포인터)에 대한 접근을 명시적으로 지원합니다.
연산자 *, 연산자 -> 오버로딩
template <class _Ty2 = _Ty, enable_if_t<!disjunction_v<is_array<_Ty2>, is_void<_Ty2>>, int> = 0>
_NODISCARD _Ty2& operator*() const noexcept {
return *get();
}
template <class _Ty2 = _Ty, enable_if_t<!is_array_v<_Ty2>, int> = 0>
_NODISCARD _Ty2* operator->() const noexcept {
return get();
}
shared_ptr은 연산자 *와 연산자 ->를 오버 로딩하여 해당 연산자를 호출하면 자원(포인터)에 직접 접근할 수 있습니다.
연사자 오버 로딩 내부는 get() 함수를 통해서 구현되어 있습니다.
get 함수 및 연산자 *의 예제
std::shared_ptr<int> pInt = std::make_shared<int>(1);
if (pInt.use_count() > 0)
{
// pInt의 주소 int *
std::cout << "pInt.get() =" << pInt.get() << std::endl;
// pInt의 주소 int 값에 직접 접근
std::cout << "*pInt =" << *pInt << std::endl;
}
reset 함수
// reset 입력 변수가 없으면 empty shared_ptr과 swap합니다.
// 소유하고 있는 자원의 반납합니다.
void reset() noexcept { // release resource and convert to empty shared_ptr object
shared_ptr().swap(*this);
}
// reset 입력 변수의 shared_ptr과 swap합니다.
// 소유하고 있는 자원의 반납합니다.
template <class _Ux>
void reset(_Ux* _Px) { // release, take ownership of _Px
shared_ptr(_Px).swap(*this);
}
reset 함수를 호출하면 자기가 소유한 자원을 반납하고 입력 변수가 있으면 해당 값으로 치환하고
입력 변수가 없다면 empty상태로 설정합니다.
reset 함수 예제
std::shared_ptr<Sample> pSample = std::make_shared<Sample>(1);
if (pSample.use_count() > 0)
{
std::cout << "[NonReset] *pSample = ";
pSample->print();
// reset으로 Sample(2) 객체 할당 -> Sample(1) 객체 할당 해제
pSample.reset(new Sample(2));
std::cout << "[.reset(new 2)] *pSample =";
pSample->print();
// reset으로 비어 있는 shared_ptr 셋팅 -> Sample(2) 객체 할당 해제
pSample.reset();
std::cout << "[.reset()] pSample.get() = " << pSample.get() << std::endl;
}
use_count 함수
_NODISCARD long use_count() const noexcept {
return _Rep ? _Rep->_Use_count() : 0;
}
shared_ptr의 참조 카운트를 리턴합니다.
owner_before 함수
template <class _Ty2>
_NODISCARD bool owner_before(const _Ptr_base<_Ty2>& _Right) const noexcept { // compare addresses of manager objects
return _Rep < _Right._Rep;
}
입력된 _Rep의 주소와 자신의 _Rep의 주소를 비교합니다. 입력된 값이 크면 TRUE, 아니면 FALSE
unique 함수
_NODISCARD _CXX17_DEPRECATE_SHARED_PTR_UNIQUE bool unique() const noexcept {
// return true if no other shared_ptr object owns this resource
return this->use_count() == 1;
}
shared_ptr의 참조 카운트가 1인지 체크해서 1이면 True, 아니면 FALSE
MSDN shared_ptr 주소
https://docs.microsoft.com/ko-kr/cpp/standard-library/shared-ptr-class?view=vs-2019#owner_before
'c++ > modern c++' 카테고리의 다른 글
[c++] std::move와 std::forward (4) | 2020.03.07 |
---|---|
[c++] Move semantics (0) | 2020.03.07 |
[c++] 람다(Lambda) 표현식 (0) | 2020.03.04 |
[c++] weak_ptr (1) | 2020.03.02 |
[c++] unique_ptr (0) | 2020.02.27 |