先来看看虚构的小故事
已经晚上 11 点了,程序员小明的双手还在键盘上飞舞着,眼神依然注视着的电脑屏幕。
没办法这段时间公司业绩增长中,需求自然也多了起来,加班自然也少不了。
天气变化莫测,这时窗外下起了蓬勃大雨,同时闪电轰鸣。
但这一丝都没有影响到小明,始料未及,突然一道巨大的雷一闪而过,办公楼就这么停电了,随后整栋楼都在回荡着的小明那一声撕心裂肺的「卧槽」。
此时,求小明的心里面积有多大?
等小明心里平复后,突然肚子非常的痛,想上厕所,小明心想肯定是晚上吃的某堡王有问题。
整栋楼都停了电,小明两眼一抹黑,啥都看不见,只能靠摸墙的方法,一步一步的来到了厕所门口。
到了厕所(共享资源),由于实在太急,小明直接冲入了厕所里,用手摸索着刚好第一个门没锁门,便夺门而入。
这就荒唐了,这个门里面正好小红在上着厕所,正好这个厕所门是坏了的,没办法锁门。
黑暗中,小红虽然看不见,但靠着声音,发现自己面前的这扇门有动静,觉得不对劲,于是铆足了力气,用她穿着高跟鞋脚,用力地一脚踢了过去。
小明很幸运,被踢中了「命根子」,撕心裂肺地喊出了一个字「痛」!
故事说完了,扯了那么多,实际上是为了说明,对于共享资源,如果没有上锁,在多线程的环境里,那么就可能会发生翻车现场。
接下来,用 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 的执行过程,我画了一张流程图,会更明确一些:
上面展示的情况称为竞争条件(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 操作:将
sem
减1
,相减后,如果sem < 0
,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞; - V 操作:将
sem
加1
,相加后,如果sem
关注打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?