关于后端:什么是线程安全一文带你深入理解

117次阅读

共计 5306 个字符,预计需要花费 14 分钟才能阅读完成。

前言

欢送来到操作系统系列,采纳图解 + 大白话的模式来解说,让小白也能看懂,帮忙大家疾速科普入门。

上篇文章有介绍过过程与线程的基础知识,过程下领有多个线程,尽管多线程间通信非常不便(同过程),然而却带来了线程平安问题,本篇次要就是介绍操作系统中是用什么办法解决多线程平安,废话不多说,进入注释吧。

博主心愿读者阅读文章后能够养成思考与总结的习惯,只有这样能力把常识消化成本人的货色,而不是单纯的去记忆

内容纲要

小故事

带薪蹲坑,置信都是大伙都爱做的事件,阿星也不例外,然而我司所在的楼层的坑位较少,粥少僧多,非常懊恼。

阿星(线程 A)每次去厕所(共享资源),门都是锁着的,阐明有共事在外面占着坑(线程 B 持有锁),只能无奈的在里面乖乖的等着,不久后冲水声响起,共事爽完进去(线程 B 开释锁),阿星一个健步进入厕所把门锁住(线程 A 持有锁),享受属于本人的空间,晚来的其余共事只能乖乖排队,一切都是那么颠三倒四。

假如门锁坏了,颠三倒四就不存在了,上厕所不再是享受,而是高度缓和,避免门忽然被关上,更蹩脚的是,开门时,是个妹子,这下不仅仅是线程平安问题,还有数组越界了。

故事说完,扯了那么多,就是想阐明,在多线程环境里,对共享资源进行操作,如果多线程之间不做正当的合作(互斥与同步),那么肯定会产生翻车现场。

竞争条件

因为多线程共享过程资源,在操作系统调度过程内的多线程时,必然会呈现多线程竞争共享资源问题,如果不采取有效的措施,则会造成共享资源的凌乱!

来写个小例子,创立两个线程,它们别离对共享变量 i 自增 1 执行 1000 次,如下代码

失常来说,i 变量最初的值是 2000,可是并非如此,咱们执行下代码看看后果

  • 后果:2000
  • 后果:1855

运行了两次,后果别离是 1855、2000,咱们发现每次运行的后果不同,这在计算机里是不能容忍的,尽管是小概率呈现的谬误,然而小概率它肯定是会产生的。

汇编指令

为了搞明确到底产生了什么事件,咱们必须要理解汇编指令执行,以 i1 为例子,汇编指令的执行过程如下

好家伙,一个加法动作,在 C P U 运行,理论要执行 3 条指令。

当初模仿下线程 A 与线程 B 的运行,假如此时内存变量 i 的值是 0,线程 A 加载内存的 i 值到寄存器,对寄存器 i 值加 1,此时 i 值是 1,正筹备执行下一步寄存器 i 值回写内存,工夫片应用完了,产生线程上下文切换,保留线程的公有信息到线程管制块 T C P。

操作系统调度线程 B 执行,此时的内存变量 i 仍然还是 0,线程 B 执行与线程 A 一样的步骤,它很侥幸,在工夫片应用完前,执行完了加 1,最终回写内存,内存变量 i 值是 1

线程 B 工夫片应用完后,产生线程上下文切换,回到线程 A 上次的状态继续执行,寄存器中的 i 值回写内存,内存变量再次被设置成 1

按理说,最初的 i 值应该是 2,然而因为不可控的调度,导致最初 i 值是 1,上面是线程 A 与线程 B 的流程图

  • 第一步:内存取出 i 值,加载进寄存器
  • 第二步:对寄存器内的 i 值加 1
  • 第三步:寄存器内的 i 值取出 加载进内存

小结

这种状况称为竞争条件(race condition),多线程相互竞争操作共享资源时,因为运气不好,在执行过程中产生线程上下文切换,最初失去谬误的后果,事实上,每次运行都可能失去不同的后果,因而输入的后果存在不确定性(indeterminate)。

互斥与同步

为了解决因竞争条件呈现的线程平安,操作系统是通过互斥与同步来解决此类问题。

互斥概念

多线程执行共享变量的这段代码可能会导致竞争状态,因而咱们将此段代码称为临界区(critical section),它是执行共享资源的代码片段,肯定不能给多线程同时执行。

所以咱们心愿这段代码是互斥(mutualexclusion)的,也就说执行临界区(critical section)代码段的只能有一个线程,其余线程阻塞期待,达到排队成果。

互斥并不只是针对多线程的竞争条件,同时还可用于多过程,防止共享资源凌乱。

同步概念

互斥解决了「多过程 / 线程」对临界区应用的问题,然而它没有解决「多过程 / 线程」协同工作的问题

咱们都晓得在多线程里,每个线程肯定是程序执行的,它们各自独立,以不可预知的速度向前推动,但有时候咱们心愿多个线程能密切合作,以实现一个独特的工作。

所谓同步,就是「多过程 / 线程间」在一些关键点上可能须要相互期待与互通音讯,这种互相制约的期待与互通信息称为「过程 / 线程」同步。

举个例,有两个角色别离是研发、品质管控,品质管控测试性能,须要等研「发实现开发」,研发要修 bug 也要等品质管控「测试实现提交 B U G」,失常流程是研发实现开发,告诉品质管控进行测试,品质管控测试实现,告诉研发人员修复 bug。

互斥与同步的区别

  • 互斥:某一资源同时只容许一个访问者对其进行拜访,具备唯一性和排它性。但互斥无奈限度访问者对资源的拜访程序,即拜访是无序的(操作 A 和操作 B 不能在同一时刻执行)
  • 同步:互斥的根底上,通过其它机制实现访问者对资源的有序拜访。在大多数状况下,同步曾经实现了互斥(操作 A 应在操作 B 之前执行,操作 C 必须在操作 A 和操作 B 都实现之后能力执行)

显然,同步是一种更为简单的互斥,而互斥是一种非凡的同步。也就是说互斥是两个线程之间不能够同时运行,他们会互相排挤,必须期待一个线程运行结束,另一个能力运行,而同步也是不能同时运行,但他是必须要依照某种秩序来运行相应的线程(也是一种互斥)!

互斥与同步的实现

互斥与同步能够保障「多过程 / 线程间正确合作」,然而互斥与同步仅仅只是概念,操作系统必须要提供对应的实现,针对互斥与同步的实现有上面两种

  • 锁:加锁、解锁操作(互斥)
  • 信号量:P、V 操作(同步)

这两个种形式都能够实现「多过程 / 线程」互斥,信号量比锁的性能更强一些,它还能够不便地实现「多过程 / 线程」同步。

顾名思义,给临界区上一把锁,任何进入临界区)的线程,必须先执行加锁操作,加锁胜利,能力进入临界区,在来到临界区时再开释锁,达到互斥的成果。

锁的实现形式又分为「忙期待锁」和「无忙期待锁」

忙等锁

查看并设置(test-and-set-lock,TSL)是一种不可中断的原子运算,它属于原子操作指令,能够通过它来实现忙等锁(自旋锁)。

test-and-set-lock 指令伪代码

查看并设置做了如下几个步骤

  • 查看旧值是否相等
  • 相等设置新值,返回原旧值(胜利)
  • 不相等,无任何操作,间接返回原旧值(失败)

下面的步骤,把它看成一步并具备原子性,原子性的意思是指全副执行或都不执行,不会呈现执行到一半的中间状态.

伪代码 testAndSetLock 实现忙等锁(自旋锁)

上面两种场景运行

  • 单线程:假如一个线程拜访临界区,执行 getLock 办法,查看旧值 0 通过,更新原旧值 0 为新值 1,返回原旧值 0,获取锁胜利,来到临界区时,执行 unLock 办法,查看旧值 1 通过,更新原旧值 1 为新值 0,开释锁胜利。
  • 多线程:假如两个线程,线程 A 拜访临界区,执行 getLock 办法,查看旧值 0 通过,更新原旧值 0 为新值 1,返回原旧值 0,获取锁胜利,此时线程 B 执行 getLock 办法,旧值查看失败,获取锁失败,始终循环直到更新胜利为止,当线程 A 来到临界区时,执行 unLock 办法,查看旧值 1 通过,更新原旧值 1 为新值 0,开释锁胜利,线程 B 获取锁胜利。

当获取不到锁时,线程就会始终 wile 循环,不做任何事件,所以就被称为忙期待锁,也被称为自旋锁。

这是最简略的锁,始终自旋,利用 C P U 周期,直到锁可用。在单处理器上,须要抢占式的调度器(即一直通过时钟中断一个线程,运行其余线程)。否则,自旋锁在 C P U 上无奈应用,因为一个自旋的线程永远不会放弃 C P U。

无忙等锁

顾名思义,无忙等锁不须要被动自旋,被动期待唤醒即可,在没有获取到锁的时候,就把该线程退出到期待队列,让出 C P U 给其余线程,其余线程开释锁时,再从期待队列唤醒该线程。

两种锁的实现都是基于查看并设置(test-and-set-lock,TSL),下面只是简略的伪代码,实际上操作系统的实现会更简单,然而根本思维与大抵流程还是与本例一样。

信号量

操作系统中协调「多线程 / 过程」独特配合工作,就是通过信号量实现的,通常信号量代表「资源数量」,对应一个整型(s e n)变量,还有两个原子操作的零碎调用函数来管制「资源数量」。

  • P 操作:将 s e n1,相减后,如果 s e n < 0,则过程 / 线程进入阻塞期待,否则持续,P 操作可能会阻塞
  • V 操作:将 s e n1,相加后,如果 s e n <= 0,唤醒期待中的过程 / 线程,V 操作不会阻塞

P V 操作必须是成对呈现,然而没有程序要求,也就说你能够 P V 或 V P。

举个例子,最近新冠病毒又进去捣鬼了,为了本身平安,大家都去打疫苗,因为医生只有两位(相当于 2 个资源的信号量),所以同时只能为两个人接种疫苗,过程如下图

  • 信号量等于 0 时,代表无资源可用
  • 信号量小于 0 时,代表有线程在阻塞
  • 信号量大于 0 时,代表资源可用

应用伪代码实现 P V 信号量

P V 操作的函数是由操作系统治理和实现的,所以 P V 函数是具备原子性的。

实际

信号量还是比拟有意思的,这里来做几个实际,加深大家对信号量的了解,实际的内容别离是

  • 信号量实现互斥
  • 信号量实现事件同步
  • 信号量实现生产者与消费者

互斥

应用信号量实现互斥非常简单,信号量数量为1,线程进入临界区进行 P 操作,来到临界区进行 V 操作。

事件同步

以后面说的研发、品质管控线程为例子,实现事件同步的成果,伪代码如下

首先形象出两个信号量,「是否能提测」与「是否能修 BUG」,它们默认都是否,也就是 0,关键点就是对两个信号量进行 P V 操作

  • 品质管控线程询问开发线程有没有实现开发,执行 P 操作 p(this.rDSemaphore)

    • 如果没有实现开发,this.rDSemaphore1 后果为 -1,品质管控线程阻塞期待唤醒(等后续研发线程进行 V 操作)
    • 如果实现开发,阐明研发线程先执行 V 操作 v(this.rDSemaphore) 实现开发,this.rDSemaphore1 后果 1,此时品质管控线程 P 操作 this.rDSemaphore1 后果 0,进行前面的提测工作
  • 研发线程询问品质管控线程能不能修复 B U G,执行 P 操作 p(this.qualitySemaphore)

    • 如果不能够修复 B U G,this.qualitySemaphore1 后果为 -1,研发线程阻塞期待唤醒(等后续品质管控线程执行 V 操作)
    • 如果能够修复 B U G,阐明品质管控线程先执行 V 操作 v(this.qualitySemaphore) 提交 BUG,this.qualitySemaphore1 后果为 1,此时研发线程 P 操作 this.qualitySemaphore1 后果 0,进行前面的修复 B U G 操作
  • 流程

    • 品质管控线程执行 P 操作 p(this.rDSemaphore) 能不能提测,this.rDSemaphore1 后果是 -1,不能进行提测,品质管控线程阻塞期待唤醒
    • 研发线程运行,执行 V 操作 v(this.rDSemaphore) 实现研发性能,this.rDSemaphore1 后果是 0,告诉品质管控线程提测
    • 研发线程继续执行 P 操作 p(this.qualitySemaphore) 能不能修复 B U G,this.qualitySemaphor1 后果是 -1,不能修复 B U G,研发线程阻塞期待唤醒
    • 品质管控线程唤醒后进行提测,提测结束执行 V 操作 v(this.qualitySemaphore) 实现提测与提交相干 B U G,this.qualitySemaphore1 后果是 0,告诉研发线程进行 B U G 修复

生产者与消费者

生产者与消费者是一个比拟经典的线程同步问题,咱们先剖析下有那些角色

  • 生产者:生产事件放入缓冲区
  • 消费者:从缓冲区生产事件
  • 缓冲区:装载事件的容器

问题剖析能够得出:

  • 任何时刻只能有一个线程操作缓冲区,阐明操作缓冲区是临界代码,须要互斥
  • 缓冲区空时,消费者必须期待生产者生成数据
  • 缓冲区满时,生产者必须期待消费者取出数据

通过问题剖析咱们能够形象出 3 个信号量

  • 互斥信号量:互斥拜访缓冲区,初始化 1
  • 消费者资源信号量:缓冲区是否有事件,初始化 0,无事件
  • 生产者信号量:缓冲区是否有空位装载事件,初始化 N(缓冲区大小)

伪代码如下

要害的 P V 操作如下

  • 生产线程,在往缓冲区装载事件之前,执行 P 操作 p(this.produceSemaphore),缓冲区空槽数量减 1,后果 < 0 阐明无空槽,阻塞期待「生产线程」唤醒,否则执行后续逻辑
  • 不论是生产线程还是生产线程在操作缓冲区都要执行 P V 临界区操作 p(this.mutexSemaphore)v(this.mutexSemaphore),这里就不做过多概述了
  • 生产线程,在从缓存区生产事件之前,执行 P 操作 p(this.consumeSemaphore),缓冲区事件数量减 1,后果 < 0 阐明缓冲区无事件生产,阻塞期待「生产线程」唤醒,否执行后续逻辑
  • 生产线程与生产线程,执行完「装载 / 生产」后,都要唤醒对应的「生产 / 生产线程」,执行 V 操作「缓冲区空槽加 1/ 缓冲区事件加 1

对于我

公众号 : 「程序猿阿星」 专一技术原理、源码,通过图解形式输入技术,这里将会分享操作系统、计算机网络、Java、分布式、数据库等精品原创文章,期待你的关注。

正文完
 0