当前位置: 首页 > news >正文

Linux线程池(下)(34)

文章目录

  • 前言
  • 一、v3版本
  • 二、单例模式
    • 概念
    • 特点
    • 简单实现
  • 三、其余问题
    • STL线程安全问题
    • 智能指针线程安全问题
    • 其他锁的概念
  • 总结


前言

  加油!!!


一、v3版本

  「优化版」:从任务队列入手,引入 「生产者消费者模型」,同时引入 RAII 风格的锁,实现自动化加锁与解锁

  当前的 线程池 设计已经完成的差不多了,接下来重点在于完善其他地方,比如 任务队列及锁的优化

  线程池 专注于 任务处理,至于如何确保任务装载及获取时的线程安全问题,交给 「生产者消费者模型」(基于阻塞队列) 就行了,线程池v3版本的代码可以优化成下面这个样子

#pragma once#include <vector>
#include <string>
#include <memory>
#include <functional>
#include <unistd.h>
#include <pthread.h>#include "Task.hpp"
#include "Thread.hpp"
#include "BlockingQueue.hpp" // CP模型#define THREAD_NUM 10template<class T>
class ThreadPool
{using func_t = std::function<void(T&)>; // 包装器public:ThreadPool(func_t func, int num = THREAD_NUM):_num(num), _func(func){}~ThreadPool(){// 等待线程退出for(auto &t : _threads)t.join();}void init(){// 创建一批线程for(int i = 0; i < _num; i++)_threads.push_back(Thread(i, threadRoutine, this));}void start(){// 启动线程for(auto &t : _threads)t.run();}// 提供给线程的回调函数(已修改返回类型为 void)static void threadRoutine(void *args){// 避免等待线程,直接剥离pthread_detach(pthread_self());auto ptr = static_cast<ThreadPool<T>*>(args);while (true){// 从CP模型中获取任务T task = ptr->popTask();task();ptr->callBack(task); // 回调函数}}// 装载任务void pushTask(const T& task){_blockqueue.Push(task);}protected:func_t callBack(T &task){_func(task);}T popTask(){T task;_blockqueue.Pop(&task);return task;}private:std::vector<Thread> _threads;int _num; // 线程数量BlockQueue<T> _blockqueue; // 阻塞队列func_t _func;
};

  之前的 互斥锁、条件变量 相关操作交给 「生产者消费者模型」 处理,线程池 不必关心,关于 「生产者消费者模型」 的实现,大家可自行参考我之前写的文章 《生产者与消费者模型》

  手动 加锁、解锁 显得不够专业,并且容易出问题,比如忘记释放锁资源而造成死锁,因此我们可以设计一个小组件 LockGuard,实现 RAII 风格的锁:初始化创建,析构时销毁

  将这个小组件加入 BlockingQueue.hpp 中,可以得到以下代码

#pragma once#include <queue>
#include <mutex>
#include <pthread.h>
#include "LockGuard.hpp"// 命名空间,避免冲突
#define DEF_SIZE 10template<class T>
class BlockQueue
{
public:BlockQueue(size_t cap = DEF_SIZE):_cap(cap){// 初始化锁与条件变量pthread_mutex_init(&_mtx, nullptr);pthread_cond_init(&_pro_cond, nullptr);pthread_cond_init(&_con_cond, nullptr);}~BlockQueue(){// 销毁锁与条件变量pthread_mutex_destroy(&_mtx);pthread_cond_destroy(&_pro_cond);pthread_cond_destroy(&_con_cond);}// 生产数据(入队)void Push(const T& inData){// 加锁(RAII风格)LockGuard lock(&_mtx);// 循环判断条件是否满足while(IsFull()){pthread_cond_wait(&_pro_cond, &_mtx);}_queue.push(inData);// 可以加策略唤醒,比如生产一半才唤醒消费者pthread_cond_signal(&_con_cond);// 自动解锁}// 消费数据(出队)void Pop(T* outData){// 加锁(RAII 风格)LockGuard lock(&_mtx);// 循环判读条件是否满足while(IsEmpty()){pthread_cond_wait(&_con_cond, &_mtx);}*outData = _queue.front();_queue.pop();// 可以加策略唤醒,比如消费完后才唤醒生产者pthread_cond_signal(&_pro_cond);// 自动解锁}private:// 判断是否为满bool IsFull(){return _queue.size() == _cap;}// 判断是否为空bool IsEmpty(){return _queue.empty();}private:std::queue<T> _queue;size_t _cap; // 阻塞队列的容量pthread_mutex_t _mtx; // 互斥锁pthread_cond_t _pro_cond; // 生产者条件变量pthread_cond_t _con_cond; // 消费者条件变量
};

  最后引入 main.cc,并编译运行程序,查看结果是否正确

#include "ThreadPool.hpp"
#include <memory>typedef Task<int> type;// 回调函数
void callBack(type& task)
{// 获取计算结果后打印std::string ret = task.getResult();std::cout << "计算结果为: " << ret << std::endl;
}int main()
{std::unique_ptr<ThreadPool<type>> ptr(new ThreadPool<type>(callBack));ptr->init();ptr->start();// 还有后续动作while(true){// 输入 操作数 操作数 操作符int x = 0, y = 0;char op = '+';std::cout << "输入 x: ";std::cin >> x;std::cout << "输入 y: ";std::cin >> y;std::cout << "输入 op: ";std::cin >> op;// 构建任务对象type task(x, y, op);// 装载任务ptr->pushTask(task);}return 0;
}

二、单例模式

概念

  代码构建类,类实例化出对象,这个实例化出的对象也可以称为 实例,比如常见的 STL 容器,在使用时,都是先根据库中的类,形成一个 实例 以供使用;正常情况下,一个类可以实例化出很多很多个对象,但对于某些场景来说,是不适合创建出多个对象的

  比如本文中提到的 线程池,当程序运行后,仅需一个 线程池对象 来进行高效任务计算,因为多个 线程池对象 无疑会大大增加调度成本,因此需要对 线程池类 进行特殊设计,使其只能创建一个 对象,换句话说就是不能让别人再创建对象

  在一个程序中只允许实例化出一个对象,可以通过 单例模式 来实现,单例模式 是非常 经典、常用、常考 的设计模式

特点

  单例模式 最大的特点就是 只允许存在一个对象(实例),这就好比现在的 一夫一妻制 一样,要是在古代,单例模式 肯定不被推崇

在这里插入图片描述
  在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百 GB) 到内存中,此时往往要用一个 单例 的类来管理这些数据;在我们今天的场景中,也需要一个 单例线程池 来协同生产者与消费者

简单实现

  单例模式 有两种实现方向:饿汉 与 懒汉,它们避免类被再次创建出对象的手段是一样的:构造函数私有化、删除拷贝构造

  只要外部无法访问 构造函数,那么也就无法构建对象了,比如下面这个类 Signal

#pragma once
33333333333333
#include <iostream>class Signal
{
private:// 构造函数私有化Signal(){}// 删除拷贝构造Signal(const Signal&) = delete;
};

  当外界试图创建对象时
在这里插入图片描述
  当然这只实现了一半,还有另一半是 创建一个单例对象,既然外部受权限约束无法创建对象,那么类内是肯定可以创建对象的,只需要创建一个指向该类对象的 静态指针 或者一个 静态对象,再初始化就好了;因为外部无法访问该指针,所以还需要提供一个静态函数 getInstance() 以获取单例对象句柄,至于具体怎么实现,需要分不同方向(饿汉 or 懒汉)
0
.

#pragma once#include <iostream>class Signal
{
private:// 构造函数私有化Signal(){}// 删除拷贝构造Signal(const Signal&) = delete;public:// 获取单例对象的句柄static Signal* getInstance(){return _sigptr;}void print(){std::cout << "Hello Signal!" << std::endl;}private:// 指向单例对象的静态指针static Signal *_sigptr;
};

注意: 构造函数不能只声明,需要实现,即使什么都不写

为什么要删除拷贝构造?
如果不删除拷贝构造,那么外部可以借助拷贝构造函数,拷贝构造出一个与 单例对象 一致的 “对象”,此时就出现两个对象,这是不符合 单例模式 特点的

为什么要创建一个静态函数?
单例对象也需要被初始化,并且要能被外部使用
调用链逻辑:通过静态函数获取句柄(静态单例对象地址)-> 通过地址调用该对象的其他函数

现在我们来看下饿汉模式

  在程序加载到内存时,就已经早早的把 单例对象 创建好了(此时程序服务还没有完全启动),也就是在外部直接通过 new 实例化一个对象,具体实现如下

#pragma once#include <iostream>// 饿汉模式
class Signal
{
private:// 构造函数私有化Signal(){}// 删除拷贝构造Signal(const Signal&) = delete;public:static Signal* getInstance(){return _sigptr;}void print(){std::cout << "Hello Signal!" << std::endl;}private:// 指向单例对象的静态指针static Signal* _sigptr;
};Signal* Signal::_sigptr = new Signal();

  注:在程序加载时,该对象会被创建

  这里的 单例对象 本质就有点像 全局变量,在程序加载时就已经创建好了

  外部可以直接通过 getInstance() 获取 单例对象 的操作句柄,来调用类中的其他函数

main.cc

#include <iostream>
#include "Signal.hpp"int main()
{Signal::getInstance()->print();return 0;
}

运行结果为
在这里插入图片描述
  这就实现了一个简单的 饿汉版单例类,除了创建 static Signal* 静态单例对象指针 外,也可以直接定义一个 静态单例对象,生命周期随进程,不过要注意的是:getInstance() 需要返回的也是该静态单例对象的地址,不能返回值,因为拷贝构造被删除了;并且需要在类的外部初始化该静态单例对象

#pragma once#include <iostream>// 饿汉模式
class Signal
{
private:// 构造函数私有化Signal(){}// 删除拷贝构造Signal(const Signal&) = delete;public:static Signal *getInstance(){return &_sig;}void print(){std::cout << "Hello Signal!" << std::endl;}private:// 静态单例对象static Signal _sig;
};// 初始化
Signal Signal::_sig;

  饿汉模式 是一个相对简单的单例实现方向,只需要在类中声明,在类外初始化就行了,但它也会带来一定的弊端:延缓服务启动速度

  完全启动服务是需要时间的,创建 单例对象 也是需要时间的,饿汉模式 在服务正式启动前会先创建对象,但凡这个单例类很大,服务启动时间势必会受到影响,大型项目启动,时间就是金钱

  并且由于 饿汉模式 每次都会先创建 单例对象,再启动服务,如果后续使用 单例对象 还好说,但如果后续没有使用 单例对象,那么这个对象就是白创建了,在延缓服务启动的同时造成了一定的资源浪费

  综上所述,饿汉模式 不是很推荐使用,除非图实现简单,并且服务规模较小;既然 饿汉模式 有缺点,就需要改进,于是就出现了 懒汉模式

现在我们来看下懒汉模式

  在 懒汉模式 中,单例对象 并不会在程序加载时创建,而是在第一次调用时创建,第一次调用创建后,后续无需再创建,直接使用即可

#pragma once#include <iostream>// 懒汉模式
class Signal
{
private:// 构造函数私有化Signal(){}// 删除拷贝构造Signal(const Signal&) = delete;public:static Signal *getInstance(){// 第一次调用才创建if(_sigptr == nullptr){_sigptr = new Signal();}return _sigptr;}void print(){std::cout << "Hello Signal!" << std::endl;}private:// 静态指针static Signal *_sigptr;
};// 初始化静态指针
Signal* Signal::_sigptr = nullptr;

  注意: 此时的静态指针需要初始化为 nullptr,方便第一次判断

  饿汉模式 中出现的问题这里全都避免了

  • 创建耗时 -> 只在第一次使用时创建
  • 占用资源 -> 如果不使用,就不会被创建

  懒汉模式 的核心在于 延时加载,可以优化服务器的速度及资源占用

延时加载这种机制就有点像 「写时拷贝」,就赌你不会使用,从而节省资源开销,类似的还有 动态库、进程地址空间 等

  当然,懒汉模式 下也是可以正常使用 单例对象 的

在这里插入图片描述

  这样看来,懒汉模式 确实优秀,实现起来也不麻烦,为什么会说 饿汉模式 更简单呢?

  这是因为当前只是单线程场景,程序暂时没啥问题,如果当前是多线程场景,问题就大了,如果一批线程同时调用 getInstance(),同时认定 _sigptr 为空,就会创建多个 单例对象,这是不合理的

  也就是说当前实现的 懒汉模式 存在严重的线程安全问题

我们现在来证明一下

  简单改一下代码,每创建一个单例对象,就打印一条语句,将代码放入多线程环境中测试

获取单例对象句柄 getInstance() — 位于 Signal 类

static Signal *getInstance()
{// 第一次调用才创建if(_sigptr == nullptr){std::cout << "创建了一个单例对象" << std::endl;_sigptr = new Signal();}return _sigptr;
}

源文件 main.cc

#include "test63.hpp"
#include <iostream>
#include <pthread.h>int main()
{// 创建一批线程pthread_t arr[10];for(int i = 0; i < 10; i++){pthread_create(arr + i, nullptr, [](void*)->void*{// 获取句柄auto ptr = Signal::getInstance();ptr->print();return nullptr;}, nullptr);}for(int i = 0; i < 10; i++)pthread_join(arr[i], nullptr);return 0;
}

在这里插入图片描述
  当前代码在多线程环境中,同时创建了多个 单例对象,因此是存在线程安全问题的

  没有,因为饿汉模式下,单例对象一开始就被创建了,即便是多线程场景中,也不会创建多个对象,它们也做不到

  所以现在我们利用线程互斥锁来保护单例对象的创建

#pragma once#include <iostream>
#include <mutex>// 懒汉模式
class Signal
{
private:// 构造函数私有化Signal(){}// 删除拷贝构造Signal(const Signal&) = delete;public:static Signal* getInstance(){// 加锁保护pthread_mutex_lock(&_mtx);if(_sigptr == nullptr){std::cout << "创建了一个单例对象" << std::endl;_sigptr = new Signal();}pthread_mutex_unlock(&_mtx);return _sigptr;}void print(){std::cout << "Hello Signal!" << std::endl;}private:// 静态指针static Signal *_sigptr;static pthread_mutex_t _mtx;
};// 初始化静态指针
Signal* Signal::_sigptr = nullptr;// 初始化互斥锁
pthread_mutex_t Signal::_mtx = PTHREAD_MUTEX_INITIALIZER;

  注意: getInstance() 是静态函数,互斥锁也要定义为静态的,可以初始化为全局静态锁

  依旧是借助之前的多线程场景,测试一下改进后的 懒汉模式 代码有没有问题
在这里插入图片描述
  结果是没有问题,单例对象 也只会创建一个

  现在还面临最后一个问题:效率问题

  当前代码确实能保证只会创建一个 单例对象,但即使后续不会创建 单例对象,也需要进行 加锁、判断、解锁 这个流程,要知道 加锁 也是有资源消耗的,所以这种写法不妥

  解决方法是在 加锁 前再来一次判断,N个线程,顶多只会进行 N次加锁或解锁,真是极其优雅!!!

static Signal *getInstance()
{// 双检查if(_sigptr == nullptr){// 加锁保护pthread_mutex_lock(&_mtx);if(_sigptr == nullptr){std::cout << "创建了一个单例对象" << std::endl;_sigptr = new Signal();}pthread_mutex_unlock(&_mtx);}return _sigptr;
}

  单纯的 if 判断并不会消耗很多资源,但 加锁 行为会消耗资源,延缓程序运行速度,双检查加锁 可以有效避免这个问题

在这里插入图片描述
  值得一提的是,懒汉模式 还有一种非常简单的新式写法:调用 getInstance() 时创建一个静态单例对象并返回,因为静态单例对象只会初始化一次,所以是可行的,并且在 C++11 之后,可以保证静态变量初始化时的线程安全问题,也就不需要 双检查加锁 了,实现起来非常简单

#pragma once#include <iostream>
#include <mutex>// 懒汉模式
class Signal
{
private:// 构造函数私有化Signal(){}// 删除拷贝构造Signal(const Signal&) = delete;public:static Signal *getInstance(){// 静态单例对象,只会初始化一次,并且生命周期随进程static Signal _sig;return &_sig;}void print(){std::cout << "Hello Signal!" << std::endl;}
};

注意: 静态变量创建时的线程安全问题,在 C++11 之前是不被保障的

  那之前的线程池,你是否可以通过单例模式来进行最终版本优化?

这就交给你来啦!!!

三、其余问题

STL线程安全问题

  STL库中的容器是否是线程安全的?

  答案是 不是!

  因为 STL 设计的初衷就是打造出极致性能容器,而加锁、解锁操作势必会影响效率,因此 STL 中的容器并未考虑线程安全,在之前编写的 生产者消费者模型、线程池 中,使用了部分 STL 容器,如 vector、queue、string 等,这些都是需要我们自己去加锁、解锁,以确保多线程并发访问时的线程安全问题

  从另一方面来说,STL 容器种类繁多,容器间实现方式各不相同,无法以统一的方式进行加锁、解锁操作,比如哈希表中就有 锁表、锁桶 两种方式

  所以在多线程场景中使用 STL 库时,需要自己确保线程安全

智能指针线程安全问题

  C++ 标准提供的智能指针有三种:unique_ptr、shared_ptr、weak_ptr

  首先来说 unique_ptr,这是个功能单纯的智能指针,只具备基本的 RAII 风格,不支持拷贝,因此无法作为参数传递,也就不涉及线程安全问题

  其次是 shared_ptr,得益于 引用计数,这个智能指针支持拷贝,可能被多线程并发访问,但标准库在设计时考虑到了这个问题,索性将 shared_ptr 对于引用计数的操作设计成了 原子操作 CAS,这就确保了它的 线程安全,至于 weak_ptr,这个就是 shared_ptr 的小弟,名为弱引用智能指针,具体实现与 shared_ptr 一脉相承,因此它也是线程安全的

其他锁的概念

  悲观锁:总是认为数据会被其他线程修改,于是在自己访问数据前,会先加锁,其他线程想访问时只能等待,之前使用的锁都属于悲观锁

  乐观锁:并不认为其他线程会来修改数据,因此在访问数据前,并不会加锁,但是在更新数据前,会判断其他数据在更新前有没有被修改过,主要通过 版本号机制 和 CAS操作实现

  CAS 操作:当需要更新数据时,会先判断内存中的值与之前获取的值是否相等,如果相等就用新值覆盖旧值,失败就不断重试

  自旋锁:申请锁失败时,线程不会被挂起,而且不断尝试申请锁

  自旋 本质上就是一个不断 轮询 的过程,即不断尝试申请锁,这种操作是十分消耗 CPU 时间的,因此推荐临界区中的操作时间较短时,使用 自旋锁 以提高效率;操作时间较长时,自旋锁 会严重占用 CPU 时间

  自旋锁 的优点:可以减少线程切换的消耗

#include <pthread.h>pthread_spinlock_t lock; // 自旋锁类型int pthread_spin_init(pthread_spinlock_t *lock, int pshared); // 初始化自旋锁int pthread_spin_destroy(pthread_spinlock_t *lock); // 销毁自旋锁// 自旋锁加锁
int pthread_spin_lock(pthread_spinlock_t *lock); // 失败就不断重试(阻塞式)
int pthread_spin_trylock(pthread_spinlock_t *lock); // 失败就继续向后运行(非阻塞式)// 自旋锁解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);

  就这接口风格,跟 mutex 互斥锁 是一脉相承,可以轻易上手,将 线程池 中的 互斥锁 轻易改为 自旋锁

  这就留到我们下篇再来介绍吧~本篇写累了,想结束了


总结

  要结束喽~

相关文章:

  • 加强LLM防御以实现企业部署
  • 奇异值分解(SVD):线性代数在AI大模型中的核心工具
  • 计算机一次取数过程分析
  • Error: Flash Download failed - Could not load file “xxx.axf“
  • 【Golang进阶】第七章:错误处理与defer——从优雅回收到异常恢复
  • CQF预备知识:Python相关库 -- NumPy 基础知识 - 结构化数组
  • 编码总结如下
  • ssm 学习笔记 day02
  • 【Linux】环境变量完全解析
  • 相机--RGBD相机
  • 【Linux】vim编辑器
  • git查看commit属于那个tag
  • Day 40
  • IM系统的负载均衡
  • windows-cmd 如何查询cpu、内存、磁盘的使用情况
  • Spring Web高保真Axure动态交互元件库
  • 每日Prompt:指尖做画
  • 【论文解读】CVPR2023 PoseFormerV2:3D人体姿态估计(附论文地址)
  • 在Babylon.js中创建3D文字:简单而强大的方法
  • Git的简单介绍分析及常用使用方法
  • 做金融资讯网站需要哪些牌照/播放量自助下单平台
  • 做企业网站的研究现状/seo如何优化关键词上首页
  • 临沂网站公司/营销管理培训课程
  • 网站单页面可以做302跳转吗/seo推广公司价格
  • 北京网站建设知名公司排名/常用的搜索引擎有哪些
  • 做网站选什么配置电脑/搜狗seo快速排名公司