icon
15장 : 멀티스레딩 기초

비동기 프로그래밍

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

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

이번 장에서는 C++의 표준 비동기 프로그래밍 도구인 std::asyncstd::futurestd::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

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

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

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