일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
Tags
- 게임
- 보편 참조
- 영화 리뷰
- universal reference
- implicit conversion
- lua
- Smart Pointer
- 티스토리챌린지
- 스마트 포인터
- exception
- UE4
- 암시적 변환
- reference
- 참조자
- 영화
- operator new
- effective modern c++
- 예외
- more effective c++
- effective stl
- c++
- resource management class
- std::async
- 반복자
- 오블완
- 언리얼
- 상속
- Effective c++
- virtual function
- iterator
Archives
- Today
- Total
스토리텔링 개발자
[Effective Modern C++] 37. std::thread는 unjoinable하게 본문
Effective C++/Effective Modern C++
[Effective Modern C++] 37. std::thread는 unjoinable하게
김디트 2025. 4. 11. 11:20728x90
항목 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
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보다 까다롭다.
- 디버깅이 어려운 시나리오
- doWork에서 goodVals는 지역 변수이나 람다가 캡쳐하여 본문에서 수정한다.
- 람다가 비동기적으로 실행되는 도중에 conditionsAreSatisfied()가 false인 상황이라면
- doWork가 리턴되며, 지역 변수(goodVals도 포함하는)가 파괴된다.
- doWork의 스택 프레임이 pop하며 doWork 호출 지점 다음으로 넘어가지만
- 해당 스레드는 doWork의 호출 지점에서 계속 실행된다.
- 호출 지점 다음의 문장들 중에는 함수를 호출하는 게 있을 수 있고, 그 호출 중 하나는 doWork의 스택 프레임을 차지하던 메모리를 사용하게 될 수 있다.(그런 함수를 f라고 한다면.)
- f가 실행되는 도중에 doWork에서 시작된 람다가 계속해서 비동기 실행될 수도 있을 것이다.
- 람다가 캡쳐한 goodVals는 이제 f의 스택 프레임에 있따.
- 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
'Effective C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] 39. void future 객체 (0) | 2025.04.17 |
---|---|
[Effective Modern C++] 38. 스레드 핸들 소멸자의 동작 (0) | 2025.04.15 |
[Effective Modern C++] 36. std::async의 시동 방침(launch policy) (0) | 2025.04.10 |
[Effective Modern C++] 35. 태스크 기반 프로그래밍(task-based programming) (0) | 2025.04.09 |
[Effective Modern C++] 34. std::bind 대신 람다 (0) | 2025.04.08 |
Comments