【Linux系统】线程的同步与互斥(1)——互斥量mutex

文章目录

引入问题

通过对线程的相关概念的学习,我们知道一个进程内部有多个线程,而所有的线程都共享进程地址空间,因此,进程的大部分资源都会被线程共享。那么对于共享的资源,如果多个线程同时访问它会产生什么后果呢🤔??

我们在学习线程控制的相关操作时,我们时常会见到这样一个现象:多个线程向显示器打印数据时,会出现严重的信息干扰。

在Linux眼中,显示器本质上也就是一个文件,也是一个共享资源,多线程访问共享资源,必然会引发数据不一致问题

如何解决呢🤔??这就需要我们学习本文所讲的同步与互斥相关内容了。


一、线程互斥

1.1、相关概念

🔥临界资源🔥

多线程执行流被保护 的共享资源就叫做临界资源。
🔥临界区🔥

每个线程内部,访问临界资源的代码,就叫做临界区。
🔥互斥🔥

任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
🔥原子性🔥

不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。


1.2、简单介绍互斥量

大部分情况,线程使用的数据都是局部变量,变量处于对应线程的栈空间内,这种情况,变量归属单个线程,其他线程是无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量 ,可以通过数据的共享,完成线程之间的交互。

当多个线程并发的操作共享变量时,会引发一些问题。我们来看一下代码💻:

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int tickets = 100;
void *route(void *arg)
{
    char *id = (char *)arg;
    while (true)
    {
        if (tickets > 0)
        {
            usleep(1000);   // 模拟抢票花费时间
            printf("%s sells ticket:%d\n", id, ticket);
            tickets--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, route, (void *)"thread 1");
    pthread_create(&t2, nullptr, route, (void *)"thread 2");
    pthread_create(&t3, nullptr, route, (void *)"thread 3");
    pthread_create(&t4, nullptr, route, (void *)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    return 0;
}

结果如下:

这个结果显然不符合我们的期望,并且还有点违反常识🤯。

问题1️⃣:为什么会数据不一致呢🤔??

我们在计算机组成原理这门课中学过:CPU可以处理两种运算,一个是算术运算 ,另一个是逻辑运算 。而我们代码中对tickes大小的判断就属于逻辑运算,在运算之前,CPU会将内存中对应的tickets值导入到寄存器中,而这一过程正是导致问题的元凶之一。

除了代码中的if语句可能会造成数据不一致问题,代码中的--操作同样也不可忽视,--操作本身也不是原子的 。在C/C++中,tickets--是一条语句,但在CPU眼中,实际为三条汇编语句:1️⃣将内存中的tickets移入寄存器exa中;2️⃣对exa减1;3️⃣再将exa中的tickets传入内存。因此,很有可能对exa减之前或将tickets数值更新前就进行线程切换,而导致数据不一致问题(数据重复)。对全局变量++或--,非常容易引发线程安全问题。

问题2️⃣:我们如何解决这一问题呢🤔??

tickets属于共享资源,为了避免共享资源一次性被多次访问,我们应该将共享资源保护起来,而被保护的共享资源,我们也称之为临界资源。因此一句话概括就是:将共享资源转化为临界资源

保护共享资源不被多线程同时访问,需要满足以下三点:

1️⃣代码必须要有互斥行为 :当代码进入临界区执行时,不允许其他线程进入该临界区。

2️⃣如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

3️⃣如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

而要满足以上三点,我们仅需要使用Linux提供的互斥量mutex(也称互斥锁)即可。

在我们的代码中,if判断语句中多次访问了临界资源,因此,根据临界区的定义可知,临界区的范围如下:

因此,保护临界资源的本质就是保护临界区 ,保护临界区的手段就是利用互斥 ,也就是利用


1.3、互斥量的相关接口

上文,我们简单引出了互斥量的相关话题,接下来,我们将简单介绍互斥量的相关接口。

相关接口都包含在<pthread.h>

1️⃣初始化互斥量

初始化互斥量有两种方法:一种是静态分配,也就是利用宏来初始化;

c 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

另一种则是动态分配。

c 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
						pthread_mutexattr_t *restrict attr);

其中mutex参数就是我们要初始化的互斥量attr是对应互斥量的状态 ,一般我们不必理会,将它设为NULL即可。

2️⃣销毁互斥量

c 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

其中mutex参数就是我们要销毁的互斥量

在销毁互斥量的时候,我们要注意以下三点:

  1. 使用静态分配初始化的互斥量不需要销毁。
  2. 不要销毁一个已经加锁的互斥量。
  3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

3️⃣加锁与解锁

c 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);		// 加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);	// 解锁

当加锁或解锁成功返回0,失败返回对应的错误号。

简单介绍相关接口后,接下来我们对先前的抢票系统进行优化。

可以发现,通过加锁等操作,的确避免了数据不一致问题。但是从运行结果来看,为什么抢到票的都是同一个线程呢??要回答这个问题,就必须等到下一节讲同步话题地时候了。

此外,关于互斥量相关的接口还有五个相关的细节问题:

问题1️⃣:加锁的原则问题

  • 由于互斥量的特性,同一时间段,有且只能有一个线程进入临界区,因此线程进入临界区后,就会由并行转为串行 。这样必然会导致效率降低,而这样的操作又是无法避免的,因此,在实践中,加锁的粒度必须足够的细

问题2️⃣:mutex也是共享资源,那么谁来保护它呢🤔??

  • 在我们先前的测试代码当中,我们使用的时静态分配,即定义了一个全局变量,但是全局变量不就是一个典型的共享资源吗??所有的线程都可以去使用,既然它来保护别人,那么谁来保护它呢??
  • 实际上,当年互斥量的设计师们也考虑过这个问题,为了避免mutex还需要被保护,于是就将 加锁和解锁操作设计为原子性的,也就是说加锁或者解锁这两个操作是一步到位的,并不会因为CPU的调度,而造成我们先前所讲的线程安全问题。

问题3️⃣:一些线程遵守先加锁再解锁,而有一些线程不遵守呢🤔??

  • 这种情况基本上是不会发生的,除非有人故意写bug。
  • 访问临界资源,所有的线程都必须遵守加锁和解锁的规则,绝不能有例外,这是一个共识。

问题4️⃣:申请锁失败的线程在做些什么呢🤔??

  • 一般多线程同时申请互斥量,但是只会有一个申请成功,没有竞争 到互斥量的线程会在pthread_ lock陷入阻塞(执行流被挂起),等待互斥量解锁。

问题5️⃣:临界区内部也会有多行代码,那么会发生线程切换嘛🤔??

  • 当然,即使是执行到加锁解锁操作内部,也会发生线程切换。因为,临界区本质是人为规定的一个概念,而在CPU眼中,本质上都是代码。因此,不管是不是在临界区,都会发生线程切换。

1.4、互斥量的原理

首先,我们得了解一个知识点:如果一个语句只有一行汇编代码就可以表示,那么执行该语句就是原子的。或者简单来说,一条汇编语句操作是原子的💧

互斥锁的加锁解锁操作的汇编伪码如下图:

其中,lock的第一行就是将某一个寄存器存入0,而第二行则是整个加锁逻辑中最关键的一行!!,它将寄存器中的值与内存中的mutex值进行了交换!!

我们知道CPU在调度线程的时候,是以线程为载体执行的加锁逻辑。当寄存器的值与内存中mutex的值交换过后,原本mutex的值就归属于当前线程 了,即使交换后立刻就发生了线程调度,该值也会变成硬件上下文一直跟着这个线程。而交换后的mutex在内存中存储的值则会一直为零,后来的线程执行到交换语句后也只会0与0交换,完全没有任何影响。

实际上,锁就是我们上文中所谈到的1!!exchange始终没有拷贝,也就是说始终只有一个1谁拥有这个1,谁就拥有这个锁!!

再回看先前的问题5️⃣:临界区内部也会有多行代码,那么会发生线程切换嘛🤔??

现在来看,当然不会,锁只有一份,只要线程1不还回来,锁就属于线程1私有,线程切换不影响。

综上,互斥锁的本质就是由1至0的过程,互斥的本质就是独占!!🌟🌟

除此以外,锁的实现方式是多种多样的,除了上述利用软件的方式实现互斥锁,我们还可以利用硬件方式。

互斥锁的存在,本质就是让临界区中只存在一个线程,那么如果当某一个线程执行到临界区的代码时,立刻就将时钟中断关闭,此时操作系统就无法再进行调度了,线程则无法切换,这样就无人能够打扰该线程执行临界区的代码了。

这个操作在逻辑上一定是行得通的,但是一定不建议这么做,时钟中断可以说是操作系统的灵魂,这种"触及灵魂"的事是具有极大风险的。

之所以说这种实现方式,只是想说明一个结论:锁的实现是多种多样的!!💧


1.5、互斥量的封装

C++中也有互斥量相关的接口,只不过是利用面向对象的方式将他们封装起来了。

现在,我们也利用面向对象的方式,将互斥量接口封装。

cpp 复制代码
class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&lock, nullptr);
    }
    void Lock()
    {
        pthread_mutex_lock(&lock);
    }

    void unLock()
    {
        pthread_mutex_unlock(&lock);
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&lock);
    }
private:
    pthread_mutex_t lock;
};

此外,我们亦可以按照RAII风格进行进一步封装。

补充:RAII是C++中一种非常重要的编程惯用法,核心思想是:资源的获取与对象的初始化绑定,资源的释放与对象的析构绑定,从而利用 C++ 对象生命周期(构造、析构)的自动管理机制来安全、简洁地管理资源(如动态内存、文件句柄、锁、套接字等)。

cpp 复制代码
#include <pthread.h>

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&lock, nullptr);
    }
    void Lock()
    {
        pthread_mutex_lock(&lock);
    }

    void unLock()
    {
        pthread_mutex_unlock(&lock);
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&lock);
    }
private:
    pthread_mutex_t lock;
};

class LockGuard	// RAII风格
{
public:
    LockGuard(Mutex& lock)  //引用了传进来的参数
    :_lock(lock)
    {
        _lock.Lock();
    }

    ~LockGuard()
    {
        _lock.unLock();
    }
private:
    Mutex& _lock;   //引用的引用了
};

我们依旧用抢票程序进行测试:


完🌄🌄🌄

相关推荐
t5y229 小时前
【Linux】组管理和权限管理
linux·服务器
j7~9 小时前
【Linux】 基础IO(动静态库的制作与使用)--万字详解
linux·运维·服务器·动态库·静态库
深蓝轨迹9 小时前
JVM 类加载机制详解(生命周期・双亲委派・自定义加载器)
jvm·类加载器·双亲委派
j_xxx404_9 小时前
Linux线程:核心机制与优雅的 C++ 封装实践|附源码
linux·运维·服务器·开发语言·c++·人工智能·ai
IMPYLH9 小时前
Linux 的 users 命令
linux·运维·服务器·前端·数据库·bash
xiaoye-duck9 小时前
【Linux:文件】Linux 动静态库详解:动态链接与动态库加载深度解析
linux
加油20199 小时前
嵌入式软件技术栈和学习路线详解
linux·arm开发·数据结构·mqtt·设计模式·嵌入式
Oj92q85H59 小时前
如何在Dev-C++中使用TDM-GCC编译项目
linux·开发语言·c++
行走的大喇叭9 小时前
计算机系统组成及常见概念
linux·运维·计算机网络