모든 std::thread 객체는 합류 가능 상태에거나 합류 불가능 상태이다.
◾ 기본 생성된 std::thread
◾ 다른 std::thread 객체로 이동된 후의 std::thread 객체
◾ join에 의해 합류된 std::thread
◾ detach에 의해 탈착된 std::thread
위 4가지는 합류 불가능한 std::thread 객체이다.
std::thread의 합류 가능성이 중요한 이유 중 하나는, 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료되기 때문이다.
예를 들어 필터링 함수 filter와 최댓값 maxVal을 매개변수로 받는 함수 doWork에서 필터링 수행과 조건들의 만족 여부를 점검하는 데에 시간이 오래 걸린다면 두 작업을 동시에 실행하는 것이 합리적이다.
작업 기반 설계를 적용하는 것이 좋지만 필터링을 수행하는 스레드의 우선순위를 설정해야 하기 때문에 스레드의 네이티브 핸들을 받기 위해 어쩔 수 없이 스레드 기반 설계를 해야 한다.
constexpr auto tenMillion = 10000000;
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); }
});
auto nh = t.native_handle(); // 네이티브 핸들을 이용해서 t의 우선순위 설정
...
if (conditionAreSatisfied()) {
t.join();
performComputation(goodVals);
return true; // 계산 수행 완료
}
return false; // 계산 미수행
}
일단 t를 실행시킨 뒤에 우선순위를 설정하는 것은 소 잃고 외양간 고치는 격이기 때문에 순서가 맞지 않다. 이 부분은 넘어가더라도 conditionAreSatisfied 함수가 true를 반환한다면 별 문제가 없지만 false를 반환하거나 예외를 던지면 t의 소멸자가 호출되는데, t는 여전히 합류 가능 상태이므로 프로그램 실행이 종료된다.
대체 왜 std::thread의 소멸자가 프로그램을 종료시키는가 하면 암묵적 join이나 암묵적 detach는 상황이 더 나쁘게 흘러가기 때문이다.
◾ 암묵적 join
std::thread의 소멸자가 바탕 비동기 실행 스레드의 완료를 기다리게 한다.
추적하기 어려운 성능 이상들이 나타날 수 있다.
◾ 암묵적 detach
std::thread의 소멸자가 std::thread 객체와 바탕 실행 스레드 사이의 연결을 끊는다.
암묵적 join보다 더 심각한 이슈가 발생할 수 있다.
예를 들어 goodVals는 지역 변수인데 doWork 함수가 종료되면 호출 스택이 pop 되어 프로그램 실행의 흐름이 doWork의 호출 지점 다음으로 이동되지만 t는 doWork의 호출 지점에서 계속 실행된다.
다음 함수 f가 실행되는 도중 t가 비동기적으로 실행되며 goodVals에 대해 push_back을 호출하게 되는데 goodVals가 차지하던 메모리는 이미 f의 스택 프레임에 존재하게 된다.
t가 push_back을 호출하면 f 입장에서는 자기가 소유중인 스택 프레임의 메모리가 갑자기 변해버리는 기현상을 겪게 되는 셈이다.
합류 가능 스레드를 파괴했을 때의 결과가 매우 절망적이기 때문에 파괴를 아예 금지하기로 정했고 그에 따라 합류 가능 스레드를 파괴하면 프로그램이 종료되도록 한 것이다.
따라서 std::thread 객체를 모든 경로에서 합류 불가능으로 만들어야 한다. 범위 바깥으로 나가는 모든 경로에서 어떤 동작이 반드시 수행되어야 한다고 할 때, 그 동작을 지역 객체의 소멸자 안에 넣는 것이 일반적이고 이를 RAII(Resource Acquisition Is Initilization) 객체라고 부른다. 핵심은 초기화가 아닌 파괴에 있다.
표준 컨테이너나 스마트 포인터는 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의 오른값만 받고 std::thread 객체는 마지막으로 선언하는 것이 좋다. 그래야 모든 멤버가 성공적으로 초기화 되고 안전하게 접근할 수 있다.
또한 소멸자에서 t에 대해 멤버 함수를 호출하기 전에 합류 가능인지 점검한다.
if (t.joinable()) {
if (action == DtorAction::join) { // 혹시 이 사이에 race condition이 발생하면?
t.join();
}
else {
t.detach();
}
}
joinable과 join 또는 detach의 호출 사이에 다른 스레드가 t를 합류 불가능하게 만들면 경쟁 조건이 발생하지 않을까 걱정될수도 있는데 다행히도 합류 가능한 std::thread 객체는 오직 멤버 함수 호출(join, detach) 또는 이동 연산에 의해서만 합류 불가능한 상태로 변경될 수 있다.
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {
std::vector<int> goodVals;
ThreadRAII t([&filter, maxVal, &goodVals]() {
for (auto i = 0; i <= maxVal; ++i) {
if (filter(i)) goodVals.push_back(i); }
}),
ThreadRAII::DtorAction::join);
auto nh = t.get().native_handle();
...
if (conditionAreSatisfied()) {
t.get().join();
performComputation(goodVals);
return true;
}
return false;
}
join이 디버깅하기 어려운 성능 이상을 유발할 수 있다고 했지만 다른 두 선택지가 미정의 행동과 프로그램 종료이기 때문에 join이 상대적으로 나은 선택지가 되어버린다.
그런데 나중에 다루겠지만 성능 이상뿐만 아니라 프로그램이 멈추는 문제까지 발생할 수 있는데 이런 종류의 문제에 대해 제대로 된 해결책은 비동기적으로 실행중인 람다에게 일찍 반환하라고 알려주는 것이다.(interruptible thread)
ThreadRAII는 소멸자를 선언했기 때문에 이동 연산이 작성되지 않으므로 직접 작성해주어야 한다. 다만 자동으로 작성되는 이동 연산으로도 충분하기 때문에 default로 요청하면 된다.
◾ 모든 경로에서 std::thread를 합류 불가능으로 만들어라.
◾ 소멸 시 join 방식은 디버깅하기 어려운 성능 이상으로 이어질 수 있다.
◾ 소멸 시 detach 방식은 디버깅하기 어려운 미정의 행동으로 이어질 수 있다.
◾ 멤버 변수 목록에서 std::thread 객체를 마지막에 선언하라.
'도서 > Effective Modern C++' 카테고리의 다른 글
[7장] 항목 39 : 단발성 사건 통신에는 void future 객체를 고려하라 (0) | 2023.01.31 |
---|---|
[7장] 항목 38 : 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라 (0) | 2023.01.30 |
[7장] 항목 36 : 비동기성이 필수일 때에는 std::launch::async를 지정하라 (0) | 2023.01.30 |
[7장] 항목 35 : 스레드 기반 프로그래밍보다 작업 기반 프로그래밍을 선호하라 (0) | 2023.01.30 |
[6장] 항목 34 : std::bind보다 람다를 선호하라 (0) | 2023.01.29 |