【项目实战】基于protobuf的发布订阅式消息队列(2)—— 线程池

目录

一,介绍

一,异步操作

[1.1 std::future 与 std::async 介绍](#1.1 std::future 与 std::async 介绍)

[1.2 promise::get_future](#1.2 promise::get_future)

[1.3 packaged_task::get_future](#1.3 packaged_task::get_future)

二,线程池实现


一,介绍

基础线程池的实现可以参考:Linux系统编程------线程池_linux内核线程池-CSDN博客

一个线程池其实很容易实现,只需要有一组工作线程,然后加上线程安全的任务队列,然后这些工作线程不断从这个队列里取出任务然后执行,难度不高

但是这样的线程池无法获取这个任务执行的结果,是"只进不出"的一种模式,所以我们需要通过异步操作来让我们能够获取任务执行结果

一,异步操作

1.1 std::future 与 std::async 介绍

官方文档地址:future - C++ Reference

关于 std::future:

  • std::future 是C++11标准库中的一个模板类,它表示一个异步操作的结果,可以帮助我们在需要的时候获取多线程编程中的异步任务的执行结果
  • std::future 的一个重要特性是能够阻塞当前线程,直到异步操作完成,从而确保我们在获取结果时不会遇到未完成的操作

应用场景:

  • 异步任务:当我们需要在后台执行一些耗时操作时,如网络请求或计算密集型任务等,std::future 可以用来表示这些异步任务的结果。通过将任务与主线程分离,我们可以实现任务的并行处理,从而提⾼程序的执行效率
  • 并发控制:在多线程编程中,我们可能需要等待某些任务完成后才能继续执行其他操作。通过使用 std::future,我们可以实现线程之间的同步,确保任务完成后再获取结果并继续执行后续操作
  • 结果获取:std::future 提供了一种安全的方式来获取异步任务的结果。我们可以使用 std::future::get() 来获取任务的结果,此函数会阻塞当前线程,直到异步操作完成。这样,在调用 get() 函数时,我们可以确保已经获取到了所需的结果

概括一下,future 是一个可以获取某个异步线程里面执行的函数执行结果的对象,可以保证在不同的线程中同步访问,有结果就获取,没结果就阻塞住

关于 std::async:

  • 前面说过,futrue是获取异步线程里执行的函数的执行结果,所以 future 的任务就是"获取结果",那么这个异步线程怎么来的呢?执行的函数怎么放到这个异步线程里去执行呢?
  • async 的任务就是负责启动这个异步线程(注意,目前只是单纯介绍一个简单的异步线程,还没有讲解到线程池部分)
  • async是一个函数,有两个参数,第一个参数就是要执行的函数名,第二个就是要传递的参数(可以是多个参数),然后返回一个future对象,相当于在后台建立一个线程然后执行函数,不阻塞当前线程
  • 再举一个更简单的例子,假设async是厨师,我们用户是顾客,厨师负责在后台做饭,而我们顾客要想吃饭就必须通过取餐号(futrue)去取餐,这个餐就是线程的执行结果

代码示例:

cpp 复制代码
#include <iostream>
#include <thread>
#include <future>

int Add(int a, int b)
{
    std::cout << "hello world" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(3));
    return a + b;
}

int main()
{

    std::future<int> res = std::async(std::launch::deferred, Add, 10, 20);
    // 这是 async 的带启动策略参数重载,会在后面单独讲解
    // 这里只要知道deferred的作用是不创建线程与执行,在后面调用 get 或 wait 时才会创建线程和执行
    int num = res.get(); // 调用get后才会执行异步线程里的 Add 函数,如果 Add 没有返回,则会一直等待
    std::cout << num << std::endl;
    return 0;
}

关于 async 带启动策略,主要是两个关键参数:

  • std::launch::async:立即创建线程并执行函数,调用get 和 wait 时函数已经执行完或者正在执行
  • std::launch::deferred:调用时不立即创建线程,不立即执行函数,只有后面调用 get 或 wait时才会创建线程和执行

如果不传这两个,则由系统决定是开线程异步还是延迟执行,这取决于当前线程资源

1.2 promise::get_future

除了 async 还有两个:

直接上代码:

cpp 复制代码
#include <iostream>
#include <thread>
#include <future>

void Add(int a, int b, std::promise<int> &prom)
{
    std::this_thread::sleep_for(std::chrono::seconds(3));
    prom.set_value(a + b); // 可以手动设置结果
    // 建立关系后,可以在一个线程里对这个 promise 对象设置一个值,然后另一个线程可以获取这个值,这样也可以获取异步任务执行的结果,常用于线程之间数据交流
    return;
}

int main()
{
    std::promise<int> pro;
    std::future<int> fu = pro.get_future();      // 类似构建一种关联关系
    std::thread thr(Add, 10, 20, std::ref(pro)); // 这里是强制取引用

    int num = fu.get(); // 如果函数中没有通过 set_value 设置值,则会阻塞,比如这里就是等待三秒后才获取结果
    std::cout << num << std::endl;
    thr.join();
    return 0;
}

1.3 packaged_task::get_future

翻译过来就是"函数包"的意思,功能简单介绍一下就是:把一个函数 / 可调用对象,包装成一个 "将来能拿结果" 的异步任务

它不负责开线程,不负责执行,只负责两件事:

  1. 包装任务
  2. 把任务的返回值 / 异常,塞进一个 future 里

话不多说,直接上代码:

cpp 复制代码
#include <iostream>
#include <thread>
#include <future>

int Add(int a, int b)
{
    std::this_thread::sleep_for(std::chrono::seconds(3));
    return a + b;
}

// packaged_task是一个模板类,实例化的对象可以对一个函数进行二次封装
// 然后可以通过get_future获取一个 future 对象,来获取封装的这个函数的异步执行结果

int main()
{
    // std::packaged_task<int(int, int)> task(Add);
    // std::future<int> fu = task.get_future();

    // std::async(std::launch::async, task, 10, 29); //编译时会报错,模板不匹配之类的错误,因为packaged_task不能拷贝,只能移动,如果这里的task换成 std::move(task) 就不会报错了
    // task(10. 20); //编译不报错,但是只是在这里单纯地执行,无异步效果
    // std::thread thr(task, 10, 20); //编译报错,和 async 同理

    // 所以task可以被当作一个可调用对象来调用执行任务,但是又不能完全当作一个函数来使用
    std::shared_ptr<std::packaged_task<int(int, int)>> ptask = std::make_shared<std::packaged_task<int(int, int)>>(Add);
    std::future<int> fu = ptask->get_future();
    std::thread thr([ptask]()
                    { (*ptask)(10, 20); });

    int num = fu.get();
    std::cout << num << std::endl;
    thr.join();
    return 0;
}

重点:为什么 packaged_task 对象不能被拷贝?

  • 里面有一个promise对象,这个负责把返回值传给 future
  • promise 必须唯一,因为它是结果的唯一发送方,如果拷贝了,就会出现两个 promise 都想往同一个 future 塞结果,这就会导致一系列bug,所以 promise 不能拷贝,所以 packaged_task 也不能拷贝
  • 更细一点说,std::promise 是独占型对象(类似 unique),在底层设计上就是不可拷贝的
  • 我们用的 shared_ptr 是因为我们拷贝的是指针,不是 packaged_task 本身

二,线程池实现

我们选择std::packaged_task 和 std::future 的组合来实现,因为基于线程池执行任务的时候,⼊⼝函数内部执行逻辑是固定的

线程池要做的事情:用户传入要执行的函数,以及需要处理的数据(函数的参数),然后由线程池中的工作线程来执行函数完成任务

线程需要实现的:

管理的成员:

  • 任务池:用 vector 维护的一个函数任务池子
  • 互斥锁&条件变量:实现同步互斥
  • 一定数量的工作线程:用于不断从任务池取出任务执行任务
  • 结束运行标志:以便于控制线程池的结束

管理的操作:

  • 入队任务:入队一个函数和参数
  • 停止运行:终止线程池

线程池代码如下,包括测试,有大量注释详细介绍:

cpp 复制代码
#include <iostream>
#include <functional>
#include <memory>
#include <thread>
#include <future>
#include <mutex>
#include <vector>
#include <atomic>
#include <condition_variable>

class ThreadPool
{
public:
    using Functor = std::function<void(void)>; // 声明一个类型,这个类型是个函数类型

public:
    ThreadPool(int thread_num = 1) : _stop(false)
    {
        for (int i = 0; i < thread_num; i++)
        {
            _threads.emplace_back(&ThreadPool::entry, this);
            // 在向量中直接构造线程对象,第一个参数是指向线程入口函数,this是作为参数传递给成员函数
        }
    }
    ~ThreadPool()
    {
        // 直接调用 stop() 即可
        stop();
    }

    // push 要传入一个函数和若干参数,push内部会将传入的函数封装成一个packaged_task异步任务
    // 然后根据这个异步任务生成lambda表达式的可调用对象,然后扔进任务池中,接着由工作线程取出并执行
    template <typename F, typename... Args>                                     // 第一个表示函数,第二个表示不定参数
    auto push(F &&func, Args &&...args) -> std::future<decltype(func(args...))> // 使用右值引用保持类型,由于不确定函数返回值是什么,所以使用 C++11 的尾置返回类型语法,配合decltype推导出函数返回值
    {
        // 上面这一块文章后面会有详细说明
        // 1,推导返回值类型
        using RetType = decltype(func(args...));

        // 2,将传入函数封装成 packaged_task 对象
        auto tmp_func = std::bind(std::forward<F>(func), std::forward<Args>(args)...); // bind 可以对函数进行参数绑定然后生成可调用对象,这个对象可以不用传参直接调用
        // 为什么要二次封装?因为任务队列只能存一种类型,而用户传给我们的函数类型可能是任意的
        auto task = std::make_shared<std::packaged_task<RetType()>>(tmp_func);
        // 用智能指针是因为packaged_task 不能拷贝

        // 3,获取 future,以后用来获取异步结果
        std::future<RetType> fut = task->get_future();

        // 4,构造出一个 lambda 匿名函数,捕获任务对象,函数内指向任务对象
        std::unique_lock<std::mutex> lock(_mtx);
        _taskpool.push_back([task]()
                            { (*task)(); });
        // 先捕捉匿名对象,通过智能指针直接解引用task

        // 5,通知线程来取任务
        _cv.notify_one();
        return fut;
    }

    void stop()
    {
        if (_stop == true)
            return;
        _stop = true;
        _cv.notify_all();
        for (auto &e : _threads)
            e.join();
    }

private:
    void entry() // 从任务池取出任务然后执行
    {
        while (!_stop)
        {
            // 如果一次只取一个任务的话,就会有频繁的任务取出以及加锁等操作,效率会大大降低,所以在这里定义一个小的容器,能够一次取出多个任务
            std::vector<Functor> tmp_taskpool;
            std::unique_lock<std::mutex> lock(_mtx);
            {
                // 等待任务池不为空,或者 _stop 被置为返回
                _cv.wait(lock, [this]()
                         { return _stop || !_taskpool.empty(); }); // wait第二个参数是被唤醒的条件,使用lamdba,捕获this是为了让这个lamdba能够访问类成员变量
                // 取出任务并执行
                tmp_taskpool.swap(_taskpool); // 一次取出所有任务,典型的空间换时间场景
            }
            for (auto &e : tmp_taskpool)
            {
                e(); // 执行任务
            }
        }
    }

private:
    std::atomic<bool> _stop;        // 原子变量,多线程编程中常用的同步机制,它能够确保对共享变量的操作在执行时不会被其他线程的操作干扰
    std::vector<Functor> _taskpool; // 任务池,任务就是一个一个的函数,通过下面的锁和条件变量控制访问
    std::mutex _mtx;
    std::condition_variable _cv;       // 条件变量
    std::vector<std::thread> _threads; // 工作线程
};

int Add(int a, int b)
{
    return a + b;
}

int main()
{
    ThreadPool pool;
    for (int i = 1; i <= 10; i++)
    {
        std::future<int> fu = pool.push(Add, 10, i);
        std::cout << fu.get() << std::endl;
    }
    pool.stop();
    return 0;
}

关于push函数的说明:

具体说明注释已经解释清楚啦,下面是额外补充说明:

  • 假设传进来的是 int Add(int, int),那么 F 会变成 Add,Args 会变成 int, int 两个,RetType 会变成 int
  • bind 把 函数 f 和参数 args... 绑在一起,变成一个不用传参就能直接调用的东西,然后再二次封装成 package_task 因为只有这样才能让任务返回结果到 future,然后再用 shared_ptr 包起来,因为 packaged_task 不能拷贝
  • 为什么后面把任务放队列里时要用 lamdba?因为任务队列在定义时只能存一种类型
  • push函数就是把用户的函数打包成安全的异步任务,然后扔线程池里,push函数不关心执行过程,只关心结果,最后返回一个future类型给用户,用户就可以根据这个 future 返回值拿到执行结果了
相关推荐
每日任务(希望进OD版)2 小时前
线性DP、区间DP
开发语言·数据结构·c++·算法·动态规划
怨言.2 小时前
Java内部类详解:从基础概念到实战应用(附案例)
java·开发语言
AC赳赳老秦2 小时前
OpenClaw image-processing技能实操:批量抠图、图片尺寸调整,适配办公需求
开发语言·前端·人工智能·python·深度学习·机器学习·openclaw
XiYang-DING2 小时前
【Java】 Java 集合框架
java·开发语言
charlie1145141912 小时前
嵌入式C++教程实战之Linux下的单片机编程(9):HAL时钟使能 —— 不开时钟,外设就是一坨睡死的硅
linux·开发语言·c++·单片机·嵌入式硬件·c
diving deep2 小时前
从零构建大模型--实操--搭建python环境
开发语言·python
We་ct2 小时前
LeetCode 172. 阶乘后的零:从暴力到最优,拆解解题核心
开发语言·前端·javascript·算法·leetcode·typescript
心勤则明2 小时前
Spring AI Alibaba Skills 的渐进式披露与热更新实战
java·后端·spring
netyeaxi2 小时前
Spring:如何查看Spring应用对外提供了哪些API接口?
java·spring