目录
- 🌈前言
- 🌸1、Linux线程互斥
- 🍧1.1、线程间互斥相关背景概念
- 🍨1.2、互斥量(锁)相关背景
- 🍯1.3、互斥量(锁)相关API
- 🍯1.3.1、初始化和销毁互斥锁
- 🍰1.3.2、互斥量加锁和解锁
- 🍲1.3.3、互斥锁的实现原理
🌈前言
这篇文章给大家带来线程同步与互斥的学习!!!
🌸1、Linux线程互斥
🍧1.1、线程间互斥相关背景概念
这些名词,我们在共享内存中已经了解过⭐⭐
-
概念
-
- 临界资源:多线程执行流共享(都能看到,并且能访问)的资源就叫做临界资源
-
- 临界区:每个线程执行流内部,访问临界资源的代码,就叫做临界区
-
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源(全局、静态变量、共享内存等),通常对临界资源起保护作用
-
- 原子性:不会被任何调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成,没有中间状态
🍨1.2、互斥量(锁)相关背景
-
互斥量mutex
-
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
-
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
-
- 多个线程并发的操作共享变量,会带来一些问题(原子性问题)
验证:设置一个多线程来进行抢票,票数为共享资源 – 售票系统代码
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int ticket = 10000;
void *GrabTickets(void *args)
{
// 多线程一直抢票,直到票数<=0为止
const char *name = static_cast<const char *>(args);
while (true)
{
if (ticket > 0)
{
usleep(1000);
printf("%s: 抢到了票, 票的编号为: %d\n", name, ticket);
ticket--;
}
else
{
printf("%s: 已经放弃抢票了,因为没有了...\n", name);
break;
}
}
return nullptr;
}
int main()
{
// 定义线程id
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_t tid4;
// 创建线程
if (pthread_create(&tid1, nullptr, GrabTickets, (void *)"Thread1") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid2, nullptr, GrabTickets, (void *)"Thread2") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid3, nullptr, GrabTickets, (void *)"Thread3") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid4, nullptr, GrabTickets, (void *)"Thread4") != 0)
{
exit(EXIT_FAILURE);
}
// 线程等待 -- 不获取线程退出码
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
return 0;
}
一次运行结果:出现溢出抢票的情况!!!
Thread3: 已经放弃抢票了,因为没有了...
Thread2: 抢到了票, 票的编号为: 3
Thread2: 已经放弃抢票了,因为没有了...
Thread4: 抢到了票, 票的编号为: -1
Thread4: 已经放弃抢票了,因为没有了...
Thread1: 抢到了票, 票的编号为: -2
Thread1: 已经放弃抢票了,因为没有了...
-
为什么可能无法获得正确的结果呢?
-
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
-
- usleep函数用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段(代码区)
-
- ticke自减操作本身就不是一个原子操作(有中间动作,线程切换时会被挂起)
-
- CPU内的寄存器是被所有执行流(线程)共享的,但是寄存器里面的数据是属于当前执行流的上下文数据
-
- 线程被切换时,需要保存上下文数据。线程被换回时,要恢复上下文数据
// 取出ticket--部分的汇编代码
// 指令:objdump -d a.o > test.objdump
//-------------------------------------------------------------------------------------
44: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 4a <_Z11GrabTicketsPv+0x4a>
4a: 83 e8 01 sub $0x1,%eax
4d: 89 05 00 00 00 00 mov %eax,0x0(%rip) # 53 <_Z11GrabTicketsPv+0x53>
-
ticket自减操作对应三条汇编指令
-
- load :将共享变量ticket从内存加载到寄存器中
-
- update : 更新寄存器里面的值,执行-1操作
-
- store :将新值,从寄存器写回共享变量ticket的内存地址
为什么说ticket不是原子操作呢?
-
多线程访问共享资源问题
-
- 因为CPU在运算ticket自减操作时(比如计算完后),线程的时间片到了,需要进行线程切换,但是ticket计算完后的数据没有拷贝回内存,就被切换了
-
- 线程切换时,将保存线程的上下文,下一个线程运算完后,ticket的值变成9999
-
- 随后切换回原来的线程,恢复线程的上下文,将运算好的9999拷回内存的ticket中,导致数值不一样问题(应该变成9998)!
-
要解决以上问题,需要做到三点
-
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
-
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
-
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
- 要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
🍯1.3、互斥量(锁)相关API
🍯1.3.1、初始化和销毁互斥锁
-
互斥锁概念
-
- 互斥锁只能对临界区进行加锁,加锁的本质是让线程执行临界区代码串行化
-
- 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加就都要加。如果一部分代码加,一部分不加,会出现bug
-
- 对临界区加锁时,加锁的粒度约细越好,否则可能出现死锁的情况(没有解锁)
-
- 加锁保护的是临界区, 任何线程执行临界区代码访问临界资源,都必须先申请锁,前提是都必须先看到锁
-
- 多线程竞争和申请锁的过程,就是原子的
初始化互斥量有二种方法
第一种方法:静态分配
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
-
代码解析
-
- pthread_mutex_t是互斥锁,它是一个联合体,里面有一个结构体描述锁的属性
-
- PTHREAD_MUTEX_INITIALIZER:它是一个宏,用于初始化互斥锁
第二个方法:动态分配
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrictattr);
-
函数解析
-
- mutex:要初始化的互斥锁(pthread_mutex_t变量的地址)
-
- restrictattr:设置互斥锁的属性,一般为NULL/nullptr
-
- 返回值:初始化成功返回0,失败返回一个错误码errno
销毁互斥锁:
#include <pthread.h》
int pthread_mutex_destroy(pthread_mutex_t *mutex);
-
函数解析
-
- mutex:要销毁的互斥锁(pthread_mutex_t变量的地址)
-
- 返回值:初始化成功返回0,失败返回一个错误码errno
销毁互斥锁需要注意
-
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥锁不需要销毁
-
- 不要销毁一个已经加锁的互斥锁
-
- 已经销毁的互斥锁 ,要确保后面不会有线程再尝试加锁
🍰1.3.2、互斥量加锁和解锁
加锁和解锁:
#include <pthread.h》
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
-
函数解析
-
- mutex:要加锁或解锁的互斥量(pthread_mutex_t变量的地址)
-
- 返回值:初始化成功返回0,失败返回一个错误码errno
调用 pthread_ lock 时,可能会遇到以下情况:⭐⭐
-
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
-
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁
修改前面的售票系统代码:使用动态分配互斥锁,需要释放互斥锁(pthread_mutex_destroy)
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
using namespace std;
// 1、定义互斥锁,主线程初始化
pthread_mutex_t Mutex;
int ticket = 10000;
void *GrabTickets(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
// 3、加锁
pthread_mutex_lock(&Mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s: 抢到了票, 票的编号为: %d\n", name, ticket);
ticket--;
// 解锁 -- 互斥量的粒度越细越好
pthread_mutex_unlock(&Mutex);
}
else
{
// 解锁 -- 如果没有解锁,线程再次加锁时,会一直阻塞
pthread_mutex_unlock(&Mutex);
printf("%s: 已经放弃抢票了,因为没有了...\n", name);
break;
}
}
return nullptr;
}
int main()
{
// 2、初始化互斥锁 -- 动态分配互斥锁
pthread_mutex_init(&Mutex, nullptr);
// 定义线程id
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_t tid4;
// 创建线程
if (pthread_create(&tid1, nullptr, GrabTickets, (void *)"Thread1") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid2, nullptr, GrabTickets, (void *)"Thread2") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid3, nullptr, GrabTickets, (void *)"Thread3") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid4, nullptr, GrabTickets, (void *)"Thread4") != 0)
{
exit(EXIT_FAILURE);
}
// 线程等待 -- 不获取线程退出码
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
// 释放互斥锁 -- 动态申请的互斥锁
pthread_mutex_destroy(&Mutex);
return 0;
}
修改前面的售票系统代码:使用静态分配互斥锁,不需要释放互斥锁
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
using namespace std;
// 1、定义互斥锁,主线程初始化 -- 静态分配互斥锁
pthread_mutex_t Mutex = PTHREAD_MUTEX_INITIALIZER;
int ticket = 10000;
void *GrabTickets(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
// 3、加锁
pthread_mutex_lock(&Mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s: 抢到了票, 票的编号为: %d\n", name, ticket);
ticket--;
// 解锁 -- 互斥量的粒度越细越好
pthread_mutex_unlock(&Mutex);
}
else
{
// 解锁 -- 如果没有解锁,线程再次加锁时,会一直阻塞
pthread_mutex_unlock(&Mutex);
printf("%s: 已经放弃抢票了,因为没有了...\n", name);
break;
}
}
return nullptr;
}
int main()
{
// 2、初始化互斥锁
pthread_mutex_init(&Mutex, nullptr);
// 定义线程id
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_t tid4;
// 创建线程
if (pthread_create(&tid1, nullptr, GrabTickets, (void *)"Thread1") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid2, nullptr, GrabTickets, (void *)"Thread2") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid3, nullptr, GrabTickets, (void *)"Thread3") != 0)
{
exit(EXIT_FAILURE);
}
if (pthread_create(&tid4, nullptr, GrabTickets, (void *)"Thread4") != 0)
{
exit(EXIT_FAILURE);
}
// 线程等待 -- 不获取线程退出码
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
return 0;
}
临界区的临界资源被锁后,当前线程时间片到了,还能进行线程间切换吗?加锁 == 不会切换?⭐⭐⭐
-
结论
-
- 完全可以切换,因为线程执行加锁解锁对应的也是代码
-
- 但是线程加锁是原子的,要么拿到锁,要么没拿到(多线程竞争申请互斥锁资源)
-
比如:我们有线程A和其他线程
-
- 线程A申请到了锁,执行临界区代码中途被切走了,切走时也是把锁抱走的
-
- 在线程A被切走的时候,绝对不会有线程进入临界区
-
- 因为进入临界区要申请互斥锁的资源,但是线程A已经申请了,其他线程只能一直阻塞等待资源就绪,然后竞争资源
-
- 线程A访问临界区只有进入和使用完毕二种状态(原子性),这样才对其他线程有意义
总结
-
- 不要再临界区做过多的事情,临界区代码最好越短越好
-
- 因为可能执行到一部分时,时间片就到了,然后其他线程一直阻塞等待,耗时长
🍲1.3.3、互斥锁的实现原理
-
前言
-
- 经过上面的例子,我们都已经意识到单纯的 ticket++ 或者 ++ticket 都不是原子的,有可能会导致数据一致性问题
-
- 为了实现互斥锁操作,大多数计算机体系结构都提供了swap或exchange汇编指令
-
- 该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性
-
- 即使是多处理器平台(多核CPU),访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期
-
- swap或exchange交换指令只有一句,意味着只有没做和做完二种状态,它是原子操作
当执行完二条汇编语句时,时间片到了,线程切换后,会出问题吗?
-
没有问题
-
当一个线程在执行时,CPU中一组寄存器中保存的值被称为该线程的上下文
-
- 因为线程切换时,会将寄存器中的数据全部带走!!!
-
- 凡是在寄存器中的数据,全部都是线程内部的上下文!!!
-
- 寄存器是在多线程看来,是共享的资源(CPU只有一套寄存器),但是在线程看来是自己的私有资源(因为线程会拿着寄存器的数据切换走)
-
- 多线程看起来同时在访问寄存器,但是它们互不影响
如果多线程同时竞争锁时,同时将0数据传输到al寄存器中,会出现问题吗?
-
比如:mutex = 1
-
- 不会出现问题,因为多线程中竞争资源时,至少有一个线程执行第二条交换指令
-
- 当第一个线程执行完这个指令后,寄存器的数据就会变成1,内存的数据变成0,而其他线程执行第二条指令,0跟0交换,没有发生变化
-
- 第一次执行交换指令的线程,会进入if语句,并且返回0表示申请锁成功,而其他线程会一直挂起/阻塞等待