Linux-多线程
进程与线程的区别、联系(Linux内核如何实现线程:用户级线程与内核线程、混合线程,线程的分类)
- 根本区别:进程是系统进行资源分配的基本单位,线程是任务调度和执行的基本单位。
- 内存层面:操作系统每创建一个进程,都会给这个进程分配不同的地址空间,来存储程序所占用的资源,但是线程由于它是共享进程的地址空间的,因此操作系统只为它分配很小一部分内存,TLS,线程局部存储,用来存储线程独有的资源,整体上它是共享进程的资源的。
- 开销方面:进程切换的开销很大,需要地址空间的切换,进程内核栈的切换,进程用户堆栈以及寄存器的切换;但是线程可以看做是轻量级进程,共享进程地址空间,它的切换仅仅是线程栈和 PC 寄存器的保存切换,因此线程切换的开销小。
- 包含关系:一个进程至少有一个线程,用于执行程序,称为主线程,或者单线程程序,因此,线程是包含在进程中的。
通信方式:进程之间的通信需要通过进程间通信(IPC),而同一个进程的各线程之间可以直接通过传递地址或者全局变量的方式传递变量。 在 Linux 内核的实现中,并没有单独的线程概念,线程仅仅被视为与进程共享资源的特殊进程,clone 系统调用中传入 CLONE_VM,即共享父进程地址空间;Linux 下分为用户级线程、内核级线程和混合线程。
多进程和多线程的区别
- 进程数据是分开的,共享复杂,需要用 IPC,同步简单;
- 多线程共享进程数据:共享简单,同步复杂。
- 进程创建销毁、切换复杂,速度慢 ;线程创建销毁、切换简单,速度快
- 进程占用内存多, CPU 利用率低;线程占用内存少, CPU 利用率高
- 进程编程简单,调试简单;线程编程复杂,调试复杂
- 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉
- 进程适应于多核、多机分布;线程适用于多核
一.基础知识
1.并发和并行
为什么需要时间片轮转:
没有时间片轮转,一个线程进入死循环,cpu就干耗着,其他线程就干望着
并发: 单核,只有一个CPU内核流水线,运行的却有多个任务,每个任务占用了一段CPU的时间片。每一个任务实际上还是串行执行 ,但是由于每一个任务占用的时间片时间极短,所以应用层看起来就像多个任务共同执行一样。
并行: 多核,同时可以在不同的核心上对不同任务进行调度,同一时间可以有多个任务运行。但并不是一个核就跑一个任务,也是有时间片轮转的,取决于系统调用
如无刻意区分,并发就行
2.多线程的优势
多线程程序一定好吗?
要问多线程程序一定好吗?不一定,要回答这个问题,需要根据当前程序的类型来做判断:
程序是lO密集型?
程序是CPU密集型?
lO密集型
- IO密集型:程序里面指令的执行涉及IO的操作,比如设备,文件,网络操作(等待客户的连接,客户什么时间连接是不清楚的。因此IO密集型的程序启动后有可能因为IO操作将当前进程阻塞),进程如果阻塞,再分配给该进程时间片就会造成CPU资源极大的浪费,CPU相当于空闲下来了
CPU密集型
- CPU密集型:程序里面的指令都是做计算用的,一直在做大数据的分析,或者做深度学习
对于多核CPU来说,IO密集型和CPU密集型都是有必要的。IO密集型更加适合设计成多线程程序,因为如果IO操作没有准备好会将进程阻塞,阻塞住以后就会被放在blocking queue(阻塞队列)里面。只有满足相应的操作要求才会被放在runnable queue(就绪队列)里面等待CPU执行
单核CPU,IO密集型的程序是适合写成多线程程序的,因为阻塞的进程CPU不会为其分配时间片,转而执行其他就绪的进程;而对于CPU密集型的程序则不适合写成多线程。
原因:对于CPU密集型线程,线程的调度有额外的花费
:线程的上下文切换==》当前线程调度完了该调度下一个线程了。当一个线程调度完后,需要将CPU寄存器消息保存到该线程栈(内核栈)上,保存在线程栈的目的就是保证下一次执行该线程时把上一次只执行了一部分的寄存器信息恢复到CPU的寄存器上,从而接着上一次继续执行。
线程的上下文切换
因此线程的上下文切换就是:当线程从线程一调度到线程二,线程一要先找到自己的线程栈,把此时CPU执行的现场信息(主要是寄存器的值)保存到自己的线程栈(内核栈) 。调度线程二会先访问线程二的内核栈,在内核栈中进行地址的偏移找到CPU现场信息,再从内核栈恢复到CPU的寄存器,然后调度线程二
3.线程的数量
为了完成任务,创建很多的线程可以吗?线程真的是越多越好?
-
1.线程的创建和销毁都是非常"重"的操作
耗时严重,linux上创建线程用的是pthread库,都是由内核创建的,线程的创建和销毁都是非常重的。用户态是没有权限创建线程的,而且创建的线程是需要受内核调度的,所以需要内核来完成线程的创建:所以应用程序创建线程第一步空间的切换,用户态切换到内核态; 第二步到内核空间创建线程,每个线程必须有唯一标识,因此就有线程PCB task_struct 结构,还要给线程分配内核栈,还有相应的页目录,页表,描述地址空间相应的地址结构等等;第三步再一次进行空间切换,到用户空间。因此线程的创建在内核做的事情非常多,为了执行业务创建线程,线程执行完业务销毁线程。故一个线程的创建和销毁消耗的系统内核的性能都不少,即涉及了空间的切换,又涉及了内核一系列数据结构的分配,数据的初始化等等。 如果在业务执行的过程中去实时的创建和销毁线程那就太销毁系统资源了。服务已经开始执行了,例如抢购服务,秒杀服务,此时请求服务是很多的,流量很大,系统的资源应该集中处理请求的,而不应该销毁在线程的创建和销毁上;更应该在秒杀业务之前将线程创建好,在服务的执行过程中,集中资源处理请求
-
2.线程栈本身占用大量内存
32位地址空间,用户空间3G,内核空间1G。当前进程创建的线程共享该进程的地址空间
一个线程栈默认大小8M,3G/8M约等于380,因此一个进程在linux系统下最多创建380多个线程。每一个线程的创建都伴随着线程函数的执行,线程函数执行需要栈空间 ,线程函数执行所需要的栈空间就叫做线程栈,线程栈本身一个就占用8M,创建大量线程,还没有做具体的事情,栈几乎都被占用完了,还怎么做事情,做事情不得需要内存 -
3.线程的上下文切换要占用大量时间
线程过多,线程的调度是需要进行线程上下文切换的,上下文切换花费的CPU时间也特别多,CPU利用率就不高了 -
4.大量线程同时唤醒会使系统经常出现锯齿状负载或者瞬间负载量很大导致宕机
受调度的线程处于线程的就绪队列,受阻塞的线程就放在了阻塞队列,线程过多同一时间,很多阻塞线程获取到资源被转移到就绪队列等待调度,此时系统的负载就特别高。例如一个大型游戏先最小化,过了一会在切回来,可能系统卡半天,甚至蓝屏死掉。此时后台的应用被调到前台,大量线程被唤醒,一瞬间CPU和内存的占用率急剧提升,还有可能虚拟内存交换分区的数据需要快速的导到操作系统物理内存,还要花费很多的磁盘IO
因此线程不是越多越好,像一些优秀的开源网络库muduo,libevent等等 都是基于IO复用+多线程实现的
4.线程池的模式
线程池的优势
操作系统上创建线程和销毁线程都是很"重"的操作,耗时耗性能都比较多,那么在服务执行的过程中,如果业务量比较大,实时的去创建线程、执行业务、业务完成后销毁线程,那么会导致系统的实时性能降低,业务的处理能力也会降低。
线程池的优势就是(每个池都有自己的优势),在服务进程启动之初,就事先创建好线程池里面的线程,当业务流量到来时需要分配线程,直接从线程池中获取一个空闲线程执行task任务即可,task执行完成后,也不用释放线程,而是把线程归还到线程池中继续给后续的task提供服务。
4.1 fixed模式线程池
线程池里面的线程个数是固定不变的,一般是ThreadPool创建时根据当前机器的CPU核心数量进行指定。
4.2 cached模式线程池
线程池里面的线程个数是可动态增长的,根据任务的数量动态的增加线程的数量,但是会设置一个线程数量的阈值(线程过多的坏处上面已经讲过了),任务处理完成,如果动态增长的线程空闲了60s还没有处理其它任务,那么关闭线程,保持池中最初数量的线程即可。
5.线程同步
5.1 线程互斥
当多线程程序都想进入某一块代码段进行执行时,就需要考虑这段代码能不能在多线程环境下执行?就需要看这段代码是不是存在竞态条件 (多线程程序的调度是没有顺序可言的)存在竞态条件的代码段称为临界区代码段,临界区代码段需要防止竞态条件发生就需要保证起原子操作
5.1.1 互斥锁mutex
锁的获取是由内核保证的,一次只能有一个线程获取锁,其他线程就会阻塞在这把锁上,保证每一次进入临界区代码段的只有一个线程。
lock()和unlock()需要由程序运手动添加, 这不符合OOP的编程思想,况且如果发生资源泄漏锁没有被释放,写了unlock()就一定会运行到吗?不一定,一旦锁得不到释放,其他线程得不到锁,就会陷入死锁,所以一般使用智能锁:
lock_guard<>: 只能通过构造加锁,通过析构解锁
unique_lock<>: 有专门提供lock()和unlock()方法,并且提供了右值的赋值重载和右值拷贝构造,所以具有资源转移的功能,可以使用在函数调用过程中。因为有专门提供lock()和unlock()方法所以可以和条件变量结合做线程通信的操作
5.1.2 atomic原子类型
CAS无锁机制:不是说没有锁,而是锁非常轻量
5.2 线程通信
线程互斥研究的是多个线程能不能进同一个代码段。线程通信研究的依旧是多线程,但不是同一个代码块。 一个线程带块的执行依赖于另一个线程代码块执行的结果,此时哪一个线程先执行完全取决于操作系统的调度策略,只有当线程B执行完,通知线程A条件成立,此时线程A才可以执行。对于不同的线程,一个线程代码块的执行依赖另一个线程代码块执行的结果,他们之间就需要线程通信的机制
5.2.1 条件变量 condition_variable:
场景一:
场景二:可以将while(full)改为while(size==1)
cond.wait()做2件事情:
- 1:把锁释放
- 2:把当前线程从运行态变成等待状态
但是cond.wait()要起来 要做2件事情:
- 1:等待cond.notify_all()通知,线程从等待变成阻塞态
- 2:阻塞态抢到锁就会变为就绪态
5.2.2 信号量 semaphore (C++20提供)
互斥锁和信号量的相同点:
区别:
区别
互斥锁做线程同步和线程互斥的信号量做线程同步和线程通信的。信号量包含二元信号量和互斥锁是非常相似的,也可以做线程的互斥,但是在线程互斥的过程中会存在风险,因为信号量归根到底属于线程通信的机制,可以在不同的线程里面调用wait(),直接调用post()也是可以的,此时就会出现问题,线程4手长调用了post()导致线程1还没有执行完临界区代码段,此时线程2wait()成功又进去了。对于互斥锁是不会发生这样的问题的,因为互斥锁在没有获取的情况下,在其他线程直接unlock()是非法的操作,没有实际意义。
二.线程池项目
= = = = fixed模式编写 = = = =
1.项目架构梳理
线程池只能称为库,不能称为中间件(Redis,消息队列),因为他不能像redis一样独立运行,必须依附具体的项目,镶嵌在应用程序里面 作为一个库,本质和STL库一样。所以项目最终要提供成动态库的方式可以给第三方使用
1.1 给用户提供的接口
给用户提供的接口,用户不关心内部操作,所以外部接口应该越简单越好。
ThreadPool pool;//用户使用ThreadPool类定义一个pool对象
pool.setModel(fixed(default)cached);//设置模式,默认fixed模式
pool.start();//启动线程池,创建线程,严阵以待,等待处理的任务
Result result = pool.submitTask(concreteTask)//向线程池提交任务,
result.get().Cast<结果类型>() ;//得到返回结果
本项目线程池的提供不是给某一个场景提供的,所有场景想使用都可以,但对于不同的用户需求不一样,任务完成返回结果的类型不同,有的想返回int,有的想返回数组,有的想返回结构体,众口难调。 因此想要使用一个类型表示线程池返回结果的所有类型,就需要C++17里面的Any类型。
1.2 线程池内部具体操作
- 容器一:存放线程对象
-
- 问题一:线程池有2种模式,所以容器所能容纳的线程对象还不一定是固定数量,所以容器需要动态增长
-
- 问题二:容器里面存放线程对象也要有上限。线程太多了,线程上下文切换所花费是时间比线程真真正正做事情花费的时间还多,就是本末倒置,把CPU时间片浪费了。本来一件事情叫3个人就可以高效完成,你叫来300个人,光协调这300人谁做什么事情,沟通就需要花费老鼻子时间,开始做事情,还没咋做就结束了,整体来看事情是做完了,但是花费的时间远远比3个人花费的时间多得多。其次线程数量过多,线程等待的条件满足后可能会唤醒大量线程起来进行调度,系统憋不过那口气可能宕机挂掉了 。
- 变量:需要保存线程数量,确保上限
- 容器二:放任务
-
- 问题一:任务队列需要考虑线程安全问题。线程对象从任务队列取任务,外部会向任务队列提交任务 。首先对于容器来说不是线程安全的,需要考虑线程安全问题。第二,任务也不能太多了,每一个任务都是需要占内存的 ,任务太大占内存太大,导致正常业务逻辑都无法执行了
- 变量:任务上限
- 任务队列中的任务要能接收外部传入的各式各样的类型的任务。需要继承和多态,只有基类的指针才可以指向各种各样派生类的对象
2.问题一:void ThreadPool::start(int initThreadSize)
如何给每一个线程传递线程函数
线程池创建线程,然后启动线程需要执行一个线程函数。按理说线程函数要由线程池提供,因为线程是线程池创建的,把线程创建起来执行什么样的函数,肯定由线程池指定,而且将来这个线程函数所要访问的变量也都是在线程池对象中 。如何将线程池中的函数传递给线程对象,此时就需要绑定器
//threads_.emplace_back(new Thread(std::bind(&ThreadPool::threadFunc,this)));
auto ptr = std::make_unique<Thread>(std::bind(&ThreadPool::threadFunc, this));
threads_.emplace_back(std::move(ptr));
3.问题二:void ThreadPool::submitTask(std::shared_ptr sp)
用户提交任务,最长不能阻塞超过1s,否则判断提交任务失败,返回
- wait():和时间没有关系,传入参数unique_lock的一个锁,相关的条件得用户自己加判断条件。一直等,等到条件满足才起来,继续向下执行
//while (taskQue_.size() == taskQueMaxThreadHold_)
//{
// notFull_.wait(lock);//1.把线程状态改变;2.释放当前抢到的锁
//}
//上面三行等价于
notFull_.wait(lock, [&]()->bool {taskQue_.size() < taskQueMaxThreadHold_; });
- wait_for():增加一个时间参数,持续等待的时间。也是等待条件满足,但是最多等待传入时间的长度
notFull_.wait_for(lock,std::chrono::seconds(1),
[&]()->bool {taskQue_.size() < taskQueMaxThreadHold_; });//等待一秒,一秒之后返回
if (!notFull_.wait_for(lock, std::chrono::seconds(1),
[&]()->bool {taskQue_.size() < taskQueMaxThreadHold_; }))
{
//表示notFull_等待1s,条件依然没有满足
std::cerr << "task queue is full,submit task fail." << std::endl;
return;
}
- wait_until():传入一个时间终止的节点
4.问题三:void ThreadPool::threadFunc()
防止一个线程将锁拿的时间太长
void ThreadPool::threadFunc()//定义线程执行函数 线程池的所有线程从任务队列里面消费任务
{
for (;;)//线程不断循环从任务队列里面取任务,而不是取一个执行完了就完了
{
//先获取锁
std::unique_lock<std::mutex>lock(taskQueMtx_);
//等待notEmpty条件
notEmpty_.wait(lock, [&]()->bool {return taskQue_.size() > 0; });
//不空就从任务队列中取一个任务
auto task = taskQue_.front();
taskQue_.pop();
taskSize_--;
//应该释放锁,其他线程可以获得锁,从而获取任务。防止一个线程将锁拿的时间太长,表现出来就是多线程程序只有一个线程工作
//当前线程负责执行这个任务
task->run();
}//只有出这个括号线程才会释放锁,表现出来就是,线程将自己拿到的任务玩完了才会释放锁,期间其他线程只能干瞪眼。
}
改进:
void ThreadPool::threadFunc()//定义线程执行函数 线程池的所有线程从任务队列里面消费任务
{
/*std::cout << "begin threadFunc 线程ID:" << std::this_thread::get_id() << std::endl;
std::cout << "end threadFunc 线程ID:" << std::this_thread::get_id() << std::endl;*/
for (;;)//线程不断循环从任务队列里面取任务,而不是取一个执行完了就完了
{
std::shared_ptr<Task> task;
//先获取锁
{
std::unique_lock<std::mutex>lock(taskQueMtx_);
//等待notEmpty条件
notEmpty_.wait(lock, [&]()->bool {return taskQue_.size() > 0; });
//不空就从任务队列中取一个任务
task = taskQue_.front();
taskQue_.pop();
taskSize_--;
}//局部对象出作用域会自动析构,将锁释放
//应该释放锁,其他线程可以获得锁,从而获取任务。防止一个线程将锁拿的时间太长,表现出来就是多线程程序只有一个线程工作
//当前线程负责执行这个任务
if (task != nullptr)
{
task->run();
}
}
}
5.问题四:如何设计run函数的返回值
问题1:如何设计run()的返回值,可以接收任意类型
问题2:如何设计Result机制
class MyTask :public Task
{
void run()
{
std::cout << "begin threadFunc 线程ID:" << std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "end threadFunc 线程ID:" << std::this_thread::get_id() << std::endl;
}
};
需要一个东西可以和提交的任务关联,得到线程的返回值
Result.res=pool.submitTask(std::make_shared<MyTask>());
get()时如果任务没有执行完,会阻塞
res.get();
方案一:返回值使用模板 // ERROR
class Task//任务处理方法
{
public:
virtual T run() = 0;
};
模板函数和虚函数是不能写一起的。因为代码从上往下编译的时候,发现run()是一个虚函数,就需要给当前类产生虚函数表,并将虚函数的地址记录在虚函数表里面,有模板类型的虚函数,函数还没有实例化,函数无从找寻,就没有一个真真正正的函数。
方案二:构建Any上帝类 // OK
Java,Python里面封装有Object类,这个类是所有类的基类,基类指针可以指向派生类对象。C++17也提供了一个Any类也可以完成上述功能,可以接收任意的其他类型。但是为了巩固知识,决定自实现一个Any类。
问题一:如何可以接收任意的其他类型?
毫无疑问需要使用模板template,毫无相关的类型,我怎么知道用户将来使用什么类型,写这个三方库的时候别人还没有使用,所以写库的时候就需要预估不同的用户会返回不同的类型
问题二:有什么办法可以让一个类型指向其他任意的类型呢?
C++中只有基类指针可以指向派生类类型
因此想要设计一个Any类型,它的成员应该是一个基类指针(因为只有基类指针可以指向派生类对象)
问题三:get返回一个Any类型,怎么转成具体类型呢?
因为Any中有一个基类指针指向派生类对象,对象里面的成员变量里面保存了任意的其他类型,所以需要将数据提取出来
Result.res=pool.submitTask(std::make_shared<MyTask>());
int sum=res.get().cast_<int>();
6.问题五:如何设计Result类接收返回值
class Result
{
public:
Result(std::shared_ptr<Task>task, bool isValid = true);
~Result() = default;
//问题一: setval方法,获取任务执行完的返回值的
void setVal(Any any);
//问题二:get方法,用户调用这个方法获取task的返向值
Any get();
private:
Any any_;//存储任务的返回值
Semaphore sem_;//任务线程若没有执行完,用户调用get会被阻塞,所以需要线程通信
std::shared_ptr<Task>task_;//指向对应获取返回值的任务对象。强智能指针,Task对象的引用计数不为0,Task对象是不会析构的
std::atomic_bool isValid_;//返回值是否有效
};
问题一:如何保证用户提前调用get时阻塞
Result不仅仅是获取返回值。提交任务所在的线程和真真正正执行任务所在的线程不是同一个线程,执行任务的线程是线程池里面分配的线程。用户什么时候调用get()方法获取线程执行结果是不清楚的,有可能很早就调用了,此时任务还没有执行完;有可能很晚才调用,此时任务早就执行结束了。此时就得考虑,如果用户调用get()方法早,此时就得阻塞,等待任务线程执行结束
阻塞的前提是任务提交成功。反之任务若提交失败则不需要阻塞
问题二:该如何返回Result对象。
现在面临的东西一个是给线程池提交的任务task,一个是表示task的返回值Result。返回的情况无非以下2种情况:
- 1.Result套在task里面,Result属于task,task提供一个接口得到一个针对该task的Result对象
return task->getResult();
- 2.task隶属于Result里面,使用Result把task包装一下,从而返回Result对象
return Result(task);
两种方法都是可以通过技术实现的,但是不同的场景选择不同的实现。根据线程池场景应该选择第二种实现方法。为什么不能使用第一种实现?
从任务的返回角度,第一种更适合,毕竟返回的是我该任务的返回值生成的对象。但是线程池里面线程执行任务执行完成以后,就会从任务队列里面将该任务pop出来,以后这个任务task对象就析构掉了,也就是说线程池里面的某一个线程执行完task后,task对象就被析构了 而用户还需要Result对象来接收返回值,而且Result是依赖于task的, 也就是说随着task对象被执行完,task对象没了,依赖于task对象的Result对象也没有了,因此用户在调用已经没了的Result对象的get方法是不能得到预期正确的结果。
所以对于用户来说,可能是任务线程结束好久了才会调用Result对象的get方法获取任务结果,对于任务来说,它的生命周期得持续到用户调用Result对象的get方法。即就是只有Result对象析构,task对象才能析构。因此需要使用一个强智能指针指向task对象,Task对象的引用计数不为0,Task对象是不会析构的
问题三:是如何获取任务执行完的返回值的
任务执行完,返回值在run()方法处
Any run(){}
那么该如何拿到任务执行完run的返回值存储到Result对象的Any里面?
问题四:用户调用get方法获取task的返回值。
get方法还需要看看这个任务的返回值有没有,这个任务执行完了吗?没有的话需要阻塞在信号量上
= = = =至此fixed模式编写完成= = = =
= = = = cached模式编写 = = = =
cached 模式 任务处理比较紧急 场景:小而快的任务
1.问题一:主线程
用户自己如何设置线程池的工作模式?
用户调用setMode()函数就可以进行设置。但是需要注意的是设置线程池的工作模式是需要在线程池启动之前就设置好的,并且在线程池启动之后是不允许对线程池状态进行修改的。用户自己调用接口,并不能按照程序编写人员的期望进行调用,因此需要防止用户的错误调用
切换到cached模式,需要记录线程池空闲线程的个数,刚开始线程池空闲线程数量初始化为0,调用线程池start()函数时空闲线程数量加加;执行任务后空闲线程数量就应该减减;任务执行完成后就应该加加
2.问题二:void ThreadPool::submitTask(std::shared_ptr sp)
需要根据任务数量和空闲线程的数量,判断是否需要创建新的线程出来 ?
if (poolmode_ == PoolMode::MODE_CACHED
&& taskSize_ > idleThreadSize_
&& curThreadSize_ < threadSizeThreadHold_)
{
//创建新线程
auto ptr = std::make_unique<Thread>(std::bind(&ThreadPool::threadFunc, this));
threads_.emplace_back(std::move(ptr));
curThreadSize_++;
}
3.问题三:void ThreadPool::threadFunc()
cached模式下,有可能已经创建了很多的线程,但是空闲时间超过60s,应该把多余的线程结束回收掉???
1.怎么区分:超时返回? 还是有任务待执行返回?
notEmpty_.wait_for(lock, std::chrono::seconds(1));//每次阻塞1s后返回
- no_timeout //不是超时返回,是有任务在执行
- timeout // 超时返回
2.如何删除相应的线程对象
线程函数返回,相应的线程也就结束了。关键是如何删除当前线程函数对应的线程对象
threadid => thread对象=>删除
不用获取每一个线程具体的真实id,只需要对其进行标识即可,因此定义一个静态的全局变量,在线程构造的时候进行标识就可以了。
std::vector<std::unique_ptr<Thread>>threads_;
此时放线程的容器就不能是vector了,又因为不需要对线程进行排序,所以使用unordered_map存放线程和其对应的id
std::unordered_map<int, std::unique_ptr<Thread>>threads_;
三.线程池资源回收
问题一: ThreadPool对象析构以后,怎么样把线程池相关的线程资源全部回收?
ThreadPool::~ThreadPool()
{
isPoolRunning_ = false;
notEmpty_.notify_all();
//等待线程池里面所有的线程返回
//线程池里面此时只有2种状态的线程:阻塞 & 正在执行任务中
std::unique_lock<std::mutex>lock(taskQueMtx_);
exitCond_.wait(lock, [&]()->bool {return threads_.size() == 0; });
}
线程池里面此时只有3种状态的线程:状态1:阻塞 & 状态2:正在执行任务中
//线程池要结束,回收线程资源
if (!isPoolRunning_)//阻塞的线程被唤醒后被回收
{
threads_.erase(threadid);//std::this_thread::get_id();
std::cout << "threadid:" << std::this_thread::get_id() << "exit!" << std::endl;
exitCond_.notify_all();//一个线程资源回收,得通知一下阻塞在exitCond_的析构函数,让析构函数判断是否满足条件,如果满足析构函数向下执行,不然析构函数会一直阻塞
return;
}
细节:
一个线程资源回收,得通知一下阻塞在exitCond_的析构函数,让析构函数判断是否满足条件,如果满足析构函数向下执行,不然析构函数会一直阻塞
问题二:避免死锁
只有在fixed模式下才会发生死锁
状态3:
死锁问题的解决方案:
Linux系统下可以使用GDB进行调试,attach到死锁的进程,把该进程中的每一个线程的线程栈(线程执行过程中在那个函数的那一条语句不动了)打印出来。
Windows环境下也可以分析出来:
很明显是死在了资源回收的地方,即就是用户线程pool出作用域析构的时候死了。
只有三个线程
exitCond_.notify_all();
了,执行任务的线程没有,所以导致threads_.size() != 0
,析构函数一直wait,无法向下执行,导致主线程死了。
问题:线程池中的线程为什么没有被回收?以及死锁的原因:
解除死锁:
- 情况一:pool线程先获取锁:
-
- 解决方法:防止进入内层while循环阻塞在notEmpty_上。由于pool析构函数拿到锁的前提是 isPoolRunning_ = false; ,所以判断一下如果
while (isPoolRunning_ && taskQue_.size() == 0)
就不允许进入内层循环,并且跳出外层循环,释放线程资源。将状态1和状态2处理释放线程的操作合并。
- 解决方法:防止进入内层while循环阻塞在notEmpty_上。由于pool析构函数拿到锁的前提是 isPoolRunning_ = false; ,所以判断一下如果
while (isPoolRunning_ && taskQue_.size() == 0)//没有任务
{
if (poolmode_ == PoolMode::MODE_CACHED)
{
}
else//fixed模式:只要线程没有执行任务就一直阻塞
{
//等待notEmpty条件
notEmpty_.wait(lock);
}
/*
//线程池要结束,回收线程资源
if (!isPoolRunning_)//阻塞的线程被唤醒后被回收
{
threads_.erase(threadid);//std::this_thread::get_id();
std::cout << "threadid:" << std::this_thread::get_id() << "exit!" << std::endl;
exitCond_.notify_all();//一个线程资源回收,得通知一下阻塞在exitCond_的析构函数,让析构函数判断是否满足条件,如果满足析构函数向下执行,不然析构函数会一直阻塞
return;//结束线程函数,就是结束当前线程了!
}
*/
}
//线程池要结束,回收线程资源
if (!isPoolRunning_)//阻塞的线程被唤醒后被回收
{
break;
}
- 情况二:线程池里面的线程先获取锁:
-
- 解决方法:由于析构函数后执行,可能还没有将
isPoolRunning_
置为false,所以线程池线程有可能已经进入内层循环阻塞在了notEmpty_上;此后析构函数获取锁后就需要notEmpty_.notify_all();
,才可以将等待在notEmpty_上的线程唤醒继续向下执行,释放线程资源。所以需要
在析构函数中先获取锁 后 notEmpty_.notify_all();
- 解决方法:由于析构函数后执行,可能还没有将
ThreadPool::~ThreadPool()
{
isPoolRunning_ = false;
//notEmpty_.notify_all();
//等待线程池里面所有的线程返回
//线程池里面此时只有2种状态的线程:阻塞 & 正在执行任务中
std::unique_lock<std::mutex>lock(taskQueMtx_);
notEmpty_.notify_all();
exitCond_.wait(lock, [&]()->bool {return threads_.size() == 0; });
}
四.设计
设计:当线程池ThreadPool出作用域析构时,此时任务队列里面如果还有任务,是等任务全部执行完成,再结束;还是不执行剩下的任务了? ? ?
目前设计的线程池是当ThreadPool出作用域析构时,线程就不执行任务了,直接释放线程资源。显然是不符合用户预期效果的。即便是线程池要析构,线程还是需要完成用户提交的任务后才能释放线程资源。
所以需要设计成:等所有任务必须执行完成,线程池才可以回收所有线程资源
void ThreadPool::threadFunc(int threadid)//定义线程执行函数 线程池的所有线程从任务队列里面消费任务
{
/*
std::cout << "begin threadFunc 线程ID:" << std::this_thread::get_id() << std::endl;
std::cout << "end threadFunc 线程ID:" << std::this_thread::get_id() << std::endl;
*/
auto lastTime = std::chrono::high_resolution_clock().now();
//所有任务必须执行完成,线程池才可以回收所有线程资源
//while(isPoolRunning_)//线程不断循环从任务队列里面取任务,而不是取一个执行完了就完了
for(;;)
{
std::shared_ptr<Task> task;
//先获取锁
{
std::unique_lock<std::mutex>lock(taskQueMtx_);
std::cout << "尝试获取任务 threadFunc 线程ID:" << std::this_thread::get_id() << std::endl;
// cached模式下,有可能已经创建了很多的线程,但是空闲时间超过60s,应该把多余的线程
//结束回收掉???
//结束回收掉(超过initThreadsize_数量的线程要进行回收)
//当前时间 - 上一次线程执行的时间 > 60s
//每一秒返回一次 //怎么区分:超时返回? 还是有任务待执行返回
//while (isPoolRunning_ && taskQue_.size() == 0)//没有任务
while (taskQue_.size() == 0)
{
if (!isPoolRunning_)//阻塞的线程被唤醒后被回收
{
//正在执行任务的线程,执行完任务后线程被回收
threads_.erase(threadid);//std::this_thread::get_id();
std::cout << "threadid:" << std::this_thread::get_id() << "exit!" << std::endl;
exitCond_.notify_all();//一个线程资源回收,得通知一下阻塞在exitCond_的析构函数,让析构函数判断是否满足条件,如果满足析构函数向下执行,不然析构函数会一直阻塞
return;
}
if (poolmode_ == PoolMode::MODE_CACHED)
{
//条件变量超时返回了
if (std::cv_status::timeout == notEmpty_.wait_for(lock, std::chrono::seconds(1)))
{ //每次阻塞1s后返回
auto now = std::chrono::high_resolution_clock().now();
auto dur = std::chrono::duration_cast<std::chrono::seconds>(now - lastTime);
if (dur.count() >= THREAD_MAX_IDLETIME
&& curThreadSize_ > initThreadSize_)
{
//开始回收当前线程
//记录线程相关数量的值需要修改
//把线程对象从线程列表容器中删除
//线程函数返回,相应的线程也就结束了。关键是如何删除当前线程函数对应的线程对象
//把线程对象从线程列表容器中删除 没有办法 threadFunc 《 = 》 thread对象
//threadid => thread对象=>删除
threads_.erase(threadid);//std::this_thread::get_id();
curThreadSize_--;
idleThreadSize_--;
std::cout << "threadid:" << std::this_thread::get_id() << "exit!" << std::endl;
return;
}
}
}
else//fixed模式:只要线程没有执行任务就一直阻塞
{
//等待notEmpty条件
notEmpty_.wait(lock);
}
/*
//线程池要结束,回收线程资源
if (!isPoolRunning_)//阻塞的线程被唤醒后被回收
{
threads_.erase(threadid);//std::this_thread::get_id();
std::cout << "threadid:" << std::this_thread::get_id() << "exit!" << std::endl;
exitCond_.notify_all();//一个线程资源回收,得通知一下阻塞在exitCond_的析构函数,让析构函数判断是否满足条件,如果满足析构函数向下执行,不然析构函数会一直阻塞
return;//结束线程函数,就是结束当前线程了!
}
*/
}
/*
* //线程池要结束,回收线程资源
if (!isPoolRunning_)//阻塞的线程被唤醒后被回收
{
break;
}
*/
idleThreadSize_--;//满足条件,执行任务队列中的任务,所以空闲线程减减
std::cout << " threadFunc 线程ID:" << std::this_thread::get_id() <<" 获取任务成功"<< std::endl;
//不空就从任务队列中取一个任务
task = taskQue_.front();
taskQue_.pop();
taskSize_--;
//如果依然有剩余任务,继续通知其它得线程执行任务
if (taskQue_.size() > 0)
{
notEmpty_.notify_all();
}
//取出一个任务,进行通知,通知可以继续提交生产任务
notFull_.notify_all();
}//局部对象出作用域会自动析构,将锁释放
//应该释放锁,其他线程可以获得锁,从而获取任务。防止一个线程将锁拿的时间太长,表现出来就是多线程程序只有一个线程工作
//当前线程负责执行这个任务
if (task != nullptr)
{
//task->run();//执行当前任务;把任务的返回值通过setVal方法给到Result方法
task->exec();//把run方法封装,就可以做比run方法更多的事情
}
idleThreadSize_++;
lastTime = std::chrono::high_resolution_clock().now();//更新线程执行完的时间
}
}
五.Linux环境编译线程池动态库
g++ -fPIC -shared threadpool.cpp -o libtdpool.so -std=c++17
指定使用C++17的标准
注意:系统一般是从
/usr/lib
或/usr/local/lib
目录下找 .a 或 .so
从/usr/include
或/usr/local/include
目录下找 *.h
所以需要把编译好的动态库移动到标准目录下,方便系统链接
- 1.切换root用户
-
- ubuntu:
sudo su
-
- centos:
su root
- 2.改变动态库和头文件的路径
mv libtdpool.so /usr/local/lib/
mv threadpool.h /usr/local/include/
- 3.测试编译链接
g++ main.cpp -std=c++17 -ltdpool -lpthread
- 4.运行
为什么编译的时候都可以找到tdpool.so动态库,运行的时候找不到呢?
因为编译和运行是两码事,编译的时候找动态库是在/usr/local/lib路径下找的,但是运行的时候不是的。
运行的时候到底是去哪里找动态库呢?
在全局的配置文件/etc下面放着
/etc/ld.so.cache
root@VM-16-2-ubuntu:/home/ubuntu/ThreadPool# cd /etc
root@VM-16-2-ubuntu:/etc# ls
运行的时候系统去找所有的.so库,都是在ld.so.cache里面找
,但是我们不需要修改这个文件;只需要修改ld.so.conf这个配置文件就可以
root@VM-16-2-ubuntu:/etc# vi ld.so.conf
意思是包含了/etc/ld.so.conf.d(d表示这个文件本身也是一个目录)这个目录底下所有的.conf文件
root@VM-16-2-ubuntu:/etc# cd /etc/ld.so.conf.d/
因此自己写的动态库要让系统运行的时候找到它,就需要在/etc/ld.so.conf.d/目录下创建一个.conf配置文件,里面写上动态库存放的路径
root@VM-16-2-ubuntu:/etc/ld.so.conf.d# vim mylb.conf
/usr/local/lib
把所有的配置文件里面的路径全部刷新到/etc/ld.so.cache里面,让系统能够在缓存文件里面找到
root@VM-16-2-ubuntu:/home/ubuntu/ThreadPool# ldconfig
此时再运行程序就可以运行成功了,但是会发生死锁
为什么同样的程序在Windows环境下的编译器上就可以好好的运行,但是跨平台移植到Linux平台就发生死锁了呢?
六.Linux环境运行程序再次发生死锁
分析问题:
线程池代码的开发是在Windows下开发的,VS下跑的好好的,但是我想把它放在Linux上编译成动态库,发现又有问题了,程序又死锁了。很纳闷,随着线程池对象析构,线程池里面所有的线程资源都被正常回收,程序正常结束。为什么换个平台,移植到Linux就有问题了呢?
解决问题
ps -u //可以看到运行死锁的进程
gdb有三种调试:
- 1.调试一个新程序
- 2.调试一个正在运行的进程
- 3.调试程序挂掉后的堆转储文件
gdb attack 6359 //可以直接调试当前死锁的程序(正在运行的程序)
info threads //查看当前进程的线程
线程id前面有*,表明当前程序运行在该线程处,即当前所在线程
表现:
1是主线程,线程池中的线程只有4个,表明有一个线程已经exitl1;
2,3,4,5线程都在wait,肯定不是之前遇到的第一个死锁问题,之前的问题已经在Windows下解决了
bt //打印当前线程的线程堆栈
thread 5 //切换线程
Linux下是如何解决死锁问题的
回答参考:
1:使用gdb attach 拉起了正在死锁的进程看了一下,
2:infor thread之后发现所有的线程都在wait,很显然没有人负责把睡觉的线程叫醒,所以发生了死锁;
3:通过thread+线程id ,可以在不同的线程中去切换,并且通过bt可以查看每一个线程的线程调用堆栈
结合代码仔细分析,发现其中主线程是正常的,但是子线程不对,线程池里面的线程当任务执行完之后会将任务的执行结果set到一个result里面,如果之前用户已经get了,那么用户一定会阻塞等待任务的执行结果,任务执行完后会调用信号量的post(),给等待在条件变量上的线程notify一下,用户线程等待结果的地方就会起来继续执行。但是在linux下竟然在notify_all()的地方阻塞起来了
notify_all()是C++的,对应于Linux平台,语言本身是没有条件变量的功能,它调用的还是系统的pthread库里面的pthread_cond_broadcast,实现了一个条件变量的通知。所以这个怎么能lock住呢?
结合代码仔细分析,发现不太对
Result 对象都是局部对象,在出作用域后都要析构
主线程main()函数中提交任务,线程池中线程将任务执行完成后会将结果存储到Result 对象中,并且调用信号量的post()
可以看到,问题就出在cond_.notify_all();
,正常情况下是不能阻塞的,Windows上也是如此,没有阻塞;为什么到Linux平台就发生阻塞了呢?
原因:
任务线程执行完需要花费一定的时间,但是主线程提交完任务是不管任务是否执行完成,
主线程出右括号Result对象也是要析构的
但是Result对象的析构函数是默认的,就是说当前Result对象析构,它的成员变量也是要析构的,问题出现在信号量
,信号量的析构函数也是默认的,也就是说信号量的成员变量(互斥锁,条件变量)都在进行默认的析构,
在VS下条件变量默认的析构函数会释放相应资源
即就是当任务执行完了,将返回值设置到Result对象后,调用信号量的post(),此时信号量已经析构了,相当于notify啥都没有做,不会阻塞,任务正常结束。当然能有如此良好的表现还得感谢微软的开发人员,在实现条件变量的时候,在析构函数里面已经将相应的资源释放了。所以就预估在Linux系统上g++库条件变量的析构没有释放相应的资源,它认为条件变量还正常着,所以在notify( 1:让线程转到等待状态,2:释放mutex锁(注意,锁随着信号量析构,锁也没有用了))的时候阻塞。
[xsy@192 ThreadPool]$ su root
密码:
[root@192 ThreadPool]# find / -name condition_variable
find: ‘/run/user/1000/gvfs’: 权限不够
/usr/include/c++/4.8.2/condition_variable
/opt/rh/devtoolset-7/root/usr/include/c++/7/condition_variable
[root@192 ThreadPool]# vim /opt/rh/devtoolset-7/root/usr/include/c++/7/condition_variable
可以看出Linux平台g++库里面的条件变量啥都没有做 当信号量析构时条件变量的状态失效,无故阻塞(对象已经析构了,相应的资源却没有释放,相当于使用了超过生命周期的资源,肯定是有问题的
),所以就导致最终在Linux平台下notify时阻塞。
修改:
七.使用C++新标准简化线程池
Linux上已经将源代码编译成动态库,因为底层实现都是一行行码的,闭源思想,只给用户头文件和.so动态库。但是接下来的想法就是利用C++已有的新标准,将源码全部放在头文件,即就是开源思想。
问题一:如何能让线程池提交任务更加方便
旧版本的线程池,提交任务很死板,首先得来一个智能指针,其次定义派生类,然后用户根据自定义派生类的参数传参。相当于划了很多的道道,用户使用的时候必须遵守这些道道,很不友好,难免让用户使用起来繁杂。这其实就是C/C++语言门槛高的原因,不像脚本语言简单方便直接 ,脚本语言剥去了很多细节,只暴露核心的东西让用户填写。C/C++所有的东西都需要自己做。现在旧线程池也有这样一个痛点,因此旧需要对其完善。
示例:
目标:线程池不局限于固定的用法,而是将任务函数作为参数直接提交
pool.submitTask(sum1,10,20);
pool.submitTask( sum2,1 ,2,3);
所以submitTask就需要使用可变参模板编程
问题二:如何简化Result及其相关类型
为了接收任务的返回值,自己实现了一个Result及相关类型,代码多,如何利用已有的C++标准库对其进行简化呢?
C++11提供了很多经典的库:
thread类
:创建一个线程对象后就会自动启动一个线程,代码运行在不同的操作系统就会调用不同的系统API。但是thread类没有提供相应的机制来获取线程的返回值
packaged_task
(类似于function函数对象):字面意思就是打包一个任务。该类型就是专门针对thread来说能够很方便的获取一个线程任务执行的返回值。和自定义的Result有异曲同工之妙。#include<future>
提供get_future()以阻塞的方式获取任务的返回值。使用future来代替Result节省线程池代码
async
:和thread使用一样简单,但却更加强大,可以直接获取返回值
修改1:submitTask
使用可变参模板编程,让submitTask可以接收任意任务函数和任意数量的参数
//使用可变参模板编程,让submitTask可以接收任意任务函数和任意数量的参数
//pool.submitTask( sum2,1 ,2,3);
template<typename Func,typename ...Args>
//std::future<返回值类型>// 返回值类型是属于任务函数的(Func有返回值,函数名,参数列表),所以接收函数返回值就需要使用auto
auto submitTask(Func&& func, Args&&...argc)->std::future<decltype(func(argc...))>
//函数返回值是一个future,但是future的尖括号里面是要填写返回值类型,返回值类型根本获取不了;所以就需要通过->后面的表达式推导出来auto的类型
{
//打包任务,放入任务队列
}
修改2:任务队列
原来的写法:
std::queue<std::shared_ptr<Task>>taskQue_;//任务队列
//任务队列使用裸指针是不行的,因为无法保证用户传进来的任务是生命周期很长的,也有可能是一个临时量
修改后写法:
//Task==>函数对象
using Task = std::function<void()>;
std::queue<Task>taskQue_;//任务队列
此时Task是线程池内部封装,不需要担心它的生命周期
因为任务队列里面不能确定用户提交的任务返回值都是具体的什么类型,所以使用void,void又没有办法代替所有任务的返回值类型,所以需要增加一个中间层(一个没有返回值,没有参数的lamda表达式,当调用该任务的时候就执行该表达式)
taskQue_.emplace([task]() {(*task)(); });//task是一个智能指针,解引用就是packaged_task