项目简介
项目地址:
https://gitee.com/cai-jinxiang/thread_pool
使用的c++新特性:
-
简单实现 c++ 17 Any,semaphore
-
c++ 14 函数返回值类型推导
-
c++ 11 package_task,future,decltype,lambda表达式,可变参模板,智能指针,thread,函数模板类型推导,引用折叠(package_task的左值引用的拷贝和赋值运算符都被删除了)
-
支持fixed模式和cached模式(任务提交不上时创建新线程,定时清除长期闲置的线程)
-
基于可变参模板和引用折叠,实现submitTask接口,支持任意任务函数及任意数量参数
-
使用future类型异步返回任务结果
-
使用map,queue管理线程对象和任务
-
基于条件变量condition_variable和互斥锁mutex实现任务提交线程和任务执行线程间的通信机制
多线程的优势
多线程程序一定就好吗?不一定,要看具体的应用场景:
IO密集型
程序的指令,涉及IO操作,比如设备,文件,网络操作(等待客户端连接,程序会被阻塞住,若cpu给这样的线程分配时间片,cpu属于空闲下来的)无论是CPU单核、CPU多核、多CPU,都是比较适合多线程程序的
CPU密集型
程序的指令多是在做计算
- CPU单核
多线程存在上下文切换,是额外的花销,线程越多上下文切换所花费的额外时间也越多,倒不如一个线程一直进行计算。 - CPU多核、多CPU
多个线程可以并行执行,对CPU利用率好。
线程数量
- 线程的创建和销毁是非常‘重’的操作
- 线程栈本身占用空间大,线程有自己的栈区,每个线程的用户空间都是共享的,
- 线程调度需要上下文切换,切换花费的cpu时间多
- 大量线程从阻塞队列被唤醒,加入就绪队列,导致系统出现锯齿状负载或者瞬时负载量很大,导致宕机
线程池的优势
操作系统上创建线程和销毁线程都是很"重"的操作,耗时耗性能都比较多,那么在服务执行的过程中,如果业务量比较大,实时的去创建线程、执行业务、业务完成后销毁线程,那么会导致系统的实时性能降低,业务的处理能力也会降低。线程池的优势就是(每个池都有自己的优势),在服务进程启动之初,就事先创建好线程池里面的线程,当业务流量到来时需要分配线程,直接从线程池中获取一个空闲线程执行task任务即可,task执行完成后,也不用释放线程,而是把线程归还到线程池中继续给后续的task提供服务。
fixed模式线程池
线程池里面的线程个数是固定不变的,一般是ThreadPool创建时根据当前机器的CPU核心数量进行指定。
cached模式线程池
线程池里面的线程个数是可动态增长的,根据任务的数量动态的增加线程的数量,但是会设置一个线程数量的阈值(线程过多的坏处上面已经讲过了),任务处理完成,如果动态增长的线程空闲了60s还没有处理其它任务,那么关闭线程,保持池中最初数量的线程即可。
线程同步
- 竞态条件(race condition)指的是两个或者以上进程或者线程并发执行时,其最终的结果依赖于进程或者线程执行的精确时序。在多线程情况下,存在竞态条件,这段代码是不可重入的,称为临界区代码,需要保证原子操作。不存在竞态条件,那么可重入
线程互斥
- 互斥锁
- atomic原子类型
线程通信
- 条件变量condition_variable(使用时需要搭配互斥锁mutex)
//生产者消费者模型
mutex mtx;
condition_variable cond;
//生产者访问临界区
unique_lock<mutex> lock(mtx);//不使用lock_guard的原因是,lock_guard仅仅是构造加锁,析构解锁,无其他接口,信号量需要对锁进行释放
while(full()){
cond.wait(lock);
}
//生产完后,通知消费者
produce();
cond.notify_all();
//消费者访问临界区
unique_lock<mutex> lock(mtx);
while(empty()){
cond.wait(lock);
}
cast();
cond.notify_all();
- 信号量semaphore(线程通信)
看作资源计数没有限制的mutex互斥锁,mutex的计数为1,加锁为0,没人加锁为1.
counting_semaphore sem(0);//资源初始为0
//生产者
procduec();
sem.release();//资源+1
//消费者
cast();
sem.aquare();//资源-1
项目梳理
- 封装线程池成一个库,提供fixed,cached模式,传入任务,返回结果
- 线程池包括一个线程队列,存储线程,线程数量可以扩容
- 一个任务队列需要考虑线程安全,线程不断访问,完成任务,存储一个抽象基类Task指针,当用户想使用此线程池库时,让任务继承Task基类并重写run方法即可,
- ThreadPool类,包含一个Thread的队列,一个Task队列,一个线程函数,函数内容是循环去任务队列中消费线程,创建线程类的时候,把线程函数传给线程类
class ThreadPool {
public:
ThreadPool();
~ThreadPool();
//设置模式
void setMode(PoolMode mode);
//设置任务上限
void setTaskQueMaxThresHold(int thresHold);
//传入一个智能指针,防止用户传入一个临时对象
void submitTask(shared_ptr<Task> sp);
void start(int initThreadSize = 4);
ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;
private:
//定义线程函数,传给线程类,让线程类创建线程时传入即可
void threadFunc();
vector<std::unique_ptr<Thread>> threads_;//线程列表
size_t initThreadSize_; //初始线程数量
//考虑如果用户传入一个临时对象,此处不能用普通指针
queue<shared_ptr<Task>> taskQue_;//任务队列
atomic_uint taskSize_;//任务数量
int taskQueMaxThesHold_;//任务数量上限
mutex taskQueMtx_;//保证任务队列线程安全
condition_variable notFull_, notEmpty_;//表示任务队列不空,不满的条件变量
PoolMode poolMode_;
};
- Thread类,构造函数接收一个函数对象,在start()函数中创建线程去运行函数对象,
//线程类型
class Thread {
public:
//线程函数对象类型
using ThreadFunc = function<void()>;
//线程构造
Thread(ThreadFunc func);
// 线程析构
~Thread();
//启动线程
void start();
private:
ThreadFunc func_;
};
- Task基类,用户想用这个线程库时,将任务继承Task类,重载run函数,传入线程池即可
//任务抽象基类
class Task {
public:
virtual void run() = 0;
};
- 使用方式
ThreadPool pool;
//pool.setMode();
pool.start(4);
class MyTask: public Task{
public:
void run(){ }
};
Result.result = pool.submitTask(std::make_shared<MyTask>());
//使用result.get().Cast<结果类型>(),使用c++17的any获取到任务结果
Any类型的实现
- 当用户想要获取到线程执行的结果时,线程池需要返回一个通用类,但是任务Task是一个基类,run函数是虚函数,虚函数无法写成模板函数,模板函数实例化前不会有地址,但是虚函数的虚函数表,虚函数表需要指向函数地址。
- 因此Task类的run 需要返回一个任意类型,c++17的Any类的实现,返回值为Any,接收的时候,编译器会查看有没有一个合适的构造函数去构造Any
- 注意模板类的声明和定义都在.h文件中
//Any类型
class Any {
public:
Any() = default;
~Any() = default;
//成员是unique_ptr,其把左值引用的拷贝构造删除了
Any(const Any&) = delete;
Any& operator=(const Any&) = delete;
Any(Any&&) = default;
Any& operator=(const Any&&) = default;
//这个构造函数可以接收任意其他数据
template<typename T>
Any(T data) :base_(std::make_unique<Derive<T>>(data)) {}
//这个函数来提取基类指针指向派生类对象的那个模板成员
template<typename T>
T cast_() {
//将自己的基类指针转为派生类指针,通常情况下只能派生类指针转基类指针,但是当基类指针确实是指向派生类对象时,可以转
//dynamic_cast有RTTI识别,能判断基类指针是否指向派生类对象,来决定能否转.
Derive<T>* pd = dynamic_cast<Derive<T>*>(base_.get());
if (pd == nullptr) {
//当用户的Task.run()函数返回值和,调用cast_()的模板参数不相关时,转型失败,
//例如run返回int,但是使用cast_<long>()接收,此时上一行所作的是下面,但是base_指向的是Derive<int>
//dynamic_cast<Derive<int>*>(base_.get());
throw "type is unmatch!";
}
return pd->data_();
}
private:
//基类类型
class Base {
public:
virtual ~Base() = default;
private:
};
//派生类类型
template<typename T>
class Derive : public Base {
public:
Derive(T data) :data_(data) {}
//private:
T data_;
};
//定义一个基类指针
std::unique_ptr<Base> base_;
};
Any类未实现的地方:
- 清除值
- 返回值类型
- 判断值是否有效(放在了resulte类中判断)
信号量的实现
Result.result = pool.submitTask(std::make_shared<MyTask>());
int sum = res.get().cast_<int>();
- 为了实现上述的代码,显然提交任务和执行任务不是一个线程,当用户需要通过submitTask获取到结果时,主线程需要等待执行任务的线程通知结果,c++20实现了semaphore
class Semaphore {
public:
Semaphore(int limit = 0):resLimit_(limit){}
~Semaphore() = default;
//获取一个信号量资源
void wait() {
std::unique_lock<mutex> lock(mtx_);
//等待信号量
cond_.wait(lock, [&]()->bool {return resLimit_ > 0; });
resLimit_--;
}
//增加一个信号量资源
void post() {
std::unique_lock<mutex> lock(mtx_);
resLimit_++;
cond_.notify_all();
}
private:
int resLimit_;//资源计数器
mutex mtx_;
condition_variable cond_;
};
与c++ 20的信号量相比:
c++20的信号量实现了两个版本,
① counting_semaphore 实现非负资源计数的信号量,底层采用的CAS原子变量
② binary_semaphore 仅拥有二个状态的信号量
任务类和结果类互相绑定
Result.result = pool.submitTask(std::make_shared<MyTask>()); //提交任务
int sum = res.get().cast_<int>(); //获取结果,阻塞等待线程完成任务
//Result中的代码
Any Result::get() {
if (!isVaild_) { //构造函数初始化isVaild_,如果提交成功返回的是Result(sp, true)否则返回Result(sp, false)
return "";
}
sem_.wait();
return std::move(any_);
}
void Result::setVal(Any any) {
//存储task
this->any_ = std::move(any);
sem_.post();
}
需要实现以上功能
- 任务执行完成之后,返回值交给Result,但是提交任务是在一个线程,完成任务是另一个线程
- 通过信号量类,两个线程进行通信
- 一个任务类和一个结果类应该互相绑定,并在创建result类的时候,完成绑定。
Result::Result(std::shared_ptr<Task> task, bool isVaild = true)
:isVaild_(isVaild), task_(task) {
task_->setResult(this);
}
void Task::setResult(Result* res) {
result_ = res;
}
class Task {
public:
Task();
~Task() = default;
virtual Any run() = 0;
void setResult(Result* res);
void exec();
private:
Result* result_;
};
Task::Task():result_(nullptr){}
void Task::exec() {
if (result_ != nullptr) {
result_->setVal(run());
}
}
void Task::setResult(Result* res) {
result_ = res;
}
//Result
//实现接收提交到线程池的task任务执行完成后的返回值类型Result
class Result {
public:
Result(std::shared_ptr<Task> task, bool isVaild);
~Result() = default;
//问题一:task_执行结束之后需要赋值给any_,什么时候做?
void setVal(Any any);
//问题二:get()方法,用户调这个方法获取返回值,需要考虑阻塞
Any get();
private:
Any any_;
Semaphore sem_;//线程通信信号量
std::shared_ptr<Task> task_; //指向对应获取任务的返回值的任务对象
std::atomic_bool isVaild_;//任务是否提交完成
};
Result::Result(std::shared_ptr<Task> task, bool isVaild = true)
:isVaild_(isVaild), task_(task) {
task_->setResult(this);
}
Any Result::get() {
if (!isVaild_) {
return "";
}
sem_.wait();
return std::move(any_);
}
void Result::setVal(Any any) {
//存储task
this->any_ = std::move(any);
sem_.post();
}
线程池流程梳理
ThreadPool pool;
pool.start(4);
Result res1 = pool.submitTask(std::make_shared<MyTask>(1, 100000000));
//pool.submitTask(std::make_shared<MyTask>(1, 100000000));
//pool.submitTask(std::make_shared<MyTask>(1, 100000000));
ULong sum1 = res1.get().cast_<ULong>();
cout << "sum1 = " << sum1 << endl;
- 使用者将自己的任务封装并继承Task类,重写task的run方法
- 创建线程池
- 线程池会自动根据模式创建对应数量的线程类(包含一个线程的指针,和函数,调用start方法时,创建线程并传入函数)
- 线程创建完成后会循环去任务队列中获取任务,如果是cached模式,会超时之后回收线程
- 用户传入封装好的任务,返回result类
- 如果是 cached,且任务队列中任务多 会向池中加入新线程
- 用户通过submitTask函数向池中传入一个任务的智能指针,智能指针保证即使Task类出作用域,也不会被析构
- 线程接拿到任务后,将任务从队列中删除(线程的函数是在线程中定义的成员函数,传入时绑定了this指针,因此可以访问到任务队列,并进行修改)
- 线程执行后,Task类中存在一个结果类的成员,会将执行完的结果存入结果类中
遇到的线程回收死锁问题
线程回收的过程:
(cache模式下,Thread类中放了一个lasttime,每次执行完任务都会重置一次,当抢到锁,但发现任务队列空,且是cache模式,线程数量又比设置的大,就与lasttime对比超过60s就回收)
- 线程池结束,修改isPoolRunning,通知所有线程,
- 当线程池处在①(任务队列为空,等待通知)和②(执行任务,执行任务完成后发现isPoolRunning被修改,此线程直接回收)位置时,都能成功回收,
- 当线程池处在③位置时,不论线程池先取锁还是线程先取锁,都缺少了线程池notify_all的那一步,造成线程一直在等待通知阻塞在①
解决方案(对应图片左下角的1和2):
- 锁+双重判断,判断isPoolRunning-》拿锁-》再判断一次isPoolRunning(没有这次判断的话,又进入循环,发现任务队列为空,等待通知,但是此时线程池不会再通知了)
- notify_all移动到取锁之后,当处在③的线程先取到锁,进入循环,此时线程池才完成isPoolRunning的修改,线程会等待在②位置等待通知,此时线程池拿到锁还能进行一次通知,解决思索问题
编译为.so动态库
- 编译器会去/usr/lib /usr/local/lib 找.a .so动态库 去/usr/include /usr/local/include 找.h
- 库名前加上lib,后面加上.so
- 将编译好的动态库放到/usr/lib /usr/local/lib中, 将头文件放到/usr/include /usr/local/include
- 编译测试文件-l加动态库名称,去掉前缀的lib和后缀的.so
- 运行文件报错,原因是运行时找不到动态库
- 在配置文件 /etc/ld/ld.so.cache中保存配置文件,做如下操作,在ld.so.conf.d里面新建文件,写入动态库的路径,然后运行ldconfig,相当于刷新一下配置,就可以运行了
利用c++ 14,17修改线程池
package_task
与function一样是一个函数对象,支持get_future()方法,获取future的返回值,运行结束后future.get()来取得结果
这与submittask返回result异曲同工
使用方式:
int sum(int a, int b) {
return a + b;
}
int main() {
packaged_task<int(int, int)> task(sum);
future<int> ret = task.get_future();
thread t1(move(task), 10, 20);//packaged_task的左值引用的拷贝构造被删除了
t1.detach();
cout << ret.get() << endl;//阻塞等待task完成
return 0;
}
使用此方式代替Task和result
重写submitTask,引用折叠(package_task只能右值引用拷贝)和可变参模板
使用auto 进行返回值的类型推导,返回值类型需要写在函数名后面,写前面编译器不认识func
template<typename Func, typename... Args>
auto submitTask(Func&& func, Args&&... args) -> std::future<decltype(func(args...))>
{
}
任务队列
// 线程函数对象类型,不同的任务返回值不同,因此使用中间层进行封装,将任务封装为一个返回void的对象,返回值通过package_task.getfuture获取,线程类中的线程函数的定义:
using ThreadFunc = std::function<void(int)>;
//线程池类中Task的定义,将这个放入线程池
using Task = std::function<void()>;
std::queue<Task> taskQue_; // 任务队列
//提交任务 加入线程池 简化代码 注意如何提交一个通用类型的task
template<typename Func, typename... Args>
auto submitTask(Func&& func, Args&&... args) -> std::future<decltype(func(args...))>
{
// 打包任务,放入任务队列里面
using RType = decltype(func(args...));
//1.任务被打包为一个packaged_task对象 一个函数对象的智能指针,一个返回值为RType,参数为空的函数指针,其实参数不为空,参数是被bind绑定了
auto task = std::make_shared<std::packaged_task<RType()>>(
std::bind(std::forward<Func>(func), std::forward<Args>(args)...));
std::future<RType> result = task->get_future();
// 获取锁
std::unique_lock<std::mutex> lock(taskQueMtx_);
// 用户提交任务,最长不能阻塞超过1s,否则判断提交任务失败,返回
if (!notFull_.wait_for(lock, std::chrono::seconds(1),
[&]()->bool { return taskQue_.size() < (size_t)taskQueMaxThreshHold_; }))
{
// 表示notFull_等待1s种,条件依然没有满足
//任务提交失败,返回一个空任务的返回值
std::cerr << "task queue is full, submit task fail." << std::endl;
auto task = std::make_shared<std::packaged_task<RType()>>(
[]()->RType { return RType(); });
(*task)();
return task->get_future();
}
// 如果有空余,把任务放入任务队列中
// taskQue_.emplace(sp);
// using Task = std::function<void()>;
//任务队列中是返回void的函数对象(因为不知道具体返回值)而此时提交的不是void,创建一个返回void的中间层,将Task包进去,在返回void的匿名函数里面直接调用(*task)()即可
taskQue_.emplace([task]() {(*task)(); });
taskSize_++;
// 因为新放了任务,任务队列肯定不空了,在notEmpty_上进行通知,赶快分配线程执行任务
notEmpty_.notify_all();
// 返回任务的Result对象
return result;
}
线程池的收获
- Any类,Semaphore的实现,不足
- 死锁问题的解决
- 在linux上遇到死锁,问题在任务提交上去之后,返回的Resulte类马上析构了,但是线程最终都死锁在了cond_.wait上。
分析原因:- 自己实现的semaphore使用了锁和条件变量,使用的析构函数是默认析构,调用的也是成员变量的默认析构,
- 在vs下锁和条件变量都会释放资源,当线程执行完Task,填写resulte的时候,那些资源都被释放了,什么都不会发生。
- 但是linux的g++里面锁和条件变量的默认构造也什么都没有做,因此都阻塞在了cond_.wait上,在vs上cond_已经被析构了,不会再执行了。
- 在v1.0版本中,用户需要继承Task类,将Task类传入线程池,如果传入的Tsak是普通指针,那么客户如果析构,线程池中的Task也会消失,任务提交完成了但是却不会执行。因此需要提交智能指针。
- v2.0通过packaged_task,future,可变参模板,将1.0的Task剔除,用户只需要提供函数和参数就可以
实现方式:
template<typename Func, typename... Args>
auto submitTask(Func&& func, Args&&... args) // -> std::future<decltype(func(args...))> //c++14开始auto可以进行函数返回值推导
{
// 打包任务,放入任务队列里面
using RType = decltype(func(args...));
//1.任务被打包为一个packaged_task对象 一个函数对象的智能指针,一个返回值为RType,参数为空的函数指针,其实参数不为空,参数是被bind绑定了
auto task = std::make_shared<std::packaged_task<RType()>>(
//std::bind(std::forward<Func>(func), std::forward<Args>(args)...));
std::bind(func, std::forward<Args>(args)...)); //放入线程池任务队列中的是绑定好参数的函数指针
std::future<RType> result = task->get_future();
//将任务加入队列...
//当任务队列满的情况,进行增加线程(如果cached模式)
//返回future
}