[Modern C++] 항목 39: 단발성 사건 통신에는 void 미래 객체를 고려하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 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와 미래 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 그런 접근방식은 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능하다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기