深入解析C++11异步操作线程池 · 生产消费者模型 packaged_task/future 同步机制

文章目录

前言

一、池化思想

二、实现

模型抽象

代码实现

三、细节补充

Q:为什么禁用拷贝构造与赋值重载?

Q:为什么不实现为单例模式?

entry

Q:为什么需要使用临时数组std::vector<Functor> t?

Q:关于条件标量的wait

push

Q:什么是万能引用?

Q:bind 中为什么要使用完美转发?

Q:为什么packaged_task 需要使用智能指针shared_ptr 来指向?

Q: 为什么不是在函数执行完毕再获取future?

Q:条件变量的唤醒操作为什么放在锁外?

Q:为什么“唤醒本身不需要锁”?

在锁内 notify( ❌)

stop

Q: 为什么需要将对 _is_running 的操作放在锁内?

总结


前言

路漫漫其修远兮,吾将上下而求索;


一、池化思想

池化技术的核心思想

提前创建并复用资源,避免频繁创建 / 销毁带来的性能开销。

常见的池化对象包括:

池化对象目的
线程池降低线程创建 / 销毁开销
连接池数据库 / RPC 连接复用
内存池降低 malloc / free 碎片和锁竞争

Q:为什么线程需要池化?

  • 在 Linux / C++ 中,线程的创建和销毁并是存在消耗的,频发地创建、销毁是非常消耗CPU效率的:

创建线程需要:分配栈空间、初始化线程控制块(TCB)、内核态参与调度

高频创建线程会导致:性能抖动、上下文切换成本增加、系统资源不可控

二、实现

模型抽象

Producer–Consumer 模型:

生产者:push() 提交任务、消费者:工作线程 entry() 取任务执行

共享资源:任务队列、条件变量、运行状态标志

代码实现

#pragma once
#include<iostream>
#include<string>
#include<vector>
#include<mutex>
#include<condition_variable>
#include<memory>
#include<atomic>
#include<functional>
#include<thread>
#include<future>

#define threaddefaultnum 3

class threadpool
{
    using Functor = std::function<void()>;
public:
    threadpool(int threadnum = threaddefaultnum)
    :_is_running(true)
    {
        //创建线程池
        for(int i = 0;i<threadnum;i++)
        {
            _threads.emplace_back(&threadpool::entry , this);
        }
    }
    //禁掉拷贝构造、赋值重载
    threadpool(const threadpool& tp) = delete;
    threadpool& operator=(const threadpool& tp) = delete;
    //向任务队列中放任务
    template<typename F,typename ...Args>
    auto push(F&& func , Args&& ... args) ->std::future<decltype(func(args...))>
    {
        using return_type = decltype(func(args...));
        //bind
        auto tmp_func = std::bind(std::forward<F>(func) , std::forward<Args>(args)...);//完美转发
        //利用package_task进行包装
        auto task = std::make_shared<std::packaged_task<return_type()>>(tmp_func);
        //获取futrue
        std::future<return_type> fu = task->get_future();
        //将任务放入任务队列当中
        {
            std::unique_lock<std::mutex> lock(_mutex);
            if(_is_running==false) return std::future<return_type>();
            _tasks.push_back([task](){(*task)();});
        }
        _cv.notify_one();
        return fu;
    }
    //停止线程池
    void stop()
    {
        {
            std::unique_lock<std::mutex> lock(_mutex);
            if(_is_running == false) return;
            _is_running = false;
        }
        _cv.notify_all();
        //等待线程退出
        for(auto& thread : _threads)
        {
           thread.join();
        }
    }
    ~threadpool()
    {
        stop();
    }
private:
    //线程的入口函数
    void entry()
    {
        while(true)
        {
            std::vector<Functor> t;
            {
                std::unique_lock<std::mutex> lock(_mutex);
                _cv.wait(lock , [this](){
                    return !_tasks.empty() || !_is_running;
                });
                t.swap(_tasks);
            }
            //判断是否退出
            if(_tasks.empty() && _is_running==false) 
            {
                std::cout << "线程退出" << std::endl;
                break;
            }
            //执行任务
            for(auto& task : t)
            {
                task();
            }
        }
    }
    
private:    
    //任务队列、线程池、锁 条件变量 标志位
    std::atomic<bool> _is_running;
    std::vector<Functor> _tasks;
    std::mutex _mutex;
    std::condition_variable _cv;
    std::vector<std::thread> _threads;
};

三、细节补充

构造时创建threadnum 个 线程 (传入线程的入口函数entry) --> 向任务队列中push 任务 --> 只要任务队列中任务线程池中的线程就会去处理(锁、条件变量) --> 关闭线程池 (stop)

Q:为什么禁用拷贝构造与赋值重载?

    //禁掉拷贝构造、赋值重载
    threadpool(const threadpool& tp) = delete;
    threadpool& operator=(const threadpool& tp) = delete;

需要与单例模式区别开来,单例模式至少满足的条件:构造函数 private提供全局唯一访问点(如 getInstance())、禁止拷贝 / 移动;

Q:为什么不实现为单例模式?

  • 线程池是 “可配置、可管理的资源对象”, 而并非 “全局工具” ,如果实现为单例模式,破坏可测试性以及难以控制其生命周期;

禁止了拷贝是为了避免线程资源被错误复制,由上层控制线程池的生命周期。

entry

循环进行,任务队列中有任务,线程执行任务;任务队列中没有任务,线程等待不退出;

当标志位 _is_running 为 false 并且 任务队列为空时线程就退出循环;

    //线程的入口函数
    void entry()
    {
        while(true)
        {
            std::vector<Functor> t;
            {
                std::unique_lock<std::mutex> lock(_mutex);
                _cv.wait(lock , [this](){
                    return !_tasks.empty() || !_is_running;
                });
                t.swap(_tasks);
            }
            //判断是否退出
            if(_tasks.empty() && _is_running==false) 
            {
                std::cout << "线程退出" << std::endl;
                break;
            }
            //执行任务
            for(auto& task : t)
            {
                task();
            }
        }
    }

Q:为什么需要使用临时数组std::vector<Functor> t?

  • 在持有锁的时间内尽可能少地执行逻辑代码,将任务从共享队列中批量取出后,在锁外执行,既减少了锁竞争,又避免任务执行过程中长时间占用互斥锁。同时,在高并发场景下,频繁加锁 / 解锁的成本比一次性批量取任务要高,使用临时容器(vector)可以显著减少同步开销,提高吞吐量。

使用临时数组是为了将“取任务”和“执行任务”解耦,缩短临界区长度,减少锁竞争,

Q:关于条件标量的wait

push

传入的函数可能为lambda、普通函数、成员函数、函数对象,并且这些函数参数是任意的、返回值也是任意的,此处的push 必然需要使用模板来支持泛型编程;异步操作需要取出该函数执行的结果(使用package_task + future ),函数push 的返回值类型为用户所传入的函数的返回值类型(使用decltype 进行推导),参数使用万能引用

    template<typename F,typename ...Args>
    auto push(F&& func , Args&& ... args) ->std::future<decltype(func(args...))>

将函数返回类型重命名为return_type --> bind (统一处理,方便将函数放入任务队列当中) --> package_task 包装

using return_type = decltype(func(args...));
//bind
auto tmp_func = std::bind(std::forward<F>(func) , std::forward<Args>(args)...);//完美转发
//利用package_task进行包装
auto task = std::make_shared<std::packaged_task<return_type()>>(tmp_func);

Q:什么是万能引用?

需要注意的是,并不是所有带 && 的均为万能引用,万能引用必须满足一下两个条件:

  • 必须是模板参数推导出来的类型
  • 写成 T&& (或者 auto&&
template<typename T>
void func(T&& x);   // x 是万能引用

auto&& x = expr;    // auto&& 也是万能引用

与右值引用进行区分:

void func(int&& x);     //右值引用,不是万能引用

template<typename T>
void func(const T&& x); //不是万能引用

万能引用会根据实参是左值还是右值推导成 T& 或 T&&

eg. template<typename T>
       void func(T&& x);

传入

T 推导为

x 实际类型

左值

T = U&

U&

右值

T = U

U&&

Q:bind 中为什么要使用完美转发?

看这个错误示例 

template<typename T>
void func(T&& x) {
    other(x);   // ❌
}

即使你这样调用:

func(10);

x在函数体内永远是左值!

所以:

  • 原本的右值被“降级”成了左值

完美转发的存在可以维持参数原本的左右值属性,还可以避免不必要的拷贝;

注:在源代码中 func 可能为lambda、普通函数等,如果没有使用完美转发,那么lambda / 临时对象会被当成左值;

Q:为什么packaged_task 需要使用智能指针shared_ptr 来指向?

衍生问题:可以不用只能指针吗?只能使用shared_ptr吗?paskage_task的底层原理?

packaged_task 内部独占一个可调用对象、一个promise、以及 shared_state“写权限”,可以理解为 packaged_task 是 shared_state 的写端,而 future 是 shared_state 的读端;

注:shared_state 用来表示 promise / future 之间共享的那块状态与数据

一个 shared state 包含:

  • 一个存放结果或异常的位置

  • 一个 ready 标志

  • 用于同步的机制(mutex / condition_variable)

packaged_task 、promise、future 可以指向shared_state,(packaged_task内部包含promise)而packaged_task 对 shared_state 有着写权限(负责将数据放入shared_state 之中),而future对shared_state 有读权限(负责从shared_state 当中读取数据,不能向其中写入);

packaged_task
   │
   │(内部 promise,写)
   ▼
shared_state  <────────  future
      (堆)             (读)

因为packaged_task 中存在shared_state , 所以packaged_task 不支持拷贝,只支持移动(如果支持拷贝的话,读、写端会混乱);所以此处auto task = std::make_shared<std::packaged_task<return_type()>>(tmp_func);  必须使用智能指针,(任务队列中的任务类型为Functor)而由于function要求内部对象必须是支持拷贝的,unique_ptr 不支持拷贝,auto_ptr 更不适用,weak_ptr 无法管理资源,所以此处只能使用shared_ptr ;

简单来说就是:

packaged_taskunique_ptr 都是 move-only 类型,因为它们表示对某种资源的独占所有权;
线程池任务队列通常基于 std::function,要求任务对象可拷贝,因此需要用 shared_ptr 作为可拷贝的间接层。

注: move-only 类型 ,不支持拷贝,只支持移动;

获取future:

//获取futrue
std::future<return_type> fu = task->get_future();

Q: 为什么不是在函数执行完毕再获取future?

future 并不保存结果本身,它和 promise / packaged_task 共享一块“共享状态(shared state),shared_state 的空间开辟在上,由标准库管理

整个异步获取函数执行结果的流程为:

  • 使用packaged_task 封装任务 --> get_future() 获取future --> 任务放入任务队列当中 --> 线程池中的线程执行任务 --> promise(packaged_task 内部包含promise) 将结果写入shared_state 当中  --> get (future )从 shared_state 中拿到执行的结果;

因为shared_state 内部存在同步机制,在函数(任务)执行前获取future 并不会影响获取这个函数执行的结果,并且由于是线程池中的线程领取任务执行,我们也不知道什么时候该任务执行结束,不好在代码层面实现“在函数执行完毕再获取future”;

将任务放入任务队列当中并返回:

        //将任务放入任务队列当中
        {
            std::unique_lock<std::mutex> lock(_mutex);
            if(_is_running==false) return std::future<return_type>();
            _tasks.push_back([task](){(*task)();});
        }
        _cv.notify_one();
        return fu;

Q:条件变量的唤醒操作为什么放在锁外?

条件变量的唤醒通常放在锁外,是为了避免被唤醒的线程立刻因为互斥锁不可用而再次阻塞,从而减少无效的上下文切换,提高并发效率;同时只要共享状态的修改已经在锁保护下完成,唤醒操作本身并不需要再持有锁。

Q:为什么“唤醒本身不需要锁”?

条件变量的唤醒一般放在锁外,是因为唤醒本身不涉及共享状态的访问,如果在锁内唤醒,被唤醒线程会立刻竞争互斥锁,很可能再次阻塞,造成不必要的上下文切换;只要在锁保护下完成状态修改,再释放锁后进行通知,就能保证线程被唤醒时条件已满足且可以立即继续执行,

例子:

在锁内 notify( ❌)

{
    std::unique_lock<std::mutex> lock(m);
    ready = true;
    cv.notify_one();   //在锁内
} // 这里才释放锁

会发生什么?

1️⃣ 线程 B 调用 notify_one

  • 操作系统把线程 A 标记为 “可运行”

2️⃣ 线程 A 被唤醒

  • 必须先重新 lock(m) 才能从 wait 返回

3️⃣ 问题来了:

  • mutex 还在 B 手里 ❌

  • A 只能 再次阻塞在 mutex 上

B 退出作用域,释放锁

  • A 再次被调度
  • 才真正继续执行

结果:

  • A 被“唤醒了一次”
  • 但立刻又被“卡住”
  • 多了一次上下文切换

这就是那句话的意思:

“刚唤醒 → 又睡回去”

stop

判断线程池是否运行 --> 正在运行则置标志位为false --> 唤醒所有线程(如果任务队列中有任务,就会将任务队列中的任务执行完) --> 等待所有线程退出

    //停止线程池
    void stop()
    {
        {
            std::unique_lock<std::mutex> lock(_mutex);
            if(_is_running == false) return;
            _is_running = false;
        }
        _cv.notify_all();
        //等待线程退出
        for(auto& thread : _threads)
        {
           thread.join();
        }
    }

Q: 为什么需要将对 _is_running 的操作放在锁内?

即便是 _is_running 为 atmoic ,由于多线程之间读写并发,只要一个变量被多个线程访问,
且至少有一个写,就必须同步
。将其读写放在互斥锁保护下,可以避免数据竞争并保证状态变化对所有线程可见,从而防止线程无法正确退出或发生未定义行为。


总结

  • 构造时创建threadnum 个 线程 (传入线程的入口函数entry) --> 向任务队列中push 任务 --> 关闭线程池 (stop)
  • entry: 循环(直到任务队列为空&&结束线程池才退出循环)、临时数组swap任务、锁外执行任务
  • push: 模板、万能引用、decltype、bind、完美转发、package_task包装任务、获取future、将任务放入任务队列中(先判断线程是否结束,未结束才放入)、锁外唤醒线程(避免无效的上下文切换)、返回
  • stop : 判断状态、唤醒所有线程(将任务队列中的任务执行完)、等待线程退出

根据原作 https://pan.quark.cn/s/0ed355622f0f 的源码改编 野火IM解决方案 野火IM是专业级即时通讯和实时音视频整体解决方案,由北京野火无限网络科技有限公司维护和支持。 主要特性有:私有部署安全可靠,性能强大,功能齐全,全平台支持,开源率高,部署运维简单,二次开发友好,方便与第三方系统对接或者嵌入现有系统中。 详细情况请参考在线文档。 主要包括一下项目: 野火IM Vue Electron Demo,演示如何将野火IM的能力集成到Vue Electron项目。 前置说明 本项目所使用的是需要付费的,价格请参考费用详情 支持试用,具体请看试用说明 本项目默认只能连接到官方服务,购买或申请试用之后,替换,即可连到自行部署的服务 分支说明 :基于开发,是未来的开发重心 :基于开发,进入维护模式,不再开发新功能,鉴于已经终止支持且不再维护,建议客户升级到版本 环境依赖 mac系统 最新版本的Xcode nodejs v18.19.0 npm v10.2.3 python 2.7.x git npm install -g node-gyp@8.3.0 windows系统 nodejs v18.19.0 python 2.7.x git npm 6.14.15 npm install --global --vs2019 --production windows-build-tools 本步安装windows开发环境的安装内容较多,如果网络情况不好可能需要等较长时间,选择早上网络较好时安装是个好的选择 或参考手动安装 windows-build-tools进行安装 npm install -g node-gyp@8.3.0 linux系统 nodej...
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值