기억해 둘 사항들
- 간단한 event 통신을 수행할 때 조건 변수 기반 설계에는 여분의 뮤텍스가 필요하고,
검출 태스크와 반응 태스크의 진행 순서에 제약이 있으며, event가 실제로 발생했는지 반응 태스크가 다시 확인해야 함
- 플래그 기반 설계를 사용하면 위 단점들이 없지만 차단이 아니라 폴링이 일어나는 단점
- 조건 변수와 플래그를 조합할 수도 있으나, 통신 메커니즘 필요 이상으로 복잡
- std::promise와 미래 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능
특정한 event가 일어나야만 작업을 진행할 수 있는 비동기 태스크에게 event가 발생했음을 알려주는 또 다른 태스크를 두는 것이 유용한 경우(자료구조의 초기화, 계산 과정 중 특정 단계의 완료 등)가 있음
스레드 간 통신을 처리하는 방법
조건 변수(condition variable)
조건을 검출하는 태스크를 검출 태스크라고 하고, 조건에 반응하는 태스크를 반응 태스크
std::condition_variable cv;
std::mutex m;
// 검출 태스크 코드
... // event 검출
cv.notify_one();
// 반응 태스크 코드
{
std::unique_lock<std::mutex> lock(m);
cv.wait(lock);
... // event에 대한 반응
}
위 코드의 문제점
조건 변수에 대기 연산 전에 뮤텍스를 잠그는 것은 스레드 라이브러리에 흔히 있는 과정이지만 프로그램 논리 전체에서 race condition이 발생하지 않는다면 뮤텍스를 잠굴 필요가 없음
만약 반응 태스크가 wait을 실행하기 전에 검출 태스크가 조건 변수를 통지하면 영원히 반응 태스크는 기다리게 됨
wait 호출문은 가짜 기상을 고려하지 않음
가짜 기상(spurious wakeup)은 스레드 적용 API에서 조건 변수가 통지되지 않아도 깨어날 수 있는 흔히 있는 일
C++에서는 기다리는 조건을 판정하는 람다 함수를 전달할 수 있음
하지만 위 예에서는 event 발생 여부를 검출하는 것은 검출 태스크의 몫이며, 직접 판단할 수 있다면 조건 변수를 기다리지도 않았을 것
cv.wait(lock, []{ return event 발생 여부; });
공유 bool 플래그 사용
설계의 단점은 없지만 스레드가 여전히 실행되어 폴링(루프를 돌면서 조건 체크) 비용이 발생
다른 태스크가 사용할 하드웨어 스레드를 점유하고 스레드의 타임 슬라이스 시작과 끝에서 컨텍스트 스위치 발생
std::atomic<bool> flag(false);
// 검출 태스크 코드
... // event 검출
flag = true;
// 반응 태스크 코드
while(flag == false);
... // event 반응
조건 변수 기반 설계와 플래그 기반 설계 혼합
검출 태스크가 플래그 설정 및 조건 변수 통지 두 가지를 사용하여 반응 태스크를 깨워야하므로 아주 적합한 방법은 아님
std::condition_variable cv;
std::mutex m;
bool flag = false;
// 검출 태스크 코드
{
std::lock_guard<std::mutex> lock(m);
flag = true;
}
cv.notify_one();
// 반응 태스크 코드
{
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return flag; });
}
검출 태스크가 설정한 future 객체를 반응 태스크가 기다리게 하는 방법
피호출자에서 호출자로의 통신 채널의 전송 단자(std::promise), 수신 단자(std::future)를 사용
검출 태스크과 반응 태스크는 호출자-피호출자 관계가 아니지만 통신 채널은 다른 곳으로 전송해야 하는 모든 상황에서 사용할 수 있음
std::promise와 std::future 객체는 타입 매개변수(전송할 자료의 타입)를 요구하지만 현재 예에서는 전달할 자료 없음을 뜻하는 void 사용
std::promise<void> p;
// 검출 태스크 코드
p.set_value();
// 반응 태스크 코드
p.get_future().wait();
장점
다른 방법에 비해 코드가 간단
뮤텍스가 필요 없음
가짜 기상 문제 발생하지 않음(가짜 기상 문제는 조건 변수에만 발생)
기다리는 동안 시스템 자원을 소모하지 않음
단점
힙 기반의 공유 상태의 할당 및 해제 비용 유발
std::promise를 한 번만 설정할 수 있어 여러 번 통신할 수 없음
스레드를 한 번만 suspended한다면 void future 객체를 이용하는 설계가 합리적임
suspended state로 생성하는 이유는 스레드 생성에 관련된 작업을 실행 전에 처리하거나, 실행 전에 스레드 우선순위나 코어 친화도 설정을 하기 위함
std::promise<void> p;
void react();
void detect()
{
std::thread t([]
{
p.get_future().wait();
react();
});
...
p.set_value(); // suspended state를 풂
t.join();
}
여러 개를 유보하고 풀도록 확장하는 것이 가능
반응 태스크 스레드마다 공유 상태를 참조하는 개별적인 std::shared_future 복사본을 두어야 하므로 람다에 값 캡처
std::promise<void> p;
void detect()
{
std::shared_future<void> sf = p.get_future().share();
std::vector<std::thread> vt;
for (int i = 0; i < threadToRun; i++)
{
vt.emplace_back([sf]
{
sf.wait();
react();
});
}
p.set_value();
for (std::thread& t : vt)
{
t.join();
}
}