-
[Effective Modern C++] 스레드 간 단발성 이벤트 통신C++/Effective Modern C++ 2023. 5. 4. 15:59
프로그램의 이벤트를 분류하는 방법으로, 이벤트의 반복성을 들 수 있습니다.
프로그램 전반에 걸쳐서 주기적으로 발생하는 이벤트가 있는가 하면, 한 번 발생 이후 소멸하는 이벤트가 있습니다.
단발성 이벤트 경우, 대부분 이벤트의 발생 조건이 되는 트리거가 있습니다.
프로그램의 구조 상, 이벤트는 발생 조건이 되는 트리거의 감지와, 이후 실행하는 작업을 분리하는 것이 유용한 경우가 있습니다.
예를 들면 자료구조의 초기화, 계산 과정 중 특정 단계의 완료, 센서 임계값 감지 등이 있을 수 있습니다.
이번 글에서는 위와 같은 단발성 이벤트가 이벤트의 감지와 실행이라는 두 이벤트로 분리되어 있다고 할 때, 해당 이벤트들의 통신 방법에 대하여 살펴보도록 하겠습니다.
이번 글에서는, 이벤트의 트리거를 감지하는 과제를 [검출 과제]로
트리거를 감지해서 작업을 수행하는 과제를 [반응 과제]로 명명하여 사용하도록 하겠습니다.
1. 조건 변수 (std::condition_variable)
더보기조건 변수를 사용할 경우, 반응 과제는 조건 변수를 기다리고, 검출 과제는 이벤트가 발생할 경우 조건 변수를 통지합니다.
std::condition_variable cv; // 조건 변수 std::mutex m; // 뮤택스
위와 같은 객체들이 있을 때, 검출 과제는 아래와 같이 통지할 수 있습니다.
// 이벤트 검출 ... // 과제 통지 cv.notify_one();
만약 과제가 여러개일 경우, notify_all함수를 사용할 수 있습니다.
반응 과제는 뮤택스를 잠그고, 통지를 기다립니다.
... { std::unique_lock<std::mutex> lk(m); // Lock m cv.wait(lk); // Wait ... // Work } ... // Unlock m
위와 같은 방식은 여러 문제점이 발생합니다.
- std::condition_variable은 뮤택스를 요구합니다. 하지만, 이벤트의 관점에서 뮤택스가 필요하지 않을 수 있습니다. 예를 들어, 검출 과제가 자료구조의 초기화 과정에서 접근하고, 초기화 이후에는 반응 과제만 접근할 경우, 두 과제의 접근 시간대가 완전히 달라 접근 제어가 필요하지 않습니다.
- 반응 과제의 wait이전에 검출 과제의 notify가 실행될 경우, 반응 과제가 멈추게 됩니다.
- 대기중인 반응 과제는 notify이전에도 깨어날(스레드의 차단이 해제됨)수 있습니다. 이를 Spurious wakeup이라고 합니다. 위 코드에는 이와 같은 상황에 대한 대처가 되어있지 않습니다. 이 경우, notify이전에 반응 과제가 작업을 수행할 가능성이 있습니다.
위와 같은 이유들로 조건 변수 (std::condition_variable)을 사용하는 것은, 이번 글의 주제와는 맞지 않아보입니다.
2. bool flag
더보기특정 상황에 대한 감지를 위해 bool 변수를 추가로 생성하여, 이를 flag 로 사용하는 것은 간단하면서도 강력한 기법입니다.
검출 과제는 아래와 같이, flag를 true로 설정합니다.
std::atomic<bool> flag(false); ... flag = true // 이벤트 통지
반응 과제는 아래와 같이 flag를 기다립니다.
... // Wait while(!flag); ... // Work
위와 같은 방식은 위 문단의 std::condition_variable의 문제가 없습니다.
- 뮤택스를 사용하지 않습니다.
- 검출 과제, 반응 과제의 호출 순서에 영향을 받지 않습니다.반응 과제가 정지할 우려가 없습니다.
- 반응 과제는 항상 깨어난채로 Polling(주기적 점검)을 수행합니다.. 검출 이전에 반응 과제가 작업을 수행할 우려가 없습니다.
하지만 이 접근 방식에는 치명적인 단점이 있습니다.
반응 과제가 항상 깨어있는것이 바로 그것으로, 반응 과제는 스레드를 점유합니다.
이는 프로그램이 아닌 더 큰 관점에서 바라본다면, 메모리 자원을 항상 차지하고 있다는 것과 같습니다. 반응 과제는 검출 과제의 플래그 통지를 대기할 뿐이지만, OS는 대기중인 반응 과제에 대한 Context switching을 수행해야 합니다.
3. 조건 변수 + bool flag
더보기위 두 가지 방법을 결합하는 방법도 생각해볼 수 있습니다.
이벤트 발생 여부를 flag로 나타내고, flag에 대한 접근을 뮤택스로 동기화 하는 것 입니다.
검출 과제는 다음과 같습니다.
std::condition_variable cv; std::mutex m; bool flag(false); ... // 이벤트 검출 { std::lock_guard<std::mutex> g(m); flag = true; // 통지 1 } cv.notify_one(); // 통지 2
반응 과제는 다음과 같습니다.
... { std::unique_lock<std::mutex> lk(m); cv.wait(lk, [] { return flag; }); ... // Work } ...
wait에 추가된 람다식은 Spurious wakeup의 발생을 차단하기 위한 람다입니다.
검출 과제의 통지 1은 위의 Spurious wakeup을 방지하기 위한 1차적인 통지이며, 실제 과제의 완료 여부는 notify의 호출(통지 2)로 이루어집니다.
이러한 구현은 문단 1과 문단 2의 문제를 다수 해결합니다.
- Polling을 하지 않습니다. 이벤트 발생 이전까지 반응 과제는 Sleep상태입니다.
- wait에서 flag를 점검하기 때문에 Spurious wakeup이 문제가 되지 않습니다.
- wait호출 이전에 notify가 되어도 문제가 되지 않습니다.
이벤트와 관련된 통지는 notify를 통해서 수행하지만, 이후 작업이 정상적으로 수행되려면 bool flag또한 설정해주어야 합니다.
여러 문제를 해결하지만, 검출 과제가 두 단계 (bool 및 notify)의 검출을 하는 것이 깔끔하지 않습니다.
4. std::promise
더보기이전 글 (std::furue의 소멸자)에서, std::promise에 대해 언급했습니다.
std::future객체는 특정한 통신 채널로 볼 수 있다는 내용이었습니다.
해당 글에서는 호출자와 피호출자였지만, 이번 글의 검출 과제와 반응 과제 사이의 통신에서도 적용이 가능합니다.
이 통신의 단자로 이용되는 객체가 std::promise입니다.
검출 과제는 std::promise객체, 반응 과제는 이에 대응하는 future객체를 생성합니다.
보통 future객체는 템플릿에 반환할 자료형을 지정하지만, 이번에 필요한 것은 반환값이 아닌, 이벤트의 발생 여부입니다. 따라서 템플릿은 void로 설정합니다.
따라서 다음과 같은 객체가 있을 때
std::promise<void> p;
검출 과제는 다음과 같습니다.
... p.set_value(); // 통지
반응 과제는 다음과 같습니다.
... p.get_future().wait(); // Wait ... // Work
위와 같이 간단한 구현은 위 문단들에서 발생한 문제가 일어나지 않습니다.
접근 제어가 필요하지 않아 뮤택스가 필요없고, Spuriouse wakeup도 없습니다.
wait의 실행 순서 또한 무관합니다.
Polling이 일어나지 않아 스레드는 대기시간동안 시스템 자원을 소모하지 않습니다.
하지만 다른 관점에서 살펴볼 문제가 발생합니다.
std::promise와 future객체 사이에는 Shared state가 존재합니다.
Shared state에 관한 내용은 다른 글 (std::furue의 소멸자)에 서술되어 있습니다.
Shared state의 존재로 인해, std::promise는 힙 기반 할당 및 해제 비용이 발생합니다.
또한, std::promise의 설정은 한 번만 할 수 있습니다.
위 문단에서 언급하지는 않았고, 이번 글의 주제가 단발성 통신이기에 큰 문제가 되지 않지만, 다른 구현 방식들은 여러번 사용할 수 있지만, std::promise는 한번만 사용할 수 있습니다.
스레드 간의 간단한 통신에서 사용할 수 있는 기법을 살펴보았습니다.
조건 변수(std::condition_variable), bool flag, std::promise를 사용하여 구현을 할 때, 어떤 문제가 발생하는 지 살펴보았습니다.
std::condition_variable은 여러 문제가 발생하여 이번 글과는 맞지 않아보였습니다.
bool flag는 불편한 성능 이슈가 발생합니다.
std::promise는 위와 같은 문제는 없지만, 힙 메모리 비용이 발생하고, 단발성 통신에만 이용이 가능했습니다.
이번 글의 상황에는 future객체를 통한 std::promise를 사용하는 것이 바람직해보입니다.
다른 두 방법이 단점만 있는 것은 아닙니다.
설정한 상황과 맞지 않았을 뿐, condition_variable과 bool flag또한 유용한 방법입니다.
상황에 맞는 방법을 선택해서 사용할 수 있으면 좋겠습니다.
감사합니다.
'C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] 값 전달을 고려할만한 상황 (0) 2023.06.09 [Effective Modern C++] std::atomic VS volatile (0) 2023.05.12 [Effective Modern C++] std::future의 소멸자 (0) 2023.04.19 [Effective Modern C++] std::thread를 unjoinable하게 만들어야 하는 이유 (0) 2023.04.14 [Effective Modern C++] std::async의 launch policy (std::launch) (0) 2023.03.28