[Modern C++] 항목 39: 단발성 사건 통신에는 void 미래 객체를 고려하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 7. 동시성 API
👉🏻 항목 39: 단발성 사건 통신에는 void 미래 객체를 고려하라
🔍 문제 상황과 조건 변수 접근방식
- 어떤 특정한 사건(event)이 일어나야만 작업을 진행할 수 있는 비동기 과제에게, 그 사건이 발생했음을 알려주는 또 다른 과제를 두는 것이 유용한 경우가 있다. (예: 자료구조의 초기화, 계산 과정 중 특정 단계의 완료, 감지기 값 입력)
- 조건을 검출하는 과제를 검출 과제(detecting task), 그 조건에 반응하는 과제를 반응 과제(reacting task)라고 부른다.
- 한 가지 명백한 접근방식은 조건 변수(condition variable)를 사용하는 것이다. 반응 과제는 조건 변수를 기다리고, 검출 과제는 사건이 발생하면 그 조건 변수를 통지한다.
std::condition_variable cv; // 사건을 위한 조건 변수
std::mutex m; // cv와 함께 사용할 뮤텍스
검출 과제
… // 사건을 검출한다
cv.notify_one(); // 반응 과제에게 알린다
- 반응 과제가 여러 개라면
notify_one대신notify_all을 사용하는 것이 적합하다. 지금은 반응 과제가 하나라고 가정한다.
반응 과제
- 조건 변수에 대해
wait를 호출하기 전에 먼저std::unique_lock객체를 통해서 뮤텍스를 잠가야 한다. (조건 변수 대기 연산 전에 뮤텍스를 잠그는 것은 스레드 라이브러리들에서 흔한 과정이며,std::unique_lock을 통해 잠가야 한다는 것은 C++11 API의 요구일 뿐이다.)
… // 반응 준비
{ // 임계 영역을 연다
std::unique_lock<std::mutex> lk(m); // 뮤텍스를 잠근다
cv.wait(lk); // 통지를 기다린다
// 제대로 된 방식이 아님!
… // 사건에 반응한다
// (m이 잠긴 상태임)
} // 임계 영역을 닫는다;
// lk의 소멸자가 m을 해제한다
… // 계속 반응한다
// (m은 이제 풀린 상태임)
⚠️ 조건 변수 접근방식의 문제점
잘 작동하긴 하지만 뭔가 잘못된 점이 있어 보인다. 이를 코드 냄새(code smell)라고 한다.
- 냄새의 근원: 뮤텍스가 필요하다는 점
- 뮤텍스는 공유 자료에 대한 접근을 제어하는 데 쓰이나, 검출 과제와 반응 과제에는 그런 접근 제어가 전혀 필요 없을 수도 있다.
- 예를 들어 검출 과제가 전역 자료구조를 초기화한 후 반응 과제에 넘겨주고, 검출 과제는 초기화 과정에서만 접근하며 반응 과제는 초기화 이후에만 접근한다면 두 과제가 서로 마주치는 일이 없다.
- 조건 변수 접근방식에 뮤텍스가 필요하다는 점은 설계에 뭔가 문제가 있음을 암시하는 악취다.
냄새를 참고 넘긴다 해도 반드시 처리해야 할 문제점이 두 가지 더 있다.
- 반응 과제가
wait를 실행하기 전에 검출 과제가 조건 변수를 통지하면 반응 과제가 멈추게 된다(hang).- 통지를 놓치게 되어 영원히 통지를 기다리게 된다.
wait호출문은 가짜 기상(spurious wakeup)을 고려하지 않는다.- 조건 변수가 통지되지 않았는데도 깨어날 수 있다는 것은 스레드 API들에서 흔한 일이다.
- 가짜 기상 문제를 제대로 처리하려면 기다리던 조건이 정말로 발생했는지 깨어난 후 가장 먼저 확인해야 한다.
- C++ 조건 변수 API에서는 기다리던 조건을 판별하는 람다를
wait에 넘겨주면 간편하게 수행할 수 있다.
cv.wait(lk,
[]{ return 사건 발생 여부; });
- 그런데 지금 예에서 반응 과제가 기다리는 조건은 특정 사건의 발생이며, 그 발생 여부를 검출하는 것은 검출 과제의 몫이다. 반응 과제로서는 사건이 실제로 일어났는지 판단하지 못할 수도 있다. 직접 판단할 수 있었다면 애초에 조건 변수를 기다리지도 않았을 것이다.
- 과제 간 통신에 조건 변수가 적절한 경우가 많긴 하지만, 적어도 지금 예는 그런 경우가 아니다.
🔍 대안 ①: 공유 부울 플래그
- 부울 플래그 하나를
false로 초기화해 두고, 검출 스레드는 사건 발생을 검출하면 그 플래그를 설정한다.
std::atomic<bool> flag(false); // 공유 플래그; std::atomic은 항목 40을 보라
… // 사건을 검출한다
flag = true; // 반응 과제에게 통지한다
- 반응 스레드에서는 이 부울 플래그를 폴링(polling; 주기적 점검)한다. 플래그가 설정되어 있으면 기다리던 사건이 발생했다는 뜻이므로 실질적인 반응 행동을 수행한다.
… // 반응 준비
while (!flag); // 사건을 기다린다
… // 사건에 반응한다
- 장점: 뮤텍스가 필요 없고, 반응 과제가 폴링을 시작하기 전에 검출 과제가 플래그를 설정해도 문제가 없고, 가짜 기상도 없다.
- 단점: 반응 과제의 폴링 비용
- 플래그가 설정되길 기다리는 동안 반응 과제는 사실상 차단된 상태이지만 여전히 실행 중이다.
- 다른 과제가 유용하게 사용할 하드웨어 스레드를 점유하고, 시간 조각의 시작이나 끝에서 문맥 전환 비용을 유발하며, 전원 절약을 위해 닫아도 될 코어를 계속 돌리게 된다.
- 진정으로 차단된 과제에서는 이런 일이 생기지 않으므로, 이는 조건 변수 접근방식의 장점이다.
🔍 대안 ②: 조건 변수와 플래그의 결합
- 사건 발생 여부를 플래그로 나타내되, 그 플래그에 대한 접근을 뮤텍스로 동기화한다.
- 뮤텍스가 플래그에 대한 동시 접근을 방지하므로 플래그를
std::atomic으로 둘(항목 40의 조언대로) 필요는 없다. 그냥bool이면 충분하다.
검출 과제
std::condition_variable cv; // 이전과 동일
std::mutex m;
bool flag(false); // std::atomic이 아님
… // 사건을 검출한다
{
std::lock_guard<std::mutex> g(m); // g의 생성자에서 m을 잠근다
flag = true; // 반응 과제에게 통지한다
// (제1부)
} // g의 소멸자에서 m을 푼다
cv.notify_one(); // 반응 과제에게 통지한다
// (제2부)
반응 과제
… // 반응 준비
{ // 이전과 동일
std::unique_lock<std::mutex> lk(m); // 이전과 동일
cv.wait(lk, [] { return flag; }); // 가짜 기상을 방지하기
// 위해 람다를 사용한다
… // 사건에 반응한다
// (m은 잠긴 상태)
}
… // 계속 반응한다
// (m은 이제 풀린 상태)
- 이 접근방식에는 지금까지 논의한 문제점들이 없다. 검출 과제가 통지하기 전에 반응 과제가
wait를 호출해도, 가짜 기상이 발생해도 잘 돌아가며 폴링도 하지 않는다. - 그러나 악취는 남아 있다. 검출 과제가 아주 기묘한 방식으로 반응 과제와 통신한다.
- 검출 과제는 조건 변수를 통지할 뿐만 아니라 부울 플래그도 설정한다.
- 반응 과제는 통지를 받은 것만으로는 사건 발생을 확신하지 못하고 반드시 부울 플래그를 점검해야 한다.
- 잘 작동하긴 하지만 아주 깔끔하다고는 할 수 없다.
✅ 대안 ③: std::promise<void>와 void 미래 객체
- 조건 변수와 뮤텍스, 플래그를 아예 사용할 필요가 없는 대안은, 검출 과제가 설정한 미래 객체를 반응 과제가 기다리게(wait) 하는 것이다.
- 항목 38에서 설명했듯이 미래 객체는 피호출자에서 호출자로의 통신 채널의 수신 단자에 해당한다. 지금의 검출/반응 과제는 호출자와 피호출자의 관계가 아니지만, 전송 단자가
std::promise이고 수신 단자가 미래 객체인 통신 채널은 프로그램의 한 장소에서 다른 장소로 정보를 전송해야 하는 모든 상황에서 사용할 수 있다. - 설계
- 검출 과제에는
std::promise객체(통신 채널의 전송 단자)를 하나 두고, 반응 과제에는 그에 대응되는 미래 객체를 하나 둔다. - 기다리던 사건이 발생했음을 인식하면 검출 과제는 자신의
std::promise를 설정한다(통신 채널에 정보를 기록한다). - 그동안 반응 과제는 자신의 미래 객체에 대해
wait를 호출해 둔 상태이며, 이wait호출은std::promise가 설정될 때까지 차단된다.
- 검출 과제에는
std::promise와 미래(std::future,std::shared_future) 모두 타입 매개변수를 요구하는 템플릿이다. 이는 통신 채널을 통해 전송할 자료의 타입을 뜻한다.- 그러나 지금 예에서는 전송할 자료가 없고, 반응 과제는 미래 객체가 설정되었는지만 알면 된다.
- 이럴 때 필요한 것이 전달할 자료가 없음을 뜻하는 타입인
void다. - 결론적으로 검출 과제는
std::promise<void>를, 반응 과제는std::future<void>나std::shared_future<void>를 사용하면 된다.
std::promise<void> p; // 통신 채널에서 사용할 약속 객체
검출 과제
… // 사건을 검출한다
p.set_value(); // 반응 과제에게 통지한다
반응 과제
… // 반응 준비
p.get_future().wait(); // p에 해당하는 미래 객체를
// 기다린다
… // 사건에 반응한다
- 플래그 접근방식처럼 뮤텍스가 필요하지 않다. 반응 과제가
wait로 대기하기 전에 검출 과제가std::promise를 설정해도 작동하며, 가짜 기상도 없다. (가짜 기상 문제는 조건 변수에서만 일어난다.) - 조건 변수 접근방식처럼 반응 과제는
wait호출 후 진정으로 차단되므로 기다리는 동안 시스템 자원을 전혀 소모하지 않는다.
이 접근방식의 한계
- 항목 38에서 설명했듯이
std::promise와 미래 객체 사이에는 공유 상태가 있으며, 대체로 공유 상태는 동적으로 할당된다. 따라서 이 설계는 힙 기반 할당 및 해제 비용을 유발한다고 가정해야 한다. - 더 중요한 것은
std::promise를 한 번만 설정할 수 있다는 점이다. 즉 통신 채널은 여러 번 되풀이해서 사용할 수 없는 단발성(one-shot) 메커니즘이다. (조건 변수는 여러 번 통지할 수 있고, 플래그는 몇 번이고 해제하고 다시 설정할 수 있다.)
🔍 단발성 제약의 활용: 유보된 스레드
- 이러한 단발성 제약이 생각만큼 제한적이지는 않다. 시스템 스레드를 유보된 상태로 생성한다고 하자.
- 스레드 생성에 관련된 오버헤드를 미리 처리해 두고, 뭔가를 실행할 때가 되었을 때 통상적인 스레드 생성 관련 지연 없이 즉시 실행할 수 있게 한다.
- 또는 실행 전에 스레드 우선순위나 코어 친화도 같은 것을 세밀하게 구성하고 싶을 때도 유보된 상태의 스레드를 생성한다.
- C++ 동시성 API로 그런 설정들을 직접 조작할 수는 없지만,
std::thread객체의native_handle멤버 함수를 통해 플랫폼의 바탕 스레드 API(POSIX threads나 Windows 스레드 API)에 접근하는 것은 가능하다.
- 스레드를 한 번만 유보한다면(생성 시점과 해당 스레드 함수의 실행시점 사이에서), void 미래 객체를 이용하는 설계가 합리적인 선택이다.
std::promise<void> p;
void react(); // 반응 과제에 해당하는 함수
void detect() // 검출 과제에 해당하는 함수
{
std::thread t([] // 스레드를 생성한다
{
p.get_future().wait(); // 미래 객체가 설정될
react(); // 때까지 t를 유보
});
… // 여기서 t는 유보된 상태임;
// react는 그다음에 호출됨
p.set_value(); // t의 유보를 푼다(그러면
// react를 호출한다)
… // 추가 작업을 수행
t.join(); // t를 합류 불가능으로
} // 만든다(항목 37 참고)
- (옮긴이) 흔히 suspend(유보)의 반댓말로 resume(재개)을 사용하지만, 한 번도 실행된 적이 없는 스레드를 ‘재개’한다는 것이 좀 이상했는지 저자는 유보된 스레드의 실행을 시작하는 것을 ‘unsuspend’라고 표현한다.
⚠️ ThreadRAII 사용 시 함정
detect바깥의 모든 경로에서t를 합류 불가능으로 만드는 것이 중요하므로, 항목 37의ThreadRAII같은 RAII 클래스를 사용하는 것이 바람직하다.
void detect()
{
ThreadRAII tr( // RAII 객체 사용
std::thread([]
{
p.get_future().wait();
react();
}),
ThreadRAII::DtorAction::join // 위험함! (설명 참고)
);
… // 여기서 tr 내부의
// 스레드는 유보된 상태임
p.set_value(); // tr 내부 스레드의
// 유보를 푼다
…
}
- 이전보다는 안전해 보이지만, 만일 첫
…부분(여기서 tr 내부의 스레드는 유보된 상태임)에서 예외가 발생하면p에 대한set_value호출이 일어나지 않으며, 따라서 람다 안의wait호출은 계속해서 차단된다.- 즉 람다를 실행하는 스레드가 결코 완료되지 않는다.
tr의 소멸자가 그 스레드에 대해join을 호출하므로, 코드의 첫…부분에서 예외가 방출되면tr의 소멸자가 결코 완료되지 않아 이 함수가 멈추게 된다.
- 이 문제를 해결하는 방법은 독자의 연습문제로 남겨둔다. (저자의 블로그 The View From Aristeia의 2013년 12월 24일자 글 “ThreadRAII + Thread Suspension = Trouble?”이 출발점으로 적당하다.)
🔍 여러 반응 과제: std::shared_future
- 원래의 코드(
ThreadRAII를 사용하지 않는 버전)를 반응 과제 하나가 아니라 여러 개를 유보하고 풀도록 확장하는 것이 가능하다. - 핵심은
react코드에서std::future대신std::shared_future를 사용하는 것이다.std::future의share멤버 함수가 자신의 공유 상태에 대한 소유권을share가 반환하는std::shared_future객체에 넘겨준다.- 까다로운 부분은 반응 과제 스레드마다 공유 상태를 참조하는 개별적인
std::shared_future복사본을 두어야 한다는 점이다. 그래야share로 얻은std::shared_future를 반응 과제 스레드에서 실행되는 람다가 값으로 캡처할 수 있다.
std::promise<void> p; // 이전과 동일
void detect() // 이제는 여러 개의 반응
{ // 과제에 통지한다
auto sf = p.get_future().share(); // sf의 타입은
// std::shared_future<void>
std::vector<std::thread> vt; // 반응 스레드들을 담는
// 컨테이너
for (int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{ sf.wait(); // sf의 지역 복사본을
react(); }); // 기다린다;
} // emplace_back은 항목 42를 보라
… // 만일 이 "…"에서 예외가
// 발생하면 합류 가능한
// std::thread들이 파괴되어서
// 프로그램이 종료된다!
p.set_value(); // 모든 스레드의 유보를 푼다
…
for (auto& t : vt) { // 모든 스레드를 합류
t.join(); // 불가능으로 만든다;
} // auto& 구문은 항목 2를 보라
}
- 미래 객체를 이용한 설계가 이러한 효과를 낸다는 것은 주목할 만한 일이다. 단발성 사건 통신에 그런 설계를 사용할 것을 고려해야 하는 이유가 바로 이것이다.
🧐 정리
- 간단한 사건 통신을 수행할 때, 조건 변수 기반 설계에는 여분의 뮤텍스가 필요하고, 검출 과제와 반응 과제의 진행 순서에 제약이 있으며, 사건이 실제로 발생했는지를 반응 과제가 다시 확인해야 한다.
- 플래그 기반 설계를 사용하면 그런 단점들이 없지만, 대신 차단이 아니라 폴링이 일어난다는 단점이 있다.
- 조건 변수와 플래그를 조합할 수도 있으나, 그런 조합을 이용한 통신 메커니즘은 필요 이상으로 복잡하다.
std::promise와 미래 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 그런 접근방식은 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능하다.
댓글남기기