728x90
반응형

조건변수 기반 설계

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

std::condition_variable cv; // 조건변수
std::mutex m; // cv와 함께 사용할 뮤텍스

// 사건 검출
...

// 반응해야 하는 과제에게 알림. 반응 task가 여러개라면 notify_all을 사용하면 됨
cv.notify_one();

// 반응 task 개념 접근방식
...
{
  // 임계 영역을 열고, 뮤텍스를 잠근다
  std::unique_lock<std::mutex> lk(m);

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

  // 사건에 반응한다 (m 이 잠긴 상태)
  ... 
  
} // 임계 영역을 닫고, lk 의 소멸자가 m 을 해제

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

위 코드는 잘 동작하는 것처럼 보이나 몇몇 아쉬운 점이 있는데, 일단 검출 task와 반응 task는 동시에 같은 변수에 접근하는 일이 없어 뮤텍스를 사용할 필요가 없지만  뮤텍스를 사용하고 있다.

그리고 더 큰 문제는 반응 task가 wait를 실행하기 전에 검출 task가 조건 변수를 통지하면 반응 task가 멈추게 된다(hang). 영원히 통지를 못 받게 되는 것이다. 또, wait호출문은 가짜 기상을 고려하지 않는다. 조건 변수를 기다리는 코드가 조건변수가 통지되지 않았는데도 깨어날 수 있다는 것은 스레드 적용 API들에서 흔히 있는 일이다. 이런 일을 가짜 기상이라고 부르는데 이 문제를 해결하려고 하면 기다리던 조건이 정말로 발생했는지를 확인해야 하는데 그러면 기다리던 조건이 참인지 아닌지를 반응 task가 판단해야 하고, 이게 가능했으면 검출 task의 신호를 기다릴 필요도 없다.

 

공유 bool 플래그

위의 대안으로 공유 bool 플래그를 사용할 수도 있다.

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

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


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

이러한 플래그 기반 설계를 사용하면 위의 단점들이 없지만, 대신 반응 task의 폴링(polling: 주기적 점검) 비용이 매우 크다.

 

조건변수와 플래그 사용

사건 발생 여부를 플래그로 나타내되, 그 플래그에 대한 접근을 뮤텍스로 동기화한다.

// 검출 task
std::condition_variable cv;
std::mutex m;

bool flag(false); // atomic이 아님

... // 사건 검출

{
  std::lock_guard<std::mutex> g(m); // g의 생성자에서 m을 잠금

  // 반응 task에 통지 (1부)  
  flag = true; 
}

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


// 반응 task
... // 반응 준비

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

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

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

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

위와 같은 방법으로는 지속적인 폴링도 일어나지 않으며, 가짜 기상이 발생하더라도 wait 상태를 유지한다. 그러나 mutex 를 사용해야 한다는 점과 bool 변수까지 써야 한다는 점은 무언가 깔끔하지 않다. 또한 반응 task에서 wait 이 먼저 호출되어야 한다는 점은 변하지 않았다.

 

std::promise와 future객체 사용

앞선 항목에서 살펴보았던 문제를 모두 해결하는 방법이다.

std::promise<void> p;

// 검출 task
{
  ...
  p.set_value();
}

// 반응 task
{
  ...
  p.get_future().wait();
  ...
}

 한 가지 주의해야 할 점은 힙 메모리 관리를 해야 한다는 점과 공유 상태가 존재한다는 점이다. 더 중요한 것은 std::promised를 한 번만 설정할 수 있다는 점이다. 즉 일회성이라는 의미이다. 이러한 특징을 생각하면 void 미래 객체를 사용하는 것이 합리적이다.

std::promise<void> p;

// 반응 task
void react();

// 검출 task
void detect()
{
  ThreadRAII tr(
    std::thread([]
                {
                  p.get_future().wait();
                  react();
                }),
    ThreadRAII::DtorAction::join
  );
  ... // tr의 내부 스레드는 정지
  p.set_value(); // tr 내부 스레드 정지 품
  ...
}

이 전보다는 안전하지만 만일 tr의 내부 스레드가 정지되니 상태에서 예외가 발생하면 p에 대한 set_value호출이 일어나지 않으며, 람다 안의 wait호출은 계속해서 차단된다. 즉, 람다를 실행하는 스레드가 결코 완료되지 않는다.

그리고 반응 task가 여러 개여도 된다.

std::promise<void> p;

// 여러 반응 task에 통지
void detect()
{
  auto sf = p.get_furue().share(); // std::shared_future<void>
  
  std::vector<std:;thread> vt;
  
  for (int i = 0; i < threadsToRun; ++i)
  {
    vt.emplace_back([sf]{ sf.wait(); react(); });
  }
  
  ... // 예외 발생 시 std::thread파괴로 프로그램 종료
  p.set_value();
  ...
  
  // 모든 스레드를 join불가능으로 만듬
  for (auto& t : vt)
  {
    t.join();
  }
}

 

요약

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