人生总是那么痛苦吗?还是只有小时候是这样? —总是如此
文章目录
- 一、线程互斥
- 1.多线程共享资源访问的不安全问题
- 2.提出解决方案:加锁(局部和静态锁的两种初始化/销毁方案)
- 2.1 对于锁的初步理解和实现
- 2.2 局部和全局锁的两种加锁方案的代码实现
- 3.根据代码现象提出问题
- 3.1 如何看待锁?
- 3.2 如何理解加锁和解锁的本质?(硬件层面和软件层面的加锁)
- 3.3 RAII风格的封装设计锁?(构造函数加锁,析构函数解锁)
- 4.可重入与线程安全
- 5.死锁
- 5.1 死锁概念
- 5.2 产生死锁的四个必要条件
- 二、线程同步+生产消费模型
- 1.通过条件变量抛出线程同步的话题
- 2.生产消费模型的概念理解(321原则)
- 3.条件变量实现线程同步的原理(条件变量内部维护了线程的等待队列,能wait线程也能wakeup线程)
- 4.串行、并发、并行的概念
- 5.条件变量的基本代码编写
- 三、基于blockqueue的生产消费模型
- 1.双阻塞队列的多生产多消费模型的实现
- 2.生产消费模型高效在哪里?(不影响其他多线程并发或并行的获取任务和执行任务)
一、线程互斥
1.多线程共享资源访问的不安全问题
1.
假设现在有一份共享资源tickets,如果我们想让多个线程都对这个资源进行操作,也就是tickets- -的操作,但下面两份代码分别出现了不同的结果,上面代码并没有出现问题,而下面代码却出现了票为负数的情况,这是怎么回事呢?
其实问题产生就是由于多线程被调度器调度的特性导致的。
2.
了解上面的问题需要知道线程调度的特性,实际线程在被调度时他的上下文会被加载到CPU的寄存器中,而线程在被切换的时候,线程又会带着自己的上下文被切换下去,此时要进行线程的上下文保存,以便于下次该线程被切换上来的时候能够进行上下文数据的恢复。
除此之外,像tickets- -这样的操作,对应的汇编指令其实至少有三条,1.读取数据 2.修改数据 3.写回数据,而线程函数我们知道会在每个线程的私有栈都存在一份,在上面的例子中多个线程执行同一份线程函数,所以这个线程函数就绝对会处于被重入的状态,也就绝对会被多个线程执行!今天我们假设只有一个CPU(CPU就是核心,处理器芯片会集成多个核心)在调度当前进程中的线程,那么线程是CPU调度的基本单位,所以也就会出现一个线程可能执行一半的时候被切换下去了,并且该线程的上下文被保存起来,然后CPU又去调度进程中的另一个线程。
3.
在知道上面的原理之后,还需要知道usleep的作用,当usleep放到if分支语句的第一行时,票数就出现了问题,出现了负数,主要是因为usleep可以将线程暂时阻塞,那么CPU就会把他切换下去,转而执行其他线程,但需要注意的是,如果被切换的线程重新调度上来时,还会从上次他执行后的语句继续向下运行。
所以会出现多个线程同时进入到分支判断语句,然后去阻塞等待的情况,假设tickets已经变成了1,然后其余的线程此时都被调度上来了,他们都开始执行tickets- -,- -之后不满足循环条件线程才会退出,那么如果我们创建出了4个线程,就会有3个线程在票数已经为0的情况下继续减减,所以就会出现票数为负数的情况。
4.
而我们能够复现出问题其实主要靠的是usleep和逻辑判断与tickets- -分开,那么线程就有可能在执行if逻辑判断之后,还没有执行tickets- -之前就被切换下去了,而多个线程都出现这样的情况时,他们都被重新调度时,重新加载自己的上下文数据时,继续向后执行,但此时tickets已经没有了,共享资源tickets在多线程访问时就会出现数据不安全的问题。
5.
我们上面是将逻辑判断和tickets- -分开了,那是不是只要别分开,就不会出现问题呢?
答案并不是这样的,还是会出现问题的,只不过我们复现出这样的问题需要靠概率而已,所以并不是那么好复现。但我们只要知道原理就可以,下面再来分析一下只有tickets- -这一步的情况下,是否会出现问题呢?
我举了两个线程同时循环执行票数-1的例子。如果真要说到底,这些由于多线程操作共享资源而产生的问题,本质原因只有一个,他们可能在运行的一半被切换走了,连同他自己的上下文结构,而被切换走的同时,其他调度上来的线程依旧可以访问这个共享资源,但是被切换下去的线程不知道啊!没人告诉我啊!我和我的上下文就等着被CPU重新调度回去呢!但等我回来的时候,天都已经大变样了!我还啥都不知道,继续傻傻的操作共享变量,此时就出现共享资源数据不一致的问题了。
2.提出解决方案:加锁(局部和静态锁的两种初始化/销毁方案)
2.1 对于锁的初步理解和实现
1.
那该如何解决上面的问题呢?多个执行流操作共享资源时,发生了数据不一致问题。
解决上面的问题实际要通过加锁来实现,但在谈论加锁的话题之前,我们需要来重新看待几个概念。
多个执行流总是能够共享许多资源,但在加锁保护后的共享资源我们称为临界资源。
而多个执行流执行的函数体内部,对临界资源进行操作的代码称为临界区,需要注意的是临界区不是整个函数体内部的代码,而是指对共享资源进行操作的代码称为临界区。
如果我们想让多个执行流串行的访问临界资源,而不是并发或并行的访问临界资源,这样的线程调度方案就是互斥式的访问临界资源!(串行就是指只要一个线程开始执行这个任务,那么他就不能中断,必须得等这个线程执行完这个任务,你才能切换其他线程执行其他的任务,这个概念等会讲完锁之后大家就明白什么是互斥了)
当线程在执行一个对资源访问的操作时,要么做了这个操作,要么没有做这个操作,只要两种状态,不会出现做了一半这样的状态,我们称这样的操作是原子性的。(就比如你妈让你写作业,你要么给我把作业写完了再出去玩,要么就一个字也别写给我滚出家门,就这两种状态,不会出现你写了一半,然后你妈让你出去玩的这种情况,这样也是原子性)
2.
有了上面四组概念的稍稍铺垫之后,我们来谈谈如何对共享资源进行加锁和解锁,首先锁实际就是一种数据类型,这个锁就像我们平常定义出来的变量或是对象一样,只不过这个锁的类型是系统给我们封装好的一种类型,进行重定义后为pthread_mutex_t。变量或对象在生命的时候也是可以初始化的,变量初始化后,就是变量的定义,而不是声明了。变量和对象也都有自己的销毁方案,内置类型的变量销毁时,操作系统会自动回收其资源,而自定义对象销毁时,操作系统会调用其析构函数进行资源的回收。
锁同样也是如此,锁也有自己的初始化和销毁方案,如果你定义的是一把局部锁,就需要用pthread_mutex_init()和pthread_mutex_destroy()来进行初始化和销毁,如果你定义的是一把全局锁或静态所,则不需要用init初始化和destroy销毁,直接用PTHREAD_MUTEX_INITIALIZER进行初始化即可,他有自己的初始化和销毁方案,我们无须关心静态或全局锁如何销毁。
定义好锁之后,我们就可以对某一段代码进行加锁和解锁,加锁与解锁意味着,这段代码不是一般的代码,只有申请到锁,持有锁的线程才能访问这段代码,加锁和解锁之间的代码可以称为临界区,因为想要访问这段空间必须有锁才可以访问。pthread_mutex_lock实际就是申请锁的代码和临界区的入口,如果你申请锁成功了,那么你就可以进入临界区访问临界资源,如果你并没有申请成功,比如当前这把锁已经被别的线程申请到并持有了,其他线程正持有锁在临界区访问着呢,那么你就无法进入临界区,因为你并没有持有锁,必须得在pthread_mutex_lock这个接口外面等着,直到你申请到锁之后,你才能进入临界区访问临界资源,这样的线程访问实际就是互斥,指的是当一个线程正在持有锁访问临界区的时候,其他线程无法进入临界区,直到持有锁的线程释放锁之后才会有可能进入临界区,注意是有可能,因为当线程释放锁之后,这把锁还需要被竞争,哪个线程竞争到这把锁,哪个线程才能持有锁的访问临界资源!
3.
上面谈论完锁的初始化和销毁,以及如何加锁和解锁之后,我们来利用锁解决上面出现的共享资源访问不安全的问题。你不是由于多线程再进行临界资源访问时,可能由于线程切换什么的,导致非原子性式的访问临界资源吗?那我不让你这么干,我对这段临界资源进行加锁,让你当前申请到锁正在访问临界资源的线程,必须给我以原子性的访问来访问临界资源,换句话说,你必须把访问临界资源的工作做完了,才可以,要么你不要访问临界资源,要么你访问了临界资源,就必须把临界资源全部访问完了,中间不能访问一半就不访问了!所以只要对临界资源进行加锁后,临界资源就变得安全了,因为无论什么线程想要访问临界资源,都必须以原子性的方式访问完,这样的话,就不会出现在访问一半的时候,线程被切换下去了,其他线程被切换上来继续访问临界资源了,而是说如果持有锁的线程被切换下去了,这个线程会抱着他申请到的锁被切换下去,此时其他线程如果被切换上来,想要访问临界资源,那也没用,因为你没有锁啊!持有锁的线程被切换时,是抱着锁被切换的,那你现在既然访问不了临界区,CPU无法继续执行代码,那就只能等持有锁的线程重新被切换上来时,才能继续开展临界资源的访问工作,这个工作必须且只能由申请到锁的线程来完成,其他任何线程都无法完成这个工作!反过来说,这不就是原子性吗?访问临界资源的工作只要被持有锁的线程开始做了,哪怕他在做的过程中被切换下去了,也无须担心,因为别的线程做不了这个工作,所以还是得等持有锁的线程被切换上来的时候才能继续做这个工作,那是不是这个工作只要开始做了,就一定会被做完呢?会不会出现做一半,停下来了不做了,让别的线程在去访问临界资源的情况呢?当然不会!这就是锁带来的作用。
4.
如果在加锁之后运行代码,实际可以发现他抢票的速度是要比没加锁之前慢的,原因也很简单。我来给大家解释一下,没加锁之前,线程之间是可以并发或并行执行的,我先大概说一下并发和并行是什么,后面会详细介绍这两者的区别和概念,并发你可以简单理解为,当线程运行一半被切换下去的时候,此时CPU还可以调度运行其他线程,也就是说,如果多个线程在运行的时候,每个线程都会被CPU跑一跑,那在一段时间内,所有的线程都可以被执行到,并且推进每个线程的执行过程。而并行就是在多个核心上面同一时刻跑不同的线程,比如两个同时访问临界资源的线程,在未加锁的时候,可能出现多个核心同时执行两个线程的代码,同时在访问临界资源,但实际这种情况并不常见,因为我们写出来的代码优先级并没有那么高,所以基本上都是在按照并发执行的。
然后加锁前是并发执行的,也就是说在一个线程被切换下去的时候,其他- -tickets的线程还能够被重新调度上来进行票数的- -,那么总体上来说,票数就会被一直- -。
而加锁之后就不是并发执行的了,因为我们上面说过,加锁之后即使持有锁的线程被切换下去,其他被调度到CPU上的线程也是无法进行票数- -的,因为他们没有锁,所以在持有锁的线程被切换下去的这段时间里,票数不会改变,因为线程在串行的访问临界资源,什么是串行呢?就是一个线程访问完之后,才能轮到另一个线程,就是我们前面说的,一个线程在完成他的工作之后,释放完锁之后,其他线程才有可能竞争到锁,才有可能访问临界资源,这样就是串行。
串行的执行效率肯定要比并发执行的效率底嘛,因为当多线程在执行任务的时候,我们进行并发执行,为的就是当前线程如果被切换下去了,那也没啥事,因为其他被调度上来的线程依旧可以执行这个任务。你现在加锁之后就会变成串行执行了,那当前持有锁的线程被切换下去时,其他被调度上来的线程是无法继续执行任务的,效率自然就会底一些。(效率底一点就底一点吧,毕竟现在共享资源就安全了嘛,下面运行结果你也可以看到,没有锁的时候,票数就为负数了,这种情况用户怎么可能容忍。)
2.2 局部和全局锁的两种加锁方案的代码实现
1.
如果定义局部锁的话,我们肯定是想要将这把锁传给每个线程的,让每个线程都用这把锁来互斥式的访问共享资源,以此来保证共享资源的安全性。并且我还想给每个线程带上名字,这样在打印结果上可以区分是哪个线程在进行抢票。
所以我们是不是需要一个结构体ThreadData来封装一下锁和线程名字呢?所以我们就定义出一个结构体,把结构体指针传给线程,让线程能够使用锁来访问临界资源!
2.
接下来我们还要看一下,加锁之后的运行现象。在没有一次while循环之后的usleep(1000)时,可以看到发生抢票的用户,一段时间内基本都会是一个用户,比如打印结果中,如果是用户1抢票,那大概用户1要抢比较长的一段时间的票,然后才会换到其他用户,这是为什么呢?
因为锁只规定了线程必须互斥式的访问临界资源,但并没有规定哪个线程先去执行访问临界资源的操作!换句话说,只要你线程拿着锁来访问临界资源,那我就同意你访问,我管你是哪个线程呢,你有锁就行,也就是说,你释放完锁之后,在重新竞争锁的时候,如果你又能竞争到这把锁,那你就一直拿着这个锁来访问就好了。你要是能一直竞争到锁,那你就能一直来访问临界资源。
而下面现象我们其实可以看到,刚刚释放完锁的线程,在重新竞争锁的时候,这个线程的竞争能力是比较强的,所以就会出现下面的现象,一个用户抢票之后,大概还要抢很长时间的票。(同时其他线程就无法抢票,就只能眼巴巴的看着那个竞争能力强的线程一直在抢票,这样的现象我们称为饥饿状态,解决的方式实际是通过线程同步来解决的,这里先预热一下,后面会详细讲的。)
3.
上面那种现象正确吗?当然是正确的!我这个线程竞争能力强嘛,我凭啥不能一直抢票呢?锁只规定了我要互斥式的访问临界资源,又没说必须是哪个线程先进行或后进行抢票,我就要一直抢票,你能把我怎么样?
但是!上面的现象虽然是正确的,但是他不河狸!比如抢火车票,这个票一直被一个用户抢,其他用户一直都抢不着,那铁路局咋赚钱呢?一个用户的消费咋能养活一个铁路局呢?肯定得多个用户消费啊!
所以除了使用线程同步来解决之外,还可以通过usleep(1000)来解决,睡眠的多少不重要,只要让线程在释放完锁之后,睡眠一会儿,将自己阻塞挂起(是否挂起是未知的,取决于OS)一会儿,阻塞挂起的时候,其他线程不就能竞争到锁了吗?那其他线程是不也可以进行抢票了?就不用眼巴巴的看着竞争能力强的那个线程一直在抢票了!
下面是有usleep和没有usleep的两次结果对比,没有usleep时,一个线程可能会霸占抢票较长时间,有usleep时,多个线程都可以协调的进行抢票,不会出现一个线程持续霸占抢票的情况。
4.
除了上面代码使用局部锁的实现方案外,我们还可以使用静态锁或全局锁,局部的静态锁还是需要将锁的地址传给线程函数,否则线程函数无法使用锁,因为锁是局部的嘛!如果是全局锁,那就不需要将其地址传给线程函数了,因为线程函数可以直接看到这把锁,所以直接使用即可。
3.根据代码现象提出问题
3.1 如何看待锁?
1.
完成上面对于共享资源访问不安全问题的解决之后,我们来深入的理解一下锁。
我们知道,共享资源在被多线程访问时,是不安全的,所以我们需要加锁来保护共享资源。但是我们回过头来想一想,锁本身是不是共享资源呢?所有的线程都需要申请锁和释放锁,那不就是在共同的访问锁这个资源嘛?所以锁本身不就是共享资源吗?那多个线程在访问锁这个共享资源的时候,锁本身是不是需要被保护呢?当然需要!其他的共享资源可以通过加锁来进行保护,那锁怎么办呢?
实际上,加锁和解锁的过程是原子的!也就是说只要你申请了锁,并且竞争能力恰好足够,那么你就一定能够拿到这个锁,否则你就不会拿到这个锁,不会说在申请锁申请一半的时候,线程被切换下去了,其他线程去申请锁了,不会出现这种中间态的情况!既然加锁和解锁的过程是原子的,那其实访问锁就是安全的!(但加锁解锁的过程为什么是原子的呢?我该如何理解呢?这个后面会说。)
地址空间中大部分的资源都是共享的,包括锁本身这个共享资源
2.
如果申请锁成功了,那线程就会继续向后执行代码,进入临界区,访问临界资源。那如果申请锁要是没成功呢?或者说暂时申请不到锁呢?执行流又会怎么样呢?
下面代码中,线程函数内部申请了两次互斥锁,这实际就会出问题了,可以看到代码不会继续运行了,并且是进程内的所有线程都不会被调度,没有一个线程能够进行抢票,我们通过ps -aL还可以看到线程确实都存在,但是都不会执行代码,并且ps -axj也可以看到当前进程变成了Sl+状态,也就是处于阻塞状态,而不是R运行状态!
3.
所以如果申请不到锁,执行流就会阻塞。
因为你线程申请锁的时候,锁被别的线程拿走了,那你自然就无法申请到锁,操作系统会将这样的线程暂时处于休眠状态。只有当持有锁的线程释放锁的时候,操作系统会执行POSIX库的代码,重新唤醒休眠的线程,让这个线程去竞争锁,如果竞争到,那就持有锁继续向后运行,如果竞争不到,那就继续休眠。
那上面为什么会出问题呢?实际是因为,当前线程已经申请到锁了,但是他又去申请锁了,而这个锁其实他自己正持有着呢,但是他又不知道自己持有锁,因为我们主观让线程执行了两次申请锁的语句,是我们让他这么干的,他自己拿着锁,然后他现在又要去申请锁,但锁实际已经被持有了,那么当前线程必然就会申请锁失败,也就是处于休眠状态,什么时候他才会唤醒呢?当然是锁被释放的时候!当锁被释放时,操作系统才会唤醒当前线程,但是锁会释放吗?当然是不会啦!因为你自己把锁拿着,你还等其他线程释放锁,人家其他线程又没有锁,你自己还运行不到pthread_mutex_unlock这段代码,也就是说你自己又不释放锁,你还让没有这个锁的线程去释放锁,这不就是自己把自己给搞阻塞了吗?这其实就是产生死锁了,线程永远都无法等待锁成功释放,那么这个线程将永远处于阻塞状态,无法运行,同样其他线程道理也如此!
所以我们就可以看到,上面那么多线程全都阻塞了,每一个能跑的,其实就是因为发生死锁问题了,所有的线程都无法申请到锁,其中大部分的线程都是因为根本就没碰到锁,一直想等锁被释放从而发生的休眠,而一个大傻线程是自己拿着锁呢,但是还忘记自己拿着锁了,要别人把锁还给他,而一直等待别人释放锁,从而产生的休眠问题!
4.
那该如何解决呢?两种办法,第一种就是通过pthread_mutex_trylock()来申请锁,这个接口会试着进行申请锁,如果申请到锁,那就继续向后执行代码运行即可。如果没有申请到锁,就会立马出错返回!所以这个接口实际是一种非阻塞式的申请锁的一种方式。从产生问题的原因角度解决了问题,你不是要阻塞式的申请锁吗?那我直接不阻塞不就得了?但其实这种解决方式是非常不好的,因为一个线程出问题,整个进程都会退出,你其他线程申请不到锁就申请不到呗,但现在有一个线程申请到锁了,并且互斥式的访问临界资源的呢,正访问着呢,因为别的线程申请不到锁,就把我当前线程资源就回收了?而且所有的线程还都退出了!这合理吗?当然不合理!所以这样的解决方式不好用,我们还是得用主流的lock和unlock来进行锁的申请和释放!
所以对于lock申请到的锁,还有另一种锁的叫法,叫做挂起等待锁!
那该怎么解决呢?我所知道的实际并没有很好的解决办法,只能我们程序员小心再小心,千万不要写出死锁的代码,如果一旦写出,那也要通过死锁产生的问题,迅速补救代码,检查出死锁产生的位置,进行更改代码!
实际上面总结下来也就一句话,谁持有锁谁才能进入临界区,你没有锁那就只能在临界区外面乖乖的阻塞等待,等待锁被释放,然后你去竞争这把锁,竞争到就拿着锁进入临界区执行代码,竞争不到就老样子,继续乖乖的在临界区外面阻塞等待!
5.
上面我们已经理解了临界区,临界资源,串行执行,未持有锁线程的阻塞等待,以及互斥访问这样的概念。但在锁这里,还有一个概念是原子性!我该如何真正的理解线程持有锁的过程中原子性这样的概念呢?
在谈论真正理解加锁过程中的原子性概念之前,我们先来讨论几个问题。我这里就不说这些问题了,大家可以看我下面画的图。实际这些问题我们早就在上面说过了,无非就是未持有锁的线程会阻塞等待式的等待锁被释放和持有锁的线程在被调度切换时,会拿着自己的锁被切换下去,其他被重新调度到CPU上的线程依旧是无法申请到锁的,因为锁只有一把,而且是被刚刚切换下去的线程所持有的!所以被重新调度到CPU上的线程也没啥用,因为他们无法继续向后执行代码!这两个话题其实上面都已经说过了,我们这里就相当于做一下复盘!
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就可以持有锁式的访问临界区。
4.
上面说的加锁过程是原子的,交换寄存器和mutex内容仅由一条汇编语句来完成,而mutex是我们所说的共享资源,所以一条汇编语句保证了mutex操作的原子性。
而解锁的过程也非常简单,直接将1mov到mutex里面就完成了释放锁的过程,然后唤醒阻塞等待锁的线程,让他们现在去竞争锁,因为锁已经被释放了,所以同样的,释放锁的汇编语句也只有一条,这也能保证释放锁过程的原子性!
3.3 RAII风格的封装设计锁?(构造函数加锁,析构函数解锁)
1.
如果我们想简单的封装使用锁,那我们该如何设计呢?我们也想像之前封装设计线程那样搞出来C++式的面向对象版的创建线程和销毁线程。
实际实现起来也很简单,无非就是对原生的申请锁,加锁,解锁接口的封装!我们先定义一个互斥量的类,类中实现构造函数将锁的地址进行初始化,然后定义出加锁和解锁的两个接口,这样就可以定义出来一个内部能够进行加锁和解锁的类。
然后我们再加一层封装,实现出RAII( Resource Acquisition Is Initialization)风格的加锁,即为构造函数处进行加锁,析构函数处进行解锁!
至于锁的初始化和销毁方案,是类外面的事情,使用时需要自己先初始化好一把锁,确定初始化和销毁的方案,然后利用Mutex.hpp这个小组件来进行加锁和解锁的过程!
2.
在这里补充一个知识点,对象的生命周期是随代码块儿的,也就是说,当对象离开代码块儿的时候,会自动调用析构函数,例如下面抢票代码中,我们不想把usleep(1000)也放入到临界区,因为加锁之后的代码都属于临界区了,只有对象销毁时才会发生解锁,所以我们就可以利用代码块儿来实现临界区的范围管控。
没有代码块就会出现,刚释放完锁的线程竞争能力强,持续霸占抢票,导致其他线程出现饥饿问题,有代码块也就是前面我们说过的,在释放完锁之后,让刚刚持有锁的线程停一会儿,让其他线程也能竞争到锁,也能进行抢票!
我之前并不知道这个知识点,或者说知道的并没有那么清楚,像上面那种代码块儿的使用方法我倒是没有见过,所以特地跑到vs上面验证了一下,下面是验证结果,事实确实如上面所说那样。
4.可重入与线程安全
1.
在多线程并发执行代码,同时访问共享资源的时候,如果某一个共享资源由于多线程访问,发生了数据不一致,共享资源不安全,并且导致其他线程运行出问题了,那么这种情况就是线程不安全的。尤其对于没有锁保护的共享资源的多线程访问的代码,很大概率出现线程不安全的情况。
而什么是可重入呢?这个话题并不陌生,我们之前谈论进程信号的时候,进程可能由于收到信号,并且在陷入内核时检测到信号,跳转到handler方法执行信号处理函数,信号处理函数中可能会出现和main执行流中执行相同的函数体,例如当时我们所说的链表的push_back在main和handler中同时执行,可能会导致某些未知错误的产生,如果出现了问题,那么我们称这个函数是不可重入函数,如果没有出现问题这个函数就是可重入函数。值得注意的是,不可重入函数说的是这个函数的属性,而不是说这个函数叫做不可重入函数,那么他就一定不能被执行流所重入,只是说,他如果被执行流重入,极大概率是要出问题的。
2.
下面是一些线程安全和不安全,函数可重入和不可重入的话题,实际就是混一堆概念,写代码的时候根本用不到,也就是现在在这里说一下而已。
3.
一句话,可重入函数是线程安全的充分不必要条件,线程函数如果是可重入的,那么就一定是线程安全的,反过来是不一定的。
5.死锁
5.1 死锁概念
1.
死锁是指一个进程中的各个线程,都持有着锁,但同时又去申请其他线程的锁,而每个线程持有的锁都是占有不会释放的,所以大家都会等着,等对方先释放锁,但是呢,大家又都不释放锁,全都占有着锁,所以大家就会处于一种永久等待的状态,也就是永久性的阻塞状态,所有执行流都不会被运行,这样的问题就是死锁!
之前抢票的代码中,多个线程使用的是同一把锁,未来有些场景一定是要使用多把锁的,在多把锁的情况下,如果某些线程持有锁不释放,还要去申请其他线程正持有的锁,而每个线程都是这样的状态,那就是死锁问题。
2.
一把锁有可能造成死锁问题吗?当然是有可能的,前面我们谈到过这个问题,一个线程已经持有锁了,但他又去等待这个锁释放,但这个锁现在释放不了,那他自己就会持有锁式的阻塞等待。其实就是一个人骑着毛驴找毛驴,那他最后能找到毛驴吗?当然是找不到的!
3.
下面来谈一下产生死锁的逻辑链条,大家看一下就好,我们谈论的重点还是产生死锁的四个必要条件,这里只是对死锁产生做一个解释而已。
5.2 产生死锁的四个必要条件
1.
互斥条件:一个资源每次只能被一个执行流使用,互斥其实就是加锁之后线程的串行执行。
请求与保持条件:一个执行流由于请求资源而阻塞时,对自己已经获得的资源保持不放。说白了就是我自己的东西不释放,我还要你的东西,你不给我就一直等,等到你给我为止。
不剥夺条件:一个线程在未使用完自己获得的资源之前,是不能够强行剥夺其他线程的资源的。说白了就是你先在还有资源呢,你想要别人的自由你就得等,不能强行剥夺!当你使用完自己的资源后,你可以去等待申请别人的资源。总之就是不能强行剥夺其他线程的资源,想要就必须阻塞等待别人释放资源才可以。
循环等待条件:若干个执行流之间,形成一种头尾相接的互相等待对方资源的关系。我们也称这样的现象为环路等待。
2.
破坏死锁实际就是破坏死锁的四个条件其中之一,只要破坏一个条件,死锁就无法产生。
第一个互斥是锁的特性,我们无法改变。
在申请第二把锁的时候,如果申请暂时不成功,那就不去阻塞等待该锁被释放,而是直接出错返回,这样就破坏了保持的条件,也就是说如果请求不成功,也不保持自己的资源不释放了,而是直接释放资源,出错返回,这样也能避免死锁。例如使用pthread_mutex_trylock来申请锁。
我们可以设定一个竞争策略,例如优先级较高的线程可以剥夺优先级较低线程的资源,也就是可以抢过来,直接把优先级较低线程的锁抢过来。所以判断能否剥夺资源时,我们通过优先级的高低就可以判断。
因为申请锁的顺序而导致线程出现了环路等待问题,所以我们就让他们申请锁的顺序保持一致,不要产生环路等待的问题。例如:假设访问临界资源需要持有AB两把锁,那么让所有线程申请锁的顺序都是先申请A锁再申请B锁,这样的话,申请A锁成功的线程一定能申请到B锁,那么该线程就可以拿着这两把锁去访问临界区,而其他线程由于连A锁都申请不到,更别说申请B锁了,所以他们就只能等待持有锁线程释放A锁,这样的好处就是不会产生死锁问题。如果你不这么做,那一定会导致死锁问题的产生,例如一个线程先申请A锁再申请B锁,另一个线程先申请B锁再申请A锁,那么就会出现第一个线程一直等后一个线程释放B锁,而后一个线程一直在等第一个线程释放A锁,而每个线程都是请求与保持的,所以最终结果就是,两个线程都一直处于永久阻塞等待的状态,此时就产生死锁问题。(这种解决方案还是很不错的,让所有线程申请锁的顺序保持一致!)
3.
那么如何避免死锁呢?我们可以通过下面的几种方式来避免死锁,这些是程序员在写代码上需要注意的一些细节。
例如资源一次性分配这样的细节,如果一个接口里面大量的申请了空间资源,那么就提前将这些资源申请好,而不是在写代码的途中进行资源申请,因为在多线程的环境下,多个执行流,还有锁的情况,你在代码中进行资源申请,是有可能出现问题的,如果代码量巨大,那出现的问题真是能头疼死人!同样加锁的条件也会变得非常复杂。
所以在多线程环境下,强烈建议要将资源进行一次性分配,如果你不这么做,也没关系,因为代码出错之后,代码会教你做人的。
4.
除上面需要注意的避免产生死锁的代码编写之外,还有两个避免死锁产生的算法需要说一下。
首先提一个问题,一个线程申请的锁,另一个线程可以释放这个锁吗?当然是可以的!释放锁不就是调用一下unlock接口嘛,哪个线程不能做这个工作啊,只要把对应锁的地址传给任意一个线程,该线程都可以通过调用unlock接口来释放锁。所以一种死锁检测的算法思想就是定义一个类,类里面定义计数器,这个计数器衡量的是每个线程是否运行,只要线程运行,那么这个计数器就会一直++,然后可以用另一个监控线程盯着这个计数器,一旦计数器长时间不变化,就有可能产生死锁,此时监控线程负责将锁unlock释放,通过直接释放锁的方式来避免产生死锁。
银行家算法(了解)
5.
即使教材上面对于死锁的解决方案说的非常详细,但实际在工程中能不用锁尽量不要用锁,如果非常必须用锁来解决问题,那也要尽量少的锁来解决问题。因为这个锁和C++的模板一样,水很深!我们并不能因为我们正在学这个东西,那这个东西就一定是重要的,或者是实际中使用率较高的,这不是绝对的。
二、线程同步+生产消费模型
1.通过条件变量抛出线程同步的话题
1.
我们前面就说过,在抢票逻辑中,刚释放完锁的线程由于竞争能力比较强,导致其他线程无法申请到锁,那么长时间其他线程都无法申请到锁,只能阻塞等待着,这样的线程处于饥饿状态!
我们可以举一个例子来理解条件变量是如何实现线程同步的。
假设现在学校开了一间学霸vip自习室,学校规定这间自习室一次只能进去一个人上自习,自习室门口挂着一把钥匙,谁来的早先拿到这把钥匙,就可以打开门进入自习室学习,并且进入自习室之后,把门一反锁,其他人谁都不能进来。然后你第二天准备去学习了,卷的不行,直接凌晨三点就跑过来,拿着钥匙进入自习室上自习了,然后卷了3小时之后,你想出来上个厕所,一打开门发现外面站的一堆人,都在叽叽喳喳的讨论谁先来的,怎么来的这么早?这么卷?然后你怕自己等会儿把钥匙放到墙上之后,上完厕所回来之后有人拿着钥匙进入了自习室,你就又卷不了了,所以你把钥匙揣兜里,拿着钥匙去上厕所了,其他人当然进入不了自习室,因为你拿着钥匙去上厕所了。等你回来的时候,你又打开门,又来里面上了3小时自习,你感觉自己饿的不行了,在不吃饭就饿死在里面了,所以你打开门,准备出去吃饭了,然后突然你自己感觉负罪感直接拉满,我凌晨3点好不容易抢到自习室,现在离开是不太亏了,所以你又打开自习室回去上自习去了,别人当然竞争不过你呀!因为钥匙一直都在你兜里,你出来之后把钥匙放到墙上,你发现有点负罪感,你又拿起来钥匙回去上自习,因为你离钥匙最近,所以你的竞争能力最强。结果你来自习室上了1分钟自习又出来了,然后又负罪的不行,又回去了,周而复始的这么干,结果别人连自习室长啥样都没见到。
像这样由于长时间无法得到锁的线程,没办法进入临界区访问临界资源,我们称这样的线程处于饥饿状态!
2.
所以学校推出了新政策,所有刚刚从自习室出来的人,都必须回到队列的尾部重新排队等待进入自习室,这样的话,其他人也就可以拿到钥匙进入自习室了。
所以,在保证数据安全的前提下,让线程能够按照某种特定的顺序来访问临界资源,从而有效避免其他线程的饥饿问题,这就叫做线程同步!
2.生产消费模型的概念理解(321原则)
1.
上面我们已经初步理解了条件变量带来的作用,那就是让互斥访问的线程能够实现同步,有效避免其他线程的饥饿问题,但在真正学习使用条件变量之前,我们还需要再来谈论一个模型,叫做生产消费模型,在谈论完生产消费模型之后,我们在来使用一下条件变量,然后基于条件变量+生产消费模型实现出一个基于阻塞队列式的生产消费模型代码。
2.
实际生活中,我们作为消费者,一般都会去超市这样的地方去购买产品,而不是去生产者那里购买产品,因为供货商一般不零售产品,他们都会统一将大量的商品供货到超市,然后我们消费者从超市这样的交易场所中购买产品。
而当我们在购买产品的时候,生产者在做什么呢?生产者可能正在生产商品呢,或者正在放假呢,也可能正在干着别的事情,所以生产和消费的过程互相并不怎么影响,这就实现了生产者和消费者之间的解耦。
而超市充当着一个什么样的角色呢?比如当放假期间,消费爆棚的季节中,来超市购买东西的人就会非常的多,所以就容易出现供不应求的情况,但超市一般也会有对策,因为超市的仓库中都会预先屯一批货,所以在消费爆棚的时间段内,超市也不用担心没有货卖的情况。而当工作期间,大家由于忙着通过劳动来换取报酬,可能来消费的人就会比较少,商品流量也会比较低,那此时供货商如果还是给超市供大量的货呢?虽然超市可能最近确实卖不出去东西,但是超市还是可以把供货商的商品先存储到仓库中,以备在消费爆棚的季节时,能够应对大量消费的场景。所以超市其实就是充当一个缓冲区的角色,在计算机中充当的就是数据缓冲区的角色。
而计算机中哪些场景是强耦合的呢?其实函数调用就是强耦合的一个场景,例如当main调用func的时候,func在执行代码的时候,main在做什么呢?main什么都做不了,他只能等待func调用完毕返回之后,main才能继续向后执行代码,所以我们称main和func之间就是一种强耦合的关系,而上面所说的生产者和消费者并不是一种强耦合的关系。
3.
如果深度挖掘一下生产消费模型,超市其实就是典型的共享资源,因为生产者和消费者都要访问超市,所以对于超市这个共享资源,他在被访问的时候,也是需要被保护起来的,而保护其实就是通过加锁来实现互斥式的访问共享资源,从而保证安全性。
在只有一份超市共享资源的情况下,生产和生产,消费和消费,以及生产和消费都需要进行串行的访问共享资源。但为了提高效率我们搞出了同步这样的关系,因为有可能消费者一直霸占着锁,一直在那里消费,但实际超市已经没有物资了,此时消费者由于竞争能力过强,也会造成不合理的问题,因为消费者消费过多之后,应该轮到生产者来生产了,所以对于生产者和消费者之间仅仅只有互斥关系是不够的,还需要有同步关系。
4.
从生产消费模型中可以提取出来一个321原则。即为3种关系,两个角色,1个交易场所。对应的其实是消费线程和消费线程的关系,消费线程和生产线程的关系,生产线程和生产线程的关系,交易场所就是阻塞队列blockqueue。而实现线程同步就需要一个条件变量,比如生产者生产完之后,超市给消费者打个电话,让消费者过来消费,消费完之后,超市在给生产者打个电话,让生产者来生产,这样就不会存在由于某一个线程竞争能力过强,一直生产或一直消费的情况产生,从而导致其他线程饥饿的问题。
5.
所以总结一下生产消费模型都有哪些好处。
a.他实现了生产和消费的解耦,使他们之间并不互相影响。
b.支持生产和消费一段时间的忙闲不均的问题。因为缓冲区可以预留一部分数据,进行数据的缓冲。
c.由于生产和消费的互斥与同步关系,提升了生产消费模型的效率。
但我其实还有一个问题,生产和消费是互斥的关系,那生产者生产的时候,消费者就不能消费,因为共享资源需要被加锁保护,而锁只有一把,所以每次只能有一个线程访问这个共享资源,那你凭什么说生产消费模型就高效了呢?这个问题很重要,后面讲完阻塞队列的代码实现之后,要重点谈一下这个问题!
3.条件变量实现线程同步的原理(条件变量内部维护了线程的等待队列,能wait线程也能wakeup线程)
1.
为了能够让多线程协同工作,就需要实现多线程的同步关系,为了维护同步关系,就需要引入条件变量。那条件变量是一个什么东西呢?他其实和互斥锁一样,都是一个数据类型定义出来的对象。初始化和销毁方案和互斥锁一模一样。唯一不同的是,条件变量在使用时有两个高频使用的接口,一个是pthread_cond_wait,该函数的作用是将等待某一个具体锁的线程放入条件变量的等待队列中进行等待,另一个是pthread_cond_signal,该函数的作用是唤醒条件变量中等待队列的第一个等待线程,另一个用的不怎么高频,但也偶尔会用一下的接口就是pthread_cond_broadcast,该函数将条件变量中的所有等待线程都会唤醒,让所有线程重新回归竞争锁的状态。而不是像signal那样,唤醒cond队列中任意一个阻塞等待锁的线程。
2.
除了之前我们举的自习室的例子之外,下面又举了一个面试官面试求职者的例子,其实说这么多例子就是为了让大家感受到条件变量所带来的作用,它能够让所有互斥访问的线程都能够按照某种顺序进入临界区,访问临界资源,这就是环境变量带来的最大的作用。既能保证共享资源访问的安全性,又能保证所有线程都可以拿到锁去访问共享资源,避免出现线程饥饿的问题。所以下面的例子大家看一下就好,如果你已经深刻的认识到条件变量带来的好处和作用,以及他所实现的线程同步的话,你可以直接忽略这段文字,跳转到下面条件变量实现同步的原理部分。
3.
我们可以将条件变量理解为一个结构体,它内部会有一个字段专门表示当前线程等待的锁的使用情况,如果status有效,那么代表此时锁也被释放,还有一个字段是专门维护等待某一个锁的线程队列。当status变为有效的时候,我们可以调用pthread_cond_signal唤醒cond内部的等待队列中的某一个线程,将这个线程的上下文加载到CPU的寄存器上,并且这个线程会申请到上一个线程释放的锁,然后这个线程就可以拿着锁互斥的去访问临界区了。
所以条件变量实现同步的根本原因就是通过wait和signal来实现的,比如某一个线程释放完锁了,那你这个线程就不要再给我继续申请锁了,因为我要唤醒cond的等待队列中的线程了,他们还想要这把锁呢,至于你,就去cond的等待队列中等着就行了,等下次唤醒到你的时候,你才有资格重新申请锁。所以通过条件变量等待和唤醒的这样一种方式,成功实现了多个线程都能互斥式的访问临界区,而不会出现某些线程无法申请到锁而产生的饥饿问题。
4.串行、并发、并行的概念
1.
接下来我要给大家介绍几个概念,是关于串行、并发、并行的。单独说这几个概念实际并不难,但他们在现代计算机中是如何被分配的,这样的知识就比较珍贵了。另外需要说一点的是,网上有很多都喜欢把多核叫做多CPU,但是吧这么叫确实没什么太大的错误,因为一个处理器芯片上集成了多个核心,每个核心都有自己独立的存储单元,控制单元,算术逻辑单元,所以每个核心都可以跑不同的任务,从功能角度来讲,确实可以叫做多CPU,但是也容易误导萌新啊,就比如我这样的,我以为是真的多CPU处理器呢,原来是大部分人的叫法不同而已。
2.
实际我们的计算机在工作时,是一定要进行并发的,因为并发能很好解决用户同时想要运行多个程序的需求,也就是我们所说的多任务处理,但同时也需要进行并行。就比如上面图中举得例子,每个大核跑不同的程序,但同时某一个大核在跑程序时,也可以时间片轮转的去执行另一个程序,所以并行和并发在计算中是同时存在的。
而并发一定要比并行效率高的前提是多任务情况,如果你站在多任务处理的角度去看待串行和并发,你一定可以理解为什么并发效率要更高,因为串行在线程被切换下去或者等锁被释放的时候,这段时间CPU什么都做不了,那这段时间就会被白白浪费掉,在多任务处理的情况下,效率一定就会下降。而对于并发来讲,如果某个线程被切换下去或者他在等待锁被释放的时候,是完全没有关系的,因为CPU会调度运行其他线程,所以被切换下去的线程在等待的时候,时间完全不会被浪费掉,而是会被CPU利用起来去跑其他的线程。
我以前不能理解为什么并发要比串行执行效率高的原因就是因为,我当时站的角度并不是多任务处理,而是单任务处理的角度,但这种场景一定非常少见,或者可以几乎说完全不存在,你想一下,你的电脑开机之后,会只有一个任务再被单独处理吗?绝对不会,怎么验证呢?非常简单!你打开你的任务管理器,去看一下有多少后台进程正在被运行,这会是单任务处理的场景吗?
我当时理解有误就是绝对,单独一个任务无论是串行还是并发执行效率都是一样的,但这个理解本身并没有错误,只不过这样的场景不存在,我们讨论这些线程执行效率的前提几乎都是默认在多任务处理的前提下进行讨论的!
5.条件变量的基本代码编写
1.
这里我们先用全局的互斥锁和条件变量进行简单的代码测试,帮助大家在代码层面上理解一下条件变量带来的效果,真正使用条件变量和生产消费模型编写代码的环境放在第三部分进行讲解。
首先我们创建出一批线程,并在线程函数内部对共享资源tickets进行加锁保护,和使用条件变量来实现线程之间的同步关系。在start_routine中,我们让所有的线程在进入临界区之后,先去执行等待,让所有的线程都去条件变量里面等着(实际执行pthread_cond_wait时会自动以原子性的方式释放当前线程持有的锁),然后由主线程来负责唤醒cond中的等待线程,如果是这样的话,那所有的线程都可以申请到锁访问到临界区,不会出现饥饿线程。
2.
当主线程调用pthread_cond_signal唤醒cond队列中等待的线程后,可以看到线程抢票的运行结果,非常有顺序的执行票数- -,执行的顺序是12453,并且每个线程都兼顾到位,没有出现线程饥饿,无法执行票数- -的情况产生。
主要还是因为当线程被唤醒,访问完临界资源释放完锁之后,循环执行代码,他又会去执行pthread_cond_wait了,此时就又会释放锁,进入等待队列,而signal此时会继续重新唤醒等待队列的其他线程。以这样的方式来让所有线程都可以申请到锁。
这里在补充介绍一个接口pthread_cond_timedwait,该接口与pthread_cond_wait不同的是,wait接口会将阻塞等待锁的线程放入cond的等待队列里面,直到有锁被释放时,pthread_cond_signal接口会唤醒cond等待队列中的线程。而timedwait是等待锁一段时间后,如果锁未被释放,那么该接口会自动超时返回,防止线程长时间的阻塞等待锁。但这个接口并不常用,我们还是重点使用pthread_cond_wait接口。
3.
当调用pthread_cond_broadcast时,会唤醒cond阻塞队列中的所有等待线程,然后这批线程会依次按照某种顺序竞争锁,当线程使用完锁访问完临界区之后,就会释放锁,然后重新回到条件变量中进行等待,而此时剩余被唤醒的线程再去竞争锁,做着上一个线程同样的工作。所以打印结果如下图所示,唤醒一批线程之后,5个线程都抢票,每次都是以5个线程为单位进行唤醒。
这就是条件变量带来的线程同步,让所有线程先去条件变量中进行等待,随后会唤醒其中的每一个线程,唤醒后的线程在访问完临界资源后,又会重新投入等待队列当中,以这样的方式来让所有线程都能够申请锁访问到临界区的临界资源。
三、基于blockqueue的生产消费模型
1.双阻塞队列的多生产多消费模型的实现
1.
上面我们已经谈论过生产消费模型的概念和条件变量的代码实现,现在我们就要用这两样工具实现出基于阻塞队列的生产消费模型。
原本的计划是先将单生成单消费一个阻塞队列实现的生成消费模型,但是吧这样有点简单了,我们直接上难点的,越难才能越加深大家对线程同步与互斥,阻塞队列,条件变量的使用等等的理解,所以我们直接实现下面那种生产消费模型的代码,即为多生产多消费,并且实现两个阻塞队列,在这种复杂环境下依旧能够保持线程间的同步与互斥式的访问共享资源。
2.
由于要实现两个分别存放不同任务的阻塞队列,那我们直接就写出来一个阻塞队列的类模板,这样就可以存放任意类型的对象,所以下面我们先来完善BlockQueue.hpp文件的代码,也就是阻塞队列的类模板代码。
我们需要一把锁来保证阻塞队列这个共享资源访问的安全性,并且生产线程不满足生产条件时,比如阻塞队列已经满了,则生产线程此时就不应该继续生产,而是要去cond的队列中进行wait,直到消费线程唤醒生产线程,所以生产线程要有自己的produce cond,简称pcond。反过来对于消费者来说同样如此,所以消费者在不满足消费条件的时候,也要去自己的cond队列中进行wait,那么消费者也应该要有自己的consume cond,简称ccond。所以类BlockQueue的私有成员应该包括_mutex互斥锁,_ccond,_pcond两个条件变量,我们还需要一个变量来描述阻塞队列的容量大小也就是_maxcap,然后再加一个STL容器queue< T > _q;然后希望定义出来的所有阻塞队列的最大容量都是同一个的,所以_maxcap定义为一个不可修改的静态成员变量,静态变量在类内只是声明,类外进行初始化,初始化时需要带上类名,不用添加static关键字。
阻塞队列需要实现的接口主要为四部分,构造函数内需要初始化好互斥锁以及两个条件变量,因为阻塞队列所使用的锁和条件变量是局部的(对象本身就在函数栈帧中)条件变量和锁,那么就需要在构造函数内进行初始化,在析构函数内完成销毁。
除此之外,还需要实现push和pop两个接口,为了保证向队列中push元素的安全性,所以接口中要进行加锁和解锁,然后就是判断是否满足push的条件,如果队列已经满了,那就不要继续push,也就是不要继续生产了,而是去pcond的队列中进行wait,一旦wait执行流就会阻塞停下来,等待被唤醒,如果满足条件,那直接用STLqueue的push接口push元素即可,非常简单。push元素之后,我们就该唤醒消费线程了,因为现在队列中至少有一个元素,是可以供消费者消费的,所以直接调用pthread_cond_signal唤醒ccond的队列中的线程即可。最后就是释放锁的步骤。
对于pop来说,由于STLqueue的pop接口不会返回pop出来的元素,所以我们需要通过输出型参数的方式拿到pop出来的元素值。与push的实现逻辑一样,pop满足的条件是队列中元素必须不为空,如果为空,则需要去ccond的队列中进行等待,直到被生产线程唤醒。pop数据之后,队列中一定至少有一个空的位置,所以此时应该唤醒生产线程,让生产线程进行元素的push,最后还是不要忘记释放锁。
对于接口的实现,大致逻辑说的差不多了。但在代码中还有几个细节需要特别说明一下。我们知道pthread_cond_wait接口是放在临界区内部的,所以在执行wait代码之前线程是持有锁的,为了在线程等待期间,其他线程也能申请到锁并进入临界区,所以在pthread_cond_wait被调用的时候,它会自动的以原子性的方式将锁释放,并将自己阻塞挂起到pcond的队列中。那么当队列中的某一个线程被唤醒的时候,他还是要从pthread_cond_wait开始向后执行,所以此时他还是在临界区内部,所以在pthread_cond_wait返回的时候,会自动重新申请锁,然后继续在临界区中向后执行代码。另外判断逻辑的语句必须是while,不能是if,因为在多生产多消费的情景下,可能出现伪唤醒的情况,比如broadcast唤醒所有生产线程,但实际空位置只有一个,所以此时在唤醒之后,某一个线程竞争到锁,放入元素之后,队列已经满了,然后他释放了锁,其他某一个线程在竞争到锁之后,如果是if逻辑,那就不会重新判断是否满足,而是直接push元素,那就会发生段错误越界访问,所以要用while循环来判断,保证唤醒的线程一定是在条件满足的情况下进行的push元素。至于唤醒对方和释放锁的顺序怎么样都可以,因为唤醒对方,对方没锁的话,还是需要阻塞等待锁被释放,而如果先释放锁的话,由于对方没有被唤醒,那照样还是拿不到锁,所以这两个接口的调用顺序并不影响接口的功能,所以先写谁都可以。
3.
主函数上层调用的逻辑就是要创建出多生产多消费的线程出来,而且要使用两个阻塞队列来完成计算任务和保存任务的产生与消费,所以我们又封装了一个BlockQueues类,类中封装两个Blockqueue,一个存储计算任务,一个存储保存任务,任务其实就是类对象,所以BlockQueues的类模板参数分别为C calculate和S save。然后就是创建出阻塞队列和多个生产线程和消费线程,以及保存线程。分别对应执行的线程函数是produce,consume,save,然后把BlockQueues类型的指针传给三个线程函数,这样在线程函数内部就可以通过BlockQueues类的两个指针成员去调用阻塞队列中的push和pop接口,完成任务的push和pop。
produce中,我们需要定义出CalTask类的对象,把这个任务对象push到c_bq(calculate blockqueue)这个阻塞队列中,构造对象需要两个操作数,以及操作运算符,还需要传一个mymath执行计算的函数指针进去,因为我们希望这些任务对象都是可调用对象,消费者在消费的时候,从队列中拿到任务之后就可以通过调用()运算符重载来完成计算任务mymath函数的调用,为了在打印的时候我们看的更加清晰,CalTask类内还实现了toTaskString函数,其实就是打印出计算任务的名称是什么,比如是1+1=?这样的名称,让我们在终端能够明显看到是produce线程函数在被执行。由于操作运算符有多种,所以定义出了字符串对象oper包括了5种运算符,然后我们又rand生产随机数,模拟两个操作数的生成。
consume中,任务比较艰巨,他需要消费计算任务CalTask,还需要生产保存任务SaveTask到s_bq(save blockqueue)保存阻塞队列中,消费任务需要传输出型参数,也就是一个空的CalTask对象t到pop接口中,然后pop结束后,t对象即为c_bq中取出的任务对象,拿出队列中的CalTask对象后,想要消费其实很简单,因为这个对象实际是仿函数对象,直接通过()调用即可。然后就是生产保存任务到阻塞队列中,与计算任务相同的是,保存任务对象也需要实现为可调用对象,这样在save线程取出任务对象时,也可以直接通过()来调用SaveTask类中的运算符重载函数,实现任务对象的消费。所以在构造SaveTask任务对象时,需要传计算任务的名称也就是一个string类型的对象,以便于执行保存任务到文件中时,我们能在文件当中看到对应保存的计算任务名是什么,然后还需要传一个函数指针Save,该函数的功能其实就是进行文件操作,将计算任务的名称保存到磁盘文件中。
save中,道理也是相同,要想拿出s_bq中的保存任务对象,自然需要通过输出型参数来拿出,所以我们传一个SaveTask类的空对象t到s_bq中的pop接口,pop调用之后,t就是s_bq中取出的保存任务可调用对象,所以消费的时候直接通过()来调用SaveTask类中的()重载函数即可完成保存任务,相对应的计算任务的名称就会保存在磁盘文件中。
三个线程函数的具体实现我们说完了,同样的在MainCp.cc这个文件当中也有一些细节要注意。记得我们在谈论如何避免产生死锁问题时,我们说到过一个写代码时需要注意的点就是,在多线程编程尤其是加锁的代码中,尽量将申请的资源统一在开头处一遍就申请好,不要在代码中需要的时候才去申请,因为那可能会出现一些你根本无法预料的错误。害害害,人教人教不会,事教人一教一个准,没错,我就是那个不在开头一遍申请好资源的人,所以我也遇到了我无法解决的bug,确实令我头疼了很长时间。初始化第二个阻塞队列的那行代码如果放在创建produce和consume线程之后,也就是我注释掉的那个地方,你去运行吧,保证爽死你,你看到的运行结果就会是,一会儿运行正常,一会儿报段错误,这对于刚接触多线程的萌新来说,友好度直接拉满。产生那样现象的原因是因为,如果主线程运行的足够快,那就会出现consume线程还没将保存任务放到s_bq之前,主线程的s_bq正好初始化好了,所以程序会正常运行。但如果主线程稍微运行的满了,那就会出现s_bq还未初始化好,consume线程就已经将保存任务放到s_bq里面了,但s_bq是还没分配内存的野指针,所以此时就会报段错误,因为我们访问了野指针。所以,老铁们,尽量在开头的时候把需要使用的空间资源就分配好,别等到使用的时候才去分配,因为多线程不好找错误啊!
4.
最后一个文件就是Task.hpp,这个文件就是我们要实现的计算任务类和保存任务类,以及计算的方法和保存的方法。计算任务类中要实现两个构造函数,一个是空的构造函数,用于main中构造出空对象作为输出型参数传递给阻塞队列的pop接口,另一个就是构造出真正的任务对象。类成员只需要两个操作数一个操作符,外加一个包装器即可,因为包装器可以包装很多可调用对象,所以如果你想搞一个仿函数对象,或者lambda表达式或函数指针来传给构造函数的话,包装器类型都是可以接收的,在构造函数内部将这些私有成员都初始化好即可。除此之外还需要实现一个()运算符重载和一个返回string任务名的toTaskString函数,为了将可调用对象的计算结果返回,()运算符重载内部回调了mymath的方法,将计算结果通过snprintf函数进行字符串格式化到buffer里面,然后用buffer构造出string对象进行函数返回。toTaskString也是将计算任务进行名称的格式化到buffer里面,同样返回一个由buffer构造出的string对象。
mymath函数的实现我就不说了,用switch case语句就可以实现两个操作数的计算,这真的可以算是入门级的代码实现了。
SaveTask类成员变量包括保存的计算任务名_message,这个任务名实际就是通过CalTask的()运算符重载函数返回的string对象,传到我们的SaveTask内的构造函数里的,另一个成员变量就是包装器,用于包装将任务名写到文件的文件操作方法Save函数指针。同样的还需要实现一个空的构造函数,用于main中调用pop时,将任务写到输出型参数空的SaveTask对象里。为了实现任务的消费,我们也实现出一个()运算符重载,老样子,回调一下包装器包装的可调用对象即可。
至于Save的实现也不难,就是比较正常的C语言文件操作,fopen打开文件,fclose关闭文件,fputs写入文件。
5.
到此为止我们就谈完了整个的双阻塞队列实现的多生产多消费模型,下面是程序的运行结果,我们很好的实现了计算任务的生产消费,保存任务的生产消费,且是在多个生产者多个消费者的多线程情景下实现的生产消费模型。而能够实现的原因还是因为我们有锁来保证多线程访问共享资源的互斥性,还有条件变量来保证多线程在互斥访问共享资源时的同步性。
2.生产消费模型高效在哪里?(不影响其他多线程并发或并行的获取任务和执行任务)
1.
上面代码写完了,我们要来回答一个非常重要的问题,就是为什么生产消费模型是高效的?我并没有见到他高效在哪里啊!访问阻塞队列这个共享资源时,不还是得互斥式的访问么?你凭什么说生产消费模型高效呢?
确实!你说的没有问题,很正确!但实际生产消费模型根本不是高效在向阻塞队列中放元素和从阻塞队列中拿元素。而是高效在某一个线程在向阻塞队列中放任务的时候,不会影响其他线程获取任务,某一个线程在从阻塞队列中拿任务的时候,不会影响其他线程在执行任务。
我们今天所写的阻塞队列中不过是存储了一些微不足道的计算任务或保存任务,执行和获取起来根本不费力,但未来线程在真正获取某些大型任务比如从数据库,网络,外设拿来的用户数据需要处理呢?那在获取任务和执行任务的时候,会很费时间的。
而生产消费模型高效就高效在,你某一个线程互斥式的从阻塞队列中拿任务或取任务时,根本就不会影响我其他多个线程在获取任务或执行任务,并且其他多个线程是在并发或并行的执行任务,效率是很高的!
所以总结起来就一句话,生产消费模型并不高效在放任务到阻塞队列和从阻塞队列拿任务,而是真正高效在,某一个线程拿或放任务到blockqueue的时候,并不会影响其他线程并发或并行的获取任务和执行任务。