线程(三)【线程互斥(下)】

目录

  • 4. 互斥锁
    • 4.1 解决数据不一致问题
  • 5. 锁的原理
    • 5.1 加锁
    • 5.2 解锁
  • 6. 可重入 vs 线程安全

4. 互斥锁

NAMEpthread_mutex_destroy, pthread_mutex_init - destroy and initialize a mutex	// 创建、释放锁SYNOPSIS#include <pthread.h>// pthread_mutex_t: 线程库提供的一种数据类型int pthread_mutex_destroy(pthread_mutex_t *mutex);	int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;	// 定义并初始化全局锁,即可不需要显式的释放锁

4.1 解决数据不一致问题

锁的作用,我们现在可以先感性的理解一下:就好比我们去住酒店,入住之前需要先到前台申请房卡,这个房卡就是锁,我们入住后,把房卡挂墙上。后面我们退房时,再拿着房卡到酒店办理退住,这就是释放锁。

而在我们申请到这张房卡后,保证了只有我们这一个人能入住,其它人进不来你的房间。而其它人想要申请你这房卡,只能等你退房了,即多线程并发访问某种资源时,其它线程首先得等当前线程释放锁过后才能申请锁,并且保证其它线程申请的是同一把锁,假设酒店有多张该房间的房卡,但是给客户的永远是同一张房卡,这样才能保证只有一个执行流。

NAMEpthread_mutex_lock,  pthread_mutex_trylock, pthread_mutex_unlock - lock and unlock a mutex	SYNOPSIS#include <pthread.h>int pthread_mutex_lock(pthread_mutex_t *mutex);	// 加锁int pthread_mutex_trylock(pthread_mutex_t *mutex);int pthread_mutex_unlock(pthread_mutex_t *mutex);	  //解锁

一个 tickets 全局变量,也称共享变量,所有线程共享式的访问这个全局变量,如果不想在并发访问该共享变量时出现问题,就需要在访问 tickets 的所有地方进行加锁。tickets 被加锁之后,就只能运行一个执行流同时访问,我们把共享的、任何时刻只允许一个执行流访问的资源,称为临界资源。而在代码中,并不是每一处都在访问临界资源,我们把访问临界资源的那一块代码,称为临界区。

再者,加锁的本质其实是用时间来换取安全, 加锁后,共享资源就只能被多线程串行访问,因此会降低多线程的并发度,那么访问效率肯定就没那么高了。所以,加锁的表现就在于线程对于临界区的代码是串行执行的!加锁原则为尽可能的保证临界区代码越少越好。

int tickets = 1000;class threadData
{
public:threadData(int number, pthread_mutex_t* mutex){threadname = "thread[" + to_string(number) + "]";lock = mutex;}string threadname;pthread_mutex_t* lock;
};void* getTicket(void* args)
{while(true){pthread_mutex_lock(td->lock);   // 申请锁成功,才能往后执行,不成功则一直阻塞。if(tickets > 0){usleep(1000);printf("who = %s, get a tickets = %d\n", name, tickets);tickets--;pthread_mutex_unlock(td->lock);}else{pthread_mutex_unlock(td->lock);break;}}...
}
int main()
{pthread_mutex_t lock;   // 定义锁pthread_mutex_init(&lock, nullptr);   //初始化锁...pthread_mutex_destroy(&lock);return 0;
}

在这里插入图片描述

对共享资源加锁之后,我们确实解决了多线程并发访问一个资源造成的数据不一致的问题,但是我们看到了另一个想象,每个线程访问到的资源占比不太均衡,似乎都是个别线程在访问的。

因此我们在每次释放锁过后,加上一点点休眠,来让多执行流访问到的资源尽可能均衡一些。

void* getTicket(void* args)
{while(true){...usleep(13);	}...
}

在这里插入图片描述

现在我们就看到了,基本每一个线程都能较为均衡的访问到同一份资源了。

  • 不加 sleep,导致其它线程无法访问到该资源,这种现象正常吗?

    如果在每个线程释放锁后,不加以等待,那么这个线程即可立即重新申请到锁,当前线程重复申请到锁后,其它线程申请不到,只能阻塞在 pthread_mutex_lock 处,所以这个现象是正常的。

  • 是什么导致的这种现象?

    这主要是因为线程对于锁这种资源的竞争能力可能是不同的。我们说过,如果线程申请不到锁资源,那么就会一直阻塞在申请处,其线程的 PCB 就会从运行态转而变为非运行态,处于阻塞状态。在当前进程释放锁之后,其它线程要申请锁,需要先从非运行态 --> 运行态,即唤醒线程,那么这个过程肯定是要比当前释放锁的这个线程重新申请一次锁要费力的,即线程唤醒再到申请这个过程,要比释放后再次申请要慢!所以同一个线程释放了锁,马上又申请到了,其它线程就再次陷入阻塞,循环往复,就导致了线程访问到的资源不均衡的问题。

    而当我们加了 usleep(13);,本质就是在模拟抢票之后的后续动作,而不是立马进入下一轮抢票,加上了 usleep(13); 才是现实中较为常见的场景。所以一个线程释放锁之后,因为处理后续动作,因此是无法做到立即再次申请锁的,那么其它线程就能够申请到锁资源,然后访问临界资源。

接下来,我们再次举个例子帮助理解。

一个线程释放锁后能够重新立刻申请到锁,就好比,你在一个非常火热的度假岛,你的那间房间是全岛观光最佳的海景房,因此一大批人争的头破血流。第二天你刚退了房,但是转头想到,这个房间住的这么舒服,算了,我还是继续续住吧,于是前台就在你面前,你就立刻跟前台再次续住,申请到了房卡,其它人抱着手机刷新着该房型的状态,还没来得及下单,你就已经申请到了房卡。

这就是纯互斥环境,如果锁资源分配的不够合理,就容易导致其他线程的饥饿问题(即其它线程申请不到锁资源导致无法访问临界资源)!但并不是只要有互斥,就会出现饥饿问题。

由于这种现象持续发生,该酒店的前台投诉电话就被打爆了。于是,上层领导就做出了改革,规定这间房型,同一个人不能连续申请!这就是当一个线程申请到锁资源,释放锁之后,就要重新进入锁资源的等待队列中排队申请,不能连续重复申请。但是当前线程把锁释放后,其它100个线程全部被唤醒,最终只有一个线程能够获得锁资源,等于其它 99 个线程的唤醒是无效行为,那这样也不合理。

即,为了防止上一个人退房后,前台涌入一大批人在抢购这间房型,于是再次规定,该房型的购买需要到线上进行挂号排队!先排队的人先入住。

让所有的线程(人) 获取锁 (房卡),按照一定的顺序性获取资源,我们把这种机制称为同步!

在多线程并发访问一个共享资源时,我们要保证每个线程申请的是同一个锁,那么每个线程在申请锁这个资源的时候,锁本身也是一种共享资源!而锁是为了保护其它共享资源的安全,但是在保证其它资源安全的前提下,总得先保证自己是安全的吧(自己也是共享资源),所以,申请锁和释放锁本身就被库设计成为了原子性操作的(原子性:即申请和释放锁这个行为要么做、要么不做,不会出现做一半的情况)!

  • 线程在执行临界区的时候,可以被切换吗??

    当然可以。 tickets-- 这一条语句在执行时都也可能发生切换,临界区不就是一块代码吗,一条代码都可能发现切换,执行一块代码凭什么不能切换。

    如果线程申请到锁资源后,时间片到了发生了线程切换,线程切换时,是持有锁被切走的,等到线程重新被调度运行时,锁资源依旧属于该线程。该线程被切换期间,其它线程也无法进入临界区访问临界资源!因为它们没有锁资源,锁资源就一份!

    这里可以理解为,办理了酒店入住,你出去吃个早餐,出去喝个奶茶、喝个咖啡,你肯定是把房卡带在自己身上离开的酒店,即便你离开了自己的房间,也无人能进入。

对于其他线程来讲,它们只关心两个事:某个线程申请到锁了,这个线程释放锁了。其它线程不关心当前申请到锁的这个线程,执行临界区访问临界资源的一切过程!因为关心了,它也无法访问临界资源。

所以通过对临界资源进行加锁,可以保证当前线程在访问临界资源时,对于其它线程而言,这个访问的过程是原子性的。即,一个线程要么没有申请锁,要么释放锁了。

截止目前,我们可以思考一下,我们为什么要加锁呢?因为多线程并发会有数据不一致的问题?那么为什么要有多线程呢?因为我们想提高代码的并发度,又不想创建进程这种体量的资源。那么我们反推回去,即,为了提高并发度,我们创建了多线程,创建了多线程,就导致了访问共享资源时的数据不一致问题,为了解决这个问题,我们引入了互斥锁的概念,因此又引入了临界资源、临界区以及原子性的概念,并且可能存在线程饥饿的问题等等。这个世界就是这样子的,为了解决问题,引入新的解决方案的同时往往伴随着另一个问题的出现。


5. 锁的原理

我们已经知道 tickets-- 在底层会被转换为 3 条汇编,因此它并不是原子的。所以我们可以理解为一条汇编就是原子的。但并不是只有一条汇编可以被称为原子的,诸如我们上面所说,一个线程申请到锁后访问的临界区,对于其它线程而言,这段临界区就是原子的。

为了实现互斥锁操作,大多数体系结构(即 CPU 架构) 都提供了 swap 或 exchange 指令(CPU 内部是内置了一些基础指令的,即芯片指令集,比如数据从内存加载到 CPU,CPU 内的数据写回内存等操作,而上层编写的更加复杂的代码,最终都经过编译转换为芯片里的指令集,CPU 就能够识别并操作),该指令的作用是把寄存器和内存单元的数据相交换,而由于只有一条指令,保证了原子性,所以即使是多处理器平台(主板只有一块,因此访存总线也只有一套),访问内存时由仲裁器决定由哪个 CPU 去访问,因此访问内存总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期,所以在访存上依旧是串行的,只不过是可以多处理器并发运算数据。

// 加锁、解锁的伪代码:
lock:	movb $0, %alxchgb %al, mutexif (al 寄存器的内容 > 0) return 0;else 挂起等待;goto lock;
unlock :movb $l, mutex唤醒等待 Mutex 的线程;return 0;

其中的 lock 加锁就等效于 pthread_mutex_lock,unlock 解锁等效于 pthread_mutex_unlock。

5.1 加锁

我们可以把 “锁” 理解为内存中的一个对象 int mutex,其内容默认为 1。第一条语句 movb $0, %al 即把 al 寄存器中的内容清空为 0,xchgb %al, mutex 把 al 寄存器的内容与内存中定义的 mutex 变量的内容做交换,这条语句就是申请锁的动作。接着判断 al 寄存器的值,即申请锁成功 or 失败。

当 thread[1] 被调度运行,执行第一条语句 movb $0, %al 把寄存器的内容置 0 后,thread[1] 就发生了线程切换,与此同时,thread[1] 需要把自己的硬件上下文数据全部保存带走,即 al 寄存器中置 0 了,那么 thread[1] 就把 0 这个数据带走,以及 EIP 寄存器下一条指令的地址。

接下来,thread[2] 被调度运行,也来申请锁,那么 thread[2] 也把寄存器的内容置 0(这个动作本质是把自己的硬件上下文对应的数据置 0),然后继续执行 xchgb %al, mutex,把 al 和 mutex 的内容做交互。接着,thread[2] 正准备执行 if 做判断时,thread[2] 也发生了切换,因此它也带走自己的上下文数据,即 al 寄存器的内容 1 带走。

thread[2] 被切换后,thread[1] 重新被调度,然后恢复自己的上下文数据,即把上次带走的 al 寄存器的内容 0 重新恢复回 al 寄存器中,然后与内存中的 mutex 的内容做交换,接着做 if 判断时条件为假,即 thread[1] 申请锁失败!所以 thread[1] 被阻塞。

thread[1] 申请锁失败被阻塞后, thread[2] 回来了,同样的先把自己的上下文数据恢复到寄存器中,然后接着上次没完成的 if 语句判断,最终条件为真,thread[2] 成功申请到锁,然后返回。

所以,整个过程,最重要的就是这条交换语句 xchgb %al, mutex,把内存中的数据,交换到 CPU 的寄出器中。而这个锁 mutex 本质也是一个共享资源,每个线程都能够读取到这个锁,而交换的本质一定是在多线程中,一定存在一个线程,把 锁 mutex(1) 交换到自己的硬件上下文中(属于线程私有的),然后把 0(al 寄存器的内容) 交换到内存中,而这个线程就是执行了 exchange 指令的那个线程。换言之,交换的本质就是把一个共享的锁,让一个线程以一条汇编的方式交换到自己的上下文中,成为自己私有的资源(因为内存中只有一个 mutex 锁,即只有一个 1),而就称为一个线程持有锁。

5.2 解锁

解锁相对就很简单了,movb $l, mutex 只是把 1 重新写回到内存中的 mutex,因为这是一条汇编,因此解锁也是原子的。

解锁的设计上,允许不是加锁的线程来做解锁这个动作。这样的好处是,如果申请锁的线程发生异常退出了,退出就退出呗,其它线程不关心,但是它退出时没有解锁啊,所以设计上允许其它线程解锁。


6. 可重入 vs 线程安全

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果,就是线程安全的。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题(一个线程中访问野指针,导致线程异常,最后整个进程崩溃了,这种也称为线程安全问题)。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

从理解上,好像线程安全跟重入非常接近,都是描述的多执行流情况下并发运行时会不会出现问题。但需要注意的是,线程安全与重入依旧是两个不同的概念,重入描述的是一个函数的特点,用于描述函数的,而线程安全描述的是线程并发的问题,两个概念的侧重对象不同。

如果一个函数是不可重入的,在多线程调用时,它可能是线程不安全的(因为线程并发出问题是概率性问题,不能保证百分百);如果一个函数是可重入的,那它一定是线程安全的。

常见的线程不安全的情况:

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数(比如一边调用函数,一边统计该函数的调用次数)
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见的线程安全的情况:

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况

  • 调用了malloc / free函数,因为 malloc 函数是用全局链表来管理堆的
  • 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见可重入的情况:

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

可重入与线程安全联系:

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别:

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

如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!

感谢各位观看!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/477459.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

如何使用AWS Lambda构建一个云端工具(超详细)

首发地址&#xff08;欢迎大家访问&#xff09;&#xff1a;如何使用AWS Lambda构建一个云端工具&#xff08;超详细&#xff09; 1 前言 1.1 无服务器架构 无服务器架构&#xff08;Serverless Computing&#xff09;是一种云计算服务模型&#xff0c;它允许开发者构建和运行…

网络爬虫总结与未来方向

通过深入学习和实际操作&#xff0c;网络爬虫技术从基础到进阶得以系统掌握。本节将全面总结关键内容&#xff0c;并结合前沿技术趋势与最新资料&#xff0c;为开发者提供实用性强的深度思考和方案建议。 1. 网络爬虫技术发展趋势 1.1 趋势一&#xff1a;高性能分布式爬虫 随…

实验十三 生态安全评价

1 背景及目的 生态安全是生态系统完整性和健康性的整体反映&#xff0c;完整健康的生态系统具有调节气候净化污染、涵养水源、保持水土、防风固沙、减轻灾害、保护生物多样性等功能。维护生态安全对于人类生产、生活、健康及可持续发展至关重要。随着城市化进程的不断推进&…

archlinux安装waydroid

目录 参考资料 注意 第一步切换wayland 第二步安装binder核心模组 注意 开始安装 AUR安裝Waydroid 启动waydroid 设置网络&#xff08;正常的可以不看&#xff09; 注册谷歌设备 安装Arm转译器 重启即可 其他 参考资料 https://ivonblog.com/posts/archlinux-way…

鸿蒙NEXT开发案例:随机数生成

【引言】 本项目是一个简单的随机数生成器应用&#xff0c;用户可以通过设置随机数的范围和个数&#xff0c;并选择是否允许生成重复的随机数&#xff0c;来生成所需的随机数列表。生成的结果可以通过点击“复制”按钮复制到剪贴板。 【环境准备】 • 操作系统&#xff1a;W…

【Android】Service使用方法:本地服务 / 可通信服务 / 前台服务 / 远程服务(AIDL)

1 本地Service 这是最普通、最常用的后台服务Service。 1.1 使用步骤 步骤1&#xff1a;新建子类继承Service类&#xff1a;需重写父类的onCreate()、onStartCommand()、onDestroy()和onBind()方法步骤2&#xff1a;构建用于启动Service的Intent对象步骤3&#xff1a;调用st…

【LeetCode热题100】队列+宽搜

这篇博客是关于队列宽搜的几道题&#xff0c;主要包括N叉树的层序遍历、二叉树的锯齿形层序遍历、二叉树最大宽度、在每个数行中找最大值。 class Solution { public:vector<vector<int>> levelOrder(Node* root) {vector<vector<int>> ret;if(!root) …

双因子认证:统一运维平台安全管理策略

01双因子认证概述 双因子认证&#xff08;Two-Factor Authentication&#xff0c;简称2FA&#xff09;是一种身份验证机制&#xff0c;它要求用户提供两种不同类型的证据来证明自己的身份。这通常包括用户所知道的&#xff08;如密码&#xff09;、用户所拥有的&#xff08;如…

Apple Vision Pro开发002-新建项目配置

一、新建项目 可以选择默认的&#xff0c;也可以选择Universal 3D 二、切换打包平台 注意选择Target SDK为Devices SDk&#xff0c;这种适配打包到真机调试 三、升级新的Input系统 打开ProjectSettings&#xff0c;替换完毕之后引擎会重启 四、导入PolySpatial 修改上图红…

瑞佑液晶控制芯片RA6807系列介绍 (三)软件代码详解 Part.10(让PNG图片动起来)完结篇

RA6807是RA8876M的缩小版&#xff0c;具备RA8876M的所有功能&#xff0c;只将MCU控制接口进行缩减&#xff0c;仅保留SPI-3和I2C接口&#xff0c;其它功能基本相同。 该芯片最大可控制854x600的分辨率&#xff0c;内建64Mbits显存&#xff0c;多个图层&#xff0c;使用起来相当…

机器人SLAM建图与自主导航:从基础到实践

前言 这篇文章我开始和大家一起探讨机器人SLAM建图与自主导航 &#xff0c;在前面的内容中&#xff0c;我们介绍了差速轮式机器人的概念及应用&#xff0c;谈到了使用Gazebo平台搭建仿真环境的教程&#xff0c;主要是利用gmapping slam算法&#xff0c;生成一张二维的仿真环境…

一篇保姆式centos/ubuntu安装docker

前言&#xff1a; 本章节分别演示centos虚拟机&#xff0c;ubuntu虚拟机进行安装docker。 上一篇介绍&#xff1a;docker一键部署springboot项目 一&#xff1a;centos 1.卸载旧版本 yum remove docker docker-client docker-client-latest docker-common docker-latest doc…

Robot | 用 RDK 做一个小型机器人(更新中)

目录 前言架构图开发过程摄像头模型转换准备校准数据使用 hb_mapper makertbin 工具转换模型 底版开发 结语 前言 最近想开发一个小型机器人&#xff0c;碰巧看到了 RDK x5 发布了&#xff0c;参数对于我来说非常合适&#xff0c;就买了一块回来玩。 外设也是非常丰富&#xf…

如何在 UniApp 中实现 iOS 版本更新检测

随着移动应用的不断发展&#xff0c;保持应用程序的更新是必不可少的&#xff0c;这样用户才能获得更好的体验。本文将帮助你在 UniApp 中实现 iOS 版的版本更新检测和提示&#xff0c;适合刚入行的小白。我们将分步骤进行说明&#xff0c;每一步所需的代码及其解释都会一一列出…

软件工程导论 选填题知识点总结

一 原型化方法是一种动态定义需求的方法&#xff0c;提供完整定义的需求不是原型化方法的特征&#xff0c;其特征包括尽快建立初步需求、简化项目管理以及加强用户参与和决策。 软件危机的表现包括用户对已完成的软件系统不满意的现象经常发生、软件产品的质量往往靠不住、软件…

软件测试面试之常规问题

1.描述一下测试过程 类似题目:测试的生命周期 思路:这是一个“范围”很大的题目&#xff0c;而且回答时间一般在3分钟之内&#xff0c;不可能非常详细的描述整个过程&#xff0c;因此答题的思路要从整体结构入手&#xff0c;不要过细。为了保证答案的准确性&#xff0c;可以引…

Linux|内存级文件原理

目录 进程与文件 Linux下的文件系统 文件操作&#xff0c;及文件流 C语言函数 文件流 文件描述符 系统调用操作 系统调用参数 重定向与文件描述符 输出重定向 输入重定向 文件内容属性 Linux下一切皆文件 进程与文件 当我们对文件进行操作时&#xff0c;文件必须…

洛谷 P1722 矩阵 II C语言 记忆化搜索

题目&#xff1a; https://www.luogu.com.cn/problem/P1722 我们按照案例画一下 我们会发现&#xff0c;会出现重复的子结构。 代码如下&#xff1a; #include<iostream> using namespace std; int mem[300][300]; int n; int f[305][305]; int dfs(int x,int red,…

RTSP播放器EasyPlayer.js播放器分辨率高的视频在设置container的宽高较小时,会出现锯齿状的画面效果

流媒体播放器的核心技术及发展趋势展现了其在未来数字生活中的无限潜力。随着技术的不断进步和市场的持续发展&#xff0c;流媒体播放器将在内容创新、用户体验优化以及跨平台互通等方面取得新的突破。对于从业者而言&#xff0c;把握这些趋势并积极应对挑战将是实现成功的关键…

半导体、晶体管、集成电路、芯片、CPU、单片机、单片机最小系统、单片机开发板-概念串联辨析

下面概念定义从小到大串联&#xff1a; 半导体&#xff08;semiconductor&#xff09;&#xff1a; 是一类常温下导电性能介于导体与绝缘体之间的材料&#xff0c;这种材料的导电性可以随着外部环境比如电压、温度、光照的变换而改变&#xff0c;导电特性区别于导体、超导体、…