C++ 并发设计模式:线程池、Future 与异步编程

C++11 的发布标志着现代 C++ 并发编程的开端。在此之前,开发者需要依赖平台特定的接口(如 POSIX pthreads 或 Windows API)来编写多线程程序,这不仅带来代码可移植性的问题,更使得并发逻辑与业务逻辑纠缠在一起。

C++11 的发布标志着现代 C++ 并发编程的开端。在此之前,开发者需要依赖平台特定的接口(如 POSIX pthreads 或 Windows API)来编写多线程程序,这不仅带来代码可移植性的问题,更使得并发逻辑与业务逻辑纠缠在一起。如今,C++ 标准库提供了丰富而统一的并发原语,从 std::threadstd::async,再到 C++20 的协程,构建了一套完整的异步编程范式体系。本文将系统梳理 C++ 中核心的并发设计模式,剖析其原理、使用场景与陷阱,并最终通过线程池实现并行计算框架,展示如何将这些工具组合成可复用的工程方案。

一、std::async:最简单的异步入口

std::async 是 C++11 引入的高层异步任务启动接口。它的核心思想是:将一段计算逻辑提交给系统执行,并立即返回一个 std::future,调用者可以在未来的某个时刻通过这个 future 获取计算结果。

启动策略

std::async 接受两个关键的启动策略:std::launch::asyncstd::launch::deferred

#include <future>
#include <iostream>

int compute() {
    return 42;
}

auto fut = std::async(std::launch::async, compute);

当指定 std::launch::async 时,函数保证在一个新线程中立即执行。而 std::launch::deferred 则表示惰性求值:只有在调用 future::get()future::wait() 时,任务才在当前线程同步执行。如果没有显式指定启动策略,标准允许实现选择其中之一,这意味着开发者无法精确控制线程行为。

auto fut_deferred = std::async(std::launch::deferred, compute);
// 此时 compute() 还未执行
int result = fut_deferred.get(); // 在这里才执行,阻塞当前线程

与手动创建线程的差异

相比于直接用 std::threadstd::async 的优势在于它将结果传递封装进了 std::future,省去了手动同步和返回值传递的麻烦。然而,其默认策略的不确定性是最大隐患:如果程序假设任务是异步执行的,但实际被延迟到 get() 时才运行,可能在高并发场景下意外阻塞主线程,破坏响应性。

陷阱:延迟任务的阻塞效应

auto f1 = std::async([] { return heavy_task(); });
auto f2 = std::async([] { return heavy_task(); });
// 未指定 launch policy,可能均为 deferred
f1.get(); // 如果 deferred,则在此处执行 task1,完全串行
f2.get(); // 同上

因此,对于真正需要并行的任务,务必显式声明 std::launch::async

二、std::future 与 std::promise

如果说 future 是结果的"接收端",那么 promise 就是结果的"发送端"。两者通过共享状态关联,构成了一对典型的产生者-消费者通信通道。

Promise 的基本用法

std::promise<int> p;
std::future<int> f = p.get_future();

std::thread t([&p] {
    try {
        int result = some_computation();
        p.set_value(result);
    } catch (...) {
        p.set_exception(std::current_exception());
    }
});

try {
    int value = f.get(); // 阻塞直至结果就绪
} catch (const std::exception& e) {
    // 捕获从承诺端传递来的异常
}
t.join();

通过 set_valueset_exceptionpromise 可以将正常结果或异常跨线程传递给 futurefuture::get() 的调用是阻塞的——如果结果尚未就绪,调用线程将挂起。若需要非阻塞检查,应使用 wait_for

if (f.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
    // 结果已就绪
}

std::shared_future

标准 std::future 是一次性的:只能被 get() 一次。如果有多个消费者需要读取同一结果,应使用 std::shared_future

std::shared_future<int> sf = p.get_future().share();
// sf 可以被多个线程安全地 get()

何时直接使用 promise

promise 的典型应用场景是一次性事件通知。例如,一个后台线程完成初始化后通知主线程继续:

std::promise<void> ready;
auto init_future = ready.get_future();

std::thread worker([&ready] {
    initialize_resources();
    ready.set_value();
});

init_future.wait(); // 等待通知

三、std::packaged_task

packaged_task 将任何可调用对象包装成一个"任务包":这个包可以直接调用,返回值或异常会自动存入关联的 future 中。它完美适配线程池的工作模式。

#include <packaged_task>

std::packaged_task<int(int, int)> task([](int a, int b) {
    return a + b;
});

std::future<int> result = task.get_future();

std::thread t(std::move(task), 10, 20);
int value = result.get(); // 30
t.join();

注意关键细节:packaged_task 不可复制,只能移动。这意味着它天然支持从一个线程安全地转移到线程池的工作线程上执行。

在线程池中,packaged_task 常被包装在通用函数对象中存入任务队列:

std::queue<std::packaged_task<void()>> tasks;
// 配合 std::function 实现类型擦除

四、线程池的设计与实现

为何需要线程池

线程创建和销毁是昂贵的操作系统级操作,涉及内存分配、内核对象创建和上下文切换。对于大量短生命周期任务,反复创建线程会导致严重的性能开销。线程池的核心思想是预先创建一组工作线程并复用它们,将任务提交的开销降低到入队操作的水平。

固定大小线程池的完整实现

以下是一个基于 C++11 的标准线程池实现:

#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <functional>
#include <stdexcept>

class ThreadPool {
public:
    explicit ThreadPool(size_t threads) : stop(false) {
        for (size_t i = 0; i < threads; ++i) {
            workers.emplace_back([this] {
                for (;;) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(this->queue_mutex);
                        this->condition.wait(lock, [this] {
                            return this->stop || !this->tasks.empty();
                        });
                        if (this->stop && this->tasks.empty()) return;
                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }
                    task();
                }
            });
        }
    }

    template<class F, class... Args>
    auto enqueue(F&& f, Args&&... args)
        -> std::future<typename std::invoke_result_t<F, Args...>> {
        using return_type = typename std::invoke_result_t<F, Args...>;

        auto task = std::make_shared<std::packaged_task<return_type()>>(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );

        std::future<return_type> res = task->get_future();
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            if (stop) throw std::runtime_error("enqueue on stopped ThreadPool");

            tasks.emplace([task]() { (*task)(); });
        }
        condition.notify_one();
        return res;
    }

    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            stop = true;
        }
        condition.notify_all();
        for (std::thread& worker : workers) {
            worker.join();
        }
    }

private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queue_mutex;
    std::condition_variable condition;
    bool stop;
};

设计要点解析

任务队列与任务类型擦除:线程池需要接收任意签名的可调用对象,因此使用 std::packaged_task 将任务转换为无参数、无返回值的统一形式,再通过 std::function<void()> 存入队列,实现类型擦除。

同步机制:使用 std::mutex 保护任务队列,std::condition_variable 使工作线程在无任务时休眠,避免忙等待。

优雅关闭:析构函数先将 stop 标志置为真,再唤醒所有线程检查退出条件。每个线程在队列为空时安全退出,随后通过 join() 等待其完成——这是防止资源泄漏的关键。

工作窃取概念

固定大小线程池架构简单,但当任务长度不等时易出现负载不均。工作窃取(Work-Stealing)线程池为每个线程维护双端队列(deque),空闲线程从其他线程的尾部"窃取"任务来平衡负载。Intel TBB 和 C++17 的并行算法底层均采用此策略。实现工作窃取需要无锁数据结构支持,复杂度显著高于固定池,但在大量不规则并行任务场景下性能更优。

五、C++20 std::jthread

std::jthread 解决了 std::thread 最大的易用性问题:生命周期管理。

#include <thread>

void legacy_thread() {
    std::thread t([] { /* work */ });
    t.join(); // 忘记 join 或 detach 会终止程序!
}

void modern_thread() {
    std::jthread t([] { /* work */ });
    // 析构时自动 join,无需手动管理
}

协作式取消模型

jthread 的另一重大改进是内置的取消机制。它接受一个 std::stop_token 参数,允许外部请求线程优雅退出:

void cancellable_task(std::stop_token stoken) {
    while (!stoken.stop_requested()) {
        // 执行工作,定期检查停止请求
        do_work();
    }
}

std::jthread t(cancellable_task);
// ... 一段时间后
if (need_shutdown) {
    t.request_stop(); // 请求停止,不强制中断
}

底层通过 std::stop_sourcestd::stop_token 实现多对多的取消传播。与强制终止线程相比,协作式取消保证了资源的安全释放,避免了数据不一致。

与 legacy std::thread 对比

特性std::threadstd::jthread
自动 join
协作取消内置
中断线程不支持request_stop()
适用标准C++11+C++20+

六、C++20 协程:异步 IO 的未来

为何需要协程

传统的基于回调的异步编程(如 Asio 库的 async_read 回调链)会导致"回调地狱"——控制流被隐藏在层层嵌套的 lambda 中,代码难以阅读和维护。futureasync 虽有所改进,但仍然无法写出像同步代码一样线性的异步逻辑。

协程的核心突破在于:co_await 可以在不阻塞线程的情况下挂起函数执行,等待 IO 就绪后再恢复,而写出的代码看起来完全像同步顺序结构。

核心关键字

task<int> fetch_and_process() {
    auto data = co_await async_read(socket); // 挂起,不阻塞线程
    co_yield partial_result(data);           // 向调用者产出中间值
    auto result = co_await process(data);
    co_return result;
}

co_await 的挂起/恢复机制由编译器和用户自定义的 awaitable 类型协同实现。它不需要额外线程——所有状态保存在堆分配的协程帧中,事件循环可以在 IO 就绪后通过句柄恢复执行。

简单自定义任务类型

template<typename T>
struct Task {
    struct promise_type {
        T value;
        auto get_return_object() { return Task{*this}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_value(T v) { value = std::move(v); }
        void unhandled_exception() { std::terminate(); }
    };

    using Handle = std::coroutine_handle<promise_type>;
    Handle handle;
    explicit Task(promise_type& p) : handle(Handle::from_promise(p)) {}
    ~Task() { if (handle) handle.destroy(); }

    T get() {
        if (!handle.done()) handle.resume();
        return handle.promise().value;
    }
};

与 Future 和回调的对比

范式代码风格上下文切换代价适用规模
Callback嵌套混乱小规模 IO
Future显式链条中等并发
Coroutine看起来像同步极低大规模异步

局限性

C++20 协程引入了语言级支持,但标准库并未提供通用的协程类型(如 Rust 的 Future 或 C# 的 Task)。开发者需要自行实现 promise_type、调度器和事件循环,或者依赖第三方库如 cppcoro。C++23 的 std::generator 和即将到来的网络库正在逐步填补这一空白。

七、并发模式对比总结

模式开销灵活性典型场景
std::async中等(每次创建线程)简单的单次异步计算
线程池低(均摊创建开销)大量同质任务的后台处理
packaged_task极低(仅封装)需要与 future 集成的自定义调度
协程极低(用户态切换)大规模异步 IO、高并发服务器

对于面向 IO 密集型应用(如网络服务),协程的方向是正确的;对于 CPU 密集型计算,线程池仍然是成熟稳定的首选。std::async 只适用于功能原型或极简单的单次并发场景,不应出现在高并发生产代码的核心路径上。

八、实战:并行 MapReduce

以下展示如何利用线程池和 std::async 两种方案实现对 std::vector 的并行变换(parallel map):

基于线程池的实现

template<typename T, typename Func>
std::vector<T> parallel_map(std::vector<T>& data, Func f, ThreadPool& pool) {
    const size_t n = data.size();
    if (n == 0) return {};

    const size_t num_threads = std::thread::hardware_concurrency();
    const size_t block_size = (n + num_threads - 1) / num_threads;

    std::vector<std::future<void>> futures;
    std::vector<T> result(n);

    for (size_t i = 0; i < n; i += block_size) {
        size_t end = std::min(i + block_size, n);
        futures.push_back(pool.enqueue([&, i, end]() {
            for (size_t j = i; j < end; ++j) {
                result[j] = f(data[j]);
            }
        }));
    }

    for (auto& fut : futures) fut.wait();
    return result;
}

基于 std::async 的分治实现

template<typename It, typename Func>
auto parallel_map_dc(It beg, It end, Func f) -> std::vector<decltype(f(*beg))> {
    using T = decltype(f(*beg));
    size_t len = std::distance(beg, end);
    if (len == 0) return {};
    if (len <= 1000) {
        std::vector<T> result(len);
        std::transform(beg, end, result.begin(), f);
        return result;
    }

    It mid = std::next(beg, len / 2);
    auto fut = std::async(std::launch::async,
        parallel_map_dc<It, Func>, mid, end, f);

    auto left = parallel_map_dc(beg, mid, f);
    auto right = fut.get();
    left.insert(left.end(), right.begin(), right.end());
    return left;
}

对于数量固定、执行时间均匀的计算任务,线程池方案由于避免了递归和任务创建的额外开销,实际性能通常优于 std::async 分治。而 std::async 分治更适合任务量动态变化、无法预估完成时间的场景。

结语

C++ 的并发编程设施从 C++11 的 threadfuture 起步,经 C++17 的并行算法演进,到 C++20 协程引入了全新的异步编程思维。理解这些工具的适用边界,远比掌握单个 API 更重要:std::async 适合原型验证,线程池是生产环境的主力军,而协程正逐步成为高并发 IO 系统的标配。掌握它们之间的互补关系,才能在面对具体工程问题时,做出最合理的技术选型。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. 模板元编程与编译期计算:TMP 实战指南
  2. STL 算法与迭代器:从 for_each 到并行执行策略
  3. STL 容器全解析与源码剖析