【Linux探索学习】第三十弹——线程互斥与同步(上):深入理解线程保证安全的机制

Linux学习笔记:

https://blog.csdn.net/2301_80220607/category_12805278.html?spm=1001.2014.3001.5482

前言:

在上篇我们已经学习了关于线程的大部分知识,包括线程概念和线程控制等内容,今天我们来学习一下使用线程要做到的很重要的一步,那就是要保证线程的同步与互斥,从而确保线程安全问题,我们将通过一些生活中的例子和代码示例来理解如何做到线程的互斥与同步

目录

前提知识

线程的互斥

什么是线程的互斥

为什么需要线程的互斥

互斥的实现方法:互斥量

[1. 互斥量的概念](#1. 互斥量的概念)

[2. 互斥量的接口函数](#2. 互斥量的接口函数)

初始化与销毁

加锁与解锁

尝试加锁

[3. 互斥量的使用](#3. 互斥量的使用)

互斥锁实现互斥的原理

互斥量的使用场景

保护共享资源

避免竞态条件

互斥量的注意事项

死锁问题

性能开销

总结


前提知识

在深入探讨线程的互斥与同步之前,我们需要了解一些基本概念:

  • 并发与并行:并发是指多个任务在同一时间段内交替执行,而并行是指多个任务在同一时刻同时执行。

  • 临界区:临界区是指访问共享资源的代码段,这些资源在同一时刻只能被一个线程访问。

  • 竞态条件:当多个线程同时访问共享资源时,如果对资源的访问顺序不确定,可能会导致程序的行为不可预测,这种情况称为竞态条件。

线程的互斥

什么是线程的互斥

互斥是指在同一时刻,只允许一个线程访问共享资源。互斥机制确保了一个线程在访问共享资源时,其他线程不能同时访问该资源,从而避免了竞态条件的发生。

为什么需要线程的互斥

在多线程编程中,多个线程可能会同时访问共享资源。如果没有互斥机制,可能会导致以下问题:

  1. 数据不一致:当多个线程同时修改共享数据时,可能会导致数据的不一致。例如,两个线程同时对一个变量进行自增操作,可能会导致最终结果不正确。

  2. 竞态条件:竞态条件是指程序的输出依赖于线程的执行顺序,这会导致程序的行为不可预测。

下面我们通过一个春节购票系统来看一下会有什么问题:

我们定义一个全局变量count=1000表示有1000张票,我们再创建四个新线程,这四个新线程执行抢票工作,主线程等待回收它们,抢票的逻辑具体分为以下三步:

1、先检查是否还有余票。if(count>0)

2、如果有余票就让count--,同时子线程返回抢票的那一步

3、如果没有余票就退出

cpp 复制代码
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;

int count=1000;

void *GetTickets(void *args)
{
    int thread_id = (int)(intptr_t)args; // 获取线程ID
    while(1)
    {
        if(count>0)
        {
            usleep(10000);   //模拟购票准备工作所需时间
            printf("[pthread %d] get a picket, the picket number: %d\n",thread_id,count);
            --count;
        }
        else
        {
            //如果没票,直接退出
            break;
        }
    }
    return nullptr;
}
int main()
{
    //创建四个新线程
    pthread_t tids[4];
    for(int i=0;i<4;i++)
    {
        pthread_create(&tids[i],nullptr,GetTickets,(void*)(intptr_t)(i+1));
    }
    //阻塞等待回收线程
    for(int i=0;i<4;i++)
    {
        pthread_join(tids[i],nullptr);
    }
    return 0;
}

执行结果(只截取了最后几行):

我们发现出现了错误,按照逻辑票应该是一张一张减少的,而且最终票数到0之后就不能再购买了,但是这里多购买了三张,那么原因是什么呢?

该错误是由以下两个原因组成的:

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程。
  • usleep 是个模拟漫长业务的过程。在这个漫长的业务过程中,可能有其他线程会进入该代码段。

简单点来说就是在一个线程执行购票过程的时候,其它线程也可能会进入进行购票,因为线程是并发执行的,最终就可能会导致票数已经为0了,但是有些进程已经在票数为0前进入到if判断中了,就可能导致以上问题

根本原因就是--操作并不是原子的(简单来说并不是一次完成的),而是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址
    要解决以上问题,需要做到三点:
  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临 界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到以上三点,本质上就是需要一把锁,在一个线程进入临界区时将临界区的入口锁住不让其它线程进入,Linux提供的这个锁叫做互斥量

互斥的实现方法:互斥量

1. 互斥量的概念

互斥量是一种用于多线程编程的同步机制,用于确保同一时刻只有一个线程可以访问共享资源。互斥量的核心思想是通过加锁(Lock)和解锁(Unlock)操作来控制对共享资源的访问,从而避免多个线程同时修改共享资源导致的数据不一致或竞态条件。

互斥量通常用于保护临界区,即访问共享资源的代码段。当一个线程进入临界区时,它会先加锁;当线程离开临界区时,它会解锁,允许其他线程进入。

2. 互斥量的接口函数

1、互斥量的接口函数头文件都是 #include<pthread.h>

2、返回值都是:成功返回0,失败返回相应的错误码

初始化与销毁
cpp 复制代码
pthread_mutex_init

用于初始化互斥量。

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
  • 参数

    • mutex:指向互斥量的指针。

    • attr:互斥量属性,通常设置为NULL以使用默认属性。

  • 返回值 :成功返回0,失败返回错误码。

  1. PTHREAD_MUTEX_TIMED_NP:这时默认值,也就是普通锁。当一个线程加锁以后,其它申请该锁的线程组成一个资源等待队列,并在解锁后按优先级获得锁。这种解锁策略保证了资源分配的公平性。
  2. PTHREAD_MUTEX_RECURSIVE_NP:嵌套锁,允许同一个线程对同一个锁成功获得多次,并通过多次unlock解锁。如果是不同线程请求,则在加锁线程解锁后重新竞争。
  3. PTHREAD_MUTEX_ERRORCHECK_NP:检错锁,如果同一个线程请求同一个锁,则返回EDEADLK错误,否则与PTHREAD_MUTEX_TIMED_NP类型动作相同。这样就保证当不允许多次加锁时,不会出现最简单情况下的死锁。
  4. PTHREAD_MUTEX_ADAPTIVE_NP:适应锁,动作最简单的锁类型,仅等待解锁后重新竞争。
cpp 复制代码
pthread_mutex_destroy

用于销毁互斥量,释放相关资源。

cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 参数

    • mutex:指向互斥量的指针。
  • 返回值 :成功返回0,失败返回错误码。

加锁与解锁
cpp 复制代码
pthread_mutex_lock

用于加锁互斥量。如果互斥量已被其他线程锁定,当前线程会被阻塞,直到互斥量被解锁。

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 参数

    • mutex:指向互斥量的指针。
  • 返回值 :成功返回0,失败返回错误码。

cpp 复制代码
pthread_mutex_unlock

用于解锁互斥量,允许其他线程加锁。

cpp 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 参数

    • mutex:指向互斥量的指针。
  • 返回值 :成功返回0,失败返回错误码。

尝试加锁
cpp 复制代码
pthread_mutex_trylock

尝试加锁互斥量。如果互斥量已被其他线程锁定,函数立即返回,不会阻塞当前线程。

cpp 复制代码
int pthread_mutex_trylock(pthread_mutex_t *mutex);

参数

  • mutex:指向互斥量的指针。
  • 返回值 :成功返回0,失败返回错误码(如EBUSY表示互斥量已被锁定)。

在锁的初始化上除了上面用函数来初始化外,我们也可以直接初始化:

这种初始化的方法叫做静态初始化,它将锁的声明、定义和初始化全部完成了,而且这样初始化的锁最后也不需要我们手动销毁

3. 互斥量的使用

我们使用一把互斥锁来对我们上面的购票系统进行一下加工:

首先我们先定义一个全局的互斥量,因为多个线程都要使用,定义全局的可以提高效率

主线程对所进行初始化,然后等待子线程都执行完后,回收子线程并且销毁锁

子线程在进入临界区时加锁,出临界区时解锁

cpp 复制代码
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;

int count=1000;
pthread_mutex_t mutex;

void *GetTickets(void *args)
{
    int thread_id = (int)(intptr_t)args; // 获取线程ID
    while(1)
    {
        //进入临界区时加锁
        pthread_mutex_lock(&mutex);
        if(count>0)
        {
            usleep(10000);   //模拟购票准备工作所需时间
            printf("[pthread %d] get a picket, the picket number: %d\n",thread_id,count);
            --count;
            pthread_mutex_unlock(&mutex);    //买完票后退出重新进入购票队列,并把锁释放
        }
        else
        {
            //如果没票,直接退出
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}
int main()
{
    //创建新线程前先把锁初始化了
    pthread_mutex_init(&mutex,nullptr);
    //创建四个新线程
    pthread_t tids[4];
    for(int i=0;i<4;i++)
    {
        pthread_create(&tids[i],nullptr,GetTickets,(void*)(intptr_t)(i+1));
    }
    //阻塞等待回收线程
    for(int i=0;i<4;i++)
    {
        pthread_join(tids[i],nullptr);
    }
    //线程都回收后把锁也销毁了
    pthread_mutex_destroy(&mutex);
    return 0;
}

运行结果:

此时我们就发现我们最终的执行结果没有出现上面的问题了,这就是因为互斥锁保证了ticket--的原子性问题,每一次票数减一后才会有其它线程进入到临界区中

但是我们观察上面的执行结果,我们发现怎么所有的票都被线程2抢走了呢?这是因为最一开始是线程2先拿到锁抢到票,但是之后线程2对锁的竞争力就会远强于其它线程了,因为它将锁刚一释放就马上又获取了,所以我们采取的方法可以是:线程2抢完票之后可以让它短暂睡眠一会儿,这样其它线程就能够来争夺锁了

再次运行:

此时我们就可以发现所有线程都参与到抢票中来了,符合我们的预期结果

互斥锁实现互斥的原理

在了解锁的原理前,首先我们先来看一个小的知识点:

我们都知道寄存器是32位的,但是早期的寄存器实际上是只有16位的,现在的寄存器实际上可以看成两个16位的寄存器组合在一起,看成al和ah两块,al就是低位的那块,ah就是高位的那块

锁的原理图:

  • 第一步:首先将al寄存器清零
  • 第二步:将al寄存器中的内容和mutex的内容做一次交换,这个动作其实就是我们申请锁的动作
  • 第三步:判断,如果锁的数量大于0就会申请成功并退出,如果不大于0就会挂起等待

下面来看一下锁的伪代码对应的各个过程:

在整个加锁的过程中只有一个数值1存在,这个1要么保存在某个线程的私有的寄存器中,要么保存在共享的互斥锁变量mutex里。如果某个线程的寄存器中的值为1,说明该线程已经加锁成功。

锁本身其实也是作为共享资源存在的,那锁本身需要保护吗?

答案是不需要,因为锁在执行各种操作时其实已经是互斥的了

互斥量的使用场景

保护共享资源

互斥量最常见的用途是保护共享资源,例如全局变量、文件、数据库连接等。通过加锁和解锁操作,可以确保同一时刻只有一个线程访问共享资源。

避免竞态条件

互斥量可以避免竞态条件的发生。例如,在多个线程同时修改同一个变量时,使用互斥量可以确保每次修改操作是原子的。


互斥量的注意事项

死锁问题

死锁是指多个线程相互等待对方释放锁,导致程序无法继续执行。常见的死锁场景包括:

  • 线程A锁定互斥量X,然后尝试锁定互斥量Y;

  • 线程B锁定互斥量Y,然后尝试锁定互斥量X。

为了避免死锁,可以遵循以下原则:

  • 按固定顺序加锁;

  • 使用超时互斥量;

  • 避免嵌套加锁。

性能开销

互斥量的加锁和解锁操作会带来一定的性能开销,尤其是在高并发场景下。为了减少开销,可以:

  • 尽量减少临界区的范围;

  • 使用读写锁(std::shared_mutex)替代互斥量;

  • 使用无锁数据结构(Lock-Free Data Structures)。

总结

互斥量是多线程编程中不可或缺的同步机制,用于保护共享资源、避免竞态条件和数据不一致问题。通过加锁和解锁操作,互斥量确保同一时刻只有一个线程访问临界区。

在实际开发中,需要根据具体场景选择合适的互斥量类型,并注意避免死锁和性能开销问题。通过合理使用互斥量,可以编写出高效、可靠的多线程程序。

除了互斥外,同步也是保证线程安全的很重要的概念,鉴于篇幅问题,同步我们放在下一篇进行讲解

本篇笔记:

感谢各位大佬观看,创作不易,还请各位大佬点赞支持!!!

相关推荐
努力的小T1 分钟前
使用 Docker 部署 Apache Spark 集群教程
linux·运维·服务器·docker·容器·spark·云计算
陈无左耳、13 分钟前
HarmonyOS学习第3天: 环境搭建开启鸿蒙开发新世界
学习·华为·harmonyos
Nerd Nirvana20 分钟前
OpenSSL crt & key (生成一套用于TLS双向认证的证书密钥)
linux·ssl·shell·认证·加密·tls·oepnssl
柃歌38 分钟前
【UCB CS 61B SP24】Lecture 7 - Lists 4: Arrays and Lists学习笔记
java·数据结构·笔记·学习·算法
柃歌1 小时前
【UCB CS 61B SP24】Lecture 4 - Lists 2: SLLists学习笔记
java·数据结构·笔记·学习·算法
letisgo51 小时前
记录一次部署PC端网址全过程
linux·阿里云·服务器运维
枫叶落雨2221 小时前
08-Elasticsearch
运维·jenkins
猫猫的小茶馆2 小时前
【网络编程】UDP协议
linux·服务器·网络·网络协议·ubuntu·udp
尚墨11112 小时前
linux 安装启动zookeeper全过程及遇到的坑
linux·zookeeper
鱼嘻2 小时前
Linux自学day23-进程和线程
linux·服务器·c语言·进程和线程