在了解同步和互斥之前,先总结一下线程安全和线程不安全的概念和场景
线程不安全
操作一段代码,比如修改全局变量或者静态变量,在多线程模式下和单线程模式下运行的结果不一样,那么就是线程不安全.
比如:两个线程对一个全局变量进行循环 10 次的操作,全局变量初始化为 0,每个线程访问的时候,对全局变量加 1,循环结束后,我们希望得到的是值 20,但是却可能小于 20 ,那是因为我们在进行操作变量的时候,并不是一个原子操作,而且也没有加锁.
原子操作指的是:不会被任何调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成.
临界资源指的是:多个线程执行流访问的公共资源,在上面的案例中,全局变量就是临界资源.
由于线程都是抢占式执行,所以在临界区中使用互斥可以解决对临界资源的不安全现象,使用同步保证线程在访问临界资源的合理性
互斥
通过互斥锁来实现互斥.保证临界资源的安全
步骤
-
先加锁
pthread_mutex_init(&mutex,MULL)
-
执行临界区代码
-
释放锁
pthread_mutex_destroy(&mutex)
原理:互斥锁的底层是一个互斥量,互斥量是一个计数器,该计数器只有 0 和 1 两个值,0 代表的是当前资源不可访问,1 代表当前资源可以被访问
例:互斥量的初始值为1,当一个线程抢到锁资源是会将互斥量设为1,也就意味着加锁,那么其他线程来进行加锁的时候就会进入等待状态,只有当该线程释放锁后(互斥量进行 +1 操作),其他的线程才可以争夺锁资源
互斥锁的特性
互斥锁相当于一个锁定命令,它可以用来锁定共享资源使得其他线程无法访问
特性1:原子性/唯一性,如果一个线程获取到互斥锁,则没有其他线程可以上锁和解锁
特性2:非繁忙等待,如果一个线程已经获取到互斥锁,第二个线程又试图去锁定这个互斥锁,则第二个线程将被挂起(不占用CPU资源),直到第一个线程解锁后,第二个线程才被唤醒
互斥量进行 0/1 的操作是原子操作吗?
是的,如果不是原子操作,那么也就无法实现锁的功能,互斥量是通过寄存器和内存的交换指令来实现 0,1 互换的,由于交换指令只有一条且不可被操作系统打断,所以它是原子操作.
例,寄存器有一个初值 0,内存有两个值 0 和 1,当抢到锁资源会进行交换,交换之后,如果寄存器的值为 1,也就意味着可以访问该资源,如果寄存器的值经过交换后还是 0,则意味着该资源不可被访问
互斥锁的缺陷
- 低效率
- 可能导致死锁
互斥锁虽然能保证线程的安全,但是会较低程序的效率,除此之外,还可能造成 死锁 的问题
死锁情况 1: 程序当中的一个执行流,没有释放锁资源,导致其他执行流无法获取锁资源的情况
死锁情况 2:程序当中各个执行流都占有一把锁,但是在执行流占有锁的情况下,还想申请对方的锁,也不释放自己的锁,这样僵持不下的情况也称为死锁.
上面只是简单介绍了死锁,具体可参见以下文章
https://blog.csdn.net/qq_43763344/article/details/90574833
同步
利用条件变量实现同步,保证了对临界资源的访问合理性,从而有效的避免饥饿
定义条件变量
pthread_cond_t cond;
初始化条件变量
pthread_cond_init(&cond,NULL)
等待接口 (重要)
pthread_cond_wait(&cond,&mutex)
当一个线程调用 pthread_cond_wait
函数后,该线程会陷入阻塞状态,在这个过程中,有三步
步骤一:将线程(执行流)的 PCB 放到等待队列中
步骤二:对互斥锁进行解锁
步骤三:当等待队列中的线程被唤醒后,先获取互斥锁,然后访问临界资源.
特别注意:步骤一和步骤二是不可交换的,如果先进行了解锁,而没有加载到等待队列中,那么线程可能会错过信号,因为解锁和等待并不是一个原子操作,所以为了保证原子性,需要在线程未被解锁前,就要等待在队列里面,这样就不会错过信号了
为什么要引入互斥锁?
由于线程间同步需要改变共享变量,一旦有多个线程,那么必然会导致线程不安全,拿生成者和消费者模型来说,引入互斥锁可以保证消费者和消费者之间的互斥,生产者和生成者之间的互斥,当一个线程生产好一个资源,会唤醒等待队列中的消费线程,消费线程需要先获取互斥锁,只有得到锁的线程才可以访问临界资源.
如下图所示:
第二步骤为什么需要解锁?
解锁是 pthread_cond_wait
函数内部做的事情,如果不解锁,则生成者线程无法获取到临界资源,并且无法唤醒等待队列中的线程,也就意味着,等待对列中的线程会一直等待.
唤醒等待,发送一个信号
// 至少唤醒一个等待变量,和操作系统调度有关
pthread_cond_signal(&cond)
// 唤醒所有等待变量
pthread_cond_broadcast(pthread_cond_t *cond);
销毁条件变量
pthread_cond_destroy(&cond)
吃面的案列
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define PTHREADCOUNT 4
pthread_mutex_t g_mutex;
pthread_cond_t g_eatcond;
pthread_cond_t g_makecond;
int g_middle = 0;
void* EatStart(void* arg){
while(1){
pthread_mutex_lock(&g_mutex);
while(g_middle == 0){
pthread_cond_wait(&g_eatcond,&g_mutex);
}
g_middle--;
printf("i am a pthrad:[%p], eat a [%d] middle\n",
pthread_self(),g_middle);
pthread_mutex_unlock(&g_mutex);
pthread_cond_signal(&g_makecond);
}
}
void* MakeStart(void* arg){
while(1){
pthread_mutex_lock(&g_mutex);
while(g_middle == 1){
pthread_cond_wait(&g_makecond,&g_mutex);
}
g_middle++;
printf("i am a pthrad:[%p],make a [%d] middle\n",
pthread_self(),g_middle);
pthread_mutex_unlock(&g_mutex);
pthread_cond_signal(&g_eatcond);
}
}
int main(){
pthread_t eatpid[PTHREADCOUNT],makepid[PTHREADCOUNT];
pthread_mutex_init(&g_mutex,NULL);
pthread_cond_init(&g_makecond,NULL);
pthread_cond_init(&g_eatcond,NULL);
for(int i = 0;i < PTHREADCOUNT; ++i){
pthread_create(&eatpid[i],NULL,EatStart,NULL);
pthread_create(&makepid[i],NULL,MakeStart,NULL);
}
for(int i = 0;i < PTHREADCOUNT; ++i){
pthread_join(eatpid[i],NULL);
pthread_join(makepid[i],NULL);
}
pthread_mutex_destroy(&g_mutex);
pthread_cond_destroy(&g_eatcond);
pthread_cond_destroy(&g_makecond);
return 0;
}
利用互斥锁和条件变量模拟生产者和消费者模型
借助队列来减弱消费者和生产者的一个耦合性,生产者往队列里面放元素,消费者从队列里面取元素,但是必须要有一个制约关系
队列是一个临界资源,同一时刻只能有一个执行流去操作
参考代码
#include <stdio.h>
#include <iostream>
#include <pthread.h>
#include <queue>
#define PTHREADCOUNT 4
pthread_mutex_t mutex_;
pthread_cond_t ProCond_;
pthread_cond_t ConCond_;
class RingQueue{
public:
RingQueue(int Capacity){
Capacity_ = Capacity;
pthread_mutex_init(&mutex_,NULL);
pthread_cond_init(&ConCond_,NULL);
pthread_cond_init(&ProCond_,NULL);
}
~RingQueue(){
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&ConCond_);
pthread_cond_destroy(&ProCond_);
}
void Pop(int* data){
pthread_mutex_lock(&mutex_);
while(Que_.empty()){
pthread_cond_wait(&ConCond_,&mutex_);
}
*data = Que_.front();
Que_.pop();
pthread_mutex_unlock(&mutex_);
pthread_cond_signal(&ProCond_);
}
void Push(int &data){
pthread_mutex_lock(&mutex_);
while(Que_.size() == Capacity_){
pthread_cond_wait(&ProCond_,&mutex_);
}
Que_.push(data);
pthread_mutex_unlock(&mutex_);
pthread_cond_signal(&ConCond_);
}
private:
size_t Capacity_;
std::queue<int>Que_;
};
void* ProStart(void* arg){
RingQueue* rq = (RingQueue*)arg;
int i = 0;
while(1){
rq->Push(i);
printf("i am [%p] thread,i Porspect a [%d]\n",
pthread_self(),i);
i++;
}
return NULL;
}
void* ConStart(void* arg){
RingQueue* rq = (RingQueue*)arg;
int data;
while(1){
rq->Pop(&data);
printf("i am [%p] thread,i Con a [%d]\n",
pthread_self(),data);
}
return NULL;
}
int main(){
pthread_t ProPid[PTHREADCOUNT],ConPid[PTHREADCOUNT];
RingQueue* rq = new RingQueue(5);
for(int i = 0; i < PTHREADCOUNT;++i){
pthread_create(&ProPid[i],NULL,ProStart,(void*)rq);
pthread_create(&ConPid[i],NULL,ConStart,(void*)rq);
}
for(int i = 0; i < PTHREADCOUNT;++i){
pthread_join(ProPid[i],NULL);
pthread_join(ConPid[i],NULL);
}
delete rq;
return 0;
}
线程安全的函数
可重入函数
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入
一个函数在重入的情况下,运行结果没有任何问题,则该函数被称为可重入函数,否则是不可重入函数
常见的不可重入函数
-
调用了malloc/free 的函数,因为 malloc 函数是用全局链表来管理堆内存的
-
调用了标准 I/O 的库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
-
可重入函数体内使用了静态的数据结构
常见的可重入函数
-
不使用全局变量或静态变量
-
不使用 malloc 或者 new 开辟出的空间
-
不调用不可重入函数
-
不返回静态或全局数据,所有数据都有函数的调用者提供
-
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
线程安全函数和可重入函数的关系
如果一个函数是可重入的则线程一定安全,但是如果一个函数是线程安全的不一定是可重入函数**
常见的线程不安全情况
-
不保护共享变量的函数
-
函数状态随着被调用,状态发生变化的函数
-
返回指向静态变量指针的函数
-
调用线程不安全函数的函数
常见的线程安全情况
-
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
-
类或者接口对于线程来说都是原子操作
-
多个线程之间的切换不会导致该接口的执行结果存在二义性