Linux——线程(4)

在上一篇博客中,我讲述了在多执行流并发访问共享资源的情况下,如何
使用互斥的方式来保证线程的安全性,并且介绍了Linux中的互斥使用的是
互斥锁来实现互斥功能,以及它的原理,在文章的结尾我提出了一个问题
用来引出同步的话题,那么这篇文章我将会介绍同步的概念,以及Linux中
的同步是如何实现的。

1. 正式开始前的知识

a. 可重入和线程安全

我在前面提出过两个概念或者是名词,那就是可重入/不可重入函数以及线程安全,我说不可重入的函数在多执行流下被访问的话就会引发线程不安全问题,因为一个函数可不可重入一般都会涉及到多执行流才会谈论这个话题,那我们们来正式认识一下它们两个:

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者
静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执
行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何
不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

如何避免出现线程不安全的问题呢?

这里给出几点建议:

每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这
些线程是安全的
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性

其实上面精炼一点就是对共享资源要保护好,或者对共享资源只读不写,如果要使用的话要保证原子性(原子性的体现可以使用锁来实现)。

而对于函数可不可重入则是会有这些现象:

调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构

以上情况的出现都会导致函数成为不可重入函数。

至此我们来认识一下重入与线程安全之间的关系:

如果一个函数可重入那么这个函数在多线程下使用就是线程安全的,如果不可重入
那么自然在多线程中使用这个函数当线程函数的话,势必会导致线程不安全问题。

他们两者的区别:

1. 可重入函数是线程安全函数的一种
2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数
若锁还未释放则会产生死锁,因此是不可重入的。

在上面的讲述中,我提到了死锁这个名词。关于死锁的知识我们也是有必要认识的。

b. 死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

这种现象一般出现在非常大的工程或者使用了非常多的锁的情况下比较容易出现,只是使用一个锁的话是不容易出现死锁的问题的,我来大致表述一下死锁如何出现:

假设现在我们有两个线程,并且有两个共享资源,两个线程中都使用到了这两个共享资源,既然要使用共享资源,两个执行流就得对共享资源进行保护,那就是互斥,而在Linux中就是使用互斥量来保护:

我们是有可能写出这种代码的。当两个线程开始并发运行的时候,A线程先申请好mutex1,B线程申请好mutex2,然后A线程开始申请mutex2/

B线程开始申请mutex1,这个时候两个线程就会相互阻塞。除却锁之外代码本身没有问题,但问题就出在了锁资源的申请。两个执行流都已经获取了两个锁资源,但是后续代码的执行还需要对方的锁资源,而两个执行流又不释放自己的锁资源,导致两个线程一直处于阻塞状态,这就是死锁问题。

这里给出死锁产生的必要条件,也就是当出现死锁问题时必定会有这几个问题的出现:

1.互斥条件:一个资源每次只能被一个执行流使用
2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
3. 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

那么避免死锁产生的问题就转化为了避免同时出现这四个情况中的一个就能够避免死锁的产生了。

当然要避免死锁问题,最最粗暴的方式就是不使用锁,因为锁的本质还是要保护共享资源嘛,如果我们的代码能够支持资源能够给每个线程独享一份,那么就不需要锁了。

避免死锁问题:

1. 加锁顺序一致
2. 避免锁未释放的场景
3. 资源一次性分配

2. 同步

a. 同步的提出

在上一篇博客中我最后引出了同步的话题,现在我再次举一个例子来说明同步的必要性:

假设我们现在有一个自习室,这个自习室同一时刻只允许一个人进入。那么现在有一个叫小明的同学一大早就进入到了这个自习室去学习了,一直学习到上午,自习室外有一些同学也想进入自习室里学习,但是现在小明在自习室里学习他们都进不去,又过了一会儿,小明学的烦躁得不行了,想去外面放松一下。但是他一开门发现外面这么多学生都等着使用这个自习室,小明想:"我要是走了的话,假如我想学习了,再进这个自习室里又不知道是啥时候了"。所以小明又走进了自习室关上了门继续学习,过了一两分钟小明实在不想学习了,又走出自习室,然后发现外面的人还是很多,他又走了回去,就这样小明进进出出如此往复,小明在这个时间段中根本没有学习,再说直白点,他只是把这个门开了关,关了开,根本没有做有意义的事。然而外面的学生也一直学习不上。这就出现了问题

在这其中每个学生包括小明都是线程,而自习室就是临界资源,同时这个临界资源也是互斥的,但是我们发现这些线程之后总体上来说什么事情都没干,但是还运动了,在计算机中小明就是一直申请锁释放锁,重复这个步骤,其他线程也因为无法竞争到锁资源而一直被阻塞,这些线程也叫做饥饿线程,这种问题也叫做饥饿问题。

学校知道了这件事情后,就发出了相关规定:当一个人从一个自习室出来之后,这个人不能马上再次进入自习室,而是排在所有等待进入自习室同学的最后面,这样以此往复,每个同学都能合理的在自习室中学习,而不至于出现一个学生在自习室中只仅仅出出不做任何事情浪费资源。

这种让线程有顺序的访问共享资源的方式就是同步。
互斥保证了共享资源的安全,而同步则是能够较为充分的使用资源

b. 生产消费者模型

1). 模型介绍

为了更好的认识同步,我们也有必要先认识一些生产消费者模型。以现实的生活举例,我们的生活中有生产者,有消费者。假如现在有一个大型超市,这个超市中,生产者负责将物品运送到超市中,而生产者负责拿走(消耗)物品:

而这种模型在多线程中也有所体现,供应商和消费者都是线程,生产者线程负责提供资源到超市中,消费者线程负责从超市中获取资源。那么超市无疑就是一段内存空间负责数据的存放它可以是一段堆空间,也可以是一个队列等容器。

我们现在发现上图中线程的身份有两种,那么它们之间的关系就有三种,我们需要理清楚这三种关系:

首先是生产者和生产者之间 :毫无疑问生产者和生产者之间是竞争关系,而且当我在超市的一个区域中置放好货物之后,其它供应商是不能再在这里放的,这里行为在计算机中就是数据的覆盖,这种行为是不被允许的。换句话说,当我在置放货物的时候,你是不能再置放货物的(假设置放空间不大,每个供货商要么放,要么放满)。那么由此观来,生产者和生产者之间的关系在计算机中就是互斥

其次是消费者和消费者之间 :这个也不难,它们也是竞争关系(在超市资源少的情况下可以体现出来),也是互斥 的。

而对于生产者和消费者之间

假如我们的供应商放置货物的步骤是放好货物,然后拍张照记录工作结果,但是当供应商放好货物之后还没来得及拍照,消费者就把这个货物拿走了,其中消费者还没等生产者共享资源的操作完成,消费者就来访问共享资源,导致供应商的工作出现了错误,在计算机中这就是多执行流并发访问共享资源导致的数据不一致问题,所以生产者和消费者之间是互斥 的。

还有一种情况,假如超市里面没有货物,消费者第一次来了超市一看没有货物,第二次看也没有,然后如此往复一直看。上面说到生产者和消费者之间是互斥的。而现在生产者一直在看超市中有没有货物,其实就是消费者一直在申请锁,访问共享资源(而且是无意义的),然后再释放锁。这样会一直导致生产者无法将货物放置到超市中。这种现象就跟我上面同步的提出中面临的问题一样,由于一个线程一直获取锁资源之后做无意义的事,而其他线程也无法继续向后执行,该线程也一直在浪费资源,而这种现象的解决就需要同步,所以生产者和消费者之间也应该是同步的。

2). 模型理解

这种模型在计算机中有什么用呢?

我们现在有这么一段代码:

可以发现这段代码是串行的,这里是一个Add函数,这个函数需要main函数传参给它,然后它负责将结果返回。这个过程对与计算机来说是很快的,但是假如这个Add函数其中的逻辑很复杂,执行一次Add函数需要的时间很多呢?而main函数中除却Add函数之外其它的代码执行的都很快,这就需要用到我们的生产者消费者模型了。

在生产者消费者模型中,调用Add函数的一方明显就是生产者线程,而执行Add函数的也就是消费者线程。如果这样实现的话,那么我们生产者代码的执行时间就与Add函数无关了(假如不关心返回值),提高了生产者代码执行的效率,支持忙闲不均,而且实现了多执行流之间的一个解耦。

c. 条件变量的介绍

Linux中同步实现的方式是条件变量。

经过上面的认识,我们知道在多线程并发访问共享资源的情况下,互斥只能保证资源的安全,但是只有互斥的话,会出现某一个线程一直申请锁 释放锁的无效的事情浪费资源从而产生饥饿问题,所以就需要同步来防止这现象的发生。

那么在上面介绍生产者消费者模型中生产者和消费者之间的关系中的同步的提出中,这种无意义的浪费资源的现象的本质其实是资源就没有准备好,所以,同步也是当临界资源没有准备好之后,阻止线程继续无意义的申请锁释放锁浪费资源,因为锁也是资源。

而且同步也能保证线程的执行具有一定的顺序性,而不是一个线程一直获取锁资源,而其他线程处于饥饿状态。

经过上面的总结,我们可以大概认识条件变量中为了防止线程一直因为资源没有准备好,而一直无意义的获取锁资源的行为,条件变量中需要有一个字段来表示资源是否已经准备好。

为了保证线程执行的有序性,它也必须维护一个线程的队列。

d. 条件变量的使用

那我们就来介绍,Linux中是如何实现同步的。

我在之前就提过这个问题,这里打印出现错误的原因是屏幕也是共享资源。

这里我们对打印的过程加锁就不会再出现了:

但是我们发现它们的打印顺序可以说是没有规律的。这里我们就可以使用条件变量来让它们的打印有序,在此之前我们先要认识一下接口:

以上是一个条件变量的初始化内容,和我们的互斥量有些相似。

将条件变量初始化好之后,我们还要能让线程在条件变量中排队:

pthread_cond_wait可以将调用它的线程放入条件变量的等待队列中(可以看到它的第二个参数是一把锁,这个会在之后的使用中来解释),直到条件变量被唤醒:

其中pthread_cond_signal可以唤醒一个条件变量。

我们现在来使用一下这些接口,来让上面的代码执行有序:

可以看到,线程的打印确实一直是同一个顺序了。上面还有一个接口没有介绍:pthread_cond_broadcast。pthread_cond_signal是唤醒一个线程,而pthread_cond_broadcast是唤醒所有线程:

这里看着跟上面一样,但其实上面的打印是一行一行的打印(逐一唤醒线程),而这个是三行三行的打印(唤醒所有线程)。

上面的代码中我们只展现了,条件变量能一定程度上控制线程执行的顺序,但是条件变量还有一个作用就是防止共享资源在没有准备好的时候,线程一直申请锁释放锁,浪费资源。上述代码无法体现的原因是没有直观的共享资源来体现。

而且现在也滋生出了几个问题:

我们发现条件变量的等待是在临界区中的,条件变量被唤醒之后,继续向后执行的也是临界区的代码。要知道当一个线程被条件变量等待之后,它也是带着锁资源等待的。那么当一个线程条件变量等待之后,其他线程在干什么,当条件变量等待的线程被唤醒之后继续执行临界区的代码的时候其他线程在干什么?难道是一直被阻塞在等待锁资源的那一步吗?那么为什么要申请条件变量啊?这些问题当我们给出下面的代码或许能够更好的解释这些问题:

现在我们模仿上一篇博客中的抢票代码,并做一些小修改:

**

我们首先看到的就是,抢票的线程它并没有按照一定的顺序执行抢票。并且当g_ticket不大于零时,所有线程都在打印"没有票了",打印这句话背后的含义是什么呢?那就是这些线程根本没有做任何有意义的事(因为g_ticket已经没有了,临界资源未准备好),而只是一直申请锁释放锁,做这种浪费资源的事情,所以才会有条件变量的出现,这里我们要使用条件变量就应该是不要让在票没有了之后还让线程一直做无用的浪费资源的事情:

这些线程就会被阻塞了。

但是这些线程现在一直在等待,等待资源是否已经准备好,那么我现在再对代码做一下修改,我们每过一段时间就补充一定数量的票,然后再让这些线程进行争抢:

我们也可以在票没有了之后,只唤醒一个线程:

当你执行过这段代码之后你就会发现出了第一次抢票外,后续整体的抢票都是有一定顺序的。

现在我就来解释一下上面的一些现象:

为什么条件变量在临界区中让线程进入等待之后,能够让线程保证一定的顺序性,我又说条件变量中有一个队列来保证这个现象,但是要知道条件变量让线程等待的时候该线程可是携带者锁资源等待的,其他线程此时根本不能够进入临界区,何来让线程在条件变量下等待啊?

所以我在这里给出:当条件变量让线程进行等待时,该线程会释放所资源,这也是为什么pthread_cond_wait的第二个参数会有一把锁了。

还有一个问题,现在能够实现线程进入临界区之后执行到让线程等待的条件变量的代码的时候能够排队直到临界资源准备好再让固定线程唤醒就好了,但是这里当我们唤醒所有线程的话,又不是乱套了吗?假如唤醒后线程仍会执行共享资源的访问,那么此时线程就是不安全的了,

这里也给出解释:当在条件变量下等待的线程被唤醒后返回时会重新参与到锁资源的竞争中,并不会直接执行后续的代码。这一点也保证了互斥的原则,假如没有这一点的话,我们知道线程在条件变量下等待同时会释放锁资源,假如临界区外的线程获取到锁资源进入临界区之后,等待的线程同时也被唤醒,这样也会导致多个线程同时出现在临界区中,也违反了互斥的概念。

3. 生产消费者模型

经过上面对同步的认识,以及生产消费者模型的介绍,我们知道要实现生产消费者模型其实也就是保证两种身份之间的三种关系就能很好的保证共享资源的安全性了,也就是保证线程安全了。

a. 阻塞队列

我们现在来通过阻塞队列来实现一下单个生产者消费者的模型,阻塞队列是什么呢?其实也很简单就是:当阻塞队列中为空时消费者进程会被阻塞知道生产者将数据放到阻塞队列中,当阻塞队列中为满时,生产者线程会被阻塞,直到消费者线程让阻塞队列不在是满的。

b. 阻塞队列的实现

那么我们现在就来实现:

我们需要封装一下STL中的队列,因为STL中的容器大部分方法都是不可重入函数。而且我们这个阻塞队列中可能存任意类型的数据(int、float等等),所以使用模板。

上面说到,阻塞队列是有容量的,并且生产者和消费者之间是互斥且同步的,并且生产者和消费者之间是能够互相阻塞的,所以这里需要两个条件变量,以让两者能够互相阻塞,以能够实现同步:

接下来就是具体方法的实现:

这样我们的一个比较简单的生产消费者模型就完成了,我们现在来试用一下:

我们也可以让生产者先充满阻塞队列,然后再让消费者消费数据:

可以看到我们确实可以通过条件变量来实现控制线程执行的顺序。

c. cp模型的再理解

上面的代码实际上是不够健壮的,我们来看这里:

这里会有一定的问题出现。现在我们是单个的生产者消费者,如果是多个的呢?假如有多个消费者线程都在条件变量下等待,此时生产者插入了一个数据,我们现在假如是唤醒所有的消费者线程,那么此时所有的消费者线程全部开始锁资源的竞争,第一个竞争到的消费者线程能安全的把代码执行完,假如这个时候生产者并没有产生数据,而第一个消费者线程已经执行完临界区代码后释放锁,这时第二个解除等待的消费者线程竞争到锁资源,继续执行_q.pop(),这个时候队列中可是空的啊,那么势必第二个消费者线程执行到这里的时候,程序就会报错。所以这里会产生伪唤醒 的现象出现。

解决方式也很简单:

if改while;

这样就能有效地解决了。也提高了代码的健壮性。

我们在上面对阻塞队列存储的内容是int,那可不可以是一个任务呢?


这个任务可以是从网络上传输过来的,也可以是本地的,完全自主设计。

现在可以看到生产者消费者模型确实是可以支持代码的解耦(生产者生产时消费者也可以消费)但是生产者消费者可以提升解决任务的效率又是从何而来呢?可以看到两种角色两两之间都是互斥的,这意味着当某一个角色执行任务访问共享资源时,同身份的其它线程和另一个身份的线程都会被阻塞,而阻塞也就意味着串行,何来提高效率这一说呢?

关于这个问题的理解,我们不应该在模型本身,而是应该放在生产者的数据是怎么来的?消费者消费数据以及消费数据之后该怎么做。要知道生产者获取数据以及消费者收到执行什么任务之后开始执行任务,这些事情可都是并行的了,所以这里才是生产消费者提高效率的真正原因。

上述代码我说的是单生产单消费模型,多生产多消费呢?难道要在线程函数上再上两把锁吗(生产者与生产者之间互斥,消费者和消费者之间互斥)?其实不是的,关于线程的互斥并不是对于线程来说的,而是对于资源来说,在整个生产消费者模型中使用的共享资源只有一份,那就是阻塞队列。所以只需要一把锁就可以。而我们代码中的锁正好就是在临界区上,所以我们的代码本来就是支持多生产多消费的:

相关推荐
ALISHENGYA4 分钟前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(实战训练三)
数据结构·c++·算法·图论
soragui5 分钟前
【ChatGPT】OpenAI 如何使用流模式进行回答
linux·运维·游戏
GOATLong34 分钟前
c++智能指针
开发语言·c++
白云coy1 小时前
Redis 安装部署[主从、哨兵、集群](linux版)
linux·redis
Logintern091 小时前
Linux如何设置redis可以外网访问—执行使用指定配置文件启动redis
linux·运维·redis
娶不到胡一菲的汪大东1 小时前
Linux之ARM(MX6U)裸机篇----1.开发环境搭建
linux·运维·服务器
fat house cat_1 小时前
Linux环境下使用tomcat+nginx部署若依项目
linux·nginx·tomcat
shada1 小时前
Ubuntu 24.04 APT源配置详解
linux·ubuntu
monstercl1 小时前
Ubuntu16.04手动升级内核到5.15
linux