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

操作系统中的锁:自旋锁、互斥锁、条件变量、信号量、死锁

一直对这些概念学了又忘忘了又学,究其根本是因为始终没搞清楚这几个概念之间的关系,每次都是钻进细节里面,所以就很容易忘了。所以这里整理一下,以后回忆起来也方便。

首先锁是什么?操作系统中的锁是用于协调多个进程或线程对共享资源的访问(即多个线程同时只有一个线程可以进入临界区代码),以防止竞争条件和数据不一致的问题,以及实现进程之间的同步(本来两个不同的进程是各干各的,互不影响,这叫异步,如果我想要让进程A运行完某行代码之后进程B再运行,这叫作同步),比如读写进程之间就需要同步,写好了之后才能读。下面是实现进程互斥和同步的伪代码。

//进程互斥
mutex;
Thread1(){mutex.lock();Print();mutex.unlock();
}
Thread2(){mutex.lock();Print();mutex.unlock();
}
//进程同步
mutex.lock();//先锁,再运行子线程
Thread1(){write();mutex.unlock();
}
Thread1(){mutex.lock();read();//只有当写完之后才能读
}

那么锁有哪些呢?常见的有互斥锁(Mutex)、自旋锁(Spinlock)、读写锁(Read-Write Lock)、条件变量(Condition Variable)、信号量(Semaphore)。

互斥锁和自旋锁

互斥锁和自旋锁的目标都是:确保在同一时刻,只有一个线程能够持有锁,并访问共享资源,其他尝试获取锁的线程必须等待,直到锁被释放。它们的区别在于,当一个线程访问其他线程持有的互斥锁时,会被 OS 调度为阻塞状态(休眠),直到锁被释放后,再唤醒一个休眠的线程,而在无法获得自旋锁时,线程不会进入休眠状态,而是会持续循环检查锁的状态。因此自旋锁适用于锁持有时间非常短的场景,因为它避免了线程切换的开销,互斥锁适合线程持有锁时间比较长的场景,否则线程在等待过程中会让CPU一直空转,浪费CPU资源。

这两个锁的实现方式都要通过硬件原子指令,比如TAS(Test And Set)、CAS(Compare-and-Swap)等,不过这些指令都有可能某个线程一直得不到锁(即饥饿情况),用Fetch And Add可以解决这个问题,具体看这篇文章。

当然也有不通过硬件指令,纯靠软件实现的方式,即著名的Peterson算法,可以实现两个线程的互斥。思路是定义两个变量:flag[2]turn,flag用来表示希望进入临界区的意图,turn用来决定到底谁可以进入临界区

// 初始化
bool flag[2] = {false, false};
int turn;// 线程 0 的代码
void thread0() {while (true) {flag[0] = true;      // 表示线程 0 想要进入临界区turn = 1;            // 让线程 1 先来// 等待线程 1 放弃它的权利while (flag[1] && turn == 1) {// 忙等待}// 临界区代码段// ...// 离开临界区flag[0] = false;}
}// 线程 1 的代码
void thread1() {while (true) {flag[1] = true;      // 表示线程 1 想要进入临界区turn = 0;            // 让线程 0 先来// 等待线程 0 放弃它的权利while (flag[0] && turn == 0) {// 忙等待}// 临界区代码段// ...// 离开临界区flag[1] = false;}
}

真的非常巧妙。试想一下,如果没有turn,就有可能线程0看线程1想进入临界区,1看0想,互相等待,就死锁。如果没有flag,就和普通的互斥锁实现方式一样,但是由于无法保证test和set是原子的,因此可能同时有两个线程进入临界区。而且必须是线程0让turn=1,线程1让turn=0,这样的话,当某个线程进入临界区时,说明要么另一个线程没有进入临界区的意图,要么就是有意图并且已经执行完了给turn赋值的操作,也就是说在本线程离开临界区之前,turn不可能再变了,这就保证了另一个线程不会进入临界区。而如果反过来,线程0让turn=0,线程1让turn=1,就有可能线程0进入临界区之后,线程1再改变turn,然后也进入临界区,就不互斥了。

不过由于性能原因,现代系统更倾向于使用硬件支持的原子操作来实现锁定机制,软件实现的锁主要用于教育和特定硬件环境下的研究。

条件变量

条件变是一种高级的同步原语,目标是实现当某个条件不满足时,线程进入等待状态,直到该条件被满足并由其他线程发出通知后,再继续执行。通常与互斥锁(Mutex)结合使用,来协调线程之间的合作。条件变量在需要等待某个条件的场景下非常有用,例如生产者-消费者问题。

首先想想,为什么需要条件变量?它能提供什么互斥锁提供不了的功能?设想一个生产者-消费者场景,消费者线程需要等生产者线程生产完之后才能消费资源,或许有人说可以像上面的伪代码一样实现,但是这就有一个问题,如果有多个读进程,那样实现同时只能有一个进程在读,一般来说读都是很慢的,实际上只要写完之后,多个读进程可以并行,读进程A阻塞在IO操作的时候可以切换到读进程B,以提高效率。所以需要一个新的东西来实现这个更多要求的锁,那为什么还需要和mutex结合使用呢?因为你得保证对条件的操作是互斥的呀!不然的话,设想这样一个场景,线程A先检查条件是否满足,不满足的话就休眠,但是在这两个操作期间线程B改了条件变量,使得条件变为TRUE了,但是这个时候线程A已经检查完了,然后就进入休眠状态,这还是小问题,再被唤醒就行了,但是一般来说线程B一旦使得条件满足之后,会唤醒当前被该条件阻塞的线程,但是因为线程A此时没有进入休眠状态,所以不会收到信号,等到CPU再切换回线程A,它进入休眠状态,就无法再被唤醒了。所以必须有一个锁,保证线程A的从检查条件是否满足到进入休眠状态期间成为一个临界区,以及线程B的从使得条件满足到唤醒阻塞的线程成为临界区。把这些功能封装起来,就是现在库中提供的条件变量了:

//初始化条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//等待条件变量的线程
Thread_Consume(){pthread_mutex_lock(&mutex);    // 锁定互斥锁while (!condition_met) {       // 检查条件pthread_cond_wait(&cond, &mutex); // 等待条件变量,自动释放互斥锁并挂起。当被唤醒时重新锁定互斥锁。//为什么要用while循环?因为在被唤醒时,条件可能仍然不满足,或者可能由于“假唤醒”而导致不满足。}// 这里的代码在条件满足后继续执行pthread_mutex_unlock(&mutex);  // 解锁互斥锁//消费代码。。。
}
Thread_Product(){// 生产代码。。。pthread_mutex_lock(&mutex);    // 锁定互斥锁condition_met = true;          // 修改条件pthread_cond_signal(&cond);    // 唤醒等待线程// 或者使用 pthread_cond_broadcast(&cond); // 唤醒所有等待的线程pthread_mutex_unlock(&mutex);  // 解锁互斥锁
}

信号量

信号量其实比较好理解,就是mutex的升级版,mutex规定资源只能被一个线程访问,但是信号量可以用于控制对多个相同资源的访问。信号量有两个主要操作:P(等待)和V(信号),它们分别对应于以下概念:

P(Proberen, Test)操作:
P操作通常称为"等待"(Wait)或"减"(Down)操作。它将信号量的值减1,如果结果为负,表示资源不可用,当前线程将被阻塞,直到信号量值为非负时被唤醒。

V(Verhogen, Increment)操作:
V操作通常称为"信号"(Signal)或"加"(Up)操作。它将信号量的值加1,如果有线程因为P操作被阻塞且信号量的值变为非负,该线程会被唤醒。

信号量一个比价经典的应用是哲学家问题:五位哲学家坐在一张圆桌旁,每位哲学家前面有一个盘子,他们会交替地进行两项活动:思考和进餐。桌子中央有一碗意大利面条,每位哲学家都需要两根筷子才能吃饭。筷子放在相邻的哲学家之间,每位哲学家需要拿起左手边和右手边的筷子才能开始进餐。问题在于如何设计一个算法,确保每位哲学家都能公平地进餐,同时避免死锁和资源竞争。

Semaphore chopsticks[5] = {1, 1, 1, 1, 1};
void philosopher(int i) {while (true) {think();  // 哲学家正在思考// 获取筷子 (可能会引起死锁的简单实现)P(&chopsticks[i]);              // 拿起左边的筷子P(&chopsticks[(i + 1) % 5]);    // 拿起右边的筷子eat();  // 哲学家正在吃饭// 释放筷子V(&chopsticks[i]);              // 放下左边的筷子V(&chopsticks[(i + 1) % 5]);    // 放下右边的筷子}
}

在上述简单实现中,如果所有哲学家同时拿起左边的筷子,那么他们都会等待右边的筷子,导致死锁。下面介绍一下死锁,也是一个很重要的概念

死锁

死锁是指多个进程在运行过程中,因争夺资源而造成的一种僵局,若无外力作用,所有进程都无法向前推进。产生死锁有四个必要条件:

  1. 互斥:进程对所分配到的资源进行排他性使用,当一个进程获得了别的进程相用就必须等
  2. 请求和保持:进程已经获得了至少一个资源,但是又提出了新的资源请求,该资源被其他进程占有因此阻塞(同时自己不释放已经获得的资源)
  3. 不可抢占:一个进程获得了某个资源后,除非它自己释放,否则别人无法抢占
  4. 循环等待:进程之间的阻塞形成了一个循环链,即P0等P1,P1等P2……Pn等P0.

只需破坏其中一个条件即可解决死锁。所以可以这样:
奇偶编号策略:让编号为奇数的哲学家先拿左边的筷子,再拿右边的筷子;编号为偶数的哲学家先拿右边的筷子,再拿左边的筷子。这种策略破坏了死锁的循环等待条件。或者允许最多四个哲学家同时取筷子:通过限制最多有四位哲学家能够尝试拿起筷子,可以确保至少有一位哲学家能够进餐,从而避免死锁。即再加一个semaphore初始化为4,这样也是破坏了死锁的循环等待条件。

然后还有一恶搞避免死锁很经典的算法是银行家算法,思路也很简单,这里就不详细介绍了。

相关文章:

  • 北京网站建设多少钱?
  • 辽宁网页制作哪家好_网站建设
  • 高端品牌网站建设_汉中网站制作
  • 以FLV解复用为例详解开源库FFmpeg中解复用器的源码逻辑及处理流程
  • 浅谈【网络编程】之Unix与多路复用
  • centos8 安装mysql8
  • java反序列化之CommonCollections1利⽤链的学习
  • 结合GPT与Python实现端口检测工具(含多线程)
  • [Meachines] [Easy] Legacy nmap 漏洞扫描脚本深度发现+MS08-067
  • Java编程:单一职责原则
  • 辨析sizeof() 和strlen函数(包含相关二级习题)
  • html+css+js网页设计 电商 折扣社7个页面
  • [000-01-011].第2节:持久层方案的对比
  • 鸿蒙(API 12 Beta3版)【使用ImageEffect编辑图片】图片开发指导
  • CSM数采系统助力高压动力系统的效率测量
  • 计算机四级必背-操作系统
  • 探索上门回收旧衣物系统源码开发的创新与挑战
  • Flutter Listview 缓存item滑动后不进行重新渲染
  • 2017-09-12 前端日报
  • Electron入门介绍
  • javascript从右向左截取指定位数字符的3种方法
  • JAVA之继承和多态
  • jquery cookie
  • js 实现textarea输入字数提示
  • Material Design
  • nfs客户端进程变D,延伸linux的lock
  • PHP 使用 Swoole - TaskWorker 实现异步操作 Mysql
  • React 快速上手 - 07 前端路由 react-router
  • SegmentFault 社区上线小程序开发频道,助力小程序开发者生态
  • 使用API自动生成工具优化前端工作流
  • 通过获取异步加载JS文件进度实现一个canvas环形loading图
  • 一个6年java程序员的工作感悟,写给还在迷茫的你
  • 测评:对于写作的人来说,Markdown是你最好的朋友 ...
  • ​ 全球云科技基础设施:亚马逊云科技的海外服务器网络如何演进
  • # 执行时间 统计mysql_一文说尽 MySQL 优化原理
  • #APPINVENTOR学习记录
  • #我与Java虚拟机的故事#连载19:等我技术变强了,我会去看你的 ​
  • $ is not function   和JQUERY 命名 冲突的解说 Jquer问题 (
  • ( 10 )MySQL中的外键
  • (1) caustics\
  • (C++17) optional的使用
  • (HAL)STM32F103C6T8——软件模拟I2C驱动0.96寸OLED屏幕
  • (Java岗)秋招打卡!一本学历拿下美团、阿里、快手、米哈游offer
  • (保姆级教程)Mysql中索引、触发器、存储过程、存储函数的概念、作用,以及如何使用索引、存储过程,代码操作演示
  • (超简单)使用vuepress搭建自己的博客并部署到github pages上
  • (二开)Flink 修改源码拓展 SQL 语法
  • (附源码)计算机毕业设计SSM疫情社区管理系统
  • (每日一问)基础知识:堆与栈的区别
  • (欧拉)openEuler系统添加网卡文件配置流程、(欧拉)openEuler系统手动配置ipv6地址流程、(欧拉)openEuler系统网络管理说明
  • (一)Mocha源码阅读: 项目结构及命令行启动
  • (转)eclipse内存溢出设置 -Xms212m -Xmx804m -XX:PermSize=250M -XX:MaxPermSize=356m
  • (转)VC++中ondraw在什么时候调用的
  • .gitignore文件—git忽略文件
  • .NET Core WebAPI中使用Log4net 日志级别分类并记录到数据库
  • .NET Framework Client Profile - a Subset of the .NET Framework Redistribution
  • .Net Memory Profiler的使用举例
  • .Net 基于.Net8开发的一个Asp.Net Core Webapi小型易用框架
  • .NET 漏洞分析 | 某ERP系统存在SQL注入