之前我们了解到一些线程的基本知识,线程等待,线程分离啊什么的。现在我们用这些知识简单实现一个火车站抢票的功能。
假设一共有100张票,我们开放4个窗口(线程),让每个窗口进行卖票的功能,每个窗口之间是独立的,他们的任务就是卖完这100张票,每卖一张票,就让总票数-1。
void* ThreadStart(void* arg)
{
(void*)arg;
while(1)
{
if(g_tickets > 0)
{
g_tickets--; //总票数-1
//usleep(100000); //模拟一个窗口的堵塞
printf("i am thread [%p],I'll sell ticket number [%d]\n",\
pthread_self(),g_tickets + 1);
}
else
{
break;//没有票了,直接返回该线程
}
}
return NULL;
}
这样写每个线程的任务,看上去好像是没有什么问题,先看看运行结果
好像真的没有什么问题,但是这是建立在每个线程执行一个任务都是很快的情况下,我们现实中每一个买票的过程所花费的时间都不短,这可以理解成一种阻塞。我们在程序中模拟一下这个阻塞的过程,看看会出现什么结果。
不得了了,我们发现好像有几张票没卖出去,又好像1号票被卖了四次,不同的窗口出售了同样的一张票,结果出现了二义性,这个问题很严重,怎么解决呢?这就得用线程安全的知识了
线程安全
通过上面的代码,我们发现多个线程同时运行的时候,在访问临界资源后,使得程序出现了二义性的结果。
线程安全就是为了解决多个线程在同时运行时,在访问临界资源的同时不能让程序出现二义性的结果。
- 临界资源:在同一时刻,一个资源只能被一个线程(执行流)所访问
- 访问:在临界区中对临界资源进行非原子操作
- 原子操作:原子操作使得当前操作是一步完成的,也就是每一个操作只能有两个结果,一个是完成,一个是未完成
再次了解原子性
- 执行流A先从CPU中获得数据 g_tickets = 100,在获取完数据之后发生了阻塞,开始处理这个数据,此时还没有执行 g_tickets-- 的操作。
- 在执行流A处理第100张票的同时,执行流B开始从CPU中获取数据,此时因为执行流A还没有进行 g_tickets-- 的操作,CPU中 g_tickets = 100,执行流B开始处理这个数据,进行 g_tickets–,然后返回给CPU处理后的结果,g_tickets = 99。
- 之后执行流A在执行 g_tickets-- 之后,返回给CPU的结果是 g_tickets = 99。
经过这样一个模拟阻塞的过程,发现原本应该是 g_tickets = 98 的结果,却因为二义性导致结果是 g_tickets = 99。这就是由于执行流A执行的 g_tickets-- 操作是非原子的操作,也就是执行流A在执行的时候,可能会遇到时间片耗尽,从而导致执行流A被调度。相当于执行流A在执行时的任何一个地方都可能会被打断。
如何保证线程安全
- 互斥:保证在同一时刻只能有一个执行流访问临界资源,如果多个执行流想要同时问临界资源,并且临界资源没有执行流在执行,那么只能允许一个执行流进入该临界区。
也就是如果执行流A,B,C…想要同时访问临界资源,这时就只能有一个执行流先访问临界资源,假设此时访问的是执行流A,其他执行流B,C…不能打断执行流A的执行。 - 同步:保证程序对临界资源的合理访问。也就是执行流A执行完自己的任务时,必须让出CPU资源,不能一直占着CPU,应该及时让出CPU,使其他执行流也可以执行他们自己的任务。
看着图片“形象”的再来理解一次,假设有一个厕所,互斥就是同一时间只能有一个滑稽去上厕所,其他滑稽只能在外面排队;同步就是滑稽A上完厕所后不能占着茅坑不拉* ,应该赶紧出去让出坑位给其他滑稽。
想要做到这几点,本质上就是需要一把锁,也就是互斥量 (mutex)。
互斥锁
互斥锁是用来保证互斥属性的一种操作
互斥锁的底层是互斥量,而互斥量**(mutex)**的本质是一个计数器,这个计数器只有两个状态,即 0 和 1 。
- 0:表示无法获取互斥锁,也就是需要访问的临界资源不可以被访问
- 1:表示可以获取互斥锁,也就是需要访问的临界资源可以被访问
加锁与解锁
加锁的过程可以使用互斥锁提供的接口,以此来获取互斥锁资源
- 互斥量计数器的值为1,表示可以获取互斥锁,获取到互斥锁的接口就正常返回,然后访问临界资源。
- 互斥量计数器的值为0,表示不可以获取互斥锁,当前获取互斥锁的接口进行阻塞等待。
加锁操作:对互斥锁当中的互斥量保存的计数器进行减1操作
解锁操作:对互斥锁当中的互斥量保存的计数器进行加1操作
看到这里,就可以简单的理解为加锁和解锁就是这样的一个过程
那么问题来了,我们在购票代码中改变票数的操作就是 g_tickets–,这个的本质就是减一。互斥量计数器本身也是一个变量,这个变量的取值是0/1,对于这样一个变量进行加一减一操作的时候,这就是原子性操作吗?
这时候不禁想起老爹的那句话
所以说,想要解决原子性的问题,还得要用原子性的操作,怎么一步就判断有没有加锁呢?
汇编中有一个指令xchgb,可以用来交换寄存器和内存中的内容,这个操作就是原子性的,一步到位。
我们再来分析一下,如果是需要加锁的情况,互斥量计数器最后就会从1变成0;如果是不能加锁的情况,互斥量计数器中的值还是0,也就是判断之后,互斥量计数器的值都会变成0。所以我们可以在寄存器中存一个数字0,然后用这个数字0和互斥量计数器中的内容进行交换,一步到位,然后我们再根据这个寄存器交换后的值来判断加锁的情况。
当交换完毕之后,判断寄存器中的值的两种情况
- 如果寄存器中的值为0,则表示不能进行加锁,意味着当前加锁操作就会被阻塞,也就是不能访问这个临界资源
- 如果寄存器中的值为1,则表示可以进行加锁,互斥量计数器与内存的值进行xchgb交换操作,就相当于给互斥量计数器进行了一个减一的操作,然后该执行流就可以获得被锁着的资源,并且加锁操作返回,从而去访问临界资源
再次总结一下
- 当互斥量计数器的值为1的时候,表示可以进行加锁。然后把寄存器和内存的值进行xchgb交换之后,就相当于给计数器进行了减一操作
- 当互斥量计数器的值为0的时候,表示不可以加锁。这个时候把寄存器和内存的值进行xchgb交换之后,并没有影响到原互斥量计数器数据的真实性,当前的执行流就被挂起等待了。
互斥锁的使用流程
1.定义互斥锁
- pthread_mutex_t:互斥锁变量的类型
pthread_mutex_t lock;
2.初始化互斥锁
方法一:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
- mutex:互斥锁变量,传参的时候传入互斥锁变量的地址
- attr:互斥锁的属性,一般情况下采用默认属性,传入一个参数NULL,让操作系统去处理细节的操作
方法二:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //pthread_mutex_initializer
pthread_mutexattr_t 本身是一个结构体的类型,我们可以用 PTHREAD_MUTEX_INITIALIZER 宏定义一个结构体的值,使用这种初始化的方法可以直接填充 pthread_mutexattr_t 这个结构体
3.加锁
方法一:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
- mutex:传入互斥锁变量的地址,来进行加锁操作
使用该接口进行加锁操作的时候:
如果计数器当中的值为1,意味着可以加锁,加锁操作之后,计数器当中的1变成0
如果计数器当中的值为0,意味着不可以加锁,该接口进行阻塞等待,执行流就不会向下继续执行了
方法二:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
- mutex:传入互斥锁变量的地址,来进行加锁操作
使用该接口进行加锁操作的时候:
如果计数器当中的值为1,意味着可以加锁,加锁操作之后,计数器当中的1变成0
如果计数器当中的值为0,意味着不可以加锁,该接口直接返回,不进行阻塞等待,返回值为EBUSY(表示拿不到互斥锁)
所以说,一般在采用 pthread_mutex_trylock 加锁的方式时,做一个循环加锁的操作,防止因为拿不到临界资源而直接返回,进而在代码总直接访问临界资源,从而导致程序产生二义性的结果。
方法三:带有超时时间的加锁接口
#include <pthread.h>
#include <time.h>
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
const struct timespec *restrict abs_timeout);
- mutex:传入互斥锁变量的地址,来进行加锁操作
- abs_timeout:加锁超时时间。当加锁的时候,如果超过设置的超时时间还没有获取到互斥锁的时候,则进行报错返回,并不会进行阻塞等待,返回值为ETIMEOUT
struct timespace 有两个变量,一个代表秒,一个代表纳秒
4.解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
不管是对 pthread_mutex_lock ,pthread_mutex_trylock,还是pthread_mutex_timedlock进行加锁操作,使用该函数都可以进行一个解锁,“万能钥匙”。
5.销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥锁销毁接口,如果使用互斥锁完成之后,如果不调用销毁接口,就会造成内存泄漏的问题。
我们再来完善一下那个买票的程序
- 互斥锁的定义:因为他通常需要在不同的地方使用,所以定义为全局变量会比较方便一点;如果是C++程序的话,可以定义在类的成员变量中。
- 初始化:在创建线程之前就进行初始化
- 销毁:在所有线程退出之后进行销毁。如果还有线程没有退出就销毁了互斥锁,其他线程就会出现卡顿,死锁。
- 加锁的地方:是他访问临界资源的地方之前
- 解锁的地方:必须在所有有可能退出程序的地方都要解一个锁。如果一个进程加锁之后,该执行流直接退出掉,就会使得其他想要获取该互斥锁的进程卡死。
- 如果一个执行流加锁了,但是没有进行相应的解锁操作,就会使其他想获取该互斥锁的执行流陷入进程阻塞。
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADNUM 4 //4个线程来当做购票的窗口
int g_tickets = 100; //100张票
pthread_mutex_t lock; 定义一个互斥锁变量
void* ThreadStart(void* arg)
{
(void*)arg;
while(1)
{
pthread_mutex_lock(&lock);
if(g_tickets > 0)
{
g_tickets--; //总票数-1
usleep(10000); //模拟一个
printf("i am thread [%p],I'll sell ticket number [%d]\n",\
pthread_self(),g_tickets + 1);
}
else
{
//假设有一个执行流判断了g_tickets之后发现,g_tickets的值是小于等于0的
//则会执行else逻辑,直接就被break跳出while循环
//跳出while循环的执行流还加着互斥锁
//所以在所有有可能退出线程的地方都需要进行解锁操作
pthread_mutex_unlock(&lock);
break;//没有票了,直接返回该线程
}
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main()
{
pthread_mutex_init(&lock,NULL);//创建线程之前进行初始化
pthread_t tid[THREADNUM];//保存线程的标识符
int i = 0;
for(i = 0; i < THREADNUM; i++)
{
int ret = pthread_create(&tid[i],NULL,ThreadStart,NULL);
if(ret < 0)
{
perror("pthread_create error\n");
return 0;
}
}
sleep(1);
for(i = 0; i < THREADNUM; i++)
{
//线程等待
pthread_join(tid[i],NULL);
}
//锁销毁
pthread_mutex_destroy(&lock);
return 0;
}
死锁
产生死锁的两种方式
- 假如程序当中有一个执行流因为结束了当前线程而没有进行解锁操作,由于他没有进行解锁操作,就会使其他想要获取互斥锁的线程进行阻塞,从而产生死锁
- 当程序中有多个互斥锁存在的时候,两个或者多个已经上锁的线程之间互相申请对方的互斥锁资源,就会使双方都陷入永久等待的状态,从而产生死锁
对第二种产生死锁方式的解释
假设有两个执行流(执行流A,执行流B),两个互斥锁(互斥锁1,互斥锁2)。
两个线程任务的第一步就是上锁,执行流A先获取互斥锁1,执行流B获取互斥锁2。
第二步,执行流A在已经上锁了互斥锁1的条件下,想要想要获取互斥锁2;与此同时,执行流B又在上锁了互斥锁2的条件下想要获取互斥锁1。两个执行流第二步想要获取的互斥锁都处于上锁的状态,同时两个执行流都处于无法停止的任务中,也就是阻塞状态。
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
pthread_mutex_t lock1;
pthread_mutex_t lock2;
void* ThreadA(void* arg)
{
(void)arg;
//设置进程属性为结束后自动释放进程空间
pthread_detach(pthread_self());
//获取互斥锁1
pthread_mutex_lock(&lock1);
sleep(3);
//获取互斥锁2
pthread_mutex_lock(&lock2);
//解锁
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
return NULL;
}
void* ThreadB(void* arg)
{
(void)arg;
pthread_detach(pthread_self());
//获取互斥锁2
pthread_mutex_lock(&lock2);
sleep(3);
//获取互斥锁1
pthread_mutex_lock(&lock1);
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
return NULL;
}
int main()
{
//互斥锁初始化
pthread_mutex_init(&lock1,NULL);
pthread_mutex_init(&lock2,NULL);
pthread_t tid[2];//模拟两个执行流
//创建两个线程
int ret = pthread_create(&tid[0],NULL,ThreadA,NULL);
if(ret < 0)
{
perror("pthread_create A error");
}
ret = pthread_create(&tid[1],NULL,ThreadB,NULL);
if(ret < 0)
{
perror("pthread_create B error");
}
//主线程进行等待
while(1)
{
sleep(1);
printf("i am main thread\n");
}
//互斥锁销毁
pthread_mutex_destroy(&lock1);
pthread_mutex_destroy(&lock2);
return 0;
}
再用gdb 调试一下,看看线程阻塞的地方
查看所有线程调用堆栈的信息 --> thread apply all bt
切换到某一个执行流 --> t [执行流编号]
在某一个执行流中查看该执行流调用堆栈的信息 --> f [堆栈编号] 可以跳转到具体的堆栈
查看两个锁所占有的线程号(执行流号)
查看两个线程(执行流)发生阻塞的位置
死锁产生的条件
- 每一把锁只能同时被一个执行流占用。互斥条件
- 已经占有互斥锁的执行流不能申请其他互斥锁。请求与保持
- 多个执行流在请求锁资源时,形成了一个闭环。循环等待条件
- 只有拥有互斥锁的线程才可以释放该互斥锁的资源。不可剥夺
避免死锁的方式
- 每个线程加锁的顺序一致。让每个线程都是按照同一个顺序去加互斥锁。
- 使用完互斥锁后,及时释放锁资源
- 一次性分配互斥锁。在加锁之前,先判断该执行流执行过程中需要加的所有互斥锁是否都是空闲的,然后一次性给这些互斥锁都加锁,如果有锁资源被占用,就等待锁资源齐全后再进行加锁。
- 避免产生死锁的几种情况
破坏请求与保持情况:
- 静态分配,每个进程开始执行时就申请完所需要的资源。(一次性分配互斥锁)
- 动态分配,每个线程在申请他所需要的资源时,它本身不占用系统资源
破坏不可剥夺条件:
当线程不能获得所需要的资源时,就让这个线程陷入等待状态,在等待的时候把该线程已经占有的资源隐式的释放到系统的资源列表中,让他所占有的资源可以被其他进程使用。
这个等待的进程在他重新获得自己已有的资源以及新申请的资源才可以取消等待。
破坏循环等待条件:
采用资源有序分配,将系统中的所有资源进行顺序编号,将紧缺的,稀少的用处大的资源采用较大的编号。
在线程申请资源的时候,必须按照编号的顺序进行,一个线程只有获得较小编号的资源才可以申请较大编号的资源。