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 互斥锁 是一脉相承,可以轻易上手,将 线程池 中的 互斥锁 轻易改为 自旋锁
这就留到我们下篇再来介绍吧~本篇写累了,想结束了
总结
要结束喽~