본문으로 건너뛰기

안동민 개발노트

본문 시작

비동기 프로그래밍

std::async·future·promise·packaged_task로 작업 실행과 결과 수신을 분리하고 비동기 오류를 전달합니다.

현대 C++는 이러한 복잡성을 추상화하여 더 쉽고 안전하게 동시성을 활용할 수 있도록 비동기 프로그래밍(Asynchronous Programming) 기능을 제공합니다.

비동기 프로그래밍은 작업을 백그라운드에서 실행하고, 결과가 준비되면 알림을 받는 방식으로, 주 스레드가 블록되지 않고 다른 작업을 계속 수행할 수 있게 합니다.

이번 장에서는 C++의 표준 비동기 프로그래밍 도구인 std::async, std::future, std::promise를 다룹니다.

아래 그림은 비동기 도구를 “작업 시작”과 “결과 수신”의 책임 분리로 먼저 정리합니다.


비동기 프로그래밍의 필요성

일반적인 동기(Synchronous) 프로그래밍에서는 함수 호출이 완료될 때까지 호출자(caller) 스레드가 대기합니다.

이는 순차적인 작업에는 문제가 없지만, 시간이 오래 걸리는 작업(파일 I/O, 네트워크 통신, 복잡한 계산)의 경우 사용자 인터페이스가 멈추거나 전체 프로그램의 응답성이 저하될 수 있습니다.

비동기 프로그래밍의 장점
  • 응답성 향상: 긴 작업을 백그라운드에서 실행하여 주 스레드가 블록되지 않고 사용자 상호작용이나 다른 중요한 작업을 계속 처리할 수 있게 합니다.
  • 자원 활용 효율성: I/O 바운드(I/O-bound) 작업(CPU는 대기, I/O 장치 사용)과 CPU 바운드(CPU-bound) 작업(CPU 집중 사용)을 분리하여 시스템 자원을 더 효율적으로 사용할 수 있습니다.
  • 병렬성 활용: 여러 비동기 작업을 동시에 시작하여 다중 코어 CPU의 이점을 활용할 수 있습니다.

std::asyncstd::future

C++11에서 도입된 std::async는 함수를 비동기적으로 실행하고 그 결과를 std::future 객체를 통해 나중에 비동기적으로 가져올 수 있게 해주는 템플릿 함수입니다.

이는 명시적으로 std::thread 객체를 생성하고 join() 또는 detach()하는 것보다 훨씬 고수준(high-level)의 추상화를 제공합니다.

  • std::async
    • 함수를 비동기적으로 실행합니다. 즉, 새로운 스레드에서 실행하거나, 스레드 풀에서 스레드를 가져와 실행하거나, 나중에 실행하도록 지연(defer)시킬 수 있습니다.
    • 실행 결과나 예외를 std::future 객체로 캡슐화하여 반환합니다.
    • std::launch 정책을 통해 실행 방식을 제어할 수 있습니다.
      • std::launch::async: 새로운 스레드를 생성하여 즉시 실행합니다.
      • std::launch::deferred: 함수 호출 시점이 아니라, std::futureget() 또는 wait()가 호출될 때까지 실행을 지연시킵니다 (동기적으로 실행).
      • std::launch::async | std::launch::deferred (기본값): 컴파일러/런타임이 최적의 실행 방식을 선택하도록 합니다.
  • std::future
    • 비동기적으로 실행되는 함수의 결과(또는 예외)에 대한 핸들(handle) 역할을 합니다.
    • get() 멤버 함수를 호출하여 비동기 작업의 결과를 블로킹 방식으로 가져올 수 있습니다. 결과가 아직 준비되지 않았다면, get()은 준비될 때까지 호출 스레드를 블록합니다.
    • wait() 멤버 함수를 호출하여 결과가 준비될 때까지 블록하지만, 결과를 반환하지는 않습니다.
    • wait_for(), wait_until()을 사용하여 시간 제한을 두고 대기할 수 있습니다.
    • std::futuremove-only 타입입니다 (복사 불가, 이동 가능).
std::async와 std::future 사용 예시
#include <iostream>
#include <future>   // std::async, std::future를 위해
#include <chrono>   // std::chrono::seconds를 위해
#include <string>   // std::string을 위해

// 시간이 오래 걸리는 계산 함수
int calculate_sum(int start, int end) {
    std::cout << "Calculating sum from " << start << " to " << end << " in thread: "
              << std::this_thread::get_id() << std::endl;
    int sum = 0;
    for (int i = start; i <= end; ++i) {
        sum += i;
        std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 작업 시뮬레이션
    }
    std::cout << "Calculation for " << start << "-" << end << " finished.\n";
    return sum;
}

int main() {
    std::cout << "Main thread started (ID: " << std::this_thread::get_id() << ")\n";

    // std::async를 사용하여 calculate_sum 함수를 비동기적으로 실행
    // 기본 정책 (std::launch::async | std::launch::deferred)
    std::future<int> future_sum1 = std::async(calculate_sum, 1, 500);

    // 명시적으로 새로운 스레드에서 실행하도록 지정
    std::future<int> future_sum2 = std::async(std::launch::async, calculate_sum, 501, 1000);

    std::cout << "Main thread continues doing other work...\n";
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 메인 스레드에서 다른 작업 수행

    // future_sum1의 결과를 기다리고 가져옴
    std::cout << "Waiting for future_sum1 result...\n";
    int result1 = future_sum1.get(); // get()은 결과를 받을 때까지 블록
    std::cout << "Result 1: " << result1 << std::endl;

    // future_sum2의 결과를 기다리고 가져옴
    std::cout << "Waiting for future_sum2 result...\n";
    int result2 = future_sum2.get();
    std::cout << "Result 2: " << result2 << std::endl;

    std::cout << "Total sum: " << result1 + result2 << std::endl;
    std::cout << "Main thread finished.\n";

    // 중요: future 객체가 소멸될 때 (예: 스코프를 벗어날 때),
    // 만약 get() 또는 wait()가 호출되지 않았다면 암묵적으로 wait()가 호출되어
    // 비동기 작업이 완료될 때까지 블록됩니다.
    // 따라서 명시적으로 get()이나 wait()를 호출하여 제어하는 것이 좋습니다.

    return 0;
}

std::async는 내부적으로 스레드를 관리하거나 지연 실행을 선택함으로써, 개발자가 직접 std::thread를 관리하는 복잡성을 줄여줍니다.

std::future는 비동기 작업의 결과를 조회하는 인터페이스를 제공합니다.


std::promise

promisefuture는 값을 만드는 쪽과 기다리는 쪽을 분리합니다.

다음 그림은 그 단방향 전달 흐름을 보여줍니다.

promise와 future는 shared state로 값과 예외를 전달한다

SEQUENCE · SHARED STATE

promise와 future는 shared state로 값과 예외를 전달한다

생산자와 소비자는 서로를 직접 호출하지 않고 같은 shared state의 준비 상태를 통해 결과를 전달한다.

promise와 future는 shared state로 값과 예외를 전달한다 생산자가 promise를 통해 shared state를 값 또는 예외로 ready 상태로 만들고 future 소비자가 get으로 한 번 관찰한다. set_value / set_exception get() · 필요하면 대기 값 또는 예외 결과 한 번 소비 broken promise도 예외 PRODUCERpromise<T> STATEshared state CONSUMERfuture<T> POLICYcaller
  1. 생산자

    promise가 값 또는 exception_ptr을 설정한다.

  2. shared state

    결과가 ready가 될 때까지 상태를 보관한다.

  3. 소비자

    future.get이 값 또는 재던진 예외를 한 번 관찰한다.

  4. 실패

    결과 없이 promise가 사라지면 broken promise가 전달된다.

promise가 결과 없이 사라지면 broken promise가 되고 future.get은 저장된 예외를 소비자 위치에서 다시 던진다.

std::promisestd::future와 함께 사용되어, 한 스레드에서 결과를 약속(promise)하고, 다른 스레드에서 이 결과를 기다리는(future) 패턴을 구현합니다.

std::promisestd::future 객체와 연결되어 있으며, set_value(), set_exception() 등의 함수를 통해 future에 값을 설정하거나 예외를 전달할 수 있습니다.

  • std::promise
    • 어떤 값을 나중에 제공하겠다는 약속을 나타냅니다.
    • get_future()를 호출하여 자신과 연결된 std::future 객체를 얻습니다.
    • set_value(value): 연결된 future에 값을 설정합니다.
    • set_exception(exception_ptr): 연결된 future에 예외를 설정합니다.
std::promise와 std::future를 이용한 스레드 간 통신
#include <iostream>
#include <thread>
#include <future>   // std::promise, std::future를 위해
#include <string>
#include <chrono>

// 데이터를 준비하고 promise를 통해 전달하는 함수
void prepare_data(std::promise<std::string> prom) {
    std::cout << "Data preparer thread (ID: " << std::this_thread::get_id() << ") started.\n";
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 데이터 준비 시간 시뮬레이션

    std::string data = "Hello from async world!";
    std::cout << "Data preparer: Data prepared.\n";
    prom.set_value(data); // promise를 통해 값을 설정 (future로 전달됨)
    std::cout << "Data preparer: Value set in promise.\n";
}

// 데이터를 기다리고 future를 통해 받는 함수
void consume_data(std::future<std::string>& fut) {
    std::cout << "Data consumer thread (ID: " << std::this_thread::get_id() << ") started.\n";
    std::cout << "Data consumer: Waiting for data...\n";

    try {
        std::string received_data = fut.get(); // future의 get()을 통해 값 블로킹 대기
        std::cout << "Data consumer: Received data: '" << received_data << "'\n";
    } catch (const std::exception& e) {
        std::cerr << "Data consumer: Caught exception: " << e.what() << std::endl;
    }
}

int main() {
    std::cout << "Main thread started (ID: " << std::this_thread::get_id() << ")\n";

    // 1. promise 객체 생성
    std::promise<std::string> data_promise;

    // 2. promise에서 future 객체 얻기
    std::future<std::string> data_future = data_promise.get_future();

    // 3. producer 스레드는 promise를 받아 값을 설정
    std::thread producer_thread(prepare_data, std::move(data_promise)); // promise는 move-only

    // 4. consumer 스레드는 future를 받아 값을 기다림
    std::thread consumer_thread(consume_data, std::ref(data_future)); // future도 move-only이지만,
                                                                       // 여기서는 참조로 전달하여 소유권을 유지

    std::cout << "Main thread doing other tasks...\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));

    producer_thread.join();
    consumer_thread.join();

    std::cout << "Main thread finished.\n";
    return 0;
}

이 예시에서 prepare_data 함수는 std::promise를 통해 data를 전달하고, consume_data 함수는 std::future를 통해 그 data를 받습니다.

이는 뮤텍스나 조건 변수를 직접 사용하지 않고도 스레드 간에 안전하게 데이터를 전달하는 방법입니다.


std::packaged_task

std::packaged_task는 호출 가능한 객체(함수, 람다, 함수 객체)를 std::future와 연결하여 비동기적으로 실행할 수 있도록 래핑(wrap)하는 클래스 템플릿입니다.

std::async와 유사하지만, std::packaged_task는 스레드 생성 및 실행을 더욱 세밀하게 제어해야 할 때 유용합니다.

  • std::packaged_task
    • 생성자에서 실행할 함수(또는 람다 등)를 받습니다.
    • get_future()를 호출하여 연결된 std::future를 얻습니다.
    • 객체 자체가 함수 호출 연산자 ()를 오버로드하고 있어, 이를 호출하면 래핑된 함수가 실행되고 결과가 future에 저장됩니다.
std::packaged_task를 이용한 비동기 작업
#include <iostream>
#include <thread>
#include <future>   // std::packaged_task를 위해
#include <chrono>

int process_heavy_task(int value) {
    std::cout << "Processing heavy task with value " << value << " in thread: "
              << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(3));
    return value * 2;
}

int main() {
    std::cout << "Main thread started (ID: " << std::this_thread::get_id() << ")\n";

    // 1. packaged_task 객체 생성 (함수를 래핑)
    std::packaged_task<int(int)> task(process_heavy_task);

    // 2. task에서 future 객체 얻기
    std::future<int> result_future = task.get_future();

    // 3. task를 별도의 스레드에서 실행
    std::thread worker_thread(std::move(task), 10); // task는 move-only

    std::cout << "Main thread continues doing other things...\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));

    // 4. 결과를 기다리고 가져옴
    std::cout << "Main thread: Waiting for task result...\n";
    int result = result_future.get();
    std::cout << "Main thread: Task result: " << result << std::endl;

    worker_thread.join(); // worker_thread가 종료될 때까지 대기

    std::cout << "Main thread finished.\n";
    return 0;
}

std::packaged_taskstd::async보다 더 세밀한 제어를 제공하며, 특히 스레드 풀과 같은 사용자 정의 동시성 프레임워크를 구축할 때 유용합니다.

std::async, promise, packaged_task는 모두 future를 중심으로 결과를 전달하지만, 시작 시점과 실행 제어권이 다릅니다.

비동기 도구는 실행과 결과 확정 책임으로 고른다

FLOWCHART · ASYNC RESPONSIBILITY

비동기 도구는 실행과 결과 확정 책임으로 고른다

세 도구 모두 future를 만들 수 있지만 누가 작업을 시작하고 누가 결과를 ready로 만드는지가 다르다.

비동기 도구는 실행과 결과 확정 책임으로 고른다 호출 하나로 실행까지 맡길 때 async, 생산자가 값을 직접 정할 때 promise, callable 실행 위치를 제어할 때 packaged_task를 선택한다. QUESTION 누가 실행과 결과를 책임지는가? 시작 시점 · ready 시점 DELEGATE std::async 실행과 결과 연결을 함께 위임 PRODUCE std::promise 생산자가 값·예외 직접 확정 SCHEDULE packaged_task callable을 묶고 실행 위치 제어
  1. 실행까지 위임

    std::async로 callable과 future를 한 번에 연결한다.

  2. 값만 전달

    별도 실행기가 있고 생산자가 promise를 설정한다.

  3. 실행 위치 제어

    packaged_task를 queue나 선택한 thread에서 호출한다.

  4. 정책 확인

    default async는 deferred 가능성을 포함한다.

기본 async policy는 deferred일 수 있다. 병렬 실행이 계약이면 launch::async를 명시하고 blocking 위치를 관리한다.


비동기 프로그래밍 정리

  • std::async: 가장 간단하게 비동기 작업을 시작하고 결과를 std::future로 받을 수 있는 고수준 함수입니다. (내부적으로 스레드 관리 또는 지연 실행)
  • std::promise: 한 스레드에서 다른 스레드로 결과(또는 예외)를 전달할 때 사용합니다. std::future와 함께 작동합니다.
  • std::packaged_task: 호출 가능한 객체를 std::future와 연결하여, 사용자 정의 스레드에서 실행할 때 유용합니다.

이러한 비동기 프로그래밍 도구들은 저수준 스레딩 API보다 훨씬 안전하고 생산적입니다.

개발자는 동기화 프리미티브의 복잡성을 직접 다루는 대신, 작업의 병렬성 및 비동기적 흐름에 집중할 수 있게 됩니다.

비동기 프로그래밍은 std::async, future, promise, packaged_task가 결과 전달 책임을 어떻게 나누는지로 읽습니다.