C++ std::packaged_task: 작업 큐와 스레드 풀에서 future로 결과·예외 받기

이 글의 핵심

std::async는 편하지만 언제 어느 스레드에서 실행될지 직접 정할 수 없어, 작업 큐나 스레드 풀을 만들 때는 packaged_task가 필요합니다. 대신 태스크가 이동 전용이라 복사하려 하면 컴파일 에러가 나고, get_future()를 두 번 호출하거나 태스크를 실행하지 않으면 예외나 무한 대기가 생깁니다. promise, async와의 관계와 타임아웃, 취소 패턴, 에러 처리 전략까지 정리했습니다.

packaged_task란?

std::packaged_task 는 함수나 호출 가능 객체를 래핑하여 std::future로 결과를 받을 수 있게 하는 C++11 기능입니다. std::async와 달리 수동으로 실행 시점을 제어할 수 있어, 작업 큐나 스레드 풀에서 유용합니다.

#include <future>

std::packaged_task<int(int)> task([](int x) {
    return x * x;
});

std::future<int> future = task.get_future();
task(10);  // 실행

int result = future.get();  // 100

packaged_task를 이해하는 가장 쉬운 방법은 “함수 + std::promise를 한 객체로 묶은 것”으로 보는 것입니다. 태스크를 호출하면 안에 든 함수를 실행하고, 반환값이 나오면 set_value, 예외가 나오면 set_exception을 대신 해 줍니다. 템플릿 인자가 int(int)처럼 함수 시그니처 형태인 이유도 여기에 있습니다. 앞쪽 int는 future가 받을 결과 타입이고, 괄호 안은 태스크를 호출할 때 넘길 인자입니다. 태스크 호출 자체는 void를 반환하므로 결과는 반드시 future로만 꺼낼 수 있습니다.

왜 필요한가?:

  • 실행 제어: 언제 실행할지 직접 결정
  • 작업 큐: 작업을 큐에 저장 후 나중에 실행
  • 스레드 풀: 워커 스레드에 작업 분배
  • 예외 전파: 예외를 future로 전달
// std::async: 즉시 실행 (또는 지연)
auto f1 = std::async([] { return 42; });

// packaged_task: 수동 실행
std::packaged_task<int()> task([] { return 42; });
auto f2 = task.get_future();
// 원하는 시점에 실행
task();

기본 사용

// 함수 시그니처 지정
std::packaged_task<int(int, int)> task([](int a, int b) {
    return a + b;
});

auto future = task.get_future();
task(3, 4);  // 실행
int result = future.get();  // 7

실전 예시

예시 1: 스레드에서 실행

#include <thread>
#include <future>

int compute(int x) {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return x * x;
}

int main() {
    std::packaged_task<int(int)> task(compute);
    std::future<int> future = task.get_future();
    
    std::thread t(std::move(task), 10);
    
    std::cout << "계산 중..." << std::endl;
    int result = future.get();
    std::cout << "결과: " << result << std::endl;
    
    t.join();
}

std::thread에 태스크를 넘길 때는 std::move가 필수입니다. 스레드 생성자는 인자를 복사해 새 스레드로 옮기는데, packaged_task는 복사할 수 없으므로 std::thread t(task, 10);이라고 쓰면 use of deleted function 'std::packaged_task<...>::packaged_task(const std::packaged_task<...>&)' 계열의 긴 컴파일 에러가 납니다. future.get()이 결과를 기다려 주므로 join()을 get() 뒤에 둬도 안전하지만, join() 자체를 빠뜨리면 std::thread 소멸자가 std::terminate를 호출해 프로그램이 죽는다는 점은 packaged_task와 무관하게 여전히 적용됩니다.

예시 2: 작업 큐

#include <queue>
#include <mutex>

class TaskQueue {
    std::queue<std::packaged_task<void()>> tasks;
    std::mutex mtx;
    
public:
    template<typename F>
    auto enqueue(F&& f) -> std::future<decltype(f())> {
        using ReturnType = decltype(f());
        
        std::packaged_task<ReturnType()> task(std::forward<F>(f));
        auto future = task.get_future();
        
        {
            std::lock_guard<std::mutex> lock(mtx);
            tasks.emplace(std::move(task));  // push는 explicit 생성자 때문에 컴파일 안 됨
        }
        
        return future;
    }
    
    void process() {
        std::packaged_task<void()> task;
        
        {
            std::lock_guard<std::mutex> lock(mtx);
            if (tasks.empty()) return;
            
            task = std::move(tasks.front());
            tasks.pop();
        }
        
        task();
    }
};

이 큐의 핵심 트릭은 반환 타입이 제각각인 태스크를 std::packaged_task<void()> 하나의 타입으로 저장한다는 점입니다. packaged_task<int()>도 인자 없이 호출할 수 있는 객체이므로, 이를 다시 packaged_task<void()>로 감쌀 수 있습니다. 바깥 태스크를 실행하면 안쪽 태스크가 실행되어 enqueue가 돌려준 원래 future에 결과가 채워지고, 바깥 태스크의 future(void)는 아무도 받지 않으므로 버려집니다.

처음 이 코드를 쓸 때 흔히 tasks.push(std::move(task))라고 적었다가 no matching function for call to 'std::queue<std::packaged_task<void()>>::push(...)' 에러를 만나게 됩니다. 호출 객체로 packaged_task를 만드는 생성자가 explicit이라 push가 요구하는 암시적 변환이 허용되지 않기 때문입니다. emplace는 큐 안에서 직접 생성하므로 explicit 생성자도 쓸 수 있습니다(GCC 10에서 두 경우를 모두 확인했습니다). 감싸는 비용이 부담된다면 std::shared_ptr<std::packaged_task<R()>>를 캡처한 std::function<void()> 람다를 큐에 넣는 방식도 널리 쓰입니다.

락을 잡은 범위에서 task()를 호출하지 않는 것도 중요합니다. 위 코드처럼 큐에서 꺼내는 순간까지만 락을 잡고, 실제 실행은 락 밖에서 합니다. 락을 쥔 채 실행하면 작업이 오래 걸리는 동안 다른 스레드가 enqueue조차 못 하고, 작업 안에서 다시 enqueue를 호출하면 같은 뮤텍스를 두 번 잠그려다 교착 상태에 빠집니다.

예시 3: 예외 처리

std::packaged_task<int()> task([] {
    throw std::runtime_error("에러");
    return 42;
});

auto future = task.get_future();
task();

try {
    int result = future.get();  // 예외 재던지기
} catch (const std::exception& e) {
    std::cout << "예외: " << e.what() << std::endl;
}

예시 4: 재사용

std::packaged_task<int(int)> task([](int x) {
    return x * 2;
});

auto f1 = task.get_future();
task(10);
int r1 = f1.get();  // 20

// ❌ 그대로 다시 호출하면
// task(20);  // std::future_error: Promise already satisfied

// ✅ reset()으로 새 공유 상태를 만든 뒤 재실행
task.reset();
auto f2 = task.get_future();
task(20);
int r2 = f2.get();  // 40

reset()은 같은 함수를 유지한 채 공유 상태만 새로 만듭니다. 그래서 reset() 뒤에는 반드시 get_future()를 다시 받아야 하고, 이전에 받은 f1은 첫 결과(20)를 그대로 갖습니다. 매번 새 태스크 객체를 만드는 것과 효과는 같지만, 함수가 무거운 상태를 캡처하고 있을 때 그 상태를 다시 만들지 않아도 된다는 장점이 있습니다.

async vs packaged_task

// std::async: 자동 실행
auto f1 = std::async([] { return 42; });

// packaged_task: 수동 실행
std::packaged_task<int()> task([] { return 42; });
auto f2 = task.get_future();
task();  // 명시적 실행

비교표:

특징std::asyncstd::packaged_task
실행 시점자동 (즉시 또는 지연)수동 (명시적 호출)
스레드 생성자동수동
사용 편의성간단복잡
제어 수준낮음높음
주 용도간단한 비동기 작업작업 큐, 스레드 풀

실무 선택 가이드:

int expensiveComputation();  // 어딘가에 정의되어 있다고 가정

// ✅ std::async 사용
// - 간단한 비동기 작업
// - 스레드 관리 불필요
auto result = std::async([] {
    return expensiveComputation();
});

// ✅ packaged_task 사용
// - 작업 큐에 저장
// - 실행 시점 제어
// - 스레드 풀 구현
std::packaged_task<int()> task(expensiveComputation);
taskQueue.push(std::move(task));
// 나중에 워커 스레드가 실행

자주 발생하는 문제

문제 1: 실행 누락

packaged_task는 std::async와 달리 스스로 실행되지 않습니다. get_future()로 받은 future는 누군가 task()를 호출해야 값이 채워지므로, 태스크를 큐에 넣기만 하고 실행 경로를 빠뜨리면 future.get()이 영원히 블록됩니다. 반대로 태스크가 한 번도 실행되지 않은 채 소멸되면 future 쪽에는 std::future_error(broken_promise)가 전달됩니다.

std::packaged_task<int()> task([] { return 42; });
auto future = task.get_future();

// ❌ task 실행 안함
// int result = future.get();  // 영원히 대기

// ✅ task 실행
task();
int result = future.get();

문제 2: 이동 전용

packaged_task는 내부에 공유 상태(shared state)를 하나만 가지므로 복사할 수 없고 이동만 됩니다. 그래서 복사 가능한 호출 객체를 요구하는 std::function에는 C++23의 std::move_only_function 전까지 직접 담을 수 없고, 스레드 풀 큐에 넣을 때는 std::shared_ptr<std::packaged_task<...>>로 감싸 람다에서 캡처하는 방식을 흔히 씁니다.

std::packaged_task<int()> task([] { return 42; });

// ❌ 복사 불가
// auto task2 = task;

// ✅ 이동
auto task2 = std::move(task);

문제 3: get_future 여러 번

get_future()는 태스크당 한 번만 호출할 수 있고, 두 번째 호출은 std::future_error(future_already_retrieved)를 던집니다. 결과를 여러 곳에서 기다려야 한다면 받은 future를 .share()로 std::shared_future로 바꿔 나눠 주세요.

std::packaged_task<int()> task([] { return 42; });

auto f1 = task.get_future();
// auto f2 = task.get_future();  // 에러

// get_future는 한 번만

문제 4: 스레드 이동

다른 스레드에서 실행하려면 get_future()를 먼저 호출해 future를 받아 둔 뒤 태스크를 std::move로 넘겨야 합니다. 이동한 뒤의 원본 task는 공유 상태가 없는 빈 객체라, 그때 get_future()를 부르면 no_state 에러가 납니다.

std::packaged_task<int()> task([] { return 42; });
auto future = task.get_future();

// ✅ 이동으로 전달
std::thread t(std::move(task));
t.join();

int result = future.get();

실무 패턴

패턴 1: 간단한 스레드 풀

#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>

class ThreadPool {
    std::vector<std::thread> workers_;
    std::queue<std::packaged_task<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;
    
public:
    ThreadPool(size_t numThreads) {
        for (size_t i = 0; i < numThreads; ++i) {
            workers_.emplace_back([this]() {
                while (true) {
                    std::packaged_task<void()> task;
                    
                    {
                        std::unique_lock<std::mutex> lock(mtx_);
                        cv_.wait(lock, [this]() { 
                            return stop_ || !tasks_.empty(); 
                        });
                        
                        if (stop_ && tasks_.empty()) return;
                        
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    
                    task();
                }
            });
        }
    }
    
    template<typename F>
    auto submit(F&& f) -> std::future<decltype(f())> {
        using ReturnType = decltype(f());
        
        std::packaged_task<ReturnType()> task(std::forward<F>(f));
        auto future = task.get_future();
        
        {
            std::lock_guard<std::mutex> lock(mtx_);
            tasks_.emplace(std::move(task));  // packaged_task<R()>를 packaged_task<void()>로 감쌈
        }
        
        cv_.notify_one();
        return future;
    }
    
    ~ThreadPool() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            stop_ = true;
        }
        cv_.notify_all();
        for (auto& worker : workers_) {
            worker.join();
        }
    }
};

// 사용
ThreadPool pool(4);
auto f1 = pool.submit([] { return 42; });
auto f2 = pool.submit([] { return 100; });

std::cout << f1.get() + f2.get() << '\n';  // 142

submit에서 notify_one()을 락 밖에서 호출하는 것은 깨어난 워커가 곧바로 락을 다시 기다리는 헛걸음을 줄이기 위한 관례입니다. 소멸자는 stop_을 세운 뒤 모든 워커를 깨우고, 워커는 “멈춤 요청이 있고 큐가 비었을 때”만 종료하므로 이미 제출된 작업은 끝까지 처리됩니다. 이 설계 덕분에 풀이 파괴될 때 받아 둔 future가 broken_promise로 끝나는 일은 없습니다.

이 풀에는 알아 둘 한계가 있습니다. 먼저 submit이 인자 없는 호출 객체만 받으므로 인자가 필요하면 람다로 감싸야 합니다. 또 작업이 같은 풀에 하위 작업을 제출하고 그 결과를 get()으로 기다리면, 모든 워커가 하위 작업을 기다리느라 멈춰 있고 정작 하위 작업을 실행할 워커가 남지 않는 교착이 생깁니다. 워커가 4개인 풀에서 이런 작업이 동시에 4개 들어오는 순간 프로그램 전체가 멈추는데, CPU 사용률은 0%이고 예외도 나지 않아서 원인을 찾기가 매우 어렵습니다. 작업 안에서 같은 풀의 결과를 동기적으로 기다리지 않는 것이 가장 확실한 예방책입니다. 마지막으로 소멸 후 submit을 막는 장치가 없으므로, 종료 중에 제출된 작업은 실행되지 않은 채 큐와 함께 파괴되어 future가 broken_promise를 받습니다.

패턴 2: 타임아웃 작업

template<typename F>
auto runWithTimeout(F&& f, std::chrono::milliseconds timeout) 
    -> std::optional<decltype(f())> {
    
    std::packaged_task<decltype(f())()> task(std::forward<F>(f));
    auto future = task.get_future();
    
    std::thread t(std::move(task));
    t.detach();
    
    if (future.wait_for(timeout) == std::future_status::ready) {
        return future.get();
    }
    
    return std::nullopt;  // 타임아웃
}

// 사용
auto result = runWithTimeout([] {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return 42;
}, std::chrono::seconds(1));

if (result) {
    std::cout << "결과: " << *result << '\n';
} else {
    std::cout << "타임아웃\n";
}

이 패턴은 “기다리는 쪽”의 타임아웃일 뿐, 작업을 멈추지 않습니다. 타임아웃이 나도 분리된(detached) 스레드는 2초 동안 계속 돌고, C++ 표준에는 실행 중인 스레드를 밖에서 강제로 멈추는 방법이 없습니다. 여기서 std::async 대신 packaged_task를 쓴 이유도 이것입니다. std::async가 돌려준 future는 소멸자에서 작업이 끝날 때까지 블록하므로, 함수를 빠져나가는 순간 타임아웃이 무의미해집니다. packaged_task의 future는 소멸자에서 기다리지 않습니다. 다만 타임아웃 호출이 잦으면 끝나지 않은 스레드가 계속 쌓이고, 작업이 참조로 캡처한 지역 변수는 이미 사라졌을 수 있으므로 작업에는 값으로 캡처한 데이터만 넘겨야 합니다.

패턴 3: 작업 취소

class CancellableTask {
    std::packaged_task<int()> task_;
    std::atomic<bool> cancelled_{false};
    
public:
    CancellableTask(std::function<int()> f) 
        : task_([this, f]() {
            if (cancelled_) {
                throw std::runtime_error("Cancelled");
            }
            return f();
        }) {}
    
    std::future<int> getFuture() {
        return task_.get_future();
    }
    
    void run() {
        task_();
    }
    
    void cancel() {
        cancelled_ = true;
    }
};

이 취소는 시작 전 취소만 지원합니다. 플래그는 작업이 시작될 때 한 번만 확인되므로, 이미 f()가 실행 중이라면 cancel()은 아무 효과가 없습니다. 오래 걸리는 작업을 중간에 멈추려면 작업 본체가 루프 안에서 주기적으로 플래그를 확인해야 하며, C++20이라면 std::jthread와 std::stop_token이 이 협력적 취소를 표준 방식으로 제공합니다. 또 생성자에서 this를 캡처하므로 이 객체는 이동하면 안 됩니다. std::atomic 멤버 덕분에 이동 생성자가 자동 생성되지 않아 실수로 이동하는 코드는 컴파일되지 않지만, 객체보다 태스크가 오래 살게 만드는 구조(태스크만 꺼내 다른 스레드로 넘기는 등)는 여전히 댕글링 this를 만듭니다.

std::promise / std::future와의 관계

비동기 결과를 연결하는 표준 구성은 크게 세 가지입니다.

구성 요소역할
std::promise수동으로 값/예외를 future 쪽에 설정 (set_value, set_exception)
std::packaged_task호출 가능 객체 한 번 실행의 결과를 자동으로 연결된 future에 기록
std::async함수 실행과 스레딩 정책을 묶은 편의 API (구현에 따라 스레드 풀 재사용 여부는 비표준)

packaged_task는 내부적으로 공유 상태를 두고 get_future()로 소비자 쪽 future를 내줍니다. 반면 promise는 생산자가 직접 “아직 계산 중”인 값을 채워 넣을 때 사용됩니다. 작업 큐에서는 “실행 본체”를 packaged_task로 감싸 두면, 워커가 operator()만 호출하면 되므로 연결 코드가 짧아집니다.

비동기 작업 패턴 정리

  • fire-and-forget: 결과가 필요 없으면 std::thread + 조인 정책만 정하고 끝낼 수 있지만, 예외 전파가 어렵습니다. 결과·오류를 상위로 올리려면 packaged_task/async/promise 중 하나를 씁니다.
  • 결과 필수: future.get() 한 번으로 값 또는 예외를 받습니다. 여러 번 구독하려면 shared_future를 고려합니다.
  • 백프레셔·큐 길이 제한: 생산자가 submit만 하고 소비가 못 따라가면 메모리가 불어납니다. 큐 상한, 블로킹 큐, 또는 거부 정책을 함께 설계합니다.

실전 예제 보강: 에러 처리 전략

  • future.get(): 작업 안에서 던진 예외는 저장되었다가 get() 시점에 재던집니다. 따라서 호출 스레드에서 try/catch를 두는 것이 일반적입니다.
  • 타임아웃: wait_for / wait_until로 무한 대기를 피하며, 실패 시 로깅·재시도·취소 플래그를 택합니다. 위 runWithTimeout은 데모이며, 실제로는 취소 협력(주기적 플래그 확인)이 없으면 스레드가 계속 돌 수 있어 프로덕션에서는 주의가 필요합니다.
  • std::current_exception: 저수준에서 promise에 예외를 넣을 때 유용하지만, 대부분은 packaged_task가 자동 처리하면 충분합니다.
std::packaged_task<int()> task([] {
    if (!validateInput()) {
        throw std::invalid_argument("bad input");
    }
    return compute();
});
auto fut = task.get_future();
std::thread(std::move(task)).detach();

try {
    use(fut.get());
} catch (const std::exception& e) {
    log_error(e.what());
}

FAQ

Q1: std::async와 차이는?

A:

  • std::async: 자동 실행 (즉시 또는 지연), 스레드 자동 생성
  • packaged_task: 수동 실행, 스레드 수동 생성
// async: 간단
auto f = std::async(compute);

// packaged_task: 제어
std::packaged_task<int()> task(compute);
auto f = task.get_future();
std::thread t(std::move(task));
t.join();

Q2: packaged_task를 재사용할 수 있나요?

A: 그대로 다시 호출하면 std::future_error(promise_already_satisfied)가 납니다. reset()을 호출해 새 공유 상태를 만들고 get_future()를 다시 받으면 같은 함수로 재실행할 수 있습니다.

std::packaged_task<int()> task([] { return 42; });
task();
// task();  // std::future_error

task.reset();
auto f = task.get_future();
task();  // OK

Q3: packaged_task는 복사할 수 있나요?

A: 불가능합니다. 이동만 가능합니다.

std::packaged_task<int()> task1([] { return 42; });
// auto task2 = task1;  // 에러
auto task2 = std::move(task1);  // OK

Q4: get_future()를 여러 번 호출할 수 있나요?

A: 불가능합니다. get_future()는 한 번만 호출할 수 있습니다.

std::packaged_task<int()> task([] { return 42; });
auto f1 = task.get_future();
// auto f2 = task.get_future();  // 에러

Q5: 예외는 어떻게 처리되나요?

A: 작업 실행 중 발생한 예외는 future에 저장되며, future.get() 호출 시 재던져집니다.

std::packaged_task<int()> task([] {
    throw std::runtime_error("Error");
    return 42;
});

auto f = task.get_future();
task();

try {
    f.get();  // 예외 재던지기
} catch (const std::exception& e) {
    std::cout << e.what() << '\n';
}

더 자세한 명세는 cppreference의 std::packaged_task 문서를 참고하면 됩니다.

packaged_task는 함수를 래핑하여 future로 결과를 받을 수 있게 하며, 수동 실행 제어가 가능합니다.


같이 보면 좋은 글