线程的同步与互斥
生产者与消费者模型
在讲同步与互斥之前,我们要来先讲一讲生产者与消费者模型。
生产者与消费者模型模拟了多线程对数据的访问与处理,这里主要有1个场景,2个角色,3种情况。我们直接举一个例子。
现在有2个人,一个人是生产苹果的,一个人是消费苹果的。生产者生产1个苹果要放到盘子里,消费者从盘子里拿走苹果。很明显。生产苹果的人对应生产者,拿走苹果的人对应消费者,盘子对应临界资源。
三种情况:
- 生产者与生产者:倘若有多个生产者,他们之中肯定只有1个人能放的了苹果,因为盘子就那么大一点,放多的也放不下。所以他们之间属于互斥关系。
- 消费者与消费者:如果有多个消费者,他们之中肯定只有1个人能拿走苹果,因为盘子里就1个苹果,其他人要等再有苹果时才能再竞争苹果。所以他们之间也属于互斥关系。
- 生产者与消费者:如果有多个生产者与消费者,多个生产者,多个消费者之间有互斥关系,且只有生产者生产了苹果放到盘子里,消费者才能去拿走苹果,这要讲究时序性,而这个时序性就是我们将的同步,故生产者与消费者之间对应同步与互斥关系。
补充:
生产者消费者模型和读写者模型的根本区别在于生产者消费者模型会取走临界资源,而读写者模型不会。
线程的互斥
上面的我们已经说了,同一时间只能有1个人能对盘子进行放/拿的动作,不能大家一窝蜂的盘子里放或者拿,那就乱套了。而这种同一时间维护数据的唯一访问性就是线程的互斥。
那在计算机中,我们怎么怎么做到线程的互斥呢?计算机为我们提供了一种方案,使用互斥量(mutex)。
互斥量(也叫互斥锁)
在访问临界资源前加锁,在访问完后解锁,而只有持锁的线程能够访问临界资源,其他无锁的线程只能够阻塞等待,这样就完成了同一时间对数据的唯一访问性。
互斥量使用步骤
- 创建一个互斥量
- 初始化互斥量
- 线程要访问临界资源,对其加锁
- 线程访问资源完毕,对其解锁
- 其他线程继续3,4过程
- 当不需要访问后,销毁互斥量。
互斥量接口
创建一个互斥量
pthread_mutex_t mylock;
初始化互斥量
静态分配:
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_lock(pthread_mutex_t *mutex);
参数:
mutex:互斥量名
返回值:成功返回0,失败返回错误号
解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex)
参数:
mutex:互斥量名
返回值:成功返回0,失败返回错误号
另外加锁与解锁除了lock以外,还有trylock非阻塞加锁,timelock限时阻塞加锁。
销毁锁
int pthread_mutex_destory(pthread_mutex_t* mutex);
参数:
mutex:互斥量名
使⽤PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销
不要销毁⼀个已经加锁的互斥量
已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁。
死锁
死锁是互斥量的一种特殊情况,要构成死锁,得有4个条件:
- 互斥条件: 我拿了锁,你就不能再拿锁
- 请求与等待条件:我拿了锁1,又去获取锁2,不获得锁2就不释放锁1
- 不可剥夺条件:我拿了锁,你不能释放我的锁
- 环路等待条件:我拿锁1,申请获取锁2,你拿锁2,申请获取锁1
如何预防死锁呢?
- 破坏死锁的4个必要条件之一
- 加锁顺序一致,避免构成环路等待
- 避免锁没有释放的场景
- 资源一次性分配
线程互斥代码演示
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
int tickets = 1000;
pthread_mutex_t mylock;
void* buytickets(void* arg)
{
int i = (int)arg;
for(;;)
{
//访问临界资源前加锁
usleep(1000);
pthread_mutex_lock(&mylock);
if (tickets > 0)
{
tickets--;
usleep(1000);
cout<<i <<"号窗口正在售票,剩余票数为"<<tickets<<endl;
pthread_mutex_unlock(&mylock);
//访问完毕解锁,让别的线程能够再次竞争锁
}
else
{
//这里也要解锁
pthread_mutex_unlock(&mylock);
cout<<"票已售罄,"<<i<<"号窗口关闭"<<endl;
return NULL;
}
}
}
int main()
{
int i = 0;
pthread_t tid[5];
cout<<"there is "<<tickets<<"tickets left";
//对互斥量进行初始化
pthread_mutex_init(&mylock,NULL);
for (i = 0;i < 5 ;i++)
{
pthread_create(&tid[i],NULL,buytickets,(void*)i);//这里省略了判断是否创建成功
}
for (i = 0; i < 5;i++)
{
pthread_join(tid[i],NULL);
}
pthread_mutex_destroy(&mylock);
return 0;
}
线程的同步
为什么要线程同步?
在上面的消费者与生产者模型中,我们可以看出,好像只要有线程互斥后,基本能处理所有的情况了,为啥还要个线程同步嘞?
的确,有互斥存在已经可以保证线程访问临界资源的正确性。但是如果没有线程同步,在上面的例子中,当生产者没有放苹果时,消费者对盘子加锁,一看,发现没有苹果,只好再次释放锁。而当这样的过程频繁发生,以至于生产者就算是想放苹果,但是锁一直被消费者占有,无法正常放置,就造成了双方的饥饿问题,且访问效率会很低!。
线程同步就是在保证了数据安全访问的前提下,让线程能够按照某种特性顺序访问临界资源,从而避免了饥饿问题,提高访问效率。
竞态条件
因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
条件变量
条件变量提供了等待+通知的功能,实现了多个角色,不同执行流的同步过程。
在上面放苹果的例子中,倘若加入如下机制,效率会明显提高:
- 消费者获取锁,发现盘子里没有苹果,释放锁,然后等待其他线程发送信号。
- 生产者获取锁,发现盘子里没有苹果,放苹果,通知消费者可以取苹果,释放锁。
…
条件变量的定义
条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使"条件成立"(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。
条件变量的接口
//创建条件变量
pthread_cond_t cond;
//初始化条件变量
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *re
rict 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 mute
x);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后⾯详细解释
//唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒所有条件变量下的线程
int pthread_cond_signal(pthread_cond_t *cond);//唤醒一个等待的线程。
条件变量的简单场景
一个线程A获取锁,发现条件变量不满足,然后直接调用pthread_cond_wait(), pthread_cond_wait内部有2步:先解锁后挂起等待。
另一个线程B获取锁,发现条件变量满足,然后调用pthread_cond_signal,通知pthread_cond_wait的进程条件已经满足,将其唤醒.
线程A,B共同使用一把锁!这对下面的死锁反例理解有帮助,记住这一点。
为什么要给pthread_cond_wait传互斥量?pthread_cond_wait内部为什么要执行解锁?
问题一下子有点多,但是不要怕,我们先不要考虑wait为什么要传入一个互斥量,就当他传了一个进去,我们先来看pthread_cond_wait()内为什么要解锁。
我们就用上面的简单场景来举个反例:线程A如果发现条件变量不满足,直接转休眠模式等待,等待前不进行解锁操作。那么线程A是以持有锁的状态下等待的。而线程B为了查看条件变量,第一步就要先获取锁,发现锁一直被A占着,根本就无法去访问条件变量,更不用提当条件变量满足时唤醒线程A了。所以,pthread_cond_wait()内部一定有一步解锁的过程,且解锁+等待是原子操作!,第二个问题解决。
确定了pthread_cond_wait()前有解锁操作,那么就能回答第一个问题了,为了解锁当然要传入互斥量啦。第一个问题解决。
倘若你不知道在 pthread_cond_wait前显式的添加解锁操作会错在哪里,那么这段话会对你有帮助:
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
在你解锁之后, pthread_cond_wait之前,有可能你的锁刚一解,就被别的线程拿走了锁别的地方了,而wait()内部自带解锁操作,你岂不是解了别的线程不该解的锁吗?所以显式的在 pthread_cond_wait之前加解锁是错误的,谨记。
代码演示
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
pthread_mutex_t mylock;
pthread_cond_t mycond;
int count_ = 0;
int material = 10;
void* produce(void* arg)
{
while (1)
{
//制作苹果派,解锁,睡眠一会儿,确保锁被eat拿到,否则当前进程刚释放锁又拿到锁
pthread_mutex_lock(&mylock);
material--;
count_++;
cout<<"[server say]: the pie is done, come and eat!"<<endl;
pthread_cond_signal(&mycond);
if (material == 0)
{
//没有原料了,释放锁后退出
pthread_mutex_unlock(&mylock);
cout<<"[server say]: there is no more material to make pie"<<endl;
pthread_cond_broadcast(&mycond);//唤醒后的eat发现material为0,也退出。
break;
}
pthread_mutex_unlock(&mylock);
sleep(1);
}
return NULL;
}
void* eat(void* arg)
{
while (1)
{
//先加锁,访问公共数据
pthread_mutex_lock(&mylock);
//没有苹果时需要等待
if (count_ == 0)
{
pthread_cond_wait(&mycond,&mylock);
}
cout<<"[customer say]: woo,nice pie! eating..."<<endl;
count_--;
if (material == 0)
{
//没有原料了,没得吃
cout<<"[customer say]: there is no pie,i have to go"<<endl;
break;
}
pthread_mutex_unlock(&mylock);
sleep(1);
}
pthread_mutex_unlock(&mylock);
return NULL;
}
int main()
{
pthread_t tid1,tid2;
//初始化
pthread_mutex_init(&mylock,NULL);
pthread_cond_init(&mycond,NULL);
//创建线程
pthread_create(&tid1,NULL,produce,NULL);
pthread_create(&tid2,NULL,eat,NULL);
//等待线程
pthread_join(tid1,NULL);
pthread_join(tid2,NULL);
//销毁互斥量与条件变量
pthread_mutex_destroy(&mylock);
pthread_cond_destroy(&mycond);
return 0;
}