【多线程】线程互斥 {竞态条件,互斥锁的基本用法,pthread_mutex系列函数,互斥锁的原理;死锁;可重入函数和线程安全}

本文主要是介绍【多线程】线程互斥 {竞态条件,互斥锁的基本用法,pthread_mutex系列函数,互斥锁的原理;死锁;可重入函数和线程安全},希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

一、进程线程间通信的相关概念

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

二、互斥锁

2.1 竞态条件

  • 大部分情况,线程使用的数据都是局部变量,变量存储在线程栈空间内。这种情况,变量归属单个线程,其他线程无法访问这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。比如,全局数据、堆空间数据。
  • 多个线程并发的操作共享变量,会带来数据竞争,冲突以及数据不一致等竞态条件问题。

竞态条件:

  • 竞态条件(Race Condition)是指多个线程或进程同时访问共享资源,并且对资源的访问顺序不确定,导致最终结果的正确性依赖于线程执行的具体时序。竞态条件可能导致不可预测的结果,破坏程序的正确性和一致性。
  • 竞态条件通常发生在多个线程或进程同时对共享资源进行读写操作时,其中至少一个是写操作。当多个线程或进程同时读写共享资源时,由于执行顺序的不确定性,可能会导致数据的不一致性、丢失、覆盖等问题。

测试程序:

int tickets = 100; //共有100张票void *ThreadRoutine(void *name)
{while (1){if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;}else{break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}int main()
{srand((unsigned)time(nullptr));pthread_t tid1, tid2, tid3, tid4;pthread_create(&tid1, nullptr, ThreadRoutine, (void *)"child thread 1");pthread_create(&tid2, nullptr, ThreadRoutine, (void *)"child thread 2");pthread_create(&tid3, nullptr, ThreadRoutine, (void *)"child thread 3");pthread_create(&tid4, nullptr, ThreadRoutine, (void *)"child thread 4");pthread_join(tid1, nullptr);pthread_join(tid2, nullptr);pthread_join(tid3, nullptr);pthread_join(tid4, nullptr);return 0;
}

运行结果:

在这里插入图片描述

  1. 同一编号的票被多个线程售出
  2. 某些线程售出了负数编号的票

该程序存在竞态条件问题,即公共变量tickets被多执行流同时访问和修改。

提示:除了多线程进程外,信号处理函数也是异步执行的(多执行流执行),同样存在竞态条件问题。

并发运行问题

例如:tickets > 0--tickets操作并不是原子性操作,而是对应三条汇编指令:

  1. 将数据从内存加载到寄存器(当前线程的上下文中)
  2. 进行逻辑运算或算数运算
  3. 将数据写回内存

在这三条步骤的其中任何一步,该线程都有可能被切换,切换前线程上下文会被保存。其他线程在执行时也对tickets进行了访问和修改。当原线程再次被CPU调度执行时,恢复上下文数据,此时的寄存器与内存就会发生数据不一致的错误。

并行运行问题

多核CPU允许多线程并行(同时)运行。在ThreadRoutine函数中,由于没有对访问tickets的操作进行互斥,可能会导致多个线程同时读取和修改tickets变量,从而产生不可预测的结果。

例如:当多个线程同时执行if (tickets > 0)语句时,可能会出现以下情况:

  • 线程A和线程B同时读取tickets的值为1。
  • 线程A先执行--tickets操作,将tickets的值减为0。
  • 线程B再执行--tickets操作,将tickets的值减为-1。

这样,就会出现某些线程售出了负数编号的票。


2.2 互斥锁的基本用法

为了解决竞态条件问题,可以使用互斥锁(Mutex)来保护对tickets变量的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区(对tickets变量的访问),从而避免数据竞争的发生。

在这里插入图片描述

下面是互斥锁的基本使用方法:

  1. 定义互斥锁变量:在使用互斥锁之前,需要先定义一个互斥锁变量。可以使用pthread_mutex_t类型来声明互斥锁变量,例如:pthread_mutex_t mutex;
  2. 初始化互斥锁:在使用互斥锁之前,需要对互斥锁进行初始化。
    • 静态初始化:在定义互斥锁变量时,使用PTHREAD_MUTEX_INITIALIZER宏进行初始化。
    • 动态初始化:可以使用pthread_mutex_init函数来初始化互斥锁,例如:pthread_mutex_init(&mutex, NULL);。第一个参数是要初始化的互斥锁变量,第二个参数是互斥锁的属性,通常使用NULL表示使用默认属性;
  3. 加锁:在访问共享资源之前,需要先加锁。可以使用pthread_mutex_lock函数来加锁,例如:pthread_mutex_lock(&mutex);。如果互斥锁已经被其他线程锁定,那么当前线程会被阻塞,直到互斥锁被解锁。
  4. 访问共享资源:在互斥锁被锁定的情况下,可以安全地串行访问共享资源。
  5. 解锁:在访问共享资源完成后,需要解锁互斥锁,以便其他线程可以继续访问共享资源。可以使用pthread_mutex_unlock函数来解锁,例如:pthread_mutex_unlock(&mutex);
  6. 销毁互斥锁:
    • 不再需要使用互斥锁时,需要将其销毁。可以使用pthread_mutex_destroy函数来销毁互斥锁,例如:pthread_mutex_destroy(&mutex);
    • 静态初始化的互斥锁在程序结束时会自动被系统回收,无需手动销毁。
    • 不要销毁一个已经加锁的互斥量
    • 对于已经销毁的互斥量,要确保后面不会有线程再尝试加锁

我们将上面的售票程序加入互斥锁:

int tickets = 100; //临界资源
// 定义一个全局的互斥锁变量,并利用宏进行初始化(静态初始化)
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;void *ThreadRoutine(void *name)
{while (1){// 在访问共享资源之前,需要先加锁。pthread_mutex_lock(&mtx);//临界区if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);}else{// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);break;}// 在此处解锁?不行,如果线程执行break,就不会解锁互斥锁。其他线程会被一直阻塞。usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}

运行结果:

在这里插入图片描述

需要注意的几点:

  1. 在访问共享资源完成后,需要解锁互斥锁。否则,其他线程会被一直阻塞。需要特别注意break, goto等跳转语句跳过解锁函数。

  2. 被互斥锁锁定的临界区只能串行执行(互斥访问),虽然保证了多执行流访问临界资源的安全性,但是会在一定程度上降低程序的效率

  3. 尽量保证被互斥锁锁定的代码都是访问临界资源的代码,不要将其他无关的操作也放入临界区中。因为相比并发或并行执行,临界区串行执行的效率较低。

再次改进上面的代码:

#define THREAD_NUM 5
int tickets = 100;//声明一个ThreadData类,使线程入口函数的参数更多样化。
class ThreadData
{
public:string _tname; //线程名pthread_mutex_t *_pmtx; //互斥锁变量的地址ThreadData(const string &tname, pthread_mutex_t *pmtx): _tname(tname),_pmtx(pmtx){};
};void *ThreadRoutine(void *arg)
{ThreadData *td = (ThreadData *)arg;while (1){// 在访问临界资源前进行加锁pthread_mutex_lock(td->_pmtx);if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", td->_tname.c_str(), tickets);--tickets;// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);}else{// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}delete td; // 释放各自的ThreadData结构空间return nullptr;
}int main()
{srand((unsigned)time(nullptr));// 在主线程栈区创建互斥锁变量pthread_mutex_t mtx;// 调用pthread_mutex_init初始化互斥锁(动态初始化)pthread_mutex_init(&mtx, nullptr);// 循环创建子线程pthread_t tid[THREAD_NUM];for (int i = 0; i < THREAD_NUM; ++i){string tmp = "child thread ";tmp += to_string(i + 1);ThreadData *td = new ThreadData(tmp, &mtx);pthread_create(tid + i, nullptr, ThreadRoutine, td); //传入ThreadData对象的指针}// 循环等待子线程for (int i = 0; i < THREAD_NUM; ++i){pthread_join(tid[i], nullptr);}// 在不再需要使用互斥锁时,需要将其销毁。(动态初始化的互斥锁需要进行销毁,而静态初始化不需要)pthread_mutex_destroy(&mtx);return 0;
}

新的问题:

  1. 加锁了之后,线程在执行临界区代码时,是否会被切换,会有问题吗?
    会被切换,但不会有问题!虽然被切换了,但是你是持有锁被切换的, 所以其他抢票线程要执行临界区代码,也必须先申请锁,而它是无法申请成功的。所以,也不会让其他线程进入临界区,就保证了临界区中数据一致性!

  2. 对于访问临界资源的线程而言,临界区代码要么全部执行成功,要么全部不执行,访问临界资源的操作不可被中断(不能同时执行其他线程的临界区代码),这就是原子性的体现。

  3. 要访问临界资源,每一个线程都必须先申请锁,而锁本身就是一种共享资源,那么谁来保证锁的安全呢?

    所以,为了保证锁的安全,申请和释放锁,必须是原子的!


2.3 互斥锁的原理

  1. 在汇编层面,一条汇编语句要么已经执行完,要么就还没有执行,是原子性的。
  2. 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange汇编指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。即使是多处理器平台(并行),访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
  3. CPU的寄存器数据,本质就是当前执行流的上下文。寄存器的存储空间被所有执行流共享,但是寄存器的内容是被每一个执行流私有的。所以在切换线程时,要将当前线程的寄存器数据保存到其PCB中,并恢复下一个线程的寄存器数据。

以下是加锁的核心汇编伪代码:

lock:movb $0, %al // 将数值0,move到al寄存器中xchgb %al, mutex //交换al寄存器与mutex变量(内存)的数据if(al寄存器的内容 > 0){return 0;}else挂起等待;goto lock; //跳转到lock标签,再次申请锁
  1. 我们可以将互斥锁变量mutex理解成一个整形变量,值为1表示互斥锁未被线程持有;值为0,表示互斥锁已经被其他线程锁定。创建互斥锁变量并进行初始化后,其默认值为1。
  2. 由于exchange汇编指令是原子的,所以不管线程如何切换,只有一个线程能够将mutex(内存)中的1值交换到自己的寄存器当中,即该线程的上下文中。而线程上下文是线程的私有数据,实现了公有到私有的转换。同时寄存器当中的0值被交换到了mutex中,其他线程再进行交换也只能交换到0。
  3. 在进行if判断时,交换到1值的线程执行return 0,可以安全地进入临界区访问临界资源;而交换到0值的线程阻塞等待,直到互斥锁被解锁,这些线程才会被唤醒,然后再次尝试申请锁。

以下是解锁的核心汇编伪代码:

unlock:movb $1, mutex //将数值1,move到mutex变量(内存)唤醒等待mutex的线程;return 0;
  1. 当持有锁的线程访问完临界资源后,会将mutex变量重新置为1,即解锁互斥锁。
  2. 同时,应该唤醒等待互斥锁解锁的线程,让他们再次竞争申请锁。

回答之前的问题:

  1. 谁来保证锁的安全呢?

    为了保证锁的安全,申请和释放锁,必须是原子的!在设计加锁时,通过一条原子性的exchange指令,保证了加锁和解锁的原子性。

  2. 加锁了之后,线程在临界区中,是否会切换,会有问题吗?

    线程在临界区中也可能会被切换,但他是持有锁被切换的,所谓持有锁切换是指互斥锁的1值保存在当前线程的上下文,被当前线程私有。而其他线程即使被CPU调度执行,也无法抢占互斥锁,也就无法访问临界区代码。所以不会有任何问题。


三、可重入函数和线程安全

  • 可重入函数:同一个函数被多个执行流同时进入,就叫重入。如果该函数在被重入执行的过程中不会出现任何错误,则被称为可重入函数。反之就是不可重入函数。
  • 线程安全:多个线程并发执行时,在没有锁保护的情况下访问了共享资源(如全局或静态变量,堆区数据等),会出现数据竞争从而导致数据冲突,数据不一致等线程安全问题。

3.1 线程安全的情况

  1. 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  2. 使用互斥锁(Mutex)来保护对共享资源的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区,从而避免数据竞争的发生。
  3. 每个线程对共享资源只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  4. 不调用线程不安全的函数

3.2 可重入函数的情况

  1. 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  2. 使用互斥锁(Mutex)来保护对共享资源的访问。如全局、静态变量或其他共享资源。
  3. 不调用不可重入函数

常见不可重入的情况

  1. 调用了malloc/free函数,因为Linux内核是用全局链表和全局红黑树结构来组织和管理堆空间的。(请看提示)
  2. 调用了标准I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用了全局数据结构。

提示:

  1. 关于Linux内核中的堆区管理,请阅读:【多线程】线程的概念 {Linux内核中的堆区管理;虚拟地址到物理地址的转换,页,页框,页表,MMU内存管理单元;Linux线程概念,轻量级进程;线程共享进程的资源;线程的优缺点;线程的用途}-CSDN博客
  2. 关于多执行流调用不可重入函数插入链表节点,请阅读:【信号】信号处理 {信号处理的时机;内核态和用户态;信号捕捉的原理;信号处理函数:signal, sigaction;可重入函数;volatile关键字;SIGCHLD信号}-CSDN博客

3.3 区别和联系

联系

  1. 函数是可重入的,那就是线程安全的。
  2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。

区别

  1. 可重入函数是线程安全函数的一种。
  2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的。但如果这个重入函数加锁还未释放则会产生死锁,因此是不可重入的。

四、死锁

死锁(Deadlock)是指在并发系统中,两个或多个进程(或线程)因为互相等待对方释放资源而无法继续执行的状态。在死锁状态下,进程无法前进,也无法释放资源,导致系统无法正常运行。

死锁通常发生在多个进程(或线程)同时竞争有限的资源时,每个进程都在等待其他进程释放资源,而自己又无法释放已经占有的资源。

在这里插入图片描述

特殊情况:一个执行流,一把互斥锁也可能导致死锁,即加锁后,不解锁,再次申请锁。

死锁的发生需要满足以下四个条件,也被称为死锁的必要条件:

  1. 互斥条件:一个资源每次只能被一个执行流使用(不加锁自然就不会产生死锁)。

  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。

  3. 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。

  4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,使得每个执行流都在等待下一个执行流所占有的资源。

当这四个条件同时满足时,就可能发生死锁。一旦发生死锁,系统将无法自动解除死锁状态,需要通过人工干预来解决。

为了避免死锁的发生,可以采取以下策略:

  1. 破坏互斥条件:例如,允许多个进程(或线程)同时访问某些资源。

  2. 破坏请求与保持条件:例如,要求进程(或线程)在执行之前一次性获取所有需要的资源,否则在等待资源时释放已经占有的资源。

  3. 破坏不可剥夺条件:例如,允许系统强制剥夺某些进程(或线程)的资源。

  4. 破坏循环等待条件:例如,通过对资源进行排序,按照固定的顺序申请资源,避免交叉申请,循环等待。(T1,T2都先申请R1再申请R2)

  5. 其他方法:精简临界区代码,缩短持有锁的时间;合并临界区,资源一次性分配(一把锁);

死锁是并发系统中的一个重要问题,对系统的性能和可靠性有很大影响。因此,在设计和实现并发系统时,需要合理地分配和管理资源,以避免死锁的发生。

这篇关于【多线程】线程互斥 {竞态条件,互斥锁的基本用法,pthread_mutex系列函数,互斥锁的原理;死锁;可重入函数和线程安全}的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/309250

相关文章

Java编译生成多个.class文件的原理和作用

《Java编译生成多个.class文件的原理和作用》作为一名经验丰富的开发者,在Java项目中执行编译后,可能会发现一个.java源文件有时会产生多个.class文件,从技术实现层面详细剖析这一现象... 目录一、内部类机制与.class文件生成成员内部类(常规内部类)局部内部类(方法内部类)匿名内部类二、

揭秘Python Socket网络编程的7种硬核用法

《揭秘PythonSocket网络编程的7种硬核用法》Socket不仅能做聊天室,还能干一大堆硬核操作,这篇文章就带大家看看Python网络编程的7种超实用玩法,感兴趣的小伙伴可以跟随小编一起... 目录1.端口扫描器:探测开放端口2.简易 HTTP 服务器:10 秒搭个网页3.局域网游戏:多人联机对战4.

Kotlin 作用域函数apply、let、run、with、also使用指南

《Kotlin作用域函数apply、let、run、with、also使用指南》在Kotlin开发中,作用域函数(ScopeFunctions)是一组能让代码更简洁、更函数式的高阶函数,本文将... 目录一、引言:为什么需要作用域函数?二、作用域函China编程数详解1. apply:对象配置的 “流式构建器”最

用js控制视频播放进度基本示例代码

《用js控制视频播放进度基本示例代码》写前端的时候,很多的时候是需要支持要网页视频播放的功能,下面这篇文章主要给大家介绍了关于用js控制视频播放进度的相关资料,文中通过代码介绍的非常详细,需要的朋友可... 目录前言html部分:JavaScript部分:注意:总结前言在javascript中控制视频播放

MyBatis 动态 SQL 优化之标签的实战与技巧(常见用法)

《MyBatis动态SQL优化之标签的实战与技巧(常见用法)》本文通过详细的示例和实际应用场景,介绍了如何有效利用这些标签来优化MyBatis配置,提升开发效率,确保SQL的高效执行和安全性,感... 目录动态SQL详解一、动态SQL的核心概念1.1 什么是动态SQL?1.2 动态SQL的优点1.3 动态S

SpringIntegration消息路由之Router的条件路由与过滤功能

《SpringIntegration消息路由之Router的条件路由与过滤功能》本文详细介绍了Router的基础概念、条件路由实现、基于消息头的路由、动态路由与路由表、消息过滤与选择性路由以及错误处理... 目录引言一、Router基础概念二、条件路由实现三、基于消息头的路由四、动态路由与路由表五、消息过滤

java之Objects.nonNull用法代码解读

《java之Objects.nonNull用法代码解读》:本文主要介绍java之Objects.nonNull用法代码,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐... 目录Java之Objects.nonwww.chinasem.cnNull用法代码Objects.nonN

Python中随机休眠技术原理与应用详解

《Python中随机休眠技术原理与应用详解》在编程中,让程序暂停执行特定时间是常见需求,当需要引入不确定性时,随机休眠就成为关键技巧,下面我们就来看看Python中随机休眠技术的具体实现与应用吧... 目录引言一、实现原理与基础方法1.1 核心函数解析1.2 基础实现模板1.3 整数版实现二、典型应用场景2

Java的IO模型、Netty原理解析

《Java的IO模型、Netty原理解析》Java的I/O是以流的方式进行数据输入输出的,Java的类库涉及很多领域的IO内容:标准的输入输出,文件的操作、网络上的数据传输流、字符串流、对象流等,这篇... 目录1.什么是IO2.同步与异步、阻塞与非阻塞3.三种IO模型BIO(blocking I/O)NI

Spring Boot3虚拟线程的使用步骤详解

《SpringBoot3虚拟线程的使用步骤详解》虚拟线程是Java19中引入的一个新特性,旨在通过简化线程管理来提升应用程序的并发性能,:本文主要介绍SpringBoot3虚拟线程的使用步骤,... 目录问题根源分析解决方案验证验证实验实验1:未启用keep-alive实验2:启用keep-alive扩展建