비동기 프로그래밍
현대 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::async와 std::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::future의get()또는wait()가 호출될 때까지 실행을 지연시킵니다 (동기적으로 실행).std::launch::async | std::launch::deferred(기본값): 컴파일러/런타임이 최적의 실행 방식을 선택하도록 합니다.
-
std::future- 비동기적으로 실행되는 함수의 결과(또는 예외)에 대한 핸들(handle) 역할을 합니다.
get()멤버 함수를 호출하여 비동기 작업의 결과를 블로킹 방식으로 가져올 수 있습니다. 결과가 아직 준비되지 않았다면,get()은 준비될 때까지 호출 스레드를 블록합니다.wait()멤버 함수를 호출하여 결과가 준비될 때까지 블록하지만, 결과를 반환하지는 않습니다.wait_for(),wait_until()을 사용하여 시간 제한을 두고 대기할 수 있습니다.std::future는move-only타입입니다 (복사 불가, 이동 가능).
#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
promise와 future는 값을 만드는 쪽과 기다리는 쪽을 분리합니다. 다음 그림은 그 단방향 전달 흐름을 보여줍니다.
std::promise는 std::future와 함께 사용되어, 한 스레드에서 결과를 약속(promise)하고, 다른 스레드에서 이 결과를 기다리는(future) 패턴을 구현합니다.
std::promise는 std::future 객체와 연결되어 있으며, set_value(), set_exception() 등의 함수를 통해 future에 값을 설정하거나 예외를 전달할 수 있습니다.
std::promise- 어떤 값을 나중에 제공하겠다는 약속을 나타냅니다.
get_future()를 호출하여 자신과 연결된std::future객체를 얻습니다.set_value(value): 연결된future에 값을 설정합니다.set_exception(exception_ptr): 연결된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에 저장됩니다.
#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_task는 std::async보다 더 세밀한 제어를 제공하며, 특히 스레드 풀과 같은 사용자 정의 동시성 프레임워크를 구축할 때 유용합니다.
std::async, promise, packaged_task는 모두 future를 중심으로 결과를 전달하지만, 시작 시점과 실행 제어권이 다릅니다.
비동기 프로그래밍 정리
std::async: 가장 간단하게 비동기 작업을 시작하고 결과를std::future로 받을 수 있는 고수준 함수입니다. (내부적으로 스레드 관리 또는 지연 실행)std::promise: 한 스레드에서 다른 스레드로 결과(또는 예외)를 전달할 때 사용합니다.std::future와 함께 작동합니다.std::packaged_task: 호출 가능한 객체를std::future와 연결하여, 사용자 정의 스레드에서 실행할 때 유용합니다.
이러한 비동기 프로그래밍 도구들은 저수준 스레딩 API보다 훨씬 안전하고 생산적입니다.
개발자는 동기화 프리미티브의 복잡성을 직접 다루는 대신, 작업의 병렬성 및 비동기적 흐름에 집중할 수 있게 됩니다.
비동기 프로그래밍은 std::async, future, promise, packaged_task가 결과 전달 책임을 어떻게 나누는지로 읽습니다.