[Effective Modern C++] std::thread를 unjoinable하게 만들어야 하는 이유
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하게 만들기)은 외워두고 지키는 편이 좋겠습니다.
감사합니다.