본문 바로가기
💻 programming/c++

[c++] condition variable (조건 변수)

by 연구원-A 2020. 12. 17.
반응형

condition variable (조건 변수)를 사용하면 c++에서 멀티스레드 간 동기화를 구현할 수 있다

 

그렇지만 condition variable을 사용하는 것보다는,

packaged taskasync와 같이 c++에서 제공하는 task를 이용하는 것이

멀티스레드 간 동기화를 안정적으로 구현하는 방법이라는 점을 명심하자

 

2021.08.04 - [c++/library] - [c++] thread vs task (thread 와 async)

2020/12/15 - [c++ language/library] - [c++] future

2020/12/15 - [c++ language/library] - [c++] packaged task

2020/12/15 - [c++ language/library] - [c++] async

 

아래 해외 블로그에서 조건 변수를 사용하여 스레드간 동기화를 구현하는 간단한 예제와

conditional variable의 문제점을 설명하고 있으니 참고

 

 

Condition Variables - ModernesCpp.com

Condition variables allow us to synchronize threads via notifications. So, you can implement workflows like sender/receiver or producer/consumer. In such a workflow, the receiver is waiting for the the sender's notification. If the receiver gets the notifi

www.modernescpp.com

 

condition variable (조건 변수)는 notification이라는 개념을 통해 스레드간의 동기화를 지원하고 있다

 

우리는 바로 이 notification을 이용해서

sender/receiver 또는 producer/consumer와 같은 병렬 스레드의 실행 흐름을 제어할 수 있는 것이다

 

예를들어 아래와 같이 동작하는 sender, receiver를 구현할 수 있다

  • receiver는 notification을 받을 때까지 작업을 대기하고 있다가
  • sender가 notification을 보내면 작업을 수행한다

std::condition_variable

멀티 스레드 환경에서 동기화를 수행하려면

sender와 receiver 둘 다 condition variable의 인스턴스를 가지고 있어야 한다 (동일한 condition variable을 공유해야 함)

 

코드 구현은 단순한데

  • receiver는 wait 함수를 호출해서 notification을 기다리고 (condVar.wail(lck))
  • sender는 notify 함수를 호출해 receiver에게 notification을 전달한다 (condVar.notify_one())

 

사실 위에 설명한 내용이 condition variable을 사용하는 이유의 전부라고 할 수 있다

#include <iostream>
#include <condition_variable>
#include <mutex>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar;

void doTheWork(){
  std::cout << "Processing shared data." << std::endl;
}

void waitingForWork(){
    std::cout << "Worker: Waiting for work." << std::endl;

    std::unique_lock<std::mutex> lck(mutex_);
    condVar.wait(lck);
    doTheWork();
    std::cout << "Work done." << std::endl;
}

void setDataReady(){
    std::cout << "Sender: Data is ready."  << std::endl;
    condVar.notify_one();
}

int main(){

  std::cout << std::endl;

  std::thread t1(waitingForWork);
  std::thread t2(setDataReady);

  t1.join();
  t2.join();

  std::cout << std::endl;
  
}

 

위 코드를 조금 더 설명하면 먼저 main 함수에서 t1과 t2 두 개의 스레드를 생성한다

각 스레드는 각각 호출가능한 함수인 waitingForWork와 setDataReady를 각각 인자로 받아 병렬로 실행된다

  • waitingForWork
    • lock을 유지한 상태로 notification을 기다리고 (condVar.wait(lck))
    • notification을 수신하면 doTheWork()를 수행
  • setDataReady
    • 준비가 완료되면 notify_one() 함수를 호출

코드 실행결과는 사실 특별하지 않고 생각했던 그대로 실행될 것이다

Worker: Waiting for work     # Receiver가 기다리다가
Sender: Data is ready.        # Sender가 준비되면
Processing shared data.     # Notification을 전달하고
Work done.                         # Receiver가 작업을 완료

 

그런데 한 가지 짚고 가야할 것이 있는데.. 🙄


Spurious wakeup

악마는 디테일에 있다고 한다

바로 condition variable이 spurious wakeup라는 문제를 내재하고 있다는 것이다

Spurious wakeup
POSIX나 모든 OS에서 signal을 줬을때 하나만 깨어나는 것이 아니라 동시에 여러 wait condition이 깨어나는 현상을 뜻합니다. 이는 OS의 성능 이슈이기 때문에 개발자 영역으로 남겨져 있습니다.

위와 같은 피치 못할 문제로 

sender가 notification을 다 보내기도전에 receiver가 대기하지 않고 작업을 수행해버리는 불상사가 일어날 수 있다

 

결국 이러한 문제를 막으려면 개발자가 개입해야 한다

 

바로 wait 함수에 predicate를 추가하는 것이다 (predicate는 true 또는 false를 반환하는 callable이어야 함)

 

이전 코드와 달라진 것은 dataReady라는 boolean값을 사용한 것 말고는 없다

대신 이 dataReady 값을 체크해서 sender가 정상적으로 전달한 신호인지를 더블-체크하는 방법을 사용할 수 있다

#include <iostream>
#include <condition_variable>
#include <mutex>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar;

bool dataReady;

void doTheWork(){
  std::cout << "Processing shared data." << std::endl;
}

void waitingForWork(){
    std::cout << "Worker: Waiting for work." << std::endl;

    std::unique_lock<std::mutex> lck(mutex_);
    condVar.wait(lck,[]{return dataReady;});
    doTheWork();
    std::cout << "Work done." << std::endl;
}

void setDataReady(){
    std::lock_guard<std::mutex> lck(mutex_);
    dataReady=true;
    std::cout << "Sender: Data is ready."  << std::endl;
    condVar.notify_one();
}

int main(){

  std::cout << std::endl;

  std::thread t1(waitingForWork);
  std::thread t2(setDataReady);

  t1.join();
  t2.join();

  std::cout << std::endl;
  
}

 

unique_lock과 lock_guard를 따로 사용한 이유?

  • receiver(waitingForWork)에서는 condition variable 내에서 mutex의 unlock과 lock을 을 여러번 호출하기 때문에 unique lock을 사용
  • sender(setDataReady)에서는 mutex의 lock과 unlock이 단 한 번씩만 수행되므로 lock guard로도 충분

cf. unique_lock과 lock_guard의 차이는 다음 페이지 참조:

2020/12/16 - [c++ language/library] - [c++] mutex, lock guard, unique lock, shared mutex


Lost wakeup

아 그럼 이제 다 됐나? (아니다)

 

condition variable의 문제는 아직도 남아있다

 

첫 번째 코드를 한 10번정도 실행하면 프로그램이 가끔 멈추는데

(앞서 conditional variable 보다 async와 같은 task를 사용해야 한다고 말했던 이유임)

 

멈추는 문제가 발생한 이유는

receiver가 wait 함수를 호출하기도 전에 sender가 notification을 전달해버리는 경우가 발생하기 때문이다

그러면 notification은 소실되고 receiver는 영원한 대기상태에 빠진다

 

그럼 이 문제를 해결하는 방법은 무엇일까?

 

condition variable을 통해 spurious wakeup, lost wakeup의 모든 문제를 피하기는 어렵다

 

물론 predicate를 사용하는 것만으로도 문제를 해결하는데 도움이 될 수 있겠지만

결국 c++에서 멀티스레딩을 잘 사용하려면 task를 이용할 수 있어야 한다

 

 

Thread와 Task?

2021.08.04 - [c++/library] - [c++] thread vs task (thread 와 async)

반응형

'💻 programming > c++' 카테고리의 다른 글

[c++] thread  (0) 2021.08.04
[c++] lvalue reference and rvalue reference  (0) 2021.08.04
[c++] std::shared_mutex  (0) 2020.12.16
[c++] std::timed_mutex  (0) 2020.12.16
[c++] std::recursive_mutex (std::mutex 비교)  (0) 2020.12.16

댓글