C++ shared_future | 여러 스레드에서 future 결과 공유
이 글의 핵심
C++ shared_future: 여러 스레드에서 future 결과 공유. shared_future란?·같이 보면 좋은 글 (내부 링크).
shared_future란?
std::future는 결과를 한 번만 get() 할 수 있고, 이동만 가능합니다. std::shared_future는 복사 가능하며, 여러 스레드가 각자 복사본을 가지고 동일한 비동기 결과를 get() 할 수 있습니다. promise와 future, async로 만든 결과를 여러 스레드가 나눠 쓸 때 유용하며, 스레드 기초를 알고 있으면 이해하기 쉽습니다.
future와의 차이
| 특성 | std::future | std::shared_future |
|---|---|---|
| 복사 | 불가 (이동만 가능) | 가능 |
| get() 횟수 | 한 번만 (이후 invalid) | 복사본마다 한 번씩 |
| 스레드 공유 | 불가 | 가능 (복사본 전달) |
| 사용 시나리오 | 단일 스레드 결과 수신 | 여러 스레드 결과 공유 |
// future: 이동만 가능
std::future<int> f1 = std::async([]{ return 42; });
// std::future<int> f2 = f1; // 에러! 복사 불가
std::future<int> f2 = std::move(f1); // OK: 이동
int result = f2.get(); // OK
// int result2 = f2.get(); // 에러! 두 번째 get() 불가
// shared_future: 복사 가능
std::shared_future<int> sf1 = std::async([]{ return 42; }).share();
std::shared_future<int> sf2 = sf1; // OK: 복사
int r1 = sf1.get(); // OK
int r2 = sf2.get(); // OK: 다른 복사본에서 get()
기본 사용
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<int> p;
std::shared_future<int> f = p.get_future().share();
std::thread t1([f]() { std::cout << "t1: " << f.get() << '\n'; });
std::thread t2([f]() { std::cout << "t2: " << f.get() << '\n'; });
p.set_value(42);
t1.join();
t2.join();
return 0;
}
동작 원리: share()를 호출하면 기존 future는 invalid 상태가 되고, 반환된 shared_future를 복사해 각 스레드에 전달합니다. 람다에서 값으로 캡처 [f] 하면 스레드마다 복사본이 생기므로 각자 get() 한 번씩 호출할 수 있습니다.
실무 팁:
- 값 캡처 사용:
[f]로 값 캡처하면 각 스레드가 독립적인 복사본을 가짐 - 참조 캡처 주의:
[&f]로 참조 캡처하면 같은 객체를 공유하므로, 한 스레드에서get()호출 후 다른 스레드에서 호출 시 UB
// ✅ 올바른 사용: 값 캡처
std::shared_future<int> sf = ...;
std::thread t1([sf]() { int r = sf.get(); }); // 복사본
std::thread t2([sf]() { int r = sf.get(); }); // 복사본
// ❌ 잘못된 사용: 참조 캡처
std::thread t3([&sf]() { int r = sf.get(); }); // 같은 객체
std::thread t4([&sf]() { int r = sf.get(); }); // UB!
예외 전파
promise에서 set_exception으로 예외를 넣으면, shared_future를 get()하는 모든 스레드에서 동일한 예외가 전파됩니다. 여러 스레드가 같은 실패 결과를 처리할 때 일관성이 있습니다.
#include <future>
#include <iostream>
#include <thread>
#include <exception>
std::promise<int> p;
auto sf = p.get_future().share();
std::thread t1([sf]() {
try {
int result = sf.get();
std::cout << "t1: " << result << '\n';
}
catch (const std::exception& e) {
std::cout << "t1 caught: " << e.what() << '\n';
}
});
std::thread t2([sf]() {
try {
int result = sf.get();
std::cout << "t2: " << result << '\n';
}
catch (const std::exception& e) {
std::cout << "t2 caught: " << e.what() << '\n';
}
});
// 예외 설정
p.set_exception(std::make_exception_ptr(std::runtime_error("failed")));
t1.join(); // "t1 caught: failed"
t2.join(); // "t2 caught: failed"
동작 원리: shared_future는 내부적으로 공유 상태(shared state) 를 참조 카운팅으로 관리합니다. 예외가 설정되면, 모든 복사본에서 get() 호출 시 동일한 예외가 다시 던져집니다.
실무 활용: 데이터베이스 연결 실패 시, 모든 워커 스레드가 동일한 예외를 받아 일관되게 처리할 수 있습니다.
std::promise<DatabaseConnection> conn_promise;
auto conn_future = conn_promise.get_future().share();
std::thread connector([&]() {
try {
auto conn = connect_to_database();
conn_promise.set_value(std::move(conn));
} catch (...) {
conn_promise.set_exception(std::current_exception());
}
});
// 여러 워커가 연결 대기
std::vector<std::thread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([conn_future]() {
try {
auto conn = conn_future.get();
// ....DB 작업 ...
} catch (const std::exception& e) {
std::cerr << "Worker failed: " << e.what() << '\n';
}
});
}
connector.join();
for (auto& w : workers) w.join();
스레드 안전성
shared_future는 스레드 안전한가?:
- 복사본 간: 안전 (각 복사본은 독립적)
- 같은 객체: 불안전 (동시
get()호출 시 UB)
std::shared_future<int> sf = ...;
// ✅ 안전: 각 스레드가 복사본 사용
std::thread t1([sf]() { int r = sf.get(); }); // sf의 복사본
std::thread t2([sf]() { int r = sf.get(); }); // sf의 복사본
// ❌ 불안전: 같은 객체를 참조로 공유
std::thread t3([&sf]() { int r = sf.get(); }); // 같은 sf
std::thread t4([&sf]() { int r = sf.get(); }); // UB!
내부 동작: shared_future는 내부적으로 공유 상태(shared state) 를 가리키는 포인터를 가지고 있습니다. 복사하면 포인터가 복사되고 참조 카운트가 증가합니다. get() 호출 시 공유 상태에서 결과를 읽어오는데, 이 읽기 자체는 스레드 안전하지만, 같은 shared_future 객체에서 동시에 get()을 호출하면 객체 내부 상태 변경으로 인해 UB가 발생합니다.
권장 패턴:
// ✅ 패턴 1: 값 캡처로 복사본 전달
std::shared_future<int> sf = ...;
std::thread t1([sf]() { /* ....*/ });
std::thread t2([sf]() { /* ....*/ });
// ✅ 패턴 2: 명시적 복사 후 참조 전달
std::shared_future<int> sf1 = sf;
std::shared_future<int> sf2 = sf;
std::thread t1([&sf1]() { /* ....*/ });
std::thread t2([&sf2]() { /* ....*/ });
// ❌ 패턴 3: 참조 캡처 (UB)
std::thread t1([&sf]() { /* ....*/ });
std::thread t2([&sf]() { /* ....*/ });
실전 예시: 여러 워커가 한 번의 설정값 사용
설정을 비동기로 로드한 뒤, 여러 스레드가 그 결과를 읽는 패턴입니다.
#include <future>
#include <thread>
#include <vector>
#include <iostream>
struct Config {
std::string db_url;
int max_connections;
};
Config load_config_from_disk() {
// ....파일에서 설정 로드 ...
return {"localhost:5432", 10};
}
void do_work(const Config& cfg) {
std::cout << "Working with DB: " << cfg.db_url << '\n';
}
int main() {
std::promise<Config> config_promise;
std::shared_future<Config> config_future = config_promise.get_future().share();
// 설정 로더 스레드
std::thread loader([&config_promise]() {
Config c = load_config_from_disk();
config_promise.set_value(std::move(c));
});
// 워커 스레드들 (각자 설정 복사본 사용)
std::vector<std::thread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([config_future, i]() { // 값 캡처
const Config& cfg = config_future.get(); // 각 스레드에서 한 번씩
std::cout << "Worker " << i << " got config\n";
do_work(cfg);
});
}
loader.join();
for (auto& w : workers) w.join();
return 0;
}
왜 shared_future를 사용하나?: 설정 파일을 한 번만 로드하고, 여러 워커가 동일한 설정을 사용합니다. future를 사용하면 한 워커만 결과를 받을 수 있지만, shared_future를 사용하면 모든 워커가 결과를 받을 수 있습니다.
실전 예시: shared_future로 캐시/지연 계산 공유
한 번만 계산하고, 여러 호출자가 결과를 공유하는 패턴에도 쓸 수 있습니다. (실제 캐시는 락이나 atomic 등으로 보강하는 경우가 많지만, shared_future가 “한 번만 채워지는 값” 역할을 할 수 있음.)
std::promise<HeavyResult> p;
std::shared_future<HeavyResult> sf = p.get_future().share();
std::thread producer([&p]() { p.set_value(compute_heavy()); });
std::vector<std::thread> consumers;
for (int i = 0; i < 3; ++i) {
consumers.emplace_back([sf]() { use(sf.get()); });
}
producer.join();
for (auto& c : consumers) c.join();
자주 발생하는 문제
1. get() 중복 호출
std::shared_future<int> sf = ...;
// ❌ 같은 복사본에서 get() 두 번 호출
int r1 = sf.get(); // OK
int r2 = sf.get(); // UB!
// ✅ 결과를 변수에 저장해 재사용
int result = sf.get();
use(result);
use(result); // OK
왜 문제인가?: shared_future::get()은 내부적으로 상태를 변경합니다. 같은 객체에서 두 번 호출하면 정의되지 않은 동작입니다.
실무 해결책:
// 패턴 1: 결과를 지역 변수에 저장
void worker(std::shared_future<Config> sf) {
const Config& cfg = sf.get(); // 한 번만 호출
for (int i = 0; i < 10; ++i) {
use_config(cfg); // 저장된 결과 재사용
}
}
// 패턴 2: 여러 스레드가 각자 복사본 사용
std::shared_future<int> sf = ...;
std::thread t1([sf]() { int r = sf.get(); }); // 복사본 1
std::thread t2([sf]() { int r = sf.get(); }); // 복사본 2
2. promise를 너무 늦게 set_value/set_exception
// ❌ 예외 발생 시 set을 안 하면 데드락
void bad_producer(std::promise<int>& p) {
try {
int result = compute();
p.set_value(result);
} catch (...) {
// set_exception을 안 함!
}
// promise 소멸 시 broken_promise 예외 발생하지만, 이미 늦음
}
// ✅ 모든 경로에서 set 호출
void good_producer(std::promise<int>& p) {
try {
int result = compute();
p.set_value(result);
} catch (...) {
p.set_exception(std::current_exception());
}
}
실무 패턴 - RAII로 보장:
// promise가 항상 set되도록 보장
template<typename T>
class PromiseGuard {
std::promise<T>& promise_;
bool set_ = false;
public:
explicit PromiseGuard(std::promise<T>& p) : promise_(p) {}
~PromiseGuard() {
if (!set_) {
promise_.set_exception(
std::make_exception_ptr(std::runtime_error("Promise not set"))
);
}
}
void set_value(T value) {
promise_.set_value(std::move(value));
set_ = true;
}
void set_exception(std::exception_ptr e) {
promise_.set_exception(e);
set_ = true;
}
};
void safe_producer(std::promise<int>& p) {
PromiseGuard guard(p);
int result = compute();
guard.set_value(result); // 예외 발생해도 소멸자가 set_exception 호출
}
3. future 대신 shared_future 필요 여부
// ❌ 불필요하게 shared_future 사용
std::shared_future<int> sf = std::async([]{ return 42; }).share();
int result = sf.get(); // 한 스레드만 사용하면 future로 충분
// ✅ future로 충분
std::future<int> f = std::async([]{ return 42; });
int result = f.get();
// ✅ 여러 스레드가 사용할 때만 shared_future
std::shared_future<int> sf = std::async([]{ return 42; }).share();
std::thread t1([sf]() { use(sf.get()); });
std::thread t2([sf]() { use(sf.get()); });
성능 고려사항: shared_future는 참조 카운팅 오버헤드가 있으므로, 단일 스레드에서만 사용한다면 future가 더 효율적입니다.
4. 참조 캡처로 인한 UB
std::shared_future<int> sf = ...;
// ❌ 참조 캡처: 같은 객체를 여러 스레드가 공유
std::thread t1([&sf]() { int r = sf.get(); });
std::thread t2([&sf]() { int r = sf.get(); }); // UB!
// ✅ 값 캡처: 각 스레드가 복사본 사용
std::thread t1([sf]() { int r = sf.get(); });
std::thread t2([sf]() { int r = sf.get(); }); // OK
실무 패턴
패턴 1: 브로드캐스트 알림
// 여러 스레드가 특정 이벤트를 대기
class EventBroadcaster {
std::promise<void> event_promise_;
std::shared_future<void> event_future_;
public:
EventBroadcaster()
: event_future_(event_promise_.get_future().share()) {}
void trigger() {
event_promise_.set_value(); // 모든 대기 스레드 깨움
}
std::shared_future<void> subscribe() {
return event_future_; // 복사본 반환
}
};
// 사용
EventBroadcaster broadcaster;
std::thread t1([f = broadcaster.subscribe()]() { f.wait(); do_work(); });
std::thread t2([f = broadcaster.subscribe()]() { f.wait(); do_work(); });
broadcaster.trigger(); // 모든 스레드 동시 시작
패턴 2: 여러 단계 파이프라인
// 단계별로 결과를 공유하는 파이프라인
std::promise<Data> stage1_promise;
auto stage1_future = stage1_promise.get_future().share();
std::promise<ProcessedData> stage2_promise;
auto stage2_future = stage2_promise.get_future().share();
// Stage 1: 데이터 로드
std::thread loader([&]() {
stage1_promise.set_value(load_data());
});
// Stage 2: 여러 프로세서가 Stage 1 결과 대기
std::vector<std::thread> processors;
for (int i = 0; i < 3; ++i) {
processors.emplace_back([stage1_future, i]() {
auto data = stage1_future.get();
process(data, i);
});
}
// Stage 3: 최종 결과 대기
std::thread finalizer([stage2_future]() {
auto result = stage2_future.get();
finalize(result);
});
패턴 3: 타임아웃과 함께 사용
// shared_future와 wait_for로 타임아웃 처리
std::shared_future<int> sf = ...;
std::thread worker([sf]() {
using namespace std::chrono;
if (sf.wait_for(5s) == std::future_status::ready) {
int result = sf.get();
std::cout << "Got result: " << result << '\n';
} else {
std::cout << "Timeout!\n";
}
});
정리
| 항목 | 설명 |
|---|---|
| 용도 | 하나의 비동기 결과를 여러 스레드가 공유해 읽을 때 |
| 생성 | future.share()로 얻고, 복사해 각 스레드에 전달 |
| 스레드 안전성 | 복사본 간은 안전, 같은 객체는 불안전 |
| 주의 | get()은 각 복사본당 한 번만 호출 |
FAQ
Q: shared_future와 future의 가장 큰 차이는?
A: future는 이동만 가능하고 get()을 한 번만 호출할 수 있지만, shared_future는 복사 가능하고 각 복사본에서 get()을 한 번씩 호출할 수 있습니다. 여러 스레드가 같은 결과를 읽을 때 shared_future를 사용합니다.
Q: shared_future는 스레드 안전한가요?
A: 복사본 간에는 안전합니다. 각 스레드가 값 캡처로 복사본을 가지면 안전하게 get()을 호출할 수 있습니다. 하지만 같은 shared_future 객체를 여러 스레드가 참조로 공유하면 UB입니다.
Q: get()을 여러 번 호출하려면?
A: 같은 복사본에서는 get()을 한 번만 호출하세요. 결과를 지역 변수에 저장해 재사용하거나, 여러 스레드가 각자 복사본을 가지고 각각 get()을 호출하세요.
Q: promise를 set하지 않으면?
A: promise가 소멸될 때 std::future_error (broken_promise)가 발생하고, get() 대기 중인 모든 스레드에서 예외가 던져집니다. 모든 경로에서 set_value() 또는 set_exception()을 호출하세요.
Q: 언제 future 대신 shared_future를 사용하나요?
A: 여러 스레드가 같은 비동기 결과를 읽어야 할 때만 shared_future를 사용하세요. 단일 스레드만 결과를 받으면 future로 충분합니다.
관련 글: future와 promise, async 비동기 실행, 스레드 기초.
한 줄 요약: shared_future로 promise/future 결과를 여러 스레드가 안전하게 공유해 읽을 수 있습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ future와 promise | “비동기” 가이드
- C++ async & launch | “비동기 실행” 가이드
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
관련 글
- C++ async & launch |
- C++ packaged_task |
- C++ Atomic Operations |
- C++ future와 promise |
- C++ Memory Order |
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ shared_future | 여러 스레드에서 future 결과 공유」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ shared_future | 여러 스레드에서 future 결과 공유」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, shared_future, future, async, C++11, concurrency 등으로 검색하시면 이 글이 도움이 됩니다.