学习系统编程No.30【多线程控制实战】

引言:

北京时间:2023/7/7/9:58,耳机正在充电中,所以刚好让我们先把引言写一写,昨天睡觉前听了一会小说,听小说的好处就在于,它可以让你放下手机,快速睡觉,并且还有一定的助眠效果,但是最近有点书荒,所以听小说不是很积极,平时睡觉也就控制不住,昨天把很久以前看了一半没看完的《龙族》给捡起来听了一下,由于很久没听也没看,支线和细节并不怎么清楚,所以挺起来比较没劲,当然当初没有把《龙族》追完,也是因为这本小说本质不怎么对我的口味,但是这本小说一定是好文,从故事场景中的人物和细节描写都能感觉的到,这本小说写的非常好,在当时那个年代,这本小说肯定是被大部分人奉为神作,当时能写出这种类型小说的作家顶级大佬无疑,不像现在,是人是鬼都能写小说,懂的都懂!说真的从小说这方面,特别是玄幻小说,中国的历史和文化有多牛,这里就不多说了,好了,话不多说,正式进入该篇博客的主题,有关进程控制相关的知识,当然还有上篇博客剩余相关进程优缺点分析。

在这里插入图片描述

线程相关概念剩余知识

上篇博客由于时间和字数问题,大致介绍完页表映射问题,当然也就是线程执行过程之后,我们就草草结尾了,但是还有相关剩余知识没有讲解,在这进行补充,大致就是关于页表权限问题和线程优缺点的分析,如下所述:

页表权限问题

当然上篇博客中有关页表的讲解,我们谈到的只是页表从虚拟地址映射到物理地址的一个具体过程,也就是如何通过一个实实在在的虚拟地址找到物理内存中相应的物理地址,所以还有相关拓展知识没有深入讲解,如权限问题,权限问题本质也就是在找到这个物理地址之后,控制我们对该物理地址中数据的访问,具体是只读,还是读写,当然这个问题在之前学习进程信号时,我们有一定的了解,也就是如果我们要对一个物理地址中的数据进行读写操作,但是此时页表检测到我们只有读权限,那么此时MMU(内存管理单元)就会产生一个硬件异常信号,进而终止对应进程非法写入数据。同理,结合我们之前学习过的相关知识,明白,我们在编码时,如果使用指针定义了一个字符串常量,那么该常量是被存储在虚拟地址空间上的常量区(只读数据区),那么就规定好了,只读数据区(常量区)中的数据在进行映射物理地址时,只有读权限,没有写权限,这也就是为什么我们在编码时,对应指针指向的常量数据不允许被修改的本质原因。

注意: 明白了上述知识,此时再来了解一下有关const的知识,本质还是上述知识的变形,此时我们就知道,使用const关键字的本质肯定是因为对应的数据只有读权限,没有写权限导致的,那么为什么会导致这个现象呢?本质是因为到编译器检测到某个数据被const修饰,属于const数据时,它就会对相应的代码进行优化(汇编过程进行),让对应const类型的数据不在存储在栈区上,而是将它存储到只读数据区(常量区),从而间接导致const修饰的数据在进行页表映射时,只有读权限,没有写权限。所以对于完整的页表来说,它不仅具备虚拟地址和物理地址之间的索引关系,还包括了其它属性(是否命中、权限等),入下图所示:
在这里插入图片描述

线程优缺点分析

搞定了上述有关页表的所有知识,此时对于线程执行过程,我们了然于胸,并且对于线程概念方面的知识,我们有了更深的理解,可以很好的理解下述线程概念:

一个程序中的一个执行路线我们就叫做线程,更准确的说,线程是一个进程内部的控制序列,一切进程至少都有一个执行线程,线程在进程内部运行,本质是在地址空间内运行,在Linux系统下,CPU执行的进程pcb都更加的轻量化,透过进程地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

当我们对上述有关线程概念都吃透之后,我们就来分析一下线程的相关优点和缺点,进一步深入理解线程,当然这部分知识讲解完之后,我们就正式进入到线程控制相关知识的学习,如下所述:

1.线程优点分析

  • 创建一个新线程的代价要比创建一个新进程小得多,这点是毋庸置疑的,无论是从线程共享进程资源,还是操作系统维护进程pcb来说,创建一个线程的代价都远小于创建进程。

  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多,这点也是毋庸置疑的,同理线程由于共享进程资源,所以在进行线程切换时,并不需要像进程一样,更换进程pcb、地址空间、页表、内存或者是缓存中的数据。

  • 线程占用的资源要比进程少很多,无论是从Linux系统还是Windows系统来看,由于线程资源都是从进程资源分配、共享,所以线程资源一定远少于进程。

  • 能充分利用多处理器的可并行数量,多核的好处就在于可以同时执行不同的线程,当然这里的线程并没有特指是谁的线程,即可以是不同进程的不同线程,也可以是同一进程的不同线程。

  • 在等待慢速I/O操作结束的同时,程序可执行其它计算任务,也就相当于你在使用某一款软件时,你可以一边下载该软件中的数据(慢),一边对该软件进行别的操作,因为当你在进行别的操作时,该软件就会创建其它的线程来帮你完成运算,从而实现并发运行。当然对于该点来说,这种功能我们使用多进程也能执行,也就是创建不同的进程,但是让这些进程看到同一份资源(软件),然后对这份资源(软件)进行不同的操作,但是同理上述所说,创建进程的消耗远大于线程,所以本质使用多线程不使用多进程还是为了提高效率。

  • 计算密集型应用,为了能在多处理器上运行,将计算分解到多个线程中实现,首先我们要明白什么叫计算密集型应用,计算密集型应用也就是需要大量的计算资源和处理能力,这类应用一般涉及复杂的数学运算、图像/视屏处理等需要大量CPU计算的任务,如加密解密,文件压缩解压。所以此时这种需要进行大量计算的应用就需要多线程多核并发共同完成。

  • I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作,同理,上述计算密集型涉及的是CPU的计算资源,那么I/O密集型涉及的就是大量的I/O操作,也就是对磁盘、网卡等进行数据的读取和写入操作。这类应用一般更加依赖于低延迟的存储设备(磁盘)和高带宽的网络设备(网卡),如下载数据,上传数据。所以这类应用也需要多线程多核并发完成。

注意: 都知道,当我们想要读数据(也就是进行I/O),CPU只能从内存中读,但如果内存中没有,此时就会发生缺页中断,并且操作系统向磁盘申请数据加载到内存的过程中,我们的线程由于没有资源就会被操作系统挂起(进程状态中详解),也就是等待对应我需要的资源准备就绪,只有当对应的数据从磁盘加载到内存中之后,该线程才会从挂起状态(等待)变为运行状态,然后向后执行,完成相应操作。所以当我们在进行I/O操作时,对应进行I/O操作的线程因为上述原因就一定会比计算密集型的线程更多,因为这样就可以使等待效率提高,也就是让更多的线程去等待,从而提高执行效率。也就是对于计算密集型应用来说,线程不是越多越好,对应的线程一定要和CPU的个数挂钩,只有这样才能让计算效率达到最高。

2.线程缺点分析
搞定了上述有关线程优点以及线程使用策略相关的知识,此时我们来谈谈线程的缺点吧!总的来说,大致可以分4个方面,性能损失、健壮性降低、缺乏访问控制、编程难度提高,如下分析所示:

  • 性能损失:
    当然针对于这个场景,不仅仅是线程存在这个缺点,进程同理存在,也就是多核系统下,能够同时运行的线程是固定的,而系统中却存在非常多的线程需要执行,此时就需要通过不断的去调度线程来达到程序被运行起来,所以此时在不断调度的过程中,就会导致性能损失,此时最好的解决方法就是控制线程个数,减少调度的同时,使效率达到最高。

  • 健壮性降低:
    如何理解这个健壮性降低呢?首先明白一个现象,也就是任何一个线程如果崩溃了,那么该线程对应的进程也会崩溃(终止),通过这个现象,此时我们就能明白,对某一个线程进行编码时,需要更全面的考虑,考虑该线程是否会因为某个错误导致整个程序被终止,换句话说,也就是需要让线程变得更加安全可靠,从而让整个进程得到保护。

  • 缺乏访问控制:
    同理,因为每个线程共享同一份地址空间,那么就导致它们看到的所有资源(内存上的数据)都是一样的,所以就会造成很多资源被不同的线程共同使用,类似于父进程和子进程的关系,唯一的区别就是子进程不仅拥有自己的pcb,还拥有自己的地址空间,所以子进程可以通过写实拷贝等方法,更改地址空间中的共享数据,而线程由于没有地址空间,所以它并没有办法更改对应的数据,多线程一定是共享同一地址中的数据,无论是否更改。所以最终就会导致地址空间中的数据,缺乏访问控制,也就是无论那个线程都可以访问,那个线程都可以修改。

  • 编程难度提高:
    这个点就更好证明,简单就从上述缺乏访问控制来说,此时就会导致无论是那个线程都可以访问同一个数据,都可以修改同一个数据,此时就导致数据容易被修改,从而引发健壮性问题,也就是线程崩溃,所以在进行线程编码时,就需要注意,某个数据在使用前,是否被其它线程使用,其次再从健壮性来看,如果一个进程出现问题,那么寻找问题(调式)就会变得非常困难,因为进程崩溃,可以是任意一个线程导致。

多线程控制

明白了上述有关线程概念的知识,此时我们对线程的执行过程和线程细节分析等知识都有了一定的认识,最终明白,线程只是一个执行流,它共享进程资源的同时,它也拥有自己的线程结构体(LWP),拥有自己的数据,如线程ID、寄存器组、栈空间、调度优先级、信号屏蔽字、errno等,其中,当我们谈到寄存器组时,我们就能意识到线程需要被调度,需要进行上下文切换,因为相应CPU上的寄存器组就是用来保存被切换上下文的,而谈到栈空间时,我们就意识到线程由于共享地址空间,在并发执行时,为了避免互相干扰,就需要有栈空间来保存特定的临时数据、临时变量,因为只有这样才可以使得每个线程可以在调用函数、局部变量等方面独立于其他线程,从而实现并发独立执行。从寄存器组和栈空间我们充分体会到线程的临时运行特性和切换特性。

多线程接口实战

搞定了上述知识,此时我们正式进入多线程有关编码方面的知识,重点在于有关线程接口、线程创建和线程控制等知识,首先是线程有关接口,谈到接口,就离不开动态库,谈到动态库,就离不开头文件,所以当我们想要使用系统调用接口去完成多线程的编码,就需要使用相关的头文件:pthread.h,只有有了该头文件,我们才能进行与多线程有关的代码编写如下述代码所示:

上述知识有两个值得注意的地方,注意: 其一是有关动态库和头文件相关的知识,在之前有关动静态库的文章中,我们有进行深入学习,这里仅作复习,明白,相应的头文件只是对应动态库中对象、结构体、函数调用接口的一个声明,编译器将对应的头文件进行链接,从而生成可执行程序,操作系统再根据可执行程序,生成该程序的进程地址空间,并且在该程序需要使用对应动态库中的函数或者变量时,将磁盘中对应的动态库加载到内存,当然根据之前学过的缺页中断知识,这个过程肯定是通过缺页中断来完成,最终建立地址空间中共享区和物理内存上动态库二进制文件之间的映射关系。其二是有关操作系统不同的知识,当然也就是Windows系统和Linux系统由于线程设计不同导致使用的不同,这点在之前有关线程概念方面我们详细讲解过,这里不再讲解,也就是因为Linux系统没有真正意义的线程,而是使用轻量级线程模拟的线程(LWP),所以,在Linux系统中,没有提供直接创建线程的系统调用接口,同理,Linux系统只提供了创建轻量级进程的接口。所以平时我们在Linux系统下,使用的线程创建接口,本质是轻量级进程创建接口,只不过是进行了一定的封装,实现狸猫换太子,让我们以为创建的是线程而非是轻量级进程。但值得注意的是,这个对轻量级线程接口的封装,也就是对系统调用接口的封装,一定是在用户层,所以对应Linux系统下创建线程的接口一定是在用户级线程库中,当然也就是上述所说的pthread.h头文件。

1.pthread_create
明白了上述知识,此时我们正式来看看有关线程编码相关的接口,首先第一个自然而然是有关线程创建接口,基本使用方式:int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg); 可以看出,该接口的使用方式较为复杂,具体有待我们分析,首先明白pthread_t本质就是对一个无符号长整型(unsigned long)进行的重定义而已,类似于size_t,所以此时就可以明白,其中第一个参数pthread_t *thread表示就是该线程的ID,并且由于该参数是一个输出型参数,所以我们是可以直接获取到该线程创建完成后,对应的线程ID,具体如下代码所示,这里我们先看看后面几个参数表示的意思。其中第二个参数,也就是const pthread_attr_t *attr,这个参数表示的是线程的属性(优先级、状态、栈空间等),也就是说,我们可以通过这个指针指向不同的结构体,通过对这个结构体的修改来修改一个线程的属性,并且明白,如果这个参数如果给nullptr,那么表示指向系统中默认的结构体,构建出来的线程也就是一个操作系统规定好属性的线程。然后是第三个参数void *(*start_routine) (void *),经典的函数指针类型,返回值void*,参数void*,此时的start_routine指针变量我们只需要给一个函数地址(函数名)就行,表示该线程需要执行的一个接口或者是一段代码。最后是第四个参数void *arg,参数类型void*,通过参数类型,可以猜测,该参数表示的就是第三个参数(函数指针类型)指向函数的参数,也就是说,这个参数可以作为第三个参数指向函数的参数,具体如下代码所示:

在这里插入图片描述
同理,注意pthread库文件并不是linux系统下的默认动态库(不会自动查找),所以在使用编译器编译链接,生成可执行程序时,需要指定链接pthread库,指令:g++ -o $@ $^ -std=c++11 -lpthread,并且知道,该库是在lib64/libpthread.so目录下。

2.pthread_join
搞定了上述线程创建接口的使用,其余线程相关接口对于我们来说,并没有那么重要,接下来我们马上进入线程控制相关知识的讲解,pthread_join接口基本使用方式:int pthread_join(pthread_t thread, void **retval);同理,第一个参数表示对应等待线程的线程ID。第二个参数*retval表示一个指针,用于接收被等待线程的返回值,注意:因为该参数的类型是void*类型,所以需要进行一个强制类型转换,才能进行对应线程返回值的接收。明白,该接口就是类似于waitpid()接口回收子进程一般,但不同的是,等待原因不同,等待子进程主要是为了防止内存泄露等问题(僵尸进程),而等待线程,主要是为了让线程可以实现同步、协同运行,保证线程之间的并发性,从而提高程序的执行效率。当然这两个接口除了等待原因不同之外,在等待方式上也不同,其中waitpid()接口默认是非阻塞式等待,而pthread_join()默认是阻塞式等待,当然,在使用对应接口时,具体是阻塞式等待还是非阻塞式等待,都是可以通过参数进行控制的,具体使用方式如进程控制代码中所示,这里不进行单独演示。

3.pthread_exit
搞定了上述有关线程创建和线程等待的知识,此时对于多线程控制来说,只差线程终止相关接口,所以此时pthread_exit()接口就是用于某个线程终止,因为只有当某个线程终止之后,我们才有资格对该线程进行等待,基本使用方式:void pthread_exit(void *retval); 其中该接口中的参数同理pthread_join()接口的第二个参数,就是用于获取该线程的退出状态,记录线程退出信息,然后将该退出结果传递给等待该线程的其它线程(主线程),也就是pthread_join()接口,注意:不能使用exit()接口,因为exit()接口是用于终止进程,如果我们在任意一个线程中使用了exit()接口,最终会导致整个进程终止,导致该进程中的所有线程被终止,出现大问题,所以在线程中,严禁使用exit()接口。

4.pthread_cancel
同理,对于线程终止来说,还有一个关键接口,也就是 pthread_cancel,基本使用方式:int pthread_cancel(pthread_t thread);这个接口较为简单,表示的意思就是取消对应ID的线程,这里不多做讲解,具体如下述代码所示。

多线程控制实战

搞定了上述有关多线程接口相关的知识,线程创建、线程等待等知识我们就搞定的查不多了,接下来正式进入该篇博客的重点,有关多线程控制方面的知识,其中对于多线程控制来说,我们可以分为三个部分来学习,线程创建、线程等待、线程终止,其中有关多线程创建,原理非常简单,就是使用循环创建的方法,创建出多个线程,让它们执行同一份代码(共享地址空间)而已,当然也可以是不同的代码。然后是线程等待,上述线程接口中我们简单介绍了,这里不多做讲解,最后是线程终止相关的知识,其中线程终止又可以分为多种不同的终止方法,如下所述:

1.线程执行完毕return
在这里插入图片描述

2.直接调用pthread_exit接口
在这里插入图片描述
3.调用pthread_cancel接口
在这里插入图片描述

上述三种方法,就是让一个线程终止,最后被等待的所有情况,当然还有一种情况就是主线程终止导致线程终止,值得注意的是,线程只存在终止,并不存在退出异常等问题,因为如果一个线程发生异常,整个进程就会被终止,所以该线程是否正常已经不重要的,不需要返回给进程,因为进程会被终止。

明白了上述有关线程终止方面的知识,此时有关线程控制等方面的知识我们就全部搞定啦!上述代码本质并没有什么难度,重点就是有关线程创建、线程等待、线程终止方面的知识,至于像什么snprintf接口相关的使用,只是为了可以更好的区分每一个线程,给每一个线程取了一个名字而已,在该篇博客中,不做重点讲解,有关线程控制相关的知识,该篇博客就到这了吧!See you!

总结:有关线程控制相关的知识,本质就在于线程相关接口的理解和运用,相比进程控制来说,还是较为简单的,学习成本不高。

猜你喜欢

转载自blog.csdn.net/weixin_74004489/article/details/131542585
今日推荐