Devin.KR

future·promise·async - 결과를 기다리는 방법

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 원자적 연산으로 여러 스레드가 공유하는 작은 상태를 다루었다. 주문 처리 엔진에 필요한 정보는 카운터만이 아니다. 검증한 주문의 체결 결과를 받아야 하고, 검증이 실패했다면 그 이유를 호출자에게 돌려주어야 한다. 작업을 시작하는 시점과 결과를 사용하는 시점이 다를 때는 두 시점을 연결하는 통로가 필요하다.

이 장에서는 퓨처(future)를 결과를 받는 쪽의 객체로, 프로미스(promise)를 결과를 제공하는 쪽의 객체로 살펴본다. 이어서 std::async의 실행 정책을 구분하고, 주문 세 건을 처리하면서 성공·예외·취소를 수집하는 프로그램을 만든다. 작업 큐나 원자적 연산을 다시 구현하지 않고, 이미 실행 중인 작업의 완료를 어떻게 표현하고 기다릴지에 집중한다.

  • std::launch::async와 std::launch::deferred의 실행 시점과 대기 동작을 구분한다.
  • future.get()으로 결과를 꺼내고 작업의 예외를 호출자 쪽에서 처리한다.
  • promise<void>와 shared_future<void>로 여러 작업에 시작 신호를 보낸다.
  • 스레드 풀의 역할을 이해하고, 결과 전달과 협력적 취소를 별개의 설계로 다룬다.

문제 상황

주문 접수 함수가 검증과 체결 로그 작성까지 모두 수행하면, 오래 걸리는 주문 하나가 다음 주문의 접수를 늦춘다. 접수와 계산을 분리하면 이 지연을 줄일 수 있지만 다른 문제가 생긴다. 작업을 넘긴 함수가 먼저 반환할 수 있고, 작업 안에서 발생한 예외를 어느 스레드가 처리할지도 정해야 한다. 체결 로그에는 작업의 성공 여부와 주문 번호가 함께 남아야 한다.

예를 들어 주문 101은 수량이 3이라 정상 처리하고, 주문 102는 수량이 0이라 거절해야 한다. 주문 103은 접수했지만 처리 시작 전에 취소 요청이 들어온다. 세 작업이 각자 표준 출력에 기록하면 출력 순서가 실행 때마다 달라지고, 한 줄 안에서 문자열이 섞일 수도 있다. 이 엔진에서는 작업이 결과만 반환하고, 접수한 순서대로 결과를 수집하는 스레드 하나가 로그를 작성하도록 한다.

완성 코드의 체결은 외부 거래소에 주문을 보내는 동작이 아니라, 검증한 주문을 체결 결과 객체로 바꾸는 모의 처리다. 이 구분은 취소 의미를 정할 때 필요하다. 메모리 안에서 결과를 만들기 전에는 중단할 수 있지만, 외부에 이미 전달한 주문은 지역 함수의 실행을 멈추는 것만으로 되돌릴 수 없다.

실행 정책이 작업의 시작과 대기를 결정한다

std::async는 호출할 함수와 인자를 받아 작업을 구성하고, 그 작업의 반환값을 받을 std::future<T>를 돌려준다. 함수가 Execution을 반환하면 결과 통로의 타입은 std::future<Execution>이다. 호출자는 작업이 끝나기 전에 다른 일을 할 수 있으며, 결과가 필요해지는 지점에서 기다린다.

실행 정책을 생략하는 호출은 편리하지만, 실행 시점을 코드만 보고 확정할 수 없다. 기본 정책은 async와 deferred를 함께 허용하며 구현이 정책을 선택한다. 새 스레드에서 실행해야 한다는 요구가 있다면 std::launch::async를 명시해야 한다. 반대로 결과가 실제로 필요할 때 계산해도 된다면 std::launch::deferred가 그 의도를 드러낸다.

실행 정책에 따른 작업 시작과 대기 동작
정책실행하는 곳시작 조건시간 제한 대기
launch::async새 실행 스레드비동기 실행을 개시한다준비 여부에 따라 ready 또는 timeout이다
launch::deferred처음 비시간제 대기를 수행하는 스레드get() 또는 wait()가 실행을 유발한다실행을 시작하지 않고 deferred를 반환한다
정책 생략선택된 정책에 따른다구현의 정책 선택에 따른다deferred도 처리해야 한다

async를 명시해도 함수가 호출 직후 어느 지점까지 진행했는지는 알 수 없다. 실행 스레드를 확보하지 못하면 std::async 자체가 std::system_error를 던질 수 있다. 이 예외는 작업 함수가 실행되다가 던진 예외와 다르다. 전자는 작업을 만드는 호출에서 처리하고, 후자는 결과를 받는 get()에서 처리한다.

wait()는 공유 상태가 준비될 때까지 기다릴 뿐 결과를 꺼내지 않는다. 작업이 예외를 저장했더라도 wait()는 그 작업 예외를 다시 던지지 않는다. get()은 필요한 대기를 수행한 뒤 값을 반환하거나 저장된 예외를 다시 던진다. 따라서 오류까지 확인하려면 결과를 사용하지 않는 작업이라도 get()을 호출해야 한다.

유효한 future에 get()을 한 번 호출하면 해당 객체는 공유 상태를 놓는다. 값을 받았든 저장된 예외가 던져졌든 다시 get()을 호출할 수 없다. valid()는 연결된 공유 상태가 있는지를 검사한다. 작업의 성공이나 완료 여부를 검사하는 함수는 아니다.

수명에도 주의해야 한다. std::async의 async 정책으로 만든 공유 상태에서는, 마지막 참조를 놓는 동작이 실행 스레드의 완료를 기다릴 수 있다. 반환된 future를 임시 객체로 버리면 문장 끝의 소멸에서 기다려 작업들이 직렬로 실행되는 모양이 될 수 있다. 이 장에서는 먼저 모든 future를 보관하고 나중에 결과를 수집한다.

공유 상태에 값·예외·신호를 담는다

promise와 future 사이에는 공유 상태(shared state)가 있다. promise는 set_value()로 값을 저장하거나 set_exception()으로 예외를 저장한다. future는 그 상태가 준비되기를 기다린다. 하나의 상태에는 완료 결과를 한 번만 설정할 수 있다. 값을 설정한 뒤 예외를 추가로 설정하는 식으로 결과를 수정할 수는 없다.

promise는 공유 상태를 한 번 준비시키고 future는 그 상태에서 값이나 예외를 받는다

데이터 없이 사건 발생만 알려야 한다면 promise<void>를 쓴다. 이때 인자 없는 set_value()가 신호가 된다. 받는 쪽의 get()도 값을 반환하지 않는다. 다만 실패 신호로 저장된 예외가 있다면 그대로 다시 던진다. 완료, 초기화 종료, 처리 시작 허가처럼 한 번 발생하는 사건에 알맞다.

일반 future는 복사할 수 없고 결과를 한 번만 꺼낸다. 여러 작업이 같은 신호를 기다리게 하려면 share()로 std::shared_future<void>를 만든다. 원래 future는 이 변환 뒤 유효하지 않으며, 새 객체는 복사할 수 있다. 각 작업에 복사본을 전달하면 각자 get()을 호출하여 같은 준비 상태를 관찰한다.

이 신호는 대기 중인 스레드 하나가 소비하는 큐 항목이 아니다. 상태가 한 번 준비되면 나중에 대기를 시작한 작업도 바로 통과한다. 그래서 완성 코드에서는 모든 작업을 만든 뒤 시작 신호를 보내도 신호를 놓치는 문제가 없다. 단, 반복해서 열고 닫는 문처럼 사용할 수는 없다. 여러 차례의 작업 묶음을 제어하려면 새 공유 상태를 만들거나 반복 통신에 맞는 다른 구조를 선택해야 한다.

결과를 저장하여 공유 상태를 준비시키는 동작은 그 준비 상태를 성공적으로 확인한 대기의 반환과 동기화된다. 신호 전에 준비한 데이터를 대기 이후 읽는 설계에 이 관계를 이용할 수 있다. 그렇다고 신호 이후에도 계속 변경하는 객체의 접근까지 보호되는 것은 아니다. 예제는 이 구분을 단순하게 유지하려고 주문 자체를 작업마다 값으로 전달한다.

promise를 완료시키지 않은 채 파괴하면 연결된 상태에는 broken_promise를 나타내는 std::future_error가 저장된다. 받는 쪽이 끝없이 기다리지 않도록 공급자의 포기를 오류로 표현하는 것이다. 그러나 이 동작이 모든 종료 문제를 해결하지는 않는다. promise가 파괴되기 전에 다른 객체의 소멸자가 대기 중인 작업을 기다리면, 공급자 파괴까지 도달하지 못할 수 있다.

스레드 풀과 취소는 결과 통로와 별개다

스레드 풀(thread pool)은 일정 수의 실행 스레드를 유지하고, 제출된 작업을 큐에 넣어 이 스레드들이 꺼내 실행하는 구조다. 주문마다 실행 스레드를 만드는 방식과 달리 동시에 실행하는 작업 수를 제한할 수 있다. 결과 전달에는 여전히 future를 사용할 수 있다. 작업이 어디에서 실행되는지와 결과를 어떤 통로로 받는지는 별개의 선택이다.

C++20 표준 라이브러리는 범용 스레드 풀을 제공하지 않는다. 또한 std::async를 풀의 크기나 큐 길이를 설정하는 인터페이스로 사용할 수 없다. 작은 예제에서는 실행 정책을 명시한 std::async로 충분하지만, 실제 접수량을 감당할 설계에서는 실행 개수 제한, 대기열 한도, 종료 시 남은 작업의 처리 방침까지 정해야 한다.

풀을 직접 구성한다면 호출 대상을 std::packaged_task로 감싸 반환값과 예외를 future에 연결할 수 있다. 다만 그 객체도 실행 스레드를 만들거나 큐를 관리하지는 않는다. 이 장에서는 풀 구현으로 범위를 넓히지 않고, 제출된 작업에는 실행 자원과 결과 상태가 각각 필요하다는 점을 기억한다.

작업 취소도 결과를 기다리는 기능과 분리해야 한다. future에는 실행 중인 함수를 중단시키는 cancel()이 없다. wait_for()가 timeout을 반환해도 작업은 계속될 수 있다. 기다리기를 잠시 끝냈다는 사실과 작업이 끝났다는 사실은 다르다. 특히 async 결과의 수명이 끝날 때 다시 기다릴 수 있으므로, 시간 제한 대기만으로 함수 전체의 반환 시간 상한을 보장할 수 없다.

예제는 std::stop_source가 취소 요청을 기록하고 std::stop_token을 받은 작업이 이를 확인하는 협력적 취소(cooperative cancellation)를 사용한다. std::async는 중단 토큰을 자동으로 전달하지 않으므로 함수 인자로 직접 넘긴다. 작업은 취소를 확인하면 Cancelled 예외를 던지고, 수집자는 이를 일반 검증 실패와 구분한다.

시작 신호 전에 취소를 요청하면 주문 103은 신호를 통과한 뒤 취소를 확인하고 종료한다

취소 요청의 수락은 완료 통지가 아니다. request_stop()이 반환되어도 작업이 아직 요청을 확인하지 않았을 수 있다. 작업의 실제 종료는 future.get()으로 확인한다. 또한 토큰은 보통의 블로킹 입출력을 저절로 깨우지 않는다. 긴 작업은 적절한 구간에서 요청을 확인하고, 오래 막히는 호출에는 별도의 시간 제한이나 중단 가능한 인터페이스를 마련해야 한다.

취소의 우선순위도 계약의 일부다. 이 예제는 시작 신호를 받은 직후 취소를 먼저 확인하고 그다음 수량을 검증한다. 따라서 두 조건이 모두 성립하면 취소로 분류된다. 체결 결과를 만들기 직전에도 다시 확인하지만, 마지막 검사 뒤에 들어온 요청은 정상 완료와 경합할 수 있다. 여기서의 취소는 “요청이 있으면 반드시 취소 결과가 나온다”가 아니라 “작업이 정해진 확인 지점에서 요청을 발견하면 중단한다”라는 의미다.

완성 코드

다음 프로그램을 main.cpp로 저장한다. 작업 세 개를 만든 뒤 주문 103에 취소를 요청하고 시작 신호를 보낸다. 작업의 실제 실행 순서는 정하지 않지만, 취소 요청과 신호의 순서를 정하고 접수 순서로 결과를 수집하므로 출력은 일정하다. 체결 로그는 수집 스레드만 작성한다.

#include <exception>
#include <future>
#include <iostream>
#include <stdexcept>
#include <stop_token>
#include <vector>

struct Order {
    int id;
    int quantity;
};

struct Execution {
    int order_id;
    int quantity;
};

class Cancelled final : public std::runtime_error {
public:
    Cancelled() : std::runtime_error("취소 요청을 확인했다") {}
};

Execution process(Order order,
                  std::shared_future<void> start,
                  std::stop_token stop) {
    start.get();                                      // [1]

    if (stop.stop_requested()) {                       // [2]
        throw Cancelled{};
    }
    if (order.quantity <= 0) {
        throw std::invalid_argument("수량은 1 이상이어야 한다");
    }

    // 실제 엔진에서는 이 사이에서 추가 검증을 수행한다.
    if (stop.stop_requested()) {                       // [3]
        throw Cancelled{};
    }
    return Execution{order.id, order.quantity};
}

void run() {
    const std::vector<Order> orders{
        {101, 3}, {102, 0}, {103, 2}
    };

    std::promise<void> start_promise;
    const auto start = start_promise.get_future().share(); // [4]
    std::stop_source cancel_third;

    std::vector<std::future<Execution>> results;
    results.reserve(orders.size());                    // [5]

    try {
        for (const auto& order : orders) {
            const auto token = order.id == 103
                ? cancel_third.get_token()
                : std::stop_token{};
            results.push_back(std::async(             // [6]
                std::launch::async, process, order, start, token));
        }
    } catch (...) {
        start_promise.set_exception(std::current_exception()); // [7]
        throw;
    }

    cancel_third.request_stop();                       // [8]
    std::cout << "접수: " << orders.size() << "건\n";
    start_promise.set_value();                         // [9]

    int executed = 0;
    int rejected = 0;
    int cancelled = 0;

    for (std::size_t i = 0; i < results.size(); ++i) {
        try {
            const auto execution = results[i].get();   // [10]
            ++executed;
            std::cout << "체결: 주문 " << execution.order_id
                      << ", 수량 " << execution.quantity << '\n';
        } catch (const Cancelled&) {
            ++cancelled;
            std::cout << "취소: 주문 " << orders[i].id << '\n';
        } catch (const std::exception& error) {
            ++rejected;
            std::cout << "거절: 주문 " << orders[i].id
                      << ", " << error.what() << '\n';
        }
    }

    std::cout << "요약: 체결 " << executed
              << ", 거절 " << rejected
              << ", 취소 " << cancelled << '\n';
}

int main() {
    try {
        run();
    } catch (const std::exception& error) {
        std::cerr << "엔진 실패: " << error.what() << '\n';
        return 1;
    }
    return 0;
}

줄별 해설

Order는 접수한 요청이고 Execution은 성공한 작업의 반환값이다. 타입을 나누면 성공 결과를 얻었다는 사실이 단순히 원본 주문을 가지고 있다는 사실과 구별된다. Cancelled는 취소 결과를 예외 통로로 전달하기 위한 구분자다. 취소를 예외로 표현하는 것은 이 프로그램의 선택이며, 모든 시스템이 같은 표현을 사용할 필요는 없다.

[1]의 start.get()은 처리 시작 허가를 기다린다. wait() 대신 get()을 쓰므로 시작 준비가 실패했다는 예외도 전달받는다. 이 줄을 통과하기 전에는 취소 검사와 주문 검증을 수행하지 않는다. 매개변수 start는 각 작업이 소유한 shared_future 복사본이다.

[2]는 작업에 진입할 때의 취소 확인이다. 취소를 발견하면 Cancelled를 던진다. std::async는 이 예외가 실행 스레드 밖으로 빠져나가게 두지 않고 결과 공유 상태에 저장한다. 수량 검증에서 발생하는 invalid_argument도 같은 통로에 저장된다. 어느 경우든 성공 결과는 만들어지지 않는다.

[3]은 결과를 만들기 전의 확인 지점이다. 현재 예제에서는 두 검사 사이가 짧지만, 검증 단계가 늘어나면 이 위치가 의미를 갖는다. 검사 횟수를 늘리는 것만으로 외부 체결과 취소 사이의 경합이 해결되는 것은 아니다. 외부 효과가 생기는 엔진은 체결 확정의 기준과 그 뒤의 취소 처리 규칙을 따로 정해야 한다.

[4]는 하나의 완료 상태를 여러 작업이 기다릴 수 있게 만든다. get_future()는 같은 promise에 대해 한 번만 호출한다. 그 결과에 share()를 적용하여 복사 가능한 대기 객체를 만든다. 신호를 보내는 권한은 여전히 start_promise 하나가 갖는다.

[5]에서는 작업을 시작하기 전에 결과 저장 공간을 확보한다. 할당이 실패해도 아직 실행 중인 작업이 없으므로 시작 신호를 기다리는 작업을 정리할 필요가 없다. 저장 공간을 미리 확보하고 이동 가능한 future를 넣으면, 이 반복문에서 컨테이너의 재할당 때문에 등록 도중 실패하는 경로도 줄일 수 있다.

[6]은 주문, 신호, 토큰을 값으로 전달한다. 반복문의 order가 참조 변수라는 사실과 달리, 이 호출에서 std::async가 보관하는 주문 인자는 복사된 값이다. 작업이 반복문 변수의 수명에 의존하지 않는다. 주문 103만 중단 가능한 상태와 연결되고 나머지는 기본 생성한 토큰을 받는다.

[7]은 일부 작업만 만들어진 경우의 정리 경로다. 예를 들어 세 번째 std::async 호출이 실패하면 앞의 두 작업은 이미 시작 신호를 기다릴 수 있다. 여기서 바로 다시 던지면 results가 먼저 파괴되면서 작업 종료를 기다리고, start_promise의 파괴까지 도달하지 못할 수 있다. 먼저 시작 상태에 예외를 설정하면 기존 작업들이 깨어나 예외로 종료하고, 그 뒤 수명 정리가 진행된다.

이 정리 경로에서 시작 신호의 예외는 각 작업의 start.get()에서 발생하고 다시 각 결과 상태에 저장된다. 그 결과들을 별도로 get()하지 않더라도 저장된 예외가 소멸자에서 다시 던져지지는 않는다. 호출자에게는 작업 생성 중 발생한 원래 예외를 전달한다. 따라서 준비 단계의 실패와 주문별 처리 실패가 다른 경로로 보고된다.

[8]과 [9]의 순서가 취소 결과를 일정하게 만든다. 요청을 먼저 기록하고 시작 신호를 보내므로, 주문 103은 작업이 언제 실행되든 첫 확인 지점에서 요청을 발견한다. 이는 임의의 시간 동안 잠들어 스레드 실행을 추측하는 방식이 아니다. 작업이 진행할 수 있는 조건을 공유 상태로 직접 제어한다.

[10]에서는 접수 순서대로 결과를 한 번씩 꺼낸다. 첫 작업이 늦으면 뒤의 작업이 이미 끝났어도 해당 로그는 기다린다. 이 방식은 완료 순서보다 로그 순서가 중요한 예제의 선택이다. 완료한 결과부터 처리해야 하는 엔진에는 별도의 완료 통지 큐 같은 구조가 필요하다. C++20의 std::future 자체에는 여러 결과 중 먼저 준비된 하나를 고르는 표준 멤버 함수가 없다.

취소 예외는 std::runtime_error에서 파생하므로 std::exception 처리보다 먼저 잡는다. 현재 작업의 일반 실패는 수량 검증 오류라서 거절로 기록한다. 실제 엔진에서는 자원 부족이나 내부 오류까지 주문 거절로 묶지 말고 별도 운영 오류로 분류할 필요가 있다.

실행 결과

C++20의 중단 토큰을 지원하는 컴파일러와 표준 라이브러리가 설치된 macOS 또는 Linux에서 다음과 같이 빌드한다. 정상적으로 실행 스레드를 확보한 경우의 출력이다. 작업 함수는 출력하지 않으므로 작업 실행 순서가 달라져도 아래 로그 순서는 유지된다.

c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o order_engine
./order_engine
접수: 3건
체결: 주문 101, 수량 3
거절: 주문 102, 수량은 1 이상이어야 한다
취소: 주문 103
요약: 체결 1, 거절 1, 취소 1

이 출력에서 주문 102의 오류 문구는 작업 함수가 생성했지만 출력은 수집 스레드가 수행했다. 주문 103도 실행 도중 강제로 끊긴 것이 아니다. 시작 신호를 통과한 뒤 취소 요청을 확인하여 정해진 종료 경로를 밟았다. 체결·거절·취소를 모두 수집한 뒤에 요약을 작성하므로 요약은 처리 중간의 추정치가 아니다.

실무에서 자주 틀리는 것

기본 정책에서 시간 제한 대기만 반복한다

다음 코드는 지연 실행이 선택되면 작업을 시작시키지 못한다. wait_for()가 계속 deferred를 반환해도 준비되지 않았다고만 판단하기 때문이다. 아래 조각들은 해당 표준 헤더를 포함한 함수 안에서 사용하는 예다.

auto result = std::async([] { return 7; });
while (result.wait_for(std::chrono::milliseconds{10})
       != std::future_status::ready) {
    // 지연 실행이라면 이 반복으로 작업이 시작되지 않는다.
}
const int value = result.get();

기다리는 동안 별도 스레드에서 진행해야 하는 작업이라면 실행 정책을 명시한다. 단순히 결과만 필요하다면 반복 대기 없이 get()으로 충분하다.

auto result = std::async(std::launch::async, [] { return 7; });
const int value = result.get();
std::cout << value << '\n';

결과가 필요할 때마다 get을 호출한다

get()은 보관된 값을 계속 조회하는 함수가 아니다. 다음 두 번째 호출은 유효한 공유 상태가 없는 객체를 사용하므로 허용되지 않는다. 특정 예외가 나온다고 기대해서도 안 된다.

auto result = std::async(std::launch::async, [] { return 7; });
std::cout << result.get() << '\n';
std::cout << result.get() << '\n';

호출자가 값을 여러 번 쓸 목적이라면 한 번 꺼내 지역 변수에 저장한다. 여러 소비자가 공유 상태 자체를 기다릴 필요가 있을 때 shared_future를 고려한다.

auto result = std::async(std::launch::async, [] { return 7; });
const int value = result.get();
std::cout << value << '\n';
std::cout << value << '\n';

시간 제한을 취소 완료로 기록한다

다음 코드는 작업에 중단 요청을 전달하지도 않았는데 취소되었다고 기록한다. 결과 객체의 소멸에서 기다릴 가능성도 남아 있다.

if (result.wait_for(std::chrono::milliseconds{20})
    == std::future_status::timeout) {
    std::cout << "취소 완료\n";
}

작업에 연결된 stop_source로 요청하고, 실제 결과를 수집하여 완료 형태를 확인한다. 다음 코드의 source는 작업에 전달한 토큰의 공급자라고 가정한다. 요청 직전에 작업이 끝났다면 성공 결과를 받을 수도 있다.

if (result.wait_for(std::chrono::milliseconds{20})
    == std::future_status::timeout) {
    source.request_stop();
}

try {
    const auto execution = result.get();
    std::cout << "체결: " << execution.order_id << '\n';
} catch (const Cancelled&) {
    std::cout << "취소 완료\n";
} catch (const std::exception& error) {
    std::cout << "실패: " << error.what() << '\n';
}

반환된 future를 버려도 작업이 분리된다고 생각한다

아래 코드는 반환값을 버렸다는 뜻을 명시했지만, 작업 수명까지 분리한 것은 아니다. 첫 번째 문장 끝에서 임시 결과 객체의 소멸이 기다릴 수 있어 두 호출의 실행이 겹치지 않을 수 있다.

static_cast<void>(
    std::async(std::launch::async, [] { return 1; }));
static_cast<void>(
    std::async(std::launch::async, [] { return 2; }));

두 작업을 먼저 시작하고 결과 객체를 유지한다. 결과 수집 순서가 정해져 있어도 작업 실행은 서로 겹칠 수 있다. 각 작업의 실패를 모두 기록해야 한다면 완성 코드처럼 get()마다 예외를 처리한다.

auto first = std::async(std::launch::async, [] { return 1; });
auto second = std::async(std::launch::async, [] { return 2; });

const int first_value = first.get();
const int second_value = second.get();
std::cout << first_value + second_value << '\n';

한눈에 보기

결과 대기와 작업 제어에서 각 기능이 맡는 역할
기능하는 일설계 시 확인할 점
std::async호출 대상을 실행 정책에 연결한다정책 선택과 호출 자체의 실패를 처리한다
future.get()값을 받거나 저장된 예외를 다시 던진다일반 future에서는 한 번만 호출한다
future.wait()준비 상태를 기다린다작업 예외를 확인하려면 get이 필요하다
wait_for()시간 제한을 두고 준비 상태를 확인한다timeout은 취소가 아니며 deferred도 가능하다
promise<void>한 번의 신호 또는 실패를 제공한다모든 종료 경로에서 대기자를 해제한다
shared_future같은 공유 상태를 여러 소비자가 기다린다작업별 복사본으로 수명을 유지한다
중단 토큰작업이 취소 요청을 확인하게 한다확인 지점과 취소 이후의 완료 규칙을 정한다
스레드 풀실행 스레드와 작업 대기열을 관리한다결과 전달 외에 용량과 종료 정책이 필요하다

결과를 받는 객체가 있다고 해서 실행 자원의 개수, 완료 순서, 취소 의미까지 정해지는 것은 아니다. 이번 엔진은 실행 정책을 명시하고, 시작 신호를 한 번 보내며, 취소를 작업 내부에서 확인하고, 결과를 접수 순서로 수집한다. 다음에는 이렇게 동작이 정해진 엔진을 바탕으로 메모리 배치와 복사 비용을 측정할 수 있다.

연습 문제

  1. 완성 코드에서 cancel_third.request_stop()을 삭제하면 출력이 어떻게 바뀌는지 적는다. 시작 신호보다 뒤로 옮기는 경우에는 왜 같은 결과를 보장하기 어려운지도 설명한다.
  2. 실행 정책을 std::launch::deferred로 바꾼다. 작업 함수가 어느 스레드에서 언제 실행되는지, 시작 신호 대기 때문에 멈추는지를 설명한다.
  3. 일부 작업을 만든 뒤 다음 작업 생성이 실패했다고 가정한다. 완성 코드의 set_exception()을 제거하면 어떤 소멸 순서 때문에 진행이 막힐 수 있는지 설명한다.
  4. 주문 104를 수량 5로 추가하고, 주문 102에 대한 수량 오류도 모두 수집한 다음 프로그램의 종료 코드를 2로 만들려고 한다. 최초 거절에서 바로 반환하지 않고 구현하는 방법을 설명한다.

정답과 해설

  1. 취소 요청을 삭제하면 주문 103이 수량 2로 체결된다. 기존 취소 줄은 체결: 주문 103, 수량 2로 바뀌고 마지막 줄은 요약: 체결 2, 거절 1, 취소 0이 된다. 요청을 시작 신호 뒤로 옮기면 주문 103이 두 확인 지점을 이미 통과했을 수 있다. 요청을 기록했다는 사실만으로 취소 결과를 보장할 수 없다.

  2. 작업은 결과 수집 반복문에서 해당 get()을 호출하는 스레드, 즉 이 프로그램의 주 스레드에서 실행된다. 시작 신호는 반복문에 들어가기 전에 이미 준비되므로 process() 안의 start.get()은 준비를 기다리며 막히지 않는다. 출력은 같지만 세 작업의 처리는 결과 수집 순서대로 진행한다. 비시간제 대기를 전혀 하지 않았다면 지연 작업은 실행되지 않을 수 있다.

  3. results는 start_promise보다 나중에 생성되었으므로 먼저 파괴된다. 이미 만들어진 비동기 작업은 시작 상태를 기다리고, 결과 객체의 소멸은 그 작업의 종료를 기다릴 수 있다. 시작 상태를 준비시킬 공급자는 아직 파괴되지 않았으므로 broken_promise도 만들어지지 않는다. 공유 상태에 예외를 먼저 설정하면 대기 중인 작업이 깨어나 종료하여 이 순환 대기를 끊는다.

  4. 주문 목록에 {104, 5}를 추가하고, run()의 반환 타입을 int로 바꾼다. 모든 결과를 수집하고 요약을 출력한 뒤 return rejected == 0 ? 0 : 2;를 실행한다. main()의 정상 경로는 return run();으로 바꾸고 엔진 준비 실패 경로의 반환값 1은 유지한다. 결과는 체결 2, 거절 1, 취소 1이며 종료 코드는 2다. 첫 거절에서 반환하면 뒤의 작업 종료를 기다릴 수는 있어도 그 결과의 로그와 요약을 모두 작성한다는 요구를 만족하지 못한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.