[Modern C++] 항목 37: std::thread들을 모든 경로에서 합류 불가능하게 만들어라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 7. 동시성 API

👉🏻 항목 37: std::thread들을 모든 경로에서 합류 불가능하게 만들어라

🔍 합류 가능성(joinable)

  • 모든 std::thread 객체는 합류 가능(joinable) 상태이거나 합류 불가능(unjoinable) 상태이다.
  • 합류 가능 std::thread는 바탕 실행 스레드 중 현재 실행 중(running)이거나 실행 중 상태로 전이할 수 있는 스레드에 대응된다.
    • 차단된 상태이거나 실행 일정을 기다리는 중인 바탕 스레드에 해당하는 std::thread도 합류 가능 상태이다.
    • 실행이 완료된 바탕 스레드에 해당하는 std::thread 객체도 합류 가능으로 간주한다.
  • 합류 불가능한 std::thread 객체는 다음과 같다.
    • 기본 생성된 std::thread: 실행할 함수가 없으므로 바탕 스레드와 대응되지 않는다.
    • 다른 std::thread 객체로 이동된 후의 std::thread 객체: 원본에 대응되던 바탕 스레드는 대상 std::thread의 바탕 스레드가 된다(그런 스레드가 있었다면).
    • join에 의해 합류된 std::thread: join 이후에는 실행이 완료된 바탕 스레드에 대응되지 않는다.
    • detach에 의해 탈착된 std::thread: std::thread 객체와 바탕 스레드 사이의 연결을 끊는다.

⚠️ 합류 가능한 스레드를 파괴하면 프로그램이 종료된다

  • 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료된다.
  • 예를 들어 필터링 함수 filter와 최댓값 maxVal을 매개변수로 받는 함수 doWork가 있다고 하자.
    • doWork는 필요한 모든 조건이 만족되는지 점검한 후, 0 이상 maxVal 미만의 모든 값 중 주어진 필터를 통과한 값들에 대해 계산을 수행한다.
    • 필터링과 조건 점검에 모두 시간이 오래 걸린다면 두 작업을 동시에 실행하는 것이 합리적이다.
  • 기본적으로는 과제 기반 설계가 좋지만(항목 35), 이번 예제에서는 필터링을 수행하는 스레드의 우선순위를 설정해야 한다고 가정한다.
    • 우선순위를 설정하려면 스레드의 네이티브 핸들이 필요하다.
    • 네이티브 핸들은 std::thread API를 통해서만 얻을 수 있고, 과제 기반 API(std::future 등)는 제공하지 않는다.
    • 따라서 이번에는 스레드 기반 접근방식을 사용할 수밖에 없다.
constexpr auto tenMillion = 10000000;           // constexpr는 항목 15를 보라

bool doWork(std::function<bool(int)> filter,    // 계산 수행 여부를 돌려준다;
            int maxVal = tenMillion)            // std::function은 항목 5를 보라
{
  std::vector<int> goodVals;                    // 필터를 통과한 값들

  std::thread t([&filter, maxVal, &goodVals]    // goodVals에 값들을 채운다
                {
                  for (auto i = 0; i <= maxVal; ++i)
                    { if (filter(i)) goodVals.push_back(i); }
                });

  auto nh = t.native_handle();                  // t의 네이티브 핸들을
  …                                             // 이용해서 t의 우선
                                                // 순위를 설정한다

  if (conditionsAreSatisfied()) {               // 조건들이 만족되었다면
    t.join();                                   // t의 완료를 기다린다
    performComputation(goodVals);
    return true;                                // 계산이 수행되었음을
  }                                             // 뜻하는 값을 반환

  return false;                                 // 수행되지 않았음을
}                                               // 뜻하는 값을 반환
  • C++14에서는 작은따옴표를 숫자 구분자로 사용해서 초기 값을 더 읽기 쉽게 표기할 수 있다.
constexpr auto tenMillion = 10'000'000;         // C++14
  • t의 실행을 시작한 후 우선순위를 설정하는 것은 소 잃고 외양간 고치듯 순서가 틀린 것으로 보인다.
    • t를 유보된 상태(suspended state)로 시작하는 것이 더 나은 설계이지만, 예제에 집중하기 위해 생략했다. 방법은 항목 39를 참고하라.
  • conditionsAreSatisfied()가 true를 돌려주면 별 문제가 없다.
  • 그러나 false를 돌려주거나 예외를 던지면 실행 흐름이 doWork의 끝에 도달해서 t의 소멸자가 호출되는데, 이때 t가 여전히 합류 가능 상태이므로 프로그램 실행이 종료된다.

🔍 소멸자가 이렇게 행동하는 이유

다른 두 옵션이 명백히 더 나쁘기 때문이다.

  • 암묵적 join: std::thread의 소멸자가 바탕 비동기 실행 스레드의 완료를 기다리게 하는 것이다.
    • 합리적으로 들리지만 추적하기 어려운 성능 이상(performance anomaly)이 나타날 수 있다.
    • 예를 들어 conditionsAreSatisfied()가 이미 false를 돌려주었는데도 모든 값에 필터가 적용되길 doWork가 기다리는 것은 직관적이지 않다.
  • 암묵적 detach: std::thread의 소멸자가 객체와 바탕 스레드 사이의 연결을 끊게 하는 것이다. 이 경우 바탕 스레드가 실행을 계속할 수 있다.
    • join만큼이나 합리적으로 들리지만, 유발하는 디버깅 문제는 join보다 더 나쁘다.
    • doWork에서 goodVals는 지역 변수인데, 람다가 참조로 캡처해서 본문 안에서 수정한다(push_back 호출).
    • 람다가 비동기적으로 실행되는 도중에 conditionsAreSatisfied()가 false를 돌려주면 doWork가 반환되고 지역 변수들(goodVals 포함)이 파괴된다.
    • doWork의 스택 프레임이 뽑혀(pop) 실행 흐름이 호출 지점 다음으로 넘어가지만, 해당 스레드는 호출 지점에서 계속 실행된다.
    • 호출 지점 다음의 문장들 중 함수를 호출하는 것이 있다면, 그런 호출 중 적어도 하나는 doWork의 스택 프레임이 차지하던 메모리의 일부 또는 전부를 사용하게 될 수 있다. 그런 함수를 f라고 하자.
    • f가 실행되는 도중에 doWork에서 시작된 람다가 계속 goodVals에 대해 push_back을 호출하면, f의 관점에서는 자신의 스택 프레임에 있는 메모리의 내용이 갑자기 변하는 기현상이 벌어진다. 이를 디버깅하는 것은 매우 괴로운 일이다.
  • 표준 위원회는 합류 가능 스레드를 파괴했을 때의 결과가 충분히 절망적이므로, 그런 파괴를 아예 금지하기로 했다(합류 가능 스레드를 파괴하면 프로그램이 종료됨을 명시).

따라서 std::thread 객체를 사용할 때 그 객체가 정의된 범위 바깥의 모든 경로에서 합류 불가능으로 만드는 것은 프로그래머의 책임이다.

  • ‘모든 경로’에는 범위의 끝에서 시작되는 경로는 물론 return, continue, break, goto, 예외에 의해 건너뛰어 나가는 모든 경로가 포함된다.

🔍 RAII 클래스: ThreadRAII

  • 한 범위의 바깥으로 나가는 모든 경로에서 어떤 동작이 반드시 수행되어야 할 때 흔히 쓰는 접근방식은 그 동작을 지역 객체의 소멸자 안에 넣는 것이다.
    • 그런 객체를 RAII 객체, 그런 객체의 클래스를 RAII 클래스라고 부른다.
    • RAII는 “Resource Acquisition Is Initialization”의 약자이지만, 사실 이 기법의 핵심은 초기화가 아니라 파괴이다.
    • (옮긴이) 기법으로서의 RAII에서는 파괴(특히 소멸자)가 핵심이지만, RAII라는 용어 자체에는 생성자와 초기화에 관한 고민이 깔려 있다.
  • RAII 클래스는 표준 라이브러리에 흔하다.
    • STL 컨테이너: 소멸자가 컨테이너의 내용을 파괴하고 메모리를 해제한다.
    • 표준 스마트 포인터(항목 18~20): std::unique_ptr의 소멸자는 가리키는 객체에 대해 삭제자를 호출하고, std::shared_ptr와 std::weak_ptr의 소멸자는 참조 횟수를 감소한다.
    • std::fstream 객체: 각 소멸자는 해당 파일들을 닫는다.
  • 그렇지만 std::thread 객체에 대한 표준 RAII 클래스는 없다.
    • 아마도 표준 위원회가 join과 detach 둘 다 기본 옵션으로 선택하지 않았고, 어떤 것이어야 할지 파악할 수 없었기 때문일 것이다.
  • 다행히 직접 작성하는 것이 어렵지 않다. 다음은 std::thread를 위한 RAII 클래스 ThreadRAII이다. 소멸 시 바탕 std::thread 객체에 대해 join을 호출할지 detach를 호출할지 지정할 수 있다.
class ThreadRAII {
public:
  enum class DtorAction { join, detach };      // enum class는 항목 10을 보라

  ThreadRAII(std::thread&& t, DtorAction a)    // 소멸자에서 t에
  : action(a), t(std::move(t)) {}              // 대해 동작 a를 수행

  ~ThreadRAII()
  {                                            // 합류 가능성 판단은
    if (t.joinable()) {                        // 아래 본문 참고
      if (action == DtorAction::join) {
        t.join();
      } else {
        t.detach();
      }
    }
  }

  std::thread& get() { return t; }             // 아래 본문 참고

private:
  DtorAction action;
  std::thread t;
};

코드 이해를 돕는 요점

  • 생성자는 std::thread rvalue만 받는다. 그것을 ThreadRAII 객체로 이동할 것이기 때문이다(std::thread 객체는 복사할 수 없다).
  • 생성자의 매개변수들은 호출자가 직관적으로 기억할 수 있는 순서로 선언되어 있다(std::thread를 먼저 지정하고 소멸자 동작을 지정).
    • 그러나 멤버 초기화 목록은 자료 멤버들이 선언된 순서를 따른다.
    • std::thread 객체가 마지막으로 선언되어 있음에 주목하라. 이 클래스에서는 선언 순서가 중요하지 않지만, 일반적으로는 한 자료 멤버의 초기화가 다른 멤버에 의존할 수 있다.
    • std::thread 객체는 초기화되자마자 해당 함수를 실행할 수도 있으므로, 클래스에서 std::thread 자료 멤버는 항상 제일 마지막에 선언하는 것이 좋다. 그러면 다른 모든 멤버가 성공적으로 초기화된 후에 비동기 스레드가 시작되므로 다른 모든 멤버에 안전하게 접근할 수 있다.
  • ThreadRAII는 바탕 std::thread 객체에 접근할 수 있는 get 함수를 제공한다.
    • 표준 스마트 포인터 클래스들이 바탕 raw 포인터에 접근할 수 있는 get 함수를 제공하는 것과 비슷하다.
    • std::thread 인터페이스 전체를 ThreadRAII에 복제할 필요가 없고, std::thread 객체를 요구하는 문맥에서 ThreadRAII 객체를 사용할 수 있다는 뜻이기도 하다.
  • 소멸자는 멤버 함수를 호출하기 전에 먼저 t가 합류 가능인지부터 점검한다.
    • 합류 불가능 스레드에 대해 join이나 detach를 호출하면 미정의 행동이 나오기 때문이다.
    • 클라이언트가 get으로 t에 대한 접근을 얻고 그것을 이용해서 t를 이동하거나 join 또는 detach를 호출할 수도 있는데, 그러면 t는 합류 불가능 상태가 된다.
  • 소멸자에 경쟁 조건(race condition)이 있지 않을까?
if (t.joinable()) {
  if (action == DtorAction::join) {
    t.join();
  } else {
    t.detach();
  }
}
  • t.joinable()의 실행과 join 또는 detach 호출 사이에 다른 스레드가 t를 합류 불가능하게 만들면 경쟁 조건이 성립하지 않을까? 다행히 이는 기우이다.
    • 합류 가능한 std::thread 객체는 오직 멤버 함수 호출(join이나 detach) 또는 이동 연산에 의해서만 합류 불가능한 상태로 변할 수 있다.
    • ThreadRAII 소멸자가 호출되는 시점에서는 그 객체에 대해 그런 멤버 함수를 호출할 만한 스레드가 남아 있지 않아야 한다.
    • 그런 호출들이 동시에 일어난다면 확실히 경쟁이 발생하겠지만, 그 호출들이 일어나는 곳은 소멸자 안이 아니라 하나의 객체에 대해 동시에 두 멤버 함수를 실행하려 하는 클라이언트 코드이다.
    • 일반적으로 하나의 객체에 대해 여러 멤버 함수를 동시에 호출하는 것은 그 멤버 함수들이 const 멤버 함수인 경우에만 안전하다(항목 16).

🔍 ThreadRAII를 적용한 doWork

bool doWork(std::function<bool(int)> filter,    // 이전과 동일
            int maxVal = tenMillion)
{
  std::vector<int> goodVals;                    // 이전과 동일

  ThreadRAII t(                                 // RAII 객체를 사용
    std::thread([&filter, maxVal, &goodVals]
                {
                  for (auto i = 0; i <= maxVal; ++i)
                    { if (filter(i)) goodVals.push_back(i); }
                }),
    ThreadRAII::DtorAction::join                // RAII 동작
  );

  auto nh = t.get().native_handle();
  …

  if (conditionsAreSatisfied()) {
    t.get().join();
    performComputation(goodVals);
    return true;
  }

  return false;
}
  • (옮긴이) 안타깝게도 이 버전 역시 프로그램 종료로 이어질 수 있다. 원서 정오표가 지적하듯이, 만일 doWork의 람다 함수에서 예외가 발생하면 프로그램이 종료된다. 람다 표현식을 std::packaged_task로 감싸서 이 문제를 해결한 코드가 원서 정오표(역자의 글 참고)에 있다.
  • 이 예에서는 ThreadRAII 소멸자가 비동기 실행 스레드에 대해 join을 호출하도록 했다. 앞서 보았듯이 detach를 호출하면 아주 골치 아픈 디버깅 문제가 발생할 수 있기 때문이다.
  • join이 성능 이상(그리고 솔직히 디버깅하기 어려운 버그)을 유발할 수 있다는 점도 앞서 이야기했지만, 미정의 행동(detach에서 발생할 수 있는)과 프로그램 종료(raw std::thread에서 발생할 수 있는), 성능 이상 중 하나를 고르자면 그나마 성능 이상이 제일 덜 나쁘다.
  • 안타깝게도 항목 39에서 보듯이 ThreadRAII를 이용해서 std::thread 소멸 시 join이 실행되게 하면 성능 이상만이 아니라 프로그램이 멈추는(hang) 문제까지 발생할 수 있다.
    • 이런 종류의 문제에 대한 ‘제대로 된’ 해결책은 비동기적으로 실행되는 람다에게 이제 더 일할 필요가 없으니 일찍 반환하라고 알려주는 것이겠지만, C++11은 그런 가로챌 수 있는 스레드(interruptible thread)를 지원하지 않는다.
    • 직접 구현하는 것이 가능하지만, 이는 이 책의 범위를 넘는 주제이다(앤서니 윌리엄스의 책 C++ Concurrency in Action, 섹션 9.2가 이 문제를 잘 다룬다).
  • (옮긴이) 프로그램이 “멈춘다”는 것은 무한 루프나 무기한 대기(차단) 등의 이유로 실행의 흐름이 코드의 한 지점을 벗어나지 못한다는 것이다. 이는 실행의 흐름이 프로그램을 완전히 벗어나서 운영체제로 돌아가는 “종료하다(terminate)”와는 다르다.

🔍 ThreadRAII의 이동 연산 지원

  • ThreadRAII는 소멸자를 선언하므로 컴파일러가 이동 연산들을 작성해주지 않는다(항목 17).
  • 그렇지만 ThreadRAII 객체의 이동을 지원하지 않을 이유는 없다. 컴파일러가 작성해주었을(소멸자 선언이 없었다면) 기본 이동 함수들로 충분하므로, 작성을 명시적으로 요청하면 된다.
class ThreadRAII {
public:
  enum class DtorAction { join, detach };       // 이전과 동일

  ThreadRAII(std::thread&& t, DtorAction a)     // 이전과 동일
  : action(a), t(std::move(t)) {}

  ~ThreadRAII()
  {
    …                                           // 이전과 동일
  }

  ThreadRAII(ThreadRAII&&) = default;           // 이동 연산들을
  ThreadRAII& operator=(ThreadRAII&&) = default; // 지원한다

  std::thread& get() { return t; }              // 이전과 동일

private:                                        // 이전과 동일
  DtorAction action;
  std::thread t;
};

🧐 정리

  • 모든 경로에서 std::thread를 합류 불가능으로 만들어라.
  • 소멸 시 join 방식은 디버깅하기 어려운 성능 이상으로 이어질 수 있다.
  • 소멸 시 detach 방식은 디버깅하기 어려운 미정의 행동으로 이어질 수 있다.
  • 자료 멤버 목록에서 std::thread 객체를 마지막에 선언하라.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기