【Linux】线程的互斥

一、进程线程间的互斥相关的背景概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每一个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界区资源,通常对临界资源起保护作用
  • 原子性:不会被任何调度机制打断的操作,该操作只有两个状态:要么完成,要么未完成

二、互斥量 mutex

  • 大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程栈空间中,这种情况下,变量归属于单个线程,其他线程无法获得这种变量。
  • 但是有的时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。

三、举例代码

3.1 先通过一个例子来看一看:

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <vector>
#include "Thread.hpp"

using namespace Mypthread;

int tickets = 10000; // 票数

void route(const std::string &name)
{
    while (true)
    {
        if (tickets > 0)
        {
            usleep(1000); // 微妙级,用1毫秒表示抢票的时间
            printf("who: %s , get tickets: %d\n", name.c_str(), tickets);
            tickets--;
        }
        else
            break;
    }
}

// 抢票检验线程互斥
int main()
{
    mThread t1("thread-1", route);
    mThread t2("thread-2", route);
    mThread t3("thread-3", route);
    mThread t4("thread-4", route);
    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();
    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();
    return 0;
}

3.2 解释现象:

3.2.1 判断的过程是不是一种计算

       计算机常用的数据类型分为两种:算术运算,逻辑运算。CPU在计算过程中,其操作过程不是原子的,是需要分成好几个步骤的。

我们来拿逻辑判断来举例:

  • 第一步要先将数据移动到寄存器中
  • 第二步进行逻辑判断
  • 第三步将结果发出

       在计算机中,CPU有时只有一套,但是寄存器中的数据是可以有多套的。多个线程根据时间片进行交替执行,因为寄存器中的数据属于线程私有,看起来是放在一套共有的寄存器中,但是当线程要被切走的时候,线程是需要带走自己的数据,当线程回来的时候,线程需要将数据进行恢复。

3.2.2 自减--的原理(不是原子的)

  • 重读数据
  • 减减数据
  • 写回数据

3.2.3  在上面内容的基础上,我们来解释一下这个现象

       假设票数只剩下一张,进程A看见还有一张,进行买票;但是在买票的过程中,突然被切出,进程B进入买票,梅开二度,在买票的过程中,突然被切出...当进程A恢复线程,进行减减操作,票数为0,退出;进程B恢复线程,记住,现在tickets的票数为0,减减重读数据,将tickets为0读入,将tickets减为负数,之后的线程同理。

3.3 为什么可能无法获得争取结果?

  •  if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket 操作本身就不是一个原子操作 

3.4 我们来看一看汇编代码

通过汇编代码,我们可以发现这种操作不是原子性操作,而是分别对应于三条汇编指令:

  • load:将共享变量ticket从内存中加载到寄存器中
  • update:更新寄存器里面的值,执行 -1 操作
  • store:更新值,从寄存器写回共享内存ticket的内存地址

3.5 解决以上问题的措施

要解决以上问题,需要做到三点:

  • 代码必须有互斥行为:当代码进行临界区执行时,不允许其他线程进入该临界区
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程执行,那么只能允许一个线程进入给临界区
  • 如果线程不在临界区中执行,那么线程不能组织其他线程进去临界区

四、互斥量

       要做到上述三点,本质上需要一把。Linux上提供的这把锁叫做互斥量

                         

4.1 互斥量的接口

4.1.1 互斥量的函数

函数的原型:

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
                                 const pthread_mutexattr_t *restrictattr);

// 在全局开辟的或者在静态区中开辟的
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

函数的功能:

       该函数用于C函数的多线程编程中,互斥锁的初始化。在任何时候,只允许一个线程进行访问。
函数的参数:

  • mutex:指向要初始化的互斥锁的变量本身
  • restrictattr:指定了新建互斥锁的属性。如果参数restrictattr为空(NULL),则使用默认的互斥锁属性,默认属性为快速互斥锁 。互斥锁的属性在创建锁的时候指定,在LinuxThreads实现中仅有一个锁类型属性,不同的锁类型在试图对一个已经被锁定的互斥锁加锁时表现不同。

下面是互斥锁的类型:

  • PTHREAD_MUTEX_TIMED_NP,这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将形成一个等待队列,并在解锁后按优先级获得锁。这种锁策略保证了资源分配的公平性。
  • PTHREAD_MUTEX_RECURSIVE_NP,嵌套锁,允许同一个线程对同一个锁成功获得多次,并通过多次unlock解锁。如果是不同线程请求,则在加锁线程解锁时重新竞争。
  • PTHREAD_MUTEX_ERRORCHECK_NP,检错锁,如果同一个线程请求同一个锁,则返回EDEADLK,否则与PTHREAD_MUTEX_TIMED_NP类型动作相同。这样就保证当不允许多次加锁时不会出现最简单情况下的死锁。
  • PTHREAD_MUTEX_ADAPTIVE_NP,适应锁,动作最简单的锁类型,仅等待解锁后重新竞争。

函数的返回值:

  • 函数成功完成之后会返回零
  • 其他任何返回值都表示出现了错误

4.1.2 互斥量的初始化

方法1:静态分配

// 在全局开辟的或者在静态区中开辟的
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

方法2:动态分配

int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
                                 const pthread_mutexattr_t *restrictattr);

4.1.3 互斥量的销毁

销毁互斥量需要注意:使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要进行销毁

       对于一个已经加锁的互斥量不要进行销毁,对于已经销毁的互斥量,要确保后面不会有线程在尝试加锁。

函数的原型:

#include <pthread.h>

int pthread_mutex_destroy(pthread_mutex_t* mutex);

函数的功能:

       进行互斥量的销毁,对于一个已经加锁的互斥量不要进行销毁,对于已经销毁的互斥量,要确保后面不会有线程在尝试加锁。

函数的参数:

  • mutex:指向要初始化的互斥锁的变量本身

函数的返回值:

  • 函数成功完成之后会返回零
  • 其他任何返回值都表示出现了错误

4.1.4 互斥量的加锁和解锁

4.1.4.1 互斥量的加锁(阻塞调用)

函数的原型:

#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t* mutex);

函数的功能:

       该函数用于对互斥锁进行加锁操作。它阻塞调用线程,直到可以获得互斥锁为止。如果互斥锁已经被其他线程锁定,则调用线程将被阻塞,直到互斥锁被解锁。
函数的参数:

  • mutex:指向要初始化的互斥锁的变量本身

函数的返回值:

  • 返回值为0表示成功加锁
  • 返回值为非零值表示失败
4.1.4.2 互斥量的加锁(非阻塞调用)

函数的原型:

#include <pthread.h>

int pthread_mutex_trylock(pthread_mutex_t* mutex);

函数的功能:

       该函数是pthread_mutex_lock函数的非阻塞版本。如果mutex参数所指定的互斥锁已经被锁定的话,调用pthread_mutex_trylock函数不会阻塞当前线程,而是立即返回一个值来描述互斥锁的状况。

函数的参数:

  • mutex:指向要初始化的互斥锁的变量本身

函数的返回值:

  • 函数成功完成之后会返回零
  • 其他任何返回值都表示出现了错误
4.1.4.3 互斥量的解锁

函数的原型:

#include <pthread.h>

int pthread_mutex_unlock(pthread_mutex_t* mutex);

函数的功能:

       该函数用于解锁互斥锁,它接收一个指向互斥锁的指针作为参数,并将该互斥锁解锁。

函数的参数:

  • mutex:指向要初始化的互斥锁的变量本身

函数的返回值:

  • 函数成功完成之后会返回零
  • 其他任何返回值都表示出现了错误

总结:

       所谓对临界资源进行保护,本质上是对临界区代码进行保护!我们对所有资源经访问,本质都是通过代码进行访问。保护资源本质上就是想办法把访问资源的代码进行保护起来。

4.1.5 对抢票代码进行加锁操作

4.1.5.1 全局变量的锁

代码如下:

int tickets = 10000; // 票数

// 建立一个全局范围的锁
pthread_mutex_t mutex;

void route(const std::string &name)
{
    while (true)
    {
        // 加锁
        pthread_mutex_lock(&mutex);
        if (tickets > 0)
        {
            usleep(1000); // 微妙级,用1毫秒表示抢票的时间
            printf("who: %s , get tickets: %d\n", name.c_str(), tickets);
            tickets--;
            // 解锁
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            // 解锁
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
}

代码执行结果:

4.1.5.2  局部变量的锁
// 在Thraed类中加入锁的变量
    class ThreadDate
    {
    public:
        ThreadDate(const std::string& name, pthread_mutex_t* lock)
            :_name(name)
            ,_lock(lock)
        {}

    public:
        std::string _name;
        pthread_mutex_t* _lock;
    };

4.1.6 对锁进行封装

 在想要保护的临界区前面进行定义一个锁即可,因为随着函数范围的释放,该锁也会跟着解锁。

class LockGuard
{
public:
    LockGuard(pthread_mutex_t* mutex)
        :_mutex(mutex)
    {
        pthread_mutex_lock(_mutex);
    }

    ~LockGuard()
    {
        pthread_mutex_unlock(_mutex);
    }

private:
    pthread_mutex_t* _mutex;
};

4.1.7 解决历史问题

  1. 加锁的范围的粒度一定要尽量小
  2. 任何线程要进行抢票,都要先申请锁,原则上,不应该有例外
  3. 所有线程申请锁,前提是所有线程都要看到这把锁,锁本身也是共享资源,加锁的过程必须是原子的
  4. 原子性:要么不做,要么做就要做完,没有中间状态,就是原子性
  5. 如果线程申请锁失败了,我的线程要被阻塞
  6. 如果线程申请锁成功了,继续向后进行运行
  7. 如果线程申请锁成功了,执行临界区的代码,在执行临界区的代码期间,可以进行切换,其他线程无法进入!因为我虽然被切换,但是我没有进行释放,我可以放心的执行完毕,没有人可以打扰我。

       总结:所以对于其他线程,要么我没有申请锁,要么我释放了所,对其他线程才有意义。我访问临界区,对于其他线程是原子的。

4.3 对互斥量的原理实现

我们对于锁来说,重要的函数是:

int pthread_mutex_lock(pthread_mutex_t* mutex);

如何理解申请锁成功,允许进入临界区?申请锁成功,pthread_mutex_lock函数会返回。

如何理解申请锁失败,不允许进行临界区? 申请锁时报,pthread_mutex_lock函数不会返回。

4.4 互斥量的实验原理探究

       在经过上面的例子中,我们已经可以意识到单纯的 i++ 操作或者 ++i 操作都不是原子的,有可能会有数据一致性的问题,所以为了实现互斥锁的操作,大多数体系结构都提供了swap操作和exchange操作指令。

       swap操作和exchange操作指令:这种指令就是原子性的,该指令的作用是把寄存器和内存单元的数据济宁相互交换,由于只有一条指令,保证了原子性,即使是多处理平台进行访问,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个交换指令只能等待总线周期。

现在,我们来看一下lock和unlock的伪代码进行查看:

先来看一看lock中的操作:

       lock在内存中开辟一段空间,我们可以在这段空间中放入一些值,来使线程具有互斥性。比如:当lock中的数字为1,说明我们可以进行该临界区,同时将lock中的数字进行交换变为0;当lock中的数字变为0,说明我们不能进入该临界区,需要进行挂起等待,然后重新进行lock的判断。

       举个例子:首先,这个锁处于无人状态。现在有一个线程A进行lock中,将lock中的数字和al寄存器的数字进行交换,然后现在al寄存器中的数字为1大于0,说明我们可以进入临界区中。之后随着时间片的轮转,线程A要被切走,对于线程A在CPU寄存器中的所有数据也要跟着一起切走,然后此时,lock中的数字为依旧为0,其他线程中的al寄存器中的数字也为0,所以其他线程无法进行该临界区,也就是将其他线程全部都锁在了外面,只有当线程A完成打开锁之后,其他线程才能进入该临界区中。

再来看一看umlock中的操作:

       我们在来看一看这个unlock操作,等待线程A操作完之后,我们需要直接将数字1赋值给mutex内存中,之后唤醒其他等待Mutex的线程。

总结:

  • CPU的寄存器只有一套,被所有的线程共享,但是寄存器里面的数据属于执行流的上下文,属于执行零私有的数据
  • CPU在执行代码的时候,一定要有对应的执行载体:线程或者进程
  • 数据在内存中被所有线程共享

把数据从内存中移动到CPU寄存器中,本质上,就是把数据从共享变为线程私有。

相关推荐

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-06-09 07:44:02       94 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-06-09 07:44:02       101 阅读
  3. 在Django里面运行非项目文件

    2024-06-09 07:44:02       82 阅读
  4. Python语言-面向对象

    2024-06-09 07:44:02       91 阅读

热门阅读

  1. 使用 LLaMA-Factory 实现对大模型函数调用功能

    2024-06-09 07:44:02       30 阅读
  2. 二叉树----7-3 列出叶结点

    2024-06-09 07:44:02       24 阅读
  3. bat指令踩坑记录

    2024-06-09 07:44:02       29 阅读
  4. Web Dart前端:探索、挑战与未来展望

    2024-06-09 07:44:02       34 阅读
  5. 计算机视觉中的low-level与 high-level任务

    2024-06-09 07:44:02       37 阅读
  6. python记录之字符串

    2024-06-09 07:44:02       40 阅读
  7. Playwright 这个强大的自动化测试工具

    2024-06-09 07:44:02       24 阅读
  8. 安装 hbase(伪分布式)

    2024-06-09 07:44:02       25 阅读
  9. 密码学基本概念

    2024-06-09 07:44:02       30 阅读
  10. Python为项目中添加上彩色日志

    2024-06-09 07:44:02       29 阅读
  11. perl use HTTP::Server::Simple 轻量级 http server

    2024-06-09 07:44:02       40 阅读
  12. 面试 Redis 八股文十问十答第二期

    2024-06-09 07:44:02       30 阅读
  13. ASP.NET Core 中使用基本消息的 RabbitMQ 消费者

    2024-06-09 07:44:02       29 阅读