{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[C++] The C++ Programming Language: 5. A Tour of C++: Concurrency and Utilities (1)

 5.1 Introduction

  최종 사용자(end-user)의 관점에서 보았을 때, 이상적인 표준 라이브러리는 모든 필요를 직접적으로 지원하는 컴포넌트들을 지원해주어야 합니다. 응용프로그램 도메인에서, 거대한 상용라이브러리는 그러한 이상에 근접해있죠. 하지만 C++ 표준 라이브러리의 목표는 그러한 것이 아닙니다. 관리 가능하고 보편적인 라이브러리는 모두를 만족시킬 수 없습니다. 대신 C++ 표준 라이브러리는 대부분의 응용프로그램 영역에서 대부분의 사람들에게 유용할 컴포넌트들을 제공하는데 목적을 두죠. 즉, 다양한 필요들의 교집합에 집중하고 있다는 것입니다. 추가적으로, 꽤나 중요한 응용프로그램 영역들을 위해 수학적 계산이나 문자열 처리 등은 서서히 들어왔습니다.


5.2 Resource Management

  꽤나 많은 프로그램들의 핵심 작업 중 하나는 자원 관리입니다. 자원은 습득된 이후에 반드시 해제되어야 하는 것입니다. 메모리, 락(locks), 소켓, 스레드 핸들, 파일 핸들 등이 그 예입니다. 오랫동안 동작하는 프로그램들의 경우, 적시에 자원을 해제해주지 못해(누수) 심각한 성능 저하를 야기하고 결국에는 크래쉬라는 비참한 최후를 맞이하기도 합니다. 짧은 프로그램의 경우도 누수로 인해 메모리를 제한 당하고, 실행 시간이 수십 배 증가하여 곤란해질 수 있습니다.

  표준 라이브러리 컴포넌트는 자원 누수를 피하도록 설계되었습니다. 이를 위해 생성자와 소멸자 쌍을 이용해 해당 객체의 생명주기를 벗어나지 않는 언어적 차원의 자원 관리를 이야기하려고 합니다. Vector에서의 생성자와 소멸자 쓰임은 요소들의 생명주기를 관리하기 위함이었습니다. 모든 표준 라이브러리 컨테이너는 이와 비슷하게 구현되어 있습니다. 중요한 사실은, 이러한 접근이 예외를 이용한 에러 처리와 올바르게 상호작용한다는 것입니다. 예를 들어, 표준 라이브러리 lock 클래스가 있죠:

1
2
3
4
5
6
7
mutex m; // used to protect access to shared data
// ...
void f()
{
unique_lock<mutex> lck {m}; // acquire the mutex m
// ... manipulate shared data ...
}
cs

  thread는 lck의 생성자가 그의 mutex, m을 획득할 때까지 진행되지 않습니다. 해당 소멸자는 자원을 해제하죠. 이 예제해서 unique_lock의 소멸자는 제어 스레드가 f()를 떠날 때 mutex를 해제합니다.

  이는 Resource Acquisition Is Initialization(RAII) 기법입니다. 이 기술은 C++ 내 자원의 관용적인 처리의 기본입니다. 컨테이너, string, iostream은 그들의 자원을 비슷하게 관리합니다.


5.2.1 unique_ptr and shared_ptr

  지금까지의 예제들은 스코프 안에서 정의된 객체들이 스코프 바깥으로 나갈 때 해제되는 것을 다루었습니다. 하지만 자유 공간에 할당된 객체들은 어떤가요? <memory>에서는 스마트 포인터라는 두 개의 표준 라이브러리를 제공합니다. 자유 공간의 객체들을 쉽게 다룰 수 있는 수단이죠:

  1. unique_ptr는 단독의 소유를 표현합니다.
  2. shared_ptr은 여럿의 소유를 표현합니다.

  이러한 스마트 포인터의 기본적인 쓰임은 부주의한 프로그래밍으로 나타나는 메모리 누수를 예방하는 것에 있습니다. 가령:

1
2
3
4
5
6
7
8
9
10
11
12
void f(int i, int j) // X* vs. unique_ptr<X>
{
    X∗ p = new X; // allocate a new X
    unique_ptr<X> sp {new X}; // allocate a new X and give its pointer to unique_ptr
    // ...
    if (i<99) throw Z{}; // may throw an exception
    if (j<77return// may retur n "ear ly"
    p−>do_something(); // may throw an exception
    sp−>do_something(); // may throw an exception
    // ...
    delete p; // destroy *p
}
cs

  위와 같은 코드에서 우리는 i<99이거나 j<77일 때 p를 삭제하는 것을 까먹었습니다. 반면, unique_ptr는 f()로부터 탈출할 때 해당 객체를 소멸 시켜주죠. 역설적으로, 우리는 포인터와 new를 사용하지 않음으로써 이를 해결할 수 있습니다.

1
2
3
4
5
void f(int i, int j) // use a local var iable
{
    X x;
    // ...
}
cs

  안타깝게도, new의 과도한 사용은 문제를 늘릴 뿐입니다.

  하지만, 포인터가 정말로 필요할 때가 있습니다. unique_ptr은 내장 포인터의 적절한 쓰임과 비교해 공간이나 시간의 오버헤드가 없어 아주 가볍습니다. 이는 함수의 시작과 끝에서 객체가 할당된 자유 공간을 전달하는데 널리 쓰입니다:

1
2
3
4
5
6
unique_ptr<X> make_X(int i)
    // make an X and immediately give it to a unique_ptr
{
    // ... check i, etc. ...
    return unique_ptr<X>{new X{i}};
}
cs

  unique_ptr는 vector가 일련의 객체를 다루는 것과 아주 비슷한 방식으로 개개의 객체에 대해 다루는 핸들입니다. 두 경우 모두 다른 객체의 생명주기를 제어하며, 단순하고 효율적인 반환을 위해 이동 의미 체계(move semantics)에 의존합니다.

  shared_ptr는 unique_ptr과 비슷하지만, shared_ptr는 이동을 넘어 복사가 가능하다는 차이가 있습니다. 한 객체에 대한 shared_ptr는 객체의 소유권을 공유하며 마지막 shared_ptr가 소멸되었을 때, 해당 객체가 소멸됩니다. 다음을 참고하세요:

1
2
3
4
5
6
7
8
9
10
11
12
void f(shared_ptr<fstream>);
void g(shared_ptr<fstream>);
 
void user(const string& name, ios_base::openmode mode)
{
    shared_ptr<fstream> fp {new fstream(name ,mode)};
    if (!∗fp) throw No_file{}; // make sure the file was properly opened
 
    f(fp);
    g(fp);
    // ...
}
cs

  fp의 생성자에 의해 열린 파일은 fp의 복사를 소멸시키는 마지막 함수에 의해 닫힐 것입니다. f() 혹은 g()가 fp의 복사본을 또 생성하는 작업을 수행할 수 있고, 다른 방법으로도 user() 바깥에 복사본들이 저장될 수 있다는 사실을 명심하세요. 따라서 shared_ptr은 메모리 관리 객체의 소멸자 기반 자원 관리에 의거한 가비지 컬렉션의 형태를 제공합니다. 이는 비용이 들지 않는 것도 아니고 막대하게 비싼 것도 아니지만, 공유 객체의 생명주기를 예측하기 어렵게 만듭니다. shared_ptr는 소유권의 공유가 정말로 필요할 때만 사용하세요.

  unique_ptr과 shared_ptr로 우리는 "no naked new" 정책을 적용한 프로그램들을 성공적으로 구현할 수 있습니다. 하지만 이 스마트 포인터들은 개념적으로 여전히 포인터이므로 자원 관리에서 두 번째 선택일 뿐입니다. 자기들의 자원을 높은 개념적 수준에서 관리하는 컨테이너들과 다른 자료형들의 다음인 것이죠. 특히, shared_ptr은 그들 스스로 그들의 소유자가 공유 객체를 읽거나 쓸 수 있는 규칙을 제공하지 않습니다. 데이터 경쟁(Data races)와 다른 형태의 혼란들은 단순히 자원 관리의 문제들을 없앤다고 해결되지 않습니다.

  우리가 특정한 리소스를 위해 설계된 연산들이 있는 리소스 핸들(vector 혹은 thread)을 사용하는 것 대신 스마트 포인터(unique_ptr 등)를 사용하는 것은 언제일까요? 아니나 다를까, "우리가 포인터를 활용해야 할 때"입니다.

  • 객체를 공유할 때, 우리는 공유될 객체에 대한 포인터(혹은 참조)가 필요합니다. 따라서 shared_ptr은 적절한 선택이 될 수 있습니다.
  • 다형성 객체를 참조할 때, 우리는 포인터(혹은 참조)가 필요합니다. 왜냐하면 참조될 객체가 어떤 자료형인지, 어떤 크기를 지녔는지 모르기 때문입니다. 따라서 unique_ptr는 이러한 경우 적절한 선택이 될 수 있습니다.
  • 공유되는 다형성 객체는 보통 shared_ptr을 사용합니다.

  우리는 함수로부터 객체들의 컬렉션을 반환하는데 포인터를 사용할 필요가 없습니다. 리소스 핸들인 컨테이너는 이를 효율적이고 간단하게 관리해줄 것입니다.


계속.

댓글

이 블로그의 인기 게시물

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링

[코딩의탑] 5층: 포탈(Portal), 더 나아가기