스토리텔링 개발자

[Effective Modern C++] 37. std::thread는 unjoinable하게 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 37. std::thread는 unjoinable하게

김디트 2025. 4. 11. 11:20
728x90

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

 

 

 

합류 가능(joinable) std::thread
  • 현재 실행중이거나 실행중 상태로 전이할 수 있는 스레드
  • 이에 대응하는 객체
    • 차단된 상태인 std::thread
    • 실행 일정을 기다리는 중인 std::thread
    • 실행 완료된 std::thread

 

 

 

합류 불가능(unjoinable) std::thread
  • 합류할 수 없는 스레드
  • 이에 대응하는 객체
    • 기본 생성된 std::thread
      • 실행할 함수가 없기 때문이다.
    • 다른 std::thread 객체로 이동된 후의 std::thread
      • 바탕 스레드가 다른 std::thread의 바탕 스레드가 된다.
    • join에 의해 합류된 std::thread
      • join 이후의 std::thread 객체는 실행이 완료된 바탕 실행 스레드에 대응되지 않는다.
    • detach에 의해 탈착된 std::thread
      • detach는 std::thread 객체와 그에 대응되는 바탕 스레드 사이의 연결을 끊는다.

 

 

 

std::thread의 joinable이 중요한 이유
  • joinable 스레드의 소멸자가 호출되면 프로그램이 종료된다.
  • 아래는 문제가 있는 코드
constexpr auto tenMillion = 10000000; // C++11 버전
// constexpr auto tenMillion = 10'000'000; // C++14 버전

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
    std::vector<int> goodVals;
     
    std::thread t([&filter, maxVal, &gooldVals]
                  {
                      for(auto i = 0 ; i <= maxVal ; ++i)
                      {
                          if(filter(i))
                              goodVals.push_back(i);
                      }
                  });
                  
    auto nh = t.native_handle(); // t의 핸들을 사용하여 t의 우선순위를 정한다.
    ...
    
    if(conditionAreStisfied()) // t의 완료를 기다린다.
    {
        t.join();
        performComputation(goodVals);
        return true;
    }
    
    return false;
}
  • conditionsAreSatisfied()가 false를 리턴하거나 예외를 던진다면 문제이다.
    • std::thread 객체 t의 소멸자가 호출되기 때문이다.
    • 이때, t는 여전히 joinable한 상태이다.
    • 그 때문에 크래시가 발생한다.

 

 

 

std::thread의 소멸자가 크래시를 발생시키는 이유
  • 다른 두 경우가 명백히 더 나쁜 상황이기 때문이다.
  • 암묵적 join 해주기
    • std::thread의 소멸자가 바탕 비동기 실행 스레드의 완료를 기다리게 한다.
    • 추적하기 어려운 퍼포먼스 이슈가 나타날 수 있다.
    • conditionsAreSatisfied()가 이미 false를 돌려주었는데, doWork가 계속 대기하는건 직관적이지 않다.
  • 암묵적 detach 해주기
    • std::thread의 소멸자가 std::thread 객체와 바탕 실행 스레드 사이의 연결을 끊는다.
    • 이 경우 디버깅 문제가 생기며, 암묵적 join보다 까다롭다.
    • 디버깅이 어려운 시나리오
      1. doWork에서 goodVals는 지역 변수이나 람다가 캡쳐하여 본문에서 수정한다.
      2. 람다가 비동기적으로 실행되는 도중에 conditionsAreSatisfied()가 false인 상황이라면
      3. doWork가 리턴되며, 지역 변수(goodVals도 포함하는)가 파괴된다.
      4. doWork의 스택 프레임이 pop하며 doWork 호출 지점 다음으로 넘어가지만
      5. 해당 스레드는 doWork의 호출 지점에서 계속 실행된다.
      6. 호출 지점 다음의 문장들 중에는 함수를 호출하는 게 있을 수 있고, 그 호출 중 하나는 doWork의 스택 프레임을 차지하던 메모리를 사용하게 될 수 있다.(그런 함수를 f라고 한다면.)
      7. f가 실행되는 도중에 doWork에서 시작된 람다가 계속해서 비동기 실행될 수도 있을 것이다.
      8. 람다가 캡쳐한 goodVals는 이제 f의 스택 프레임에 있따.
      9. f의 관점에서 이는 자신의 스택 프레임에 있는 메모리의 내용이 갑자기 변하는 기현상이다!
  • 따라서 std::thread 객체를 사용할 때 그 객체가 정의된 범위 바깥의 모든 경로에서 함류 불가능으로 만드는 것은 프로그래머의 책임이다.
    • return, continue, break, goto, 예외 발생을 포함하는 모든 경로에서!

 

 

 

RAII를 사용하여 해결하기
  • std::thread 객체에 대한 표준 RAII 클래스는 없다.
  • 만들기는 어렵지 않다.
class ThreadRAII
{
public:
    enum class DtorAction { join, detach };
    
    ThreadRAII(std::thread&& t, DtorAction a) : action(a), t(std::move(t)) {}
    
    ~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로만 받는다.(std::thread 객체는 복사할 수 없다.)
  • 생성자의 매개변수들은 호출자가 직관적으로 기억할 수 있는 순서로 선언되어 있다.
    • std::thread를 먼저 지정하고 소멸자 동작을 지정하는 것이 반대 순서보다 합리적이다.
    • 하지만 멤버 초기화 목록에서 std::thread 객체는 마지막에 선언되어 있다.
    • std::thread는 초기화 되자마자 다른 멤버에 의존하는 함수를 실행할 수도 있기 때문이다.
  • ThreadRAII는 바탕 std::thread 객체에 접근할 수 있는 get 함수를 제공한다.
  • ThreadRAII 소멸자는 std::thread 객체 t에 대해 멤버 함수를 호출하기 전에 먼저 t가 합류 가능한지부터 점검한다.
    • 합류 불가능 스레드에 join이나 detach를 호출하면 미정의 행동이다!
  • 소멸자의 다음 부분에 경쟁 조건(race condition)이 존재하지 않을까?
if(t.joinable())
{
    // 이 쯤에서 다른 스레드가 t를 합류 불가능하게 만들면??

    if(action == DtorAction::join)
    {
        t.join();
    }
    else
    {
        t.detach();
    }
}
  • 합류 가능한 std::thread 객체는 오직 멤버 함수 호출(join, detach) 또는 이동 연산에 의해서만 합류 불가능해질 수 있다.
    • 멤버 함수 호출이 동시에 일어난다면 경쟁이 발생하겠지만,
    • 그 호출들이 일어나는 곳은 클라이언트 코드이다.(소멸자, 다른 어떤 멤버 함수)
    • 즉, 클라이언트 쪽에서 핸들링해야 하는 문제이다.
    • 일반적으로 멤버 함수를 동시에 호출하는 것은 그 멤버 변수 함수들이 const 멤버 함수일때만 안전하다.(항목 16 참조)
  • doWork에 ThreadRAII를 적용한 코드
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
    std::vector<int> goodVals;
    
    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(); // get 함수로 std::thread 참조 가능
        performcomputation(goodVals);
        return true;
    }
    
    return false;
}
  • 소멸자가 join을 하든, detach를 하든 문제인 건 마찬가지이지만(앞에서 말했듯)
    • 미정의 행동(dtach), 프로그램 종료(raw std::thread), 성능이상(join) 중 하나를 고르자면 그나마 성능 이상이 제일 덜 나쁘다.
  • ThreadRAII를 이용해서 std::thread 소멸 시 join이 실행되게 하면 성능 이상뿐 아니라 프로그램이 행잉(hang)되는 문제까지 발생할 수 있다.
    • 이 문제는 비동기적으로 실행되는 람다에게 일찍 반환하라고 알려주는 것이다.
    • 하지만 C++11은 그런 인터럽터블 스레드(interruptible thread)를 지원하지 않는다.
  • ThreadRAII는 소멸자를 선언하므로 컴파일러가 이동 연산들을 작성해주지 않는다.(항목 17 참조)
    • 이동을 지원하지 않을 이유가 없으므로 아래처럼 이동을 지원해주도록 하자.
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;
};
728x90
Comments