비동기 프로그래밍
현대 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
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
: 가장 간단하게 비동기 작업을 시작하고 결과를std::future
로 받을 수 있는 고수준 함수입니다. (내부적으로 스레드 관리 또는 지연 실행)std::promise
: 한 스레드에서 다른 스레드로 결과(또는 예외)를 전달할 때 사용합니다.std::future
와 함께 작동합니다.std::packaged_task
: 호출 가능한 객체를std::future
와 연결하여, 사용자 정의 스레드에서 실행할 때 유용합니다.
이러한 비동기 프로그래밍 도구들은 저수준 스레딩 API보다 훨씬 안전하고 생산적입니다.
개발자는 동기화 프리미티브의 복잡성을 직접 다루는 대신, 작업의 병렬성 및 비동기적 흐름에 집중할 수 있게 됩니다.