当前位置: 首页 > news >正文

Linux操作系统学习之---线程互斥(互斥锁)

任何技术都是一体两面的 , Linux下线程的设计在本身不带来额外内存资源耗费的情况下 , 实现了任务处理效率上的提升 , 但也带来了一些问题 .
多个线程都能访问同一个进程PCB资源 , 不加管控的话势必会出现冲突
一般来说 , 多个线程并发访问同一份资源造成的问题叫做数据不一致

一.黄牛抢票:

下面看一个抢票的例子 , 每个线程就相当于一个抢票的人 .

数据不一致问题(未加锁):

  • BuyTicket函数里的capacity–是真正访问共享资源的代码.
  • 在临界区前使用usleep()休眠 , 增加竞态窗口 .
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;int ticket = 1000;void* BuyTicket(void* input)
{char* ch = static_cast<char*>(input);while(true){if(ticket >= 0){usleep(1000);printf("%s买票:%d\n",ch,ticket);ticket--;}else{break;}}return nullptr;
}int main()
{pthread_t t1;pthread_t t2;pthread_t t3;pthread_t t4;pthread_create(&t1,nullptr,BuyTicket,(void*)"thread1");pthread_create(&t2,nullptr,BuyTicket,(void*)"thread2");pthread_create(&t3,nullptr,BuyTicket,(void*)"thread3");pthread_create(&t4,nullptr,BuyTicket,(void*)"thread4");pthread_join(t1,nullptr);pthread_join(t2,nullptr);pthread_join(t3,nullptr);pthread_join(t4,nullptr);return 0;
}

运行结果:
![[黄牛抢票数据不一致.png]]

加锁:

#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
int ticket = 1000;
pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;//初始化锁void* BuyTicket(void* input)
{char* ch = static_cast<char*>(input);while(true){pthread_mutex_lock(&mylock); //在临界区外加锁if(ticket >= 0){usleep(1000);printf("%s买票:%d\n",ch,ticket);ticket--;pthread_mutex_unlock(&mylock); //退出临界区前解锁}else{pthread_mutex_unlock(&mylock);break;}}return nullptr;
}int main()
{//正常调用逻辑,同上return 0;
}

![[黄牛抢票数据一致.png|500]]

二.数据不一致的原因:

ticket–的非原子性 :

  • c语言是编译型语言 , 在编写代码时通过一行ticket--;来达到让变量自减的目的 .
  • 但是在编译链接后 , 我们的代码往往会被编译成多条指令 .
  • 可以暂时粗略的理解为 — 一条汇编代码的执行才是原子的

ticket–的底层逻辑:

冯诺依曼体系结构规定 : 内存里的数据和代码要载入到CPU才能被运算和执行 !!!

可以理解为一句ticket--的代码 , 在底层分三步执行:

  1. 将内存里的ticket变量载入到CPU的寄存器 .
  2. 由CPU的计算器进行运算 , 将得到的结果暂存在寄存器.
  3. 最后再将结果从寄存器会写到内存 , 完成 一条上层语句ticket--的逻辑.

问题在于 :这三步不是完全连续的(不是原子的) , 而是可能被打断 !!!
在这里插入图片描述

根源一(非主要矛盾):tickets-- 操作的非原子性

  1. 假如存在线程a 和 线程b , 都有修改ticket变量的需求 , 并且ticket还剩下100 . 就类似于两个用户都要抢同一个高铁上的坐票 , 还有100张票.

  2. 线程a先执行 , 将ticket从内存载入CPU , 然后由CPU做–运算 , 此时ticket=99.

  3. 不幸的时刻来了 , CPU触发时钟中断 , 发现线程a时间片耗尽 , 将其从调度队列剥离 . 但是也将线程a的硬件上下文存到他的pcb里 . 也就是说 : 线程a停止前 , 认为自己抢了一张票后 , 还剩下 99张!!!

  4. 线程b随后执行 , 他是一个抢票大师 , 阴差阳错的在线程a重新调度前执行了100次ticket-- , 最后内存里的ticket被更新为0.

  5. 灾难到来 , 线程b重新调度时 , 恢复硬件上下文 , 将自己pcb里保存的ticket=99存到了CPU寄存器里 , 然后执行之前剩下来的写回内存操作.

自此 , 内存中原本已经归零的ticket变量 , 这样一折腾 , 又变成了99. 造成了严重的数据不一致问题 .

根源二(主要矛盾):if (tickets > 0) 判断的并发执行

上面只是阐述了多线程访问共享资源时确实可能出错这个事实 , 接下来在黄牛抢票的程序里为啥ticket最后回变为负数???

下面是黄牛抢票程序的部分代码:

if(ticket >= 0){usleep(1000);ticket--; printf("%s买票:%d\n",ch,ticket);}

可以发现 , 除了ticket--比较关键外 , 判断语句if(ticket >= 0)也很关键 . 按道理来说 , 当ticket已经被减到-1 , 就不能再让任何线程的代码执行代码块内部的ticket--, 那为啥还会出现问题???

CPU里存在运算器控制器两大部件 , 运算器负责算术运算—比如ticket--的操作 , 控制器负责逻辑运算—就是这里的if(ticket >= 0)语句!!!

于是就说得通了, 逻辑类似于刚才的ticket-- , 同样是非原子的操作.

  1. ticket0得先载入到CPU寄存器 .
  2. 然后由控制器进行if(ticket >= 0)的逻辑运算.
  3. 最后还得将得到的真假值写回到内存 .

试想 :

如果线程1刚执行完if(ticket >= 0),刚好被操作系统剥离 , 然后线程2也执行if(ticket >= 0)后被剥离 , 以此类推… 四个线程自己的硬件上下文数据里记录的都是从if(ticket >= 0)判断成功的代码开始运行 , 那这四个线程就都可以执行到ticket--; , 即便此时ticket已经为0了!!!

概念总结 :

线程安全问题 :

当多个线程并发访问(即都想访问,也有权利访问,甚至是同时访问)共享资源时 , 由于缺乏合适的保护机制 , 导致程序出现了非预期的结果 , 就称为线程安全问题.

三.解决方案 : 互斥锁(mutex)

互斥锁是为了解决多线程中的问题 , 所以linux下相关函数在线程相关的头文件<pthrea.h>

1.全局锁:

全局锁的使用很简单 , 一个锁类型pthread_mutex_t , 一个宏定义PTHREAD_MUTEX_INITIALIZER , 以及两个库函数phtread_mutex_lockpthread_mutex_unlock

使用示例(简洁):

//mylock就是一个初始好后的互斥锁变量
pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;
//加锁
pthread_mutex_lock(&mylock);
//解锁
pthread_mutex_lock(&mylock);

2. 局部锁:

  • pthread_mutex_init( , ) : 参数一是锁变量的指针 , 参数二是控制初始化方式的指针(通常传nullptr用默认的).
  • pthread_mutex_init( ) : 参数是锁变量的指针 , 用于初始化锁.
  • pthread_mutex_lock( ) : 参数是锁变量的指针,用于为线程加锁.
  • pthread_mutex_unlock( ) : 参数是锁变量的指针 , 用于解锁.
  • pthread_mutex_destroy() : 参数是锁变量的指针 , 用于销毁锁 .哪个线程创建,就那个线程销毁!!!

3.互斥锁的封装 :

mutex.hpp(类)

设计思路 :

  1. 分为两个类 , class Mutexclass LockGuard.

  2. Mutex类用于封装底层的pthread_mutex_lock函数和pthread_mutex_unlock函数 ,同时也在构造析构构里加入pthread_mutex_initpthread_mutex_destroy来保证锁的生命周期随对象

  3. 只要创建锁,必然是用来对线程的临界区代码加锁以及解锁 . 因此把加锁和解锁的函数调用进一步封装LockGuard类中.

  4. 自此达到了一个效果 : LockGuard类在局部域创建对象时自动调用构造来创建锁和加锁,在出了局部域后自动调用析构来释放锁和销毁锁 . 我们需要做的就是—把LockGuard对象的创建放在临界区的外面一层 .


```c++
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>namespace mutex_module
{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& mutex) :_mutex(mutex){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex& _mutex;};
}

main.cpp

#include"mutex.hpp"
using namespace mutex_module;int capacity = 1000;void* routine(void* input)
{while(1){LockGuard guard(mylock); //类if(capacity >= 1){usleep(1000);std::cout << ch << "抢票:" << capacity << std::endl;capacity--;}else{break;}//执行下一次while循环前,对象guard对象会调用析构释放锁资源}return nullptr;
}
int main()
{Mutex mylock;pthread_t tid1 = 0;pthread_t tid2 = 0;pthread_t tid3 = 0;pthread_t tid4 = 0;pthread_create(&tid1,nullptr,routine,(void*)"线程一:");pthread_create(&tid2,nullptr,routine,(void*)"线程二:");pthread_create(&tid3,nullptr,routine,(void*)"线程三:");pthread_create(&tid4,nullptr,routine,(void*)"线程四:");pthread_join(tid1,nullptr);pthread_join(tid2,nullptr);pthread_join(tid3,nullptr);pthread_join(tid4,nullptr);return 0;
}

4.互斥锁如何解决问题:

前面说到 , 数据不一致的原因有两个 :

  1. ticket--这一算术运算非原子操作, 即ticket–的指令执行时可能会被终端.
  2. if (tickets > 0)这一逻辑运算是原子操作 , 但是可能会让其他线程在加锁前在临界区内已经占位置了.

有了互斥锁后的多线程并发抢票流程(线程一/线程二)

5.互斥锁的底层原理:

互斥锁的实现分我硬件或软件层 , 硬件层就是暂时的将CPU的时钟中断关闭 , 从而使得非原子的操作也不能被打断 , 但是这样做的话弊端很大 , 一旦程序出现bug , 后果是灾难性的.因此互斥锁的实现还是通过软件层更理想!!!

1️. 互斥锁,本质上就是一个 0/1 的小开关

可以把互斥锁理解成一个共享的整型变量,比如:

  • 0​:表示锁空闲,没人用,你可以来抢!

  • 1​:表示锁被占用,有别的线程在使用,你得等!

线程想进入临界区,就得先去“抢锁”——也就是尝试把锁状态从 0 改成 1,​只有改成功了,才代表抢到了锁,才能进去操作共享资源。​


2. 但!锁也是公共资源,所以“抢锁”这个动作,得是原子的!

问题来了:如果多个线程同时去读锁的状态,都看到是 0,然后都去尝试改成 1,那不就乱套了吗?

所以,​对锁的“读取 + 修改”操作必须是原子的!​​ 也就是说,它不能被拆成多步,也不能被线程切换、中断、多核并发干扰,必须一次性搞定:要么成功拿到锁,要么失败什么都不改。


3️. 底层实现:依靠原子指令

那这个“原子地修改锁状态”的操作,是怎么完成的呢?

答案是:​通过一条硬件支持的原子指令!​

比如在 x86 架构上,可能会用 LOCK XCHG(带锁的交换指令),或者更通用的 ​CAS(Compare And Swap)​。这些指令在执行时不会被线程切换、中断等干扰,是真正的原子操作。

抢锁成功返回 0,失败返回 1,就这么简单粗暴,但非常有效。


4️ 线程切换?

如果线程在加锁时,刚把锁状态加载到寄存器,然后突然发生了线程切换,寄存器内容被换出,新线程也看到锁是 0,那会不会也去抢锁?

其实不会!因为 ​锁变量本身是存储在共享内存里的,不是线程的寄存器或栈里。​

虽然线程可能会把锁的值临时加载到寄存器里加速访问,但最终修改一定会写回内存,而且这个写回是受原子指令保护的。​所以,寄存器切换不影响锁的真实状态。​


5️ 线程中断?

还有个细节:如果线程 A 成功加锁(锁状态变为 1),然后突然被时钟中断打中,被调度器踢出了 CPU,这把锁会不会失效?

不会!因为锁状态已经原子地修改为 1,并且写回内存了。其他线程再来抢锁时,一定会读到锁是 1,它们就知道:“有人用了,我得等。”

所以,只要成功加锁,这把锁就是你的,哪怕你突然被切走了,也没人能抢走!​


6️ 总结:

关键点说明
1. 锁是一个 0/1 状态变量表示锁当前是否被占用
2. 对锁的修改必须是原子的不能被线程切换、中断等干扰,防止数据竞争
3. 通常通过硬件原子指令完成(如 xchg / CAS)​即一条汇编指令 , 无法被打断
4. 锁变量在共享内存中,修改后对其他线程立即可见即便有寄存器切换,锁状态依旧正确

技术的两面性

因为加锁带来了竞争、上下文切换、原子指令本身的开销,还有多核间的缓存同步等问题。​锁是安全的代名词,但也是性能的潜在瓶颈,临界区要尽量小!

http://www.dtcms.com/a/520374.html

相关文章:

  • 【物联网控制体系项目实战】—— 整体架构流程与 WS 实现
  • dedecms网站后台模板做汽车网站费用
  • 做网站就上房山华网天下大型网站如何开发
  • 从「能用」到「可靠」:深入探讨C++异常安全
  • 如何让AI更好地理解中文PDF中的复杂格式?
  • Mount Image Pro,在取证安全的环境中挂载和访问镜像文件内容
  • 四元数(Quaternion)之Eigen::Quaternion使用详解(5)
  • 太平洋建设集团有限公司网站wordpress标签扩展
  • 二级域名解析网站天津效果图制作公司
  • Linux iptables:四表五链 + 实用配置
  • Ceph 简介
  • idea开启远程调试
  • UE5 蓝图-6:汽车蓝图项目的文件夹组织与运行效果图,
  • 编程竞赛小技巧
  • CrewAI 核心概念 团队(Crews)篇
  • 小九源码-springboot100-基于springboot的房屋租赁管理系统
  • 珠宝网站建设公司微信公众号推文模板素材
  • 自己可以做类似淘宝客网站吗北京公司网站制作流程
  • winform迁移:从.net framework 到 .net9
  • 计算机视觉领域顶会顶刊
  • 华为OD, 测试面经
  • 好听的公司名字大全附子seo教程
  • AiOnly深度体验:从注册到视频生成,我与“火山即梦”的创作之旅
  • 电商网站建设思维导图澧县网站建设
  • 网站app怎么制作建英语网站
  • 阮一峰《TypeScript 教程》学习笔记——泛型
  • 数据结构——三十、图的深度优先遍历(DFS)(王道408)
  • Linux中的DKMS机制
  • springboot基于Java的高校超市管理系统设计与实现(代码+数据库+LW)
  • Qt 文件与目录操作详解:QFile, QDir, QFileInfo, 与 QTextStream