쓰레기 수거(garbage collection) 기능을 갖춘 언어를 사용하는 프로그래머들은 C++ 프로그래머들이 자원 누수를 막기 위해 애쓰는 모습을 보고 비웃습니다. 그들은 "얼마나 원시적인가! 1960년대 Lisp의 메모를 못 본 모양인데, 자원의 수명은 사람이 아니라 컴퓨터가 관리해야지!"라고 조롱합니다. 그러나 C++ 개발자들도 만만치 않습니다. "무슨 메모? 메모리가 유일한 자원이고 자원 재확보 시점이 비결정론적인 그 메모? 우리는 소멸자들의 일반성과 예측가능성을 선호하니 상관없음." 그러나 이는 부분적으로 허세입니다. 쓰레기 수거는 실제로 편리하며, 수동적인 수명 관리는 돌칼과 곰 가죽을 이용해서 니모닉 메모리 회로를 구축하는 것과 비슷해 보일 정도입니다. 두 세계의 장점만 취할 수는 없을까요?? 즉, 자동으로 작동하지만(쓰레기 수거처럼) 모든 종류의 자원에 적용되며 그 시점을 예측할 수 있는(소멸자처럼) 자원 관리 시스템을 만들 수는 없을까요??
(* 니모닉 메모리 회로(mnemonic memory circuit)는 SF 드라마 트렉(Star Trek)에 나오는 23세기의 컴퓨터 기술 중 하나입니다. "돌칼과 곰 가죽"이라는 표현 자체도 스타 트렉에 나오는 것입니다.)
C++11에서 두 세계를 합치는 수단이 바로 std::shared_ptr입니다. 공유 포인터, 즉 std::shared_ptr를 통해서 접근되는 객체의 수명은 그 공유 포인터가 공유된 소유권(shared wonership) 의미론을 통해서 관리합니다. 특정한 하나의 std::shared_ptr가 객체를 소유하는 것이 아님을 주의하기 바랍니다. 그 대신 모든 std::shared_ptr는 객체가 더 이상 필요하지 않게 된 시점에서 객체가 파괴됨을 보장하기 위해 협동합니다. 객체를 가리키던 마지막 std::shared_ptr가 객체를 더 이상 가리키지 않게 되면(이를테면 그 std::shared_ptr가 다른 객체를 가리키게 되었거나 자신이 파괴되는 시점이어서), 그 std::shared_ptr는 자신이 가리키는 객체를 파괴합니다. 쓰레기 수거처럼, 클라이언트를 공유 포인터가 가리키는 객체의 수명에 대해 신경 쓸 필요가 없습니다. 그러나 소멸자처럼, 객체의 파괴 시점은 결정론적입니다.
그런데 std::shared_ptr는 자신이 객체를 가리키는 최후의 공유 포인터임을 어떻게 알까요?? 그 비결은 바로 자원의 참조 횟수(reference count)에 있습니다.
참조 횟수는 관리되는 자원에 연관된 값으로, 그 자원을 가리 키는 std::shared_ptr들의 개수에 해당합니다. std::shared_ptr의 생성자는 이 참조 횟수를 증가하고(그렇지 않은 경우도 있는데, 잠시 후에 설명합니다), 소멸자는 감소합니다. 복사 배정 연산자는 증가와 감소를 모두 수행합니다. (sp1과 sp2가 서로 다른 객체를 가리키는 std::shared_ptr들이라 할 때, "sp1 = sp2;"에 의해 sp1은 sp2가 가리키던 객체를 가리키게 됩니다. 이러한 배정의 결과로, 원래 sp1이 가리키던 자원에 대한 참조 횟수가 감소하고 sp2가 가리키는 자원에 대한 참조 횟수는 증가합니다.) 어떤 std::shared_ptr가 자원의 참조 횟수를 감소한 후 그 횟수가 0이 되었다면, 그 자원을 가리키는 std::shared_ptr가 더 이상 없다는 뜻입니다. 그러면 그 std::shared_ptr는 자원을 파괴합니다.
이러한 참조 횟수 관리는 성능에 다음과 같은 영향을 미칩니다.
- std::shared_ptr의 크기는 생 포인터의 두 배입니다. 내부적으로 자원을 가리키는 생 포인터뿐만 아니라 자원의 참조 횟수를 가리키는 생 포인터도 저장해야 하기 때문입니다.
(* 표준이 이러한 구현 방식을 요구하는 것은 아니지만, 내게 익숙한 모든 표준 라이브러리 구현은 이런 방식을 사용합니다.)
- 참조 횟수를 담을 메모리를 반드시 동적으로 할당해야 합니다. 개념적으로 참조 횟수는 공유 포인터가 가리키는 객체에 연관된 것이지만, 그 객체 자체는 참조 횟수를 전혀 알지 못합니다. 따라서 객체는 참조 횟수를 담을 장소를 따로 마련하지 않습니다. (이로부터 비롯된 바람직한 귀결 하나는, 사용자 정의 객체뿐만 아니라 내장 형식들까지 포함한 모든 객체를 std::shared_ptr로 관리할 수 있다는 것입니다.) Chapter(21)에서 설명하겠지만, std::make_shared를 이용해서 std::shared_ptr를 생성하면 동적 할당의 비용을 피할 수 있습니다. 그러나 std::make_shared를 사용할 수 없는 상황들도 존재합니다. 어떤 경우이든, 참조 횟수는 동적으로 할당된 자료로서 저장됩니다.
- 참조 횟수의 증가와 감소가 반드시 원자적 연산이어야 합니다. 여러 스레드가 참조 횟수를 동시에 읽고 쓰려 할 수 있기 때문입니다. 예를 들어 어떤 자원을 가리키는 어떤 std::shared_ptr의 소멸자가 한 스레드에서 실행되는(그래서 그것이 가리키는 자원의 참조 횟수를 감소하는) 도중에 다른 어떤 스레드에서 같은 자원을 가리키는 std::shared_ptr가 복사될(그래서 같은 참조 횟수가 증가할) 수도 있습니다. 대체로 원자적 연산은 비 원자적 연산보다 느리므로, 비록 참조 횟수가 워드 하나 크기라고 해도 그것을 읽고 쓰는 연산이 비교적 느릴 것이라고 가정해야 마땅합니다.
앞에서 std::shared_ptr의 생성자가 피지칭 객체의 참조 횟수를 증가하지 "않는" 경우도 있다고 말했는데, 어떤 경우인지 궁금한 독자를 위해 이제부터 이야기해 보겠습니다. 어떤 객체를 가리키는 std::shared_ptr를 생성하면, 그 객체를 가리키는 std::shared_ptr의 개수는 반드시 1 이상이 됩니다. 그렇다면 참조 횟수는 당연히 "항상" 증가해야 할 것입니다. 그렇지 않은 경우가 있는 것은 왜일까요?
답은 이동 생성입니다. 기존의 std::shared_ptr를 이동해서 새 std::shared_ptr를 생성하면, 원본 std::shared_ptr는 널이 됩니다. 즉, 새 std::shared_ptr의 수명이 시작되는 시점에서 기존의 std::shared_ptr는 더 이상 자원을 가리키지 않는 상태가 됩니다. 따라서 std::shared_ptr를 이동하는 것이 복사하는 것보다 빠릅니다. 복사 시에는 참조 횟수를 증가해야 하지만 이동 시에는 증가할 필요가 없습니다. 배정에서도 마찬가지입니다. 이동 생성이 복사 생성보다 빠르듯이, 이동 배정은 복사 배정보다 빠릅니다.
std::unique_ptr(Chapter(18) 참고)처럼 std::shared_ptr는 delete를 기본적인 자원 파괴 메커니즘으로 사용합니다. 또한 커스텀 삭제자를 지원한다는 점도 동일합니다. 그러나 삭제자를 지원하는 구체적인 방식은 std::unique_ptr의 것과 다릅니다. std::unique_ptr에서는 삭제자의 형식이 똑똑한 포인터의 형식의 일부였지만, std::shared_ptr에서는 그렇지 않습니다.
auto loggingDel = [](Widget *pw) // 커스텀 삭제자
{ // (Chapter(18)의 것과 같음)
makeLogEntry(pw);
delete pw;
};
std::unique_ptr< // 삭제자의 형식이 포인터 형식의 일부임
Widget, decltype(loggingDel)
> upw(new Widget, loggingDel);
std::shared_ptr<Widget> // 삭제자의 형식이 포인터 형식의 일부가 아님
spw(new Widget, liggingDel);
std::shared_ptr의 설계가 더 유연합니다.
'컴퓨터과학' 카테고리의 다른 글
C++ (19-3) 소유권 공유 자원의 관리에는 std::shared_ptr를 사용할 것 (0) | 2020.06.20 |
---|---|
C++ (19-2) 소유권 공유 자원의 관리에는 std::shared_ptr를 사용할 것 (0) | 2020.06.20 |
[Effective C++] (18-3) 소유권 독점 자원의 관리에는 std::unique_ptr를 사용할 것 (0) | 2020.06.17 |
[Effective C++] (18-2) 소유권 독점 자원의 관리에는 std::unique_ptr를 사용할 것 (0) | 2020.06.15 |
[Effective C++] (똑똑한 포인터) / Chapter(18-1) 소유권 독점 자원의 관리에는 std::unique_ptr를 사용할 것 (0) | 2020.06.14 |