多线程冲突了怎么办?(多线程的同步与互斥问题)

竞争与协作

在单核 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 处理。
所谓同步,就是并发进程 / 线程在⼀些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通 信息称为进程 / 线程同步
举个⽣活的同步例⼦,你肚⼦饿了想要吃饭,你叫妈妈早点做菜,妈妈听到后就开始做菜,但是在妈妈没有做完饭之前,你必须阻塞等待,等妈妈做完饭后,⾃然会通知你,接着你吃饭的事情就可以进⾏了。

互斥与同步的实现和使用

在进程 / 线程并发执⾏的过程中,进程 / 线程之间存在协作的关系,例如有互斥、同步的关系。
为了实现进程 / 线程间正确的协作,操作系统必须提供实现进程协作的措施和⽅法,主要的⽅法有两种:
  • :加锁、解锁操作;
  • 信号量PV 操作;
这两个都可以⽅便地实现进程 / 线程互斥,⽽信号量⽐锁的功能更强⼀些,它还可以⽅便地实现进程 / 线程同步。

使⽤加锁操作和解锁操作可以解决并发线程 / 进程的互斥问题。
任何想进⼊临界区的线程,必须先执⾏加锁操作。若加锁操作顺利通过,则线程可进⼊临界区;在完成对临界资源的访问后再执⾏解锁操作,以释放该临界资源。
根据锁的实现不同,可以分为「忙等待锁」和「⽆忙等待锁」。
我们先来看看「忙等待锁」的实现
在说明「忙等待锁」的实现之前,先介绍现代 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 操作:将 sem 1 ,相减后,如果 sem < 0 ,则进程/线程进⼊阻塞等待,否则继续,表明 P操作可能会阻塞;
  • V 操作:将 sem 1 ,相加后,如果 sem <= 0 ,唤醒⼀个等待中的进程/线程,表明 V 操作不会阻塞;
P 操作是⽤在进⼊临界区之前, V 操作是⽤在离开临界区之后,这两个操作是必须成对出现的。
举个类⽐, 2 个资源的信号量,相当于 2 条⽕⻋轨道, 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
对于两个并发线程,互斥信号量的值仅取 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

经典同步问题

哲学家就餐问题

 
先来看看哲学家就餐的问题描述:
  • 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
具体代码实现如下:
上⾯的程序使⽤了⼀个信号量数组,每个信号量对应⼀位哲学家,这样在所需的叉⼦被占⽤时,想进餐的哲学家就被阻塞。
注意,每个进程 / 线程将 smart_person 函数作为主代码运⾏,⽽其他 take_forks put_forks
test 只是普通的函数,⽽⾮单独的进程 / 线程。
⽅案四同样不会出现死锁,也可以两⼈同时进餐。

读者-写者问题

前⾯的「哲学家进餐问题」对于互斥访问有限的竞争问题(如 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) ,唤醒刚才的写者,写者则继续开始进⾏写操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

编程小猹

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值