目录
Linux——多线程—02
为了更好复习,我将多线程的知识拆分成几篇笔记
上一篇笔记,主要学习了
-
线程【Linux线程的由来,线程的概念,线程的理解、Linux中CPU如何看待线程、线程异常】
-
Linux中的线程和进程的区别
-
学习了一定的线程控制。【线程创建、线程等待、线程终止、线程取消】
-
理解原生线程库【用户级线程、pthread_t的本质】
1.Linux线程互斥
1.1互斥的相关概念
- 多个线程执行流共享的资源叫——临界资源
- 每个线程内部,访问临界资源的代码叫——临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:对一个数据进行操作,要不不做,要么就做到底
为了更好的理解互斥这个概念,下面做一个实验,看看不互斥是怎么样的
1.2线程互斥实验
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
多个线程并发的操作共享变量,会带来一些问题。
在这个代码中,就是借助自己封装的一个简易的线程库,然后简单的实现一个抢票程序【线程不互斥版本】:
#include"mythread.hpp"
// 让新线程去抢火车票【线程不互斥版本】
//下面的代码是有问题的!
// 由于我们让线程一开始执行的时候就进行了一个usleep,此时os就会对线程进行线程切换的操作(因为时间片到了)
//但是每次切换到另一个新线程,发现还是usleep,这样就大大增加了线程切换的次数
// 这里发生了多个线程交叉执行——让调度器尽可能得频繁发生线 程切换和线程调度
int tickets = 1000;
void* GetTicket(void* args)
{
const char* name = static_cast<const char*>(args);
// printf("我是线程%s, 我要开始抢票了\n", name);
while(true)
{
if(tickets > 0)
{
//还有票可以抢
usleep(12345); // 这里大概是0.01s
// 1s = 1000ms = 1000000微秒 这里usleep的单位是微秒
// std::cout << name << "正在进行抢票:" << tickets-- << std::endl; // 不知道为什么一用cout就容易出现缓冲区bug
printf("%s正在进行抢票:%d\n", name, tickets--);
}
else
{
printf("%s发现已经没有票了\n", name);
break;
}
}
return nullptr;
}
int main()
{
Thread* thread1 = new Thread(GetTicket, (void*)"thread1", 1);
Thread* thread2 = new Thread(GetTicket, (void*)"thread2", 2);
Thread* thread3 = new Thread(GetTicket, (void*)"thread3", 3);
Thread* thread4 = new Thread(GetTicket, (void*)"thread4", 4);
Thread* thread5 = new Thread(GetTicket, (void*)"thread5", 5);
thread1->join();
thread2->join();
thread3->join();
thread4->join();
thread5->join();
return 0;
}
执行的结果每次都不太一样的,是不确定的【因为在OS中,那个线程先被调用,是由OS决定的,那个线程先被切换,也是OS决定的】
这个时候,我们发现抢出了负数的票数?这肯定是有问题的,只放了1000张票,抢出了1003张,那不是完蛋了嘛、
那为什么会有这个问题呢?
要弄清楚这个问题:还是要结合两个方面来说:1.CPU,2.进程切换
这是因为发生了多个线程交叉执行——让调度器尽可能得频繁发生线程切换和线程调度
线程什么时候切换?——1.时间片到了,2.线程等待,3.有优先级更高的线程
下面画个图:
文字总结一下就是:当tickets为1的时候,可能还会有多个线程被OS调用,但是每个线程都带着tickets为1的信息被线程切换,这是为了保护线程上下文信息。然后当线程再次被切换回来的时候,就带着tickets为1的信息继续执行抢票。而tickets–的操作也会执行!
当thread1切回来的时候,会重新读取内存的ticket–,将1–为0。然后就0写回内存。
当thread2切回来的时候,将0–为-1,-1写回内存,依次类推。
最后thread2抢的是-3,他也会–,实际上tickets是-4、
注意:++、–操作都不是原子操作!
每一个++、–操作都对应着3条汇编指令
load :将共享变量ticket从内存加载到寄存器中
update : 更新寄存器里面的值,执行-1操作
store :将新值,从寄存器写回共享变量ticket的内存地址
3条汇编就代表在CPU中执行这个指令的时候会存在中间状态,那么就可能会被进程切换打断。因此++、–都不是原子操作
那什么是原子操作呢?——只有一条汇编指令,不会被打断的指令
总结:
如果一个全局变量没有收到保护的同时被多个线程所操作,那么这个全局变量是不安全的!也就是这个全局变量(临界资源)是不被保护的
1.3互斥量mutex(加锁)
那如何解决这个问题?
答案——加锁。让线程互斥。当一个线程执行流,进入它自己的临界区去访问临界资源的时候,只能做原子操作,做完了操作之后,其他线程执行流才能访问该数据,而Linux上提供的这把锁叫互斥量。
就是串行的去访问资源,而不要并发访问
代码如下:
在这个代码中我用了两套加锁方案,一个是静态加锁,一个是动态
// 将抢票代码做线程互斥,也就是加锁
class ThreadDate
{
public:
// 当结构体用,直接公开成员。当然也可以private,然后写对应的get set接口
std::string _name;
pthread_mutex_t* _mutex_p; // 对锁这个类型的指针
ThreadDate(const std::string& name, pthread_mutex_t* mutex_p)
:_name(name)
,_mutex_p(mutex_p)
{}
~ThreadDate(){}
};
// 直接变成全局锁, 静态初始化
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int tickets = 1000;
void* GetTicket(void* args)
{
// ThreadDate* td = static_cast<ThreadDate*>(args);
const char* name = static_cast<const char*>(args);
while(true)
{
// pthread_mutex_lock(td->_mutex_p); // 加锁
pthread_mutex_lock(&lock); // 加锁
// 这里要注意,锁只规定了线程会互斥访问临界资源,并没有规定那个线程先执行
// 锁是多个线程执行流竞争的
if(tickets > 0)
{
//还有票可以抢
usleep(12345); // 这里大概是0.01s
// 1s = 1000ms = 1000000微秒 这里usleep的单位是微秒
// std::cout << td->_name << "正在进行抢票: " << tickets-- << std::endl;
std::cout << name << "正在进行抢票: " << tickets-- << std::endl;
// pthread_mutex_unlock(td->_mutex_p); // 解锁,加锁区域是临界区
pthread_mutex_unlock(&lock); // 解锁,加锁区域是临界区
}
else
{
// 这里也要解锁的原因是,加锁是在条件判断之前加的,一旦进入else,就无法解锁
// pthread_mutex_unlock(td->_mutex_p); // 解锁
pthread_mutex_unlock(&lock); // 解锁
break;
}
// 抢完票,还要对抢完票的信息进行汇总,然后给用户显示出来
//这里用一个usleep来模拟这个汇总并显示的工作
usleep(12345);
}
return nullptr;
}
int main()
{
// #define NUM 4
// // 创建锁并初始化
// pthread_mutex_t lock; // 除了在局部创建锁,也可以把锁定义成全局的
// pthread_mutex_init(&lock, nullptr);
// // 创建线程
// std::vector<pthread_t> tids(NUM);
// for(int i = 0; i < NUM; i++)
// {
// char buffer[64];
// snprintf(buffer, sizeof buffer, "thread %d", i+1);
// ThreadDate* td = new ThreadDate(buffer, &lock);
// pthread_create(&tids[i], nullptr, GetTicket, (void*)td);
// }
// // 线程等待
// for(auto& tid : tids)
// {
// pthread_join(tid, nullptr);
// }
// pthread_mutex_destroy(&lock); // 销毁锁
// 使用全局锁,不需要代码中调用初始化和销毁锁的接口
pthread_t t1,t2,t3,t4;
pthread_create(&t1, nullptr, GetTicket, (void*) "thread 1");
pthread_create(&t2, nullptr, GetTicket, (void*) "thread 2");
pthread_create(&t3, nullptr, GetTicket, (void*) "thread 3");
pthread_create(&t4, nullptr, GetTicket, (void*) "thread 4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
}
两套加锁方案执行效果都是一样的,如下图所示:
- 关于两种加锁方法要注意的地方:
静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex:要初始化的互斥量
attr:NULL
- 销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥量需要注意:
-
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
-
不要销毁一个已经加锁的互斥量
-
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
- 加锁与解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
- 调用
pthread_mutex_lock
要注意:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功【上面代码就是这样】
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁
1.4如何看待锁
代码很简单,下面来谈一下如何看待锁:
- 锁,既然是来保护临界资源的,那就要被全部线程都能够看到,即锁本身也是一个共享资源!那谁来保护锁这个共享资源呢?
- OS早就考虑到这个问题,加锁这个操作本身就是原子操作,不存在中间态,只能是加锁成功或者加锁不成功
- 那个线程持有锁,那个线程才能够进入临界区
- 一个线程持有锁进入临界区,它也是可以被OS线程切换的【这和它本身有没有锁没关系】。而该线程持有锁被切换走了,但是其他线程仍然无法被调度,因为锁没解开,线程需要阻塞等待锁的持有
- 因此加锁的区域(临界区),应该尽可能保证粒度要小,即临界区的代码要少,要尽快的解锁,让其他线程能够快的执行
- 加锁是程序员行为,要加就要给全部线程都要加,要不就都不加。【如果只加一个线程一个不加是bug行为】
1.5理解加锁和解锁的本质
前面也提及了,加锁和解锁的操作都是原子性的。尤其是加锁!加锁一定是原子操作
并且经过上面的例子
已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构(cpu的arm架构或x86架构)都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码分析一下,看看加锁的和解锁的实际过程是怎么样的
首先就是一个线程A,当他调用了pthread_mutex_lock加锁时,实际上发生了什么呢?
实际上就是执行了一个mov指令,将0给到寄存器al,然后,这里加锁就是将内存数据单元的互斥量mutex与al寄存器交换!此时加锁完毕!假设mutex原本为1,交换为mutex为0、
当然线程A也可能会被切换走,但是会带着线程A自己的上下文结构走。内存里的mutex仍然是0。此时线程B被切换进来,它也要调用pthread_mutex_lock加锁
因此只要线程A不解锁,将mutex置为1,其他线程就一直无法加锁,会挂起等待
1.6可重入&&线程安全
-
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
-
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
-
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
-
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
-
可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
-
可重入函数是线程安全函数的一种
-
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
-
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
2.常见锁概念
死锁
死锁概念——死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
为了更好的理解死锁,我们来看一下为什么会有死锁:
这是因为我们再多线程的操作的时候,当一个全局变量被所有线程共享的时候,就会出现公共资源不安全的问题。因此为了解决这个问题,引入了锁,来保证了公共资源的安全、但是引入锁也导致死锁的问题出现了。接下来就是分析死锁,然后解决死锁问题
- 死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
- 避免死锁
- 破坏死锁的四个必要条件【一般都是破坏第二个条件】【比如当一个线程已经持有锁,在再次申请加锁的时候,如果无法申请到,那就直接取消等待,并释放之前的锁】
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
- 避免死锁算法
死锁检测算法(了解)
银行家算法(了解)
一般来说,以后工作了,能不用锁,就不要用锁
3.Linux线程同步
3.1同步概念
就是在线程安全的情况下,让线程按照一定的顺序去被调用,避免饥饿状态,就是线程同步
【饥饿状态:不断的加锁,但是临界资源的情况目前无法让自己执行,因此就解锁。然后不断的加锁,判断,解锁。】
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
3.2条件变量
条件变量可以结合生产者消费者模型来一起看
-
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了【比如之前写的抢票,如果官方不放票,那线程是无法抢票的,但是线程不知道官方什么时候放票,因此就会一直申请加锁,判断能不能抢,不能就解锁,然后一直循环】
-
因此有些时候需要加一些条件,先判断一下条件,不满足就要等待,而不要解锁。【这是一个安全的线程互斥访问,但是不合理,会导致饥饿问题】比如:当一个线程需要访问队列的时候,如果发现队列为空,就不要解锁了,而是先等待,等其他线程将节点插入到该队列中,然后就可以访问了。这叫——条件变量
3.3条件变量接口使用
初始化:
- 动态分配
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:NULL
- 静态分配
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁:
int pthread_cond_destroy(pthread_cond_t *cond)
等待:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
唤醒等待:
int pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒全部
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒一个
代码案例:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
// 通过条件变量控制线程的执行
// 静态分配锁,和静态分配条件变量
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int tickets = 1000;
void* start_routine(void* args)
{
std::string name = static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&lock); // 加锁
// 这里应该有个判断条件是否满足
pthread_cond_wait(&cond, &lock); // 不满足条件就要等待。
std::cout << name << " -> " << tickets << std::endl;
tickets--;
pthread_mutex_unlock(&lock); // 解锁
}
return nullptr;
}
int main()
{
// 创建5个线程
std::vector<pthread_t> tds(5);
for(int i = 0; i < 5; i++)
{
// char buffer[32]; // 这里不能是这个,不然会导致当线程开始执行的时候,拿到的buffer都是thread_5
char* buffer = new char[32];
snprintf(buffer, 32, "thread_%d", i+1); // 不能是sizeof buffer,因为buffer现在是一个指针
pthread_create(&tds[i], nullptr, start_routine, (void*)buffer);
}
// 主线程负责将等待中的线程唤醒
while(true)
{
sleep(1);
pthread_cond_signal(&cond); // 唤醒等待的线程,只唤醒一个
//pthread_cond_broadcast(&cond); // 将等待的线程全部唤醒
std::cout << "main thread wakeup one thread" << std::endl;
}
// 线程等待
for(auto& td : tds)
{
pthread_join(td, nullptr);
}
return 0;
}
执行结果如下:
如果将pthread_cond_signal(&cond);
改为pthread_cond_broadcast(&cond);
,那么就会一次性将因为条件变量而等待的线程全部唤醒
执行效果如下图所示:
3.4生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的
为了更好的理解生产者消费者模型,下面举一个学生、超市、供货商的例子:
总结:321原则
- 三种关系:消费者和消费者之间是互斥、生产者和生产者之间是互斥、生产者和消费者之间也是互斥关系
- 2种角色:生产者线程和消费者线程
- 1个交易场所:一段特定结构的缓冲区
生产者消费者模型的特点:
- 将生产过程和消费过程进行了解耦,使得线程不必一定要串行,而是可以进行一定的并,提高效率
- 支持生产和消费之间的忙闲不均问题
3.5基于BlockingQueue的生产者消费者模型
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。
其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
实验1
而下面会做一个实验:
用代码实现一个基于BlockingQueue的生产者消费者模型,只有一个消费者和一个生产者。
一共会有2个文件MainCp.cc,BlockQueue.hpp
代码如下:
MainCp.cc:
#include"BlockQueue.hpp"
#include"Task.hpp"
#include<sys/types.h>
#include<cstdlib>
// 基于阻塞队列的生产者消费者模型的实现
void* comsume(void* blockqueue)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(blockqueue);
// 消费——往阻塞队列取数据
while(true)
{
int data;
bq->pop(&data);
printf("消费:%d\n", data);
// sleep(1); // 利用sleep控制消费的速度
}
return nullptr;
}
void* productor(void* blockqueue)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(blockqueue);
// 生产——往阻塞队列放数据
while(true)
{
// 生成一个随机数然后插入到队列里面
int data = rand() % 10 + 1; //+1是不想看到0,%10+1是想控制在1~10
bq->push(data);
printf("生产:%d\n", data);
sleep(1); // 利用sleep控制生产的速度
}
return nullptr;
}
int main()
{
srand((unsigned int)time(nullptr) ^ getpid());
BlockQueue<int>* bq = new BlockQueue<int>(); // 创建一个阻塞队列
//创建一个生产者线程,一个消费者线程
pthread_t c,p;
pthread_create(&c, nullptr, comsume, bq);
pthread_create(&p, nullptr, productor, bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
BlockQueue.hpp:
#pragma
#include<iostream>
#include<queue>
#include<pthread.h>
#include<unistd.h>
using namespace std;
static const int MAX = 5;
template<class T>
class BlockQueue
{
public:
BlockQueue(const int& maxcap = MAX)
:_maxcap(maxcap)
{
//初始化锁和条件变量
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_pcond, nullptr);
pthread_cond_init(&_ccond, nullptr);
}
~BlockQueue()
{
//销毁锁和条件变量
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_pcond);
pthread_cond_destroy(&_ccond);
}
//插入数据——生产
void push(const T& data) // 输入型参数一般是const&
{
pthread_mutex_lock(&_mutex); // 加锁
// 细节2:这里不能用if,而要用while。
// 1.pthread_cond_wait可能调用失败!失败了不能再往后继续执行
// 2.因为当生产者线程很多的时候,全部因为pthread_cond_wait被挂起的线程当它重新回来的时候
// 就会继续向后执行,很有可能导致push多插入——多生产了
while(is_full()) // 要判断队列是否位满,满了无法插入,要阻塞
{
//生产者线程要阻塞,直至队列有空位,可以插入——生产的时候会被唤醒
pthread_cond_wait(&_pcond, &_mutex);
// 细节2:pthread_cond_wait一定要传当前线程的一个互斥锁进去作为参数
// 1.当调用pthread_cond_wait时,会先解锁(原子操作),在将自己挂起
// 2.当pthread_cond_wait返回时,会自动获取之前所传入的锁【申请锁,成功后才能向后运行】
}
// 走到这里,就是可以生产——插入
_q.push(data);
pthread_cond_signal(&_ccond); //唤醒消费者线程来消费【这里的唤醒可以有一定的策略】
//细节3:这个pthread_cond_signal可以放在临界区内,也可以放在临界区后。
// 但是建议放在临界区内
pthread_mutex_unlock(&_mutex); //解锁
}
//弹出数据——消费
void pop(T* out) // 输出型参数不带const,一般是*。输入输出型一般是&
{
pthread_mutex_lock(&_mutex); //加锁
// 判断当前队列是否为空
while(is_empty())
{
//队列为空,无法消费,消费者线程要阻塞,直至有数据可以消费,会被唤醒
pthread_cond_wait(&_ccond, &_mutex);
}
//走到这里就是可以消费了
*out = _q.front(); //将消费的数据输出回去
_q.pop();
// 只要消费了一定有至少一个空位,唤醒生产者线程
pthread_cond_signal(&_pcond); //可设置一定的策略
pthread_mutex_unlock(&_mutex); //解锁
}
private:
bool is_empty()
{
return _q.empty();
}
bool is_full()
{
// 判断是否和阻塞队列上限个数相同
if(_q.size() == _maxcap)
return true;
return false;
}
private:
queue<T> _q;
int _maxcap; //队列的上限
pthread_mutex_t _mutex;
pthread_cond_t _pcond; // 生产者对应的条件变量
pthread_cond_t _ccond; // 消费者对应的条件变量
};
实验效果如下:
这里要分3种情况:
- 生产的很快,消费的很慢
现象就是一下子就生产到队列上限,然后消费者开始消费队列的队头数据
- 生产的很慢,消费的很快
看到的现象应该是生产一个消费一个,因为一开始没有数据无法消费。
- 生产和消费速度一样快。
看到的现象和2应该是一样的
为什么 pthread_cond_wait 需要互斥量?
经过实验1的理解之后,现在就可以理解这个问题了。因为你一个线程因为条件变量不满足而被挂起的时候,如果不解锁,那么就会一直占用这个锁,那其他的线程就无法申请到锁,就无法执行,只能阻塞等待。这样不合理。
因此传一个互斥锁进去给 pthread_cond_wait
-
当调用
pthread_cond_wait
时,会先解锁(原子操作),在将自己挂起 -
当
pthread_cond_wait
返回时,会自动获取之前所传入的锁【申请锁,成功后才能向后运行】
实验2(改进实验1):
这个模型如果生产和消费的都是一个int类型的数据有点捞。
下面改进一下,生产和消费的数据是一个Task类型的对象,可以通过它完成一些任务【下面我会让他实现一些±*\的任务】
并且更改为多个生产者,多个消费者
Task.hpp:
#include<iostream>
#include<functional>
using namespace std;
// 让任务类型提供一些+-*\的接口
class Task
{
// typedef function<int(int, int)> fun_t; // 定义一个函数类型,返回值int,参数为(int, int)
using func_t = function<int(int, int, char)>; // 和上面那句话的功能是一样的
public:
Task(){}
Task(int x, int y, char op, func_t func)
:_x(x)
,_y(y)
,_op(op)
,_callback(func)
{}
string operator()()
{
int result = _callback(_x, _y, _op);
char buffer[64];
snprintf(buffer, sizeof buffer, "%d %c %d = %d", _x, _op, _y, result);
return buffer;
}
string toTackString()
{
char buffer[64];
snprintf(buffer, sizeof buffer, "%d %c %d = ?", _x, _op, _y);
return buffer;
}
private:
int _x;
int _y;
char _op; //存储选项
func_t _callback;
};
BlockQueue.hpp:
和之前一样的
MainCp.cc:
#include"BlockQueue.hpp"
#include<sys/types.h>
#include<cstdlib>
#include"Task.hpp"
// 基于阻塞队列的生产者消费者模型的实现
const string oper = "+-*/%";
int mymath(int x, int y, char op)
{
int result;
switch(op)
{
case '+':
result = x + y;
break;
case '-':
result = x - y;
break;
case '*':
result = x * y;
break;
case '/':
{
if(y == 0)
{
cout << "不能/0\n";
break;
}
result = x / y;
break;
}
case '%':
{
if(y == 0)
{
cout << "不能%0";
break;
}
result = x % y;
break;
}
default:
cout << "error oper" << endl;
break;
}
return result;
}
void* comsume(void* blockqueue)
{
BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(blockqueue);
// 消费——往阻塞队列取数据
while(true)
{
Task t;
bq->pop(&t);
printf("消费任务:%s\n", t().c_str());
// sleep(1); // 利用sleep控制消费的速度
}
return nullptr;
}
void* productor(void* blockqueue)
{
BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(blockqueue);
// 生产——往阻塞队列放数据
while(true)
{
//生产一个任务
int x = rand() % 10 + 1; //+1是不想看到0,%10+1是想控制在1~10
int y = rand() % 10 + 1;
int index = rand() % oper.size(); //获取下标,从而随机获取计算符
Task t(x, y, oper[index], mymath);
bq->push(t);
printf("生产任务:%s\n", t.toTackString().c_str());
sleep(1); // 利用sleep控制生产的速度
}
return nullptr;
}
int main()
{
srand((unsigned int)time(nullptr) ^ getpid());
BlockQueue<Task>* bq = new BlockQueue<Task>(); // 创建一个阻塞队列
// 创建3个生产者线程,2个消费者线程
pthread_t c[2], p[3];
pthread_create(p, nullptr, productor, bq);
pthread_create(p + 1, nullptr, productor, bq);
pthread_create(p + 2, nullptr, productor, bq);
pthread_create(c, nullptr, comsume, bq);
pthread_create(c + 1, nullptr, comsume, bq);
pthread_join(c[0], nullptr);
pthread_join(c[1], nullptr);
pthread_join(p[0], nullptr);
pthread_join(p[1], nullptr);
pthread_join(p[2], nullptr);
return 0;
}
执行效果如下:
- 生产速度快过消费
- 消费快过生产
可以看到即便是多生产者,多消费者也没有问题。因为加了锁,所有线程被调用的时候都要申请锁,竞争锁,所以真正访问阻塞队列的线程永远只有一个。只有该线程解锁了,其他线程才能访问阻塞队列(临界资源)
该模型高效的原因
但是此时就有一个问题?这和之前加锁的代码好像没有区别。那这个生产者消费者模型凭什么效率就高呢?
这是因为生产者消费者模型的高效并不是体现在对临界资源(这里就是阻塞队列)的生产(输入数据)和消费(拿取数据),而是体现在,在申请锁,加锁之前,以及解锁后的过程!
要知道,一个任务要被生产到临界资源之前,生产者线程,肯定要构建任务,数据来源肯定就是外设,磁盘,网络,而由于构建任务是在加锁之前的,这个构建任务的代码是多线程并发的!【因此在一个生产者线程加锁后,将任务生产到临界资源中的时候,其他生产者线程也没暂停者,而是不断的构建任务】
同理,消费者线程也是一样的。当一个任务执行的时间很长的时候,一个消费者线程加锁之后,从临界资源拿取任务的时候,其他消费者线程也没闲着,在吃力拿出来的任务。【处理任务是解锁之后的代码,也是所有消费者线程并发的】
4.POSIX信号量
4.1信号量的理解
在上面的我自己实现的生产者消费者模型是有一些缺点的。
- 那就是作为公共资源的阻塞队列是以一个整体被使用的!【该队列整体进行了加锁】
实际情况下一个公共资源很可能会需要不同的线程同时访问不同的区域【例如电影院】
那怎么实现呢?——需要程序员来保证不同的线程可以并发的访问公共资源的不同区域!——通过信号量实现!
之前在进程间通信的时候,有对System V信号量的理解,如果忘了就要复习【当然,那个是System V的信号量。但是POSIX的信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。】
因此这里直接下结论:信号量的本质就是计数器,而申请信号量的本质就是对一个临界资源的部分区域的预订机制
注意!
也就是说:我们不必再去先加锁然后去判断临界资源目前的情况是否能够让该线程使用,而是直接在加锁前,就可以申请信号量!
如果申请成功,这个资源就是该线程的了,在加锁然后访问临界资源。
如果申请失败,这个资源就不属于该线程,也就不在需要判断临界资源是否能让自己使用了!等待即可
梳理一下:
一个线程要访问临界资源的某个区域——>要申请信号量——>因此信号量需要被所有线程都要看到【信号就是一个公共资源】——>要保护信号量——>所有对信号量的操作都要是互斥的,即信号量的操作是原子操作
因此信号量的PV操作都是原子操作【预定资源就是P操作,释放资源就是V操作】
4.2信号量操作接口
信号量的操作接口非常简单。
- 初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
- 销毁信号量
int sem_destroy(sem_t *sem);
- 等待信号量
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
- 发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
4.3基于环形队列的生产消费模型
上一节生产者-消费者的例子是基于queue的,其空间可以动态分配,现在基于固定大小的环形队列重写这个程序(POSIX信号量)
编写代码之前要注意:
- 生产者和消费者什么时候会访问同一个资源?
只有在队列为空或者满的时候,会访问到一个位置(资源)。其他时候访问的都是不一样的位置。即只有这两个情况,需要利用同步和互斥来保证该临界资源的安全。【这就是和上一个阻塞队列这个资源的不同了,阻塞队列只能当一个整体来看】
因此在环形队列中,大部分单生产和单消费都是可以并发执行的,只有在满和空着两个情况下需要注意互斥和同步的问题!
因此这也是为什么如果是基于环形队列的生产消费模型,更适合用信号量,而不是全部都直接用互斥锁和条件变量
而信号量是用来衡量临界资源中的资源数量的!【在这里给两个信号量,分别对应生产者和消费者即可】
- 对于生产者,它只需要申请信号量,信号量会统计该环形队列中剩余的空间,如果申请成功就有空间给生产者生产。申请失败就表示队列满了。【申请成功信号量就–(P操作),释放信号量就++(V操作)】
- 对于消费者,它只需要申请信号量,信号量会统计该环形队列中可消费的资源数量,如果申请成功就有资源给消费者消费。申请失败就表示队列为空。【申请成功信号量就–(P操作),释放信号量就++(V操作)】
- PV操作的对象分别是谁?
一定要理清楚PV操作的时候,分别PV的是消费者还是生产者。
比如:
当一个环形队列中,生产者首先要申请一个信号量,执行P操作,申请预订§的是自己的信号量。但是释放信号量(V操作)要V的是消费者的信号量!【生产者放入数据后,是无法释放自己的信号量的,因为即便生产者继续往后放入数据,这个资源已经被占用了,对于生产者自己来说,可生产的空间并没有归还、反而是消费者可消费的资源增加了】
而消费者执行P操作,P的也是自己的信号量,当有数据给自己消费的时候,就能申请成功。一样的,释放信号量的时候要释放(V)的是生产者的信号量【消费者取出数据之后,不能释放自己的信号量,因为可消费的数据并没有增加,自己用的数据没有归还、反而是生产者可生产的资源增加了】
而这样的机制,可以保证,在环形队列中,生产者不会生产到消费者指向的位置之后,消费者也不会消费到生产者的前面、即双方都无法套圈对方
- 实际编写代码的时候,生产者消费者的位置如何表示?
因为是环形队列,所以可以用数组作为底层来实现,用下标来表示生产者消费者的位置。当队列为空或队列为满的时候下标就相同【在这一点和之前在数据结构所学的环形队列不太一致】
那这里为空和为满都下标相同,那不会无法区分吗?
不会,因为两种情况的生产者和消费者的对应的信号量是完全相反的!
实验1:
分为两个文件RingQueue.hpp和main.cc
RingQueue.hpp:
#pragma
#include<iostream>
#include<vector>
#include <semaphore.h>
#include<cassert>
using namespace std;
static const int gcap = 5;
template<class T>
class RingQueue
{
public:
RingQueue(const int& cap = gcap)
:_queue(cap) //调用vector的构造
,_cap(cap)
{
// 对锁和信号量要初始化
int n = sem_init(&_spaceSem, 0, _cap); //对于生产者一开始资源数量就是容量大小
assert(n == 0);
n = sem_init(&_dataSem, 0, 0); //对于消费者一开始没有资源
assert(n == 0);
_productorPos = _ConsumerPos = 0; // 一开始都在0下标处
}
~RingQueue()
{
// 对锁和信号量要销毁
int n = sem_destroy(&_spaceSem);
assert(n == 0);
n = sem_destroy(&_dataSem);
assert(n == 0);
}
void push(const T& data)
{
// 生产——插入之前要先申请信号量,看看有没有资源给自己生产
P(_spaceSem);
// 走到这里就是申请到了,不然会阻塞等待
_queue[_productorPos++] = data;
_productorPos %= _cap; // 符合环装队列的下标
V(_dataSem); // 释放信号量(消费者的)
}
void pop(T* out)
{
// 消费之前也要先申请信号量
P(_dataSem);
*out = _queue[_ConsumerPos++]; // 消费
_ConsumerPos %= _cap;// 符合环装队列的下标
V(_spaceSem); //释放生产者的信号量
}
private:
// 这里封装一下PV操作,实际上不封装也是可以的
void P(sem_t& sem) // P操作,预定信号量
{
int n = sem_wait(&sem); // P
assert(n == 0);
(void)n;
}
void V(sem_t& sem) // V操作,释放信号量
{
int n = sem_post(&sem); // V
assert(n == 0);
(void)n;
}
private:
vector<T> _queue; //以数组为底层
int _cap; //环形队列的容量
sem_t _spaceSem; //空间资源对生产者来说才是资源
sem_t _dataSem; //数据资源对消费者来说才是资源
// 下面这两个属性是可以不用的,这样会更清楚一点
int _productorPos; // 生产者在队列中的下标
int _ConsumerPos; // 消费者在队列中的下标
};
main.cc:
#include"RingQueue.hpp"
#include<pthread.h>
#include<cstdlib>
#include<unistd.h>
void* Productor(void* ringqueue)
{
RingQueue<int>* rq = static_cast<RingQueue<int>*>(ringqueue);
while(true) // 不断生产
{
int data = rand() % 10 + 1;
rq->push(data);
cout << "生产数据:" << data << endl;
// sleep(1); // 通过sleep控制生产的速度
}
}
void* Consumer(void* ringqueue)
{
RingQueue<int>* rq = static_cast<RingQueue<int>*>(ringqueue);
while(true) // 不断消费
{
int data;
rq->pop(&data);
printf("消费数据:%d\n", data);
sleep(1); // 通过sleep控制消费的速度
}
}
int main()
{
srand((unsigned int)time(nullptr) ^ getpid());
RingQueue<int>* rq = new RingQueue<int>();
pthread_t c, p;
pthread_create(&p, nullptr, Productor, rq);
pthread_create(&c, nullptr, Consumer, rq);
pthread_join(p, nullptr);
pthread_join(c, nullptr);
return 0;
}
可以通过sleep控制生产和消费之间的速度。来达到不同的效果,实际出来的效果和上面基于阻塞队列的生产消费模型是一样的
- 生产快过消费
- 消费快过生产
可以发现,不需要加锁,信号量就可以解决互斥和同步。并且在大多数情况下,生产者线程和消费者线程,访问的资源都不是同一个位置,可以并发的执行
实验2(改进):
和阻塞队列那边是一样的,生产消费的数据是一个int,没什么意思,所以可以自己编写一个Task任务类,这样生产者可以生产一个任务去给消费者消费。
Task.hpp:
#include<iostream>
#include<functional>
using namespace std;
// 让任务类型提供一些+-*\的接口
class Task
{
// typedef function<int(int, int)> fun_t; // 定义一个函数类型,返回值int,参数为(int, int)
using func_t = function<int(int, int, char)>; // 和上面那句话的功能是一样的
public:
Task(){}
Task(int x, int y, char op, func_t func)
:_x(x)
,_y(y)
,_op(op)
,_callback(func)
{}
string operator()()
{
int result = _callback(_x, _y, _op);
char buffer[128];
snprintf(buffer, sizeof buffer, "%d %c %d = %d", _x, _op, _y, result);
return buffer;
}
string toTackString()
{
char buffer[128];
snprintf(buffer, sizeof buffer, "%d %c %d = ?", _x, _op, _y);
return buffer;
}
private:
int _x;
int _y;
char _op; //存储选项
func_t _callback;
};
const string oper = "+-*/%";
int mymath(int x, int y, char op)
{
int result;
switch (op)
{
case '+':
result = x + y;
break;
case '-':
result = x - y;
break;
case '*':
result = x * y;
break;
case '/':
{
if (y == 0)
{
cout << "不能/0\n";
break;
}
result = x / y;
break;
}
case '%':
{
if (y == 0)
{
cout << "不能%0";
break;
}
result = x % y;
break;
}
default:
cout << "error oper" << endl;
break;
}
return result;
}
main.cc:
#include "RingQueue.hpp"
#include <pthread.h>
#include <cstdlib>
#include <unistd.h>
#include "Task.hpp"
void *Productor(void *ringqueue)
{
RingQueue<Task> *rq = static_cast<RingQueue<Task> *>(ringqueue);
while (true) // 不断生产
{
// version2
//生产任务
int x = rand() % 100;
int y = rand() % 10;
int index = rand() % oper.size();
Task t(x, y, oper[index], mymath);
rq->push(t); // 生产
printf("生产任务:%s\n", t.toTackString().c_str()); // 打印任务
// sleep(1);// 控制生产速度
}
}
void *Consumer(void *ringqueue)
{
RingQueue<Task> *rq = static_cast<RingQueue<Task> *>(ringqueue);
while (true) // 不断消费
{
// version2
Task t;
rq->pop(&t); // 消费任务(获取任务)
string result = t(); // 执行任务
printf("消费任务:%s\n", result.c_str());
sleep(1);// 控制消费速度
}
}
int main()
{
srand((unsigned int)time(nullptr) ^ getpid());
RingQueue<Task> *rq = new RingQueue<Task>();
pthread_t c, p;
pthread_create(&p, nullptr, Productor, rq);
pthread_create(&c, nullptr, Consumer, rq);
pthread_join(p, nullptr);
pthread_join(c, nullptr);
return 0;
}
执行效果如下:
- 生产速度快过消费
- 消费快过生产
实验3(改进):
在信号量这里,多生产多消费的情况,和阻塞队列的那个不太一样。上面的代码无论是多生产多消费还是单生产单消费,代码不用修改。因为进入临界区之前都要申请锁。但是这里不一样,一旦很多个线程同时进入生产或者消费,那么环形队列的下标的++,–不是原子操作,很可能出现对同一个下标(资源)进行生产或消费。这样就不对。【信号量只保证该环形队列中仍然有资源给线程去生产/消费。】
因此,一旦是多生产,多消费,就需要有两把锁,专门对环形队列的下标这个临界资源进行保护
代码如下:
RingQueue.hpp:
const int gcap = 5;
template<class T>
class RingQueue
{
public:
RingQueue(const int& cap = gcap)
:_queue(cap) //调用vector的构造
,_cap(cap)
{
// 对信号量要初始化
int n = sem_init(&_spaceSem, 0, _cap); //对于生产者一开始资源数量就是容量大小
assert(n == 0);
n = sem_init(&_dataSem, 0, 0); //对于消费者一开始没有资源
assert(n == 0);
_productorPos = _ConsumerPos = 0; // 一开始都在0下标处
// 对锁也要初始化
pthread_mutex_init(&_pmutex, nullptr);
pthread_mutex_init(&_cmutex, nullptr);
}
~RingQueue()
{
// 对锁和信号量要销毁
int n = sem_destroy(&_spaceSem);
assert(n == 0);
n = sem_destroy(&_dataSem);
assert(n == 0);
pthread_mutex_destroy(&_pmutex);
pthread_mutex_destroy(&_cmutex);
}
void push(const T& data)
{
// 生产——插入之前要先申请信号量,看看有没有资源给自己生产
P(_spaceSem);// 要注意,先申请信号量在申请锁、信号量不需要被保护,它自己的操作就是原子操作
pthread_mutex_lock(&_pmutex); //先预定资源,再互斥访问,
// 走到这里就是申请到了,不然会阻塞等待
_queue[_productorPos++] = data;
_productorPos %= _cap; // 符合环装队列的下标
pthread_mutex_unlock(&_pmutex);
V(_dataSem); // 释放信号量(消费者的)
}
void pop(T* out)
{
// 消费之前也要先申请信号量
P(_dataSem); //先预定资源,再互斥访问,
pthread_mutex_lock(&_cmutex);
*out = _queue[_ConsumerPos++]; // 消费
_ConsumerPos %= _cap;// 符合环装队列的下标
pthread_mutex_unlock(&_cmutex);
V(_spaceSem); //释放生产者的信号量
}
private:
// 这里封装一下PV操作,实际上不封装也是可以的
void P(sem_t& sem) // P操作,预定信号量
{
int n = sem_wait(&sem); // P
assert(n == 0);
(void)n;
}
void V(sem_t& sem) // V操作,释放信号量
{
int n = sem_post(&sem); // V
assert(n == 0);
(void)n;
}
private:
vector<T> _queue; //以数组为底层
int _cap; //环形队列的容量
sem_t _spaceSem; //空间资源对生产者来说才是资源
sem_t _dataSem; //数据资源对消费者来说才是资源
// 下面这两个属性是可以不用的,这样会更清楚一点
int _productorPos; // 生产者在队列中的下标
int _ConsumerPos; // 消费者在队列中的下标
// 在多消费,多生产的情况下,需要两把锁,来保证临界资源的安全
pthread_mutex_t _pmutex;
pthread_mutex_t _cmutex;
};
main.cc:
没有区别,除了创建了多个线程
int main()
{
srand((unsigned int)time(nullptr) ^ getpid());
RingQueue<Task> *rq = new RingQueue<Task>();
// 多消费,多生产
pthread_t p[3], c[6]; // 3生产,6消费
for(int i = 0; i < 3; i++)
pthread_create(p+i, nullptr, Productor, rq);
for(int i = 0; i < 6; i++)
pthread_create(c+i, nullptr, Consumer, rq);
for(int i = 0; i < 3; i++)
pthread_join(p[i], nullptr);
for(int i = 0; i < 6; i++)
pthread_join(c[i], nullptr);
return 0;
}
Task.hpp:
这里不展示了,没有变动
其实执行效果都是一样的,看不太出来区别,只是这里变成了多消费多生产。那这样的好处是什么?
好处就是高效——在一个线程拿着锁在访问临界资源的时候,其他线程也没有闲着,而是在干自己的事情,如生成任务,执行任务,申请信号量等等…
详细的解析在上面阻塞队列那边已经有讲了。
; // 消费
_ConsumerPos %= _cap;// 符合环装队列的下标
pthread_mutex_unlock(&_cmutex);
V(_spaceSem); //释放生产者的信号量
}
private:
// 这里封装一下PV操作,实际上不封装也是可以的
void P(sem_t& sem) // P操作,预定信号量
{
int n = sem_wait(&sem); // P
assert(n == 0);
(void)n;
}
void V(sem_t& sem) // V操作,释放信号量
{
int n = sem_post(&sem); // V
assert(n == 0);
(void)n;
}
private:
vector _queue; //以数组为底层
int _cap; //环形队列的容量
sem_t _spaceSem; //空间资源对生产者来说才是资源
sem_t _dataSem; //数据资源对消费者来说才是资源
// 下面这两个属性是可以不用的,这样会更清楚一点
int _productorPos; // 生产者在队列中的下标
int _ConsumerPos; // 消费者在队列中的下标
// 在多消费,多生产的情况下,需要两把锁,来保证临界资源的安全
pthread_mutex_t _pmutex;
pthread_mutex_t _cmutex;
};
main.cc:
没有区别,除了创建了多个线程
```cpp
int main()
{
srand((unsigned int)time(nullptr) ^ getpid());
RingQueue<Task> *rq = new RingQueue<Task>();
// 多消费,多生产
pthread_t p[3], c[6]; // 3生产,6消费
for(int i = 0; i < 3; i++)
pthread_create(p+i, nullptr, Productor, rq);
for(int i = 0; i < 6; i++)
pthread_create(c+i, nullptr, Consumer, rq);
for(int i = 0; i < 3; i++)
pthread_join(p[i], nullptr);
for(int i = 0; i < 6; i++)
pthread_join(c[i], nullptr);
return 0;
}
Task.hpp:
这里不展示了,没有变动
其实执行效果都是一样的,看不太出来区别,只是这里变成了多消费多生产。那这样的好处是什么?
好处就是高效——在一个线程拿着锁在访问临界资源的时候,其他线程也没有闲着,而是在干自己的事情,如生成任务,执行任务,申请信号量等等…
详细的解析在上面阻塞队列那边已经有讲了。