前言
std::thread的构造函数接受任意可调用对象,但它的返回类型固定是void。也就是说,线程函数即使写了return 42;,这个值也会被安静地丢弃。很多初学者第一次写多线程时,会试图用「让线程函数返回一个值」的方式取结果,结果发现根本拿不到,甚至误以为是编译器优化掉了。
另一个常见误解是「线程里抛异常顶多让那个线程失败」。实际上,异常如果逃出线程入口函数,标准要求调用std::terminate,整个进程终止。因此「把返回值带出来」和「把异常带出来」其实是同一个问题:线程与调用方之间需要一个共享状态来传递结果或异常。
本文以 C++17 为基准,介绍std::future/std::promise/std::packaged_task/std::async这套标准设施,也讲共享变量加锁、条件变量、std::atomic这些手工方案,并比较它们各自的适用场景。文中代码只做语义推演,本机没有编译环境,不表示已经运行过。
一、std::thread为什么不返回结果
std::thread的构造函数把可调用对象复制(或移动)到内部存储,然后在新线程里执行INVOKE(f, args...),执行结果被忽略。标准这样设计是为了保持接口简单:线程函数的签名千差万别,如果允许返回值,就得再引入一个模板参数来承载它,而返回值如何同步、何时可读、由谁销毁,全部要额外规定。
// 适用于 C++17
#include <iostream>
#include <thread>
int compute() {
return 42;
}
int main() {
std::thread t(compute); // 合法:返回值被丢弃
t.join();
// 这里没有任何标准途径拿到 42
}还要注意:std::thread析构时如果仍然joinable(),会调用std::terminate。所以每个线程都必须join()或detach(),这也意味着调用方通常要在某个点等待线程结束——这恰好是future::get()擅长的事情。
二、future/promise/packaged_task三件套
标准库的方案是把「结果」放进一个由实现管理的共享状态(shared state):
std::promise<T>是共享状态的写端,调用set_value或set_exception填入结果;std::future<T>是共享状态的读端,调用get()阻塞等待并取出结果;std::packaged_task<Sig>把一个可调用对象包装成「执行后自动把返回值写进共享状态」的任务;std::async则是更高层的封装,自动创建线程(或延迟执行)并返回future。
future::get()有几个必须记住的性质:它只能成功调用一次,调用后valid()变为false;再次调用会抛出std::future_error,错误码通常是future_errc::no_state,这不是未定义行为,但显然是逻辑错误。如果共享状态里存的是异常,get()会重新抛出该异常。若promise在未设置值的情况下被销毁,共享状态被置为broken_promise,get()抛出std::future_error。
// 适用于 C++17
#include <future>
#include <iostream>
#include <thread>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread producer([&p] {
try {
p.set_value(42);
} catch (...) {
p.set_exception(std::current_exception());
}
});
int value = f.get(); // 阻塞直到 set_value 完成
std::cout << value << '\n';
producer.join();
}packaged_task适合把「任务」和「执行它的线程」分开:任务可以先创建、排队,稍后交给线程池执行。
// 适用于 C++17
#include <future>
#include <thread>
#include <iostream>
int main() {
std::packaged_task<int(int, int)> task([](int a, int b) {
return a + b;
});
std::future<int> f = task.get_future();
std::thread worker(std::move(task), 2, 3); // packaged_task 不可拷贝
std::cout << f.get() << '\n';
worker.join();
}packaged_task不可拷贝、只能移动,传进std::thread时必须std::move。它的operator()返回void,结果只能从关联的future取。
三、std::async的策略与生命周期陷阱
std::async的第一个参数是启动策略:
| 策略 | 行为 | 何时执行 |
|---|
std::launch::async | 一定在新线程执行 | 调用后尽快开始 |
std::launch::deferred | 不创建线程,延迟执行 | 首次get()或wait()时在调用线程执行 |
| 不传(默认) | 由实现选择上面之一 | 不确定 |
默认策略意味着「可能根本不并行」。如果代码依赖并行性,应显式写std::launch::async。deferred模式下任务在get()所在的线程同步执行,wait_for会立刻返回std::future_status::deferred。
还有一个容易踩的坑:由std::async(std::launch::async, ...)返回的future,如果一直不调用get()或wait()就析构,析构函数会阻塞等待任务完成。这保证了异步任务不会在后台悄悄访问已经销毁的对象,但也可能让程序在某一行莫名其妙地卡住。
// 适用于 C++17
#include <cstddef>
#include <future>
#include <vector>
#include <numeric>
#include <iostream>
long long sum_range(const std::vector<int>& v, std::size_t b, std::size_t e) {
return std::accumulate(v.begin() + static_cast<std::ptrdiff_t>(b),
v.begin() + static_cast<std::ptrdiff_t>(e), 0LL);
}
int main() {
std::vector<int> data(1000, 1);
const std::size_t parts = 4;
const std::size_t n = data.size();
std::vector<std::future<long long>> futures;
for (std::size_t i = 0; i < parts; ++i) {
std::size_t b = i * n / parts;
std::size_t e = (i + 1) * n / parts;
futures.push_back(std::async(std::launch::async,
[&data, b, e] { return sum_range(data, b, e); }));
}
long long total = 0;
for (auto& f : futures) {
total += f.get();
}
std::cout << total << '\n';
}这段代码里,每个future都只get()一次;data的生命周期覆盖所有任务,因此按引用捕获是安全的。若在循环里提前return,futures析构会等待尚未完成的任务,反而不会悬垂。
四、共享变量方案:互斥量、条件变量与原子量
不用future也能传结果,常见做法是让线程写一个共享变量,再用同步设施通知调用方。
// 适用于 C++17
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <optional>
#include <thread>
struct ResultSlot {
std::mutex m;
std::condition_variable cv;
std::optional<int> value;
bool done = false;
};
int main() {
ResultSlot slot;
std::thread worker([&slot] {
int r = 42;
{
std::lock_guard<std::mutex> lk(slot.m);
slot.value = r;
slot.done = true;
}
slot.cv.notify_one();
});
std::unique_lock<std::mutex> lk(slot.m);
slot.cv.wait(lk, [&slot] { return slot.done; });
std::cout << *slot.value << '\n';
lk.unlock();
worker.join();
}这里谓词版本wait解决虚假唤醒;写端先在锁内改状态再notify_one,避免丢通知。若结果只是一个整数,也可以用std::atomic<int>配合std::atomic<bool>标志,但多字段结果仍建议用锁或future,因为「标志与数据一起可见」需要仔细设计内存序。
| 方案 | 适合场景 | 主要代价 |
|---|
std::async | 一次性并行任务,天然返回结果 | 默认策略不确定;future 析构会等待 |
promise+future | 结果由回调/事件驱动产生 | 需要手动配对,promise 生命周期要管好 |
packaged_task | 任务队列、线程池 | 任务只能移动,需自行调度 |
| 共享变量 + 锁/条件变量 | 长期运行的线程、多次通信 | 同步逻辑要手写,易漏通知或死锁 |
std::atomic | 单个平凡类型的结果标志 | 只适合简单类型,复合状态仍需锁 |
常见坑点
- ❌ 以为
std::thread能接住返回值:std::thread t([]{ return 1; });拿不到任何东西。✅ 用std::async或std::packaged_task把返回值送进future。
- ❌ 在线程函数里抛异常:异常逃出线程入口会触发
std::terminate。✅ 用packaged_task/std::async,或在函数体内catch (...)后通过promise::set_exception传递。
- ❌ 对同一个
future调用两次get(),第二次抛std::future_error。✅ 把结果存进局部变量,或改用std::shared_future供多方读取。
- ❌ 不写启动策略却假设任务并行:默认策略可能选择
deferred。✅ 需要并行时显式写std::launch::async。
- ❌
promise先于set_value销毁,get()抛broken_promise。✅ 保证每条路径都set_value或set_exception,包括异常路径。
- ❌ 按引用捕获局部变量,线程还没
join函数就返回了。✅ 按值捕获,或确保join发生在对象销毁之前。
- ❌ 用
std::atomic<int>存结果又用普通bool做完成标志,两者没有同步关系。✅ 标志也用原子量并配release/acquire,或者干脆用锁保护整个结果。
- ❌ 在线程里
detach()后让它访问main的局部对象。✅ 要么join,要么把所需数据的所有权移进线程。
总结
| 方法 | 返回值 | 异常传递 | 是否自动建线程 | 典型用途 |
|---|
std::thread+ 共享变量 | 手工同步 | 不支持,逃逸即终止 | 是 | 长期工作线程 |
std::promise/std::future | 是 | 是 | 否 | 事件驱动、回调 |
std::packaged_task | 是 | 是 | 否 | 任务队列、线程池 |
std::async | 是 | 是 | 视策略而定 | 一次性并行计算 |
std::atomic | 仅简单类型 | 否 | 否 | 标志位、计数器 |
获取线程返回值的核心不是「让线程返回」,而是「让线程把结果写进一个双方都能安全访问的共享状态」。future系列已经把同步、异常传播和生命周期管理打包好了,绝大多数场景应优先使用;只有在需要长期通信或极简标志位时,才手工用锁或原子量。
需要补充的是,C++20 引入了std::jthread,它在析构时自动join,并支持stop_token协作式取消,但它同样不承载返回值,取结果仍要靠future一类设施。使用前请确认工具链对 C++20 线程设施的支持程度。