多线程同步

news2024/12/25 9:27:41

文章目录

  • 一、多线程同步
      • 竞争与协作
            • 互斥的概念
            • 同步的概念
      • 互斥与同步的实现和使⽤
            • 信号量
            • ⽣产者-消费者问题
      • 经典同步问题
            • 读者-写者问题


一、多线程同步

竞争与协作

在单核 CPU 系统⾥,为了实现多个程序同时运⾏的假象,操作系统通常以时间⽚调度的⽅式, 让每个进程
执⾏每次执⾏⼀个时间⽚,时间⽚⽤完了,就切换下⼀个进程运⾏,由于这个时间⽚的时间很短,于是就造成了「并发」的现象。

在这里插入图片描述
另外,操作系统也为每个进程创建巨⼤、私有的虚拟内存的假象,这种地址空间的抽象让每个程序好像拥
有⾃⼰的内存,⽽实际上操作系统在背后秘密地让多个地址空间「复⽤」物理内存或者磁盘。
在这里插入图片描述
如果⼀个程序只有⼀个执⾏流程,也代表它是单线程的。当然⼀个程序可以有多个执⾏流程,也就是所谓
的多线程程序,线程是调度的基本单位,进程则是资源分配的基本单位。

所以,线程之间是可以共享进程的资源,⽐如代码段、堆空间、数据段、打开的⽂件等资源,但每个线程
都有⾃⼰独⽴的栈空间。
在这里插入图片描述
那么问题就来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。

我们做个⼩实验,创建两个线程,它们分别对共享变量 i ⾃增 1 执⾏ 10000 次,如下代码(虽然说
是 C++ 代码,但是没学过 C++ 的同学也是看到懂的):
在这里插入图片描述
按理来说, i 变量最后的值应该是 20000 ,但很不幸,并不是如此。我们对上⾯的程序执⾏⼀下:
在这里插入图片描述
运⾏了两次,发现出现了 i 值的结果是 15173 ,也会出现 20000 的 i 值结果。
每次运⾏不但会产⽣错误,⽽且得到不同的结果。在计算机⾥是不能容忍的,虽然是⼩概率出现的错误,
但是⼩概率事件它⼀定是会发⽣的,「墨菲定律」⼤家都懂吧。

为什么会发⽣这种情况?
为了理解为什么会发⽣这种情况,我们必须了解编译器为更新计数器 i 变量⽣成的代码序列,也就是要了
解汇编指令的执⾏顺序。
在这个例⼦中,我们只是想给 i 加上数字 1,那么它对应的汇编指令执⾏过程是这样的:
在这里插入图片描述
可以发现,只是单纯给 i 加上数字 1,在 CPU 运⾏的时候,实际上要执⾏ 3 条指令。

设想我们的线程 1 进⼊这个代码区域,它将 i 的值(假设此时是 50 )从内存加载到它的寄存器中,然后它
向寄存器加 1,此时在寄存器中的 i 值是 51。

现在,⼀件不幸的事情发⽣了:时钟中断发⽣。因此,操作系统将当前正在运⾏的线程的状态保存到线程
的线程控制块 TCB。

现在更糟的事情发⽣了,线程 2 被调度运⾏,并进⼊同⼀段代码。它也执⾏了第⼀条指令,从内存获取 i
值并将其放⼊到寄存器中,此时内存中 i 的值仍为 50,因此线程 2 寄存器中的 i 值也是 50。假设线程 2 执
⾏接下来的两条指令,将寄存器中的 i 值 + 1,然后将寄存器中的 i 值保存到内存中,于是此时全局变量 i
值是 51。

最后,⼜发⽣⼀次上下⽂切换,线程 1 恢复执⾏。还记得它已经执⾏了两条汇编指令,现在准备执⾏最后
⼀条指令。回忆⼀下, 线程 1 寄存器中的 i 值是51,因此,执⾏最后⼀条指令后,将值保存到内存,全局
变量 i 的值再次被设置为 51

简单来说,增加 i (值为 50 )的代码被运⾏两次,按理来说,最后的 i 值应该是 52,但是由于不可控的调
度,导致最后 i 值却是 51。

针对上⾯线程 1 和线程 2 的执⾏过程,我画了⼀张流程图,会更明确⼀些:
在这里插入图片描述

互斥的概念

上⾯展示的情况称为竞争条件(race condition),当多线程相互竞争操作共享变量时,由于运⽓不好,
即在执⾏过程中发⽣了上下⽂切换,我们得到了错误的结果,事实上,每次运⾏都可能得到不同的结果,
因此输出的结果存在不确定性(indeterminate)。

由于多线程执⾏操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical
section),它是访问共享资源的代码⽚段,⼀定不能给多线程同时执⾏

我们希望这段代码是互斥(mutualexclusion)的,也就说保证⼀个线程在临界区执⾏时,其他线程应该
被阻⽌进⼊临界区,说⽩了,就是这段代码执⾏过程中,最多只能出现⼀个线程。
在这里插入图片描述
另外,说⼀下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使⽤互斥的⽅式
来避免资源竞争造成的资源混乱。

同步的概念

互斥解决了并发进程/线程对临界区的使⽤问题。 这种基于临界区控制的交互作⽤是⽐较简单的,只要⼀个
进程/线程进⼊了临界区,其他试图想进⼊临界区的进程/线程都会被阻塞着,直到第⼀个进程/线程离开了
临界区。

我们都知道在多线程⾥,每个线程并不⼀定是顺序执⾏的,它们基本是以各⾃独⽴的、不可预知的速度向
前推进,但有时候我们⼜希望多个线程能密切合作,以实现⼀个共同的任务。

例⼦,线程 1 是负责读⼊数据的,⽽线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线
程 2 在没有收到线程 1 的唤醒通知时,就会⼀直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,
线程 1 会唤醒线程 2,并把数据交给线程 2 处理。

所谓同步,就是并发进程/线程在⼀些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通
信息称为进程/线程同步。

举个⽣活的同步例⼦,你肚⼦饿了想要吃饭,你叫妈妈早点做菜,妈妈听到后就开始做菜,但是在妈妈没
有做完饭之前,你必须阻塞等待,等妈妈做完饭后,⾃然会通知你,接着你吃饭的事情就可以进⾏了。
在这里插入图片描述
注意,同步与互斥是两种不同的概念:

  • 同步就好⽐:「操作 A 应在操作 B 之前执⾏」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执
    ⾏」等;
  • 互斥就好⽐:「操作 A 和操作 B 不能在同⼀时刻执⾏」;

互斥与同步的实现和使⽤

在进程/线程并发执⾏的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和⽅法,主要的⽅法有两种:

  • 锁:加锁、解锁操作;
  • 信号量:P、V 操作;

这两个都可以⽅便地实现进程/线程互斥,⽽信号量⽐锁的功能更强⼀些,它还可以⽅便地实现进程/线程同
步。

使⽤加锁操作和解锁操作可以解决并发线程/进程的互斥问题。

任何想进⼊临界区的线程,必须先执⾏加锁操作。若加锁操作顺利通过,则线程可进⼊临界区;在完成对
临界资源的访问后再执⾏解锁操作,以释放该临界资源。
在这里插入图片描述
根据锁的实现不同,可以分为「忙等待锁」和「⽆忙等待锁」。

我们先来看看「忙等待锁」的实现
在说明「忙等待锁」的实现之前,先介绍现代 CPU 体系结构提供的特殊原⼦操作指令 —— 测试和置位
(Test-and-Set)指令。

如果⽤ C 代码表示 Test-and-Set 指令,形式如下:
在这里插入图片描述
测试并设置指令做了下述事情:

  • 把 old_ptr 更新为 new 的新值
  • 返回 old_ptr 的旧值;

当然,关键是这些代码是原⼦执⾏。因为既可以测试旧值,⼜可以设置新值,所以我们把这条指令叫作
「测试并设置」。

那什么是原⼦操作呢?原⼦操作就是要么全部执⾏,要么都不执⾏,不能出现执⾏到⼀半的中间状态

我们可以运⽤ Test-and-Set 指令来实现「忙等待锁」,代码如下:
在这里插入图片描述
我们来确保理解为什么这个锁能⼯作:

  • 第⼀个场景是,⾸先假设⼀个线程在运⾏,调⽤ lock() ,没有其他线程持有锁,所以 flag 是 0。当
    调⽤ TestAndSet(flag, 1) ⽅法,返回 0,线程会跳出 while 循环,获取锁。同时也会原⼦的设置 flag
    为1,标志锁已经被持有。当线程离开临界区,调⽤ unlock() 将 flag 清理为 0。

很明显,当获取不到锁时,线程就会⼀直 wile 循环,不做任何事情,所以就被称为 「忙等待锁」,也被称
⾃旋锁(spin lock)

这是最简单的⼀种锁,⼀直⾃旋,利⽤ CPU 周期,直到锁可⽤。在单处理器上,需要抢占式的调度器(即
不断通过时钟中断⼀个线程,运⾏其他线程)。否则,⾃旋锁在单 CPU 上⽆法使⽤,因为⼀个⾃旋的线程
永远不会放弃 CPU。

再来看看「⽆等待锁」的实现
⽆等待锁顾明思议就是获取不到锁的时候,不⽤⾃旋。

既然不想⾃旋,那当没获取到锁的时候,就把当前线程放⼊到锁的等待队列,然后执⾏调度程序,把 CPU
让给其他线程执⾏。
在这里插入图片描述
本次只是提出了两种简单锁的实现⽅式。当然,在具体操作系统实现中,会更复杂,但也离不开本例⼦两
个基本元素。

信号量

信号量是操作系统提供的⼀种协调共享资源访问的⽅法。

操作系统是如何实现 PV 操作的呢?
信号量数据结构与 PV 操作的算法描述如下图:
在这里插入图片描述
PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执⾏ PV 函数时是具有原⼦性的。

⽣产者-消费者问题

在这里插入图片描述
⽣产者-消费者问题描述:

  • ⽣产者在⽣成数据后,放在⼀个缓冲区中;
  • 消费者从缓冲区取出数据处理;
  • 任何时刻,只能有⼀个⽣产者或消费者可以访问缓冲区;

我们对问题分析可以得出:

  • 任何时刻只能有⼀个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥
  • 缓冲区空时,消费者必须等待⽣产者⽣成数据;缓冲区满时,⽣产者必须等待消费者取出数据。说明
    ⽣产者和消费者需要同步。

那么我们需要三个信号量,分别是:

  • 互斥信号量 mutex :⽤于互斥访问缓冲区,初始化值为 1;
  • 资源信号量 fullBuffers :⽤于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0
    (表明缓冲区⼀开始为空);
  • 资源信号量 emptyBuffers :⽤于⽣产者询问缓冲区是否有空位,有空位则⽣成数据,初始化值为 n
    (缓冲区⼤⼩);

具体的实现代码:
在这里插入图片描述
如果消费者线程⼀开始执⾏ P(fullBuffers) ,由于信号量 fullBuffers 初始值为 0,则此时 fullBuffers 的
值从 0 变为 -1,说明缓冲区⾥没有数据,消费者只能等待。

接着,轮到⽣产者执⾏ P(emptyBuffers) ,表示减少 1 个空槽,如果当前没有其他⽣产者线程在临界区执
⾏代码,那么该⽣产者线程就可以把数据放到缓冲区,放完后,执⾏ V(fullBuffers) ,信号量 fullBuffers
从 -1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。

消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进⼊临界区,从缓冲区读
取数据。最后,离开临界区后,把空槽的个数 + 1。

经典同步问题

哲学家就餐问题
来图解这道题
在这里插入图片描述
先来看看哲学家就餐的问题描述:

  • 5 个⽼⼤哥哲学家,闲着没事做,围绕着⼀张圆桌吃⾯;
  • 巧就巧在,这个桌⼦只有 5 ⽀叉⼦,每两个哲学家之间放⼀⽀叉⼦;
  • 哲学家围在⼀起先思考,思考中途饿了就会想进餐;
  • 奇葩的是,这些哲学家要两⽀叉⼦才愿意吃⾯,也就是需要拿到左右两边的叉⼦才进餐;
  • 吃完后,会把两⽀叉⼦放回原处,继续思考;

那么问题来了,如何保证哲 学家们的动作有序进⾏,⽽不会出现有⼈永远拿不到叉⼦呢?

⽅案⼀
我们⽤信号量的⽅式,也就是 PV 操作来尝试解决它,代码如下:
在这里插入图片描述
上⾯的程序,好似很⾃然。拿起叉⼦⽤ P 操作,代表有叉⼦就直接⽤,没有叉⼦时就等待其他哲学家放回
叉⼦。
在这里插入图片描述
不过,这种解法存在⼀个极端的问题:假设五位哲学家同时拿起左边的叉⼦,桌⾯上就没有叉⼦了,
这样就没有⼈能够拿到他们右边的叉⼦,也就说每⼀位哲学家都会在 P(fork[(i + 1) % N ]) 这条语句阻塞
了,很明显这发⽣了死锁的现象。

⽅案⼆
既然「⽅案⼀」会发⽣同时竞争左边叉⼦导致死锁的现象,那么我们就在拿叉⼦前,加个互斥信号量,代
码如下:
在这里插入图片描述
上⾯程序中的互斥信号量的作⽤就在于,只要有⼀个哲学家进⼊了「临界区」,也就是准备要拿叉⼦时,
其他哲学家都不能动,只有这位哲学家⽤完叉⼦了,才能轮到下⼀个哲学家进餐。
在这里插入图片描述
⽅案⼆虽然能让哲学家们按顺序吃饭,但是每次进餐只能有⼀位哲学家,⽽桌⾯上是有 5 把叉⼦,按道理
是能可以有两个哲学家同时进餐的,所以从效率⻆度上,这不是最好的解决⽅案。

⽅案三
那既然⽅案⼆使⽤互斥信号量,会导致只能允许⼀个哲学家就餐,那么我们就不⽤它。

另外,⽅案⼀的问题在于,会出现所有哲学家同时拿左边⼑叉的可能性,那我们就避免哲学家可以同时拿
左边的⼑叉,采⽤分⽀结构,根据哲学家的编号的不同,⽽采取不同的动作。

即让偶数编号的哲学家「先拿左边的叉⼦后拿右边的叉⼦」,奇数编号的哲学家「先拿右边的叉⼦后拿左
边的叉⼦」。

在这里插入图片描述
上⾯的程序,在 P 操作时,根据哲学家的编号不同,拿起左右两边叉⼦的顺序不同。另外,V 操作是不需
要分⽀的,因为 V 操作是不会阻塞的。
在这里插入图片描述
⽅案三即不会出现死锁,也可以两⼈同时进餐。

⽅案四
在这⾥再提出另外⼀种可⾏的解决⽅案,我们⽤⼀个数组 state 来记录每⼀位哲学家在进程、思考还是饥
饿状态(正在试图拿叉⼦)。

那么,⼀个哲学家只有在两个邻居都没有进餐时,才可以进⼊进餐状态

第 i 个哲学家的左邻右舍,则由宏 LEFT 和 RIGHT 定义:

  • LEFT : ( i + 5 - 1 ) % 5
  • RIGHT : ( i + 1 ) % 5

⽐如 i 为 2,则 LEFT 为 1, RIGHT 为 3。

读者-写者问题

读者只会读取数据,不会修改数据,⽽写者即可以读也可以修改数据。
读者-写者的问题描述:

  • 「读-读」允许:同⼀时刻,允许多个读者同时读
  • 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
  • 「写-写」互斥:没有其他写者时,写者才能写

接下来,提出⼏个解决⽅案来分析分析。

⽅案⼀
使⽤信号量的⽅式来尝试解决:

  • 信号量 wMutex :控制写操作的互斥信号量,初始值为 1 ;
  • 读者计数 rCount :正在进⾏读操作的读者个数,初始化为 0;
  • 信号量 rCountMutex :控制对 rCount 读者计数器的互斥修改,初始值为 1;

接下来看看代码的实现:
在这里插入图片描述

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

为dev c++配置图形开发环境easyx之mingw32

easyx官方的文档在说明如何配置环境上面不太详细,所以就有了我的那篇博文为dev c配置图形开发环境easyx,默认的是在64位的编译器TDM-gcc下配置的,也就是我们配置的easyx最终都是放在mingw64文件夹下的,5.1版本后的dev c内置的编译…

什么是分层架构

👨‍💻个人主页:微微的猪食小窝 欢迎 点赞👍 收藏⭐ 留言📝 加关注✅! 本文由 微微的猪食小窝 原创 1、什么是架构分层? 分层架构是将软件模块按照水平切分的方式分成多个层,一个系统由多层组成…

同时安装Vue2和Vue3

背景 当我们的工作中使用的还是脚手架还是基于Vue2.x的版本,那么我们想要学习和使用Vue3怎么办?直接升级脚手架的话,会影响到我们现在的项目,那就需要去处理下关于Vue3的东西了。 下载安装Vue3的脚手架 任意磁盘根目录新建一个文件…

【深入理解C++】new/delete和new[]/delete[]探秘

文章目录1.operator new()和operator delete()2.new记录分配的内存大小供delete使用3.new[]/delete[]申请和释放一个数组3.1 基本数据类型(内置类型)3.2 自定义类型(类类型)4.new/delete和new[]/delete[]要配对使用1.operator new…

开发 Chrome 扩展 之 Hello World 心血来潮

开发 Chrome 扩展 Hello, World 项目加载未打包的扩展icon刷新引入 JS 与错误处理 开发 Chrome 扩展 开发 Chrome 扩展除了需要基本的 HTML, CSS, JS 之外, 还可以使用 Chrome 额外提供的 API. 除了需要的 .html, .css 和 .js 文件之外呢, 扩展还包括不同类型的文件, 具体可…

杨辉三角形(Java版)

不为失败找理由,只为成功找方法。所有的不甘,因为还心存梦想,所以在你放弃之前,好好拼一把,只怕心老,不怕路长。 文章目录1. 什么是杨辉三角形2. 实现思路(方式)2.1 递归方式2.2 递归…

Nginx简单使用

安装龙蜥操作系统 镜像文件在这里下载就行 下载之后新建虚拟机 ISO选择刚才下载文件即可 具体配置可以照我来 也可自定义 基本工具安装 安装一下最基本的网络工具 yum install net-tools openssh-server wget tar make vim -y测试一下ssh连接 方便后期操作 修改主机名 …

Jedis 使用教程总结

一、Redis 常用命令 1 连接操作命令 quit:关闭连接(connection)auth:简单密码认证help cmd: 查看 cmd 帮助,例如:help quit 2 持久化 save:将数据同步保存到磁盘bgsave&#xff…

设计模式之原型模式

文章目录1.前言概念使用场景2.原型模式核心组成UML图3.浅拷贝与深拷贝基本类型与引用类型浅拷贝代码演示深拷贝代码演示4.原型模式的优点与缺点1.前言 概念 原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能。这种类型的…

Cpp知识点系列-类型转换

前言 在做题的时候发现了需要用到类型转换,于是在这里进行了简单的记录。 历史原因,慢慢整理着发现类型转换也能写老大一篇文章了。又花了时间来梳理一下就成了本文了。 cpp 之前使用的环境是DEV-C 5.4,而对应的GCC版本太低了。支持c11需要…

【CSS】重点知识梳理,这样上手无压力

推荐前端学习路线如下: HTML、CSS、JavaScript、noodJS、组件库、JQuery、前端框架(Vue、React)、微信小程序和uniapp、TypeScript、webpack 和 vite、Vue 和 React 码源、NextJS、React Native、后端内容。。。。。。 CSS定义: …

docker入门到精通一文搞定

文章目录前言一、Docker概述1.Docker为什么会出现?2.Docker相比VM技术3.Docker 能做什么?3.1 比较Docker和虚拟机技术的不同:3.2 DevOps (开发、运维):4个特点二、Docker安装1.dokcer架构图:2.Docker基本组成&#xff…

python+django体质测试数据分析及可视化设计

目 录 摘 要 I ABSTRACT II 目 录 II 第1章 绪论 1 1.1背景及意义 1 1.2 国内外研究概况 1 1.3 研究的内容 1 第2章 相关技术 3 2.1 B/S架构 4 本选题则旨在通过标签分类管理等方式,实现管理员:管理员:首页、个…

11.前端笔记-CSS盒子模型-外边距margin

1、margin 1.1 margin的语法 盒子与盒子之间的距离 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta http-equiv"X-UA-Compatible" content"IEedge"><meta name"viewpor…

linux 系统的磁盘 mbr 转gpt方法

linux磁盘用fdisk格式化挂盘的格式都为mbr, 不支持大于2T的磁盘扩容&#xff0c;需要用parted转化。 查询磁盘格式 输入&#xff1a; fdisk -l 看Disk label type 的值&#xff0c;是dos 的为mbr 是gpt的为gpt 当前&#xff0c;因挂盘时&#xff0c;用的fdisk方式选gpt,挂…

基于STM32的u8g2移植以及学习

实验硬件&#xff1a;STM32F103C8T6&#xff1b;0.96寸OLED&#xff08;12864&#xff09; U8g2库开源网址&#xff1a;https://github.com/olikraus/u8g2 一、u8g2库知识 1.1 什么是u8g2&#xff1f; U8g2是嵌入式设备的单色图形库。主要应用于嵌入式设备&#xff0c;包括我…

正大国际期货:投资外盘期货如何运用K线图中十字星形态?

很多人都明白&#xff0c;做外盘期货需要学会看线图。那么K线图上面的一根两根的柱子代表的什么意思呢&#xff1f;其中星星点点的十字星又是什么意思&#xff1f;下面正大IxxxuanI详细给大家讲解一下&#xff01; 1、什么是多头十字星形态&#xff1f; 多头十字星是一种经典…

KEITHLEY 吉时利2601B源表产品技术参数

KEITHLEY 2601B 吉时利 2601B 源表让您可以比以前更快、更轻松、更经济地进行精密直流、脉冲和低频交流源测量测试。Keithley 2601B 通过结合以下特性&#xff0c;为 IV 功能测试提供竞争产品 2 到 4 倍的测试速度&#xff1a; 吉时利的高速第三代源测量单元 (SMU) 设计 嵌…

【Python】八、函数的使用

文章目录实验目的一、定义函数二、调用函数三、参数的传递和函数的返回值四、编写函数&#xff0c;输入不同的参数&#xff0c;绘制不同的科赫曲线参考代码实验截图实验目的 掌握函数的定义和调用&#xff1b;掌握函数的用法&#xff1b;理解递归&#xff1b;培养学生动手查阅资…

开源:分享4个非常经典的CMS开源项目

❤️作者主页&#xff1a;IT技术分享社区 ❤️作者简介&#xff1a;大家好,我是IT技术分享社区的博主&#xff0c;从事C#、Java开发九年&#xff0c;对数据库、C#、Java、前端、运维、电脑技巧等经验丰富。 ❤️个人荣誉&#xff1a; 数据库领域优质创作者&#x1f3c6;&#x…