引言:
北京时间:2023/7/19/15:22,昨天更新完博客,和舍友下了一会棋,快乐就是这么简单,哈哈哈!总体来说,摆烂程度得到一定的改善,想要达到以前的水准,需要一定的契机,毕竟人生在世,快乐最重要是吧!更文带给我的快乐已经没有那么多了,虽然欠了非常多的作业,非常多的课需要补,很多的题等着我去刷,怎叹一个懒字了得,本质还是作息控制不住,哎!这周小目标更文4篇,只要能达到这个水准,其它的都好说,想到还有那么多课没有看,现在真的挺头疼!不管那么多,正式进入该篇博客的正题,承接上篇博客有关多线程互斥和同步相关的知识,该篇博客我们继续深入理解一下有关线程的互斥和同步吧!
深入线程互斥
承接上篇博客有关线程互斥相关知识,此时我们在深入理解一下线程互斥。在上篇博客中,我们重点强调了为什么要进行线程互斥和如何进行线程互斥,也就是如何让一份共享资源变为临界资源,每次访问共享资源时,只能有一个线程获得资源的使用权(加锁),其他线程必须等待,直到该线程释放资源后才能继续执行(解锁)。并且在此基础上,我们还简单介绍了有关线程互斥的相关线程库接口,如:pthread_mutex_init,pthread_mutex_lock,pthread_mutex_unlock,pthread_mutex_destroy
,当然我们也明白,在使用这些线程互斥接口的前提是我们定义了一个全局的锁,pthread_mutex_t mutex;
在使用文档中,当我们定义了一个全局的锁结构时,该锁结构是一定需要进行初始化和销毁,也就是必须使用pthread_mutex_init
接口和pthread_mutex_destroy
,但是使用文档中也给我们提供了另一种方法,让我们可以不需要使用这两个接口,就能完成锁结构的初始化和销毁,在定义锁结构时直接在其后面添加对应的宏结构,pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
这样就可以直接在定义锁的同时,完成对锁的初始化和最终锁的销毁,更简便的供给我们使用。那么此时有的同学就会有问题了,有了这个宏定义,还需要之前初始化和销毁的接口干嘛呢?答案是,使用宏定义快速对锁进行初始化的前提是该锁是一个全局变量的锁,只有是全局变量的锁才有资格使用pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
方法,因为如果对应的锁是局部变量,那么就会导致当函数调用完之后,对应的局部变量锁随着栈帧的销毁而销毁,最终导致编译器无法根据地址再找到对应的锁结构,造成资源泄露问题,所以当定义一个局部变量的锁时,此时就只能使用pthread_mutex_init
接口和pthread_mutex_destroy
接口进行对应的初始化和销毁。当然此时还没有讲清楚,为什么局部变量不能用 PTHREAD_MUTEX_INITIALIZER
方法,只有全局变量可以,这是因为如果使用了 PTHREAD_MUTEX_INITIALIZER
方法,本质是将对应的锁变量初始化为默认值,并且将其存储在静态区中,使其生命周期与该进程的声明周期相同,当进程结束,操作系统就会自动回收该锁变量占用的资源,达到销毁目的,所以这也就是为什么局部变量锁不能使用 PTHREAD_MUTEX_INITIALIZER
方法的原因。
互斥锁细节介绍
搞定了上述知识,此时我们对锁就有了进一步的理解,当然想要彻底搞定锁相关的知识,重点是搞定锁的实现原理,明白它为什么可以让共享资源变成临界资源,不过在了解锁实现原理之前,此时我们先来谈谈有关加锁方面的细节知识,如:一个共享资源只允许使用一把锁进行保护,不然就有可能导致死锁问题。并且在进行加锁时,加锁的位置一定要合理,尽量细化,只要将共享资源保护起来就行,不允许大范围加锁,否则会导致代码执行效率非常低。然后还要明白,对于锁来说,加锁和解锁本身就是一个原子结构,也就是因为锁也属于共享资源,如果不对锁进行保护的话,那么同理会导致竞态条件问题,所以锁的设计者在设计锁的时候,已经将锁设计为原子结构,也就是当一个线程在访问一个锁的时候,别的线程不允许访问该锁。最后还要明白,一个临界区不仅仅只是一行代码,也可能是一批代码,所以当某个线程在执行该临界区中的代码时,该线程有可能会因为时间片到了,而被操作系统调度,使得对应临界区中的代码没有执行完,但是这并不会导致其它线程可以访问对应的临界资源,因为加锁之后,无论临界区的代码是否执行完毕,其它线程都无法访问到对应的临界资源,这也正是加锁之后带来的线程串行化表现。
锁的基本实现原理
搞定了上述知识,我们正式进入锁的实现原理讲解,首先明白,锁本质就是一个互斥量,因为其可以保护共享资源,也就是只让一个线程访问对应的资源,所以我们将这种特性(互斥)称之为锁,也叫互斥锁。明白了这点之后,接下来我们要搞定的也就是互斥量如何进行互斥,从而让多个线程访问共享资源时,只有一个线程能够成功访问,如下图所示:
如上图所示,此时我们明白,对于所有线程来说,它们在同时访问同一份共享资源时,因为我们进行了加锁操作(pthread_mutex_lock
),所以它们在访问该共享资源之前就需要执行加锁接口相关的代码,也就是如上图所示的伪代码,首先它们要将自己上下文中的%al变量初始化为0,然后再使用exchange接口将我们事先定义并且初始化好的锁变量(mutex)从内存中交换到寄存器,也就是交换到自己的上下文中,让%al由0变1,让mutex由1变0,完成了这一步骤之后,对应的线程就实现了互斥操作,也就是我们所说的加锁,并且此时mutex就是该互斥操作中的互斥量。交换的目的就是让这个互斥量只被一个线程拿到,当下一个线程也要交换时,由于mutex互斥量已经变为了锁定状态(0),此时它就无法获取到mutex中的1(未锁定状态),从而无法执行pthread_mutex_lock
中的后序代码,只能被操作系统挂起等待(if语句判断)。最后明白一点,也就是我们一直说的锁是共享资源,却不会造成竞态条件的原因是因为其设计成了原子性,从上图我们就能看出,一个线程在执行对应加锁代码时,其中获取互斥量的过程,仅仅就只是一个交换语句,所以可以明白,对于线程来说,单独一句代码,要么执行,要么就是不执行,所以对于加锁操作,它天生就是原子性的。
所以同理解锁操作,就是将mutex的值由0变1,这样,下一个线程在执行加锁操作时,就可以获取到对应mutex互斥量中的1,因为此时mutex处于未锁定状态(1),同理获取到之后(交换)mutex就又会处于锁定状态(0),所以这也是为什么有加锁操作,就一定要有解锁操作,否则就会造成死锁,无论是那个线程都无法访问到该共享资源。
线程封装
明白了上述有关互斥锁的相关知识之后,此时我们进行线程的封装,也就是对pthread.h头文件中有关线程控制相关接口的封装,实现一个自己的简易线程库,当然无论是在C++,还是Java中,它们的线程库都是和我们一样,对pthread.h头文件进行的封装,只不过在设计上不同,所以导致不同的语言在线程库的使用上不同,本质原因就是封装的方法不同,如下代码所示,就是我们自己对线程库的一个封装:
如上图所示,此时我们就使用Thread类,完成了对线程库的一个简易封装,重点就是注意参数类型和函数指针传参方面的问题,并且还要注意有关静态成员函数相关的知识,也就是如果在一个类中,你因为参数的原因,无法使用this指针,那么此时你就可以使用静态成员函数,使用static声明,这样就完成了该成员函数和类之间的解耦,但是,因为你将该成员函数和类解耦,所以也就导致该成员函数没有this指针,最终导致该成员函数无法访问到类中的成员变量,具体如何解耦这里我们不详谈,这里注意,明白会用就行。
互斥锁的封装
明白了上述有关线程库的封装,此时我们再来看看有关互斥锁的封装,当然此时的封装还是同理对系统接口进行封装,而不是对伪代码进行封装,本质就是为了让我们可以更方便的使用加锁和解锁,如下代码所示:
此时我们就完成了对锁的封装,那么此时有的同学就会问了,为什么要这样对锁进行封装呢?如下代码所示:本质就是为了让加锁和解锁操作变得更加简易
如上图所示,通过两种不同的加锁和解锁操作,我们发现,如果将加锁和解锁操作封装在一个类的构造和析构函数中,然后通过对该类对象进行传参,这样可以非常方便的完成对共享资源的保护。