线程安全主要分为两个方面,分别是资源访问互斥与线程同步(线程协同配合)
本篇博客,我们主要来讲解资源访问互斥这一方面
目录
为什么要实现资源访问互斥?
我们来做一个情景假设
假设现在有两个线程,分别是线程A和线程B,有一个全局变量,名为num,num的初始值位0。
现在这两个线程都要对num这个全局变量进行+1操作,并且都得到了时间片,大家觉得结果一定是2吗?
还真不一定,为什么呢?我来通过几条汇编指令来给大家讲解一下
首先我们要知道 " num = num + 1 ; " 这条语句在计算机中的汇编语言是如何实现的
- mov eax , num #将num的数值放入寄存器eax中
- add eax , 1 #将寄存器eax中的数值+1
- mov num , eax #再将eax中的数值赋给num
假设线程A和线程B同时获得了num的初始值,也就是他们的第一条汇编指令是同时进行的,不论后续两步谁快谁慢,num最后的结果都是1,因为他们寄存器中num的数值都是0。只有两个线程先后进行对num数值的修改,才能够得到正确的结果2
为什么会出现这种情况呢?就是因为他们没实现对资源的原子访问,两个线程互相将对方的结果覆盖了。所以我们要采用方法杜绝这种情况,实现资源访问互斥,让两个线程对这些共享数据实现原子访问。也就是当自己对这些共享数据进行读写的时候,其余线程不可以对这些数据进行读写操作
实现资源访问互斥(原子访问)的经典机制——互斥锁
实现原子访问的经典机制之一,便是互斥锁机制。要理解互斥锁机制,我们可以拿身边最常见的一个东西来进行举例,那就是卫生间
我做一个情景假设,来帮助大家理解互斥锁机制
假设现在陆续有十个人要来上厕所,卫生间只有一个
- 第一个人到了,尝试开卫生间的门(其实也就是尝试获取卫生间门锁的使用权),发现门没有被锁上,于是获得了卫生间门锁的使用权,锁上了卫生间的门并使用卫生间
- 第二个人到了,尝试开卫生间的门,发现门被锁上了,于是排在第一个等待卫生间开门
- 其他的人到了,尝试开卫生间的门,发现门被锁上了,排在第一个人后面等待卫生间开门
- 第一个人出来了,对卫生间的门进行解锁,卫生间现在可供一个人使用
- 排在队伍的第一个人获得了卫生间门锁的使用权,锁上了卫生间的门并使用卫生间,后面的步骤同上
卫生间就好比数据,卫生间的门锁就好比互斥锁。
每个线程要想使用这些共享数据,就要先尝试获取互斥锁,如果发现卫生间门锁被锁上,也就是该互斥锁正在被使用的话,这些线程就会进入资源等待队列,等待这个锁的使用权,等到该互斥锁可以使用了,排在最前面的线程就会得到该锁,并对需要访问的共享资源进行上锁,从而正确使用这些共享资源
PS:
- 相同共享资源的互斥锁只能有一把,如果有多把互斥锁,每个线程都可以拿着互斥锁对共享资源进行上锁的话,互斥锁的存在就没有意义了
- 线程间的共享资源包括:全局资源(全局变量就属于全局资源的一种)、文件描述符、进程信息、堆区空间、信号行为、库空间等
- 在互斥锁保护区间的代码也被称为临界区代码,临界区代码越简短越好,否则会影响工作效率
- 拿到互斥锁的线程在解锁后可能会再次拿到互斥锁,不是一个线程只能拿一次
如果还是不能够理解的话,我们仍旧拿上面的那两个线程举例,并和上面的情景进行比对
假设现在有两个线程,分别是线程A和线程B,要对初始值位0的全局变量num进行加一操作,步骤如下
步骤 | 卫生间情景 | 线程情景 |
① | 第一个人到了,尝试获取卫生间门锁的使用权,发现门没有被锁上,于是获得了卫生间门锁的使用权,锁上了卫生间的门并使用卫生间 | 线程A到了,尝试获取互斥锁,发现锁没有被使用,于是获得了该锁的使用权,并对全局变量num进行上锁操作 |
② | 第二个人到了,尝试开卫生间的门,发现门被锁上了,于是排在第一个等待卫生间开门 | 线程B到了,尝试获取互斥锁,但是锁只有一把,于是便进入资源等待队列等待该锁的使用权 |
③ | 第一个人出来了,对卫生间的门进行解锁,卫生间门锁现在可供一个人使用 | 线程A对全局变量num的+1操作完成,对该互斥锁进行解锁操作,锁现在可以被一个线程使用 |
④ | 第二个人获得了卫生间门锁的使用权,锁上了卫生间的门并使用卫生间 | 线程B获得了该互斥锁的使用权,并对全局变量num进行上锁操作 |
⑤ | 第二个人出来了,对卫生间的门进行解锁,卫生间被使用了两次, 情景完成 | 线程B对全局变量num的+1操作完成,对该互斥锁进行解锁操作,num的结果为2,情景完成 |
以上大致就是实现互斥锁机制的具体过程了,接下来我们来了解以下相关函数
互斥锁相关函数
这里我们要用到一个结构体,叫做pthread_mutex_t,这是互斥锁的相关结构体
先介绍下一会会用到的几个变量:
- pthread_mutex_t lock ; //定义一个互斥锁的结构体
- const pthread_mutexattr_t attr ; //锁属性相关结构体,使用默认属性就直接传NULL
函数 | 功能 | 返回值 |
pthread_mutex_init(&lock , &attr); | 实现互斥锁的初始化 | 成功完成之后会返回0,其他任何返回值都表示出现了错误 |
pthread_mutex_destroy(&lock); | 释放锁资源所占用的内存 | 成功返回0,失败返回错误编号 |
pthread_mutex_lock(&lock); | 上锁 | 成功返回0,失败返回错误编号 |
pthread_mutex_unlock(&lock); | 解锁 | 成功返回0,失败返回错误编号 |
使用互斥锁实现资源访问互斥的具体实现
在了解了相关函数和实现资源互斥访问的情况下,我们来写一段小代码,来实现一个功能:
- 两个线程各对全局变量num加5000次,每次加1 (难度:⭐⭐)
PS:要注意的是,不是每个线程一次加满5000次才让另一个线程再加5000次,而是两个线程轮流加,最后都能加满5000次
代码实现
//mutex_lock.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <signal.h>
#include <pthread.h>
#include <fcntl.h>
//将次数定义成宏
#define CONT 5000
//将互斥锁与num定义成全局变量,来让两个线程都可以获取该锁和num
pthread_mutex_t lock;
int num = 0;
//两个线程的工作是一样的,所以这里只用定义一个工作函数
void* thread_job(void* arg)
{
int i = 0;
pthread_detach(pthread_self());//将线程设置为分离态,让系统自己回收
//对num进行循环+1操作
//注意,不要把锁加在循环外面,如果放在外面,就代表着让一个线程一次性加满5000次后再让另一个线程加
//如果把锁家在循环外面,两个线程的工作效率还不如单线程工作效率高
while(i < CONT)
{
pthread_mutex_lock(&lock);//上锁
//这就是临界区代码,在上锁与解锁之间的代码就是临界区代码
num++;
i++;
printf("thread No.0x%x ++num , num = %d\n" , (unsigned int)pthread_self() , num);
pthread_mutex_unlock(&lock);//解锁
}
pthread_exit(NULL);
}
int main()
{
//初始化互斥锁
pthread_mutex_init(&lock , NULL);
pthread_t tids[2];
//创造线程A和线程B
pthread_create(&tids[0] , NULL , thread_job , NULL);
pthread_create(&tids[1] , NULL , thread_job , NULL);
//让主线程循环睡眠,来让线程A和线程B获取时间片
//由于我们把线程设置成了分离态,系统会自动回收线程,不用我们操心
while(1)
{
sleep(1);
}
//回收锁资源
pthread_mutex_destroy(&lock);
exit(0);
}
结果图示
我们可以发现,以上代码实现了该功能并完成了我们的要求,没有让一个线程一次性加满五千次,在加到9310的时候就发生了一次线程转换
以上就是本篇博客的全部内容了,大家有什么地方没有看懂的话,可以在评论区留言给我,咱要力所能及的话就帮大家解答解答
今天的学习记录到此结束啦,咱们下篇文章见,ByeBye!