操作系统—读者-写者问题及Peterson算法实现

文章目录

  • I.读者-写者问题
    • 1.读者-写者问题和分析
    • 2.读者—写者问题基本解法
    • 3.饥饿现象和解决方案
    • 总结
  • II.Peterson算法实现
    • 1.Peterson算法问题与分析
      • (1).如何无锁访问临界区呢?
      • (2).Peterson算法的基本逻辑
      • (3).写对方/自己进程号的区别是?
    • 2.只包含意向的解决方案
    • 3.严格轮换法
    • 4.完整的Peterson算法
    • 总结
  • 参考资料

I.读者-写者问题

1.读者-写者问题和分析

  • 读者: 读数据
  • 写者: 写数据
  • 访问规则:多个读者可以同时读数据,任何时刻只能有一个写者写数据,一个读者与一个写者不能同时在相应的临界区中
  • 实质: 一个写者不能与其它的读者或写者同时访问相应的临界资源。

  这算是一个非常经典的问题,实际上这也是数据库系统中遇到的最重要的一个问题,因为对于同一个文件,可以多人读,但是不能多人写,如何才能更好地安排资源的分配呢?
  对于这个问题首先肯定应该给出一个基本解法,那就是保证读者可以读,写者在一定情况下也能写,因此比较常用的是这样一种解法:首先有一把排他锁(写锁),只有一个线程可以获得这把排他锁,并且有若干把共享锁(读锁),共享锁可以被任意读线程获取,但是这些线程都不能写,不过如果用信号量实现的话,我们总是需要一个可能无限大的变量进行锁的分配,因此可以改变一下思路:用一个变量记录读者数量,当读者为0时允许获取写锁,而当读者不为0时记录数量,并且不允许获取写锁,因为这个变量本身属于临界区,因此只需要对这个变量加一把互斥锁即可。

2.读者—写者问题基本解法

  在1中,我们已经分析了这个问题的一种基本解法,因此可以用C语言实现如下的代码:

#include <stdio.h>
#include <semaphore.h>
#include <pthread.h>
#define P sem_wait
#define V sem_post
#define RNUMS 10
#define WNUMS 3
sem_t mutex, x;
int readers_cnt;
int rids[RNUMS], wids[WNUMS];void init()
{sem_init(&mutex, 0, 1);sem_init(&x, 0, 1);readers_cnt = 0;for (int i = 0; i < RNUMS; i++) {rids[i] = i + 1;} for (int i = 0; i < WNUMS; i++) {wids[i] = i + 1;} 
}void* Treader(void* ID)
{while (1) {P(&mutex);readers_cnt++;if (readers_cnt == 1) P(&x);V(&mutex);printf("Reader %d: read\n", *(int*)ID);P(&mutex);readers_cnt--;if (readers_cnt == 0) V(&x);V(&mutex);}pthread_exit(NULL);
}void* Twriter(void* ID)
{while (1) {P(&x);printf("Writer %d: write\n", *(int*)ID);V(&x);}pthread_exit(NULL);
}int main()
{init();pthread_t ws[WNUMS], rs[RNUMS];for (int i = 0; i < WNUMS; i++) {pthread_create(&ws[i], NULL, Twriter, &wids[i]);}for (int i = 0; i < RNUMS; i++) {pthread_create(&rs[i], NULL, Treader, &rids[i]);}for (int i = 0; i < WNUMS; i++) {pthread_join(ws[i], NULL);}for (int i = 0; i < RNUMS; i++) {pthread_join(rs[i], NULL);}return 0;
}

  非常简单的实现,对于当前设定的10个读者和3个写者的情况下,观察一段时间输出会发现:几乎根本就不存在写者写入的情况,这是由于读者的锁很明显比排他锁更好获得,对于更易达成的条件,从概率和期望的角度上来说,实现的次数也应该会更多

  这就有问题了:写者想要获取排他锁,看样子非常困难,甚至说,我们可以构造一个读顺序,让写者几乎不可能获取到排他锁:有五个线程,四个读线程和一个写线程,两个读线程为了保障读取到的数据永远是最新的,总是会每隔一分钟读取一次,但是非常巧妙的是,这四个读线程始终有一小段时间是重合的,在这种情况下,因为引用计数一直不能清零,所以排他锁一直不能被获取,此时就造成了写者的饥饿问题

3.饥饿现象和解决方案

  上面已经相对比较详细地描述了饥饿现象的产生,那么一个解决方案是这样的:我既然饥饿现象起源于读者和写者获取锁的难度不公平,那我们就让二者再次公平,在写者全程加上wait_mutex锁,在读者试图增加读者数量的时候也加上wait_mutex锁,因此我们可以写出下面的代码:

#include <stdio.h>
#include <semaphore.h>
#include <pthread.h>
#define P sem_wait
#define V sem_post
#define RNUMS 10
#define WNUMS 1
sem_t mutex, x, wait_mutex;
int readers_cnt;
int rids[RNUMS], wids[WNUMS];void init()
{sem_init(&mutex, 0, 1);sem_init(&x, 0, 1);sem_init(&wait_mutex, 0, 1);readers_cnt = 0;for (int i = 0; i < RNUMS; i++) {rids[i] = i + 1;} for (int i = 0; i < WNUMS; i++) {wids[i] = i + 1;} 
}void* Treader(void* ID)
{while (1) {P(&wait_mutex);P(&mutex);readers_cnt++;if (readers_cnt == 1) P(&x);V(&mutex);V(&wait_mutex);printf("Reader %d: read\n", *(int*)ID);P(&mutex);readers_cnt--;if (readers_cnt == 0) V(&x);V(&mutex);}pthread_exit(NULL);
}void* Twriter(void* ID)
{while (1) {P(&wait_mutex);P(&x);printf("Writer %d: write\n", *(int*)ID);V(&x);V(&wait_mutex);}pthread_exit(NULL);
}int main()
{init();pthread_t ws[WNUMS], rs[RNUMS];for (int i = 0; i < WNUMS; i++) {pthread_create(&ws[i], NULL, Twriter, &wids[i]);}for (int i = 0; i < RNUMS; i++) {pthread_create(&rs[i], NULL, Treader, &rids[i]);}for (int i = 0; i < WNUMS; i++) {pthread_join(ws[i], NULL);}for (int i = 0; i < RNUMS; i++) {pthread_join(rs[i], NULL);}return 0;
}

  在原先代码的基础上简单加上了一个新的wait_mutex作为写者的特权,当写时就会申请wait_mutex作为特权,因此读者和写者在初始状态需要竞争这把互斥锁,在写者竞争到后,读者就无法继续操作,无法增加读者数量,直到写线程的写结束,由此一来发现,由于写者和读者的竞争再次公平,因此写者的写入次数明显提升,对比原先代码的写读比1:10的情况,后者的结果中写入次数明显增加:
在这里插入图片描述

总结

  读者—写者问题是一类非常经典的问题,实际上代表了一系列的文件共享的互斥问题,在数据库系统当中,经常性地查表和写表也属于读者—写者问题的实例,数据库还会采取更多的措施来增加数据库的并发效率,因为目前我们的解决方案上的锁实际上是整个数据库的锁,为了同时让更多读写操作能够进行,数据库采取了表级锁行级锁这些更加细粒度的锁,例如同处在同一个文件的一张表中有两行数据,但是一个事务读取行1,而另一个事务写入行2,这两个操作实际上不会冲突,而采取简单的锁直接锁住会明显降低这个操作的效率

II.Peterson算法实现

1.Peterson算法问题与分析

(1).如何无锁访问临界区呢?

  当两个进程/线程希望访问同一个临界区的时候,应该怎么让这两个线程能够在不发生冲突的情况下获得临界区的数据呢?在Peterson算法之前出现的大部分解决方案实际上都不能完美地解决问题,虽然Peterson算法本身也只能用于解决两个进程之间的互斥问题,但它的确完成了任务。

(2).Peterson算法的基本逻辑

  所以Peterson算法本身究竟做了什么呢?Peterson算法融合了意向严格轮换的想法,对于希望访问临界区的变量,首先它需要将自己的访问意向设置为true,在这之后,有两种方案(本质一样,只是实际意义不同):将自己的进程代号放进turn变量将对方的进程代号放进turn变量,在这两个步骤之后,每个线程就可以去检测对方是否有意向访问 && 目前轮到了对方访问,如果这个条件满足,则需要循环等待。

(3).写对方/自己进程号的区别是?

  所以把对方的和自己的进程号写入turn变量的区别在哪呢?其实比较简单,因为对于同一个变量的写入有一个先来后到的顺序,如果两个进程均写入对方的进程号,则手快的进程会优先把对方进程号写在turn中,手慢的会在自己进程号已经被写入turn之后,把对方的进程号再写入turn当中,这种情况下,进程对于临界区的访问是抢占式的,也就是谁的速度更快,谁就能抢到临界区进行访问。

  而两个进程均写入自己的进程号则是遵循了让步的原则,因为对于上述的情况,手快的进程会优先把自己的进程号写在turn中,手慢的则会在对方进程号已经被写入turn之后,把自己的进程号再写入turn当中,这种情况下,相当于手快的进程把临界区的访问权让给了手慢的进程。

  所以这两种方案其实区别不是很大,只要我们没有一个进程放自己进程号,一个进程放对方进程号,就不会出现很大的问题

2.只包含意向的解决方案

  所以我们知道只包含意向的解决方案是会出问题的,我们可以用下面的代码进行尝试:

#include <stdio.h>
#include <pthread.h>
#include <stdbool.h>
bool flags[2] = {false, false};void* T1()
{while (true) {flags[0] = true;while (flags[1]);printf("T1 access!\n");flags[0] = false; }pthread_exit(NULL);
}void* T2()
{while (true) {flags[1] = true;while (flags[0]);printf("T2 access!\n");flags[1] = false; }pthread_exit(NULL);
}int main()
{pthread_t p1, p2;pthread_create(&p1, NULL, T1, NULL);pthread_create(&p2, NULL, T2, NULL);pthread_join(p1, NULL);pthread_join(p2, NULL);return 0;
}

  非常好代码,T1访问了一次就顺利地进入了死锁的阶段:
在这里插入图片描述
  我用死锁这个词可能都不太好,因为这个解决方案连锁都没有用,开个玩笑。那到底为什么会出这个问题呢?其实很简单,因为我们在等待的时候用了一个完全没法完成互斥的操作—我们只关注对方是不是true,如果true就不访问,那如果两个线程都有意向访问,相当于谁都进不去,换言之,这个方法完全没有解决互斥问题,只是检测是否别人可能在访问罢了。

3.严格轮换法

  这个方法看起来好像要正确一点点,它的代码如下:

#include <stdio.h>
#include <pthread.h>
#include <stdbool.h>
int turn;void* T1()
{while (true) {while (turn != 0);printf("T1 access!\n");turn = 1; }pthread_exit(NULL);
}void* T2()
{while (true) {while (turn != 1);printf("T2 access!\n");turn = 0;}pthread_exit(NULL);
}int main()
{turn = 0;pthread_t p1, p2;pthread_create(&p1, NULL, T1, NULL);pthread_create(&p2, NULL, T2, NULL);pthread_join(p1, NULL);pthread_join(p2, NULL);return 0;
}

在这里插入图片描述

  从结果上看:它好像还真没出问题,严格轮换法的确可以让不同的进程轮流访问临界区,但问题在于,这种方法会在轮不到某个进程的时候让进程持续进入轮询阶段,这会造成CPU的忙等待,浪费了CPU的资源,这种策略之下,快的进程总是不能优先地完成任务,从而造成一定的浪费和调度问题。

4.完整的Peterson算法

  来吧,让竞争更激烈一点。Peterson算法实际上完成了上述两种方法的融合,它的代码实现如下:

#include <stdio.h>
#include <pthread.h>
#include <stdbool.h>
#include <unistd.h>
bool flags[2] = {false, false};
int turn;
int cnt = 0;void* T1()
{while (true) {flags[0] = true;turn = 1;while (flags[1] && turn == 1);sleep(1);printf("T1 access! cnt = %d\n", cnt++);flags[0] = false;}pthread_exit(NULL);
}void* T2()
{while (true) {flags[1] = true;turn = 0;while (flags[0] && turn == 0);printf("T2 access!\n");flags[1] = false;}pthread_exit(NULL);
}int main()
{pthread_t p1, p2;pthread_create(&p1, NULL, T1, NULL);pthread_create(&p2, NULL, T2, NULL);pthread_join(p1, NULL);pthread_join(p2, NULL);return 0;
}

在这里插入图片描述

  结果看起来已经很正确了,它基本完成了Peterson算法的思想,好像问题到这儿就解决了,对吗?这台机器运行在x86-64架构的处理器下,实际上x86-64架构的CPU保证了至少对于int类型的loadstore操作是原子的,如果它们不是原子的,会发生什么?具体的实例我没有查到相关的资料,我也没有成功复现出来,之后我可能还会继续研究一下。

总结

  Peterson算法的确是真正通过软件的方式完成了临界区互斥访问的问题,不过编译器并不一定能够让我们的指令依照顺序执行,编译器可能会对我们写的代码进行乱序,而这样的乱序可能导致load和store指令顺序调整而导致Peterson算法失效,我们可能需要使用:

__sync_synchronize();
// 或者
asm("mfence");

  利用内存屏障指令从而避免对指令顺序进行优化,从而避免出现关键指令乱序执行的问题,所以Peterson算法的实现可能还是需要一些特别的软硬件结合以避免出现乱序的问题。

参考资料

  • 1.并发控制:基础 (Peterson 算法、模型检验、原子操作)
  • 2.博客园—内存栅栏(memory barrier):解救peterson算法的应用陷阱
  • 3.知乎—对int变量赋值的操作是原子的吗?

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

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

相关文章

Android手势密码–设置和校验功能的实现代码

效果图如下&#xff0c;大家感觉不错请参考实现代码 具体代码如下所示&#xff1a; private void setGesturePassword() {toggleMore.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {Overridepublic void onCheckedChanged(CompoundButton button…

最新版两款不同版SEO超级外链工具PHP源码

可根据个人感觉喜好自行任意选择不同版本使用&#xff08;版V1或版V2&#xff09; 请将zip文件全部解压缩即可访问&#xff01; 源码全部开源&#xff0c;支持上传二级目录访问 已更新增加大量高质量外链&#xff08;若需要增加修改其他外链请打开txt文件&#xff09;修复优…

基于springboot+vue+Mysql的教学视频点播系统

开发语言&#xff1a;Java框架&#xff1a;springbootJDK版本&#xff1a;JDK1.8服务器&#xff1a;tomcat7数据库&#xff1a;mysql 5.7&#xff08;一定要5.7版本&#xff09;数据库工具&#xff1a;Navicat11开发软件&#xff1a;eclipse/myeclipse/ideaMaven包&#xff1a;…

史上最强 PyTorch 2.2 GPU 版最新安装教程

一 深度学习主机 1.1 配置 先附上电脑配置图&#xff0c;如下&#xff1a; 利用公司的办公电脑对配置进行升级改造完成。除了显卡和电源&#xff0c;其他硬件都是公司电脑原装。 1.2 显卡 有钱直接上 RTX4090&#xff0c;也不能复用公司的电脑&#xff0c;其他配置跟不上。…

知识图谱简介:探索知识的宇宙

知识图谱简介&#xff1a;探索知识的宇宙 一、引言 在这个由数据驱动的世界里&#xff0c;信息呈现出爆炸式的增长&#xff0c;人们对于管理和利用这些庞大数据量的需求也随之增长。知识图谱以其独特的方式&#xff0c;成为了整合和利用这些信息的有力工具。它不仅有助于组织杂…

补充知识

补充知识1 内存的本质是对数据的临时存储 内存与磁盘进行交互时&#xff0c; 最小单位是4kb叫做页框(内存)和页帧(磁盘) 也就是&#xff0c; 如果我们要将磁盘的内容加载到内存中&#xff0c; 可是文件大小只有1kb&#xff0c; 我们也要拿出4kb来存他&#xff0c; 多余的就直…

01 Python进阶:正则表达式

re.match函数 使用 Python 中的 re 模块时&#xff0c;可以通过 re.match() 函数来尝试从字符串的开头匹配一个模式。以下是一个简单的详解和举例&#xff1a; import re# 定义一个正则表达式模式 pattern r^[a-z] # 匹配开头的小写字母序列# 要匹配的字符串 text "h…

【QingHub】企业级应用开发管理

QingHub 企业级应用开发设计器是QingHub Studio的一个核心模块&#xff0c;它可以实现应用搭建、团队管理&#xff0c;共享开发&#xff0c;可以快速接入API接口&#xff0c;复杂功能可以通过自定义脚本快速实现业务逻辑。打通前端开发与后台业务逻辑一体化。通过可视化的方式&…

Linux网络编程二(TCP图解三次握手及四次挥手、TCP滑动窗口、MSS、TCP状态转换、多进程/多线程服务器实现)

文章目录 1、TCP三次握手(1) 第一次握手(2) 第二次握手(3) 第三次握手 2、TCP四次挥手(1) 一次挥手(2) 二次挥手(3) 三次挥手(4) 四次挥手 3、TCP滑动窗口4、TCP状态时序图5、多进程并发服务器6、多线程并发服务器 1、TCP三次握手 TCP三次握手(TCP three-way handshake)是TCP协…

如何做用户体验优化

本文是从用户体验优化角度谈用户体验&#xff0c;其实用户体验不是设计必须的步骤&#xff0c;而是分散在产品设计中的产品设计思想。 一、用户体验分类 用户体验是指用户在“使用”某个产品或服务过程中的全部感受&#xff0c;包括情感、信仰、喜好、认知印象、生理和心理反应…

Spark实战:词频统计

文章目录 一、Spark实战&#xff1a;词频统计&#xff08;一&#xff09;Scala版1、分步完成词频统计2、一步搞定词频统计 &#xff08;二&#xff09;Python版1、分步完成词频统计2、一步搞定词频统计 二、实战总结 一、Spark实战&#xff1a;词频统计 &#xff08;一&#x…

Golang并发模型-Pipeline模型、Fan-in Fan-out模型

这段时间由于项目的需要&#xff0c;本人正在研究关于如何优雅的进行go的并发&#xff0c;以下是结合资料和视频的结果&#xff0c;文末会给出参考资料 Go语言的并发模型主要通过goroutine和channel实现&#xff0c;通过这个我们可以更有效地使用IO和CPU 这里我们围绕生成一个…