多线程为了同个资源打起架来了,该如何让他们安定?


前言

先来看看虚构的小故事程序员

已经晚上 11 点了,程序员小明的双手还在键盘上飞舞着,眼神依然注视着的电脑屏幕。面试

没办法这段时间公司业绩增加中,需求天然也多了起来,加班天然也少不了。算法

天气变化莫测,这时窗外下起了蓬勃大雨,同时闪电轰鸣。数据库

但这一丝都没有影响到小明,始料未及,忽然一道巨大的雷一闪而过,办公楼就这么停电了,随后整栋楼都在回荡着的小明那一声撕心裂肺的「卧槽」。数组

此时,求小明的内心面积有多大?微信

等小明内心平复后,忽然肚子很是的痛,想上厕所,小明心想确定是晚上吃的某堡王有问题。数据结构

整栋楼都停了电,小明两眼一抹黑,啥都看不见,只能靠摸墙的方法,一步一步的来到了厕所门口。多线程

到了厕所(共享资源),因为实在太急,小明直接冲入了厕所里,用手摸索着恰好第一个门没锁门,便夺门而入。并发

这就荒唐了,这个门里面正好小红在上着厕所,正好这个厕所门是坏了的,没办法锁门。app

黑暗中,小红虽然看不见,但靠着声音,发现本身面前的这扇门有动静,以为不对劲,因而铆足了力气,用她穿着高跟鞋脚,用力地一脚踢了过去。

小明很幸运,被踢中了「命根子」,撕心裂肺地喊出了一个字「痛」!

故事说完了,扯了那么多,其实是为了说明,对于共享资源,若是没有上锁,在多线程的环境里,那么就可能会发生翻车现场。

接下来,用 30+ 张图,带你们走进操做系统中避免多线程资源竞争的互斥、同步的方法。


正文

竞争与协做

在单核 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。

如今,一件不幸的事情发生了:时钟中断发生。所以,操做系统将当前正在运行的线程的状态保存到线程的线程控制块 TCP。

如今更糟的事情发生了,线程 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 的执行过程,我画了一张流程图,会更明确一些:

蓝色表示线程 1 ,红色表示线程 2蓝色表示线程 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。

  • 第二种场景是,当某一个线程已经持有锁(即 flag 为1)。本线程调用 lock(),而后调用 TestAndSet(flag, 1),这一次返回 1。只要另外一个线程一直持有锁,TestAndSet() 会重复返回 1,本线程会一直忙等。当 flag 终于被改成 0,本线程会调用 TestAndSet(),返回 0 而且原子地设置为 1,从而得到锁,进入临界区。

很明显,当获取不到锁时,线程就会一直 wile 循环,不作任何事情,因此就被称为「忙等待锁」,也被称为自旋锁(spin lock

这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,须要抢占式的调度器(即不断经过时钟中断一个线程,运行其余线程)。不然,自旋锁在单 CPU 上没法使用,由于一个自旋的线程永远不会放弃 CPU。

再来看看「无等待锁」的实现

无等待锁顾明思议就是获取不到锁的时候,不用自旋。

既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,而后执行调度程序,把 CPU 让给其余线程执行。

无等待锁的实现无等待锁的实现

本次只是提出了两种简单锁的实现方式。固然,在具体操做系统实现中,会更复杂,但也离不开本例子两个基本元素。

若是你想要对锁的更进一步理解,推荐你们能够看《操做系统导论》第 28 章锁的内容,这本书在「微信读书」就能够免费看。

信号量

信号量是操做系统提供的一种协调共享资源访问的方法。

一般信号量表示资源的数量,对应的变量是一个整型(sem)变量。

另外,还有两个原子操做的系统调用函数来控制信号量的,分别是:

  • P 操做:将 sem1,相减后,若是 sem < 0,则进程/线程进入阻塞等待,不然继续,代表 P 操做可能会阻塞;
  • V 操做:将 sem1,相加后,若是 sem <= 0,唤醒一个等待中的进程/线程,代表 V 操做不会阻塞;

P 操做是用在进入临界区以前,V 操做是用在离开临界区以后,这两个操做是必须成对出现的。

举个类比,2 个资源的信号量,至关于 2 条火车轨道,PV 操做以下图过程:

信号量与火车轨道信号量与火车轨道

操做系统是如何实现 PV 操做的呢?

信号量数据结构与 PV 操做的算法描述以下图:

PV 操做的算法描述PV 操做的算法描述

PV 操做的函数是由操做系统管理和实现的,因此操做系统已经使得执行 PV 函数时是具备原子性的。

PV 操做如何使用的呢?

信号量不只能够实现临界区的互斥访问控制,还能够线程间的事件同步。

咱们先来讲说如何使用信号量实现临界区的互斥访问

为每类共享资源设置一个信号量 s,其初值为 1,表示该临界资源未被占用。

只要把进入临界区的操做置于 P(s)V(s) 之间,便可实现进程/线程互斥:

此时,任何想进入临界区的线程,必先在互斥信号量上执行 P 操做,在完成对临界资源的访问后再执行 V 操做。因为互斥信号量的初始值为 1,故在第一个线程执行 P 操做后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。

若此时又有第二个线程想进入临界区,也应先执行 P 操做,结果使 s 变为负值,这就意味着临界资源已被占用,所以,第二个线程被阻塞。

而且,直到第一个线程执行 V 操做,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操做,使 s 恢复到初始值 1。

对于两个并发线程,互斥信号量的值仅取 一、0 和 -1 三个值,分别表示:

  • 若是互斥信号量为 1,表示没有线程进入临界区;
  • 若是互斥信号量为 0,表示有一个线程进入临界区;
  • 若是互斥信号量为 -1,表示一个线程进入临界区,另外一个线程等待进入。

经过互斥信号量的方式,就能保证临界区任什么时候刻只有一个线程在执行,就达到了互斥的效果。

再来,咱们说说如何使用信号量实现事件同步

同步的方式是设置一个信号量,其初值为 0

咱们把前面的「吃饭-作饭」同步的例子,用代码的方式实现一下:

妈妈一开始询问儿子要不要作饭时,执行的是 P(s1) ,至关于询问儿子需不须要吃饭,因为 s1 初始值为 0,此时 s1 变成 -1,代表儿子不须要吃饭,因此妈妈线程就进入等待状态。

当儿子肚子饿时,执行了 V(s1),使得 s1 信号量从 -1 变成 0,代表此时儿子须要吃饭了,因而就唤醒了阻塞中的妈妈线程,妈妈线程就开始作饭。

接着,儿子线程执行了 P(s2),至关于询问妈妈饭作完了吗,因为 s2 初始值是 0,则此时 s2 变成 -1,说明妈妈还没作完饭,儿子线程就等待状态。

最后,妈妈终于作完饭了,因而执行 V(s2)s2 信号量从 -1 变回了 0,因而就唤醒等待中的儿子线程,唤醒后,儿子线程就能够进行吃饭了。

生产者-消费者问题

生产者-消费者模型生产者-消费者模型

生产者-消费者问题描述:

  • 生产者在生成数据后,放在一个缓冲区中;
  • 消费者从缓冲区取出数据处理;
  • 任什么时候刻,只能有一个生产者或消费者能够访问缓冲区;

咱们对问题分析能够得出:

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

那么咱们须要三个信号量,分别是:

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

具体的实现代码:

若是消费者线程一开始执行 P(fullBuffers),因为信号量 fullBuffers 初始值为 0,则此时 fullBuffers 的值从 0 变为 -1,说明缓冲区里没有数据,消费者只能等待。

接着,轮到生产者执行 P(emptyBuffers),表示减小 1 个空槽,若是当前没有其余生产者线程在临界区执行代码,那么该生产者线程就能够把数据放到缓冲区,放完后,执行 V(fullBuffers) ,信号量 fullBuffers 从 -1 变成 0,代表有「消费者」线程正在阻塞等待数据,因而阻塞等待的消费者线程会被唤醒。

消费者线程被唤醒后,若是此时没有其余消费者线程在读数据,那么就能够直接进入临界区,从缓冲区读取数据。最后,离开临界区后,把空槽的个数 + 1。


经典同步问题

哲学家就餐问题

当初我在校招的时候,面试官也问过「哲学家就餐」这道题目,我当时听的一脸懵逼,不管面试官怎么讲述这个问题,我也始终没听懂,就莫名其妙的说这个问题会「死锁」。

固然,我这回答槽透了,因此当场 game over,残酷又悲惨故事,就很少说了,反正当时菜就是菜。

时至今日,看我来图解这道题。

哲学家就餐的问题哲学家就餐的问题

先来看看哲学家就餐的问题描述:

  • 5 个老大哥哲学家,闲着没事作,围绕着一张圆桌吃面;
  • 巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子;
  • 哲学家围在一块儿先思考,思考中途饿了就会想进餐;
  • 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是须要拿到左右两边的叉子才进餐
  • 吃完后,会把两支叉子放回原处,继续思考

那么问题来了,如何保证哲 学家们的动做有序进行,而不会出现有人永远拿不到叉子呢?

方案一

咱们用信号量的方式,也就是 PV 操做来尝试解决它,代码以下:

上面的程序,好似很天然。拿起叉子用 P 操做,表明有叉子就直接用,没有叉子时就等待其余哲学家放回叉子。

方案一的问题方案一的问题

不过,这种解法存在一个极端的问题:假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人可以拿到他们右边的叉子,也就说每一位哲学家都会在 P(fork[(i + 1) % N ]) 这条语句阻塞了,很明显这发生了死锁的现象

方案二

既然「方案一」会发生同时竞争左边叉子致使死锁的现象,那么咱们就在拿叉子前,加个互斥信号量,代码以下:

上面程序中的互斥信号量的做用就在于,只要有一个哲学家进入了「临界区」,也就是准备要拿叉子时,其余哲学家都不能动,只有这位哲学家用完叉子了,才能轮到下一个哲学家进餐。

方案二的问题方案二的问题

方案二虽然能让哲学家们按顺序吃饭,可是每次进餐只能有一位哲学家,而桌面上是有 5 把叉子,按道理是能能够有两个哲学家同时进餐的,因此从效率角度上,这不是最好的解决方案。

方案三

那既然方案二使用互斥信号量,会致使只能容许一个哲学家就餐,那么咱们就不用它。

另外,方案一的问题在于,会出现全部哲学家同时拿左边刀叉的可能性,那咱们就避免哲学家能够同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不一样,而采起不一样的动做。

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

上面的程序,在 P 操做时,根据哲学家的编号不一样,拿起左右两边叉子的顺序不一样。另外,V 操做是不须要分支的,由于 V 操做是不会阻塞的。

方案三可解决问题方案三可解决问题

方案三即不会出现死锁,也能够两人同时进餐。

方案四

在这里再提出另一种可行的解决方案,咱们用一个数组 state 来记录每一位哲学家在进程、思考仍是饥饿状态(正在试图拿叉子)。

那么,一个哲学家只有在两个邻居都没有进餐时,才能够进入进餐状态。

i 个哲学家的左邻右舍,则由宏 LEFTRIGHT 定义:

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

好比 i 为 2,则 LEFT 为 1,RIGHT 为 3。

具体代码实现以下:

上面的程序使用了一个信号量数组,每一个信号量对应一位哲学家,这样在所需的叉子被占用时,想进餐的哲学家就被阻塞。

注意,每一个进程/线程将 smart_person 函数做为主代码运行,而其余 take_forksput_forkstest 只是普通的函数,而非单独的进程/线程。

方案四也可解决问题方案四也可解决问题

方案四一样不会出现死锁,也能够两人同时进餐。

读者-写者问题

前面的「哲学家进餐问题」对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用。

另外,还有个著名的问题是「读者-写者」,它为数据库访问创建了一个模型。

读者只会读取数据,不会修改数据,而写者便可以读也能够修改数据。

读者-写者的问题描述:

  • 「读-读」容许:同一时刻,容许多个读者同时读
  • 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
  • 「写-写」互斥:没有其余写者时,写者才能写

接下来,提出几个解决方案来分析分析。

方案一

使用信号量的方式来尝试解决:

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

接下来看看代码的实现:

上面的这种实现,是读者优先的策略,由于只要有读者正在读的状态,后来的读者均可以直接进入,若是读者持续不断进入,则写者会处于饥饿状态。

方案二

那既然有读者优先策略,天然也有写者优先策略:

  • 只要有写者准备要写入,写者应尽快执行写操做,后来的读者就必须阻塞;
  • 若是有写者持续不断写入,则读者就处于饥饿;

在方案一的基础上新增以下变量:

  • 信号量 rMutex:控制读者进入的互斥信号量,初始值为 1;
  • 信号量 wDataMutex:控制写者写操做的互斥信号量,初始值为 1;
  • 写者计数 wCount:记录写者数量,初始值为 0;
  • 信号量 wCountMutex:控制 wCount 互斥修改,初始值为 1;

具体实现以下代码:

注意,这里 rMutex 的做用,开始有多个读者读数据,它们所有进入读者队列,此时来了一个写者,执行了 P(rMutex) 以后,后续的读者因为阻塞在 rMutex 上,都不能再进入读者队列,而写者到来,则能够所有进入写者队列,所以保证了写者优先。

同时,第一个写者执行了 P(rMutex) 以后,也不能立刻开始写,必须等到全部进入读者队列的读者都执行完读操做,经过 V(wDataMutex) 唤醒写者的写操做。

方案三

既然读者优先策略和写者优先策略都会形成饥饿的现象,那么咱们就来实现一下公平策略。

公平策略:

  • 优先级相同;
  • 写者、读者互斥访问;
  • 只能一个写者访问临界区;
  • 能够有多个读者同时访问临街资源;

具体代码实现:

看完代码不知你是否有这样的疑问,为何加了一个信号量 flag,就实现了公平竞争?

对比方案一的读者优先策略,能够发现,读者优先中只要后续有读者到达,读者就能够进入读者队列, 而写者必须等待,直到没有读者到达。

没有读者到达会致使读者队列为空,即 rCount==0,此时写者才能够进入临界区执行写操做。

而这里 flag 的做用就是阻止读者的这种特殊权限(特殊权限是只要读者到达,就能够进入读者队列)。

好比:开始来了一些读者读数据,它们所有进入读者队列,此时来了一个写者,执行 P(falg) 操做,使得后续到来的读者都阻塞在 flag 上,不能进入读者队列,这会使得读者队列逐渐为空,即 rCount 减为 0。

这个写者也不能立马开始写(由于此时读者队列不为空),会阻塞在信号量 wDataMutex 上,读者队列中的读者所有读取结束后,最后一个读者进程执行 V(wDataMutex),唤醒刚才的写者,写者则继续开始进行写操做。


唠叨唠叨

不负众望,小林又拖延了,又没有达到「周更」,惭愧惭愧,估计不少读者都快忘记小林是谁了,小林求求你们不要取关呀!

是的,就在上上周,大好的周末,小林偷懒了,重温了两天的「七龙珠 Z 」动漫,看的确实很爽啊,虽然已经看过不少遍了,谁叫我是龙珠迷。

没办法这就是生活,时而松时而紧。

小林是专为你们图解的工具人,Goodbye,咱们下次见!


好文推荐:

进程和线程基础知识全家桶,30 张图一套带走

20 张图揭开内存管理的迷雾,瞬间豁然开朗

相关文章
相关标签/搜索