Linux线程同步方法之二
条件变量
饥饿状态:由于线程A频繁地申请/释放锁,而导致其他线程无法访问临界资源的情况。
同步synchronized:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
竞态条件Race Condition:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。解决方案之一为信号量(semaphore)。
条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待条件变量的条件成立而挂起;另一个线程使条件成立(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥量结合在一起。
例如生产消费模型中一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中,这种情况就需要用到条件变量。
生产消费模型
321原则==>是生产消费模型所需要维护的基本原则
- 3种关系:生产者与生产者之间是互斥关系,消费者与消费者之间也是互斥关系,生产者和消费者之间既互斥又同步;
- 2种角色:生产者线程、消费者线程;
- 1个交易场所:一段特定结构的共享缓冲区。
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
main()函数是生产者,它交给调用函数fun()的变量是数据,fun()函数是消费者,在这种情况,生产者和消费者处于强耦合状态(main要等待fun函数执行完毕返回)
优点
生产者和消费者之间解耦,支持并发,支持忙闲不均。
理解条件变量
当某种条件不满足的时候,线程必须去某些定义好的条件变量上进行等待。
条件变量内部有个PCB类型的队列,struct cond{ int status; task_struct* q; };
当条件变量不满足的时候,就可以把该线程的PCB链接到后面进行等待。
条件变量函数
pthread_cond_init初始化和销毁
// 初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrictattr);
参数:
cond:要初始化的条件变量
attr:一般设为nullptr
// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);
// ------作为全局变量初始化------ //
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_wait等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
第二个参数必须传入互斥锁。是为了让函数在被调用的时候以原子性的方式释放锁,并将当前进程挂起。该函数在被唤醒返回的时候会自动重新获取锁,如果没有获取锁成功,就会一直竞争锁,直到成功,换而言之,只要该函数返回了就一定取得锁。
pthread_cond_signal唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒在cond条件下等待的所有线程
int pthread_cond_signal(pthread_cond_t *cond);//唤醒在cond条件下等待的单个进程
基于阻塞队列单/多生产消费模型的实现
日常使用中,基于互斥锁和条件变量访问临界资源的流程一般是1、先加锁;2、判断是否满足某种条件,满足则执行对应语句,不满足就阻塞挂起;3、然后再解锁。
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。
阻塞队列blockqueue就是这个程序里的共享资源,需要用锁进行互斥保护,用条件变量进行同步,生产者和消费者都需要在各自的cond下进行等待。
//BlockQueue.hpp
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>
const int g_maxcap = 5;
template <class T>
class BlockQueue
{
public:
BlockQueue(const int &maxcap = g_maxcap) : _maxcap(g_maxcap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_pcond, nullptr);
pthread_cond_init(&_ccond, nullptr);
}
// 生产者调用push
// 输入输出型参数为&
void push(const T &in) // 输入型参数一般为const T&
{
// 1 加锁
pthread_mutex_lock(&_mutex);
// 2.1 如果队列满了,就不能生产,生产者就要到条件变量下等
while (isFull())
{
pthread_cond_wait(&_pcond, &_mutex);
}
// 2.2 走到这,队列一定没有满
_q.push(in);
// 3 此时阻塞队列里一定有数据,可以让消费者来消费
pthread_cond_signal(&_ccond);
// 4 解锁
pthread_mutex_unlock(&_mutex);
}
// 消费者调用pop
void pop(T *out) // 输出型参数一般为T*
{
// 1 加锁
pthread_mutex_lock(&_mutex);
// 2.1 如果队列是空的,就不能消费,消费者就要到条件变量下等
while (isEmpty())
{
pthread_cond_wait(&_ccond, &_mutex);
}
// 2.2 走到这,队列一定有数据
*out = _q.front();
_q.pop();
// 3 此时阻塞队列里一定有1个空位数据,可以让生产者放数据
pthread_cond_signal(&_pcond);
// 4 解锁
pthread_mutex_unlock(&_mutex);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_ccond);
pthread_cond_destroy(&_pcond);
}
private:
bool isEmpty()
{
return _q.empty();
}
bool isFull()
{
return _q.size() == _maxcap;
}
private:
std::queue<T> _q;
int _maxcap; // 最大容量
pthread_mutex_t _mutex; // 互斥锁
pthread_cond_t _pcond; // 生产者的条件变量 -- 队列满
pthread_cond_t _ccond; // 消费者的条件变量 -- 队列空
};
//Task.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
const std::string oper = "+-*/%";
int mymath(int x, int y, char op)
{
int result = 0;
switch (op)
{
case '+':
result = x + y;
break;
case '-':
result = x - y;
break;
case '*':
return x * y;
break;
case '/':
{
if (y == 0)
{
std::cerr << "div zero error!" << std::endl;
result = -1;
}
else
result = x / y;
}
break;
case '%':
{
if (y == 0)
{
std::cerr << "mod zero error!" << std::endl;
result = -1;
}
else
result = x % y;
}
break;
default:
// do nothing
break;
}
return result;
}
class CalcTask
{
// 以下两种写法等价
// typedef std::function<int(int, int)> func_t; // c98写法
using func_t = std::function<int(int, int, char)>; // C11写法
public:
CalcTask()
{}
CalcTask(int x, int y, char op, func_t callback) :_x(x), _y(y), _op(op), _callback(callback)
{}
std::string operator()()
{
int result = _callback(_x, _y, _op);
char buffer[1024];
snprintf(buffer, sizeof buffer, "%d %c %d = %d", _x, _op, _y, result);
return buffer;
}
std::string toTaskString()
{
char buffer[1024];
snprintf(buffer, sizeof buffer, "%d %c %d = ?", _x, _op, _y);
return buffer;
}
private:
int _x;
int _y;
char _op;
func_t _callback;
};
void record(const std::string& message)
{
const std::string target = "./log.txt";
FILE* fp = fopen(target.c_str(), "a+");
if(!fp)
{
std::cerr << "fopen error" << std::endl;
return;
}
fputs(message.c_str(), fp);
fputs("\n", fp);
fclose(fp);
}
class RecoTask
{
typedef std::function<void(const std::string&)> func_t;
public:
RecoTask(){}
RecoTask(const std::string& msg, func_t func) : _message(msg), _func(func)
{}
void operator()()
{
_func(_message);
}
private:
std::string _message;
func_t _func;
};
//main_testcp.cc
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <ctime>
#include <sys/types.h>
#include <unistd.h>
//两个队列,一个是计算队列,一个是存储队列
template <class Calc, class Reco>
class BlockQueues
{
public:
BlockQueue<Calc> *c_bq;
BlockQueue<Reco> *r_bq;
};
void *producer(void *args)
{
BlockQueue<CalcTask> *bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->c_bq;
while (true)
{
// 1 生产随机数
int x = rand() % 100 + 1;
int y = rand() % 40;
int operCode = rand() % oper.size();
CalcTask t(x, y, oper[operCode], mymath);
bq->push(t);
std::cout << "producer thread " << t.toTaskString() << std::endl;
sleep(1); // 让生产者慢一点
}
return nullptr;
}
void *consumer(void *args)
{
BlockQueue<CalcTask> *bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->c_bq;
BlockQueue<RecoTask> *reco_bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->r_bq;
while (true)
{
CalcTask t;
bq->pop(&t);
std::string result = t();
std::cout << "consumer thread " << t() << std::endl;
RecoTask reco_t(result, record);
reco_bq->push(reco_t);
std::cout << "consumer thread 推送保存任务已完成" << std::endl;
}
return nullptr;
}
void *recorder(void *args)
{
BlockQueue<RecoTask> *reco_bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->r_bq;
while(true)
{
RecoTask t;
reco_bq->pop(&t);
t();
std::cout << "recorder thread 保存任务已完成" << std::endl;
}
return nullptr;
}
int main()
{
srand((unsigned long)time(nullptr) ^ getpid());
BlockQueues<CalcTask, RecoTask> bqs;
bqs.c_bq = new BlockQueue<CalcTask>();
bqs.r_bq = new BlockQueue<RecoTask>();
// 多生产者 多消费者 单记录者
// pthread_t p_er[2], c_er[3], re_er; // 生产者、消费者、记录员
// pthread_create(&p_er[0], nullptr, producer, &bqs);
// pthread_create(&p_er[1], nullptr, producer, &bqs);
// pthread_create(&p_er[2], nullptr, producer, &bqs);
// pthread_create(&c_er[0], nullptr, consumer, &bqs);
// pthread_create(&c_er[1], nullptr, consumer, &bqs);
// pthread_create(&re_er, nullptr, recorder, &bqs);
// pthread_join(p_er[0], nullptr);
// pthread_join(p_er[1], nullptr);
// pthread_join(p_er[2], nullptr);
// pthread_join(c_er[0], nullptr);
// pthread_join(c_er[1], nullptr);
// pthread_join(re_er, nullptr);
// 单生产者 单消费者 单记录者
pthread_t p_er, c_er, re_er; // 生产者、消费者、记录员
pthread_create(&p_er, nullptr, producer, &bqs);
pthread_create(&c_er, nullptr, consumer, &bqs);
pthread_create(&re_er, nullptr, recorder, &bqs);
pthread_join(c_er, nullptr);
pthread_join(p_er, nullptr);
pthread_join(re_er, nullptr);
delete bqs.c_bq;
delete bqs.r_bq;
return 0;
}
实验现象观察与分析
若生产者生产慢一点(在producer函数里加个sleep(1);),消费者快一点
程序刚运行时,假设生产者先竞争到锁,就往阻塞队列里塞了挺多数据,此时消费者来了,由于消费的快,就会把阻塞队列里的数据消费完,程序稳定时,一定是空队列或者队列里只有一个数据的状态,生产一个新数据,消费一个新数据。
若生产者生产快一点,消费者慢一点(在consumer函数里加个sleep(1);)
程序刚运行时,假设生产者先竞争到锁,就往阻塞队列里塞了挺多数据,此时消费者来了,由于消费的慢,只消费了阻塞队列里的几个数据消,程序稳定时,一定是满队列或者队列里只有一个空位的状态,生产一个新数据,消费一个历史数据。
代码细节
1、加锁和解锁之间的条件判断
充当条件判断的语法必须是while
,不能是if。因为从pthread_cond_wait
醒来返回时,可以重新进入判断,避免伪唤醒情况!比如有1个消费者,10个生产者,此时只消费了一个数据,而用的是pthread cond broadcast
广播给10个生产者线程,由于判断条件是if(isFul))执行流就直接往后走了,很有可能生产多了数据,所以要用while,让每个生产者线程醒过来的时候都再去判断一下有没有空位。
2、条件等待函数
在加锁后,若满足while判断条件,则调用pthread_cond_wait
函数,它的第2个参数是mutex,目的是让该函数被调用的时候以原子性的方式释放锁,并将自己挂起;只要该函数被唤醒且成功返回时,就表示自动重新获取锁。若pthread_cond_wait函数调用失败?调用失败的话就不会返回了。
3、pthread cond signal最好放在临界资源区
pthread_cond_signal可以在临界区的内部,也可以在临界区外部。建议在临界资源去内部,因为对于线程本身来说,我的unlock和lock代码离得近,越方便我抢锁!
拓展1:生产者+消费者+记录员,就用两个阻塞队列来实现,单生产者和单消费者、单消费者和单记录者。
**拓展2:那多生产者和多消费者可以用这份代码实现吗?可以,push和pop里面都加锁了。**每个生产者都需要调用push接口,因为有锁的存在,可以实现生产者之间的互斥,消费者都调用pop接口同理。
pthread_t p_er[2], c_er[3], re_er; // 生产者、消费者、记录员
pthread_create(&p_er[0], nullptr, producer, &bqs);
pthread_create(&p_er[1], nullptr, producer, &bqs);
pthread_create(&p_er[2], nullptr, producer, &bqs);
pthread_create(&c_er[0], nullptr, consumer, &bqs);
pthread_create(&c_er[1], nullptr, consumer, &bqs);
pthread_create(&re_er, nullptr, recorder, &bqs);
pthread_join(p_er[0], nullptr);
pthread_join(p_er[1], nullptr);
pthread_join(p_er[2], nullptr);
pthread_join(c_er[0], nullptr);
pthread_join(c_er[1], nullptr);
pthread_join(re_er, nullptr);
思考
1、创建多线程生产和消费的意义是什么?
对于生产者而言,要向blockqueue
里放数据;对于消费者而言,要从blockqueue
拿出数据。
生产者放任务的预备工作是去构建任务(可能是从外设、网络、数据块这些地方拿来的用户数据),构建任务也是要花时间的;
消费者取出任务后还要执行该项任务(可能非常耗时)。
2、生产消费模型高效在哪里?
blockqueue这里的动作只允许串行执行,这里并不高效,而是体现在某一个线程放置/拿去任务时,并不影响其他线程构建任务/执行任务。在放置任务之前/拿出任务之后,允许多线程并行执行,才叫做高效!
3、就目前为止,代码还可以作何改进?
一个线程要想操作临界资源,该临界资源需满足某种条件(如生产者要push时,阻塞队列必须有一个空位;消费者要pop时,阻塞队列里至少有1个数据)。push和pop对应的代码里,流程是先加锁,再检测、再push/pop,再解锁(因为不访问临界资源,就不知道该临界资源是否满足条件)>即检测的前提是先访问临界资源>访问之前要先加锁,临界资源就被整体锁定了==>当前线程拥有了对临界资源整体使用权。
第一点:在实际情况下,一份公共资源是允许同时访问不同区域的!即允许多线程并发访问公共资源不同区域。而我们目前的代码很明显是单线程独占公共资源,无法实现并发访问。
第二点:我们在操作临界资源的时候,有可能资源并不符合条件,但是线程无法提前得知,只能加锁访问,而频繁地加锁判断是否满足条件也意味着系统资源消耗,此处仍有不足。
故引入信号量来弥补不足。