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과 메모리 모델로 내려간다.