【Linux】多线程 --- 线程同步与互斥+生产消费模型-1

简介: 【Linux】多线程 --- 线程同步与互斥+生产消费模型-1

人生总是那么痛苦吗?还是只有小时候是这样? —总是如此b23dcde528bc4233a4bf6d14ed0d83b2.jpeg


一、线程互斥

1.多线程共享资源访问的不安全问题


1.

假设现在有一份共享资源tickets,如果我们想让多个线程都对这个资源进行操作,也就是tickets- -的操作,但下面两份代码分别出现了不同的结果,上面代码并没有出现问题,而下面代码却出现了票为负数的情况,这是怎么回事呢?

其实问题产生就是由于多线程被调度器调度的特性导致的。b86797fb07df467db87b6aa225ca3948.png

2.

了解上面的问题需要知道线程调度的特性,实际线程在被调度时他的上下文会被加载到CPU的寄存器中,而线程在被切换的时候,线程又会带着自己的上下文被切换下去,此时要进行线程的上下文保存,以便于下次该线程被切换上来的时候能够进行上下文数据的恢复。

除此之外,像tickets- -这样的操作,对应的汇编指令其实至少有三条,1.读取数据 2.修改数据 3.写回数据,而线程函数我们知道会在每个线程的私有栈都存在一份,在上面的例子中多个线程执行同一份线程函数,所以这个线程函数就绝对会处于被重入的状态,也就绝对会被多个线程执行!今天我们假设只有一个CPU(CPU就是核心,处理器芯片会集成多个核心)在调度当前进程中的线程,那么线程是CPU调度的基本单位,所以也就会出现一个线程可能执行一半的时候被切换下去了,并且该线程的上下文被保存起来,然后CPU又去调度进程中的另一个线程。cd1df5c3d3cd44a2819a0a5cd5ab8c7b.png



3.

在知道上面的原理之后,还需要知道usleep的作用,当usleep放到if分支语句的第一行时,票数就出现了问题,出现了负数,主要是因为usleep可以将线程暂时阻塞,那么CPU就会把他切换下去,转而执行其他线程,但需要注意的是,如果被切换的线程重新调度上来时,还会从上次他执行后的语句继续向下运行。

所以会出现多个线程同时进入到分支判断语句,然后去阻塞等待的情况,假设tickets已经变成了1,然后其余的线程此时都被调度上来了,他们都开始执行tickets- -,- -之后不满足循环条件线程才会退出,那么如果我们创建出了4个线程,就会有3个线程在票数已经为0的情况下继续减减,所以就会出现票数为负数的情况。

46fbca7377cc4364842918f785b51bac.png


4.

而我们能够复现出问题其实主要靠的是usleep和逻辑判断与tickets- -分开,那么线程就有可能在执行if逻辑判断之后,还没有执行tickets- -之前就被切换下去了,而多个线程都出现这样的情况时,他们都被重新调度时,重新加载自己的上下文数据时,继续向后执行,但此时tickets已经没有了,共享资源tickets在多线程访问时就会出现数据不安全的问题。


5.

我们上面是将逻辑判断和tickets- -分开了,那是不是只要别分开,就不会出现问题呢?

答案并不是这样的,还是会出现问题的,只不过我们复现出这样的问题需要靠概率而已,所以并不是那么好复现。但我们只要知道原理就可以,下面再来分析一下只有tickets- -这一步的情况下,是否会出现问题呢?

我举了两个线程同时循环执行票数-1的例子。如果真要说到底,这些由于多线程操作共享资源而产生的问题,本质原因只有一个,他们可能在运行的一半被切换走了,连同他自己的上下文结构,而被切换走的同时,其他调度上来的线程依旧可以访问这个共享资源,但是被切换下去的线程不知道啊!没人告诉我啊!我和我的上下文就等着被CPU重新调度回去呢!但等我回来的时候,天都已经大变样了!我还啥都不知道,继续傻傻的操作共享变量,此时就出现共享资源数据不一致的问题了。862c3bab0d924cce90b6a1510e736dbd.png


2.提出解决方案:加锁(局部和静态锁的两种初始化/销毁方案)

2.1 对于锁的初步理解和实现


1.

那该如何解决上面的问题呢?多个执行流操作共享资源时,发生了数据不一致问题。

解决上面的问题实际要通过加锁来实现,但在谈论加锁的话题之前,我们需要来重新看待几个概念。


多个执行流总是能够共享许多资源,但在加锁保护后的共享资源我们称为临界资源。

而多个执行流执行的函数体内部,对临界资源进行操作的代码称为临界区,需要注意的是临界区不是整个函数体内部的代码,而是指对共享资源进行操作的代码称为临界区。

如果我们想让多个执行流串行的访问临界资源,而不是并发或并行的访问临界资源,这样的线程调度方案就是互斥式的访问临界资源!(串行就是指只要一个线程开始执行这个任务,那么他就不能中断,必须得等这个线程执行完这个任务,你才能切换其他线程执行其他的任务,这个概念等会讲完锁之后大家就明白什么是互斥了)

当线程在执行一个对资源访问的操作时,要么做了这个操作,要么没有做这个操作,只要两种状态,不会出现做了一半这样的状态,我们称这样的操作是原子性的。(就比如你妈让你写作业,你要么给我把作业写完了再出去玩,要么就一个字也别写给我滚出家门,就这两种状态,不会出现你写了一半,然后你妈让你出去玩的这种情况,这样也是原子性)

df1e8bccfb0c4841bb67f7f24a6f9023.png


2.

有了上面四组概念的稍稍铺垫之后,我们来谈谈如何对共享资源进行加锁和解锁,首先锁实际就是一种数据类型,这个锁就像我们平常定义出来的变量或是对象一样,只不过这个锁的类型是系统给我们封装好的一种类型,进行重定义后为pthread_mutex_t。变量或对象在生命的时候也是可以初始化的,变量初始化后,就是变量的定义,而不是声明了。变量和对象也都有自己的销毁方案,内置类型的变量销毁时,操作系统会自动回收其资源,而自定义对象销毁时,操作系统会调用其析构函数进行资源的回收。

锁同样也是如此,锁也有自己的初始化和销毁方案,如果你定义的是一把局部锁,就需要用pthread_mutex_init()和pthread_mutex_destroy()来进行初始化和销毁,如果你定义的是一把全局锁或静态所,则不需要用init初始化和destroy销毁,直接用PTHREAD_MUTEX_INITIALIZER进行初始化即可,他有自己的初始化和销毁方案,我们无须关心静态或全局锁如何销毁。

定义好锁之后,我们就可以对某一段代码进行加锁和解锁,加锁与解锁意味着,这段代码不是一般的代码,只有申请到锁,持有锁的线程才能访问这段代码,加锁和解锁之间的代码可以称为临界区,因为想要访问这段空间必须有锁才可以访问。pthread_mutex_lock实际就是申请锁的代码和临界区的入口,如果你申请锁成功了,那么你就可以进入临界区访问临界资源,如果你并没有申请成功,比如当前这把锁已经被别的线程申请到并持有了,其他线程正持有锁在临界区访问着呢,那么你就无法进入临界区,因为你并没有持有锁,必须得在pthread_mutex_lock这个接口外面等着,直到你申请到锁之后,你才能进入临界区访问临界资源,这样的线程访问实际就是互斥,指的是当一个线程正在持有锁访问临界区的时候,其他线程无法进入临界区,直到持有锁的线程释放锁之后才会有可能进入临界区,注意是有可能,因为当线程释放锁之后,这把锁还需要被竞争,哪个线程竞争到这把锁,哪个线程才能持有锁的访问临界资源!

2b297e93125a4ad19c179560158ae142.png


3.

上面谈论完锁的初始化和销毁,以及如何加锁和解锁之后,我们来利用锁解决上面出现的共享资源访问不安全的问题。你不是由于多线程再进行临界资源访问时,可能由于线程切换什么的,导致非原子性式的访问临界资源吗?那我不让你这么干,我对这段临界资源进行加锁,让你当前申请到锁正在访问临界资源的线程,必须给我以原子性的访问来访问临界资源,换句话说,你必须把访问临界资源的工作做完了,才可以,要么你不要访问临界资源,要么你访问了临界资源,就必须把临界资源全部访问完了,中间不能访问一半就不访问了!所以只要对临界资源进行加锁后,临界资源就变得安全了,因为无论什么线程想要访问临界资源,都必须以原子性的方式访问完,这样的话,就不会出现在访问一半的时候,线程被切换下去了,其他线程被切换上来继续访问临界资源了,而是说如果持有锁的线程被切换下去了,这个线程会抱着他申请到的锁被切换下去,此时其他线程如果被切换上来,想要访问临界资源,那也没用,因为你没有锁啊!持有锁的线程被切换时,是抱着锁被切换的,那你现在既然访问不了临界区,CPU无法继续执行代码,那就只能等持有锁的线程重新被切换上来时,才能继续开展临界资源的访问工作,这个工作必须且只能由申请到锁的线程来完成,其他任何线程都无法完成这个工作!反过来说,这不就是原子性吗?访问临界资源的工作只要被持有锁的线程开始做了,哪怕他在做的过程中被切换下去了,也无须担心,因为别的线程做不了这个工作,所以还是得等持有锁的线程被切换上来的时候才能继续做这个工作,那是不是这个工作只要开始做了,就一定会被做完呢?会不会出现做一半,停下来了不做了,让别的线程在去访问临界资源的情况呢?当然不会!这就是锁带来的作用。

532ddf0125874b55b042c7f2cc71c172.png


4.

如果在加锁之后运行代码,实际可以发现他抢票的速度是要比没加锁之前慢的,原因也很简单。我来给大家解释一下,没加锁之前,线程之间是可以并发或并行执行的,我先大概说一下并发和并行是什么,后面会详细介绍这两者的区别和概念,并发你可以简单理解为,当线程运行一半被切换下去的时候,此时CPU还可以调度运行其他线程,也就是说,如果多个线程在运行的时候,每个线程都会被CPU跑一跑,那在一段时间内,所有的线程都可以被执行到,并且推进每个线程的执行过程。而并行就是在多个核心上面同一时刻跑不同的线程,比如两个同时访问临界资源的线程,在未加锁的时候,可能出现多个核心同时执行两个线程的代码,同时在访问临界资源,但实际这种情况并不常见,因为我们写出来的代码优先级并没有那么高,所以基本上都是在按照并发执行的。

然后加锁前是并发执行的,也就是说在一个线程被切换下去的时候,其他- -tickets的线程还能够被重新调度上来进行票数的- -,那么总体上来说,票数就会被一直- -。

而加锁之后就不是并发执行的了,因为我们上面说过,加锁之后即使持有锁的线程被切换下去,其他被调度到CPU上的线程也是无法进行票数- -的,因为他们没有锁,所以在持有锁的线程被切换下去的这段时间里,票数不会改变,因为线程在串行的访问临界资源,什么是串行呢?就是一个线程访问完之后,才能轮到另一个线程,就是我们前面说的,一个线程在完成他的工作之后,释放完锁之后,其他线程才有可能竞争到锁,才有可能访问临界资源,这样就是串行。

串行的执行效率肯定要比并发执行的效率底嘛,因为当多线程在执行任务的时候,我们进行并发执行,为的就是当前线程如果被切换下去了,那也没啥事,因为其他被调度上来的线程依旧可以执行这个任务。你现在加锁之后就会变成串行执行了,那当前持有锁的线程被切换下去时,其他被调度上来的线程是无法继续执行任务的,效率自然就会底一些。(效率底一点就底一点吧,毕竟现在共享资源就安全了嘛,下面运行结果你也可以看到,没有锁的时候,票数就为负数了,这种情况用户怎么可能容忍。)

image.gif



2.2 局部和全局锁的两种加锁方案的代码实现


1.

如果定义局部锁的话,我们肯定是想要将这把锁传给每个线程的,让每个线程都用这把锁来互斥式的访问共享资源,以此来保证共享资源的安全性。并且我还想给每个线程带上名字,这样在打印结果上可以区分是哪个线程在进行抢票。

所以我们是不是需要一个结构体ThreadData来封装一下锁和线程名字呢?所以我们就定义出一个结构体,把结构体指针传给线程,让线程能够使用锁来访问临界资源!

42e0fefdd0e948419976154d517dbc8c.png

2.

接下来我们还要看一下,加锁之后的运行现象。在没有一次while循环之后的usleep(1000)时,可以看到发生抢票的用户,一段时间内基本都会是一个用户,比如打印结果中,如果是用户1抢票,那大概用户1要抢比较长的一段时间的票,然后才会换到其他用户,这是为什么呢?

因为锁只规定了线程必须互斥式的访问临界资源,但并没有规定哪个线程先去执行访问临界资源的操作!换句话说,只要你线程拿着锁来访问临界资源,那我就同意你访问,我管你是哪个线程呢,你有锁就行,也就是说,你释放完锁之后,在重新竞争锁的时候,如果你又能竞争到这把锁,那你就一直拿着这个锁来访问就好了。你要是能一直竞争到锁,那你就能一直来访问临界资源。

而下面现象我们其实可以看到,刚刚释放完锁的线程,在重新竞争锁的时候,这个线程的竞争能力是比较强的,所以就会出现下面的现象,一个用户抢票之后,大概还要抢很长时间的票。(同时其他线程就无法抢票,就只能眼巴巴的看着那个竞争能力强的线程一直在抢票,这样的现象我们称为饥饿状态,解决的方式实际是通过线程同步来解决的,这里先预热一下,后面会详细讲的。)

image.gif


3.

上面那种现象正确吗?当然是正确的!我这个线程竞争能力强嘛,我凭啥不能一直抢票呢?锁只规定了我要互斥式的访问临界资源,又没说必须是哪个线程先进行或后进行抢票,我就要一直抢票,你能把我怎么样?

但是!上面的现象虽然是正确的,但是他不河狸!比如抢火车票,这个票一直被一个用户抢,其他用户一直都抢不着,那铁路局咋赚钱呢?一个用户的消费咋能养活一个铁路局呢?肯定得多个用户消费啊!

所以除了使用线程同步来解决之外,还可以通过usleep(1000)来解决,睡眠的多少不重要,只要让线程在释放完锁之后,睡眠一会儿,将自己阻塞挂起(是否挂起是未知的,取决于OS)一会儿,阻塞挂起的时候,其他线程不就能竞争到锁了吗?那其他线程是不也可以进行抢票了?就不用眼巴巴的看着竞争能力强的那个线程一直在抢票了!image.gif


image.gifimage.gif


下面是有usleep和没有usleep的两次结果对比,没有usleep时,一个线程可能会霸占抢票较长时间,有usleep时,多个线程都可以协调的进行抢票,不会出现一个线程持续霸占抢票的情况。

image.png



4.

除了上面代码使用局部锁的实现方案外,我们还可以使用静态锁或全局锁,局部的静态锁还是需要将锁的地址传给线程函数,否则线程函数无法使用锁,因为锁是局部的嘛!如果是全局锁,那就不需要将其地址传给线程函数了,因为线程函数可以直接看到这把锁,所以直接使用即可。

image.png



3.根据代码现象提出问题

3.1 如何看待锁


1.

完成上面对于共享资源访问不安全问题的解决之后,我们来深入的理解一下锁。

我们知道,共享资源在被多线程访问时,是不安全的,所以我们需要加锁来保护共享资源。但是我们回过头来想一想,锁本身是不是共享资源呢?所有的线程都需要申请锁和释放锁,那不就是在共同的访问锁这个资源嘛?所以锁本身不就是共享资源吗?那多个线程在访问锁这个共享资源的时候,锁本身是不是需要被保护呢?当然需要!其他的共享资源可以通过加锁来进行保护,那锁怎么办呢?

实际上,加锁和解锁的过程是原子的!也就是说只要你申请了锁,并且竞争能力恰好足够,那么你就一定能够拿到这个锁,否则你就不会拿到这个锁,不会说在申请锁申请一半的时候,线程被切换下去了,其他线程去申请锁了,不会出现这种中间态的情况!既然加锁和解锁的过程是原子的,那其实访问锁就是安全的!(但加锁解锁的过程为什么是原子的呢?我该如何理解呢?这个后面会说。)image.png


image.png

2.

如果申请锁成功了,那线程就会继续向后执行代码,进入临界区,访问临界资源。那如果申请锁要是没成功呢?或者说暂时申请不到锁呢?执行流又会怎么样呢?

下面代码中,线程函数内部申请了两次互斥锁,这实际就会出问题了,可以看到代码不会继续运行了,并且是进程内的所有线程都不会被调度,没有一个线程能够进行抢票,我们通过ps -aL还可以看到线程确实都存在,但是都不会执行代码,并且ps -axj也可以看到当前进程变成了Sl+状态,也就是处于阻塞状态,而不是R运行状态!image.png



3.


所以如果申请不到锁,执行流就会阻塞。


因为你线程申请锁的时候,锁被别的线程拿走了,那你自然就无法申请到锁,操作系统会将这样的线程暂时处于休眠状态。只有当持有锁的线程释放锁的时候,操作系统会执行POSIX库的代码,重新唤醒休眠的线程,让这个线程去竞争锁,如果竞争到,那就持有锁继续向后运行,如果竞争不到,那就继续休眠。


那上面为什么会出问题呢?实际是因为,当前线程已经申请到锁了,但是他又去申请锁了,而这个锁其实他自己正持有着呢,但是他又不知道自己持有锁,因为我们主观让线程执行了两次申请锁的语句,是我们让他这么干的,他自己拿着锁,然后他现在又要去申请锁,但锁实际已经被持有了,那么当前线程必然就会申请锁失败,也就是处于休眠状态,什么时候他才会唤醒呢?当然是锁被释放的时候!当锁被释放时,操作系统才会唤醒当前线程,但是锁会释放吗?当然是不会啦!因为你自己把锁拿着,你还等其他线程释放锁,人家其他线程又没有锁,你自己还运行不到pthread_mutex_unlock这段代码,也就是说你自己又不释放锁,你还让没有这个锁的线程去释放锁,这不就是自己把自己给搞阻塞了吗?这其实就是产生死锁了,线程永远都无法等待锁成功释放,那么这个线程将永远处于阻塞状态,无法运行,同样其他线程道理也如此!


所以我们就可以看到,上面那么多线程全都阻塞了,每一个能跑的,其实就是因为发生死锁问题了,所有的线程都无法申请到锁,其中大部分的线程都是因为根本就没碰到锁,一直想等锁被释放从而发生的休眠,而一个大傻线程是自己拿着锁呢,但是还忘记自己拿着锁了,要别人把锁还给他,而一直等待别人释放锁,从而产生的休眠问题!


4.

那该如何解决呢?两种办法,第一种就是通过pthread_mutex_trylock()来申请锁,这个接口会试着进行申请锁,如果申请到锁,那就继续向后执行代码运行即可。如果没有申请到锁,就会立马出错返回!所以这个接口实际是一种非阻塞式的申请锁的一种方式。从产生问题的原因角度解决了问题,你不是要阻塞式的申请锁吗?那我直接不阻塞不就得了?但其实这种解决方式是非常不好的,因为一个线程出问题,整个进程都会退出,你其他线程申请不到锁就申请不到呗,但现在有一个线程申请到锁了,并且互斥式的访问临界资源的呢,正访问着呢,因为别的线程申请不到锁,就把我当前线程资源就回收了?而且所有的线程还都退出了!这合理吗?当然不合理!所以这样的解决方式不好用,我们还是得用主流的lock和unlock来进行锁的申请和释放!


所以对于lock申请到的锁,还有另一种锁的叫法,叫做挂起等待锁!


那该怎么解决呢?我所知道的实际并没有很好的解决办法,只能我们程序员小心再小心,千万不要写出死锁的代码,如果一旦写出,那也要通过死锁产生的问题,迅速补救代码,检查出死锁产生的位置,进行更改代码!


实际上面总结下来也就一句话,谁持有锁谁才能进入临界区,你没有锁那就只能在临界区外面乖乖的阻塞等待,等待锁被释放,然后你去竞争这把锁,竞争到就拿着锁进入临界区执行代码,竞争不到就老样子,继续乖乖的在临界区外面阻塞等待!

image.png


5.

上面我们已经理解了临界区,临界资源,串行执行,未持有锁线程的阻塞等待,以及互斥访问这样的概念。但在锁这里,还有一个概念是原子性!我该如何真正的理解线程持有锁的过程中原子性这样的概念呢?


在谈论真正理解加锁过程中的原子性概念之前,我们先来讨论几个问题。我这里就不说这些问题了,大家可以看我下面画的图。实际这些问题我们早就在上面说过了,无非就是未持有锁的线程会阻塞等待式的等待锁被释放和持有锁的线程在被调度切换时,会拿着自己的锁被切换下去,其他被重新调度到CPU上的线程依旧是无法申请到锁的,因为锁只有一把,而且是被刚刚切换下去的线程所持有的!所以被重新调度到CPU上的线程也没啥用,因为他们无法继续向后执行代码!这两个话题其实上面都已经说过了,我们这里就相当于做一下复盘!image.png


6.

那么!对于其他未持有锁的线程而言,实际有意义的锁的状态,无非就两种!一种是申请锁前,一种是释放锁后!申请锁前,锁还没有被申请到,那么对于其他未持有锁的线程来说,当然是有意义的。释放锁后,锁此时处于未被申请到的状态,那未持有锁的线程当然有可能竞争到这把锁,所以这也是一种有意义的状态!

而我们站在未持有锁的线程角度来看的话,当前持有锁的线程不就是原子的吗?他们看到的锁只有在未申请前和持有锁线程释放锁之后这两种有意义的状态,那这就是原子的,不会出现中间态的情况。

所以,在未来使用锁的时候,一定要保证临界区的粒度非常小,因为加锁之后,线程会串行执行,如果粒度非常大,那么执行这段临界区所耗费的时间就越多,整体代码运行的效率自然就会降下来,因为其余非临界区是并发或并行执行,而临界区是串行,所以整体效率会由于临界区的执行效率受较大影响,那么在平常加锁和解锁时,我们就要保证临界区的粒度较小,为此能够让程序整体的运行效率依旧保持较高的状态!


7.

谈论额外的几个话题,我们说未持有锁线程在等待释放锁期间会进入阻塞状态,如果说具体一些的话,实际这些未持有锁的线程会被放在互斥锁对应的等待队列中,互斥锁对象内部维持了一个等待队列,用于存放被该锁阻塞的线程。

加锁是程序员行为,如果要访问共享资源,那么所有访问该共享资源的线程都要加锁,不能说有的线程加锁有的线程不加锁。比如现在有一批线程,他们要执行两个线程函数,这两个线程函数内部都会访问共享资源,但一个线程函数内部对共享资源进行加锁,一个没有加锁,那么就会导致其中一批线程需要互斥式的串行访问共享资源,而另一批线程则可以随意并发式的访问共享资源,这一定会出安全问题的,这算程序员写出了bug,因为你对共享资源的保护不够彻底,算你自己的问题!



3.2 如何理解加锁和解锁的本质?(硬件层面和软件层面的加锁)1.

在文章的较前部分,我们谈到过单纯的i++和++i的语句都不是原子的,因为这样的语句实际还要至少对应三条汇编语句,从内存中读取数据,在寄存器中修改数据,最后再将修改后的数据写回内存,所以++i和i++这样的语句一定不是原子的,因为他在执行的时候是有中间态的,可能在执行一半的时候由于某些原因被切换下去,这样就会停下来。这种非原子性的操作就会导致数据不一致性的问题,也就是前面我们常谈的共享资源访问不安全的问题!随之而来的解决方案就是我们所说的加锁,对共享资源进行互斥式的访问,以保证其安全性。

而加锁和解锁的过程实际也是访问共享资源锁的过程,那么加锁和解锁是如何保证其访问锁的原子性呢?答案是通过一条汇编语句来实现。

为了实现互斥锁的加锁过程,大多数CPU架构都提供了swap和exchange指令,该指令的作用是把寄存器和内存单元的数据进行交换,因为只有一条汇编指令,保证了其原子性。并且即便是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期就绪后才能访问。


2.

实际上除我们语言所说的一条汇编语句交换数据,而保证的原子性外,在操作系统内还有另一种硬件层面上的实现原子性的简单做法。因为线程在执行过程中,有可能出现线程执行一半被切换了,那么线程完成任务就不是原子的了,所以我们能不能让线程在执行的时候,压根就不能被切换,只要你线程上了CPU的贼船就不能下去,必须得等你完全执行完代码之后才可以被切换下去。

至于线程在执行一半的时候被切换走,原因有很多,可能是时间片到了,来了更高优先级的线程,线程由于访问某些外设或自己的原因等等,需要进行阻塞等待,这些情况下,都有可能在线程执行一半的时候被切换下去!

所以在系统层面,我们只要禁止一切中断,对线程的中断不做任何响应,禁止中断的总线做出任何响应,关闭外部中断以达到线程不被切换下去的效果,从而实现访问共享资源的原子性。

当然这样的方案比较偏底层,算是一个比较重量级的方案,在硬件层面实现这样的方案的话,成本还是挺高的,除非线程要完成的工作优先级特别高且必须是原子性的,我们才会这么做,否则一半情况下,不会采用这样的方案来实现原子性。


3.

在谈论加锁过程的汇编代码之前,我们先来谈几个共识性的话题,CPU内寄存器只有一套,被所有的执行流共享,并且CPU内寄存器的内容是每个执行流都私有的,称为运行时的上下文。可以看到加锁的汇编语句就是将0放到al寄存器内部,然后就是执行只有一条的汇编语句xchgb,将al寄存器的内容和物理内存单元进行数据交换,此时al寄存器内容就会变为1,物理内存中的mutex互斥量的值变为0,将物理内存中mutex的1和al寄存器内0进行交换,我们可以形象化的表示为线程A把锁拿走了,在拿走锁之后,线程A有没有可能被切换走呢?当然有可能,但线程A在切换的时候,他是带着自己的上下文数据被切换走的。


此时线程B被重新调度上来后,他也会先将0加载到自己上下文中的al寄存器内部,然后再执行xchgb汇编语句,但此时物理内存的mutex是0,代表锁已经被申请了,所以交换以后,al寄存器内部的值依旧是0,继续判断之后会进入else分支语句,该线程就会由于等待锁被持有锁的线程释放而处于挂起等待的状态。


所以,只要线程A申请锁成功了,即使线程A的运行被中断了,我们也不担心,因为交换寄存器和内存的汇编语句只有一条,这能保证加锁过程,也就是申请锁过程的原子性。并且在线程A被切走时,线程A是持有锁被切走的,那么即使其他线程此时被调度上来,他们也一定无法申请到锁,那就必须进行阻塞等待!只有重新调度线程A,将线程A的上下文加载到寄存器内部,此时al内容就会变为1,则返回return 0代表申请锁成功,线程A就可以持有锁式的访问临界区。

image.png


4.

上面说的加锁过程是原子的,交换寄存器和mutex内容仅由一条汇编语句来完成,而mutex是我们所说的共享资源,所以一条汇编语句保证了mutex操作的原子性。

而解锁的过程也非常简单,直接将1mov到mutex里面就完成了释放锁的过程,然后唤醒阻塞等待锁的线程,让他们现在去竞争锁,因为锁已经被释放了,所以同样的,释放锁的汇编语句也只有一条,这也能保证释放锁过程的原子性!


3.3 RAII风格的封装设计锁?(构造函数加锁,析构函数解锁)

1.

如果我们想简单的封装使用锁,那我们该如何设计呢?我们也想像之前封装设计线程那样搞出来C++式的面向对象版的创建线程和销毁线程。

实际实现起来也很简单,无非就是对原生的申请锁,加锁,解锁接口的封装!我们先定义一个互斥量的类,类中实现构造函数将锁的地址进行初始化,然后定义出加锁和解锁的两个接口,这样就可以定义出来一个内部能够进行加锁和解锁的类。

然后我们再加一层封装,实现出RAII( Resource Acquisition Is Initialization)风格的加锁,即为构造函数处进行加锁,析构函数处进行解锁!

至于锁的初始化和销毁方案,是类外面的事情,使用时需要自己先初始化好一把锁,确定初始化和销毁的方案,然后利用Mutex.hpp这个小组件来进行加锁和解锁的过程!

image.png


2.

在这里补充一个知识点,对象的生命周期是随代码块儿的,也就是说,当对象离开代码块儿的时候,会自动调用析构函数,例如下面抢票代码中,我们不想把usleep(1000)也放入到临界区,因为加锁之后的代码都属于临界区了,只有对象销毁时才会发生解锁,所以我们就可以利用代码块儿来实现临界区的范围管控。image.png



没有代码块就会出现,刚释放完锁的线程竞争能力强,持续霸占抢票,导致其他线程出现饥饿问题,有代码块也就是前面我们说过的,在释放完锁之后,让刚刚持有锁的线程停一会儿,让其他线程也能竞争到锁,也能进行抢票

image.png


我之前并不知道这个知识点,或者说知道的并没有那么清楚,像上面那种代码块儿的使用方法我倒是没有见过,所以特地跑到vs上面验证了一下,下面是验证结果,事实确实如上面所说那样。image.png

4.可重入与线程安全


1.

在多线程并发执行代码,同时访问共享资源的时候,如果某一个共享资源由于多线程访问,发生了数据不一致,共享资源不安全,并且导致其他线程运行出问题了,那么这种情况就是线程不安全的。尤其对于没有锁保护的共享资源的多线程访问的代码,很大概率出现线程不安全的情况。

而什么是可重入呢?这个话题并不陌生,我们之前谈论进程信号的时候,进程可能由于收到信号,并且在陷入内核时检测到信号,跳转到handler方法执行信号处理函数,信号处理函数中可能会出现和main执行流中执行相同的函数体,例如当时我们所说的链表的push_back在main和handler中同时执行,可能会导致某些未知错误的产生,如果出现了问题,那么我们称这个函数是不可重入函数,如果没有出现问题这个函数就是可重入函数。值得注意的是,不可重入函数说的是这个函数的属性,而不是说这个函数叫做不可重入函数,那么他就一定不能被执行流所重入,只是说,他如果被执行流重入,极大概率是要出问题的。


2.

下面是一些线程安全和不安全,函数可重入和不可重入的话题,实际就是混一堆概念,写代码的时候根本用不到,也就是现在在这里说一下而已。

image.png


3.

一句话,可重入函数是线程安全的充分不必要条件,线程函数如果是可重入的,那么就一定是线程安全的,反过来是不一定的。


image.png


5.死锁

5.1 死锁概念


1.

死锁是指一个进程中的各个线程,都持有着锁,但同时又去申请其他线程的锁,而每个线程持有的锁都是占有不会释放的,所以大家都会等着,等对方先释放锁,但是呢,大家又都不释放锁,全都占有着锁,所以大家就会处于一种永久等待的状态,也就是永久性的阻塞状态,所有执行流都不会被运行,这样的问题就是死锁!

之前抢票的代码中,多个线程使用的是同一把锁,未来有些场景一定是要使用多把锁的,在多把锁的情况下,如果某些线程持有锁不释放,还要去申请其他线程正持有的锁,而每个线程都是这样的状态,那就是死锁问题。


2.

一把锁有可能造成死锁问题吗?当然是有可能的,前面我们谈到过这个问题,一个线程已经持有锁了,但他又去等待这个锁释放,但这个锁现在释放不了,那他自己就会持有锁式的阻塞等待。其实就是一个人骑着毛驴找毛驴,那他最后能找到毛驴吗?当然是找不到的!


3.

下面来谈一下产生死锁的逻辑链条,大家看一下就好,我们谈论的重点还是产生死锁的四个必要条件,这里只是对死锁产生做一个解释而已。

image.png


5.2 产生死锁的四个必要条件


1.

互斥条件:一个资源每次只能被一个执行流使用,互斥其实就是加锁之后线程的串行执行。

请求与保持条件:一个执行流由于请求资源而阻塞时,对自己已经获得的资源保持不放。说白了就是我自己的东西不释放,我还要你的东西,你不给我就一直等,等到你给我为止。


不剥夺条件:一个线程在未使用完自己获得的资源之前,是不能够强行剥夺其他线程的资源的。说白了就是你先在还有资源呢,你想要别人的自由你就得等,不能强行剥夺!当你使用完自己的资源后,你可以去等待申请别人的资源。总之就是不能强行剥夺其他线程的资源,想要就必须阻塞等待别人释放资源才可以。


循环等待条件:若干个执行流之间,形成一种头尾相接的互相等待对方资源的关系。我们也称这样的现象为环路等待

a256752a38d5409da7c35938e0dc38b6.png



2.

破坏死锁实际就是破坏死锁的四个条件其中之一,只要破坏一个条件,死锁就无法产生。

第一个互斥是锁的特性,我们无法改变。

在申请第二把锁的时候,如果申请暂时不成功,那就不去阻塞等待该锁被释放,而是直接出错返回,这样就破坏了保持的条件,也就是说如果请求不成功,也不保持自己的资源不释放了,而是直接释放资源,出错返回,这样也能避免死锁。例如使用pthread_mutex_trylock来申请锁。

我们可以设定一个竞争策略,例如优先级较高的线程可以剥夺优先级较低线程的资源,也就是可以抢过来,直接把优先级较低线程的锁抢过来。所以判断能否剥夺资源时,我们通过优先级的高低就可以判断。

因为申请锁的顺序而导致线程出现了环路等待问题,所以我们就让他们申请锁的顺序保持一致,不要产生环路等待的问题。例如:假设访问临界资源需要持有AB两把锁,那么让所有线程申请锁的顺序都是先申请A锁再申请B锁,这样的话,申请A锁成功的线程一定能申请到B锁,那么该线程就可以拿着这两把锁去访问临界区,而其他线程由于连A锁都申请不到,更别说申请B锁了,所以他们就只能等待持有锁线程释放A锁,这样的好处就是不会产生死锁问题。如果你不这么做,那一定会导致死锁问题的产生,例如一个线程先申请A锁再申请B锁,另一个线程先申请B锁再申请A锁,那么就会出现第一个线程一直等后一个线程释放B锁,而后一个线程一直在等第一个线程释放A锁,而每个线程都是请求与保持的,所以最终结果就是,两个线程都一直处于永久阻塞等待的状态,此时就产生死锁问题。(这种解决方案还是很不错的,让所有线程申请锁的顺序保持一致!)


3.

那么如何避免死锁呢?我们可以通过下面的几种方式来避免死锁,这些是程序员在写代码上需要注意的一些细节。

例如资源一次性分配这样的细节,如果一个接口里面大量的申请了空间资源,那么就提前将这些资源申请好,而不是在写代码的途中进行资源申请,因为在多线程的环境下,多个执行流,还有锁的情况,你在代码中进行资源申请,是有可能出现问题的,如果代码量巨大,那出现的问题真是能头疼死人!同样加锁的条件也会变得非常复杂。

所以在多线程环境下,强烈建议要将资源进行一次性分配,如果你不这么做,也没关系,因为代码出错之后,代码会教你做人的。1512eb1d9e1741548252bee918e9e044.png


4.

除上面需要注意的避免产生死锁的代码编写之外,还有两个避免死锁产生的算法需要说一下。

首先提一个问题,一个线程申请的锁,另一个线程可以释放这个锁吗?当然是可以的!释放锁不就是调用一下unlock接口嘛,哪个线程不能做这个工作啊,只要把对应锁的地址传给任意一个线程,该线程都可以通过调用unlock接口来释放锁。所以一种死锁检测的算法思想就是定义一个类,类里面定义计数器,这个计数器衡量的是每个线程是否运行,只要线程运行,那么这个计数器就会一直++,然后可以用另一个监控线程盯着这个计数器,一旦计数器长时间不变化,就有可能产生死锁,此时监控线程负责将锁unlock释放,通过直接释放锁的方式来避免产生死锁。


银行家算法(了解)


5.

即使教材上面对于死锁的解决方案说的非常详细,但实际在工程中能不用锁尽量不要用锁,如果非常必须用锁来解决问题,那也要尽量少的锁来解决问题。因为这个锁和C++的模板一样,水很深!我们并不能因为我们正在学这个东西,那这个东西就一定是重要的,或者是实际中使用率较高的,这不是绝对的。




























相关文章
|
2天前
|
缓存 网络协议 算法
【Linux系统编程】深入剖析:四大IO模型机制与应用(阻塞、非阻塞、多路复用、信号驱动IO 全解读)
在Linux环境下,主要存在四种IO模型,它们分别是阻塞IO(Blocking IO)、非阻塞IO(Non-blocking IO)、IO多路复用(I/O Multiplexing)和异步IO(Asynchronous IO)。下面我将逐一介绍这些模型的定义:
|
18天前
|
Linux C++
c++高级篇(三) ——Linux下IO多路复用之poll模型
c++高级篇(三) ——Linux下IO多路复用之poll模型
|
18天前
|
缓存 监控 网络协议
c++高级篇(二) ——Linux下IO多路复用之select模型
c++高级篇(二) ——Linux下IO多路复用之select模型
|
2天前
|
Linux 网络安全 虚拟化
Ngnix04系统环境准备-上面软件是免费版的,下面是收费版的,他更快的原因使用了epoll模型,查看当前Linux系统版本, uname -a,VMWARE建议使用NAT,PC端电脑必须使用网线连接
Ngnix04系统环境准备-上面软件是免费版的,下面是收费版的,他更快的原因使用了epoll模型,查看当前Linux系统版本, uname -a,VMWARE建议使用NAT,PC端电脑必须使用网线连接
|
11天前
|
安全 Linux 数据安全/隐私保护
【linux】线程同步和生产消费者模型
【linux】线程同步和生产消费者模型
10 0
|
20天前
|
API
linux---线程互斥锁总结及代码实现
linux---线程互斥锁总结及代码实现
|
2月前
|
安全 算法 Linux
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(下)
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(下)
|
2月前
|
存储 Linux 程序员
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(中)
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(中)
|
2月前
|
缓存 Linux 调度
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(上)
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(上)
|
2月前
|
安全 Linux 调度
【linux线程(二)】线程互斥与线程同步
【linux线程(二)】线程互斥与线程同步