C++ promise와 future: 스레드 간 결과 전달, std::async 실행 정책, shared_future
이 글의 핵심
스레드를 직접 만들면 결과값과 예외를 어떻게 돌려받을지가 문제가 되는데, promise와 future는 이를 일회성 채널로 해결합니다. 다만 std::async가 반환한 future를 버리면 소멸자에서 작업이 끝날 때까지 블록된다는 점, get()을 호출하지 않으면 작업 중 던진 예외가 확인되지 않고 묻힌다는 점 같은 함정이 있습니다. 병렬 계산, 파일 다운로드, 병렬 이미지 처리 예제로 사용법을 정리했습니다.
다른 스레드에서 계산한 값을 받아 오려면 공유 변수와 뮤텍스, 조건 변수를 직접 엮어야 하는데, std::future는 이 과정을 “나중에 채워질 값” 하나로 감쌉니다. 이 글은 std::async로 작업을 띄우는 가장 간단한 방법에서 시작해, std::promise로 값을 직접 설정하는 패턴, launch::async와 launch::deferred 정책의 차이, 스레드 간 예외 전파, shared_future, 그리고 future를 버리거나 get()을 두 번 부르는 흔한 실수까지 다룹니다.
std::async
async 비동기 실행에서 다루는 std::async로 비동기 실행 후 future로 결과를 받을 수 있습니다.
#include <future>
#include <iostream>
using namespace std;
int compute(int x) {
this_thread::sleep_for(chrono::seconds(1));
return x * x;
}
int main() {
// 비동기 실행
future<int> result = async(launch::async, compute, 10);
cout << "계산 중..." << endl;
// 결과 대기
cout << "결과: " << result.get() << endl; // 100
}
async는 compute(10)을 별도 스레드에서 실행하고, 그 결과를 담을 공유 상태(shared state) 에 연결된 future를 즉시 반환합니다. 메인 스레드는 “계산 중…”을 출력하며 다른 일을 계속하다가, get()을 호출하는 순간 결과가 준비될 때까지 기다립니다. std::thread를 직접 쓰면 반환값을 받을 방법이 없어서 공유 변수와 뮤텍스를 따로 만들어야 하지만, async는 반환값과 예외를 모두 future로 돌려준다는 것이 가장 큰 장점입니다. join()을 잊어 std::terminate가 호출되는 std::thread의 흔한 실수도 없습니다.
promise와 future
promise-future 관계
graph LR
A[promise] -->|get_future| B[future]
C[생산자 스레드] -->|set_value| A
B -->|get| D[소비자 스레드]
style A fill:#e1f5ff
style B fill:#ffe1e1
style C fill:#e1ffe1
style D fill:#ffe1ff
void compute(promise<int> p, int x) {
this_thread::sleep_for(chrono::seconds(1));
p.set_value(x * x); // 결과 설정
}
int main() {
promise<int> p;
future<int> f = p.get_future();
thread t(compute, move(p), 10);
cout << "계산 중..." << endl;
cout << "결과: " << f.get() << endl; // 100
t.join();
}
promise는 값을 쓰는 쪽, future는 값을 읽는 쪽이며 둘은 같은 공유 상태를 가리킵니다. async와 달리 값을 설정하는 시점을 직접 정할 수 있으므로, 함수가 끝날 때가 아니라 작업 중간(예: 초기화가 끝난 순간)에 결과를 알리거나, 콜백 기반 API의 결과를 future로 바꿔 주는 어댑터를 만들 때 유용합니다.
promise는 복사할 수 없고 이동만 가능해서 thread t(compute, move(p), 10)처럼 std::move로 넘겨야 합니다. move를 빠뜨리면 std::thread가 인자를 복사하려다 삭제된 복사 생성자 때문에 긴 템플릿 에러가 납니다. 또 promise에 값을 설정하지 않은 채 파괴되면, 기다리던 future의 get()은 영원히 멈추는 대신 std::future_error(에러 코드 broken_promise)를 던집니다. 작업 스레드가 도중에 return해 버리는 경로가 있으면 이 예외로 드러나므로, 모든 경로에서 set_value나 set_exception을 호출하는지 확인해야 합니다. 두 번 설정하면 promise_already_satisfied 에러가 납니다.
동작 흐름
sequenceDiagram
participant Main as Main Thread
participant Promise as promise
participant Future as future
participant Worker as Worker Thread
Main->>Promise: create
Main->>Future: get_future()
Main->>Worker: start thread
Main->>Main: other work
Worker->>Worker: compute
Main->>Future: get()
Note over Main,Future: waiting...
Worker->>Promise: set_value(result)
Promise->>Future: deliver result
Future->>Main: return result
Main->>Worker: join()
launch 정책
정책별 특성 비교
| 정책 | 실행 시점 | 스레드 | 오버헤드 | 적합한 작업 |
|---|---|---|---|---|
| async | 즉시 | 새 스레드 | 높음 | CPU 집약적, 긴 작업 |
| deferred | get() 시 | 현재 스레드 | 낮음 | 짧은 작업, 조건부 실행 |
| async|deferred | 구현 선택 | 자동 | 중간 | 일반적 사용 |
// async: 새 스레드
auto f1 = async(launch::async, compute, 10);
// deferred: 지연 실행 (get() 호출 시)
auto f2 = async(launch::deferred, compute, 10);
// 자동 선택
auto f3 = async(compute, 10);
기본 정책(f3)은 “구현이 알아서 고른다”는 뜻인데, 실제로는 deferred로 실행될 수도 있다는 점이 함정입니다. deferred로 선택되면 get()이나 wait()를 호출하기 전까지 작업이 아예 시작되지 않고, 호출한 스레드에서 동기적으로 실행됩니다. 그래서 병렬로 돌 것이라고 기대한 코드가 순차 실행이 되거나, thread_local 변수가 엉뚱한 스레드의 것이 됩니다. 더 곤란한 경우는 while (f.wait_for(100ms) != future_status::ready)처럼 완료를 폴링하는 루프입니다. deferred 작업의 wait_for는 future_status::deferred를 반환하므로 이 루프는 영원히 끝나지 않습니다. 작업이 다른 스레드에서 병렬로 돌아야 한다면 launch::async를 명시하는 것이 안전하고, 이 글의 예제들이 모두 정책을 적어 둔 이유입니다.
launch::async의 “새 스레드”도 구현에 따라 다릅니다. GCC(libstdc++)와 Clang(libc++)은 호출마다 새 스레드를 만들지만, MSVC는 Windows 스레드 풀을 사용합니다. 어느 쪽이든 async는 작업 수를 제한하지 않으므로, 작업 수천 개를 한꺼번에 launch::async로 띄우면 스레드가 수천 개 만들어지거나 std::system_error: Resource temporarily unavailable이 날 수 있습니다.
실전 예시
예시 1: 병렬 계산
#include <future>
#include <vector>
#include <numeric>
long long sumRange(int start, int end) {
long long sum = 0; // int면 오버플로 (0~999999 합은 약 5천억)
for (int i = start; i < end; i++) {
sum += i;
}
return sum;
}
int main() {
const int N = 1000000;
const int numThreads = 4;
const int chunkSize = N / numThreads;
vector<future<long long>> futures;
// 병렬 실행
for (int i = 0; i < numThreads; i++) {
int start = i * chunkSize;
int end = (i + 1) * chunkSize;
futures.push_back(async(launch::async, sumRange, start, end));
}
// 결과 수집
long long total = 0;
for (auto& f : futures) {
total += f.get();
}
cout << "합계: " << total << endl; // 499999500000
}
작업을 네 조각으로 나눠 각각 async로 띄우고, 모든 future를 vector에 모아 두었다가 차례로 get()하는 것이 병렬 계산의 기본 형태입니다. future를 벡터에 저장하는 것이 중요한데, 저장하지 않으면 아래 “실수 1”처럼 루프 한 바퀴마다 작업이 끝날 때까지 기다리게 되어 순차 실행과 같아집니다. N이 numThreads로 나누어떨어지지 않으면 마지막 조각이 나머지를 놓치므로, 실제로는 마지막 조각의 end를 N으로 두어야 합니다.
이 예제의 이전 버전은 합계를 int로 계산했는데, 0부터 999,999까지의 합은 약 5천억이라 int 범위(약 21억)를 한참 넘습니다. 부호 있는 정수 오버플로는 정의되지 않은 동작이라 에러 없이 틀린 값이 출력되므로, 병렬화 코드를 검증할 때는 순차 계산 결과와 반드시 비교해 보는 편이 좋습니다. 또 이 정도 계산은 스레드를 만드는 비용에 비해 너무 가벼워서 실제로 측정하면 병렬 버전이 더 느릴 수도 있습니다. 병렬화 이득은 작업당 최소 수 밀리초 이상일 때부터 의미가 생깁니다.
예시 2: 파일 다운로드
#include <future>
#include <vector>
string downloadFile(const string& url) {
// 다운로드 시뮬레이션
this_thread::sleep_for(chrono::seconds(1));
return "Content from " + url;
}
int main() {
vector<string> urls = {
"http://example.com/file1",
"http://example.com/file2",
"http://example.com/file3"
};
vector<future<string>> futures;
// 병렬 다운로드
for (const auto& url : urls) {
futures.push_back(async(launch::async, downloadFile, url));
}
// 결과 수집
for (auto& f : futures) {
cout << f.get() << endl;
}
}
다운로드처럼 CPU보다 네트워크 대기 시간이 긴 작업은 스레드를 늘리면 거의 선형으로 빨라집니다. 세 파일을 순차로 받으면 3초, 병렬로 받으면 약 1초입니다. downloadFile이 const string&을 받지만 async는 인자를 복사해서 새 스레드에 넘기므로, 루프 변수 url이 사라져도 안전합니다. 반대로 참조를 넘기고 싶어서 std::ref(url)을 쓰면 원본의 수명을 직접 보장해야 합니다. 결과는 futures의 순서대로 수집되므로, 두 번째 파일이 먼저 끝나도 첫 번째 파일을 기다린 다음에 출력됩니다. 먼저 끝난 것부터 처리하고 싶다면 표준 future만으로는 “여러 future 중 아무거나 준비되면” 기다리는 기능이 없어서, wait_for(0s)로 돌아가며 확인하거나 결과를 큐에 넣는 방식을 따로 구현해야 합니다.
예시 3: 타임아웃
int longComputation() {
this_thread::sleep_for(chrono::seconds(5));
return 42;
}
int main() {
auto f = async(launch::async, longComputation);
// 2초 대기
if (f.wait_for(chrono::seconds(2)) == future_status::ready) {
cout << "결과: " << f.get() << endl;
} else {
cout << "타임아웃" << endl;
}
}
wait_for는 ready, timeout, deferred 세 가지 상태 중 하나를 반환합니다. 여기서 흔히 오해하는 부분은 타임아웃이 작업을 취소하지 않는다는 점입니다. “타임아웃”을 출력한 뒤 main이 끝나려고 하면, async가 반환한 future의 소멸자가 작업이 끝날 때까지 기다리므로 프로그램은 결국 5초를 다 채운 뒤에 종료됩니다. 표준 C++에는 실행 중인 스레드를 강제로 멈추는 방법이 없으므로, 정말로 중단해야 하는 작업이라면 std::atomic<bool> 플래그나 C++20의 std::stop_token을 작업 함수에 넘겨 작업이 스스로 주기적으로 확인하고 빠져나오게 만들어야 합니다. 저도 처음 타임아웃을 구현할 때 “2초 뒤에 포기했는데 왜 프로그램이 안 끝나지?”를 한참 들여다본 적이 있는데, 거의 모든 사람이 한 번은 밟는 함정입니다.
예시 4: 예외 전달
int divide(int a, int b) {
if (b == 0) {
throw runtime_error("0으로 나눌 수 없음");
}
return a / b;
}
int main() {
auto f = async(launch::async, divide, 10, 0);
try {
int result = f.get(); // 예외 재발생
cout << result << endl;
} catch (const exception& e) {
cout << "에러: " << e.what() << endl;
}
}
작업 스레드에서 던진 예외는 그 스레드에서 사라지지 않고 공유 상태에 exception_ptr로 저장되었다가, get()을 호출한 스레드에서 다시 던져집니다. std::thread에서 예외가 스레드 함수 밖으로 빠져나가면 std::terminate로 프로그램 전체가 종료되는 것과 비교하면 큰 차이입니다. 예외 객체는 복사 없이 그대로 전달되므로 catch (const runtime_error& e)처럼 구체적인 타입으로 잡을 수도 있습니다.
shared_future
int compute() {
this_thread::sleep_for(chrono::seconds(1));
return 42;
}
int main() {
shared_future<int> sf = async(launch::async, compute).share();
// 여러 스레드에서 접근 가능
thread t1([sf]() {
cout << "스레드 1: " << sf.get() << endl;
});
thread t2([sf]() {
cout << "스레드 2: " << sf.get() << endl;
});
t1.join();
t2.join();
}
future는 이동만 가능하고 get()이 결과를 꺼내 가는 구조라 한 번만 읽을 수 있지만, shared_future는 복사가 가능하고 get()이 결과에 대한 const 참조를 돌려주므로 여러 번, 여러 스레드에서 읽을 수 있습니다. 람다가 [sf]로 값 캡처한 것이 중요합니다. 각 스레드가 자기 shared_future 복사본을 가지면 동시에 get()을 호출해도 안전하지만, 하나의 shared_future 객체를 참조로 공유한 채 여러 스레드가 접근하면 데이터 경쟁이 됩니다. .share()를 호출하면 원래 future는 비어 있는 상태(valid() == false)가 되므로 그 뒤로는 사용하면 안 됩니다. 여러 워커가 “설정 로딩 완료” 같은 하나의 신호를 기다려야 할 때 promise<void>와 shared_future<void>를 조합하는 패턴이 자주 쓰입니다.
자주 발생하는 문제
문제 1: get() 여러 번 호출
// ❌ get()은 한 번만
future<int> f = async(compute, 10);
int x = f.get();
// int y = f.get(); // f.valid()가 false인 상태에서 호출: 정의되지 않은 동작
// // (대부분 구현은 future_error: no_state 예외)
// ✅ 한 번 받은 결과를 변수에 저장해 두고 재사용
int result = x;
get()이 한 번만 가능한 것은 설계상의 선택입니다. 결과를 이동해서 돌려주기 때문에 future<unique_ptr<T>>처럼 복사할 수 없는 타입도 결과로 받을 수 있습니다. 한 번 꺼낸 뒤에는 공유 상태와의 연결이 끊겨 valid()가 false가 되므로, 여러 곳에서 결과가 필요한지 확신이 없다면 호출하기 전에 f.valid()를 확인하거나 처음부터 shared_future를 쓰는 것이 방법입니다.
문제 2: future 소멸
// ❌ future 소멸 시 대기
{
auto f = async(launch::async, compute, 10);
} // 여기서 대기 (블로킹)
// ✅ future를 필요한 범위까지 살려 두고, 기다릴 시점을 직접 정함
auto f = async(launch::async, compute, 10);
// ... 다른 작업 ...
f.wait();
이것은 std::async가 반환한 future에만 있는 특별한 규칙입니다. async로 만든 공유 상태를 참조하는 마지막 future가 파괴될 때, 작업이 아직 실행 중이면 끝날 때까지 블록됩니다. 백그라운드 스레드가 파괴된 지역 변수를 참조하는 사고를 막기 위한 설계지만, “비동기로 던져 놓고 잊어버리는(fire-and-forget)” 용도로는 쓸 수 없다는 뜻이기도 합니다. promise::get_future()나 packaged_task로 얻은 future는 소멸자에서 기다리지 않으므로, 같은 future 타입이라도 어디서 왔는지에 따라 동작이 다르다는 점이 혼란스러운 부분입니다. 정말로 기다리지 않는 백그라운드 작업이 필요하다면 스레드 풀이나 std::jthread처럼 수명을 명시적으로 관리하는 도구를 쓰는 편이 낫습니다.
문제 3: 예외 무시
// ❌ 예외 무시
auto f = async(launch::async, [] {
throw runtime_error("에러");
});
// f.get() 호출 안하면 예외가 아무도 모르게 사라짐
// ✅ 예외 처리
try {
f.get();
} catch (const exception& e) {
cout << e.what() << endl;
}
promise 고급
void compute(promise<int> p, int x) {
try {
if (x < 0) {
throw invalid_argument("음수 불가");
}
p.set_value(x * x);
} catch (...) {
p.set_exception(current_exception());
}
}
int main() {
promise<int> p;
future<int> f = p.get_future();
thread t(compute, move(p), -10);
try {
cout << f.get() << endl;
} catch (const exception& e) {
cout << "에러: " << e.what() << endl;
}
t.join();
}
catch (...)로 모든 예외를 잡고 current_exception()으로 현재 처리 중인 예외를 exception_ptr로 만들어 set_exception에 넘기는 것이 promise로 예외를 전달하는 표준 패턴입니다. 예외 객체를 직접 만들어 넘기고 싶다면 p.set_exception(make_exception_ptr(invalid_argument("음수 불가")))처럼 쓸 수 있습니다. 이 try-catch를 빠뜨리면 compute에서 던진 예외가 스레드 함수 밖으로 빠져나가 std::terminate로 프로그램이 종료되고, 그 전에 promise가 파괴되면서 future는 broken_promise를 받게 됩니다. std::async나 std::packaged_task는 이 처리를 자동으로 해 주므로, promise를 직접 쓸 때만 신경 쓰면 됩니다.
FAQ
Q1: async vs thread?
A: 결과값이나 예외를 돌려받아야 하는 “계산 하나”라면 async가 간단합니다. 반면 스레드의 수명을 직접 관리해야 하는 경우(오래 도는 워커 루프, 스레드 이름·우선순위 설정, 명시적인 detach)에는 std::thread나 C++20의 std::jthread가 맞습니다. 작업이 많다면 둘 다 아니라 스레드 풀을 쓰는 것이 일반적입니다.
Q2: future는 언제 사용하나요?
A:
- 비동기 작업
- 병렬 계산
- 결과 전달
Q3: 성능은?
A: launch::async가 스레드를 새로 만드는 구현(GCC·Clang)에서는 호출당 수십 마이크로초 정도의 스레드 생성 비용과 공유 상태의 힙 할당이 추가됩니다. 작업 하나가 그보다 훨씬 오래 걸릴 때만 이득이 있고, 아주 작은 작업을 수만 개 띄우는 용도라면 스레드 풀에 작업을 넣는 방식이 적합합니다.
Q4: future는 재사용 가능?
A: 아니요. get()은 한 번만 호출 가능.
Q5: 타임아웃은?
A: wait_for()나 wait_until() 사용.
Q6: future/promise 학습 리소스는?
A:
- “C++ Concurrency in Action”
- cppreference.com
- “Effective Modern C++” 관련 글: async 비동기 실행, shared_future, 스레드 기초, packaged_task.
실전 예제: 병렬 이미지 처리
실무에서 자주 사용하는 패턴입니다:
#include <future>
#include <vector>
#include <iostream>
#include <chrono>
using namespace std;
// 이미지 처리 시뮬레이션
string processImage(const string& filename) {
this_thread::sleep_for(chrono::seconds(1));
return "Processed: " + filename;
}
int main() {
vector<string> images = {
"photo1.jpg", "photo2.jpg", "photo3.jpg",
"photo4.jpg", "photo5.jpg"
};
// 병렬 처리 시작
vector<future<string>> futures;
auto start = chrono::steady_clock::now();
for (const auto& img : images) {
futures.push_back(
async(launch::async, processImage, img)
);
}
// 결과 수집
for (auto& f : futures) {
cout << f.get() << endl;
}
auto end = chrono::steady_clock::now();
auto duration = chrono::duration_cast<chrono::seconds>(end - start);
cout << "\n총 처리 시간: " << duration.count() << "초" << endl;
// 순차 처리: 5초, 병렬 처리: 1초!
}
성능 비교:
- 각 작업이 1초씩 걸리는 시뮬레이션이므로 순차로 돌리면 약 5초가 걸립니다.
launch::async로 다섯 작업을 동시에 띄우면 약 1초에 끝납니다.- 실제 이미지 처리처럼 CPU를 쓰는 작업은 코어 수와 메모리 대역폭에 따라 이득이 줄어듭니다.
자주 하는 실수와 해결법
실수 1: future를 저장하지 않음
// ❌ 나쁜 예: future를 무시
async(launch::async, []{
cout << "작업 실행" << endl;
});
// 반환된 임시 future가 이 문장 끝에서 파괴되며 작업 완료까지 블로킹됨!
// ✅ 좋은 예: future 저장
auto f = async(launch::async, []{
cout << "작업 실행" << endl;
});
// 나중에 f.get()으로 결과 받기
반환값을 받지 않으면 임시 future가 문장이 끝나는 즉시 파괴되고, 앞에서 본 소멸자 규칙 때문에 작업이 끝날 때까지 기다립니다. 루프 안에서 이렇게 쓰면 병렬로 띄운다고 생각한 작업들이 하나씩 순서대로 실행됩니다. C++20부터 표준 라이브러리 구현들이 std::async에 [[nodiscard]]를 붙여 두어 반환값을 버리면 경고가 나오므로, 경고를 무시하지 않는 것만으로도 이 실수를 잡을 수 있습니다.
실수 2: get()을 여러 번 호출
future<int> f = async(launch::async, []{ return 42; });
int x = f.get(); // ✅ OK
int y = f.get(); // ❌ 런타임 에러!
해결법: shared_future 사용
shared_future<int> sf = async(launch::async, []{ return 42; }).share();
int x = sf.get(); // ✅ OK
int y = sf.get(); // ✅ OK
실수 3: 예외를 무시
// ❌ 나쁜 예
auto f = async(launch::async, []{
throw runtime_error("Error!");
});
// get()을 호출하지 않으면 예외가 무시됨
// ✅ 좋은 예
try {
f.get();
} catch (const exception& e) {
cerr << "에러: " << e.what() << endl;
}
같이 보면 좋은 글
- C++ std::async와 launch 정책: future, deferred 실행, 흔한 함정
- C++ shared_future | 여러 스레드에서 future 결과 공유
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ std::packaged_task: 작업 큐와 스레드 풀에서 future로 결과·예외 받기
- C++20 Coroutines | co_await·co_yield·Generator
- JavaScript 비동기 프로그래밍 | Promise, async/await