C++ 동시성 · 4편

future, promise, async로 결과 다루기

스레드가 계산한 결과 하나를 어떻게 돌려받는가? mutex+cv 배관을 걷어내는 future/promise, std::async, packaged_task, 그리고 스레드 경계를 넘는 예외 전파.

앞선 편들에서는 스레드를 직접 띄우고(1편), 잠금으로 공유를 보호하고(2편), 조건 변수로 신호를 주고받았다(3편). 이 도구들은 강력하지만 손이 많이 간다. 스레드가 계산한 결과 하나를 돌려받고 싶을 뿐인데도, mutex와 cv와 공유 변수를 직접 엮어야 한다. 이번 편은 그 반복 작업을 걷어내는 고수준 도구 — future, promise, async를 다룬다.

문제 — 결과를 어떻게 돌려받는가

std::thread로 함수를 실행하면 반환값을 받을 방법이 없다. thread 생성자는 반환값을 무시한다. 결과를 얻으려면 공유 변수를 하나 두고, 그걸 mutex로 보호하고, “계산이 끝났다”는 신호를 cv로 알리는 식으로 직접 배관을 깔아야 한다.

// 결과 하나 받자고 이 모든 걸 직접...
int result;
bool done = false;
std::mutex mtx;
std::condition_variable cv;

std::thread t([&]{
    int r = heavyComputation();
    {
        std::lock_guard<std::mutex> lock(mtx);
        result = r;
        done = true;
    }
    cv.notify_one();
});

int value;
{
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return done; });
    value = result;
}
t.join();

“결과 하나 받기”에 이만큼의 코드가 든다. 이 패턴 — 한 번 계산하고, 한 번 결과를 넘긴다 — 은 워낙 흔해서 표준이 전용 도구로 추상화해 두었다.

future와 promise — 결과를 담는 통로

std::future<T>는 “아직 준비되지 않았을 수도 있는 미래의 값 하나”를 나타낸다. std::promise<T>는 그 값을 채워 넣는 쪽이다. 둘은 한 쌍으로, promise에 값을 넣으면 연결된 future에서 꺼낼 수 있다.

#include <future>

std::promise<int> prom;
std::future<int> fut = prom.get_future();   // 이 promise와 연결된 future

// 생산 측 스레드
std::thread t([&prom]{
    int r = heavyComputation();
    prom.set_value(r);      // 결과를 채운다
});

int value = fut.get();      // 준비될 때까지 대기했다가 결과 수령
t.join();

앞의 mutex+cv 배관이 통째로 사라졌다. fut.get()이 “결과가 준비될 때까지 기다렸다가 값을 꺼내는” 일을 알아서 한다. 내부적으로는 여전히 대기와 신호가 일어나지만, 그 배관을 우리가 짤 필요가 없다.

get()에는 규칙이 하나 있다. future마다 한 번만 호출할 수 있다. 값을 꺼내면 future는 비게 되고, 다시 get()을 부르면 정의되지 않은 동작이다. 결과를 여러 번 읽어야 한다면 std::shared_future를 쓴다.

예외도 함께 전달된다

future/promise의 큰 장점 하나는 예외 전파다. 생산 측에서 예외가 나면, 그걸 promise에 실어 소비 측으로 넘길 수 있다. 소비 측에서는 get()이 그 예외를 다시 던진다.

std::promise<int> prom;
std::future<int> fut = prom.get_future();

std::thread t([&prom]{
    try {
        int r = riskyComputation();   // 예외를 던질 수 있음
        prom.set_value(r);
    } catch (...) {
        prom.set_exception(std::current_exception());   // 예외를 넘긴다
    }
});

try {
    int value = fut.get();   // 저쪽에서 난 예외가 여기서 다시 던져진다
} catch (const std::exception& e) {
    std::cerr << "계산 실패: " << e.what() << "\n";
}
t.join();

직접 mutex+cv로 짜면 예외를 스레드 경계 너머로 넘기는 일을 손수 처리해야 한다. future는 이걸 공짜로 해준다. 스레드에서 난 예외가 마치 같은 스레드에서 난 것처럼 소비 측 try/catch에 잡힌다.

std::async — 스레드 관리까지 한 번에

promise조차 직접 다루기 번거롭다면 std::async가 있다. 함수를 넘기면 그 함수를 (경우에 따라 새 스레드에서) 실행하고, 결과를 담은 future를 바로 돌려준다. 스레드 생성도, promise 연결도 알아서 한다.

#include <future>

std::future<int> fut = std::async(std::launch::async, []{
    return heavyComputation();   // 그냥 값을 반환하면 된다
});

// ... 그동안 다른 일 ...

int value = fut.get();   // 준비될 때까지 대기했다가 수령

가장 간결한 형태다. 스레드를 직접 만들지도, join하지도, promise에 값을 넣지도 않는다. 함수는 그냥 값을 return하고, 예외가 나면 그냥 throw하면 된다 — 둘 다 future를 통해 소비 측으로 전달된다.

launch 정책 — async와 deferred

std::async의 첫 인자는 실행 정책이다. 두 가지가 있고, 이 차이를 모르면 함정에 빠진다.

std::launch::async즉시 새 스레드에서 실행한다. 우리가 보통 원하는 동작이다.

std::launch::deferred — 새 스레드를 만들지 않는다. get()을 호출하는 바로 그 순간, 호출한 스레드에서 지연 실행된다. 즉 get()을 부르기 전까지는 함수가 아예 실행되지 않는다.

정책을 생략하면 std::launch::async | std::launch::deferred가 되어, 둘 중 무엇으로 실행될지 구현이 정한다. 새 스레드로 돌 수도, 지연될 수도 있다. 병렬 실행을 의도했는데 구현이 deferred를 골라 사실상 순차 실행되는 함정이 여기서 나온다. 병렬성을 원한다면 std::launch::async를 명시하자.

async의 함정 — future 소멸이 블록한다

std::async(std::launch::async, ...)가 돌려준 future를 변수에 받지 않고 버리면, 미묘한 일이 벌어진다. 그 future의 소멸자가 작업이 끝날 때까지 블록한다.

std::async(std::launch::async, longTask);   // 임시 future가 즉시 소멸
std::async(std::launch::async, anotherTask);
// 사실상 순차 실행: 첫 줄의 future가 소멸하며 longTask 완료를 기다린다

두 작업을 병렬로 돌리려 했지만, 첫 줄이 만든 임시 future가 그 줄 끝에서 소멸하며 longTask가 끝나기를 기다린다. 결과적으로 순차 실행이 된다. 병렬로 돌리려면 반환된 future를 각각 변수에 담아 수명을 늘려야 한다.

auto f1 = std::async(std::launch::async, longTask);
auto f2 = std::async(std::launch::async, anotherTask);
// 이제 둘이 병렬로 실행된다

이 블로킹 소멸 동작은 std::async가 만든 future에만 있는 특수 규칙이다. promise에서 얻은 future는 소멸해도 블록하지 않는다.

packaged_task — 실행을 나중으로 미루기

std::packaged_task는 “호출 가능한 것 + 그 결과를 담을 future”를 하나로 묶은 것이다. async가 실행까지 바로 시작하는 것과 달리, packaged_task언제 어디서 실행할지를 내가 정한다. 작업을 만들어 두고 큐에 넣었다가 원하는 스레드에서 꺼내 실행하는 식이다.

#include <future>

std::packaged_task<int()> task([]{
    return heavyComputation();
});
std::future<int> fut = task.get_future();   // 결과를 받을 통로

std::thread t(std::move(task));   // 원하는 스레드에서 실행
// task()가 실행되면 그 반환값이 fut로 흘러든다

int value = fut.get();
t.join();

이 성질 덕분에 packaged_task는 스레드 풀의 작업 큐에 잘 맞는다. 작업을 packaged_task로 감싸 큐에 넣고, 워커 스레드가 꺼내 실행하면, 제출한 쪽은 대응하는 future로 결과를 받는다. 3편의 작업 큐가 원시적인 값 큐였다면, packaged_task 큐는 “결과를 돌려받는” 작업 큐로 자연스럽게 확장된다.

무엇을 언제 쓰나

세 도구의 추상화 수준이 다르다.

std::async — 가장 높은 수준. “이 함수를 비동기로 실행하고 결과를 줘.” 스레드·promise를 신경 쓸 필요 없다. 일회성 비동기 계산의 기본 선택이다.

std::packaged_task — 중간 수준. 실행 시점과 위치를 내가 통제하되, 결과 전달 배관(future 연결)은 자동. 스레드 풀·작업 큐에 알맞다.

std::promise / std::future — 가장 낮은 수준. 결과가 함수 반환값에서 자연스럽게 나오지 않고, 임의의 시점에 임의의 코드가 값을 채워 넣어야 할 때. 가장 유연하지만 손이 가장 많이 간다.

공통점은 셋 다 결과 하나를 스레드 경계 너머로 안전하게(예외까지) 넘기는 일을, mutex와 cv를 직접 짜지 않고 해준다는 것이다.

정리

  • 동기 — 스레드가 계산한 결과 하나를 돌려받는 흔한 패턴을, mutex+cv 배관 없이 처리한다.
  • future/promise — future는 미래의 값, promise는 그 값을 채우는 쪽. get()은 준비될 때까지 대기하고 값(또는 예외)을 꺼낸다. get()은 future당 한 번(여러 번은 shared_future).
  • 예외 전파 — 생산 측 예외가 소비 측 get()에서 다시 던져진다. 스레드 경계를 넘는 예외 처리가 공짜.
  • std::async — 스레드 생성부터 결과 전달까지 한 번에. 병렬을 원하면 std::launch::async를 명시. 반환 future를 버리면 소멸자가 블록해 순차 실행되는 함정 주의.
  • packaged_task — 실행 시점·위치를 내가 정하는 작업 단위. 스레드 풀 작업 큐에 알맞다.
  • 선택 — 일회성 계산은 async, 작업 큐는 packaged_task, 임의 시점 값 주입은 promise.

여기까지가 표준이 제공하는 고수준 결과 전달이다. 다음 편에서는 방향을 반대로 틀어, 잠금조차 쓰지 않고 공유하는 저수준 세계 — atomic과 메모리 모델로 내려간다.