什么是线程安全?一文带你深刻理解

前言

欢迎来到操做系统系列,采用图解 + 大白话的形式来说解,让小白也能看懂,帮助你们快速科普入门。数据库

上篇文章有介绍过进程与线程的基础知识,进程下拥有多个线程,虽然多线程间通讯十分方便(同进程),可是却带来了线程安全问题,本篇主要就是介绍操做系统中是用什么方法解决多线程安全,废话很少说,进入正文吧。数组

博主但愿读者阅读文章后能够养成思考与总结的习惯,只有这样才能把知识消化成本身的东西,而不是单纯的去记忆缓存

内容大纲

小故事

带薪蹲坑,相信都是大伙都爱作的事情,阿星也不例外,可是我司所在的楼层的坑位较少,粥少僧多,十分烦恼。安全

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

假设门锁坏了,井井有理就不存在了,上厕所再也不是享受,而是高度紧张,防止门忽然被打开,更糟糕的是,开门时,是个妹子,这下不只仅是线程安全问题,还有数组越界了。多线程

故事说完,扯了那么多,就是想说明,在多线程环境里,对共享资源进行操做,若是多线程之间不作合理的协做(互斥与同步),那么必定会发生翻车现场。分布式

竞争条件

由于多线程共享进程资源,在操做系统调度进程内的多线程时,必然会出现多线程竞争共享资源问题,若是不采起有效的措施,则会形成共享资源的混乱!函数

来写个小例子,建立两个线程,它们分别对共享变量 i 自增 1 执行 1000 次,以下代码测试

正常来讲,i 变量最后的值是 2000 ,但是并不是如此,咱们执行下代码看看结果this

  • 结果:2000
  • 结果:1855

运行了两次,结果分别是185五、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、分布式、数据库等精品原创文章,期待你的关注。

相关文章
相关标签/搜索