Linux操作系统之线程(六):线程互斥
目录
前言
一、进程线程间的互斥相关背景概念
二、互斥量mutex
三、互斥量实现原理探究
四、封装mutex
总结:
前言
前文我们已经完成了对线程的简单封装,本文我们将开始对线程另外一个大阶段:线程的同步与互斥的学习。
本文将帮助大家了解线程互斥,锁的相关概念与知识。
注意,本文所用到的封装的thread,都是上一篇文章写好的代码。
一、进程线程间的互斥相关背景概念
要了解互斥,我们就需要先了解一下相关的背景概念。
临界资源:被多个线程(执行流)共享访问的资源(如全局变量、共享内存、文件、硬件设备等)。
临界区:每一个线程内部,访问临界资源的代码段,叫做临界区,注意是代码。
互斥:任何时刻,互斥都会保证有且仅有一个执行流进入临界区,访问临界资源。互斥通常对临界资源起保护作用,但是效率好不好就不一定了。通常会降低效率。
原子性:不会被任何调度机制(软中断等中断操作)打断的操作,该操作只有两种结果,要么完成,要么没完成。通常来说,转换成汇编时,只有一行代码的就是有原子性,否则是无原子性。
二、互斥量mutex
在大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程的栈空间上,这种情况,变量归属于单个进程,其他线程理论上来讲不能获得这个变量。
但有些时候,有很多变量需要再线程间共享,这样的变量叫做共享变量,其卡退通过数据的共享实现线程之间的交互。一般来说,访问共享变量的代码默认情况下绝对不是原子的。
在有些时候,多个线程并发的操作共享变量,会给我们带来一些问题,大家可以看以下这段代码:
#include <stdio.h>
#include<iostream>
#include <pthread.h>
#include<string>
#include <unistd.h>int ticketnum = 1000;
void *ticket(void*argv)
{char* id=(char*)argv;while(true){if(ticketnum > 0){usleep(1000);//当做买票的那些加载,记录信息等操作std::cout << "ticketnum:" << ticketnum <<"线程id:"<<id<< std::endl;ticketnum--;}else{break;}}return nullptr;
}
int main()
{pthread_t tid1,tid2,tid3,tid4;pthread_create(&tid1,nullptr,ticket,(void*)"tid1");pthread_create(&tid2,nullptr,ticket,(void*)"tid2");pthread_create(&tid3,nullptr,ticket,(void*)"tid3");pthread_create(&tid4,nullptr,ticket,(void*)"tid4");pthread_join(tid1,nullptr);pthread_join(tid2,nullptr);pthread_join(tid3,nullptr);pthread_join(tid4,nullptr);return 0;
}
以上是一个非常简单的模拟我们黄牛抢票的操作,在这里我们使用了四个子线程并发式的调用,抢票。
按照代码逻辑,在我们if判断时就应该终止票数为0的操作了。
我们运行一下:
可是,为什么运行结果会出现0,-1,-2的打印呢?
难道我们的if条件判断语句没有生效吗?
我们先来解释一下造成这种结果的原因:
这是因为ticketnum--这个操作并不是原子的。
对于任何的++,--这类的操作代码,转换成汇编一办有三行指令:
mov ticketnum eax
sub 减少值
mov eax ticketnum
也就是说,它不满足原子性。
这样会导致什么结果呢?
同学们,我们之前学过中断,也明白在操作系统中有一个时钟中断,定期的帮助操作系统调度进程,我们也知道每个进程都有一个时间片,时间片到了就会切换进程。
那么,我想问一下同学们,在这三个汇编指令的执行时期之间,会发生中断吗?
答案是:会中断!!!!
而我们的if条件判断也不是原子性的
所以,就会出现如下这种情况:
在我们线程1判断时,num>0成立,所以线程1进入了if语句中,但此时发生中断了,随后就该线程2执行了if条件判断,此时num还没减到0,所以线程2也满足进入if条件语句.......
所以我们会放进多个线程进入寄存器中去执行我们的--操作,而当这些线程恢复上下文时,接着执行我们未完成的代码,由于都已经进入了if判断,所以每个进入的线程最后都会让num--,所以就会出现打印数量为负数的情况。
我们总结一下:凭什么num会减到负数呢?
1、整个 "判断-操作" 过程(if
+ ticketnum--
)不是原子的,导致多个线程可以同时进入临界区。
2、操作系统会让所有的线程尽可能多的进行调度切换执行。(线程通常会因为时间片耗尽,更高优先级的进程要执行,sleep返回用户态时进行时间片检测,而导致进程切换)
怎么解决上面的问题呢?
这里就要引出我们的互斥量:mutex的概念呢?
mutex锁会保护我们的资源,注意,保护资源,而不是保护每一个全局变量。
mutex会保护一段代码,这段代码通常不具备原子性操作。而mutex会让同一时间只有一个执行流进入我们临界区的代码中。防止出现上面的错误。
pthread库中自然也有相应的调用接口:
初始化:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;互斥锁销毁:
int pthread_mutex_destroy(pthread_mutex_t *mutex);加锁与解锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);int pthread_mutex_unlock(pthread_mutex_t *mutex);
这几个调用是较为常用的互斥量mutex的简单调用接口。
第一个是动态初始化,在运行时初始化:
-
mutex
:指向要初始化的互斥锁。 -
attr
:锁的属性(NULL
表示默认属性)。
第二个是静态初始化,编译时初始化,PTHREAD_MUTEX_INITIALIZER是一个宏。通常用这个是全局变量锁的初始化,无需手动销毁。
第三个用来销毁一个锁,释放互斥锁占用的资源。在销毁锁的时候需要注意:
使用PTHREAD_MUTEX_INITIALIZER初始化的锁不需要销毁。
不要销毁一个已经加锁的互斥量。
已经销毁的互斥量,确保后面不会有线程再尝试加锁
第四个是加锁,第五个是解开锁。我们调用加锁函数的时候可以会遇见以下情况:
互斥量处于未锁状态,此时函数会将互斥量锁定,返回成功。
其他线程已经加锁,或存在其他线程同时申请互斥量,但没有竞争到,此时这个函数调用会陷入阻塞(执行流被挂起),等待互斥量解锁。这也就是我们加锁会影响效率的原因。
我们可以在上面ticket的代码中加上锁的代码来试一试:
#include <stdio.h>
#include<iostream>
#include <pthread.h>
#include<string>
#include <unistd.h>pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;int ticketnum = 1000;
void *ticket(void*argv)
{char* id=(char*)argv;while(true){pthread_mutex_lock(&mutex);if(ticketnum > 0){usleep(1000);std::cout << "ticketnum:" << ticketnum <<"线程id:"<<id<< std::endl;ticketnum--;pthread_mutex_unlock(&mutex);}else{pthread_mutex_unlock(&mutex);break;}}return nullptr;}
int main()
{pthread_t tid1,tid2,tid3,tid4;pthread_create(&tid1,nullptr,ticket,(void*)"tid1");pthread_create(&tid2,nullptr,ticket,(void*)"tid2");pthread_create(&tid3,nullptr,ticket,(void*)"tid3");pthread_create(&tid4,nullptr,ticket,(void*)"tid4");pthread_join(tid1,nullptr);pthread_join(tid2,nullptr);pthread_join(tid3,nullptr);pthread_join(tid4,nullptr);return 0;
}
此时再运行代码,就不会出现之前为负数的情况了:
三、互斥量实现原理探究
1. 问题的本质:i++
或 ++i
不是原子操作
在之前的例子中,我们已经发现,即使是简单的 i++
或 ++i
操作,在多线程环境下也可能导致数据竞争(Data Race)。这是因为:
-
i++
实际上包含多个步骤(读取→修改→写入),线程切换可能发生在任意步骤之间。 -
如果多个线程同时执行
i++
,可能会导致最终结果不符合预期(如i
只增加 1 而非 2)。
2. 互斥锁的实现原理
为了保证操作的原子性,现代 CPU 提供了 原子交换指令(如 swap
或 exchange
):
-
原子交换指令的作用:
将 寄存器 和 内存单元 的数据进行交换,由于该操作是 单条 CPU 指令,因此具有原子性。 -
多处理器环境下的保证:
即使多个 CPU 核心同时访问同一内存地址,总线仲裁机制也会确保 同一时刻只有一个swap
指令能执行,其他 CPU 必须等待。
3. lock
和 unlock
的底层实现(伪代码)
我们全局资源,临界区资源被锁保护住了,但是锁也可能会是全局变量,那么谁来保护锁呢?
为了锁的安全性,所以锁被设计为硬件级别的原子性的操作,不会被线程调度打断。
基于 swap
指令,我们可以重新定义 lock
和 unlock
的底层逻辑(伪代码):
大家认为,哪一个汇编指令是加锁呢?
没错,是xchgb %al ,mutex
四、封装mutex
为了方便我们后面代码的执行,我们可以把锁封装成一个对象,而不是去一个一个调用它的初始化,销毁接口,如图C++一样的mutex类:
锁的封装的代码简单,基本思路就是默认构造和默认析构函数中我们可以内部调用锁的初始化与销毁函数,随后加上我们的加锁与解锁的接口就行了。
值得一提的是我们可以使用采用RAII风格对锁进行管理,即定义一专门的类型,在初始化时把我们定义的Mutex类的变量传进去,在一些场景下通过创建局部变量,局部自动的生命周期销毁来进行锁的控制:
#ifndef _MUTEX_HPP_
#define _MUTEX_HPP_#include <pthread.h>namespace MutexModule
{class Mutex{public:Mutex(){pthread_mutex_init(&_mutex, nullptr);}~Mutex(){pthread_mutex_destroy(&_mutex);}bool lock(){return pthread_mutex_lock(&_mutex) == 0;}bool unlock(){return pthread_mutex_unlock(&_mutex) == 0;}private:pthread_mutex_t _mutex;//互斥量锁};class LockGuard//采⽤RAII⻛格,进⾏锁管理{public:LockGuard(Mutex &mtx):_mtx(mtx)//通过后续使用时定义一个LockGuard类型的局部变量,在局部变量的声明周期内,互斥量会被自动加锁与解锁{_mtx.lock();}~LockGuard(){_mtx.unlock();}private:Mutex &_mtx;};
}#endif
如果采用我们自己的锁,那么上面的ticket代码就可以变成:
#include <stdio.h>
#include<iostream>
#include <pthread.h>
#include<string>
#include <unistd.h>
#include"mutex.hpp"MutexModule::Mutex mutex;int ticketnum = 1000;
void *ticket(void*argv)
{char* id=(char*)argv;while(true){//mutex.lock();MutexModule::LockGuard lockguard(mutex);if(ticketnum > 0){usleep(1000);std::cout << "ticketnum:" << ticketnum <<"线程id:"<<id<< std::endl;ticketnum--;//mutex.unlock();}else{//mutex.unlock();break;}}return nullptr;}
int main()
{pthread_t tid1,tid2,tid3,tid4;pthread_create(&tid1,nullptr,ticket,(void*)"tid1");pthread_create(&tid2,nullptr,ticket,(void*)"tid2");pthread_create(&tid3,nullptr,ticket,(void*)"tid3");pthread_create(&tid4,nullptr,ticket,(void*)"tid4");pthread_join(tid1,nullptr);pthread_join(tid2,nullptr);pthread_join(tid3,nullptr);pthread_join(tid4,nullptr);return 0;
}
我们这里可以通过LockGuard类(临时变量生命周期)来帮助我们进行管理,如果手动进行解锁上锁,难免会出现遗漏。
总结:
本文我们进行了互斥量的概念讲解,希望对大家有所帮助!!!