(学习笔记-进程管理)多线程冲突如何解决

对于共享资源,如果没有上锁,在多线程的环境里,很有可能发生翻车。


竞争与合作

在单核 CPU 系统里,为了实现多个程序同时运行的假象,操作系统通常以时间片调度的方式,让每个进程每次执行一个时间片,时间片用完了,就切换下一个进程运行,由于这个时间片的时间很短,于是就造成了并发现象

另外,操作系统也为每个进程创建巨大,私有的虚拟内存的假象,这种地址空间的抽象,让每个程序好像拥有自己的内存,而实际上操作系统在背后秘密地让多个地址空间 复用 物理内存或磁盘。

如果一个程序只有一个执行流程,也代表它是单线程的。当然一个程序可以有多个执行流程,也就是所谓的多线程程序,线程是调度的基本单位,进程则是资源分配的基本单位。

所以,线程之间是可以共享进程的资源,比如代码段、堆空间、数据段、打开的文件等资源、但每个线程都有自己独立的栈空间。

那么问题就来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。

实验:创建两个线程,它们分别对共享变量 i 自增 1 执行 10000 次,如下代码:

按理来说, i 变量最后应该是 20000 ,但是,实际结果如下:

运行了两次,发现结果 i 值可能会20000,也可能为其他结果。

每次运行不但会产生错误,而且得到不同的结果。在计算机里是不能容忍的,虽然是小概率出现的错误,但是小概率事件它一定是会发生的。

为什么会出现这种情况?

为了理解为什么会发生这种情况,必须了解编译器为更新计数器 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.

针对2上面线程 1 和 线程 2 的执行过程,可以表示为:

互斥

上面展示的情况称为竞争条件 ,当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性

由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区,它是访问共享资源的代码片段,一定不能给多线程同时执行

我们希望这段代码是互斥的,也就是说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,即这段代码执行过程中,最多只能出现一个线程。

另外,互斥并不是只针对多线程。在多线程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。

同步

互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个进程/线程加入了临界区,其他试图想进入临界区的进程/线程都会被阻塞,直到第一个进程/线程离开了临界区。

在多线程里,每个线程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但是有时候我们又希望多个线程能密切合作,以实现一个共同的任务

例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。

所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种互相制约的等待和互通信息称为进程/线程同步

PS:同步和互斥是两种不同的概念:

  • 同步: [操作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 ,从而获得锁,进入临界区。

很明显,当获取不到锁时,线程会一直 while 循环,不做任何事情,所以就被称为 [忙等待锁],也被称为 自旋锁

这是最简单的一种锁,一直自旋,利用CPU周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程。)否则,自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会放弃CPU。

无等待锁 的实现

无等待锁:获取不到锁的时候,不用自旋。当没有获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把CPU让给其他线程执行

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

信号量

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

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

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

  • P操作:将 sem 减 1 ,相减后,如果 sem < 0 ,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
  • V操作:将 sem 加 1 ,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;

TIP 为什么V 操作中是sem <= 0

例子:如果 sem = 1 ,有三个线程进行了 P 操作:

  • 第一个线程 P 操作后 ,sem = 0;第一个线程继续运行
  • 第二个线程 P 操作后,sem = -1;sem < 0 第二个线程阻塞等待
  • 第三个线程 P 操作后,sem = -2;sem < 0 第三个线程阻塞等待

这时,第一个线程在执行 V 操作后,sem=-1;sem <=0,所以要唤醒阻塞等待中的第二个线程或第三个线程。

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(fullBuffers),表示减少 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_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) 唤醒写者的写操作。

方案三

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

公平策略:

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

具体代码实现:

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

没有读者到达就会导致读者队列为空,即 rCount = 0,此时写者才可以进入临界区执行写操作。

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

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

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

相关推荐
李小星同志6 分钟前
高级算法设计与分析 学习笔记6 B树
笔记·学习
霜晨月c17 分钟前
MFC 使用细节
笔记·学习·mfc
Jhxbdks30 分钟前
C语言中的一些小知识(二)
c语言·开发语言·笔记
小江湖199431 分钟前
元数据保护者,Caesium压缩不丢重要信息
运维·学习·软件需求·改行学it
AlexMercer10121 小时前
【C++】二、数据类型 (同C)
c语言·开发语言·数据结构·c++·笔记·算法
dot.Net安全矩阵1 小时前
.NET内网实战:通过命令行解密Web.config
前端·学习·安全·web安全·矩阵·.net
微刻时光1 小时前
Redis集群知识及实战
数据库·redis·笔记·学习·程序人生·缓存
chnyi6_ya2 小时前
一些写leetcode的笔记
笔记·leetcode·c#
青椒大仙KI113 小时前
24/9/19 算法笔记 kaggle BankChurn数据分类
笔记·算法·分类
liangbm33 小时前
数学建模笔记——动态规划
笔记·python·算法·数学建模·动态规划·背包问题·优化问题