Day5:线程池进阶——我从「只会跑 void 任务」到「能返回 future」,并用 Demo 验证跑通

这是我操作系统 / 多线程手撕题的 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 启动线程时,线程入口需要一个"可调用对象"。

成员函数要成为"可调用",必须同时提供两样东西:

  1. 成员函数指针:&ThreadPool::Work
  2. 对象指针: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_taskmove-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 的目的(我自己的检查点)

  1. 任务确实跑在 worker 线程,不是在 main 线程同步执行

    • 通过打印 std::this_thread::get_id()
  2. future 确实能拿到结果

    • get() 返回 1、2、3
  3. wait_for 能反映"还没完成"这种状态

    • task2 睡 500ms
    • main 对 fut2 wait_for(50ms) 应该 timeout

输出确实符合预期:

  • task thread id ≠ main thread id
  • wait_for(50ms) timeout
  • get() 得到 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_status
  • wait_until(time_point):等到某个时间点

wait_for 的返回值:std::future_status

  • ready:已就绪
  • timeout:超时未就绪
  • deferred:延迟执行(常见于 std::async 的 deferred 策略;线程池一般不会出现)

我的 demo 就是在用它观察"50ms 时还没好"。


8.3 using namespace std::chrono_literals; 是干什么的?

为了能直接写:

  • 50ms
  • 100ms
  • 2s

否则要写:

  • 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",而是三件更重要的事:

  1. 线程池的执行端只做一件事:执行 void() 任务

    返回值不靠 return,靠 future 的共享状态通道。

  2. future/packaged_task 的责任边界非常清楚

    • packaged_task:负责把结果写进去
    • future:负责让调用者在合适的时间取出来
  3. 工程实现会遇到库约束(VS2022 + std::function)

    move-only callable 不能进 std::function → 用 shared_ptr 让它可拷贝,这是面试时也能讲清楚的"工程点"。


相关推荐
CS创新实验室3 小时前
计算机考研408【操作系统】核心知识点总结
java·linux·考研·计算机·操作系统·408
_Voosk7 小时前
C指针存储字符串为何不能修改内容
c语言·开发语言·汇编·c++·蓝桥杯·操作系统
OpenAnolis小助手8 小时前
构建新计算范式下的开源生态,龙蜥技术生态分论坛回顾来了
开源·操作系统·龙蜥社区·openanolis
OpenAnolis小助手9 小时前
专访 | 深耕八载,双向赋能:阿里云与龙蜥的开源共生之路
开源·云计算·操作系统·龙蜥社区·openanolis
OpenAnolis小助手13 小时前
基于Anolis OS的国产CPU性能优化实践,共推多芯混部时代操作系统新范式
ai·性能优化·开源·操作系统·龙蜥社区·openanolis
重拾梦想1 天前
操作系统 - 文件管理
操作系统
技术性摸鱼1 天前
程序计数器 PC,指令寄存器IR、状态寄存器SR、通用寄存器GR
操作系统·系统架构师
Hello_Embed2 天前
FreeRTOS 入门(二十六):队列创建与读写 API 实战解析
笔记·学习·操作系统·嵌入式·freertos
空x格2 天前
Linux读写苹果APFS系统文件
操作系统