欢迎光临
我们一直在努力

【C++多线程】std::async 深度剖析:启动策略、线程复用与异常陷阱

上一篇我们剖析了 future/promise/packaged_task 的底层机制。std::async 是建立在它们之上的更高层抽象——一行代码即可创建异步任务并获取 future。但 std::async 也是 C++ 标准库中最容易被误解和误用的组件之一:它的启动策略行为微妙、返回 future 的析构有隐式 join 语义、与线程池的性能差异巨大。本文将剖析 std::async 的两种启动策略、底层实现、异常传播机制,以及在实际工程中的适用边界。


一、问题引入:std::async 的"魔法"与陷阱

1.1 看似简单的异步

#include <future>

int compute(int x) {
// 耗时计算
return x * 2;
}

int main() {
// 一行代码:异步执行 compute,获取 future
std::future<int> f = std::async(std::launch::async, compute, 21);

// 做其他工作…

int result = f.get(); // 等待并获取结果
// result == 42
}

std::async 看起来非常优雅——不需要手动创建线程、不需要手动管理 promise、不需要手动 join。它自动完成了:

  • 创建线程(或延迟执行)
  • 执行任务
  • 将结果设置到 future
  • (在 future 析构时)自动 join 线程
  • 但这种"魔法"背后隐藏着许多微妙的行为和陷阱。

    1.2 最著名的陷阱:隐式 join

    // ❌ 看似异步,实际同步
    std::async(std::launch::async, []() {
    long_running_task();
    }); // 返回的 future 立即析构 → 隐式 join → 阻塞!

    // 这行代码要等 long_running_task 完成后才会执行
    do_other_work();

    std::async 返回的 future 在析构时,如果任务是用 launch::async 启动的,会隐式地 join 线程——阻塞直到任务完成。这意味着如果不保存返回的 future,std::async 的行为和同步调用没有区别!

    这是 C++ 标准中一个有争议的设计决策,也是很多并发 bug 的根源。


    二、API 速览:std::async

    2.1 基本用法

    #include <future>

    // 形式1:指定启动策略
    std::future<int> f1 = std::async(std::launch::async, compute, 21);
    std::future<int> f2 = std::async(std::launch::deferred, compute, 21);
    std::future<int> f3 = std::async(std::launch::async | std::launch::deferred, compute, 21);

    // 形式2:不指定启动策略(等价于 async | deferred,由实现选择)
    std::future<int> f4 = std::async(compute, 21);

    2.2 启动策略

    策略行为
    std::launch::async 立即创建新线程执行任务(异步)
    std::launch::deferred 延迟执行,直到 future 的 get() 或 wait() 被调用时才在当前线程执行(同步)
    async | deferred 由实现选择(默认行为)

    2.3 不指定策略的默认行为

    如果不指定启动策略(或指定 async | deferred),标准允许实现自行选择:

    • MSVC:总是选择 async(创建新线程)
    • libstdc++(GCC):总是选择 async(创建新线程)
    • libc++(Clang):总是选择 async(创建新线程)

    虽然标准允许选择 deferred,但实际上所有主流实现都选择 async。因此不指定策略时,std::async 几乎总是创建新线程。

    但这不是标准保证的——可移植代码不应该依赖这个行为。


    三、底层原理:std::async 的实现

    3.1 std::async 的内部流程

    std::async 本质上是对 std::thread + std::packaged_task 的封装:

    // std::async 的简化实现
    template<typename F, typename... Args>
    future<result_of_t<F(Args...)>> async(launch policy, F&& f, Args&&... args) {
    using ResultType = result_of_t<F(Args...)>;

    // 1. 创建 packaged_task
    packaged_task<ResultType()> task(
    std::bind(std::forward<F>(f), std::forward<Args>(args)...)
    );

    // 2. 获取 future
    future<ResultType> result = task.get_future();

    // 3. 根据启动策略执行
    if (policy == launch::async) {
    // 创建新线程执行任务
    thread t(std::move(task));
    t.detach(); // 分离线程,由 future 的析构来 join
    } else if (policy == launch::deferred) {
    // 延迟执行:将任务存储在 future 的 shared_state 中
    // 等到 get() 或 wait() 时才在当前线程执行
    result._M_set_deferred_task(std::move(task));
    }

    return result;
    }

    关键设计点:

  • packaged_task 封装任务:自动处理返回值和异常
  • async 策略:detach 线程:线程被 detach,但 future 的 shared_state 持有线程的引用,future 析构时 join
  • deferred 策略:存储任务:任务不立即执行,而是存储在 shared_state 中,等到 get()/wait() 时在调用线程执行
  • 3.2 async 策略:future 析构的隐式 join

    这是 std::async 最关键、最容易被误解的行为。当用 launch::async 启动任务时,返回的 future 的 shared_state 中存储了线程的信息。future 析构时:

    ~future() {
    if (valid()) {
    // 如果是 async 启动的任务,且线程还在运行
    if (state->_M_is_async_task && state->_M_thread_running) {
    state->_M_thread.join(); // 隐式 join!阻塞等待线程完成
    }
    state->_M_remove_reference();
    }
    }

    标准规定(C++14 及以后明确):

    如果 future 是由 std::async(launch::async, …) 返回的,且 shared_state 还未就绪,future 的析构函数会阻塞直到任务完成。

    这意味着:

    {
    auto f = std::async(launch::async, long_task);
    // f 析构时,如果 long_task 还没完成,会阻塞等待
    } // 隐式 join

    为什么这样设计?

    如果 future 析构时不 join,detach 的线程可能继续访问已经销毁的局部变量(通过引用捕获),导致未定义行为。标准选择"隐式 join"来避免这种悬空引用问题。

    但这个设计导致了一个反直觉的结果:不保存 future 的 std::async 调用是同步的。

    3.3 deferred 策略:延迟执行

    launch::deferred 策略下,任务不立即执行,而是存储在 shared_state 中。当调用 get() 或 wait() 时,任务在调用线程中同步执行:

    auto f = std::async(launch::deferred, []() {
    std::cout << "任务执行在线程: " << std::this_thread::get_id() << std::endl;
    return 42;
    });

    // 此时任务还没执行
    std::cout << "调用 get 前" << std::endl;

    int result = f.get(); // 在当前线程执行任务!
    // 输出:任务执行在线程: [当前线程ID]

    deferred 策略的特点:

  • 不创建新线程:任务在调用 get()/wait() 的线程中执行
  • 惰性求值:只有需要结果时才执行
  • future 析构不阻塞:因为没有创建线程,析构时不需要 join
  • wait_for/wait_until 返回 deferred:如果任务还没执行,超时等待会立即返回 future_status::deferred
  • auto f = std::async(launch::deferred, long_task);

    // wait_for 不会触发执行,立即返回 deferred
    auto status = f.wait_for(std::chrono::seconds(1));
    // status == future_status::deferred

    // 只有 get() 或 wait() 才会触发执行
    f.wait(); // 现在执行任务

    3.4 两种策略的行为对比

    行为launch::asynclaunch::deferred
    是否创建新线程 ✅ 是 ❌ 否
    何时执行 立即(在新线程) 延迟(get/wait 时在当前线程)
    future 析构是否阻塞 ✅ 是(隐式 join) ❌ 否
    wait_for 返回 ready/timeout deferred(未执行时)
    并发度 高(真正异步) 无(同步执行)
    适用场景 真正需要异步执行 惰性求值、可能不需要结果

    四、异常传播

    4.1 std::async 的异常处理

    std::async 内部使用 packaged_task,因此异常会自动被捕获并通过 future 传播:

    auto f = std::async(launch::async, []() -> int {
    throw std::runtime_error("异步任务失败");
    });

    try {
    int result = f.get(); // 重新抛出异常
    } catch (const std::runtime_error& e) {
    std::cout << "捕获异常: " << e.what() << std::endl;
    }

    4.2 析构时的异常处理

    一个微妙的问题:如果 future 析构时隐式 join,而任务抛出了异常,会发生什么?

    {
    auto f = std::async(launch::async, []() {
    throw std::runtime_error("任务失败");
    });
    // f 析构时隐式 join,任务的异常被存储在 shared_state 中
    // 但因为没有调用 get(),异常不会被重新抛出
    // 异常被"吞掉"了!
    }

    如果不调用 get(),任务中的异常会被存储在 shared_state 中,然后随 shared_state 一起被销毁——异常被静默吞掉。

    这是一个潜在的问题:异步任务可能失败了,但你永远不知道。

    最佳实践:始终在 future 上调用 get()(或至少 wait() 后检查状态),确保异常被正确处理。


    五、源码剖析:libstdc++ 的实现

    5.1 std::async 的核心代码

    libstdc++ 中 std::async 定义在 <future> 中:

    template<typename _Fn, typename... _Args>
    future<__async_result_of<_Fn, _Args...>>
    async(launch __policy, _Fn&& __fn, _Args&&... __args) {
    using __result_type = __async_result_of<_Fn, _Args...>;

    // 创建 packaged_task
    __shared_state_base::__state_type __state =
    __make_shared_state<__result_type>();

    auto __task = __make_packaged_task<__result_type()>(
    __state, std::bind(std::forward<_Fn>(__fn),
    std::forward<_Args>(__args)...));

    if ((__policy & launch::async) != launch{}) {
    // async 策略:创建新线程
    __try {
    thread __t(std::move(__task));
    __state->_M_thread_ = __t.native_handle();
    __t.detach();
    __state->_M_policy = launch::async;
    } __catch(...) {
    __state->_M_set_exception(std::current_exception());
    }
    } else {
    // deferred 策略:存储任务
    __state->_M_deferred_task = std::move(__task);
    __state->_M_policy = launch::deferred;
    }

    return future<__result_type>(__state);
    }

    5.2 future 析构的隐式 join

    ~future() {
    if (_M_state) {
    // 如果是 async 启动的任务
    if (_M_state->_M_policy == launch::async) {
    // 等待线程完成(隐式 join)
    _M_state->_M_wait();
    }
    _M_state->_M_remove_reference();
    }
    }

    注意:libstdc++ 的实现中,future 析构时调用 _M_wait() 等待任务完成,这就是隐式 join。

    5.3 deferred 任务的执行

    void _State_base::_M_wait() {
    if (_M_policy == launch::deferred && _M_deferred_task) {
    // deferred 任务:在当前线程执行
    _M_deferred_task();
    _M_deferred_task = nullptr;
    }
    // 正常等待(条件变量)
    std::unique_lock<std::mutex> __lock(_M_mutex);
    _M_cond.wait(__lock, [this]{ return _M_ready; });
    }

    deferred 任务在 _M_wait() 中被执行——在调用 get() 或 wait() 的线程中同步执行。


    六、工程实践

    6.1 始终保存返回的 future

    铁律:使用 std::async 时,始终保存返回的 future,否则任务会同步执行。

    // ❌ 错误:future 立即析构,隐式 join,任务同步执行
    std::async(launch::async, background_task);
    do_other_work(); // 要等 background_task 完成后才执行

    // ✅ 正确:保存 future
    auto f = std::async(launch::async, background_task);
    do_other_work(); // 与 background_task 并发执行
    f.get(); // 或 f.wait()

    6.2 用 std::async 还是线程池?

    场景推荐原因
    少量长时间任务 std::async 简单,不需要管理线程池
    大量短任务 线程池 避免频繁创建线程的开销
    需要控制并发度 线程池 std::async 每次创建新线程,可能创建过多
    一次性异步操作 std::async 简洁方便
    持续的任务调度 线程池 线程池支持任务队列和调度策略

    std::async 的性能问题:

    每次调用 std::async(launch::async, …) 都会创建新线程。如果频繁调用(如每秒数百次),线程创建开销和上下文切换会成为瓶颈。此外,没有并发度限制——如果同时调用 1000 次 std::async,会创建 1000 个线程,可能导致系统资源耗尽。

    经验法则:

    • 任务执行时间 > 10ms,且并发数量少 → std::async
    • 任务执行时间 < 1ms,或并发数量大 → 线程池

    6.3 deferred 策略的巧妙用法

    deferred 策略虽然不常用,但在某些场景下很有用:

    场景1:可能不需要结果的惰性计算

    auto f = std::async(launch::deferred, []() {
    return expensive_computation();
    });

    if (need_result()) {
    auto result = f.get(); // 只有需要时才执行
    use_result(result);
    }
    // 如果不需要结果,任务永远不执行,零开销

    场景2:确保任务在特定线程执行

    // deferred 任务在调用 get() 的线程执行
    // 可以用来确保任务在主线程执行(如 UI 更新)
    auto f = std::async(launch::deferred, ui_update_task);
    // … 在主线程 …
    f.get(); // ui_update_task 在主线程执行

    6.4 常见误用

    误用1:在类成员函数中用 std::async 捕获 this

    class MyClass {
    public:
    void start() {
    future_ = std::async(launch::async, &MyClass::worker, this);
    }
    ~MyClass() {
    // future_ 析构时隐式 join,等待 worker 完成
    // 但如果 worker 访问 this,而 this 正在析构…
    }
    private:
    void worker() {
    // 访问成员变量
    data_ = 42;
    }
    std::future<void> future_;
    int data_;
    };

    这个模式有潜在问题:析构函数中 future_ 析构会隐式 join,等待 worker 完成。但 worker 可能正在访问正在析构的对象。虽然 join 能确保 worker 在析构完成前结束,但成员变量的析构顺序可能导致问题。

    更安全的做法是在析构函数中显式处理:

    ~MyClass() {
    stop_flag_.store(true); // 请求停止
    if (future_.valid()) {
    future_.get(); // 显式等待
    }
    }

    误用2:用 std::async 执行永不返回的任务

    auto f = std::async(launch::async, []() {
    while (true) {
    // 无限循环
    }
    });
    // f 析构时会永远阻塞!

    如果任务永不返回,future 析构时的隐式 join 会永远阻塞。对于长期运行的任务,应该用 std::thread + detach,或用停止标志配合显式 join。

    误用3:假设不指定策略一定创建线程

    auto f = std::async(task); // 不指定策略
    // 标准允许实现选择 deferred!
    // 虽然主流实现都选择 async,但可移植代码不应依赖

    如果需要确保异步执行,显式指定 launch::async。

    6.5 std::async 与 std::thread 的对比

    特性std::asyncstd::thread
    创建线程 是(async 策略)
    获取返回值 ✅(通过 future) ❌(需要输出参数)
    异常传播 ✅(通过 future) ❌(未捕获异常导致 terminate)
    自动 join ✅(future 析构隐式 join) ❌(需要手动 join/detach)
    延迟执行 ✅(deferred 策略)
    并发度控制 ❌(每次创建新线程) 手动控制
    适用场景 一次性异步任务 长期运行的线程

    七、性能考量

    7.1 std::async 的开销

    操作开销
    std::async(launch::async) 创建 ~20-60μs(线程创建 + shared_state 分配)
    future::get(已就绪) ~50ns
    future::get(阻塞) 取决于任务执行时间
    future 析构(隐式 join) 取决于任务剩余时间
    std::async(launch::deferred) 创建 ~50-100ns(仅 shared_state 分配)
    deferred 任务执行(get 时) 任务本身的执行时间

    async 策略的主要开销是线程创建(20-60μs),和直接用 std::thread 一样。deferred 策略几乎没有创建开销(只分配 shared_state)。

    7.2 与线程池的性能对比

    指标std::async(每次创建线程)线程池(复用线程)
    任务提交开销 20-60μs <1μs
    最大并发度 无限制(可能创建过多线程) 可配置
    内存占用 每个线程 1-8MB 栈 固定数量线程
    适用任务时长 >10ms 任意(尤其短任务)

    对于大量短任务,线程池的性能可能比 std::async 高 10-100 倍。


    八、面试高频问题

    Q1:std::async 的两种启动策略有什么区别?

    launch::async 立即创建新线程异步执行任务;launch::deferred 延迟执行,直到 future 的 get() 或 wait() 被调用时才在当前线程同步执行。关键区别:(1)async 创建新线程,deferred 不创建;(2)async 立即执行,deferred 惰性执行;(3)async 返回的 future 析构时会隐式 join(阻塞等待线程完成),deferred 的 future 析构不阻塞;(4)deferred 任务未执行时,wait_for/wait_until 立即返回 future_status::deferred。不指定策略时(或 async|deferred),标准允许实现选择,主流实现都选择 async。

    Q2:什么是 std::async 的隐式 join?它导致了什么著名陷阱?

    用 launch::async 启动任务时,返回的 future 的 shared_state 中存储了线程信息。future 析构时,如果任务还未完成,会阻塞等待线程完成(隐式 join)。这是标准规定的行为(C++14明确),目的是避免 detach 线程访问已销毁的局部变量。著名陷阱:如果不保存返回的 future,future 立即析构,隐式 join 导致任务同步执行——std::async 变成了同步调用!例如 std::async(launch::async, task); do_work(); 中,do_work() 要等 task 完成后才执行。因此使用 std::async 必须始终保存返回的 future。

    Q3:std::async 和 std::thread 有什么区别?各自适用场景?

    区别:(1)std::async 返回 future,可以获取返回值和异常;std::thread 不支持返回值,异常未捕获会导致 terminate;(2)std::async 的 future 析构隐式 join,std::thread 必须手动 join 或 detach;(3)std::async 支持 deferred 延迟执行,std::thread 总是立即执行;(4)std::async 不控制并发度(每次创建新线程),std::thread 手动管理。适用场景:std::async 适合一次性异步任务、需要获取返回值的场景;std::thread 适合长期运行的线程、需要精细控制线程生命周期的场景。大量短任务两者都不适合,应该用线程池。

    Q4:std::async 的异常是如何传播的?如果不调用 get() 会怎样?

    std::async 内部使用 packaged_task 封装任务,任务执行时抛出的异常会被自动捕获并存储在 shared_state 中。调用 future::get() 时,如果结果是异常,会调用 std::rethrow_exception 重新抛出。如果不调用 get()(也不调用 wait()),异常会一直存储在 shared_state 中,随 shared_state 一起被销毁——异常被静默吞掉,调用者永远不知道任务失败了。因此最佳实践是始终在 future 上调用 get(),确保异常被正确处理。

    Q5:std::async 为什么不适合大量短任务?线程池好在哪里?

    std::async(launch::async) 每次调用都创建新线程,线程创建开销约 20-60μs,且每个线程占用 1-8MB 栈空间。对于大量短任务(执行时间 <1ms),线程创建开销占比过高,且大量线程的上下文切换和内存占用会成为瓶颈。此外 std::async 没有并发度限制,同时调用 1000 次会创建 1000 个线程,可能耗尽系统资源。线程池通过复用已创建的线程,将任务提交开销降到 <1μs,并可配置最大并发度,避免资源耗尽。经验法则:任务执行时间 >10ms 且并发少用 std::async;任务短或并发大用线程池。

    Q6:launch::deferred 策略有什么实际用途?

    deferred 策略下任务不立即执行,而是存储在 shared_state 中,等到 get() 或 wait() 时在调用线程同步执行。实际用途:(1)惰性求值——可能不需要结果的昂贵计算,只有真正需要时才执行,不需要则零开销;(2)确保任务在特定线程执行——deferred 任务在调用 get() 的线程执行,可以用来确保任务在主线程执行(如 UI 更新);(3)零开销的 future 封装——需要 future 接口但不需要真正异步时,deferred 避免了线程创建开销。注意 deferred 任务未执行时 wait_for 立即返回 deferred 状态,不会触发执行。


    下一篇预告:第 10 篇《std::call_once 与 once_flag:线程安全的延迟初始化》。我们将剖析 once_flag 的内部状态机、call_once 的双重检查锁定(DCLP)实现、Meyers Singleton(函数局部静态变量)的线程安全保证、异常安全与重试机制,以及 C++11 前后单例模式的演变。

    赞(0)
    未经允许不得转载:171主机测评 » 【C++多线程】std::async 深度剖析:启动策略、线程复用与异常陷阱
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址