[Modern C++] 항목 37: std::thread들을 모든 경로에서 합류 불가능하게 만들어라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 7. 동시성 API
👉🏻 항목 37: std::thread들을 모든 경로에서 합류 불가능하게 만들어라
🔍 합류 가능성(joinable)
- 모든
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이후에는 실행이 완료된 바탕 스레드에 대응되지 않는다.detach에 의해 탈착된std::thread:std::thread객체와 바탕 스레드 사이의 연결을 끊는다.
- 기본 생성된
⚠️ 합류 가능한 스레드를 파괴하면 프로그램이 종료된다
- 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료된다.
- 예를 들어 필터링 함수
filter와 최댓값maxVal을 매개변수로 받는 함수doWork가 있다고 하자.doWork는 필요한 모든 조건이 만족되는지 점검한 후, 0 이상maxVal미만의 모든 값 중 주어진 필터를 통과한 값들에 대해 계산을 수행한다.- 필터링과 조건 점검에 모두 시간이 오래 걸린다면 두 작업을 동시에 실행하는 것이 합리적이다.
- 기본적으로는 과제 기반 설계가 좋지만(항목 35), 이번 예제에서는 필터링을 수행하는 스레드의 우선순위를 설정해야 한다고 가정한다.
- 우선순위를 설정하려면 스레드의 네이티브 핸들이 필요하다.
- 네이티브 핸들은
std::threadAPI를 통해서만 얻을 수 있고, 과제 기반 API(std::future등)는 제공하지 않는다. - 따라서 이번에는 스레드 기반 접근방식을 사용할 수밖에 없다.
constexpr auto tenMillion = 10000000; // constexpr는 항목 15를 보라
bool doWork(std::function<bool(int)> filter, // 계산 수행 여부를 돌려준다;
int maxVal = tenMillion) // std::function은 항목 5를 보라
{
std::vector<int> goodVals; // 필터를 통과한 값들
std::thread t([&filter, maxVal, &goodVals] // goodVals에 값들을 채운다
{
for (auto i = 0; i <= maxVal; ++i)
{ if (filter(i)) goodVals.push_back(i); }
});
auto nh = t.native_handle(); // t의 네이티브 핸들을
… // 이용해서 t의 우선
// 순위를 설정한다
if (conditionsAreSatisfied()) { // 조건들이 만족되었다면
t.join(); // t의 완료를 기다린다
performComputation(goodVals);
return true; // 계산이 수행되었음을
} // 뜻하는 값을 반환
return false; // 수행되지 않았음을
} // 뜻하는 값을 반환
- C++14에서는 작은따옴표를 숫자 구분자로 사용해서 초기 값을 더 읽기 쉽게 표기할 수 있다.
constexpr auto tenMillion = 10'000'000; // C++14
t의 실행을 시작한 후 우선순위를 설정하는 것은 소 잃고 외양간 고치듯 순서가 틀린 것으로 보인다.t를 유보된 상태(suspended state)로 시작하는 것이 더 나은 설계이지만, 예제에 집중하기 위해 생략했다. 방법은 항목 39를 참고하라.
conditionsAreSatisfied()가true를 돌려주면 별 문제가 없다.- 그러나
false를 돌려주거나 예외를 던지면 실행 흐름이doWork의 끝에 도달해서t의 소멸자가 호출되는데, 이때t가 여전히 합류 가능 상태이므로 프로그램 실행이 종료된다.
🔍 소멸자가 이렇게 행동하는 이유
다른 두 옵션이 명백히 더 나쁘기 때문이다.
- 암묵적
join:std::thread의 소멸자가 바탕 비동기 실행 스레드의 완료를 기다리게 하는 것이다.- 합리적으로 들리지만 추적하기 어려운 성능 이상(performance anomaly)이 나타날 수 있다.
- 예를 들어
conditionsAreSatisfied()가 이미false를 돌려주었는데도 모든 값에 필터가 적용되길doWork가 기다리는 것은 직관적이지 않다.
- 암묵적
detach:std::thread의 소멸자가 객체와 바탕 스레드 사이의 연결을 끊게 하는 것이다. 이 경우 바탕 스레드가 실행을 계속할 수 있다.join만큼이나 합리적으로 들리지만, 유발하는 디버깅 문제는join보다 더 나쁘다.doWork에서goodVals는 지역 변수인데, 람다가 참조로 캡처해서 본문 안에서 수정한다(push_back호출).- 람다가 비동기적으로 실행되는 도중에
conditionsAreSatisfied()가false를 돌려주면doWork가 반환되고 지역 변수들(goodVals포함)이 파괴된다. doWork의 스택 프레임이 뽑혀(pop) 실행 흐름이 호출 지점 다음으로 넘어가지만, 해당 스레드는 호출 지점에서 계속 실행된다.- 호출 지점 다음의 문장들 중 함수를 호출하는 것이 있다면, 그런 호출 중 적어도 하나는
doWork의 스택 프레임이 차지하던 메모리의 일부 또는 전부를 사용하게 될 수 있다. 그런 함수를f라고 하자. f가 실행되는 도중에doWork에서 시작된 람다가 계속goodVals에 대해push_back을 호출하면,f의 관점에서는 자신의 스택 프레임에 있는 메모리의 내용이 갑자기 변하는 기현상이 벌어진다. 이를 디버깅하는 것은 매우 괴로운 일이다.
- 표준 위원회는 합류 가능 스레드를 파괴했을 때의 결과가 충분히 절망적이므로, 그런 파괴를 아예 금지하기로 했다(합류 가능 스레드를 파괴하면 프로그램이 종료됨을 명시).
따라서 std::thread 객체를 사용할 때 그 객체가 정의된 범위 바깥의 모든 경로에서 합류 불가능으로 만드는 것은 프로그래머의 책임이다.
- ‘모든 경로’에는 범위의 끝에서 시작되는 경로는 물론
return,continue,break,goto, 예외에 의해 건너뛰어 나가는 모든 경로가 포함된다.
🔍 RAII 클래스: ThreadRAII
- 한 범위의 바깥으로 나가는 모든 경로에서 어떤 동작이 반드시 수행되어야 할 때 흔히 쓰는 접근방식은 그 동작을 지역 객체의 소멸자 안에 넣는 것이다.
- 그런 객체를 RAII 객체, 그런 객체의 클래스를 RAII 클래스라고 부른다.
- RAII는 “Resource Acquisition Is Initialization”의 약자이지만, 사실 이 기법의 핵심은 초기화가 아니라 파괴이다.
- (옮긴이) 기법으로서의 RAII에서는 파괴(특히 소멸자)가 핵심이지만, RAII라는 용어 자체에는 생성자와 초기화에 관한 고민이 깔려 있다.
- RAII 클래스는 표준 라이브러리에 흔하다.
- STL 컨테이너: 소멸자가 컨테이너의 내용을 파괴하고 메모리를 해제한다.
- 표준 스마트 포인터(항목 18~20):
std::unique_ptr의 소멸자는 가리키는 객체에 대해 삭제자를 호출하고,std::shared_ptr와std::weak_ptr의 소멸자는 참조 횟수를 감소한다. std::fstream객체: 각 소멸자는 해당 파일들을 닫는다.
- 그렇지만
std::thread객체에 대한 표준 RAII 클래스는 없다.- 아마도 표준 위원회가
join과detach둘 다 기본 옵션으로 선택하지 않았고, 어떤 것이어야 할지 파악할 수 없었기 때문일 것이다.
- 아마도 표준 위원회가
- 다행히 직접 작성하는 것이 어렵지 않다. 다음은
std::thread를 위한 RAII 클래스ThreadRAII이다. 소멸 시 바탕std::thread객체에 대해join을 호출할지detach를 호출할지 지정할 수 있다.
class ThreadRAII {
public:
enum class DtorAction { join, detach }; // enum class는 항목 10을 보라
ThreadRAII(std::thread&& t, DtorAction a) // 소멸자에서 t에
: action(a), t(std::move(t)) {} // 대해 동작 a를 수행
~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::threadrvalue만 받는다. 그것을ThreadRAII객체로 이동할 것이기 때문이다(std::thread객체는 복사할 수 없다). - 생성자의 매개변수들은 호출자가 직관적으로 기억할 수 있는 순서로 선언되어 있다(
std::thread를 먼저 지정하고 소멸자 동작을 지정).- 그러나 멤버 초기화 목록은 자료 멤버들이 선언된 순서를 따른다.
std::thread객체가 마지막으로 선언되어 있음에 주목하라. 이 클래스에서는 선언 순서가 중요하지 않지만, 일반적으로는 한 자료 멤버의 초기화가 다른 멤버에 의존할 수 있다.std::thread객체는 초기화되자마자 해당 함수를 실행할 수도 있으므로, 클래스에서std::thread자료 멤버는 항상 제일 마지막에 선언하는 것이 좋다. 그러면 다른 모든 멤버가 성공적으로 초기화된 후에 비동기 스레드가 시작되므로 다른 모든 멤버에 안전하게 접근할 수 있다.
ThreadRAII는 바탕std::thread객체에 접근할 수 있는get함수를 제공한다.- 표준 스마트 포인터 클래스들이 바탕 raw 포인터에 접근할 수 있는
get함수를 제공하는 것과 비슷하다. std::thread인터페이스 전체를ThreadRAII에 복제할 필요가 없고,std::thread객체를 요구하는 문맥에서ThreadRAII객체를 사용할 수 있다는 뜻이기도 하다.
- 표준 스마트 포인터 클래스들이 바탕 raw 포인터에 접근할 수 있는
- 소멸자는 멤버 함수를 호출하기 전에 먼저
t가 합류 가능인지부터 점검한다.- 합류 불가능 스레드에 대해
join이나detach를 호출하면 미정의 행동이 나오기 때문이다. - 클라이언트가
get으로t에 대한 접근을 얻고 그것을 이용해서t를 이동하거나join또는detach를 호출할 수도 있는데, 그러면t는 합류 불가능 상태가 된다.
- 합류 불가능 스레드에 대해
- 소멸자에 경쟁 조건(race condition)이 있지 않을까?
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
t.joinable()의 실행과join또는detach호출 사이에 다른 스레드가t를 합류 불가능하게 만들면 경쟁 조건이 성립하지 않을까? 다행히 이는 기우이다.- 합류 가능한
std::thread객체는 오직 멤버 함수 호출(join이나detach) 또는 이동 연산에 의해서만 합류 불가능한 상태로 변할 수 있다. ThreadRAII소멸자가 호출되는 시점에서는 그 객체에 대해 그런 멤버 함수를 호출할 만한 스레드가 남아 있지 않아야 한다.- 그런 호출들이 동시에 일어난다면 확실히 경쟁이 발생하겠지만, 그 호출들이 일어나는 곳은 소멸자 안이 아니라 하나의 객체에 대해 동시에 두 멤버 함수를 실행하려 하는 클라이언트 코드이다.
- 일반적으로 하나의 객체에 대해 여러 멤버 함수를 동시에 호출하는 것은 그 멤버 함수들이
const멤버 함수인 경우에만 안전하다(항목 16).
- 합류 가능한
🔍 ThreadRAII를 적용한 doWork
bool doWork(std::function<bool(int)> filter, // 이전과 동일
int maxVal = tenMillion)
{
std::vector<int> goodVals; // 이전과 동일
ThreadRAII t( // RAII 객체를 사용
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;
}
- (옮긴이) 안타깝게도 이 버전 역시 프로그램 종료로 이어질 수 있다. 원서 정오표가 지적하듯이, 만일
doWork의 람다 함수에서 예외가 발생하면 프로그램이 종료된다. 람다 표현식을std::packaged_task로 감싸서 이 문제를 해결한 코드가 원서 정오표(역자의 글 참고)에 있다. - 이 예에서는
ThreadRAII소멸자가 비동기 실행 스레드에 대해join을 호출하도록 했다. 앞서 보았듯이detach를 호출하면 아주 골치 아픈 디버깅 문제가 발생할 수 있기 때문이다. join이 성능 이상(그리고 솔직히 디버깅하기 어려운 버그)을 유발할 수 있다는 점도 앞서 이야기했지만, 미정의 행동(detach에서 발생할 수 있는)과 프로그램 종료(rawstd::thread에서 발생할 수 있는), 성능 이상 중 하나를 고르자면 그나마 성능 이상이 제일 덜 나쁘다.- 안타깝게도 항목 39에서 보듯이
ThreadRAII를 이용해서std::thread소멸 시join이 실행되게 하면 성능 이상만이 아니라 프로그램이 멈추는(hang) 문제까지 발생할 수 있다.- 이런 종류의 문제에 대한 ‘제대로 된’ 해결책은 비동기적으로 실행되는 람다에게 이제 더 일할 필요가 없으니 일찍 반환하라고 알려주는 것이겠지만, C++11은 그런 가로챌 수 있는 스레드(interruptible thread)를 지원하지 않는다.
- 직접 구현하는 것이 가능하지만, 이는 이 책의 범위를 넘는 주제이다(앤서니 윌리엄스의 책 C++ Concurrency in Action, 섹션 9.2가 이 문제를 잘 다룬다).
- (옮긴이) 프로그램이 “멈춘다”는 것은 무한 루프나 무기한 대기(차단) 등의 이유로 실행의 흐름이 코드의 한 지점을 벗어나지 못한다는 것이다. 이는 실행의 흐름이 프로그램을 완전히 벗어나서 운영체제로 돌아가는 “종료하다(terminate)”와는 다르다.
🔍 ThreadRAII의 이동 연산 지원
ThreadRAII는 소멸자를 선언하므로 컴파일러가 이동 연산들을 작성해주지 않는다(항목 17).- 그렇지만
ThreadRAII객체의 이동을 지원하지 않을 이유는 없다. 컴파일러가 작성해주었을(소멸자 선언이 없었다면) 기본 이동 함수들로 충분하므로, 작성을 명시적으로 요청하면 된다.
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;
};
🧐 정리
- 모든 경로에서
std::thread를 합류 불가능으로 만들어라. - 소멸 시
join방식은 디버깅하기 어려운 성능 이상으로 이어질 수 있다. - 소멸 시
detach방식은 디버깅하기 어려운 미정의 행동으로 이어질 수 있다. - 자료 멤버 목록에서
std::thread객체를 마지막에 선언하라.
댓글남기기