线程基础复习
之前在Linux阶段学习APUE时,我们学过进程的相关概念、后面又学了线程的相关概念。而现在我们主要是来进行线程的进一步学习。之前在Linux里面使用的是C语言面向过程的思想,从现在开始我们需要使用C++面向对象的思想进行封装,但是在封装之前我们先来回顾一下线程相关的API。
线程的创建
#include <pthread.h>
//函数原型
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine) (void *),
void *arg);
//参数说明
thread:线程id
attr:线程属性,默认为空
start_routine:线程入口函数
arg:线程入口函数的参数,默认可以使用空
更详细的内容:
线程终止
#include <pthread.h>
//功能:线程终止(注意:进程终止是exit函数)
//函数原型
void pthread_exit(void *value_ptr);
//参数说明
参数value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
更详细的内容:
线程等待
#include <pthread.h>
//函数原型
int pthread_join(pthread_t thread, void **value_ptr);
//参数说明
参数thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
更详细的内容:
为什么需要线程等待?
1、已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
2、创建新的线程不会复用刚才退出线程的地址空间。
3、主线程需要知道所创建的新线程是否有完成任务,并且可以避免像僵尸进程的问题。
线程取消
#include <pthread.h>
//函数原型
int pthread_cancel(pthread_t thread);
//参数说明
参数thread:线程ID
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止。
thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数
PTHREAD_ CANCELED(这是一个宏定义,本质就是-1)。
如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给
pthread_exit的参数
如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数
更详细的内容:
线程分离
int pthread_detach(pthread_t thread);
为什么需要使用线程分离
这是因为下面两点:
1、默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无
法释放资源,从而造成系统资源泄漏。
2、如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动
释放线程资源。
使用方法有下面两种:
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:pthread_detach(pthread_self())
;
使用效果:线程分离以后,主线程以后就再也不需要关心这个线程了,在这个线程结束以后,会自动的进行资源释放。
但是这里有一点要注意:如果这个分离的线程出现了问题,依旧会导致进程的退出。
面向对象与基于对象的代码封装
面向对象具有四大基本特征:抽象、封装、继承、多态,所以我们可以从面向对象的思想对线程进行封装。
面向对象的线程封装
有了抽象的概念之后,现在我们将线程抽象成一个类,用类的观点来看线程。
然后用线程类去创建具体的对象,线程对象与线程对象之间进行交互,通信。
接下来就为线程类设置成员,每个线程会有线程号,可以设置为数据成员;线程是否开始运行、线程是否结束,对应会有 start 函数与 join 函数;
线程入口函数可以封装为一个函数 threadFunc,然后具体需要执行的任务可以封装为一个 run 方法,具体的业务留给 Thread 的派生类去实现,所以 run 方法可以设置为虚方法,Thread 类也就成为抽象类了。
为了实现具体的任务方法,可以再用一个类去继承抽象类 Thread,并实现虚方法 run,所以可以设置为下面的关系图:
代码实现
一般在写 C++ 代码的时候都会将源码分成头文件和实现文件,特别是代码内容多了之后分开之后的好处会非常明显,真正的 C++ 程序员日常工作也是这样的。
目录结构如下:
a.out是执行文件,TestThread.cc是测试文件,Thread.cc是实现文件,Thread.h是头文件。
Thread.h 头文件实现:
//下面这两行代码是预处理指令,用于防止头文件被多次包含
//如果__THREAD_H__没有被定义,则定义它,并继续编译下面的代码
//这是一种常见的防止头文件被重复包含的技巧
#ifndef __THREAD_H__
#define __THREAD_H__
#include <pthread.h>
class Thread{
public:
Thread();
//将析构函数设置为虚,用于确保当通过当前基类指针删除
//派生类对象时,派生类的析构函数也会被调用
virtual ~Thread();
//线程启动运行函数
void start();
//等待线程结束函数
void join();
private:
//成员函数threadFunc作为线程的入口函数,形式符合
//posix线程库的对线程函数的要求
//为什么要设计成static是为了满足pthread_create的传参要求
static void* threadFunc(void *arg);
//run()是一个纯虚函数,由派生类实现具体的线程执行代码
//纯虚函数定义在基类(也称为抽象基类)中,但不包含任何实现(即没有函数体),
//仅用于强制要求派生类(子类)必须实现该函数。
//纯虚函数通过在函数声明的末尾加上 =0 来标识。
virtual void run() = 0;
private:
//线程id标识
pthread_t _thid;
//标识线程是否在运行
bool _isRunning;
};
//结束预处理指令,标志着头文件的结束
#endif
Thread.cc 文件实现:
//引入头文件,在这个文件中将头文件的内容进行实现
//注意自己写的头文件,需要使用双引号引入
//尖括号引入的是系统库
#include "Thread.h"
#include <stdio.h>
//实现构造函数,主要就是初始化两个成员变量
Thread::Thread()
: _thid(0) //一开始不知道线程id,使用0初始化
, _isRunning(false) //一开始肯定是没运行的嘛
{
}
Thread::~Thread(){
if(_isRunning){
pthread_detach(_thid);
}
}
//实现start函数:创建线程
void Thread::start(){
//创建线程
//第三个参数是传递一个函数指针,其指向一个函数类型为 void* (void*) 的函数
//对于C风格的普通函数来说,其函数名就是地址,因此直接传入函数名即可
//函数返回值为0创建线程成功
//另外这里需要将头文件中的threadFunc设置为静态的成员函数,不然会报错
//因为对于pthread_create函数的第三个线程入口函数地址参数而言
//其形式必须为void* (*funcName)(void*),即只能有一个void*类型的函数
//而C++类中的成员函数参数列表中的第一个位置会有一个隐含this指针参数
//不符合上述形式,因此将threadFunc设置为静态的,此时this指针就会消失
int ret = pthread_create(&_thid,nullptr,threadFunc,this);
if(ret){
perror("pthread_create()");
return;
}
//创建线程成功,那么让_isRunning=true
_isRunning = true;
}
//实现主线程等待子线程退出的函数
void Thread::join(){
//如果线程是在运行的
if(_isRunning){
//那么等待回收子线程资源
pthread_join(_thid,nullptr);
_isRunning = false;
}
}
//实现线程的入口函数
void* Thread::threadFunc(void* arg){
//静态成员函数threadFunc无法直接调用非静态成员函数run
//原因是静态成员函数没有 this 指针
//而且非静态成员函数也没办法通过类名调用,只能通过对象调用
//所以我们选择通过pthread_create函数的第四个参数传进this指针即可
//所以这第四个参数就是线程入口函数pthreadFunc的参数
Thread* pthread = static_cast<Thread*>(arg);
if(pthread){
pthread->run();//实现真正的任务
}
//直接 return nullptr 也 ok
pthread_exit(nullptr);
}
TestThread.cc 文件实现:
#include <iostream>
#include <unistd.h>
#include <memory>
#include "Thread.h"
using namespace std;
//公有继承一下 Thread
class MyThread: public Thread{
public:
void run() override{
while(1){
cout << "The thread is running" << endl;
sleep(1);
}
}
};
int main(){
//Thread* pthread = new MyThread();
//使用智能指针自动回收指针资源
unique_ptr<Thread> pthread(new MyThread());
//启动子线程
pthread->start();
//等待回收子线程
pthread->join();
return 0;
}
运行结果:
基于对象的线程封装
解决问题的思维方式很多,除了之前C语言的面向过程的方法,以及上面的面向对象的方法之外,还有一种方式。
我们前面学习过,类和类之家的关系除了继承,还有一些其他的关系,所以可以通过组合与依赖的方式,也就是基于对象的方法(不用使用继承)。因为没有继承,所以需要把 Thread 类中的抽象方法 run() 函数通过 function 与 bind 的方式进行修改即可。之前的run方法是一个返回类型是 void 参数为空的函数,现在直接使用 bind 改变函数的形态,将 Thread 变为一个非抽象类。然后将任务通过参数传给 Thread 的构造函数,当然这个参数可以使用对应的数据成员进行接收,这个任务可以交给其他的类来完成,并且打包给Thread线程进行执行,类图设计如下:
代码实现
目录结构同上,代码略有差异。
Thread.h:
//下面这两行代码是预处理指令,用于防止头文件被多次包含
//如果__THREAD_H__没有被定义,则定义它,并继续编译下面的代码
//这是一种常见的防止头文件被重复包含的技巧
#ifndef __THREAD_H__
#define __THREAD_H__
#include <pthread.h>
#include <functional>
//重定义一个函数类型为 void() 的函数名 ThreadCallback
//这是回调函数,用来执行子线程 run 方法的
using ThreadCallback = std::function<void()>;
class Thread{
public:
//构造函数中传入这个用于初始化
Thread(ThreadCallback&& cb);
~Thread();
//线程启动运行函数
void start();
//等待线程结束函数
void join();
private:
//成员函数threadFunc作为线程的入口函数,形式符合
//posix线程库的对线程函数的要求
//为什么要设计成static是为了满足pthread_create的传参要求
static void* threadFunc(void *arg);
private:
//线程id标识
pthread_t _thid;
//标识线程是否在运行
bool _isRunning;
//我们将线程要执行的任务也就是 run 方法给去掉
//因此现在使用基于对象的方式,使用回调函数来完成这个 run
ThreadCallback _cb;
};
//结束预处理指令,标志着头文件的结束
#endif
Thread.cc:
//引入头文件,在这个文件中将头文件的内容进行实现
//注意自己写的头文件,需要使用双引号引入
//尖括号引入的是系统库
#include "Thread.h"
#include <stdio.h>
//实现构造函数,主要就是初始化两个成员变量
Thread::Thread(ThreadCallback&& cb)
: _thid(0) //一开始不知道线程id,使用0初始化
, _isRunning(false) //一开始肯定是没运行的嘛
, _cb(std::move(cb))
{
}
Thread::~Thread(){
if(_isRunning){
pthread_detach(_thid);
}
}
//实现start函数:创建线程
void Thread::start(){
//创建线程
//第三个参数是传递一个函数指针,其指向一个函数类型为 void* (void*) 的函数
//对于C风格的普通函数来说,其函数名就是地址,因此直接传入函数名即可
//函数返回值为0创建线程成功
//另外这里需要将头文件中的threadFunc设置为静态的成员函数,不然会报错
//因为对于pthread_create函数的第三个线程入口函数地址参数而言
//其形式必须为void* (*funcName)(void*),即只能有一个void*类型的函数
//而C++类中的成员函数参数列表中的第一个位置会有一个隐含this指针参数
//不符合上述形式,因此将threadFunc设置为静态的,此时this指针就会消失
int ret = pthread_create(&_thid,nullptr,threadFunc,this);
if(ret){
perror("pthread_create()");
return;
}
//创建线程成功,那么让_isRunning=true
_isRunning = true;
}
//实现主线程等待子线程退出的函数
void Thread::join(){
//如果线程是在运行的
if(_isRunning){
//那么等待回收子线程资源
pthread_join(_thid,nullptr);
_isRunning = false;
}
}
//实现线程的入口函数
void* Thread::threadFunc(void* arg){
Thread* pthread = static_cast<Thread*>(arg);
if(pthread){
pthread->_cb();//实现真正的任务,通过回调函数形式
}
//直接 return nullptr 也 ok
pthread_exit(nullptr);
}
TestThread.cc:
#include <iostream>
#include <unistd.h>
#include <memory>
#include "Thread.h"
using namespace std;
//公有继承一下 Thread
class MyThread {
public:
//线程真正要执行的任务
void process(){
while(1){
cout << "The thread is running" << endl;
sleep(1);
}
}
};
//线程真正要执行的任务,普通函数也ok
void process_normal(){
while(1){
cout << "The thread task is running" << endl;
sleep(1);
}
}
int main(){
MyThread myThread;
//对于类的成员函数而言,因为我们的回调函数类型为 void ()
//而类成员函数会有一个隐形的参数this指针位于参数列表中的第一个位置
//这与我们需要的回调函数是不相符的,因此直接传是无法传入的
//此时就需要bind函数进行绑定,将 void(this) 函数类型转换为 void()
Thread pthread(std::bind(&MyThread::process,&myThread));
Thread pthread_normal(process_normal);
//启动子线程
pthread.start();
pthread_normal.start();
//等待回收子线程
pthread.join();
pthread_normal.join();
//main作为主线程,要等上面两个子线程结束之后
//才可以结束,或者说继续执行main自己的代码逻辑
return 0;
}
运行结果如下:
生产者与消费者问题
问题概述
如上图所示,所谓的生产者和消费者都是一个线程,生产者与消费者之间需要进行交互,生产者生产数据往缓冲区内放,生产一个放一个,消费者则会从仓库里面拿数据,此时这个缓冲区就是一个共享资源。
生产者与消费者在进行对缓冲区的访问时,为了使得线程同步所以需要获得锁才可以,获得了锁的线程才能进行访问,访问完之后再释放掉锁等其它线程竞争获取。
那为什么还需要条件变量呢?
来看这么个情况,如果消费者竞争到了锁,然后进入了缓冲区,但此时缓冲区内并没有数据(这是一个没有意义的操作),是不是就造成了锁资源的浪费?(拿到了锁却没有做到该线程应该做的事情,若是生产者拿到了就可以生产数据了呀,而且万一一直都是消费者拿到锁呢?)所以此时就需要条件变量的存在,条件变量的作用就是告诉该线程缓冲区内是否存在数据,若是有数据那么条件变量的条件成立,此时消费者线程才会真正地去拿锁上锁进行缓冲区的访问。
生产者与消费者问题,本质就是多个线程的问题,但是会涉及到线程之间对共享资源的互斥访问,所以需要有互斥锁与条件变量的准备知识,接下来我们先看看互斥锁与条件变量。
复习:互斥锁mutex
数据混乱的三个原因
1、资源共享(独享资源则不会出现混乱)
2、调度随机(意味着数据访问会出现竞争)
3、线程间缺乏必要的同步机制
总结:前面两个条件是不能改变的,因此若想让数据井然有序,只能改变第三个原因。
互斥锁的创建
pthread_mutex_t mutex;//直接定义一把互斥锁
更详细的内容:
互斥锁的初始化
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
//功能: 初始化互斥锁
//参数解释
mutex:pthread_mutex_t类型的变量的地址,也就是互斥锁的名字。
mutexattr:pthread_mutexattr_t类型的变量地址,用于设置锁的属性,默认可以设为NULL;
返回: 成功0,错误返回错误代码。
静态初始化:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态初始化:pthread_mutex_init(&mutex, nullptr);
更详细的内容:
互斥锁的销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//功能: 销毁互斥锁
参数: mutex为要销毁的互斥锁的地址
返回: 成功0,错误返回错误代码
互斥锁的加锁
//这个操作是阻塞调用的,如果这个锁此时正在被其它线程占用,那么函数调用会进入到这个锁的排队队
//列中,并会进入阻塞状态,直到拿到锁之后才会返回
int pthread_mutex_lock(pthread_mutex_t *mutex);
//会尝试对互斥量加锁,如果该互斥量已经被锁住,函数调用失败,返回EBUSY,否则加锁成功返回0,
//线程不会被阻塞
int pthread_mutex_trylock(pthread_mutex_t *mutex);
更详细的内容:
互斥锁的解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
复习:条件变量condition
条件变量的创建
pthread_cond_t cond;//定义一个条件变量
条件变量的初始化
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
功能:初始化条件变量
cond:条件变量名的地址
cond_attr:条件变量的属性
返回: 成功0,错误返回错误编号
静态初始化:pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
动态初始化:pthread_cond_init(&cond, nullptr);
更详细的内容如下:
条件变量的销毁
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
功能:销毁条件变量
cond:条件变量名的地址
返回: 成功0,错误返回错误代码
条件变量的等待
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
功能:等待互斥锁mutex下的条件cond的发生
cond:要等待的条件变量
mutex:保护条件变量的互斥锁
返回:成功0,错误返回错误代码
//函数作用:
//1、阻塞等待条件变量cond满足。
//2、释放已掌握的互斥锁(解锁互斥量)相当于pthread_mutex_unlock(&mutex); 第1,2步为一个原子操作
//3、当被唤醒,pthread_cond_wait函数返回,解除阻塞并重新获得互斥锁
pthread_mutex_lock(&mutex)
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
更详细的内容如下:
条件变量的唤醒
int pthread_cond_signal(pthread_cond_t *cond);
功能:发送条件变量cond
参数:cond为要发送的条件变量
返回:成功0,否则返回错误编号
备注:唤醒至少一个阻塞在条件变量上的线程
int pthread_cond_broadcast(pthread_cond_t *cond);
功能:发送条件变量cond
参数:cond为要发送的条件变量
返回:成功0,否则返回错误编号
备注: 通知所有等待该条件的线程
更详细的内容如下:
生产者消费者模型
建立模型
模型中的数据(即缓冲区中的数据)我们抽象为一个一个的int类型数据,push操作就是生产者线程往缓冲区内存放数据的行为,pop操作就是消费者线程从缓冲区中取走数据的行为。
仓库缓冲区我们采用图中 TaskQueue 任务队列的数据结构来进行设计,该数据结构的成员如上图所示,对应成员应该设计的比较好懂,不懂的话代码中会标有注释,这里不再赘述。
UML设计
基础版本:单消费者生产者的代码实现
注意这里的 Thread.cc 和 Thread.h 依然是复用前文的面向对象的线程封装那一节的两个文件,此处不再赘述。
这里插一个 vim 上的批量行处理的快捷键方式:
目录结构如下:
先实现 MutexLock.h:
#ifndef __MUTEXLOCK_H__
#define __MUTEXLOCK_H__
#include <pthread.h>
//封装锁资源为类
class MutexLock{
public:
MutexLock();
~MutexLock();
void lock();
void unlock();
//获取pthread_mutex_t类型的指针
pthread_mutex_t* getMutexLockPtr(){
return &_mutex;
}
private:
//声明锁资源
pthread_mutex_t _mutex;
};
#endif
再实现对应的实现文件 MutexLock.cc:
#include "MutexLock.h"
#include <stdio.h>
MutexLock::MutexLock(){
int ret = pthread_mutex_init(&_mutex,nullptr);
if(ret){
perror("pthread_mutex_init()");
}
}
MutexLock::~MutexLock(){
int ret = pthread_mutex_destroy(&_mutex);
if(ret){
perror("pthread_mutex_destroy()");
}
}
void MutexLock::lock(){
int ret = pthread_mutex_lock(&_mutex);
if(ret){
perror("pthread_mutex_lock()");
}
}
void MutexLock::unlock(){
int ret = pthread_mutex_unlock(&_mutex);
if(ret){
perror("pthread_mutex_unlock()");
}
}
再实现 Condition.h:
#ifndef __CONDITION_H__
#define __CONDITION_H__
#include <pthread.h>
//用下面这种方式进行 MutexLock类型引入容易发生头文件重复包含编译的问题
//因为假如MutexLock中包含Condition类,而Condition类又包含MutexLock类
//两边都引入一遍的话,就会重复编译
//#include "MutexLock.h"
//因此更建议使用前向声明的方式来引入
class MutexLock;
class Condition{
public:
Condition(MutexLock& mutex);
~Condition();
//等待条件变量
void wait();
//唤醒单个线程
void notify();
//唤醒相关的全部线程
void notifyAll();
private:
pthread_cond_t _cond;
MutexLock& _mutex;
};
#endif
实现文件 Condition.cc:
#include "Condition.h"
//这里不引入不行了,因为前向声明毕竟不是完整的类声明
//这里不引入的话_mutex对象就无法引用其类成员
#include "MutexLock.h"
Condition::Condition(MutexLock& mutex)
: _mutex(mutex)
{
pthread_cond_init(&_cond,nullptr);
}
Condition::~Condition(){
pthread_cond_destroy(&_cond);
}
//等待条件变量
void Condition::wait(){
//这里无法直接使用 &_mutex
//因为我们有的只是一个MutexLock类型的对象
//而这里pthread_cond_wait函数的第二个参数需要的类型是
//pthread_mutex_t*,因此这里我们要去MutexLock类中封装一个函数
//来完成这件事情
pthread_cond_wait(&_cond,_mutex.getMutexLockPtr());
}
//唤醒单个线程
void Condition::notify(){
pthread_cond_signal(&_cond);
}
//唤醒相关的全部线程
void Condition::notifyAll(){
pthread_cond_broadcast(&_cond);
}
然后再实现 TaskQueue.h:
#ifndef _TASKQUEUE_H__
#define _TASKQUEUE_H__
#include <queue>
#include "MutexLock.h"
#include "Condition.h"
using std::queue;
class TaskQueue{
public:
TaskQueue(size_t _queSize);
~TaskQueue();
bool empty() const;
bool full() const;
void push(const int &value);
int pop();
private:
//数据队列的大下
size_t _queSize;
//消费生产数据为 int 类型的数字
//由queue队列充当缓冲区
queue<int> _que;
MutexLock _mutex;
Condition _notEmpty;
Condition _notFull;
};
#endif
TaskQueue.cc:
#include "TaskQueue.h"
TaskQueue::TaskQueue(size_t queSize)
:_queSize(queSize)
,_que()
,_mutex()
,_notEmpty(_mutex)
,_notFull(_mutex)
{
}
TaskQueue::~TaskQueue(){
}
bool TaskQueue::empty() const{
return _que.size() == 0;
}
bool TaskQueue::full() const{
return _que.size() == _queSize;
}
void TaskQueue::push(const int &value){
//往缓冲区(也就是队列)生产数据时
//先上锁进行访问,然后再判断缓冲区是不是满的
_mutex.lock();
if(full()){
//如果缓冲区已满,那么执行三个操作:
//1、使当前调用本方法的线程阻塞,放进阻塞队列
//2、释放_mutex锁,以便消费者线程可以访问
//注意:在pthread_cond_wait()函数中,这两步是原子操作
//因此不需要担心这里会不会产生并发访问问题
//3、当本线程被唤醒时,pthread_cond_wait函数会返回解除阻塞并马上重新获得锁
_notFull.wait();
}
//生产数据放入缓冲区中
_que.push(value);
//此时缓冲区中肯定不会为空了
//因此对于因为缓冲区数据为空而被阻塞的线程就应该被唤醒了
_notEmpty.notify();
_mutex.unlock();
}
int TaskQueue::pop(){
//消费数据时同样先上锁
_mutex.lock();
//然后判断一下缓冲区是否为空
if(empty()){
//如果是空的,那么当前线程阻塞释放锁资源
_notEmpty.wait();
}
//先将队头元素给保存一下
int res = _que.front();
//然后再删除掉
_que.pop();
//当去掉一个数据之后,缓冲区有可能为空
//那么因为缓冲区满而被阻塞了的线程就应该被唤醒了
_notFull.notify();
_mutex.unlock();
return res;
}
Producer.h:
#ifndef __PRODUCER_H__
#define __PRODUCER_H__
#include "Thread.h"
#include "TaskQueue.h"
#include <stdlib.h>
#include <iostream>
#include <unistd.h>
using std::cout;
using std::endl;
class Producer: public Thread{
public:
Producer(TaskQueue& taskQueue): _taskQueue(taskQueue){
}
void run() override{
int cnt = 20;
//产生随机int值表示需要共享的数据
::srand(clock());//匿名命名空间,表示这是C语言中的函数,这是一种良好的规范
while(cnt-- > 0){
int number = ::rand()%100;
//生产数据
_taskQueue.push(number);
cout << "producer number = " << number << endl;
sleep(1);
}
}
~ Producer(){
}
private:
TaskQueue& _taskQueue;
};
#endif
Consumer.h:
#ifndef __CONSUMER_H__
#define __CONSUMER_H__
#include "Thread.h"
#include "TaskQueue.h"
#include <stdlib.h>
#include <iostream>
#include <unistd.h>
using std::cout;
using std::endl;
class Consumer: public Thread{
public:
Consumer(TaskQueue& taskQueue): _taskQueue(taskQueue){
}
void run() override{
int cnt = 20;
//产生随机int值表示需要共享的数据
while(cnt-- > 0){
//生产数据
int number = _taskQueue.pop();
cout << "consumer number = " << number << endl;
sleep(1);
}
}
~Consumer(){
}
private:
TaskQueue& _taskQueue;
};
#endif
最后是测试文件 TestPC.cc:
#include <iostream>
#include "Producer.h"
#include "Consumer.h"
#include <memory>
using std::unique_ptr;
int main(){
TaskQueue taskQueue(10);
unique_ptr<Thread> producer(new Producer(taskQueue));
unique_ptr<Thread> consumer(new Consumer(taskQueue));
producer->start();
consumer->start();
producer->join();
consumer->join();
return 0;
}
运行结果如下:
多消费者生产者问题分析
上面的基础版本是基于一个消费者生产者来实现的,比较简单,那么当我们遇到多个生产者消费者时会不会遇到什么问题呢?
先来看看两个生产者和一个消费者的情况,这很容易就能做到:
int main(){
TaskQueue taskQueue(5);
unique_ptr<Thread> producer(new Producer(taskQueue));
unique_ptr<Thread> producer2(new Producer(taskQueue));
unique_ptr<Thread> consumer(new Consumer(taskQueue));
producer->start();
producer2->start();
consumer->start();
producer->join();
producer2->join();
consumer->join();
}
两个生产者一共能生成40个数据,而消费者只有一个就只能消费掉20个数据,同时缓冲区大小为10。
因此此时运行代码,程序一定会在某个时间节点阻塞,也就是缓冲区满无法继续写的情况(缓冲区达到数据存储上限10个,加上被消费的20个,还剩10个数据需要生产进缓冲区,但此时没有消费者了,因此程序阻塞了)。
运行结果如下:
正如预期,已经堵塞住了。
我们知道问题是怎么发生的,就是生产数据太多了嘛,那么有一种解决办法,那就是让消费者一直消费:
void run() override{
//int cnt = 20;
//产生随机int值表示需要共享的数据
//while(cnt-- > 0){
while(1){
//生产数据
int number = _taskQueue.pop();
cout << "consumer number = " << number << endl;
sleep(1);
}
}
此时多余的生产数据确实能读掉了,但是又出问题了嗷。
因为数据为空时,我们的消费者线程也会因为空缓冲区而被阻塞:
从上图不难看出,程序最后还是堵塞了,正如我们分析的结果。
使用 RAII 方式优化锁资源管理问题
在代码文件 TaskQueue.cc 文件中,我们生产数据和消费数据的两个方法是像下面这么写的:
//生产数据
void TaskQueue::push(const int &value){
_mutex.lock();
if(full()){
_notFull.wait();
}
_que.push(value);
_notEmpty.notify();
_mutex.unlock();
}
//消费数据
int TaskQueue::pop(){
_mutex.lock();
if(empty()){
_notEmpty.wait();
}
int res = _que.front();
//假如此时我就需要return 了
//return;
_que.pop();
_notFull.notify();
_mutex.unlock();
return res;
}
这样的代码有不合理之处,现在没有产生问题是因为我们的业务逻辑还不复杂,如果我们的业务逻辑比较复杂,那么很有可能遇到这样的问题:我们的代码很有可能要在上面某个函数的中间位置要 return,也就是在 lock 和 unlock 之间,我要 return 结束掉函数。
那么此时就出现问题了,因为我们的锁还没 unlock 掉呢!这很有可能造成死锁问题!
解决方法也很简单,可以使用我们之前就说过的 RAII 初始化资源即管理的思想,将我们的锁资源进一步封装,让其变成一个栈对象,其在构造函数里面调用 lock,在析构函数里面调用 unlock,这样就不管 return 在哪里, 只要方法一结束这个栈对象就会销毁,销毁之后析构函数就会执行,那 unlock 就会自动执行了,也就解决了我们的问题。
我们来实现一下,就在 MutexLock 这个文件下添加就可以。
直接加在 MutexLock.h 头文件中:
#ifndef __MUTEXLOCK_H__
#define __MUTEXLOCK_H__
#include <pthread.h>
//封装锁资源为类
class MutexLock{
public:
MutexLock();
~MutexLock();
void lock();
void unlock();
//获取pthread_mutex_t类型的指针
pthread_mutex_t* getMutexLockPtr(){
return &_mutex;
}
private:
//声明锁资源
pthread_mutex_t _mutex;
};
//使用 RAII 技术管理锁资源
class MutexLockGuard{
public:
MutexLockGuard(MutexLock& mutex): _mutex(mutex){
_mutex.lock();
}
~MutexLockGuard(){
_mutex.unlock();
}
private:
MutexLock& _mutex;
};
#endif
然后对 TaskQueue.cc 中的代码进行小小更改:
//生产数据
void TaskQueue::push(const int &value){
//_mutex.lock();
//使用 RAII 思想来解决锁资源管理问题
//通过创建下面的栈对象,这样就可以通过栈对象的生命周期来自动管理锁资源了
MutexLockGuard autoLock(_mutex);
if(full()){
_notFull.wait();
}
_que.push(value);
_notEmpty.notify();
//_mutex.unlock();
}
//消费数据
int TaskQueue::pop(){
//_mutex.lock();
MutexLockGuard autoLock(_mutex);
if(empty()){
_notEmpty.wait();
}
int res = _que.front();
//假如此时我就需要return 了
//return;
_que.pop();
_notFull.notify();
//_mutex.unlock();
return res;
}
虚假唤醒:if 语句的缺陷问题
还是刚刚那段代码:
//其它代码
if(full()){
_notFull.wait();
}
//其它代码
上面的这一小段代码依然存在问题:如果 full() == true 了,那么就会执行下面的 wait 函数,当前线程就会被阻塞,直到下面的代码唤醒该线程:
int res = _que.front();
//假如此时我就需要return 了
//return;
_que.pop();
//满足条件变量,唤醒阻塞线程
_notFull.notify();
那么此时 _notFull.wait() 函数返回后代码就会直接往下执行了,问题就在于假如正好在 wait 函数返回期间这个 full() 又为 true 了呢?在高并发场景下这是极其容易发生的,那么继续往下执行代码:
if(full()){
_notFull.wait();
}
//wait 函数返回继续往下执行
_que.push(value);
此时缓冲区都是满的,肯定是插入不了数据的就会产生问题。
所以使用 if 肯定不好,使用 while 更合适,在 wait 函数返回时再检查一下条件如果还是满足再往下执行是更合适的选择:
修改如下:
//push 函数中的代码
//if(full()){
while(full()){
_notFull.wait();
}
//pop 函数中的代码
//if(empty()){
while(empty()){
_notEmpty.wait();
}
禁止复制问题
在之前的代码中,如果我们想进行 MutexLock 和 Condition 类的复制或者赋值可以成功吗?
当然肯定是没问题的:
MutexLock mutex;
MutexLock mutex2 = mutex;
但是这样的行为没有必要的,不管是 mutex 还是 condition 还是 thread 它们都应该只具有对象语义。
对象语义:不能进行复制或者赋值
值语义:可以进行复制或者赋值
如果需要让它们只具有对象语义的话,我们要做的是事情就是将拷贝构造函数和赋值运算符函数给 delete 掉或者私有化掉即可。
但是此时有一个问题,如果只有几个类那么还好说,如果有成百上千个类的话都手动写的话是不是有一点呆了?
有一种更加优雅的方式:继承!
使用继承如何实现我们要的效果?
来看一个例子。
下面是一个 Example 类:
#ifndef __EXAMPLE_H__
#define __EXAMPLE_H__
class Example{
public:
Example();
~Example();
};
#endif
测试代码:
#include "Example.h"
#include <iostream>
using namespace std;
void test(){
Example ex1;
Example ex2 = ex1;
Example ex3;
ex3 = ex1;
}
int main(){
return 0;
}
此时我们要实现的事情就是通过继承方式让上面的 ex2 = ex1 和 ex3 = ex1 报错。
那么此时就还需要一个类,称为 NoCopyable:
#ifndef __NOCOPYABLE_H__
#define __NOCOPYABLE_H__
//继承了本类的成员都将具有对象语义
//即无法实现对象赋值和对象复制的操作
class NoCopyable{
protected:
NoCopyable(){
}
~NoCopyable(){
}
//删除拷贝构造函数
NoCopyable(const NoCopyable& rhs) = delete;
//删除赋值运算符函数
NoCopyable& operator=(const NoCopyable& rhs) = delete;
};
#endif
此时再让我们的 Example 类去继承 NoCopyable:
此时再去测试文件中:
不难发现此时如果还想进行赋值就会报错了,也就达到了我们的目的。
此时我们只需要将不需要值语义的类如 Thread、MutexLock、Condition 都继承自这个 NoCopyable 工具类即可删除其的值语义定义。
代码比较简单,这里就不再赘述了。