어떤 특정한 사건(event)이 일어나야만 작업을 진행할 수 있는 비동기 작업에게 그 사건이 발생했음을 알려주는 또 다른 작업을 두는 것이 유용한 경우가 있다.
예를 들어 자료구조의 초기화, 계산 과정 중 특정 단계의 완료 등이 있다.
스레드 간 통신을 처리하는 방법 몇 가지를 알아보자.
조건 변수 사용
검출 작업(조건을 검출), 반응 작업(조건에 반응)으로 나누어 수행한다.
std::condition_variable cv;
std::mutex m;
위와 같은 객체들이 있을 때,
... // 사건 검출
cv.notify_one(); // 반응 작업에게 통보
검출 작업은 매우 간단하다.
... // 반응 준비
{ // 임계 영역 오픈
std::unique_lock<std::mutex> lk(m); // 뮤텍스 잠금
cv.wait(lk); // 통보 대기
... // 사건 반응
} // 임계 영역 닫음
... // 계속 반응
반응 작업의 코드는 조금 복잡하다.
여기서 문제는 뮤텍스가 필요 없을수도 있는데 뮤텍스를 사용한다는 점이다. 그렇다고 뮤텍스를 사용한다고 해서 엄청난 문제가 있는것은 아니다.
그보다 다른 문제들이 있다.
◾ 만약 반응 작업이 wait를 실행하기 전에 검출 작업이 조건 변수를 통지하면 반응 작업이 멈추게 된다(hang). 반응 작업이 wait를 실행하기 전에 검출 작업이 통지를 실항하게 되면 반응 작업은 그 통지를 놓치게 되어 영원히 기다리게 된다.
◾ wait 호출문은 가짜 기상을 고려하지 않는다. 조건 변수가 통지되지 않았는데도 깨어날 수 있다. 이 경우 조건 변수가 깨어나자마자 실제로 조건이 발생했는지 체크를 해야한다.
cv.wait(lk, []{ return 사건 발생 여부; });
wait에 람다를 넘겨주면 되는데 사건 발생 여부를 검출하는 것은 검출 작업의 몫이기 때문에 반응 작업은 사건 발생 여부를 판단하지 못할 수 있다. 애초에 판단이 됐으면 조건 변수를 기다리지도 않았을 것이다.
공유 bool 플래그 사용
std::atomic<bool> flag(false);
...
flag = true; // 사건 검출
반응 작업 스레드에서는 bool 플래그를 계속 폴링한다.
조건 변수 사용에서 발생하는 단점들이 모두 사라지지만 폴링 비용이 모든 장점을 다 깎아먹는다.
플래그가 설정되기 전까지 반응 작업은 사실상 차단된 상태지만 계속 폴링을 해야한다.
조건 변수와 공유 플래그 결합
조건 변수와 공유 플래그 사용을 결합해서 사용하는 경우도 흔히 있다.
사건 발생 여부를 플래그로 나타내고 그 플래그에 대한 접근을 뮤텍스로 동기화 하는 것이다.
문제점은 없지만 검출 작업이 반응 작업과 기묘하게 통신한다는게 꺼림직하다.
검출 작업은 조건 변수를 통지할 뿐만 아니라 플래그도 설정하고 반응 작업은 통지를 받아도 확신하지 못하고 반드시 플래그를 점검해야 한다.
검출 작업이 설정한 future 객체를 반응 작업이 기다리게 하기
검출 작업과 반응 작업은 호출자와 피호출자의 관계는 아니지만 반드시 해당 관계에서만 사용할 수 있는것은 아니다.
단순히 전송 단자가 std::promise이고 수신 단자가 future 객체일 뿐이다. 이를 사용하면 프로그램 내에서 서로 통신해야 하는 모든 상황에 사용할 수 있다.
검출 작업에는 std::promise 객체를 하나 두고 반응 작업에는 대응되는 future 객체 하나를 둔다.
사건이 발생하면 검출 작업은 std::promise를 설정하게 되고 반응 작업은 future 객체에 대한 wait을 호출해 둔 상태이기 때문에 std::promise가 설정될 때까지 차단된다.
그런데 std::promise나 future객체나 둘 다 타입 매개변수를 요구하는 템플릿이다. 그런데 딱히 넘겨줄 자료는 없고 그저 반응 작업이 std::promise가 설정되었는지만 체크하면 된다.
이 때 void를 사용하게 된다.
std::promise<void> p;
p.set_value(); // 검출 작업
...
p.get_future().wait(); // 반응 작업
검출 작업은 std::promise<void>, 반응 작업은 std::future<void> 또는 std::shared_future<void>를 사용하면 된다.
반응 작업이 검출 작업에게 아무런 데이터도 받지 않지만, 검출 작업이 set_value를 호출해서 무언가 기록되었다는 사실은 알 수 있다. 이를 통해 서로 통신할 수 있다.
플래그를 이용한 접근방식과 마찬가지로 뮤텍스가 필요하지 않고 반응 작업이 wait로 대기하기 전에 검출 작업이 먼저 std::promise를 설정해도 잘 작동할뿐만 아니라 가짜 기상 문제도 발생하지 않는다. 가짜 기상 문제는 조건 변수에서만 일어나기 때문이다.
또한 반응 작업은 wait 호출 후 진짜로 차단되기 때문에 대기하는 동안 시스템 자원을 전혀 소모하지 않는다.
전체적으로 봤을때 많은 문제들을 회피할 수 있기 때문에 좋지만 몇 가지 주의해야 할 점들이 있다.
우선 std::promise와 future 객체 사이에는 공유 상태가 있고 공유 상태는 동적으로 할당되기 때문에 동적할당 및 해제에 대한 비용이 발생할 수 있다고 가정해야한다.
그것보다 더 중요한것은 조건 변수나 플래그 설정과 다르게 std::promise는 오직 한번만 설정할 수 있다.
std::promise와 future 객체 사이의 통신 채널은 여러번 사용할 수 없고 오직 단발성(일회성) 통신에만 사용될 수 있다.
왜냐면 검출 작업에서 set_value 호출로 인해 무언가 기록되었다는 여부만 확인할 뿐이기 때문이다.
그러나 이런 단발성 제약이 생각보다 크게 문제되지는 않는다.
std::promise<void> p;
void react(); // 반응 작업
void detect() { // 검출 작업
std::thread t([]
{
p.get_future().wait(); // future 객체가 설정될 때까지 t를 대기시킨다
react();
});
...
p.set_value(); // t의 대기 해제
... // 추가 작업 수행
t.join(); // t를 합류 불가능으로 만듬
}
스레드를 한 번만 유보시키는 경우의 기본적인 구조이다.
void detect() {
ThreadRAII tr {
std::thread t([]
{
p.get_future().wait();
react();
}),
ThreadRAII::DrotAction::join
};
...
p.set_value();
...
}
RAII 클래스를 사용한다면 위와 같은 구조가 된다.
그런데 만약 p.set_value가 호출되기 전에 예외가 발생한다면 p.set_value의 호출은 일어나지 않게 되고 람다 안의 wait 호출은 계속해서 차단되어 스레드가 완료되지 않는 문제가 생긴다. 하지만 이 부분은 이번에 따로 다루지 않는다.
RAII 클래스를 적용하기 이전 코드를 기준으로 반응 작업 하나가 아니라 여러 개의 반응 작업을 유보시키고 풀도록 확장하는 것이 가능하다. 이 경우 std::future 대신 std::shared_future를 사용하면 된다.
std::promise<void> p;
void detect() {
auto sf = p.get_future().shared(); // std::shared_future<void>
std::vector<std::thread> vt; // 반응 작업(스레드)들을 담는 컨테이너
for (int i = 0; i < threadsToRun; ++i) { // 이제는 단일 작업이 아니라 여러 작업이 담긴다
vt.emplace_back([sf]{ sf.wait();
react(); });
}
...
p.set_value(); // 모든 스레드의 유보 해제
...
for (auto& t : vt) { // 모든 스레드를 합류 불가능으로 만든다
t.join();
}
}
std::shared_future와 컨테이너를 이용해서 다수의 반응 작업을 동시에 제어하는 일반적인 코드이다.
주의할 점은 각 반응 작업 스레드마다 std::shared_future의 복사본을 두어야 한다는 것이다. 그래야 람다가 값으로 캡쳐할 수 있다.
◾ 간단한 사건 통신을 수행할 때, 조건 변수 기반 설계에는 여분의 뮤텍스가 필요하고, 검출 작업과 반응 작업의 진행 순서에 제약이 있으며, 사건이 실제로 발생했는지를 반응 작업이 다시 한번 확인해야 한다.
◾ 플래그 기반 설계를 사용하면 그런 단점들이 없지만, 대신 차단이 아니라 폴링이 일어난다는 단점이 있다.
◾ 조건 변수와 플래그를 조합할 수도 있으나, 그런 조합을 이용한 통신 매커니즘은 필요 이상으로 복잡하다.
◾ std::promise와 future 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 그런 접근방식은 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능하다.
'도서 > Effective Modern C++' 카테고리의 다른 글
[8장] 항목 41 : 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라 (0) | 2023.01.31 |
---|---|
[7장] 항목 40 : 동시성에는 std::atomic을 사용하고, volatile은 특별한 메모리에 사용하라 (0) | 2023.01.31 |
[7장] 항목 38 : 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라 (0) | 2023.01.30 |
[7장] 항목 37 : std::thread들을 모든 경로에서 합류 불가능하게 만들어라 (0) | 2023.01.30 |
[7장] 항목 36 : 비동기성이 필수일 때에는 std::launch::async를 지정하라 (0) | 2023.01.30 |