这是我操作系统 / 多线程手撕题的 Day5 。
Day4 我已经写出了基础线程池:队列里放 std::function<void()>,worker 线程循环取任务执行。
但我 Day5 的核心诉求很现实:
我希望线程池提交一个
int()任务后,能拿到返回值。我想要的是:提交任务立刻返回;结果什么时候取由调用者决定。
最终我把进阶版本跑通了,并用一个验证 demo 得到输出:
ini
[main] submit tasks, main thread id = 111212
[task1] submit tasks, task1 thread id = 111172
[task2] submit tasks, task2 thread id = 125744
[task3] submit tasks, task3 thread id = 111172
[main] f2 not ready after 50ms (expected timeout)
[main] results: 1, 2, 3
[main] done
一、我一开始没懂的「进阶线程池结构」到底长啥样?
1.1 Day4 的线程池结构(我已经会了)
- 一个任务队列:
queue<function<void()>> tasks_ - 一堆 worker 线程:循环 wait → pop → 执行
fn() Enqueue()往队列里塞void()任务
问题 :如果任务有返回值(比如 int()),worker 只会执行 void(),返回值怎么传回来?
1.2 我 Day5 的目标结构(今天才真正理解)
核心思想 :执行端(worker)不需要理解"返回值",它只需要执行 void()。
返回值走另一条通道:future。
-
队列里仍然只放:
std::function<void()> -
但是我可以把一个
int()任务包装成void():void()里执行packaged_task<int()>packaged_task会把结果写进future<int>的共享状态
-
EnqueueInt()返回future<int>给调用者 -
调用者想什么时候拿结果,就什么时候
get()/wait_for()
这就是我今天反复确认的那句话:
调用者自己决定什么时候 get()(也可以不 get、也可以 wait_for)
二、我问过的小问题:这里的"调用者"到底指谁?
我当时有点卡在词上:到底谁是调用者?
结论 :调用者就是"调用 EnqueueInt() 的那个人"。在我的 demo 里就是 main()。
EnqueueInt()的职责:提交任务 + 返回future<int>main()的职责:决定什么时候取结果(get()/wait()/wait_for())
这也解释了我自己的那段理解(后来你也确认了我方向对):
EnqueueInt()返回 future;真正的返回值由调用者什么时候get()决定。
三、版本迭代 1:我写出了 EnqueueInt,但出现了两个"理解型坑"
我当时的雏形是:
std::packaged_task<int()> pt(task);auto fut = pt.get_future();- 把
pt放进队列,让 worker 执行 - return
fut
这里出现两个关键问题,都是我后来逐步问出来的。
3.1 我问:为什么 &ThreadPool::Work, this 这么写?我以前不是也能写吗?
我当时觉得"Work 和构造函数都在一个类里",为什么还要写得这么麻烦。
正确理解
Work 是成员函数,不是普通函数。成员函数必须依赖对象才能被调用,本质上是:
- 普通函数:
Work() - 成员函数:
this->Work()
当我用 std::thread 启动线程时,线程入口需要一个"可调用对象"。
成员函数要成为"可调用",必须同时提供两样东西:
- 成员函数指针:
&ThreadPool::Work - 对象指针:
this
所以标准写法是:
kotlin
workers_.emplace_back(&ThreadPool::Work, this);
我追问的"是不是因为函数类型所以要加 &?"
更准确:不是"因为函数类型",而是"因为要取成员函数指针"。
&ThreadPool::Work 表示"指向成员函数 Work 的指针"(pointer-to-member-function)。
3.2 我问:pt 不是和 fut 绑定吗?pt 是局部变量销毁后,fut 还能有效吗?
这个问题非常关键,我当时确实没想通。
正确理解:future 不"绑在 pt 身上",它们是共享同一份共享状态
packaged_task内部持有一个"共享状态"(shared state)pt.get_future()返回的future也指向这份共享状态- 共享状态才是"结果存放地"
所以:
-
pt是局部变量并不等于fut立刻失效 -
但如果
pt被销毁时共享状态还没被写入结果,那么:- 要么
future永远等不到结果(卡住) - 要么变成"broken promise"(
get()抛异常) - 更糟糕的是:你如果让 worker 执行一个"引用了已销毁 pt 的函数",会出现悬空引用(UB)
- 要么
所以关键不是"pt 销毁 fut 是否有效",而是:
必须保证 worker 最终一定会执行那次
pt(),并且执行时 pt 仍然活着。
四、版本迭代 2:我修到了"能编译",但又犯了一个很真实的小坑:{ p; } 不等于执行任务
你那时指出我有一版写成:
css
[p = std::move(pt)] { p; }
我当时的真实问题就是:我以为"把 p 写在里面就算用了",结果它根本没执行。
{ p; }:只是一个无意义表达式- 我真正需要的是:
p();(调用 packaged_task)
而且 packaged_task::operator() 不是 const,所以一般 lambda 要写 mutable 才能调用它。
这个坑我后来自己也认可:它属于"刚学时很容易犯的低级但真实错误":
五、版本迭代 3:我把逻辑写对了,但 VS2022 给我一个"类型系统的硬限制"报错(最关键的坑)
当我终于写成"move 捕获 pt 到 lambda,然后塞进 tasks_"时,我遇到了这个编译错误:
std::function的 target 必须可拷贝(copy constructible)
根因是:
std::packaged_task是 move-only- lambda 捕获 move-only 的 pt → lambda 也变成 move-only
- 但我的队列类型是
std::function<void()> - MSVC 的
std::function要求可拷贝,不能装 move-only callable - 所以 static_assert 直接炸掉
这一步我学到一个非常"工程"的点:
不是我并发逻辑错,是容器/类型擦除工具的约束不允许我这么存。
在 VS2022 下这是硬限制。
六、版本迭代 4(最终跑通):我用 shared_ptr 让 lambda 可拷贝,成功塞进 std::function<void()>
解决思路很朴素:
- 既然
std::function要求可拷贝 - 那我就让 lambda 捕获一个可拷贝的对象
std::shared_ptr可拷贝- 把
packaged_task放进shared_ptr,lambda 捕获shared_ptr,就满足std::function的要求了
最终我写成了这样(也是我最终版里保留的写法):
c
std::packaged_task<int()> pt(task);
std::future<int> fut = pt.get_future();
auto pt_ptr = std::make_shared<std::packaged_task<int()>>(std::move(pt));
tasks_.emplace([pt_ptr]() mutable { (*pt_ptr)(); });
return fut;
到这里,结构完全闭环:
EnqueueInt返回future<int>- worker 执行
void()包装任务 - 包装任务执行
(*pt_ptr)(),把结果写入 future 的共享状态
七、验证 Demo:我为什么要写这个 demo?它验证了什么?
我同意你提议"先做验证 demo",因为它能让我确信这不是"我以为写对了"。
7.1 我写 demo 的目的(我自己的检查点)
-
任务确实跑在 worker 线程,不是在 main 线程同步执行
- 通过打印
std::this_thread::get_id()
- 通过打印
-
future 确实能拿到结果
get()返回 1、2、3
-
wait_for 能反映"还没完成"这种状态
- task2 睡 500ms
- main 对 fut2
wait_for(50ms)应该 timeout
输出确实符合预期:
- task thread id ≠ main thread id
wait_for(50ms)timeoutget()得到 1,2,3
八、Day5 我学到的所有知识点(这部分我按"复习笔记"来写)
8.1 std::packaged_task<R()> 是什么?
它把"一个可调用对象"包装成"能产生 future 的任务"。
-
packaged_task<int()> pt(task); -
future<int> fut = pt.get_future(); -
当执行
pt()时:- task 被执行
- 返回值(或异常)会写进 fut 对应的共享状态
关键点 :执行端执行 pt(),调用端持有 fut。
8.2 std::future 常用方法(面试高频)
get():取结果。若未就绪,会阻塞;只能调用一次(取走结果)wait():只等待就绪,不取结果wait_for(duration):等一段时间,返回future_statuswait_until(time_point):等到某个时间点
wait_for 的返回值:std::future_status
ready:已就绪timeout:超时未就绪deferred:延迟执行(常见于std::async的 deferred 策略;线程池一般不会出现)
我的 demo 就是在用它观察"50ms 时还没好"。
8.3 using namespace std::chrono_literals; 是干什么的?
为了能直接写:
50ms100ms2s
否则要写:
std::chrono::milliseconds(50)
所以这句本质上是:开启 chrono 的字面量后缀,让 demo 更直观。
8.4 为什么任务队列里取出任务后要 unlock() 再执行?
在 worker 里我写的是:
php
std::function<void()> fn = std::move(tasks_.front());
tasks_.pop();
lock.unlock();
fn();
原因是:锁应该只保护"队列结构",不应该包住"任务执行"。
-
如果执行任务时还持锁:
- 其它线程无法入队
- 其它 worker 也抢不到锁取任务
- 线程池会退化得很严重
这是线程池题里非常关键的工程点:锁粒度必须小。
8.5 std::function 在 VS2022 下的限制:必须可拷贝
这是 Day5 最大坑:
lambda 捕获了 move-only(比如 packaged_task)→ lambda move-only → std::function 装不下。
解决办法(我最终用的):
- 用
shared_ptr让捕获对象可拷贝 - 使 lambda 可拷贝
- 使
std::function<void()>能存
九、我最终跑通的版本
下面是我 Day5 最终版。
arduino
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <assert.h>
#include <future>
#include <iostream>
#include <chrono>
class ThreadPool {
public:
explicit ThreadPool(size_t thread_count);
~ThreadPool();
void Enqueue(std::function<void()> task);
std::future<int> EnqueueInt(std::function<int()> task);
private:
void Work();
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_ = false;
};
void ThreadPool::Work() {
while (true) {
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return !tasks_.empty() || stop_; });
if (stop_ && tasks_.empty()) break;
std::function<void()> fn = std::move(tasks_.front());
tasks_.pop();
lock.unlock();
fn();
}
}
ThreadPool::ThreadPool(size_t thread_count) {
for (int i = 1; i <= static_cast<int>(thread_count); ++i) {
workers_.emplace_back(&ThreadPool::Work, this);
}
}
ThreadPool::~ThreadPool() {
std::unique_lock<std::mutex> lock(mtx_);
stop_ = true;
lock.unlock();
cv_.notify_all();
for (auto& t : workers_) {
t.join();
}
}
void ThreadPool::Enqueue(std::function<void()> task) {
std::unique_lock<std::mutex> lock(mtx_);
assert(!stop_ && "ThreadPool stopped");
tasks_.push(std::move(task));
lock.unlock();
cv_.notify_one();
}
std::future<int> ThreadPool::EnqueueInt(std::function<int()> task) {
std::packaged_task<int()> pt(task);
std::future<int> fut = pt.get_future();
auto pt_ptr = std::make_shared<std::packaged_task<int()>>(std::move(pt));
std::unique_lock<std::mutex> lock(mtx_);
assert(!stop_ && "ThreadPool stopped");
tasks_.emplace([pt_ptr]() mutable { (*pt_ptr)(); });
lock.unlock();
cv_.notify_one();
return fut;
}
int main() {
using namespace std::chrono_literals;
ThreadPool pool(2);
std::cout << "[main] submit tasks, main thread id = "
<< std::this_thread::get_id() << std::endl;
auto fut1 = pool.EnqueueInt([] {
std::cout << "[task1] submit tasks, task1 thread id = "
<< std::this_thread::get_id() << std::endl;
return 1;
});
auto fut2 = pool.EnqueueInt([] {
std::cout << "[task2] submit tasks, task2 thread id = "
<< std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(500ms);
return 2;
});
auto fut3 = pool.EnqueueInt([] {
std::cout << "[task3] submit tasks, task3 thread id = "
<< std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(100ms);
return 3;
});
auto st = fut2.wait_for(50ms);
if (st == std::future_status::ready) {
std::cout << "[main] f2 is ready (unexpected)\n";
} else if (st == std::future_status::timeout) {
std::cout << "[main] f2 not ready after 50ms (expected timeout)\n";
} else {
std::cout << "[main] f2 deferred (usually not in thread pool)\n";
}
int r1 = fut1.get();
int r2 = fut2.get();
int r3 = fut3.get();
std::cout << "[main] results: " << r1 << ", " << r2 << ", " << r3 << "\n";
std::cout << "[main] done\n";
return 0;
}
十、Day5 小结:我今天真正"从零到有"学会了什么?
今天我觉得我学到的不是"会写 EnqueueInt",而是三件更重要的事:
-
线程池的执行端只做一件事:执行
void()任务返回值不靠 return,靠 future 的共享状态通道。
-
future/packaged_task 的责任边界非常清楚
- packaged_task:负责把结果写进去
- future:负责让调用者在合适的时间取出来
-
工程实现会遇到库约束(VS2022 + std::function)
move-only callable 不能进 std::function → 用 shared_ptr 让它可拷贝,这是面试时也能讲清楚的"工程点"。