26. std::async / std::future / std::promise 내부 동작과 launch policy, 예외 전파
난이도 상내 리뷰 · C++
해설 · C++
해설 — std::async / std::future / std::promise 내부 동작과 launch policy, 예외 전파
난이도: 상
요약
std::async는 std::launch::async(즉시 별도 스레드에서 실행)와 std::launch::deferred(느긋한 실행, 처음 get()/wait()가 호출되는 시점에 호출한 스레드에서 실행)라는 서로 다른 launch policy를 갖는다. future::get()은 태스크 내부에서 발생한 예외를 저장해두었다가 get() 호출 시점에 그대로 재던진다(예외 전파). std::launch::async로 만든 future가 아직 완료 전에 소멸되면, 그 소멸자는 **태스크가 끝날 때까지 블로킹(join과 유사)**한다 — 이는 스레드가 "즉시 백그라운드로 떨어져 나간다"는 흔한 오해와 반대다.
핵심 개념
std::launch::async:std::async호출 즉시 새 스레드를 만들어 태스크를 실행한다.std::launch::deferred: 태스크를 즉시 실행하지 않고, future에 대해get()또는wait()가 처음 호출되는 시점에 그 호출을 수행한 스레드 위에서(동기적으로, 새 스레드 생성 없이) 태스크를 실행한다. 한 번도get()/wait()가 호출되지 않으면 태스크는 영원히 실행되지 않는다.- future의 공유 상태(shared state):
promise/async가 만든 태스크의 결과(정상값 또는 예외)는 future와 공유하는 내부 상태 블록에 저장된다. 태스크 내부에서 예외가 던져지면 이 예외가std::exception_ptr형태로 공유 상태에 저장되고,get()호출 시std::rethrow_exception으로 그 예외가 다시 던져진다. std::launch::asyncfuture의 소멸자 특수 규칙:std::async(std::launch::async, ...)가 반환한 future로, 아직get()을 부르지 않은 상태에서 소멸자가 호출되면, 그 소멸자는 내부적으로 스레드가 끝날 때까지 블로킹한다(마치std::thread::join()처럼). 이는 일반적인std::promise에서 얻은 future나packaged_task의 future에는 없는,std::async가 반환한 future에만 있는 특별한 규칙이다.
단계별 내부 동작 (실측 기반)
아래는 실제로 g++ -std=c++17 -pthread로 컴파일·실행한 결과다(스레드 스케줄링에 따라 async 스레드끼리의 출력 순서/인터리빙은 실행마다 달라질 수 있음에 유의).
[main] 태스크 생성 시작
[main] task_unused 스코프 종료 직전
[throw-task] 실행됨, 곧 예외를 던짐
[async] 실행됨 (base=10)
[async] 실행됨 (base=999)
[main] task_unused 스코프 종료 직후 (future 소멸됨)
[main] task_async 폴링 시작
[main] 폴링 결과: ready
[main] task_deferred.get() 호출 직전
[deferred] 실행됨 (base=1)
[main] task_deferred 결과 = 101
[main] task_async.get() 호출 직전
[main] task_async 결과 = 20
[main] task_throw.get() 호출 직전
[main] task_throw.get()에서 예외 포착: 랭킹 계산 실패: 데이터 손상
[main] 종료
task_deferred는std::launch::deferred이므로std::async호출 시점(생성 시점)에는 아무 것도 실행되지 않는다."[deferred] 실행됨"로그는 프로그램 전체에서 오직task_deferred.get()호출 직후, 즉"[main] task_deferred.get() 호출 직전"이 출력된 바로 다음에만 나타난다. deferred 태스크는get()을 호출한 메인 스레드 위에서 동기적으로 실행되므로 새 스레드가 생기지 않는다.task_async와task_throw는std::launch::async이므로std::async호출 즉시 각각 새 스레드에서 실행이 시작된다. 두 스레드와task_unused용 스레드까지 총 세 개의 백그라운드 스레드가 거의 동시에 뜨기 때문에,"[async] 실행됨","[throw-task] 실행됨..."로그들은 메인 스레드의"[main] task_unused 스코프 종료 직전"이후 임의의 시점에, 서로 인터리빙되며 출력된다. 이 순서는 OS 스케줄러에 달려 있어 실행마다 달라질 수 있다(교육적으로 중요한 포인트 — "정확한 한 가지 순서"가 없다는 것 자체가 정답이다).task_throw.get()을 호출하면,ThrowingCompute()가 던진std::runtime_error가 공유 상태에 저장돼 있다가get()호출 시점에 그대로 재던져진다. 문제 코드에서는try/catch(const std::exception&)로 감싸두었으므로 프로그램이 종료되지 않고"[main] task_throw.get()에서 예외 포착: 랭킹 계산 실패: 데이터 손상"이 출력된다. 만약 이try/catch가 없었다면std::runtime_error가main을 빠져나가std::terminate()가 호출되어 프로그램이 비정상 종료됐을 것이다.task_deferred의 future를 한 번도get()/wait()하지 않고 프로그램이 끝난다면,DeferredCompute는 절대 실행되지 않는다 —"[deferred] 실행됨"로그는 나오지 않는다. deferred 태스크는 "요청 시 실행(lazy)"이며, 요청이 없으면 실행 자체가 안 일어난다.task_unused(std::launch::async)는 지역 블록을 벗어나며 future가 소멸된다. 이때get()을 부르지 않았으므로, future의 소멸자가SlowCompute(999)스레드가 끝날 때까지 블로킹한다. 실측 로그에서"[main] task_unused 스코프 종료 직전"이 출력된 후,"[main] task_unused 스코프 종료 직후"가 출력되기 전에 반드시"[async] 실행됨 (base=999)"가 출력된다(50ms 슬립이 있으므로 그동안 메인 스레드는 블로킹). 이것이 "스레드가 즉시 백그라운드로 떨어져 나간다"는 오해를 깨는 핵심 포인트다 —std::thread를 detach한 것과 달리,std::async(std::launch::async, ...)의 future 소멸자는 암묵적으로 join과 동등한 대기를 수행한다.task_async.wait_for(std::chrono::milliseconds(0))의 실측 결과는 **ready**였다. 처음 설계 의도는 "아직 실행 중이라 timeout이 나올 것"이었지만, 실제로는task_unused블록에서 이미 50ms 이상 블로킹 대기를 했기 때문에(위 5번 항목), 그 사이task_async(동일하게 50ms 슬립)도 이미 끝나 있어 폴링 시점에는 이미ready상태였다. 이는 "폴링 결과는 그 이전에 메인 스레드가 얼마나 대기했는가에 의존한다"는 것을 보여주는 실측 근거다 — 만약task_unused블록이 없었다면 폴링 시점에timeout이 나왔을 가능성이 높다.
흔한 오해·함정
- "
std::async는 항상 새 스레드를 만든다"는 오해 —std::launch::deferred가 정책에 포함되면 실행이 지연되며, 정책을 명시하지 않고std::async(f)만 호출하면 구현이async/deferred중 하나를 자유롭게 선택할 수 있어(둘 다 허용된 기본 정책) 이식성 있는 코드는 launch policy를 명시하는 것이 안전하다. - "future를 버리면(get을 안 부르면) 백그라운드 스레드가 알아서 돌다가 끝난다"는 오해 —
std::launch::asyncfuture의 소멸자는 완료까지 블로킹한다. 이 때문에 임시 future를 받아두지 않고std::async(...)를 단독 구문으로만 호출하면(예:std::async(std::launch::async, f);), 그 즉시 반환된 임시 future가 문장 끝에서 소멸되며 사실상 동기 호출처럼 블로킹돼버리는 잘 알려진 함정이 있다. - "예외는 태스크가 끝나는 즉시 프로그램에 영향을 준다"는 오해 — 실제로는 예외가 공유 상태에 저장되어 있다가
get()호출 시점에야 재던져진다.get()을 부르지 않으면 예외는 조용히 보관된 채로 남아있고(단, future 소멸 시 예외가 버려짐), 프로그램에 어떤 영향도 주지 않는다. - 스레드 인터리빙 순서를 "결정론적으로" 예측하려는 시도 자체가 함정이다.
std::launch::async로 만든 여러 태스크의 상대적 실행/로그 순서는 스케줄러 의존적이며, 이는 실제 실측에서도 실행마다 달라졌다.
면접 포인트
std::launch::async와std::launch::deferred의 실행 시점 차이를 정확히 설명할 수 있는가(특히 deferred가 "호출한 스레드에서 get 시점에" 실행된다는 점).std::async가 반환하는 future의 소멸자가std::promise/packaged_task의 future와 달리 특별히 블로킹한다는 규칙을 아는가, 그리고 이것이 실무에서 어떤 버그(의도치 않은 동기화)를 유발할 수 있는지 설명할 수 있는가.- future의 예외 전파 메커니즘(
exception_ptr,get()시점의 재던짐)을 설명할 수 있는가. wait_for로 폴링할 때 결과가 그 이전 코드의 타이밍에 의존적일 수 있다는 것을 인지하고 있는가(교과서적 정답이 아니라 "그 시점의 실제 상태"를 봐야 한다는 실무 감각).