C++/Effective Modern C++

[Effective Modern C++] std::thread를 unjoinable하게 만들어야 하는 이유

ruru14 2023. 4. 14. 15:25

std::thread객체는 현재 상태에 따라 두 종류로 구분이 가능합니다.

  • Joinable : 실행 중(Running)이거나, 실행 중 상태로 전이할 수 있는 스레드입니다.
  • Unjoinable : Joinable하지 않은 스레드입니다.

 

Unjoinable상태는 다음과 같은 경우가 있습니다.

  • 기본 생성된 std::thread는 실행할 함수가 없기 때문에 joinable하지 않습니다.
  • std::thread는 복사할 수 없는 객체이기 때문에, 이동 후 원본 std::thread는 joinable하지 않습니다.
  • join 및 detach된 std::thread는 joinable하지 않습니다.

 

std::thread의 joinable 상태를 면밀히 점검하는 것은 매우 중요합니다.

이번 글에서는 std::thread의 상태에 따라 어떤 문제가 발생할 수 있는지 살펴보도록 하겠습니다.

 


 

1. 예제

 

더보기

이번 글에서 사용될 예제는 다음과 같습니다.

constexpr auto tenMillion = 10`000`000;

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {
    std::vector<int> goodVals;
    
    std::thread t([&filter, maxVal, &goodVals] {
            for(auto i = 0; i<=maxVal; ++i)
                if(filter(i)) goodVals,push_back(i);
        });
    
    // Set priority
    auto nh = t.native_handle();
    
    ...
    
    if(ConditionsAreSatisfied()) {
        t.join();
        performComputation(goodVals);
        return true;
    }
    
    return false;
}

doWork함수는 인자로 받은 filter함수로 필터링 된 값들을 goodVals에 저장합니다.

필터링은 다른 스레드에서 수행되며, 그동안 doWork는 ConditionsAreSatisfied함수를 통해 조건을 체크하고, 조건이 만족되었을 경우 저장된 goodVals에 대하여 performComputation연산을 수행합니다.

필터링에 시간이 오래 걸릴 수 있고, 필터링과 이후 연산 사이에 다른 작업이 수행되어야 할 경우, 해당 과정은 비 동기적으로 실행되는 것이 적합합니다.

 

이전 글 (std::thread와 std::async)에 따르면 std::async를 사용하는 것이 편리하지만, std::thread의 사용에 당위성을 부여하기 위해 스레드의 우선순위를 지정한다고 가정하겠습니다. (스레드의 우선순위를 지정하기 위해서는 네이티브 핸들이 필요하고, 이는 std::async에서는 제공하지 않는 기능입니다.)

 

다음 문단에서 이어집니다.

 

2. 문제 : std::thread의 소멸자

 

더보기

예제의 코드에 대하여, ConditionAreSatisfied함수가 false를 반환할 경우 프로그램이 종료됩니다.

이는 joinable한 상태인 t인스턴스 (std::thread)의 소멸자가 호출되어 발생하는 현상입니다.

 

이 때, 프로그램을 종료시키는 것 외에 std::thread에서 할 수 있는 행동은 두 가지가 더 있습니다.

  • join : 종료 대신 std::thread를 join합니다. 이는 단편적으로는 합리적이지만, 필요 없는 계산 (doWork가 false를 반환했음)을 한다는 점에서 직관적이지 않습니다.
  • detach : t와 바탕 스레드 사이의 연결을 끊습니다. 이는 심각한 문제를 발생시킬 수 있습니다. detach로 연결을 끊는다고 해도, t는 필터링을 계속 수행합니다. 하지만 직후 doWork가 false를 반환하고, t가 캡쳐한 doWork의 지역변수인 goodVals또한 반환됩니다. 만약 이후 다른 함수가 goodVals의 메모리 영역을 할당받을 경우, 이 영역은 t에 의해 조정될 수 있습니다.

표준 위원회는 위 두 가지 방법보다는 프로그램 종료가 더 나은 방안이라고 판단했습니다.

따라서 Joinable한 스레드가 파괴되는 상황이 금지(해당 상황에서 프로그램을 종료)되었습니다.

 

위의 문제로 인하여, 프로그래머는 std::thread객체의 스코프에 대하여, 모든 구간에서 객체를 Unjoinable하게 만들어야 할 책임이 있습니다.

 

3. 해결 : RAII

 

더보기

RAII는 Resource Aquisition Is Initialization의 약자로, 리소스 획득은 초기화 라는 의미입니다.

이는 프로그래밍 기법 중의 하나로, STL 컨테이너, 스마트 포인터 등에 적용되어 있습니다.

이 기법이 적용된 클래스는 소멸자가 호출되면 특정 작업을 수행한 뒤 파괴됩니다.

예를 들어 STL 컨테이너는 컨테이너의 내용을 제거하고, 스마트 포인터는 자신이 가리키는 객체에 대한 삭제자를 호출하거나(unqiue), 참조 횟수를 감소(shared,weak)시킵니다.

 

std::thread에는 이러한 기법이 적용되어 있지 않은데, join과 detach중 어떤 방침을 기본으로 선택해야 할 지 모호하기 때문입니다.

하지만 구현 자체는 어렵지 않습니다.

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;
};

이 클래스에서 눈여겨보아야 할 점은 다음과 같습니다.

  • 생성자는 R-value만 받습니다. std::thread는 복사할 수 없는 객체입니다.
  • std::thread의 선언이 마지막에 되어있습니다. 초기화 목록은 멤버의 선언 순서를 따르며, std::thread객체는 초기화와 동시에 함수를 실행할 수 있다는 점에서, 타 멤버에 안전하게 접근하게 하기 위해 마지막에 선언했습니다.
  • get함수를 제공합니다. 이는 ThreadRAII클래스가 std::thread를 간단하게 대체할 수 있게 하며, std::thread의 인터페이스를 복제할 필요가 없게 만듭니다.
  • 소멸자는 std::thread필드의 joinable을 점검합니다. unjoinable 스레드에 대한 join 및 detach는 미정의 행동을 유발하기 때문입니다.
  • 소멸자의 join 혹은 detach호출은 thread-safe합니다. 해당 함수를 호출하는 지점이 소멸자이기 때문에, 여러 스레드에서 호출될 수 없습니다.

 

추가적으로, std::thread는 이동 연산을 지원합니다.

하지만 ThreadRAII는 소멸자를 선언했기 때문에 기본 이동 연산이 자동 작성되지 않습니다.

std::thread를 Wrapping한 ThreadRAII클래스가 이를 지원하지 않을 이유가 없으므로, 명시적으로 추가할 수 있습니다.

class ThreadRAII {
public:
    ...
    ThreadRAII(ThreadRAII&&) = default;
    ThreadRAII& operator=(ThreadRAII&&) = default;
    ...
};

 

4. RAII 적용

 

더보기

위 문단에서 구현한 ThreadRAII클래스를 문제 상황에 적용하면 다음과 같이 될 수 있습니다.

constexpr auto tenMillion = 10`000`000;

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
    );
    
    // Set priority
    auto nh = t.get().native_handle();
    
    ...
    
    if(ConditionsAreSatisfied()) {
        t.get().join();
        performComputation(goodVals);
        return true;
    }
    
    return false;
}

인스턴스 t를 ThreadRAII로 변경하고, 기본 소멸 방식을 지정합니다.

이 후 std::thread에 접근하는 코드는 t.get함수를 호출하여 std::thread에 접근할 수 있습니다.

위 구현의 경우, ConditionsAreSatisfied함수가 false를 반환하여 doWork가 false를 반환하더라도, ThreadRAII가 소멸자에서 스레드 객체를 join합니다.

 

사실, 위 코드는 임시적인 해결책에 불과합니다.

앞서 언급한 위 t인스턴스에 대한 선택지는 다음과 같습니다.

  • 프로그램 종료
  • join (성능 이슈)
  • detach (디버깅하기 난해한 버그 발생 가능)

위 선택지 중 차악이 성능 이슈를 발생시키는 것이기 때문에, 그것을 선택한 것에 불과합니다.

이 문제에 대한 조금 더 적절한 해결책은, 실행중인 람다를 중지시키는 것 입니다.

하지만 이는 이번 글에서 곁다리로 다루기에는 조금 큰 문제이기 때문에, 차후 다른 글에서 다룰 수 있도록 하겠습니다.

 


 

std::thread보다는 std::async가 사용하기 편하지만, std::thread를 사용해야 하는 상황이 있다는 점을 이전 글에서 살펴보았습니다.

그 때 언급한 상황에 대한 (네이티브 핸들) 구체적인 예제가 이번 글에 사용된 예제입니다.

그와는 별개로, 이번 글의 핵심 주제는 std::thread객체의 join상태가 야기할 수 있는 문제였습니다.

 

멀티스레드는 프로그램의 흐름이 갈라지는 만큼, 신경써야 할 부분도 그만큼 늘어납니다.

성능 이슈를 해결하기 위한 방안이 성능 이슈를 발생시키지 않도록, 이번 글과 같은 간단한 철칙 (unjoinable하게 만들기)은 외워두고 지키는 편이 좋겠습니다.

 

감사합니다.