KoreanFoodie's Study

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

Tutorials/C++ : Advanced

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

GoldGiver 2022. 10. 26. 10:08

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

1. 간단한 사건 통신을 수행할 때, 조건 변수 기반 설계에는 여분의 뮤텍스가 필요하고, 검출 과제와 반응 과제의 진행 순서에 제약이 있으며, 사건이 실제로 발생했는지를 반응 과제가 다시 확인해야 한다.
2. 플래그 기반 설계를 사용하면 그런 단점들이 없지만, 대신 차단이 아니라 폴링이 일어난다는 단점이 있다.
3. 조건 변수와 플래그를 조합할 수도 있으나, 그런 조합을 이용한 통신 메커니즘은 필요 이상으로 복잡하다.
4. std::promise 와 미래 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 그런 접근 방식은 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능하다.

 

스레드 간 통신을 수행할때, 조건 변수(condition variable, 줄여서 condvar)을 사용하는 경우가 많다. 조건을 검출하는 과제를 검출 과제(detecting task) 라고 부르고, 그 조건에 반응하는 과제를 반응 과제(reacting task) 라고 부르도록 한다. 예시를 보자.

std::condition_variable cv;

std::mutex m;

// 사건 검출
...

// 반응해야 하는 과제에게 알림
cv.notify_one();

이제 반응 과제의 코드를 보자.

{
  // 임계 영역을 열고, 뮤텍스를 잠근다
  std::unique_lock<std::mutex> lk(m);

  // 통지를 기다린다 (제대로 된 방식이 아님!)
  cv.wait(lk);

  // 사건에 반응한다 (m 이 잠긴 상태)
  ... 

  // 임계 영역을 닫고, lk 의 소멸자가 m 을 해제
}

// 계속 반응한다 (m 은 이제 풀린 상태)
...

위의 코드는 뮤텍스가 필요 없을 수 있는(두 과제가 같은 자료구조를 동시에 수정하는 경우가 없을 수도 있으므로) 잠재적인 코드 냄새(code smell; 또는 코드 악취) 가능성 이외에도, 두 가지 문제점이 더 있다.

  • 만일 반응 과제가 wait 을 실행하기 전에 검출 과제가 조건 변수를 통지하면 반응 과제가 멈추게 된다(hang). 영원히 통지를 놓치게 되는 것이다.
  • wait 호출문은 가짜 기상을 고려하지 않는다. 조건 변수를 기다리는 코드가 조건 변수가 통지되지 않았는데도 깨어날 수 있다는 것은 스레드 적용 API 들에서 흔히 있는 일이다. 그런 일을 가짜 기상(spurious wakeup) 이라고 부른다. 그런데 이 능력을 활용하려면, 자신이 기다리는 조건이 참인지를 반응 과제가 판단할 수 있어야 하는데, 그게 가능했다면 애초에 조건 변수를 기다리지 않았을 것이다!

 

대안으로, 공유 bool 플래그를 만들어 보자.

/* 검출 스레드 */
// 공유 플래그
std::atomic<bool> flag(false);

... // 사건을 검출
flag = true; // 반응 과제에게 통지


/* 반응 스레드 */
... // 반응 준비
while (!flag); // 사건을 기다림
... // 사건에 반응

위 코드는 조건 변수 기반 설계의 단점은 없지만, 반응 과제의 폴링(polling; 주기적 점검) 비용이 매우 크다.

 

조건 변수 기반 설계와 플래그 기반 설계를 결합할 수도 있다. 즉, 사건 발생 여부를 플래그로 나타내되, 그 플래그에 대한 접근을 뮤텍스로 동기화하는 것이다.

/* 검출 과제 */
std::condition_variable cv;
std::mutex m;

bool flag(false);

... // 사건 검출

{
  std::lock_guard<std::mutex> g(m);

  // 반응 과제에게 통지 (1부)  
  flag = true; 
}

// g 의 소멸자에서 m 을 푼다
// 반응 과제에게 통지 (2부)
cv.notify_one(); 


/* 반응 과제 */
... // 반응 준비

{
  std::unique_lock<std::mutex> lk(m);

  // 가짜 기상을 방지하기 위해 람다 사용
  cv.wait(lk, [] { return flag; });

  ... // 사건에 반응 (m 은 잠긴 상태)
}

... // 계속 반응 (m 은 풀린 상태)

위 방식은 제대로 동작하긴 하지만, 플래그를 점검하기 전에 먼저 조건 변수를 통지해 반응 과제를 깨워야 한다.

조건 변수와 뮤텍스, 플래그를 아예 사용할 필요가 없는 한 가지 대안은, 검출 과제가 설정한 미래 객체를 반응 과제가 기다리게(wait 를 통해서) 하는 것이다. 검출 과제에는 std::promise 객체(통신 채널의 전송 단자)를 하나 두고, 반응 과제에는 그에 대응되는 미래 객체를 하나 둔다. 기다리던 사건이 발생했음을 인식하면 검출 과제는 자신의 std::promise 를 설정한다(즉, 통신 채널에 정보를 기록한다). 예시를 보자.

/* 검출 과제 */

// 통신 채널에서 사용할 약속 객체
std::promise<void> p;

... // 사건 검출

p.set_value(); // 반응 과제에게 통지


/* 반응 과제 */

... // 반응 준비

// p 에 해당하는 미래 객체를 기다림
p.get_future().wait();

... // 사건에 반응

위 코드에서 std::promise 의 형식을 void 로 설정한 것은 통신 채널을 통해 전달할 자료가 없다는 의미이다.

위 코드는 뮤텍스가 필요 없고, 반응 과제가 wait 으로 대기하기 전에 검출 과제가 자신의 std::promise 를 설정해도 작동하며, 가짜 기상도 없다(가짜 기상 문제는 조건 변수에서만 일어난다). 그리고 wait 호출 후 반응 과제는 진정으로 차단되므로 기다리는 동안 시스템 자원을 전혀 소모하지 않는다.

물론 std::promise 를 사용한 방식은 공유 상태를 동적으로 할당해야 하는 비용을 지불해야 하며, 단발성(one-shot) 매커니즘이라는 차이가 있다. std::promise 와 미래 객체 사이의 통신 채널은 여러 번 되풀이해서 사용할 수 없기 때문이다.

 

시스템 스레드를 유보된 상태로 생성하는 예시를 보자.

std::promise<void> p;

// 반응 과제에 해당하는 함수
void react();

// 검출 과제에 해당하는 함수
void detect()
{

  std::thread t([]
    {
      // 미래 객체가 설정될 때까지 t 를 유보
      p.get_future().wait();
      react();
    });

  // t 는 유보된 상태; react 는 그 다음에 실행됨
  ...

  // t 의 유보를 푼다(react 호출)
  p.set_value();

  ...

  // t 를 합류 불가능으로 만든다
  t.join();
}

 

그리고 detect 바깥의 모든 경로에서 t 를 합류 불가능으로 만들기 위해, 항목 37 의 ThreadRAII 클래스를 사용해 보자.

std::promise<void> p;

// 반응 과제에 해당하는 함수
void react();

// 검출 과제에 해당하는 함수
void detect()
{

  ThreadRAII tr(
    std::thread([]
      {
        // 미래 객체가 설정될 때까지 tr 를 유보
        p.get_future().wait();
        react();
      }),
    ThreadRAII::DtorAction::join // 위험!
  );

  // tr 는 유보된 상태; react 는 그 다음에 실행됨
  ...

  // tr 의 유보를 푼다(react 호출)
  p.set_value();

  ...
}

위의 코드에서, 만약 첫 "... (tr 이 유보된 상태)" 에서 예외가 발생하면 p 에 대한 set_value 가 호출되지 않아 람다 안의 wait 호출은 계속해서 차단된다(결국, 이 함수가 멈추게 됨). 이 문제를 해결하는 여러 방법은 작가의 블로그 글을 참고하자.

 

지금 논의에서 중요한 건, ThreadRAII 를 사용하지 않는 코들르 반응 과제 하나가 아니라 여러 개를 유보하고 풀도록 확장하는 것이 가능하다는 것이다. 다음과 같이, std::future 대신 std::shared_future 을 사용하여 일반화를 하면 된다. 까다로운 부분은, 반응 과제 스레드마다 공유 상태를 참조하는 개별적인 std::shared_future 복사본을 두어야 share 로 얻은 std::shared_future 를 반응 과제 스레드에서 실행되는 람다가 값으로 캡쳐할 수 있다는 것이다.

std::promise<void> p;

// 반응 과제에 해당하는 함수
void react();

// 이제는 여러 개의 반응 과제에 통지
void detect()
{

  // sf 의 형식은 std::shared_future<void>
  auto sf = p.get_future().share();

  // 반응 스레드들을 담는 컨테이너
  std::vector<std::thread> vt;

  // sf 의 지역 복사본을 기다린다
  for (int i = 0; i < threadsToRun; ++i) {
    vt.emplace_back([sf]{ sf.wait();
      react(); });
  }

  // 여기서 예외가 발생하면 합류 가능한
  // std::thread 들이 파괴되어 프로그램이 종료됨!
  ...

  // 모든 스레드의 유보를 푼다
  p.set_value();

  ...

  // t 를 합류 불가능으로 만든다
  for (auto& t : vt) {
    t.join();
  }
}
Comments