W...Y的主页 😊
代码仓库分享💕
前言:我们上篇博客中提到了线程互斥、引出了互斥锁解决了线程中的未将临界资源保护的问题。但是随之出来的问题——竞争锁是自由竞争的,竞争锁的能力太强的线程会导致其他线程抢不到票,所以导致有些版本下进程会出现饥饿问题,这就要引出线程同步问题。
首先我们用一个实例来说明一下饥饿问题:有一个图书自习室一次只能进去一个人去学习,但是进入必须要有一个钥匙。当有个人进去时就其余的人就必须在外面等待,当有资格学习的人出来看到外面排着长队时就觉得下次如果在想进入自习室很难,于是拿着钥匙又进去了,就这样卡bug占用自习室一天,剩下的人只能是在自习室外等候。这就造成了其余人的饥饿问题,根本抢不到进入自习室的钥匙。
但是这个bug没有违反自习室一次只能进去一个人的规则,所以学校看到这个现象就修复了这个bug。当一个人从自习室出来不能马上再返回自习室,而是要到队尾进行排队进入。这样的方法让不同的人保证自习室只有一个人的前提下,让所有人都有去自习室学习的顺序性!!!
这个工作被我们称为同步。
目录
Linux线程同步
条件变量
同步概念与竞态条件
条件变量函数 初始化
销毁
等待条件满足
唤醒等待
常见锁概念
死锁
生产者消费者模型
基于BlockingQueue的生产者消费者模型
Linux线程同步
条件变量
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
那我们如何做到线程的同步呢?这就要使用我们的条件变量!
我们先来说明一下什么是条件变量:桌子上放了一个盘子,有两个参与人员一个瞎子和一个哑巴。哑巴会往盘子中放入苹果,瞎子要去盘子中取苹果。因为哑巴会不定时的在盘子中放入苹果但是不会说话,所以瞎子就会不断的确认盘子中是否有苹果,有苹果就将苹果取出来。首先我们必须保证再哑巴放苹果时瞎子不会去取苹果(互斥),这个苹果就像临界资源,而临界资源应该被保护,所以就会有一个mutex。但是瞎子不知道什么时候有苹果,所以导致瞎子就会一直访问临界资源申请mutex。所以哑巴就申请不到锁访问不了临界资源也就是放不了苹果,瞎子做的都是无效动作,导致哑巴有了饥饿问题。
所以哑巴在身边放了一个铃铛,当自己放入苹果后就会敲响铃铛告诉瞎子盘子里有苹果了,这样瞎子就可以取苹果了。当一群瞎子去取苹果,发现没有时就不会去继续访问,而是在一旁排队等待,直到哑巴放入苹果后敲响铃铛可以将我们队头的瞎子,或者所有的瞎子都叫过来去访问资源!!
这里的铃铛和队列等整体组合就是条件变量。
条件变量函数 初始化
pthread_cond_t pthread_cond_t cond = PTHREAD_COND_INITIALIZER,这个与互斥锁中的规则一样在全局、静态使用上述宏初始化。局部使用ptjread_cond_init函数初始化。
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁
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);
唤醒所有在cond等待的线程
int pthread_cond_signal(pthread_cond_t *cond);唤醒一个在cond等待的线程
简单案例:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<vector>
#include<string>
using namespace std;
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER;
void *SlaverCore(void *args)
{
std::string name = static_cast<const char *>(args);
while (true)
{
// 1. 加锁
pthread_mutex_lock(&gmutex);
// 2. 一般条件变量是在加锁和解锁之间使用的
pthread_cond_wait(&gcond, &gmutex); // gmutex:这个是,是用来被释放的[前一半]
std::cout << "当前被叫醒的线程是: " << name << std::endl;
// 3. 解锁
pthread_mutex_unlock(&gmutex);
}
}
void *MasterCore(void *args)
{
sleep(3);
std::cout << "master 开始工作..." << std::endl;
std::string name = static_cast<const char *>(args);
while (true)
{
pthread_cond_signal(&gcond);// 唤醒其中一个队列首部的线程
//pthread_cond_broadcast(&gcond);// 唤醒队列中所有的线程
std::cout << "master 唤醒一个线程..." << std::endl;
sleep(1);
}
}
void StartMaster(std::vector<pthread_t> *tidsptr)
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, MasterCore, (void *)"Master Thread");
if (n == 0)
{
std::cout << "create master success" << std::endl;
}
tidsptr->emplace_back(tid);
}
void StartSlaver(std::vector<pthread_t> *tidsptr, int threadnum = 3)
{
for (int i = 0; i < threadnum; i++)
{
char *name = new char[64];
snprintf(name, 64, "slaver-%d", i + 1); // thread-1
pthread_t tid;
int n = pthread_create(&tid, nullptr, SlaverCore, name);
if (n == 0)
{
std::cout << "create success: " << name << std::endl;
tidsptr->emplace_back(tid);
}
}
}
void WaitThread(std::vector<pthread_t> &tids)
{
for (auto &tid : tids)
{
pthread_join(tid, nullptr);
}
}
int main()
{
vector<pthread_t> tids;
StartMaster(&tids);
StartSlaver(&tids);
WaitThread(tids);
return 0;
}
我们创建两个函数,第一个函数创建一个master线程用来唤醒对列中的子线程。第二个函数创建三个线程,其中公共资源就是cout输出,我们在cout周围加锁,然后进行pthread_cond_wait等待,等待master线程使用pthread_cond_signal唤醒一个线程循环。我们可以在运行结果中看到1、2、3分别被唤醒,这就说明这些线程都是在一个队列中存储的。
为什么 pthread_cond_wait 需要互斥量?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
我们先来认识一下什么是锁:
常见锁概念
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
最常见的场景就比如两个线程需要访问同一块公共资源,但是访问公共资源时必须要有两把锁才可以进行访问。但是因为程序员的问题,导致两个进程在争夺锁的顺序不同。线程1先争夺锁1再争夺锁2。而线程2刚好反过来导致线程1争夺下锁1时线程2争夺了锁2。但是接下来他们两个就想争夺自己没有的锁,但是这两个锁分别被两个进程都持有1把,然后进入休眠没有释放锁,最终导致死锁问题。
死锁四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
如何避免死锁:
破坏死锁的四个必要条件
加锁顺序一致(最好实现)
避免锁未释放的场景
资源一次性分配
避免死锁算法有:死锁检测算法、银行家算法。
所以再回到上述唤醒等待时,我们需要再pthread_cond_wait函数中加入互斥锁!
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。
int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,
会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。
生产者消费者模型
什么是生产消费者模型呢?就比如超市就是一个典型的生产消费者模型,供货商、超市、消费者就是三种关系。供货商将商品提供给超市,而超市将商品卖给消费者。这种关系就是生产消费者模型。而提供的商品就是数据,超市是可以临时保存数据的一个场所,我们一般使用一种数据结构进行存储。
而在这种模型下肯定会有其并发问题。比如当生产者放数据时消费者直接拿走,导致数据不统一等等。而最终会归结为三种关系:生产者VS生产者、消费者VS消费者、生产者VS消费者。他们直接肯定都会有同步和互斥的关系,两种角色和一个交易场所。 为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
生产者消费者模型优点:解耦、支持并发、支持忙闲不均。
基于BlockingQueue的生产者消费者模型
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
我们先从单生产单消费模型开始,因为单生产单消费只需要维护生产者与消费者之间的关系,然后进行多生产多消费:
blockqueue.hpp
#ifndef __BLOCK_QUEUE_HPP__
#define __BLOCK_QUEUE_HPP__
#include <iostream>
#include <unistd.h>
#include <queue>
#include <pthread.h>
using namespace std;
template <typename T>
class BlockQueue
{
public:
BlockQueue(int cap)
:_cap(cap)
{
_productor_wait_num = 0;
_consumer_wait_num = 0;
pthread_mutex_init(&_mutex,nullptr);
pthread_cond_init(&_product_cond,nullptr);
pthread_cond_init(&_consum_cond,nullptr);
}
bool IsFull()
{
return _block_queue.size() == _cap;
}
bool IsEmpth()
{
return _block_queue.empty();
}
void Enqueue(const T& in)
{
pthread_mutex_lock(&_mutex);
while(IsFull())
{
_productor_wait_num++;
pthread_cond_wait(&_product_cond, &_mutex);
_productor_wait_num--;
}
_block_queue.push(in);
if(_consumer_wait_num > 0)pthread_cond_signal(&_consum_cond);
pthread_mutex_unlock(&_mutex);
}
void Pop(T* out)
{
pthread_mutex_lock(&_mutex);
while(IsEmpth())//健壮性
{
_consumer_wait_num++;
pthread_cond_wait(&_consum_cond, &_mutex);
_consumer_wait_num--;
}
*out = _block_queue.front();
_block_queue.pop();
if(_productor_wait_num > 0)pthread_cond_signal(&_product_cond);
pthread_mutex_unlock(&_mutex);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_product_cond);
pthread_cond_destroy(&_consum_cond);
}
private:
std::queue<T> _block_queue; // 阻塞队列,是被整体使用的!!!
int _cap; // 总上限
pthread_mutex_t _mutex; // 保护_block_queue的锁
pthread_cond_t _product_cond; // 专门给生产者提供的条件变量
pthread_cond_t _consum_cond; // 专门给消费者提供的条件变量
int _productor_wait_num;
int _consumer_wait_num;
};
#endif
我们需要两把锁和一个条件变量,当往阻塞队列中放入和拿出东西时必须是有锁的加持。但是当队列为空或为满时,消费者和生产者就必须阻塞等待,这时我们就必须要有一个条件变量进行控制,当生产者生产了一个任务时,我们可以通知消费者去拿去对应的内容。
这里需要强调的点是Enqueue与pop函数中的判断队列是否为空的while语句,很多人觉得应该使用if,但是我们的代码中唤醒队列是每添加一个或消费一个内容就会调用唤醒函数,所以使用if只能有一次判断的机会,但是当有五个消费者时,我们使用pthread_cond_broadcast函数将所有消费者全部唤醒,只有一个任务时,只有一个消费者可以拿到任务,其余消费者被异常唤醒也再竞争锁,线程1继续往下执行代码,其余线程不会在条件变量下等待,而是在等待锁释放。当线程1将锁释放完后其余四个线程会直接从pthread_cond_wait函数下竞争锁然后继续执行内容,但是阻塞队列中没有数据从而产生问题。
这个代码中通知次数肯定远远大于休眠次数,所以我们肯定就会有过剩的通知来唤醒刚休眠的线程,所以就有可能被异常唤醒。所以我们就要使用while来一直进行判断。
main.cc
#include "BlockQueue.hpp"
#include "Thread.hpp"
#include "Task.hpp"
#include <string>
#include <vector>
#include <unistd.h>
#include <ctime>
int a = 10;
using namespace ThreadModule;
using namespace std;
using blockqueue_t = BlockQueue<Task>;
void PrintHello()
{
cout << "hello" << endl;
}
void Consumer(blockqueue_t &bq)
{
while (true)
{
// 1. 从blockqueue取下来任务
Task t;
bq.Pop(&t);
// 2. 处理这个任务
t(); //消费者私有
//std::cout << "Consumer Consum data is : " << t.ResultToString() << std::endl;
}
}
void Productor(blockqueue_t &bq)
{
// srand(time(nullptr)^pthread_self());
// int cnt = 10;
while (true)
{
sleep(1);
// // 1. 获取任务
// int a = rand() % 10 + 1;
// usleep(1234);
// int b = rand() % 20 + 1;
// Task t(a, b);
// 2. 把获取的任务,放入blockqueue
Task t = PrintHello;
bq.Enqueue(t);
//std::cout << "Productor product data is : " << t.DebugToString() << std::endl;
}
}
void StartComm(std::vector<Thread<blockqueue_t>> *threads, int num, blockqueue_t &bq, func_t<blockqueue_t> func)
{
for (int i = 0; i < num; i++)
{
std::string name = "thread-" + std::to_string(i + 1);
threads->emplace_back(func, bq, name);
//(*threads)[threads->size()-1].Start();
threads->back().Start();
}
}
void StartConsumer(std::vector<Thread<blockqueue_t>> *threads, int num, blockqueue_t &bq)
{
StartComm(threads, num, bq, Consumer);
}
void StartProductor(std::vector<Thread<blockqueue_t>> *threads, int num, blockqueue_t &bq)
{
StartComm(threads, num, bq, Productor);
}
void WaitAllThread(std::vector<Thread<blockqueue_t>> threads)
{
for(auto thread : threads)
{
thread.Join();
}
}
int main()
{
blockqueue_t *bq = new blockqueue_t(5);
std::vector<Thread<blockqueue_t>> threads;
StartConsumer(&threads, 1, *bq);
StartProductor(&threads, 1, *bq);
WaitAllThread(threads);
return 0;
}
我们所使用的线程不是原生线程库,而是对其进行了分装处理。以上是部分代码需要全部代码的可以到博主代码库中自行拿去。
而多生产多消费只需要在main函数中将参数从1改为多就可以了,代码不需要任何修改,因为其进入函数都需要竞争一把锁,是串行的。
以上就是本次全部内容,感谢大家观看!!!