前言
std::thread本身不能带回返回值。它的构造函数形如template<class F, class... Args> explicit thread(F&& f, Args&&... args);,语义是「启动一个新线程执行f(args...),如果有返回值就丢掉」。这一点常被误解——不少人第一次写多线程时会去找std::thread::get(),然后发现根本没有这个成员函数。
另一个常见误解是认为「带返回值」只是个小语法问题,加个全局变量就行了。真正的问题在于:返回值需要通过共享状态(shared state)在线程之间传递,而共享状态的建立、就绪通知、异常传递、以及提前退出时的清理,都需要一套固定的机制。C++11 把这套机制标准化成了std::future/std::promise/std::packaged_task,本文要讲的就是这几种方案各自的边界。
按本文的写法,你可以用四种方式拿到线程的返回值:std::async+std::future、std::promise+std::future、std::packaged_task+std::future,以及最原始的「输出参数 + 引用」。代码以 C++17 为基准,编译用g++ -std=c++17 -pthread。
一、std::async 与 std::future
这是最省事的一种。标准声明(简化后):
template <class F, class... Args> std::future<std::invoke_result_t<std::decay_t<F>, std::decay_t<Args>...>> std::async(F&& f, Args&&... args); template <class F, class... Args> std::future<std::invoke_result_t<std::decay_t<F>, std::decay_t<Args>...>> std::async(std::launch policy, F&& f, Args&&... args);std::invoke_result_t是 C++17 引入的(C++17 之前用std::result_of,它在 C++17 起被弃用、C++20 被移除)。返回的std::future里装着任务的结果,调用get()会阻塞直到结果可用。
// async_demo.cpp 编译:g++ -std=c++17 -pthread async_demo.cpp #include <future> #include <iostream> long long sum_range(int lo, int hi) { long long s = 0; for (int i = lo; i < hi; ++i) { s += i; } return s; } int main() { // 显式指定 launch::async,保证一定在新线程上跑 auto f1 = std::async(std::launch::async, sum_range, 0, 500); auto f2 = std::async(std::launch::async, sum_range, 500, 1000); // get() 阻塞到各自的结果就绪;两段合并后是 0+1+...+999 std::cout << f1.get() + f2.get() << '\n'; // 输出 499500 return 0; }三个必须知道的语义
第一,get()只能调用一次。std::future的共享状态在get()之后就不再与它关联,valid()变成false,再调用get()会抛std::future_error,错误码是std::future_errc::no_state。需要多方读取结果就用std::shared_future(std::future::share()或std::async(...).share()),它的get()可以重复调用。
第二,任务里的异常会在get()时重新抛出。这是这套机制相比「全局变量」方案的关键优势:任务抛出什么异常,get()就抛什么异常;如果future析构时异常还没被取走,那个异常就会被丢弃(不会std::terminate,而是静默消失)。
第三,也是最大的坑:std::async返回的future,其析构函数会阻塞。标准规定,当future与由std::async启动的共享状态关联时,析构会等到任务完成(见标准 futures.async 一节)。于是下面这行代码其实变成了同步执行:
std::async(std::launch::async, [] { heavy_work(); }); // ⚠️ 临时的 future 立刻析构,这一行会阻塞到 heavy_work 结束要真正异步,必须把future存下来,或者交给别的机制接管它的生命周期。
launch 策略
| 策略 | 行为 | 陷阱 |
|---|---|---|
std::launch::async | 一定在新线程上执行 | 线程资源开销真实存在 |
std::launch::deferred | 不执行,直到调用wait()或get(),在调用线程上惰性执行 | 忘了get()就永远不执行 |
| 默认(`async \ | deferred`) | 由实现决定用哪一个 |
用默认策略时要特别小心:如果拿到future之后既不get()也不wait(),任务有可能从头到尾没被执行过——它既没有报错也没有运行,只是安静地消失了。所以除非你确定会等待结果,否则一律显式写std::launch::async。
想知道任务是不是已经完成,可以用wait_for:f.wait_for(std::chrono::milliseconds(0))返回std::future_status,取值是ready/timeout/deferred。注意deferred是个单独的取值——它表示「这个任务还没被推迟执行过」。
二、std::packaged_task:把可调用体贴上 future
std::packaged_task的模板参数是函数签名而不是返回类型:
template <class R, class... Args> class packaged_task<R(Args...)>;它包装一个可调用体,自带一个共享状态;get_future()拿到与之关联的std::future,调用operator()执行任务并把返回值(或异常)写进共享状态。它是可移动但不可拷贝的,因此配合std::thread时需要std::move。用它可以把「计算」和「线程」解耦——你可以把packaged_task丢进自己的线程池,而把future留给需要结果的一方。
// task_demo.cpp 编译:g++ -std=c++17 -pthread task_demo.cpp #include <future> #include <iostream> #include <thread> int add(int a, int b) { return a + b; } int main() { std::packaged_task<int(int, int)> task(add); std::future<int> fut = task.get_future(); // packaged_task 不可拷贝,必须 move 进线程 std::thread worker(std::move(task), 3, 4); std::cout << fut.get() << '\n'; // 输出 7 worker.join(); // 必须在 fut 与 worker 都还合法时 join return 0; }这里std::thread worker(std::move(task), 3, 4);的机制是:std::thread会把可调用体和参数按值存起来(对packaged_task而言是移动构造),然后在目标线程上以task(3, 4)的形式调用它。
用packaged_task最容易犯的错是忘了执行它。get_future()只是建立关联,任务不会自己跑;如果这个packaged_task对象从头到尾没被调用过就被销毁,共享状态会被标记为「已就绪但只有异常」,future::get()会抛出std::future_error,错误码是std::future_errc::broken_promise——也就是「承诺了但没兑现」。
三、std::promise:手动设置结果
std::promise给了最细粒度的控制:结果什么时候就绪、是值还是异常,都由你决定。
// promise_demo.cpp 编译:g++ -std=c++17 -pthread promise_demo.cpp #include <exception> #include <future> #include <iostream> #include <stdexcept> #include <thread> int main() { std::promise<int> prom; std::future<int> fut = prom.get_future(); std::thread t([&prom] { try { // 这里可以是任意异步过程:回调、I/O 完成、事件循环里的一段逻辑 const bool failed = true; // 换成真实条件 if (failed) { throw std::runtime_error("模拟失败"); } prom.set_value(42); } catch (...) { // 把当前异常搬进共享状态,交给 future::get() 重新抛出 prom.set_exception(std::current_exception()); } }); try { std::cout << fut.get() << '\n'; } catch (const std::exception& e) { std::cout << "捕获到:" << e.what() << '\n'; // 捕获到:模拟失败 } t.join(); // 必须 join:prom 的生存期要覆盖线程的整个执行过程 return 0; }几个要点:
set_value/set_exception每个 promise只能成功调用一次,第二次调用会抛std::future_error,错误码std::future_errc::promise_already_satisfied。promise析构时如果还没设置过值,共享状态会被置成broken_promise,等待方get()时抛异常。这比「永远阻塞」好——至少错误是可观测的。set_exception(std::current_exception())必须在catch块里调用,std::current_exception()在catch之外返回空指针。- 线程捕获
prom的引用,所以prom的生命周期必须覆盖线程的执行;本文例子中t.join()在prom析构之前,是安全的。
四、其他方式与选型
输出参数 + 引用
最原始的办法,等价于 C 语言里的「传指针出去」。这里有一个std::thread特有的坑:线程函数的参数默认按值拷贝,传引用必须用std::ref包一层,否则你改的是线程内部的一份副本。
// ref_demo.cpp 编译:g++ -std=c++17 -pthread ref_demo.cpp #include <functional> #include <iostream> #include <thread> void compute(int n, int& out) { out = n * 2; } int main() { int result = 0; // ❌ 写成 std::thread t(compute, 21, result); 会编译失败: // 线程参数按值拷贝后是 int,绑定不到 compute 的 int& 形参。 // ✅ 必须用 std::ref 显式要求「传引用」。 std::thread t(compute, 21, std::ref(result)); t.join(); std::cout << result << '\n'; // 输出 42 return 0; }需要强调的是引用的生命周期:result必须活得比线程久。如果result是某个已经在join之前被销毁的局部对象,线程写进去的就是一片已经归还的内存,属于未定义行为。
拿到返回类型
需要写泛型包装时,C++17 用std::invoke_result_t<F, Args...>拿返回类型;C++17 之前用std::result_of(已弃用并在 C++20 移除)。下面这个小工具可以模拟std::async的一部分行为:
#include <future> #include <thread> #include <type_traits> #include <utility> template <typename F, typename... Args> auto run_async(F&& f, Args&&... args) -> std::future<std::invoke_result_t<std::decay_t<F>, std::decay_t<Args>...>> { using R = std::invoke_result_t<std::decay_t<F>, std::decay_t<Args>...>; std::packaged_task<R(std::decay_t<Args>...)> task(std::forward<F>(f)); std::future<R> fut = task.get_future(); // 注意:这里的线程需要由调用方管理;真实实现应把线程交给线程池 std::thread(std::move(task), std::forward<Args>(args)...).detach(); return fut; }这段代码只为说明类型推导的写法,它有一个明确的缺陷:detach()之后线程无人管理,如果f捕获了临时对象会出事。生产代码请用线程池或std::async,不要照抄。
选型对照
| 需求 | 推荐方案 | 理由 |
|---|---|---|
| 一次性并行计算,要结果 | std::async(std::launch::async, ...)+std::future | 代码最短,异常自动传播 |
| 任务要进自己的线程池 | std::packaged_task+future | 计算与执行分离,可排队 |
| 结果由外部事件产生(回调、网络完成) | std::promise+future | 手动控制就绪时机与异常 |
| 只要「跑完就好」,不要结果 | std::thread+join() | 没有共享状态的额外开销 |
| 多个等待者都要同一份结果 | std::shared_future | get()可重复调用 |
常见坑点
| 场景 | ❌ 错误写法 | ✅ 正确写法 |
|---|---|---|
| 想从线程拿返回值 | std::thread t(f); int r = t.get();(没有get) | auto fut = std::async(std::launch::async, f); int r = fut.get(); |
| 把 async 写成同步 | std::async(std::launch::async, f);临时 future 立刻析构 | auto fut = std::async(...);保存 future,再fut.get() |
| 重复取结果 | fut.get(); fut.get();(第二次抛future_error) | 改用std::shared_future,或多存一份副本 |
| 传引用给线程 | std::thread t(f, x);(按值拷贝) | std::thread t(f, std::ref(x)); |
忘了执行packaged_task | 只调用get_future()就等结果 | 必须让某处调用task(...),否则是broken_promise |
promise未设值就析构 | 线程里提前return,绕过了set_value | 用set_exception或try/catch保证一定设值 |
| 异常未取出 | future析构时任务抛出的异常被直接丢弃 | 总是get()一次,或在wait()前把异常取走 |
| 线程析构时仍可 join | std::thread对象析构而没join/detach | 析构前join(),或交给 RAII 包装(C++20 是std::jthread) |
有一处值得展开:表格里「异常未取出」这一条。future的析构不会像promise那样抛异常,也不会std::terminate,它只是安静地把异常扔掉。这在调试时非常难发现——任务明明失败过,主线程却什么都没看到。所以只要用了future,就应当保证有一条路径确实调用了get()。
另一个容易忽略的点是线程数量。上面几个示例都是每次调用创建一个std::thread,而线程创建是有真实成本的(栈分配、内核对象、上下文切换)。std::async用默认策略时,实现可能在内部复用线程池,也可能每个任务新建一个线程——这是实现定义的,不要依赖某一种行为。
总结
| 方案 | 结果获取方式 | 异常如何传递 | 谁负责线程生命周期 |
|---|---|---|---|
std::async+future | future::get() | 自动存入共享状态,get()时重抛 | 实现(可能新建线程,也可能复用) |
packaged_task+future | future::get() | 同上,由operator()捕获 | 调用方(通常配合std::thread或线程池) |
promise+future | future::get() | 必须手动set_exception | 调用方 |
输出参数 +std::ref | 直接读被引用的对象 | 无法传递,会直接std::terminate | 调用方 |
要记住的其实只有三句话:std::thread不返回值,返回值得靠future这条线;std::async得到的future析构会阻塞,默认策略还可能让任务不执行,所以永远显式写std::launch::async并把future存好;future::get()只能调用一次,而且一定要调用一次——否则任务里的异常会被静默丢弃。需要把任务排进线程池时用packaged_task,需要手动控制就绪时机时用promise,剩下的场景std::async就够了。