引言:
北京时间:2023/8/3/19:21,刚刚将文章更新,是近期以来为数不多的一次早更,不然每次更文都要卡在12点左右,这是我们实现日更的一个好开端,再接再厉实现日更不是梦。最近失眠一直困扰着我,不知道是真的有点焦虑,还是白天睡太多了,反正每天躺在床上就是睡不着,老是想一些七七八八的事情,没有两个小时以上根本睡不着,莫名不知道什么原因,大概率是作息问题,但是我记得以前无论我睡多久,什么时候睡,都能实现倒头就睡,睁眼就是天亮,睡的还贼舒服,现在反正每天都是强制起床,这个问题有待解决,目前的解决方法就是寻找一部小说听听,以前睡觉之前都是听着小说睡,听着听着就睡着了,但是最近书荒,不过可以考虑入手很久以前发现的有关耳根的小说,看评论对耳根的评价好像非常高,像什么《求魔》好像挺火,所以我考虑去听一听它的另一本小说《我欲封天》,由于是第一次接触耳根的小说,具体适不适合我,有待深入。正式进入该篇博客的主题,有关线程池、单例设计模式、自旋锁、读者写者问题的学习。
线程池相关知识
首先对这块知识我们不要害怕,因为线程池相关代码实现与生产消费模型颇有类似之处,所以此时我们同理只要对线程池的实现原理和基础概念明白之后,线程池的代码实现自然就是手到擒来,所以下面让我们来分析分析有关线程池的知识吧!
常见的资源管理方式
第一点明白什么是池化技术,想要明白什么是池化技术,那么前提我们要来看看系统内部的一些资源管理方式,谁让池化技术的本质就是一种资源管理方式呢? 此时我们明白对于系统内部的资源管理,最常见的就是两种,一种是延迟加载,一种是预先加载,对于这两种资源管理方式我们都不陌生,一路走来多多少少都是了解过一点的,只是因为我们没有进行系统的了解,所以并不知道它们都是对系统资源的管理方式。其中对于延迟加载,我们在学习地址空间和页表时体会最为深刻,我们知道当一个可执行程序被加载到内存之后(进程),根据该可执行程序,操作系统就会帮我们创建一份地址空间,当CPU在执行该进程地址空间代码段上的代码时,就会通过页表将地址空间上的地址映射到内存中的物理地址,当然此时对应内存地址中可能并不存在相应的数据,因为系统要满足延迟加载,也就是当某数据在没有使用之前,不允许它加载到内存中浪费内存资源,但此时因为CPU已经在访问相关数据了,所以操作系统就会触发缺页中断,在内存中申请空间提供给地址空间中的地址映射,但此时因为操作系统只申请了内存地址,并没有数据,所以操作系统还需要再进行一次缺页中断,将数据从磁盘上加载到内存中刚刚申请的地址中,最后让CPU可以成功访问到其需要执行的代码数据。明白了这个过程之后,我们对延迟加载就有了很好的认识,本质延迟加载就是为了提高系统性能和资源的利用率。 当然上述讲到的知识,无论是缺页中断还是页表映射,在之前博客中我们都详细进行了介绍,所以这里默认你们都懂哈!搞定了延迟加载相关的知识,此时就轮到预先加载了,谈到预先加载就不得不提局部性原理,因为以前我们认为局部性原理的本质就是进行预加载,但是这里有个误区,就是局部性原理其实是计算机中的一种对数据和指令访问模式的观察原则,其中分为时间局部性和空间局部性,时间局部性表示当某一个数据被频繁访问时,该数据就会被保留在缓存中,而空间局部性表示,在访问某数据时,因为该数据附近的数据大概率下次也会被访问,所以此时会通过预先加载的方式将该数据附近的数据一起加载到缓冲中。所以明白局部性原理本质是计算机中的一种规则,并不等价于预先加载,这里需要注意。但是通过局部性原理这一规则此时我们明白预先加载的本质就是将那些大概率会被使用、会被频繁使用的资源进行事先加载,从而实现下次真正需要使用的时候,不需要再浪费时间去加载,从而优化系统的性能和资源的利用率(以空间换时间)。
为什么有池的概念?
成功完成了对上述资源管理方式的理解,此时我们顺理成章的来看看什么是池化技术、什么是线程池吧!明白池化技术就是一种对资源进行预先加载的资源管理方式,它通过预先创建一定数量的资源提供给操作系统使用,从而达到提高系统性能的作用。所以此时我们明白,池化技术本身就是一种预先加载技术,同理线程池就是一种预先创建一批线程提供给系统必要时使用的方法。 同理有关内存资源的管理,虽然为了避免内存资源被浪费,操作系统设计了延迟加载的方式,但是延迟加载同理会带来效率问题,也就是虽然延迟加载可以提高内存资源的利用率,但是它也会使系统的执行速度降低,所以为了缓解这一问题,操作系统对于内存资源的管理不单单是简单的延迟加载,其中还使用了预先加载的管理方式,当然此时对于内存来说更准确一点可以称为“预取”。也就是当CPU在执行代码时,需要频繁的向内存申请空间,此时操作系统就需要频繁的执行内存管理算法,然后分配相应的内存给你,特别是如果当你频繁的执行系统调用代码(形成栈帧),那么此时不仅需要频繁从磁盘中加载数据而且还要频繁向内存申请空间,那么此时程序执行效率就会非常低,所以当我们在使用系统调用或者是某些容器时,此时底层代码在开辟空间时,就会额外申请一块空间,从而当你额外需要使用内存时,可以直接使用,不需要再向内存申请,从而提高程序的执行效率,当然这一规则我们称为内存池(以空间换时间)。所以同理这一原则,在网络中当用户发送某个请求时,操作系统虽然会立刻创建一个线程去处理,但是创建线程的这个过程被计算到完成这个请求的过程中,从而导致系统的响应速度下降,所以为了解决该问题,我们就会在系统内部事先创建一批线程,当用户发送请求时,直接将该请求封装成某个任务加载到任务队列中,然后去唤醒某个事先创建好的线程去执行该任务,从而实现高响应。这也就是线程池概念最大的应用和实现规则。
线程池工作原理
在系统启动时,线程池会创建一批线程,并存储在容器中。当任务队列不为空时,线程池会从线程池中获取某空闲的线程来执行该任务。任务执行完成后,线程并不会被销毁,而是重新放回线程池中,等待下一个任务的到来。如果线程池中的线程都在执行任务,而没有空闲线程可用,新的任务就会被存储在任务队列中,反之,如果任务队列中没有任务,那么线程将全部被阻塞,直到被唤醒。
线程池简易代码实现
当然,上述代码中涉及了一个静态成员函数的问题没有详解,这里我简单谈一谈,本质是因为在类中,所有成员函数的第一个参数默认是this指针,而因为我在使用pthread_create接口创建线程时,回调的是一个类内成员函数,所以此时当我们在传参(void* args)时,就会导致this指针和args之间的类型不匹配问题,所以此时最好的解决方法就是使用静态存储,将该类内成员函数存储在静态区,实现与该类的解耦,从而解决第一个参数是this导致的类型问题。但因为我们实现了与类的解耦,所以此时该成员函数并不能访问到类内属性(对象),所以pthread_create接口在传参时就会将this指针传过去给它使用,从而解决这一问题,当然最后虽然我们把this指针传过去了,但是由于类内成员变量是private状态,所以该静态成员函数依然访问不到类内属性,所以此时我们使用了友元的方式,让该静态成员函数可以成功访问到类内私有属性,达到编码目的。当然想要实现访问类内私有属性方法很多,你也可以使用公共成员函数返回或者直接调用公共成员函数来实现。当然上述代码只是一份最简易的代码,有待优化,下述在有关单例模式相关的知识中,我们将进行进一步的线程池代码优化。
线程安全的单例模式
首先明白,单例模式本身是一种经典的设计模式,而对于我们来说,设计模式就是一套被广泛接受和使用的解决特定问题的经验总结和最佳实践。并且提供了一套标准化的方法来解决常见的设计问题,从而提高代码的可读性、可维护性和可扩展性。
为什么有单例模式?
上述我们知道单例模式本身是一个设计模式,那么为什么要提出单例模式这个概念呢?当然想要回答这个问题,本质就是明白单例模式的好处是什么,当然由于我们此时具体还不知道单例模式的详情,所以我们先来谈单例模式的优点是比较抽象的,但是明白了这点是有利于我们下述理解单例模式的,所以互相印证就行。其中实现单例模式的好处有:可以实现全局访问,可以避免重复创建对象,如何理解呢?也就是在单例模式中我们的单例对象是通过静态存储的方式实现,所以它并不属于某个类,我们可以在程序的任意位置直接使用类名进行访问。并且同理由于我们将单例对象存储在了静态区,那么在多线程访问该类时,所有线程都不会像以前那样将类对象拷贝到自己的栈空间中,而是共享静态区上的同一个对象,并且通过同步机制对单例对象的保护,从而实现避免重复创建对象的效果。当然有关使用单例模式的好处还非常多,这里不重点讲解,此时我们只要明白,它可以让多线程在访问类对象时,减少对象的重复创建,从而提高效率就行。
什么是单例模式
首先明白单例模式最大的三个特征,一是构造函数私有化、禁止使用拷贝构造和赋值构造,二是实现单例对象静态存储、并且类型是类类型,三是需要提供一个静态获取方法来获取单例对象,确保整个程序中只有一个地方可以获取该类的实例。具体为什么要将获取方法设置为静态获取,主要是为了实现全局使用类名就能访问和简化对单例对象的访问。所以只要一个类符合上述三种情况,那么它就是一个单例模式实现的类,同理,我们自己想要实现一个单例模式的类,就必须遵守上述三个规则。
注意: 当我们按照上述三个规则实现了单例模式之后,那么此时该类就只允许通过静态获取方法获取该类中的单例对象,因为此时该类不提供任何形式的构造,然后因为我们将单例对象的类型设计成了类类型,所以此时就可以通过获取单例对象的方法来调用该类中的其它成员变量和成员函数。并且由于该单例对象被存储在静态区中,所以当多线程想要使用静态获取方法获取单例对象来访问该类时,此时这个对象在什么时候被创建就分为两种不同的方式,也就是饿汉模式和懒汉模式。其中这两种方式的区别就是一种是当可执行程序被加载到内存准备执行的时候该单例对象就被创建出来(饿汉模式),另一种是可执行程序加载到内存之后在第一次使用时该单例对象时才被创建(懒汉模式)。在明白上述有关资源管理方式和池相关的知识之后,饿汉模式和懒汉模式的优缺点我们很容易就能搞定,类似延迟加载和预先加载的区别,饿汉模式会导致空间资源浪费和加载速度慢,但是能提高代码执行效率,而懒汉模式则节省加载时间和空间资源,但是代码执行效率降低。
饿汉模式实现和懒汉模式实现
首先是饿汉模式
同理,依据饿汉模式的原理,此时单例对象在可执行程序被加载到内存准备执行时,单例对象就已经被创建并且在内存中分配空间,因为此时我们的单例对象instance是一个被初始化完成的是静态成员变量,所以当该代码被加载到内存之后,编译器就会创建并且分配内存空间给该初始化单例对象。反之,如果是未初始化静态成员变量,那么此时编译器只会创建并不会分配内存空间。明白了这些之后,上述的代码就是一个标准的单例模式代饿汉代码实现。最后注意:此时的单例对象是一个静态成员变量,重点区分懒汉模式的单例对象是一个静态指针变量。
其次是懒汉模式
同理区分饿汉模式,此时懒汉模式的实现则略显复杂,主要是涉及第一次开辟内存空间由于单例对象是静态变量导致,因为对于多线程来说静态变量就是共享资源,所以此时需要对其进行加锁处理。值得注意的是此时保护静态成员变量我们只能使用静态锁,而不能使用局部锁,为什么呢?这个问题在开始的时候困扰了我挺久,在不断的查询之后,我有了一定的浅显理解:也就是因为对于多个线程来说,由于它们都有独立的栈空间,所以如果当我们使用局部锁来保护静态成员变量的话,虽然只有一个线程会最终抢到锁,进行串行访问,但是其余未抢到锁的线程并不会因为阻塞而不能访问其它局部对象,也就是说如果我们对单例模式下的静态成员变量使用局部锁保护,那么此时就会导致未抢到局部锁的其它线程可能会去访问其它的局部锁,而如果当某线程访问的其它加锁局部对象是单例模式中的某个局部对象,那么此时就可能会导致数据不一致问题,也就是线程安全问题。所以我们必须要使用静态锁来保证访问单例模式中对象的线程只有一个,无论是单例对象还是其它局部对象,因为只要当线程没有抢到静态锁,那么该线程就只能访问其它类中的静态资源,而不能再访问目标类中其它被加锁的局部对象,从而实现对整个类中无论是静态成员变量还是加锁局部变量都进行了保护。总而言之:静态锁的作用就是对类级别的资源进行保护,当碰到一个类中既有静态成员变量也有局部变量同时需要被保护时,我们只能使用静态锁,而非局部锁。
搞懂了上述为什么使用静态锁的问题,此时对于懒汉模式实现只是使用上的一些理解,如:因为单例对象只需要被定义一次,所以为了防止线程重复加锁,此时我们就会在加锁外部再增加一个判断条件,当单例对象被第一次加锁完成之后,所有线程在访问时,都共享同一资源直接返回静态区地址,不需要再进行额外的加锁过程,从而提高一定的代码执行效率。最后注意:在懒汉模式中我们的单例对象是一个类指针类型,不是静态变量,因为只有通过指针的方式我们才可以实现懒汉模式,也就是在第一次需要使用的时候再去开辟空间,然后将地址返回给我们定义的指针,从而实现延迟加载的目的。
使用单例模式实现线程池代码
该份代码和第一份线程池代码最大的区别除我们使用了单例模式实现之外,我们还使用了自己封装的线程库,使用单例模式的好处在上述我们已经介绍过了,而使用自己实现的线程库主要是为了帮助我们深入理解线程库中的传参和回调函数理解,其次就是可以让我们获取到线程状态(属性),并且在加锁和解锁方面我们使用的也是上次讲过的RAII方法,通过定义局部对象的方式,实现加锁(构造)和解锁(析构)的自动化。
STL和智能指针是否涉及线程安全问题
对于这块知识我们简单了解一下就行,大部分的STL容器都不是线程安全的,因为其内部并没有实现同步机制保护,容器的目的只是为了提高我们的编码效率而已,所以当我们在使用STL容器时,我们就需要对其实现同步机制保护来避免线程安全问题。而对于智能指针问题,具体由于我们还没有深入学习过有关知识,所以我们浅浅的了解一下就行,对于unique_ptr来说,因为其只在当前代码块范围内生效,因此不涉及线程安全问题,对于shared_ptr来说,由于多个对象共享一个引用计数变量,所以存在线程安全问题,但是在标准库内部使用了原子操作对shared_ptr进行保护,所以shared_ptr并不涉及线程安全问题。具体深入智能指针的知识以后博客中将会更新。
其它常见的锁
这部分知识因为没有具体的场景和示例,所以我们简单了解就行,重点放在下述有关读者写者的问题,具体如下所述:
-
悲观锁:它是一种假设并发环境中一定会产生线程安全问题的策略,所以当多线程同时访问某共享资源时,线程就一定需要先获取锁才能继续向后执行,此时这个获取到的锁我们就称为悲观锁,由于悲观锁的这种策略,所以它一般被用在并发冲突频繁的场景下,缺点:性能较低。
-
乐观锁:悲观锁反之则是乐观锁,它是一种假设并发环境不会产生线程安全问题的策略,所以当多线程在访问共享资源时,它不会优先获取锁,而是在线程访问共享资源之后,检查数据时候被修改,如果被修改,则恢复数据重新尝试,反之合理,所以同理它一般被用在并发冲突较少的场景。
-
自旋锁:自旋锁本质是一种轮询等待的策略,线程在没有获取到锁时,不会进入阻塞状态,也不会去访问其它的锁,而是通过轮询的方式不断的尝试去获取锁,直到获取到为止,一般用在临界区很小,也就是短时间内能释放锁的场景,优点:可以减少线程切换的消耗。
由于悲观锁和乐观锁不是具体的锁,而自旋锁是具体的锁,所以下述我们来看看自旋锁的接口使用方式,如下所示:
(本质与之前学习的互斥锁相同)
自旋锁(spinlock)接口使用 |
---|
定义一把自旋锁:pthread_spinlock_t mutex; |
初始化:pthread_spin_init(pthread_spinlock_t* mutex,PTHREAD_PROCESS_PRIVATE); |
加锁:pthread_spin_lock(pthread_spinlock_t* mutex); |
锁定:pthread_spin_trylock(pthread_spinlock_t* mutex); |
解锁:pthread_spin_unlock(pthread_spinlock_t* mutex); |
销毁:pthread_spin_destroy(pthread_spinlock_t* mutex); |
注意: 如果自旋锁被锁定(非阻塞式加锁),那么同理获取锁失败之后会直接返回,不会再轮询式等待。
读者写者问题
来到多线程相关知识的最后一个知识点,有关读者写者的问题,本质还是离不开线程间通信的范畴,只不过读者和写者问题拥有属于自己的特性,并且在一些场景中被使用,所以我们需要进行一定的了解。谈到读者和写者问题,我们第一时间想到的就是生产消费模型,因为它们之间有非常多的相似之处,如一个缓冲区、两个角色、三种关系,同理只不过是因为读者写者模型有自己的特性,所以需要额外注意。
此时我们就明白,读者写者问题首先应该从三种关系原则出发:读者和读者、写者和写者、读者和写者,那么它们之间具体是什么关系呢?明白,对于写者和写者来说,它们也是互斥关系,读者和写者之间同理是同步且互斥关系,而唯一和生产消费模型不同的就是读者和读者之间的关系,读者和读者之间没有关系,区别消费者和消费者之间的互斥关系。那么为什么对于生产消费模型中消费者和消费之间的关系就是互斥关系,而对于读者写者模型中读者和读者之间就是没有关系呢?本质其实是因为在生产消费模型中消费者每次需要从缓冲区中拿走数据,而读者却不需要从缓冲区中拿走数据,这也就是为什么读者和读者之间没有关系的原因。
所以此时按照这三种关系,此时我们想要实现读者和写者模型,此时同理需要使用互斥锁、条件变量等同步机制来控制。如下就是有关读者写者问题的系统调用接口:
读写锁(rwlock)接口使用 |
---|
定义一把读写锁:pthread_rwlock_t rwlock; |
初始化读写锁:pthread_rwlock_init(pthread_rwlock_t* rwlock,nullptr); |
销毁读写锁: pthread_rwlock_destroy(pthread_rwlock_t* rwlock); |
对读者进行加锁:pthread_rwlock_rdlock(pthread_rwlock_t* rwlock); |
对读者锁定加锁:pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock); |
对写者进行加锁:pthread_rwlock_wrlock(pthread_rwlock_t* rwlock); |
对写者锁定加锁:pthread_rwlock_trywrlock(pthread_rwlock_t* rwlock); |
对读者和写者解锁:pthread_rwlock_unlock(pthread_rwlock_t* rwlock); |
读者写者模型伪代码实现
通过上述伪代码实现,明白此时我们就是想要维护读者写者问题中的三种关系,实现当写者在写的时候,不允许读者读以及在读者读取不允许写者写的同时运行其它读者也能够读取。所以此时通过二元信号量,我们就实现了写者与写者之间的互斥关系以及写者与读者之间的同步且互斥关系。
但是此时会碰到一个问题,当读者非常多,就会导致写者拿不到信号量,导致写者的饥饿问题,所以对于这一现象我们分为两种不同的场景,一种是读者优先,一种是写者优先,如下所述:
读者优先:在这种策略下,如果有读者正在读取数据,或者有读者在等待读取数据,那么写者必须等待,直到所有的读者都读取完数据。这种策略优先保证读者的访问,可以避免读者被长时间阻塞,但是如果读者的请求非常频繁,可能会导致写者饥饿,也就是写者长时间得不到访问资源的机会。
写者优先:在这种策略下,一旦有写者请求写入数据,那么后续的读者必须等待,直到所有的写者都写入完数据。这种策略优先保证写者的访问,可以避免写者饥饿,但是如果写者的请求非常频繁,可能会导致读者饥饿,也就是读者长时间得不到访问资源的机会。