【Linux】多线程 —— 线程互斥

🌈欢迎来到Linux专栏 ~~ 线程互斥

线程互斥

🐣资源共享问题

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

  1. 临界资源:多线程执行流被保护的共享资源就叫做临界资源

  2. 临界区 :每个线程内部,访问临界资源的代码,就叫做临界区

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

  4. 原子性 (后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

🌊锁的概念引入

临界资源 要想被安全的访问,就得确保 临界资源使用时的安全性

举个例子:公共厕所是共享的,但卫生间只能供一人使用,为了确保如厕时的安全性,就需要给每个卫生间都加上一道门,并且加上一把锁

对于 临界资源 访问时的安全问题,也可以通过 加锁 来保证,实现多线程间的 互斥访问互斥锁 就是解决多线程并发访问问题的手段之一

我们可以 在进入临界区之前加锁,出临界区之后解锁 , 这样可以确保并发访问 临界资源 时的绝对串行化

说白了 加锁 的本质就是为了实现原子性

🐣多线程抢票

接下来通过代码演示多线程并发访问问题

image2生成的图还有点意思

🏝️多线程并发抢票

思路很简单:存在 1000 张票和 4 个线程,4 个线程同时抢票,直到票数为 0,程序结束后,可以看看每个线程分别抢到了几张票,以及最终的票数是否为 0

共识:购票需要时间,抢票成功后也需要时间,这里通过 usleep 函数模拟耗费时间

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"

int tickets = 10000; //共享资源 ~ 没保护导致数据不一致

void GetTickets()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    while (1)
    {
        if (tickets > 0) //临界区
        {
            usleep(4000); //4ms 模拟具体抢票花的时间
            printf("%s get ticket %d\n", name, tickets);
            tickets--;
        }
        else 
            break;
    }
}

int main()
{
    ThreadModule::Thread t1(GetTickets);
    ThreadModule::Thread t2(GetTickets);
    ThreadModule::Thread t3(GetTickets);
    ThreadModule::Thread t4(GetTickets);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();

    return 0;
}

理想状态下,最终票数为 0,4 个线程抢到的票数之和为 1000,但实际并非如此

我们看见线程1 和线程4 怎么抢到了-1-2张票呢? 为什么总共1000张票,还会多抢出来两张??

所以:多线程并发访问是绝对存在问题的

🏝️引发问题

其实对应的tickets--,经过编译器汇编后,至少是经历下面三步

cpp 复制代码
movl    -4(%rbp), %eax //rbp的值移动到eax里
subl    $1, (%eax)	//eax里的值 -1
movl    %eax, -4(%rbp) //eax的值移回到rbp里

所以tickets-- 操作本身就不是⼀个原子操作,而是上述的三条汇编代码

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

出现上述的原因是:

假设 tickets = 10000线程A 在抢票,准备完成第3步,将数据拷贝回内存时被切走了,切走时保存自己的硬件上下文数据 。线程B的时间片充足,足够它把tickets 减到1000了,正准备再对1000做减减操作时,时间片到了 ~ 被切走。线程把之前的上下文数据加载回去,此时把9999加载回内存(把之前线程B的数据覆盖了) ------ 这就是出现的数据不一致的问题

全局变量的++-- 操作,对于多线程也是会导致数据不一致问题 ------ 会引起线程安全问题

那么票数抢到 -1 了,是哪个地方出问题了呢?

  • 主要是 if (tickets > 0) 导致的,为什么不是tickets--
  • tickets--只会把我们的tickets值变大 ,因为它拿到的永远是老的数据(更大)

为了模拟出多线程访问全局数据出问题,核心理念:💥尽可能的让执行流进行切换 ------ 检测时间片、同时切换的时间:从内核态返回用户态时

  • usleep() 的作用是:当前线程主动发起系统调用进入内核态,请求"睡眠一段时间";内核会把该线程置为 阻塞态(SLEEPING),然后调用调度器 schedule() 去运行其它可运行线程。
  • 某一次发生时钟中断时,进入内核,内核发现:线程1 sleep结束 ,调度器选择了线程1继续跑

对于 这种 临界资源 ,可以通过 加锁 进行保护,即实现 线程间的互斥访问 ,确保多线程购票时的 原子性

也就是:3 条汇编指令要么不执行,要么全部一起执行完

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

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

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

🐣线程互斥

互斥 -> 互斥排斥:事件 A 与事件 B 不会同时发生

比如 多线程并发抢票场景中可以通过添加 互斥锁 的方式,来确保同一张票不会被多个线程同时抢到 ~ 线程变成串型执行

🍅互斥锁相关操作

🔒锁的创建和销毁

互斥锁 同样出自 原生线程库 ,类型为 pthread_mutex_t互斥锁 在创建后需要进行 初始化

1️⃣动态初始化一个互斥锁对象 (栈上的锁必须在运行时动态初始化)

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

pthread_mutex_t mutex; //定义一把锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
                       const pthread_mutexattr_t *restrict attr);

🔹 mutex指向要初始化的互斥锁变量(传地址)pthread_mutex_t mutex

🔹 attr 互斥锁属性 ------ NULL:使用默认属性(最常见)

返回值初始化成功返回 0,失败返回 error number

2️⃣静态初始化

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

等价于:默认互斥锁类型、默认属性不可配置

cpp 复制代码
pthread_mutex_init(&mutex, NULL)

静态分配优点在于 无需手动初始化和手动销毁锁的生命周期伴随程序缺点 就是定义的 互斥锁 必须为 全局互斥锁

分配方式 操作 适用场景
动态分配 手动初始化/销毁 局部锁/全局锁
静态分配 自动初始化/销毁 全局锁

注意: 使用静态分配时,互斥锁必须定义为全局锁

互斥锁 是一种向系统申请的资源,在使用完毕后需要销毁

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

int pthread_mutex_destroy(pthread_mutex_t *mutex);

其中只有一个参数 pthread_mutex_t* 表示想要销毁的 互斥锁

返回值:销毁成功返回 0,失败返回 error number

注意:

复制代码
互斥锁是一种资源,一种线程依赖的资源,因此 [初始化互斥锁] 操作应该在线程创建之前完成,[销毁互斥锁] 操作应该在线程运行结束后执行;总结就是 使用前先创建,使用后需销毁
对于多线程来说,应该让他们看到同一把锁,否则就没有意义
不能重复销毁互斥锁
已经销毁的互斥锁不能再使用

🔒加锁

互斥锁 最重要的功能就是 加锁与解锁 操作,主要使用 pthread_mutex_lock 进行 加锁

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

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数 pthread_mutex_t* 表示想要使用哪把互斥锁进行加锁操作

返回值:销毁成功返回 0,失败返回 error number

使用 pthread_mutex_lock 加锁时可能遇到的情况:

  1. 当前互斥锁没有被别人持有,正常加锁,函数返回 0
  2. 当前互斥锁被别人持有,加锁失败,当前线程被阻塞(执行流被挂起),无法向后运行,直到获得 [锁资源]

如果想要非阻塞加锁pthread_mutex_trylock() ~ 拿不到锁就立刻返回

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

int pthread_mutex_trylock(pthread_mutex_t *mutex);

锁空闲 → 拿锁成功 → 返回0

锁已占用 → 不等待
          立即返回EBUSY

🔒解锁

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

int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数 pthread_mutex_t* 表示想要对哪把互斥锁进行解锁

返回值:销毁成功返回 0,失败返回 error number

♦️在 加锁 成功并完成对 临界资源 的访问后,就应该进行 解锁将 [锁资源] 让出,供其他线程(执行流)进行 加锁

注意: 如果不进行解锁操作,会导致后续线程无法申请到 [锁资源] 而永久等待,引发 死锁 问题

🍅解决抢票问题

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"

int tickets = 10000; //共享资源 ~ 没保护导致数据不一致
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //静态初始化互斥锁

void GetTickets()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    while (1)
    {
        pthread_mutex_lock(&mutex); //加锁
        if (tickets > 0) //临界区
        {
            usleep(4000); //4ms 模拟具体抢票花的时间
            printf("%s get ticket %d\n", name, tickets);
            tickets--;
            pthread_mutex_unlock(&mutex); //解锁
        }
        else 
        {
            pthread_mutex_unlock(&mutex); //解锁 在break之前解锁,保证线程退出前能释放锁
            break;
        }
    }
}

int main()
{
    ThreadModule::Thread t1(GetTickets);
    ThreadModule::Thread t2(GetTickets);
    ThreadModule::Thread t3(GetTickets);
    ThreadModule::Thread t4(GetTickets);

    t1.Start();
    t2.Start();
    t3.Start();
    t4.Start();

    t1.Join();
    t2.Join();
    t3.Join();
    t4.Join();

    return 0;
}

加锁后,确实是抢了10000张票,并没有多抢。

👀互斥锁细节

👇🏻下面的细节才是重头戏:

1️⃣加锁的原则问题
加锁会导致效率降低,加锁的粒度,必须足够细! 由并行变成串型走

2️⃣多线程都申请加锁,前提是都看见同一把锁,mutex不也是共享资源?谁来保护它

菜鸟保护菜鸟吗? 哈哈

  • 不需要别人来保护,mutex加锁 和 解锁(lock & unlock)被设计成原子的!! ------ 后续原理部分解释

3️⃣一些线程遵守先加锁,再解锁;一些线程不遵守呢??

  • 💥访问临界资源,所有的线程必须遵守加锁和解锁规则,不能有例外!
  • 对临界资源的保护,加锁的过程,本质是所有相关线程的共识
  • 对于加锁的线程 ------ 本质是只有一个线程在跑。比如10个线程:5个线程加锁,另外5个不加;不就转化为6个线程在不加锁的情况下运行吗??

4️⃣申请不成功的线程,在干什么??

竞争失败的进程(2、3、4)必须在锁上阻塞等待!直到线程1把锁给unlock了,把

前面三个线程唤醒并且重新竞争 ------ 又有一个线程申请成功,另外的继续阻塞等待 ~

给我们的启示:

🔹加锁是会保证原子性的 ,抢成功的线程进去了,没成功的线程在外面等

🔹如果只有一行汇编,那么执行该汇编的语句也是原子的!(要么做了 or 没做)

最后两个问题:

  1. 竞争锁成功的线程A ,因临界区内部有多行代码,会发生线程切换吗??
  2. 线程切换后,对于锁及临界资源有影响吗?

首先,线程在执行临界区内的代码时,是允许被调度的,比如线程 1 在持有 [锁资源] 后结束运行,是完全可行的(证明可以被调度);其次,线程在持有锁的情况下被调度是没有影响的,不会扰乱原有的加锁次序 ------ 看下面的例子(只要不把锁还回去,其他线程门都没有,切换到你也没有钥匙啊)ps:总有换钥匙的一天吧哈哈

简单举例说明:

假设你的学校里有一个 很夯的 VIP 自习室 ,一次只允许一个人使用。作为学校里的公共资源,这个 顶级 VIP 自习室 开放给所有学生使用

使用规则:

  • 一次只允许一个人使用
  • 自习室的门上装有一把锁,优先到达自习室的可以获取钥匙并进入自习室
  • 自习室内无限制,允许一直自习,直到自愿退出,退出后需要把钥匙交给下一个想要自习的同学

假设某天早上 6:00 张三就到达了 顶级 VIP 自习室 ,并成功获取钥匙,解锁后进入了自习室自习;之后陆陆续续有同学来到了 顶级 VIP 自习室 门口,因为他们都没有钥匙,只能默默等待张三或上一个进入自习室的人交接钥匙

此时的张三不就是持有 [锁资源] ,并且在进行 临界资源 访问的 线程(执行流) 吗?其他线程(执行流)无法进入 临界区 ,只有等待张三 解锁(交出 [锁资源] / 钥匙)

假如张三此时想上厕所,并且不想失去钥匙,那么此时他就会带着钥匙去上厕所,即便自习室空无一人,但其他同学也无法进入自习室!

张三上厕所的行为可以看作线程在持有 [锁资源] 的情况下被调度了,显然此时对于整体程序是没有影响的,因为 锁还是处于 lock 状态,其他线程无法进入临界区

假若张三自习够了,潇洒出门,把钥匙往门上一放,正好被李四同学抢到了,那么此时 顶级 VIP 自习室 就是属于李四的

交接钥匙的本质是让出 自习室 的访问权,这不就是 线程解锁后离开临界区,其他线程加锁并进入临界区吗

综上可以借助 张三与顶级 VIP 自习室 的故事理解 线程持有锁时的各种状态

🔓互斥锁原理

锁的原理有硬件实现和软件实现两种

1️⃣硬件实现: 如果实现在访问临界资源时,不让线程发生切换 ~ 不也是一种原子性吗?

  • 是不是可以在线程进入时,把时钟中断给关掉 ------ 执行完 再把时钟中断开回来 ------ 打开和关闭时钟中断来完成
  • 开关时钟中断是内核级别的 ------ 很高风险(特殊情况使用)

2️⃣软件实现:

在如今,大多数 CPU 的体系结构(比如 ARMX86AMD 等)都提供了 swap 或者 exchange 指令,这种指令可以把 寄存器内存单元 的数据 直接交换 ,由于这种指令只有一条语句,可以保证指令执行时的 原子性

即便是在多处理器环境下(总线只有一套),访问内存的周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期,即 swapexchange 指令在多处理器环境下也是原子的

首先看一段伪汇编代码(加锁相关的)~ 本质上就是 pthread_mutex_lock() 函数

cpp 复制代码
lock:
	movb $0, %al
	xchgb %al, mutex
	if(al寄存器里的内容 > 0){
		return 0;
	} else
		挂起等待;
	goto lock;

其中 movb 表示赋值,al 为一个寄存器,xchgb 就是支持原子操作的 exchange 交换语句

共识:计算机中的硬件,如 CPU 中的寄存器只有一份,被所有线程共享,但其中的内容随线程,不同线程的内容可能不同,也就是我们常说的上下文数据,所以上下文数据是有多份的

  • 寄存器 != 寄存器中的内容(执行流的上下文)

当线程A 首次加锁时,整体流程如下:

0 赋值给 al 寄存器,这里假设 mutex 默认值为 1(其他不为 0 的整数也行)

cpp 复制代码
movb $0, %al

al 寄存器中的值与 mutex 的值交换(原子操作

cpp 复制代码
xchgb %al, mutex

判断当前 al 寄存器中的值是否 > 0

cpp 复制代码
if(al寄存器里的内容 > 0){
		return 0;
	} else
		挂起等待;

此时线程 A 就可以快快乐乐的访问 临界区 代码了,如果此时线程A 被切走了(并没有出临界区,[锁资源] 也没有释放 ),OS 会保存 thread_A上下文数据al = 1,eip:if,把🔑带走了),并让线程 thread_B 入场


thread_B 也是执行 pthread_mutex_lock() 的代码,试图进入 临界区

首先将 al 寄存器中的值赋为 0

cpp 复制代码
movb $0, %al

其次将 al 寄存器中的值与 mutex 的值交换(原子操作

重点来了:mutex 作为内存中的值,被所有线程共享,因此 thread_B 看到的 mutex 是被 thread_A 修改后的值

  • 把 内存变量 交换到CPU的寄存器中。其本质是:🌈 把共享数据,变成某个线程的私有数据

显然此时交换了个寂寞

最后判断 al 寄存器中的值是否 > 0

此时的 thread_B 因为没有 [锁资源] 而被拒绝进入 临界区 ,不止是 thread_B, 后续再多线程(除了 thread_A) 都无法进入 临界区

不难看出,此时 thread_A 的上下文数据中,al = 1 正是解开临界区的钥匙 ,其他线程是无法获取的,因为 钥匙 只能有一份。

竞争锁,本质就是竞争执行xchgb %al, mutex

而汇编代码中 xchgb %al, mutex 的本质就是 加锁 ,当 mutex 不为 0 时,表示 钥匙 可用,可以进行 加锁 ;并且因为 xchgb %al, mutex 只有一条汇编指令,足以确保 加锁 过程是 原子性


再来看看 解锁 操作吧,本质上就是执行 pthread_mutex_unlock() 函数:把钥匙1还给mutex

cpp 复制代码
unlock:
	movb $1, mutex
	唤醒等待 [锁资源] 的线程;
	return 0;

thread_A 登场,并进行 解锁

线程A把al中的1,还给mutex ~ 赋值

cpp 复制代码
movb $1, mutex

既然 thread_A 都走到了 解锁 这一步,证明它已经不需要再访问 临界资源 了,可以让其他线程去访问,也就是 唤醒其他等待 [锁资源] 的线程,然后 return 0 走出 临界区

cpp 复制代码
唤醒等待 [锁资源] 的线程;
return 0;


现在 [锁资源] 跑到 thread_B 手里了,并没有新增或丢失,如此重复,就是 加锁 / 解锁 的原理

至于各种被线程执行某条汇编指令时被切出的情况,都可以不会影响整体 加锁 情况

注意:

  • 加锁是一个让不让你通过的策略
  • 交换指令 swapexchange 是原子的,确保 锁 这个临界资源不会出现问题
  • 未获取到 [锁资源] 的线程会被阻塞至 pthread_mutex_lock()

加锁:mutex值从 1 --- > 0互斥的本质是独占,独占的本质是我们认为临界资源只有一份!!

  • 把互斥锁理解为一个信号量,只不过信号量值为1,表示一份资源。

💥💥互斥锁的本质:是对资源的预定机制!! 我预定了(拿到了mutex的1️⃣值),其他人就不可预定

🍅互斥锁封装

原生线程库 提供的 互斥锁 相关代码比较简单,也比较好用,但有一个很麻烦的地方:就是每次都得手动加锁、解锁,如果忘记解锁,还会导致其他线程陷入无限阻塞的状态

因此我们对锁进行封装,实现一个简单易用的 小组件

封装思路:利用创建对象时调用构造函数,对象生命周期结束时调用析构函数的特点,融入 加锁、解锁 操作即可

非常简单,直接创建一个 LockGuard

创建头文件,编写代码

cpp 复制代码
#pragma one

#include <pthread.h>
#include <iostream>

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
{
public:
    LockGuard(Mutex &lock) : _lockref(lock)
    {
        _lockref.Lock(); // 加锁
    }
    ~LockGuard()
    {           
        _lockref.Unlock(); // 解锁
    }
private:    Mutex &_lockref;
};

这个版本就写的很优雅,无需手动加锁,解锁

在while循环里,会自动创造lockguard临时对象,自动用你传进来的锁去进行自动加锁 ,循环体退出循环时,自动被析构 ------ 自动调用解锁

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
#include <mutex>
#include "Mutex.hpp"

int ticket = 1000;

Mutex lock; //全局互斥锁

class thread_data
{
public:
    thread_data(const std::string &n, pthread_mutex_t *p) : name(n), pmutex(p) {}  
public:
    std::string name;
    pthread_mutex_t *pmutex;
};

void *route(void *arg)
{
    thread_data *td =static_cast<thread_data* >(arg);
    while (1)
    {
        //临界区!!!
        {
            LockGuard lockguard(lock); //使用LockGuard自动管理锁的加锁和解锁
            if (ticket > 0)
            {
                usleep(1000);
                printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
                ticket--;
            }
            else
            {
                break;
            }
        }
    }
    return nullptr;
}
int main(void)
{
    pthread_mutex_t mutex; //局部
    pthread_mutex_init(&mutex, nullptr);

    pthread_t t1, t2, t3, t4;
    thread_data td1("thread 1", &mutex);
    thread_data td2("thread 2", &mutex);
    thread_data td3("thread 3", &mutex);
    thread_data td4("thread 4", &mutex);

    pthread_create(&t1, NULL, route, (void *)&td1);
    pthread_create(&t2, NULL, route, (void *)&td2);
    pthread_create(&t3, NULL, route, (void *)&td3);
    pthread_create(&t4, NULL, route, (void *)&td4);

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

    pthread_mutex_destroy(&mutex);
    return 0;
}

像这种 获取资源即初始化 的风格称为 RAII 风格,由 C++ 之父 本贾尼·斯特劳斯特卢普 提出,非常巧妙的运用了 类和对象 的特性,实现半自动化操作

📢写在最后

接下来是线程同步 ------ 冲冲冲🚀

《写给阿嫲的情书》好似一碗白粥,恰到好处 ~

相关推荐
CodeMartain1 小时前
Dify Windows 原生部署(无 Docker、纯本地)
运维·docker·容器
xxx1x1x1 小时前
极客向:DLL/运行库故障的底层逻辑与自动化修复方案
运维·自动化·dll文件·dll·dll修复·dll缺失·dll一键修复
YuanDaima20481 小时前
Linux 进阶运维与 AI 环境实战:进程管理、网络排错与 GPU 监控
linux·运维·服务器·网络·人工智能
lolo大魔王3 小时前
Linux 数据文件处理实战:排序、搜索、压缩、归档一站式详解
linux·运维·服务器
llrraa20103 小时前
配置docker国内镜像源
运维·docker·容器
做人求其滴3 小时前
面试经典 150 题 380 274
c++·算法·面试·职场和发展·力扣
starvapour3 小时前
Ubuntu切换到Fcitx5中文输入法
linux·运维·ubuntu
见叶之秋3 小时前
C++基础入门指南
开发语言·c++
计算机安禾3 小时前
【c++面向对象编程】第42篇:模板特化与偏特化:为特定类型定制实现
开发语言·c++·算法