一 线程同步
1 同步的意义
现实中抢票可能没票了还在抢票,然后线程就会一直在加锁解锁,就会导致其它线程抢不到锁而产生饥饿问题,我们前面也提过usleep就是让线程被切换,能让其它线程去申请锁,这种方式并不好,而且也不适用所有情况,而同步就是专门设计出来解决这种问题的,接下来就来看看什么是同步,来帮我们理解线程同步如何解决锁的饥饿问题。
2 同步概念
(1) linux是用条件变量来实现同步的
锁,条件变量也是线程库提供的,显然锁和条件变量也要被管理,显然锁和条件变量都是某种特殊的数据类型。
(2)不过都去排队了,为什么还要锁,首先这个等待队列肯定是共享资源,既然是共享资源,那如果没有被保护,那你说多线程访问下,还会按顺序排列吗? 所以注定等待的过程,一定是在加锁解锁之间。显然这里强调同步是要和锁配合的。
我们等会在实现的时候就知道锁和条件变量是如何配合的了。
3 线程同步实现
也就是用条件变量来实现。下面是init和destroy函数,它们的功能和锁的是一样的。
有意思的是条件变量也是像锁那样有全局和局部的概念。全局和静态的在初始化的时候也可以不用手动调用init函数。
这个等待函数有两个参数,一个是条件变量,还有一个是锁,传条件变量让函数判断是否成立,好吧,那为什么还要传锁呢?如果不成立,那你不就抱着锁去休眠了吗,后面的线程怎么拿到锁?所以要传入锁去解锁,不过当线程被唤醒后,wait函数会帮线程申请锁的。
多线程在某个条件变量下等待
等待主线程唤醒,唤醒后次线程就会从wait函数往后运行,而且是按照顺序的唤醒线程,这样就会公平的获得锁,不会出现一个线程一直持有锁的情况了。
全部唤醒
二 生产消费者模型
这个模型有生产者和消费者,以及超市,那为什么要有超市呢,为什么消费者不直接去供货商里买东西呢? 对于供货商来说,单个消费者无法向供货商要太多的数据,需求太零散,那供货商为了方便保存,只能来一个客户生产一个产品,赚的钱可能不够交电费,而且客户可能24小时都会来到,难道时刻喊工人开设备生产吗,售出少,利润少,而对于消费者来说,找供货商要也很麻烦,距离太远,而且去的时候供货商也不一定在开工,也就是说消费需求和生产步调往往是不一致的,这就导致消费,要等生产,生产,要等消费来。
所以需要有一个超市做为中间者,它向供货商要大量的产品,并且为消费者提供全年无休的服务,这样供货商就算不生成产品,也不影响消费者在超市消费,提高了消费者的效率,而且超市一次性要大批量的产品,工厂一次性生产完,也提高了供货商生产的效率。
对应于计算机中,生产者和消费者都是线程,超市就是一个缓冲区,这些线程往缓冲区放数据和拿数据,生产者和生产者是竞争关系,因为缓冲区是有限的,只有部分生产者可以把产品放入展架,消费者和消费者也是竞争,因为数据是有限的。而生产者和消费者还要有同步关系,要按照顺序访问缓冲区,不能总是生产者在放数据,打满了还在放就不合理,同理也不能总是在拿数据。而且有时候还得互斥,不能在放数据的时候去拿,容易出错,可是一拿一放是互斥的,那就是串行执行的,这也没体现出高效率啊。
我们得不能把目光放在这个缓冲区上,首先生产者放数据,这数据肯定是要获取的,要获取就要时间,此时消费者是在等吗,不是的,它可以去缓冲区获取数据,那此时就是多个生产者源源不断地生产数据,然后直接给缓冲区,多个消费者也一直获取数据来加工。如果没有缓冲区,此时单线程的时候,生产者获取数据,消费者只能等,等的时候没干活就是低效。
三 实现一个cp
这是一个基于BlockQueue(阻塞队列)的消费者模型,生产者将数据放到队列中,消费者从队列拿数据。我们分了三个文件放代码,一个头文件是放阻塞队列的实现,Makefile内没什么内容,就是一句gcc,main.cc是放测试代码。
设计一个单生产单消费的模型
void* Consumer(void* arg)
{BlockQueue<Task>* bq = (BlockQueue<Task>*) arg;while(true){Task t;这个Task会在介绍队列实现时介绍,因为Task代码和队列实现放在一起了现在只要知道是往队列获取任务就好了。bq->pop(t);这里是调用仿函数执行任务t();打印执行结果和退出码cout<<"Consumer:"<<t.getRformat()<<endl;sleep(1);}
}
void* Productor(void* arg)
{BlockQueue<Task>* bq = (BlockQueue<Task>*) arg;string format = "+-*/%";while(true){int x = rand() % 10;int y = rand() % 10;char op = format[rand()%format.size()];任务是对x,y变量做+,-*/等处理,处理方式要传入Task t(x,y,op);bq->push(t);传一个任务,并打印任务信息。cout<<"Productor: "<<t.getformat()<<endl;;sleep(1);}return nullptr;
}
int main()
{BlockQueue<Task> bq;srand(time(nullptr));pthread_t c,p;pthread_create(&c,nullptr,Consumer,&bq);pthread_create(&p,nullptr,Productor,&bq);pthread_join(c,nullptr);pthread_join(p,nullptr);return 0;
}
并且让他们看到同一个阻塞队列,也就是把队列的地址传入线程执行函数中,Consumer对队列pop处理,Productor对队列push处理,本质都是访问共享资源,所以这个操作必须得在临界区,也就是加锁内。
Task实现。
#define SIZE 5
class Task
{
public:Task(){;}Task(int x,int y,char op):_x(x),_y(y),_op(op){;}void operator()(){switch(_op) 根据传入的处理方式,对x,y做处理{case '+':{_result = _x + _y;_exitcode = 0;}break;case '-':{_result = _x - _y;_exitcode = 0;}break;case '*':{_result = _x * _y;_exitcode = 0;}break;case '/':{if(_y == 0)_exitcode = 1;else _result = _x / _y;}break;case '%':{ if(_y == 0)_exitcode = 1;else _result = _x % _y;}break;}}string getformat(){return to_string(_x) + _op + to_string(_y)+'?';}string getRformat(){return to_string(_result) + "("+to_string(_exitcode) + ")";}int _x;int _y;char _op;int _result;int _exitcode = 0;
};
阻塞队列实现。
这是队列成员,q变量就不介绍了,然后就是一把锁和两个条件变量,接下来就说说为什么是一把锁,两个条件变量。一把锁比较好理解,因为一次只能有一个线程在访问队列这个共享资源!因为我们要复用stl容器的Push和pop,而stl的push和pop不能同时进行。
至于为什么有两个条件变量得分析需求,
如果队列为空,不应该让消费者来消费了,此时就应该去等待队列了。
如果队列为满,不应该让生产者来生产了,如果生产者和消费者共用一个条件变量,那你想队列满的时候要唤醒消费者,现在消费者和生产者一起在等待队列,如果唤醒了生产者继续往阻塞队列塞东西怎么办?
template<class T>
class BlockQueue
{
public:BlockQueue(){pthread_mutex_init(&mutex,nullptr);pthread_cond_init(&Productor,nullptr);pthread_cond_init(&Consumer,nullptr);}~BlockQueue(){pthread_mutex_destroy(&mutex);pthread_cond_destroy(&Productor);pthread_cond_destroy(&Consumer);}bool isEmpty(){return q.size() == 0;}bool isFull(){return q.size() == SIZE;}void pop(T& data){//加锁pthread_mutex_lock(&mutex);之所以是while,是因为假如有多个线程被唤醒,然后队列的数据被前面线程取走了,
又变成空的了,然后继续pop就会出问题。while(isEmpty()) 空的时候消费者就不能消费了,生产者会唤醒它,下面提及{pthread_cond_wait(&Consumer,&mutex);}data = q.front();q.pop();取任务pthread_cond_signal(&Productor);发信号,唤醒该条件变量下等待的一个线程pthread_mutex_unlock(&mutex);}void push(const T& data){pthread_mutex_lock(&mutex);while(isFull()) 若此时队列为满,则不能继续push{pthread_cond_wait(&Productor,&mutex);}q.push(data);pthread_cond_signal(&Consumer); 只有生产者才知道什么时候队列一定不为空,所以让生产者去唤醒消费者最合适不过了pthread_mutex_unlock(&mutex);}
private:std::queue<T> q; pthread_mutex_t mutex; pthread_cond_t Productor;pthread_cond_t Consumer;
};
虽然在wait的线程被唤醒时已经没有锁了,没事,wait函数会帮我们申请,只有申请成功才会向后运行。
问题1 下面代码是否有区别
可是如果我唤醒了消费者,我又没释放锁,会不会对方又休眠阻塞了,没事就算它又阻塞了也是在申请锁的时候阻塞,后面你一释放锁,后面线程切换的时候os会检查有没有锁给消费者的,有就唤醒消费者继续执行,就像在lock的时候阻塞一样。
问题2 多生产多消费,代码会出什么问题?
没有问题,因为都是还是竞争一把锁。不过线程多了后打印会有点乱,这个可能是刚好线程切换了,只要Consumer的结果符合Productor生产的任务顺序即可。
四 信号量
1 理论部分
这个应该是线程的最后一点了,我们学完信号量后也要实现一个cp模型,代码实现比上一个cp模型还要简单,不用担心,我学之前学完都有点忐忑,把代码一写就豁然开朗了。
信号量本质就是一个计数器,是用来实现同步的,表示某个临界资源允许多少个线程来访问,这不会出问题吗,会的,所以后面实现的时候要用锁保证大家用不同的位置就没事,先前互斥的时候临界资源只允许一个线程访问,用二元信号量也可以保证临界资源被一个线程使用,只有0和1。显然信号量比锁更广泛,但是无法保证线程安全,所以使用必然要伴随锁。
申请信号量成功就一定有资源,所以就不用像下面一样做判断了。
2 认识接口
显然信号量类型是sem_t,第二个参数表示这个信号量是线程共享还是进程共享,第三个参数就是资源的数量。
1 初始化
2 销毁
同样信号量也有wait函数,其实我们学了互斥锁,同步的条件变量,信号量,我们会发现这些接口是一样的。
资源申请就用wait函数,释放了,还要用sem_post还回去。
3 再实现一个cp
当我们说了上面那些接口,可能,还是没什么思路来实现cp模型,接下来就说说思路,先前的cp是基于阻塞队列的,现在我们要把阻塞队列换成环形队列。
看似是一个圆形,其实是用数组模拟的环形队列。
既然是个队列就一定有头,有尾,假设头先不动,也就是一直往队列放数据,尾是一直指向空的,就是先放数据再++。
那按一般情况空和满的时候head都和tail重合,所以一般为了区分,我们可以在放的时候少放一个,下一个就是头,那就不放了,此时就是满,也可以搞一个计数器。实际上我们也不用担心,因为有信号量,信号量就是一个计数器。
显然如果了解了先前实现的cp问题,此时我们就应该知道此时是生产者往队列push数据,消费者往队列pop数据。有几点要点规则要先说一下,方便我们实现代码。
1 生产者和消费者如果都要用信号量来申请资源的使用,那请问他们的资源是一样的吗?
又或者说它们需要的计数器是一样的吗,假如有一个计数器记录了这里面的数据个数,消费者拿到这个计数器显然是有用的,但对于生产者来说,数据个数貌似没用,它更关心剩余空位置的个数,剩余的空位置数可以用总空间数-数据个数。
2那如何用计算机语言来描述呢?
显然要给生产者和消费者定义两个不同的信号量,当生产者push要时,要先申请sem_room信号量,就是wait函数,wait函数会对该信号量--,此时对于消费者来说,可访问的数据个数增加了,所以sem_data应该+1。同理得,消费者申请信号量也会对sem_data--,此时空位置数就+1,就对sem_room用前面提到的post函数就可以。
3 队列空的时候让生产者先跑,队列满的时候让消费者先跑
这个如何实现呢,很简单,队列为空,数据个数为零,消费者全都会在wait处阻塞,因为信号量此时为0,一定是生产者先跑,同理,队列满的时候也一定是消费者先跑。
4 不能让生产者在环形队列套圈消费者,也就是满了还在放,显然信号量会解决,也不能让head超过tail,信号量也会解决。
具体代码实现。
#include<vector>
#include<string>
#include<semaphore.h>
using namespace std;
#define SIZE 10
class Task 这个还是上一个cp模型中的Task类
{
public:Task(){;}Task(int x,int y,char op):_x(x),_y(y),_op(op){;}void operator()(){switch(_op){case '+':{_result = _x + _y;_exitcode = 0;}break;case '-':{_result = _x - _y;_exitcode = 0;}break;case '*':{_result = _x * _y;_exitcode = 0;}break;case '/':{if(_y == 0)_exitcode = 1;else _result = _x / _y;}break;case '%':{ if(_y == 0)_exitcode = 1;else _result = _x % _y;}break;}}string getformat(){return to_string(_x) + _op + to_string(_y)+'?';}string getRformat(){return to_string(_result) + "("+to_string(_exitcode) + ")";}int _x;int _y;char _op;int _result;int _exitcode = 0;
};
环形队列实现。
template<class T>
class CirQueue
{
public:CirQueue(int n = SIZE):vt_(n),head_(0),tail_(0){sem_init(&Consumer_,0,0);sem_init(&Productor_,0,SIZE);}~CirQueue(){sem_destroy(&Consumer_);sem_destroy(&Productor_);}void push(const T& data){sem_wait(&Productor_);tail_ = tail_ % vt_.size(); 防止越界vt_[tail_++] = data;sem_post(&Consumer_); 数据信号量++}void pop(T& data){sem_wait(&Consumer_);head_ = head_ % vt_.size();data = vt_[head_++];sem_post(&Productor_); 位置信号量++}
public:std::vector<T> vt_;模拟环形队列int head_; 两个下标用来记录存数据和取数据的下标int tail_;sem_t Consumer_;sem_t Productor_;
};
测试代码。
#include<vector>
#include<unistd.h>
#include<string>
#include<pthread.h>
#include<time.h>
#include<iostream>
#include"CircularQueue.hpp"
using namespace std;
void* Consumer(void* arg)
{CirQueue<Task>* bq = (CirQueue<Task>*) arg;while(true){Task t;bq->pop(t);t();cout<<"Consumer:"<<t.getRformat()<<endl;sleep(1);}
}
void* Productor(void* arg)
{CirQueue<Task>* bq = (CirQueue<Task>*) arg;string format = "+-*/%";while(true){int x = rand() % 10;int y = rand() % 10;char op = format[rand()%format.size()];Task t(x,y,op);bq->push(t);cout<<"Productor: "<<t.getformat()<<endl;;sleep(1);}return nullptr;
}
int main()
{CirQueue<Task> bq;srand(time(nullptr));pthread_t c[1],p[1]; 单生产单消费模型pthread_create(&c[0],nullptr,Consumer,&bq);pthread_create(&p[0],nullptr,Productor,&bq);pthread_join(c[0],nullptr);pthread_join(p[0],nullptr);return 0;
}
4 多线程cp实现
不过上面的代码只是单生产单消费的,我们已经用信号量来控制生产者和消费者不会访问统一位置了,但是如果是多生产多消费的情况,生产者和生产者可能访问同一个位置,因为下面这个操作是未被锁保护的。
要控制生产者之间访问不同区域,这就需要我们程序员自己实现了。显然是要用锁来维护生产者和生产者的互斥关系,消费者和消费者的互斥关系,难道所有生产者和消费者共用一把锁吗,那有没有一种情况,当队列有数据时,也有空位置的时候,生产者申请到了锁,可以生产,但消费者就要等待锁,就没办法进行消费,显然这是没必要的等待,所以要有两把锁。
在哪里加锁解锁? 显然肯定是访问容器时加锁。
推荐先申请信号量,再申请锁? 原因: 因为效率,在多cpu下比较好理解,假如线程1拿到了锁,其它线程还是可以边等锁一边同步地先把信号量申请了,但是如果要先申请锁才能申请信号量,此时其它线程啥也干不了,就浪费了一块cpu了。而且申请信号量的操作是原子性的,不用担心线程安全。
先释放锁还是先申请信号量对效率就没什么影响了,不过还是建议释放锁了再释放信号量,免得有其它线程申请到了信号量,然后此时就多出一个线程可以访问临界资源了。注意:前面在讲生产消费者模型时已经强调缓冲区的意义,我们现在要再谈谈多线程的生产消费者模型和单线程的模型效率区别,为了线程安全,一次只有一个生产者能放数据,一个消费者拿数据,两个模型在这里效率是一样的,但是当一个消费者拿到了数据,在做复杂运算的时候,此时另一个消费者就可以继续去拿数据,在多cpu下,同步地将结果快速返回在,这就是多线程模型的意义。
学了信号量,锁和条件变量后,锁我知道是用来保证线程安全,那什么时候用信号量,什么时候用条件变量呢,这两个都可以用来实现线程同步。
信号量的作用:
1 首先就是让我们不用判断队列空还是满了,而且不用再锁内判断,申请和释放信号量是原子,如果环形队列用条件变量,这个判空和判满要自己实现,很麻烦,所以才设计出了信号量来代替。
2 还有就是锁和信号量其实是有关联的,我们前面提过了二元信号量就是锁,但是多元信号量就不是了,那什么时候用锁,什么时候用信号量,关键就在于这份资源能不能被并发访问,能就用多元信号量,不能就用锁!
多生产代码如下,大体上和单生产代码一样,只是这个队列的实现不太一样。
template<class T>
class CirQueue
{
public:CirQueue(int n = SIZE):vt_(n),head_(0),tail_(0){sem_init(&Consumer_,0,0);sem_init(&Productor_,0,n);pthread_mutex_init(&Con_mutex,nullptr);pthread_mutex_init(&Pro_mutex,nullptr);}~CirQueue(){sem_destroy(&Consumer_);sem_destroy(&Productor_);pthread_mutex_destroy(&Con_mutex);pthread_mutex_destroy(&Pro_mutex);}void Lock(pthread_mutex_t& mutex){pthread_mutex_lock(&mutex);}void Unlock(pthread_mutex_t& mutex){pthread_mutex_unlock(&mutex);}void push(const T& data){sem_wait(&Productor_);//申请信号量tail_ = tail_ % vt_.size();Lock(Con_mutex);//加锁vt_[tail_++] = data;Unlock(Con_mutex);//解锁sem_post(&Consumer_);//释放信号量}void pop(T& data){sem_wait(&Consumer_);head_ = head_ % vt_.size();Lock(Pro_mutex);data = vt_[head_++];//多线程访问会出问题,这里还要加锁,因为信号量只是起到限制执行流数量Unlock(Pro_mutex);sem_post(&Productor_);}
public:std::vector<T> vt_;int head_;int tail_;sem_t Consumer_;sem_t Productor_;pthread_mutex_t Con_mutex;pthread_mutex_t Pro_mutex;
};
五 线程池实现
虽然写了三份代码,主要是三份使用的知识点是类似的,所以就放在一起讨论了。线程池,内存池,进程池都是在用空间换时间,因为我们进程在运行时可能受到大量请求,这个时候要创建线程去运行,如果是来一个任务才创建线程,此时完成任务的时间就要算上创建线程,可是如果我们在没任务时候提前创建好线程,就能比较快的响应请求,今天我们就聊聊线程池的小实现。
其实还是一种变形的生产消费模型。客户端发任务到消息队列,线程池从消息队列拿任务去执行,显然超市就是任务队列,客户端是生产者,线程池是消费者,应该算是单生产多消费的模型。
大致代码文件如下。
1 main.cc
可以先往队列中push一个数字,看看程序跑起来有没有问题,没问题再将数字改成一个Task类。
2 线程池实现
一个vp_用于保存多线程的id,tasks是个任务队列,多线程并发访问从中获得任务。
任务队列我们可以设计成整体使用,然后代码就像阻塞队列那样,也可以设计成局部使用,代码就类似环形队列那样,这次就设计成阻塞队列。由于环形队列的空间是固定开辟好的,不方便判空和判满,如果用条件变量来实现同步,判空判满会很麻烦,大家可以下去试试。
然后我们阻塞队列实现同步用条件变量,用信号量如果扩容缩容也很麻烦,显然要两个条件变量,生产者和消费者不可以共用一个条件变量的,这个在阻塞队列的消费者模型曾提过。
我们说了这个任务队列是会扩容和减小的,用条件变量比较容易实现同步,还有就是生产者和消费者无法并发访问任务队列,所以只有一把锁,因为我们是复用了stl容器的push和pop,这个是线程不安全的,必须要加锁使用,环形队列没有复用stl的接口所以才可以用两把锁,让push合pop并发。下面是一些基本函数封装
#define NUM 5
template<class T>
class threadPool
{
public:threadPool(int size = NUM):vp_(size){pthread_mutex_init(&mutex_,nullptr);pthread_cond_init(&Consumer,nullptr);pthread_cond_init(&Productor,nullptr);}~threadPool(){pthread_mutex_destroy(&mutex_);pthread_cond_destroy(&Consumer);pthread_cond_destroy(&Productor);}void Lock(){pthread_mutex_lock(&mutex_);}void Unlock(){pthread_mutex_unlock(&mutex_);}bool Full(){return tasks_.size() == NUM;}bool Empty(){return tasks_.size() == 0;}std::queue<T> tasks_; 任务队列std::vector<pthread_t*> vp_; 线程池pthread_mutex_t mutex_;pthread_cond_t Consumer;pthread_cond_t Productor;
};
线程执行函数
static void* threadRun(void* arg) 这个函数必须为静态的,否则参数会不匹配{线程分离,懒得join了pthread_detach(pthread_self());threadPool<T>* tp = static_cast<threadPool<T>*> (arg);取出一个数据进行读取或者取一个任务来执行T data;tp->pop(data);std::cout<<data.getformat()<<std::endl;执行任务data();std::cout<<data.getRformat()<<"id: "<<pthread_self()<<std::endl;}void start(){for(int i = 0; i < NUM; i++) 创建多线程{pthread_create(vp_[i],nullptr,threadRun,this);为了threadRun可以访问类内成员,只能把this传入了}}
push和pop函数介绍,push函数由于我们是单个生产者,可以加锁也可以不加。
void push(const T&data){Lock();while(tp->Full()){pthread_cond_wait(&tp->Productor,&tp->mutex_);}tasks_.push(data);pthread_cond_signal(&Consumer); 唤醒消费者Unlock();}防止多个线程并发pop,要加锁控制void pop(T& data){Lock();//检查是否有任务while(tp->Empty()){pthread_cond_wait(&tp->Consumer,&tp->mutex_);}data = tasks_.front();pthread_cond_signal(&Productor); Unlock();}
这就是所有的代码了,不过还可以改进,因为我之前写过对锁和创建线程的原生线程库接口进行封装,可以添加到这里,只是代码量太大了,就不写入博客中了。