
C++ 표준 라이브러리에는 std::promise, std::packaged_task, std::async, std::future, std::sharedfuture처럼 서로 관련된 함수와 클래스가 있다. 이 글은 이들이 공유하는 상태 객체를 단순화해 설명하고, 값을 설정하고 기다린 뒤 가져오는 방식을 살펴본다.
이 클래스들은 모두 C++ 표준 라이브러리가 ‘상태(state)’라고 부르는 이름 없는 클래스 하나를 기반으로 한다. 설명을 위해 여기서는 이 객체를 source라고 부르겠다. 값을 만들어 내는 객체이며, C#의 작업 완료 소스(task completion source)와 비슷하게 동작한다.
source는 개념적으로 다음 작업을 지원한다.
- 값 설정
- 예외 설정
- 값이나 예외가 설정될 때까지 기다리기
- 값 가져오기 또는 예외 다시 던지기
다음은 참조를 지원하지 않는 단순화된 버전이다.
template<typename T>
struct source
{
private:
enum what {
nothing,
value,
exception,
consumed,
} d; // discriminant
union {
T value;
std::exception_ptr ex;
} r; // result
std::mutex m;
std::condition_variable cv;
판별자는 공용체에 무엇이 들어 있는지 나타낸다. 아무것도 설정되지 않은 상태, 값, 예외, 또는 값이 소비되어 더는 없는 상태다.
뮤텍스는 다른 멤버에 스레드 안전하게 접근하도록 하고, 조건 변수는 값이나 예외가 설정될 때까지 기다릴 수 있게 한다.
void must_be_empty() {
if (d != what::empty) {
throw std::future_error(future_errc::promise_already_satisfied);
}
}
set 메서드는 source가 비어 있을 때만 호출할 수 있으므로, 비어 있는지 확인하는 부분을 따로 뒀다.
public:
void set_value(T& v) {
std::lock_guard g(m);
must_be_empty();
new(&r.value) T(v); // "r.value = v"
d = what::value;
cv.notify_all();
}
값이나 예외는 한 번만 설정할 수 있다. 이미 값이나 예외가 있으면 오류를 내고, 그렇지 않으면 값을 저장한 뒤 값이 설정되기를 기다리는 모든 대기자에게 알린다.
T&&를 받는 오버로드는 설명하지 않겠다. 같은 방식으로 동작하되 std::move()를 사용한다. set_value_at_thread_exit도 있지만, 그 메서드는 다른 기회에 다루겠다.
값 대신 예외를 설정할 수도 있다.
void set_exception(std::exception_ptr ex) {
std::lock_guard g(m);
must_be_empty();
new(&r.ex) std::exception_ptr(ex); // "r.ex = ex"
d = what::exception;
cv.notify_all();
}
예외를 저장한다는 점만 빼면 set_value와 기본적으로 같다.
이렇게 값과 예외를 source에 넣는다. 다음으로는 source가 비어 있지 않은 상태가 될 때까지 기다리는 방법을 살펴본다.
void wait() {
std::lock_guard g(m);
while (d == what::empty) {
cv.wait(g);
}
}
void wait_for(duration dur) {
std::lock_guard g(m);
cv.wait_for(g, dur, [&] { return d != what::empty; });
}
계속 기다리거나 지정한 시간 동안 기다릴 수 있다. wait_for 메서드가 있기 때문에 조건 변수가 필요하다. 이 메서드가 없었다면 std::atomic의 wait와 notify_all 메서드를 사용할 수도 있었다.
마지막으로 값을 가져오는 부분이다.
void can_get() {
if (d == what::consumed) {
throw std::future_error(future_errc::future_already_retrieved);
}
if (d == what::exception) {
std::rethrow_exception(r.ex);
}
}
값을 어떤 방식으로 가져오든 이미 소비된 값은 가져올 수 없다. 결과가 예외라면 해당 예외를 다시 던진다.
T consume() {
wait();
std::lock_guard g(m);
can_get();
d = what::consumed;
return std::move(r.v);
}
const T& get() {
wait();
std::lock_guard g(m);
can_get();
return r.v;
}
};
값을 가져오는 방법은 두 가지다. get은 내부 저장소에 있는 값의 상수 참조를 반환한다. consume은 값을 내부 저장소에서 이동시키고 소비된 상태로 표시한다. 값이 더는 없으므로 이후에는 누구도 그 값을 소비하거나 가져올 수 없다.
이제 이 객체를 바탕으로 나머지 객체와 함수를 만들 수 있다. 다음 글에서 다룬다.
덧붙임: 소멸자 작성은 독자에게 맡긴다. 어렵지는 않지만 공용체 때문에 번거롭다.
덧붙임 둘: 이 시리즈를 쓰는 이유는 동작 방식을 늘 잊어버리기 때문이다. 글로 정리하면 기억하는 데 도움이 된다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요