KoreanFoodie's Study

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

Tutorials/C++ : Advanced

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

GoldGiver 2022. 10. 26. 10:07

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

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

 

모든 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 이후의 std::thread 객체는 실행이 완료된 바탕 실행 스레드에 대응되지 않는다.
  • detach 에 의해 탈착된 std::thread : detach 는 std::thread 객체와 그에 대응되는 바탕 스레드 사이의 연결을 끊는다.

std::thread 의 합류 가능성이 중요한 이유 중 하나는, 만일 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료된다는 것이다. 다음 예시를 보자. 아래 코드에서는 필터링과 doWork 에서 조건들의 만족 여부를 점검하는 것을 별개의 스레드에서 동시에 실행할 것이다.

// C++14 부터는 작은 따옴표로 숫자 구분 가능
constexpr auto tenMillion = 10'000'000;

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

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

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

  ...

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

  // 수행되지 않았음을 알림
  return false;
}

위에서 conditionsAreSatisfied 가 true 를 돌려주면 문제가 없다. 하지만 false 를 돌려주거나 예외를 던지면, 실행의 흐름이 doWork 의 끝에 도달해서 std::thread 객체 t 의 소멸자가 호출되는데, 문제는 이 때 t 가 여전히 합류 가능 상태라는 점이다. 이 때문에 프로그램 실행이 종료된다. 

그렇다면 std::thread 의 소멸자가 왜 이런 식으로 행동할까? 이유는, 다른 두 옵션은 명백히 더 나쁘기 때문이다. 두 옵션은 다음과 같다.

  • 암묵적 join : std::thread 의 소멸자가 바탕 비동기 실행 스레드의 완료를 기다리게 하는 것이다. 이 경우, 실제로는 추적하기 어려운 성능 이상(performance anomaly) 가 나타날 수 있다(예를 들어 conditionAreSatisfied 가 false 인데 나머지 모든 값에 필터가 적용되길 기다릴 수도 있음).
  • 암묵적 detach : std::thread 의 소멸자가 std::thread 객체와 바탕 실행 스레드 사이의 연결을 끊게 하는 것이다. 이때 생길 수 있는 문제의 예를 들어 보자. doWork 에서 goodVals 은 지역 변수인데, 람다가 참조에 의해 캡쳐하므로 비동기적으로 수정이 이루어진다. 그 도중에 conditionsAreSatisfied 가 false 를 돌려주면 doWork 가 반환되면서 지역 변수들이 파괴된다. 이제 doWork 의 스택 프레임이 뽑혀서(pop) 실행의 흐름이 doWork 의 호출 지점 다음으로 넘어가고, 해당 스레드는 doWork 의 호출 지점에서 계속 실행된다. 호출 지점 다음에 doWork 스택 프레임이 차지하던 메모리의 일부 혹은 전부를 사용하는 함수 f 가 실행된다고 하자. goodVals 에 대해 push_back 을 호출하는 람다는 비동기적으로 계속 실행될텐데, 함수 f 의 입장에서는 스택 프레임에 있는 메모리의 내용이 갑자기 변하는 기현상을 겪게 된다!

표준 위원회는 합류 가능 스레드를 파괴했을 때의 결과가 절망적이므로, 그런 파괴를 금지하기로(프로그램 종료) 했다.

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

아쉽게도, std::thread 객체에 대한 표준 RAII 클래스는 없다. 아마도 이는, join 과 detach 둘 다 기본 옵션으로 선택하지 않은 표준 위원회로서는 std::thread 객체에 대한 표준적인 RAII 클래스가 어떤 것이어야 할지 파악할 수 없었기 때문일 것이다.

std::thread 를 위한 RAII 클래스인 ThreadRAII 클래스를 한 번 직접 구현해 보자.

class ThreadRAII {
public:
  enum class DtorAction { join, detach };

  // 생성자는 std::thread 오른값만 받음
  // std::thread 객체는 복사할 수 없음
  ThreadRAII(std::thread&& t, DtorAction a)
  : action(a), t(std::move(t)) {}

  // 소멸자에서 t 에 대해 동작 a 를 수행
  ~ThreadRAII()
  {
    // 합류 가능성 판정
    if (t.joinable()) {
      if (action == DtorAction::join) {
        t.join();
      } else {
        t.detach();
      }
    }
  }

  // 스마트 포인터의 get 인터페이스와 비슷
  std::thread& get() { return t; }

private:
  // std::thread 객체가 초기화되자마자 해당 함수를 실행할 수 있으므로
  // std::thread 자료 멤버는 마지막에 선언/초기화하는 것이 좋음
  DtorAction action;
  std::thread t;
};

 

 t.joinable 부분에서 경쟁 조건(race condition) 이 존재할 수 있을까? 즉, joinable 과 join 사이에 다른 스레드가 t 를 합류 불가능하게 만드는 경우를 의미한다.

답은 '그렇지 않다' 이다. 합류 가능한 std::thread 객체는 오직 멤버 함수 호출(join 이나 detach) 또는 이동 연산에 의해서만 합류 불가능한 상태로 변할 수 있다. ThreadRAII 객체의 소멸자가 호출되는 시점에서는 그 객체에 대해 그런 멤버 함수를 호출할 만한 스레드가 남아 있지 않아야 한다. 그런 호출들이 일어나는 곳은 소멸자 안이 아니라 하나의 객체에 대해 동시에 두 멤버 함수(하나는 소멸자, 또 하나는 어떤 멤버 함수)를 실행하려 하는 클라이언트 코드이다. 일반적으로 하나의 객체에 대해 여러 멤버 함수들을 호출하는 것은 그 멤버 함수들이 const 멤버 함수인 경우에만 안전하다.

 

이전의 doWork 예제에 ThreadRAII 를 적용한 결과는 다음과 같다.

bool doWork(std::function<bool(int)> filter,
  int maxVal = tenMillion)
{
  std::vector<int> goodVals;

  // RAII 객체 이용
  ThreadRAII t(
    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;
}

join 을 사용하면 성능 이상을 유발할 수 있지만, detach 로 인해 발생할 수 있는 미정의 행동과 프로그램 종료보다는 덜 나쁘다. 물론 위의 코든느 프로그램이 멈추는(hang) 문제도 발생할 수 있지만, 이는 C++11 의 가로챌 수 있는 스레드(interruptible thread) 를 사용해야 하며, 이는 이 책의 범위를 넘는 주제이다(C++ Concurrency in Action 책 참고).

ThreadRAII 는 소멸자를 선언하므로, 객체의 이동 연산들을 default 로 선언해 컴파일러에게 명시적으로 요청해주면 된다.

ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;

 

Comments