小此方头像
关注
Re:Linux系统篇(五十三)线程篇 · 六:从“抢票超卖”到“并发安全”:一文搞懂 Linux 线程互斥与互斥锁(Mutex)封面图

Re:Linux系统篇(五十三)线程篇 · 六:从“抢票超卖”到“并发安全”:一文搞懂 Linux 线程互斥与互斥锁(Mutex)

在这里插入图片描述

◆ 博主名称: 小此方-CSDN博客
大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


概要&序論

  Hello大家好,我是此方,本文开始,我们正式进入线程同步互斥章节的讲解,内容与前面的线程篇紧密相连。本文讲解互斥量的概念与接口操作。下一篇文章讲解互斥量的原理与封装。好的,我们开始吧。

一、 线程互斥

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

  信号量与并发编程初步认识第一节

1.2 互斥量mutex

1.2.1多线程并发引发的问题

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

我们用一个模拟抢票的demo来测试这个问题:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

void *route(void *arg)
{
    char *id = (char*)arg;
    while ( 1 ) {
        if ( ticket > 0 ) {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        } else {
            break;
        }
    }
}

int main( void )
{
    pthread_t t1, t2, t3, t4;

    pthread_create(&t1, NULL, route, (void*)"thread 1");
    pthread_create(&t2, NULL, route, (void*)"thread 2");
    pthread_create(&t3, NULL, route, (void*)"thread 3");
    pthread_create(&t4, NULL, route, (void*)"thread 4");

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

在这里插入图片描述

1.2.2引发问题的原因:抢票操作下非原子性

  为什么会抢到负数?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket 操作本身就不是一个原子操作
#取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152  40064b: 8b 05 e3 04 20 00        mov    0x2004e3(%rip),%eax        # 600b34 <ticket>
153  400651: 83 e8 01                  sub    $0x1,%eax
154  400654: 89 05 da 04 20 00        mov    %eax,0x2004da(%rip)        # 600b34 <ticket>

-- 操作并不是原子操作,而是对应三条汇编指令:

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

我们暂时给出一个结论:单条汇编语句是原子的。

1.2.3 整个问题触发的过程

  ticket–的操作分为以下三步,对应三句汇编代码,

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

在这里插入图片描述

场景一:条件穿透与连续扣减

ticket = 1 时,线程 1~4 均通过 if 判断并进入 usleep 挂起。随后被陆续唤醒:

  • 线程 1 挂起于写回前:线程 1 执行 load(读到 1)与 update(算得 0),在 store 前被切走,其上下文保存 %eax = 0,此时内存仍为 1。
  • 线程 2 率先清零:线程 2 完整执行 load(1) → \rightarrow update(0) → \rightarrow store(写回 0),内存变为 0。
  • 线程 3 扣至负数:线程 3 唤醒后,直接执行 load(读到当前内存 0) → \rightarrow update(-1) → \rightarrow store(写回 -1),内存变为 -1
  • 线程 4 继续扣减:线程 4 依次执行 load(读到 -1) → \rightarrow update(-2) → \rightarrow store(写回 -2),内存变为 -2
  • 线程 1 覆写掩盖异常:线程 1 恢复运行,基于保存的旧上下文(%eax = 0)直接执行 store 将 0 写回内存,覆盖了后续线程的计算结果。

场景二:上下文还原导致的数据覆写(导致错乱)

  • 步骤 1(线程 A 载入旧值后被切走):假设内存中 ticket = 100。线程 A 执行 load 指令,把 100 读取到自身的 CPU 寄存器 %eax 中。准备执行 update 时时间片耗尽,线程 A 被挂起,系统将其上下文(%eax = 100)保存起来。
  • 步骤 2(线程 B 持续扣减):线程 B 被调度运行,连续执行了多次 ticket– 操作,将内存中的 ticket 从 100 一路扣减修改到了 10。
  • 步骤 3(线程 A 恢复上下文覆写内存):线程 A 再次被调度运行,系统恢复其上下文,CPU 寄存器 %eax 重新被还原为 100。线程 A 从被打断的 update 处继续执行(100 - 1 = 99),随后执行 store 将 99 写回内存 ticket
  • 结果:线程 B 辛苦扣减的结果(变为 10)被瞬间抹去,内存中的 ticket 倒退回 99,造成严重的并发数据覆盖问题。

1.2.4如何解决问题:引出互斥锁

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

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

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

在这里插入图片描述
在这里插入图片描述

1.3互斥量的接口

加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码

1.3.1 互斥量的初始化与销毁

  在多线程编程中,使用互斥锁需要包含头文件 pthread.h,互斥量的类型为 pthread_mutex_t。初始化互斥量有两种主要方式:

  • 静态分配:使用宏 PTHREAD_MUTEX_INITIALIZER 静态初始化全局或静态互斥量。例如 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;。这种静态初始化的互斥量不需要手动释放,当程序运行结束时会自动释放。
  • 动态分配:通过调用函数 pthread_mutex_init 进行动态初始化。
    • 函数声明:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
    • 参数 mutex:指向需要被初始化的互斥量对象的指针。
    • 参数 attr:互斥量的属性设置,通常传入 NULL 使用默认属性。

  销毁互斥量需要调用 pthread_mutex_destroy 函数:

  • 函数声明:int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 销毁注意事项
    • 使用 PTHREAD_MUTEX_INITIALIZER 静态初始化的互斥量不需要进行销毁。
    • 切勿销毁一个处于已加锁状态的互斥量。
    • 已经被销毁的互斥量,必须确保后续不会有任何线程再次尝试对其加锁。

1.3.2 互斥量的加锁与解锁

  互斥量的加锁与解锁包含以下核心接口:

  • 阻塞加锁int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 非阻塞加锁int pthread_mutex_trylock(pthread_mutex_t *mutex);
  • 解锁操作int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 返回值:函数执行成功时返回 0,失败时返回错误号。
1.3.2.1 加锁时的执行流状态

  在调用 pthread_mutex_lock 申请锁时,执行流可能面临以下情况:

  • 获取成功:若互斥量当前处于未锁状态,该函数会将互斥量锁定并返回成功。持锁成功的线程可以继续向后运行,访问临界区代码和临界资源。
  • 获取失败与阻塞:发起函数调用时,若其他线程已经锁定该互斥量,或者有多个线程同时申请互斥量且当前线程未竞争到锁,pthread_mutex_lock 调用会导致当前线程陷入阻塞(执行流被挂起),直到互斥量被释放解锁。

1.3.3 互斥锁的本质与原子性保障

  • 锁本身是临界资源:竞争申请锁时,所有多线程都必须能够先看到锁,因此锁本身就是一种临界资源。
  • 加解锁的原子性:申请锁的过程必须是原子的。无论是 pthread_mutex_lockpthread_mutex_trylock 还是 pthread_mutex_unlock,其内部操作均为原子操作。
  • 锁的核心能力:锁提供的能力的本质,是把执行临界区代码由并行转换为串行。在持锁线程执行期间,其操作不会被打扰,这也是一种变相的原子性表现。

1.3.4 进程间互斥锁的实现与应用

  互斥锁默认的作用域为进程内部(PTHREAD_PROCESS_PRIVATE)。若结合共享内存(shm),并配合进程间共享属性,即可将互斥锁扩展至多进程间的同步与互斥。

1.3.4.1 实现理论与核心要点
  • 首地址挂载锁结构:将共享内存的首地址通过强制类型转换(如 (pthread_mutex_t *)shm)映射为互斥锁对象,使所有映射该共享内存的进程均能访问同一把锁。
  • 修改共享属性:必须在初始化锁前,通过 pthread_mutexattr_setpshared 将属性设置为 PTHREAD_PROCESS_SHARED
  • 避免静态初始化:共享内存中的锁只能通过 pthread_mutex_init 动态初始化,不可使用 PTHREAD_MUTEX_INITIALIZER
1.3.4.2 实践代码示例
#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>
#include <pthread.h>

struct SharedData {
    pthread_mutex_t mutex; // 挂载在共享内存头部的互斥锁
    int data;              // 进程间共享的临界资源
};

// 1. 初始化进程间互斥锁(由创建共享内存的进程执行)
void init_process_mutex(struct SharedData *shm) {
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED); // 设置进程间共享
    pthread_mutex_init(&(shm->mutex), &attr);                    // 动态初始化
    pthread_mutexattr_destroy(&attr);
}

// 2. 跨进程加锁访问示例
void access_shared_resource(struct SharedData *shm) {
    pthread_mutex_lock(&(shm->mutex));   // 强转头部地址获取锁,阻塞等待
    
    // 临界区操作
    shm->data++;
    
    pthread_mutex_unlock(&(shm->mutex)); // 解锁
}

1.4添加互斥锁优化后的抢票代码

在这里插入图片描述

#include <iostream>
#include <pthread.h>
#include <string>
#include <vector>
#include <unistd.h>
int ticket = 10000 ;
pthread_mutex_t LOCK = PTHREAD_MUTEX_INITIALIZER ;
void* BuyTicket(void* args){
    std::string* name = (std::string*)args ;
    std::cout<<"I am The Child Thread["<< *name <<"]"
    <<"My ThreadId is" <<pthread_self()<<std::endl;
    while(true){
        pthread_mutex_lock(&LOCK);
        if(ticket > 0){
            usleep(1);
            ticket--;
            std::cout<<"进程["<<*name<<"]抢到一张票"<<"ticket="<<ticket<<std::endl;
            pthread_mutex_unlock(&LOCK);
            
        }
        else {
            pthread_mutex_unlock(&LOCK);
            break;
        }
    } 
    return nullptr ;
} 
int main(){
    std::vector<pthread_t> ptd ;
    for(int i = 0 ; i < 5 ; i ++){
        pthread_t tid = 0 ;
        std::string* name = new std::string("Thread" + std::to_string(i));
        pthread_create(&tid , NULL,BuyTicket ,name);
        ptd.push_back(tid);
    }
    for(int i = 0 ; i < 5 ; i++){
        pthread_join(ptd[i],nullptr);
    }
}

在这里插入图片描述

1.5 关于互斥锁的思考

1.5.1 关乎锁机制的两个核心问题

  • 问题一:如果有线程不遵守规则,不加锁就直接访问临界资源会怎样?

    • 解答:这属于严重的程序 Bug。互斥锁是一种软约束与编程约定,必须保证所有访问该临界资源的线程都严格遵守“先申请锁 → \rightarrow 访问临界区 → \rightarrow 释放锁”的规范。若有线程不加锁直接访问临界资源,锁的保护机制将彻底失效。
  • 问题二:加锁之后,在临界区内部允许线程切换吗?切换了会怎样?

    • 解答完全允许切换
    • 影响与原理:即便持锁线程在临界区内被切走,也不会带来安全问题。因为该线程只是暂时失去了 CPU 执行权,但它并未释放锁(属于持有锁被切换)。在此期间,其他线程被调度运行并尝试申请锁时,均会因锁被占用而陷入阻塞。所有线程都必须等待该持锁线程重新被调度、执行完代码并释放锁后,才能重新展开锁的竞争。

1.5.2 一个故事理解临界区的原子性:超级自习室比喻

  • 自习室与钥匙:把临界资源比作一间只能容纳一人的“超级自习室”,把互斥锁比作自习室门口唯一的“钥匙”。只有一个线程能拿到钥匙并进入自习室。
  • 门外的等待者:自习室门外站着许多等待自习的人(其他竞争锁的线程)。
  • 站在门外看门内:站在门外的人,无法干预你在自习室里的自习过程。即使你在自习途中停下来休息(持有锁被系统切换),门外的人依然进不去,因为钥匙还在你身上。
  • 原子性的本质:对于门外排队的人来说,你的自习过程只有两种有意义的状态——要么你还没进去,要么你已经自习完毕并带钥匙出来了。这种“要么不做,要做就做完”的整体验象,对于门外的人而言就是原子性

在这里插入图片描述

好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Z2314246476/article/details/163534229

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--