引言:
北京时间:2023/6/29/15:33,刚刚更新完博客,目前没什么状态,不好趁热打铁,需要去睡一会会,昨天睡的有点迟,然后忘记把7点到8点30之间的4个闹钟关掉了,恶心了我自己一早上,真的是罪过呀!极度没睡好加没睡够,由于上篇博客马上就可以完成,所以中午没有选择睡觉,而是想着更新完再睡,但是现在困意不是很重,所以趁着这个没什么状态期间,将该篇博客的引言写写,然后把git提交一下,并且重点是今天出成绩啦!在持续摆烂中,和预想的一样,考的不怎么样,不过好在都及格了,其中高数应该是老师捞起来的,低分飘过,哈哈哈!摆烂过程只有我自己知道,具体不好形容,所以只要没有挂科我就已经很满足了,不过对于我来说更重要的是,信号有关知识在上篇博客我们就全部学完了,接下来终于可以进军多线程的学习了,这点令我非常的激动,不然博客标题都不知道怎么命名了,哈哈哈!正式进入该篇博客的学习,有关Linux系统下多线程的知识!
什么是可重入函数
可重入函数是一个新的概念,伴随着可重入函数,那么自然就有不可重入函数,那么具体什么叫可重入函数,什么叫不可重入函数,需要我们进行一定的铺垫,才能搞清楚,首先由于该知识点是在信号相关知识,并且与内核态和用户态之间发生进程调度相关,所以此时我们明白第一点,就是当一个进程,时间片到了,就会发生进程调度,也就是保存当前进程的上下文,替换下一个等待进程,当然进程调度也就是让用户态进程切换为内核态进程,而如果是进行用户态到内核态进程的切换,那么同时就会导致操作系统对该进程进行信号检测,此时就会发生信号递达(默认动作,自定义动作,忽略),按照该场景,此时就会产生一个非常细节的问题,如下图所示:
同理,如图所示,此时就可以发现,如果在插入接口时,发生了进程调度,从而导致操作系统进行信号检测,执行对应信号的自定义动作,且刚好该动作,也是一个插入动作,那么就会导致上图所示问题,也就是内存泄露问题。显然,该问题的存在是不合理的,并且该不合理现象是由于同一个函数被重复进入导致,所以对于上述这种函数,一但被重入就会导致一系列的问题,那么这种函数就被称为不可重入函数,反之称为重入函数,也就是同一个函数被重入,不会出现问题的函数。同理,明白了这点之后,什么是可重入函数,什么是不可重入函数,我们就搞定啦!并且一般常见的不可重入接口都在各种容器中,如上述的链表,当然还有STL中各种容器的接口,还有malloc等!
理解volatile关键字
首先明白,这个关键字是C语言中的一个关键字,本质就是为了告诉编译器某个变量在程序执行过程中可能会被修改,要进行特殊处理,不能只是盲目的从CPU中的寄存器获取该变量,而是要重新从内存中获取,具体是什么意思呢?想要搞懂这个关键字的概念,需要从下述代码入手,如下:
从上图中可以看出,使用了gcc编译器中的优化级别对比没有使用gcc编译器的优化级别,两者在代码执行结果方面,有很大的不同,并且从运行结果来看,没有使用gcc优化,那么程序就会按照我们的预期,在收到了2号信号后,发生自定义动作捕捉,然后将quit置1,紧接着返回继续执行代码时,退出死循环,程序正常退出。而如果使用了gcc编译器的优化功能(-O2
),此时从运行结果来看,无论是否收到2号信号,那么程序都一直处于死循环,本质也就是当该进程收到2号信号后,执行自定义动作捕捉时,quit全局变量并没有由0置1,导致循环不会退出,那么此时问题就来了,为什么执行了自定义动作捕捉,也就是执行了quit置1的代码,循环不会终止呢?还有就是为什么只有使用了gcc优化功能才会导致这个问题呢?具体如下图所示:
从上图有关代码在硬件上的执行过程,我们就发现,本质就是因为使用了O2的优化级别,从而导致CPU在进行计算时,不直接从物理内存中获取相关变量的值,而是直接从寄存器中获取先关变量的值,从而就会导致,因为信号执行自定义动作捕捉让quit全局变量由0置1,单单只是让物理内存上的quit变量置1,而没有让CPU寄存器中的quit变量置1,从而导致quit置不变,程序持续死循环,所以上述两个问题,就非常容易回答,本质就是因为使用了gcc的优化功能,会导致CPU在计算时,不直接从物理内存中获取数据,而是直接从寄存器中获取,所以也可以得出结论,gcc的优化功能本质就是在优化物理内存不断加载数据到CPU寄存器中的这个过程,从而导致意料中改变的值,CPU接收不到。当然注意:不是所有的代码都能像上述一样通过gcc的优化功能进行优化,从而导致寄存器中的数据不会被物理内存中的数据影响,只要像上述代码中while(quit != 0);
这样频繁执行同一结果的代码,才有资格被优化,也就是减少频繁从物理内存加载数据到CPU寄存器中,从而提高效率。明白了上述知识之后,无论是上述的现象还是问题,我们就都搞定了,当然搞懂了上述问题和现象,volatile就不是什么重点了,同理上述所说,使用volatile就是在告诉编译器,每次进行计算时,都要去物理内存中获取数据而已(保证内存可见性)。所以当我们使用了volatile关键字,上述因为gcc优化功能导致死循环的问题就可以很好的被解决,如下图所示:
切记:
使用gcc优化的本质,还是在通过代码控制具体的执行方式,并且这个代码是在生成汇编指令的时候添加进去的,也就是我们的代码因为优化变成了一套更复杂和高级的代码,从而导致CPU在执行该代码时,变成优化形式执行(也就是不从物理内存中读取数据,而是从寄存器中读取),所以CPU具体执行代码如下图所示:
最终明白,在gcc中有许多的优化级别,具体如下图所示,这里不多过讲解:
SIGCHLD信号
搞定了上述相关知识,此时我们正式进入信号有关知识的最后一个知识点,与子进程退出相关的信号,在之前的学习中,我们学习了进程创建,进程等待等一系列知识,明白子进程需要被回收(父进程等待),不然就会导致僵尸进程等问题,并且父进程在等待子进程时,有两种方式,一种是阻塞式等待,一种是轮询式等待(非阻塞式),从而导致父进程想要成功的回收子进程就一定要牺牲一定的效率,那么如何可以避免这个问题呢? 首先明白父进程需要等待子进程的本质原因在于父进程并不知道子进程在干嘛,进而不知道对应子进程在什么时候会退出导致。明白了这点之后,我们就可以将问题转移为:子进程在退出时,是不是安安静静什么都不干,就默默的退出?答案肯定不是,那么子进程在退出时,会干什么呢?通过这个问题,我们就可以很好的引出该知识点的主角:SIGCHLD信号,明白子进程在退出时,它会发送一个SIGCHLD信号给父进程,但由于父进程对于该信号的默认处理动作是忽略,所以在我们看来,子进程退出时是默默无闻的退出,那么如何证明,子进程确实会发送SIGCHLD信号呢?如下代码所示:
整体代码非常简单,就是在一个程序中创建一个子进程,然后让该子进程退出的同时,父进程循环运行(防止孤儿进程),然后对SIGCHLD信号进行捕捉,看父进程是否会执行对应的自定义动作,当然,如果执行了,那么就表示父进程确实收到了子进程退出时,发送的SIGCHLD信号,反之没有。总之,目前从上图运行结果可以看出,父进程确实收到了SIGCHLD(17)信号。表明,子进程在退出时,确实不是默默无闻的退出,而是会发送SIGCHLD信号给父进程。搞清楚了这点之后,接下来就是顺水推舟,我们水到渠成的就可以搞定有关SIGCHLD相关的知识啦!还是从子进程退出时会发送一个信号给父进程出发,首先这样就可以解决我们上述父进程需要浪费效率等待子进程的问题,现在因为子进程会发送信号给父进程,那么父进程就不需要再浪费资源去等待子进程,而是等子进程退出,发送SIGCHLD信号给父进程时,父进程再去回收它,具体代码如下所示:
如上代码所示,我们在等子进程退出,父进程接收到SIGCHLD信号,执行自定义捕捉动作时,在该自定义动作中进行子进程回收,当然也就是使用waitpid接口等待子进程退出,并且明白如果等待成功,那么waitpid接口就会返回对应被等待进程的进程pid,所以使用该方法到底能不能成功等待子进程退出呢?如下图运行结果所示:
如图可以发现,最终waitpid的返回值和进程的pid值是相同的,并且代码执行了3秒之后,由于父进程没有立即回收,而是等待了一秒才回收,所以子进程在第四秒时,是处于僵尸状态,而在第五秒,父进程开始回收子进程时,子进程才从僵尸状态被父进程回收,父进程继续运行。
总而言之,上述通过子进程退出,发送信号的方式,我们就可以让父进程不需要特意的去关心子进程是否退出,所以当我们的父进程在需要执行很多代码的情况下,此时就可以使用上述方法,通过信号来回收子进程。
但是当我们使用信号的方式来回收子进程,按照上述代码来看就会存在一定的问题,当然这个问题是存在于不同的情况下,也就是当我有多个子进程需要被回收的情况下,凭借以前学过有关信号处理的知识,我们知道一个进程的pending位图只能记录一次信号,当一个信号正在被执行时,该信号就会被添加到信号屏蔽字中(block位图),那么就会导致父进程不能同时处理多个子进程发送过来的SIGCHLD信号,那么此时就会导致某些子进程的SIGCHLD信号被遗漏,从而导致某些子进程不能被回收,最终造成僵尸进程问题。所以为了解决该问题,我们需要将上述代码进行一定的升级处理,如下代码所示:
注意:上述代码有两个知识点,一是waitpid的第一个参数使用-1就可以在不需要指定进程pid的情况下去等待任意进程,二是在使用waitpid接口时,第三个参数我们最好是使用WNOHANG参数,表示非阻塞等待,也就是只等待退出进程,不会等待未退出进程,这样可以使代码更加安全。
注意:
除了之前学习的回收子进程知识和今天学习的回收子进程知识,在Linux系统内部还存在一种回收子进程的方式,就是让父进程去调用sigaction或者signal接口将SIGCHLD信号的处理动作设置为SIG_IGN(忽略),这样fork出来的子进程在进程终止时,也会自动被操作系统清理,不会产生僵尸进程,也不会通知父进程。
线程基础知识学习
该篇博客来到这里,上述有关可重入函数,volatile和SIGCHLD信号相关知识就搞定啦!接下来正式进入该篇博客的主题,有关线程相关知识的学习,当然由于线程相关知识非常的繁杂,所以一篇博客肯定是搞不定的,并且由于我们是刚开始学习线程相关知识,所以肯定是由浅入深 ,先学习一下线程基础知识,大致了解一下线程的概念及其使用,具体如下所述:
Linux线程概念
什么是线程
首先明白操作系统相关的知识被称为是计算机里的哲学,就是那种读一遍过去,你感觉,嗯,很有道理,但是却不知道是什么意思,不知道怎么用,然后学了等于没学,哈哈哈!下述几个就是经典操作系统书籍中对线程的简单描述:
- 线程是一个执行分支,执行粒度比进程更细,调度成本更低
- 线程是进程内部的一个执行流
- 线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体
明白了上述操作系统中对线程的描述,也就是那几句哲学一样的语句,此时如何理解呢?下面我们就将这几句话通过经典的场景来分析,进而搞懂这句话的深层含义,如下:
1.如何理解线程是一个执行分支,执行粒度比进程更细,调度成本更低
想要理解该知识点,首先需要明白CPU中有两类寄存器,一类是可见的,一类是不可见的,也就是有的寄存器是暴露给我们,允许我们使用的,有的寄存器是由CPU自己做管理,不提供给我们使用的,明白了这点之后,我们就可以来谈谈线程相关的知识了,每个线程都具有独立的执行上下文和栈空间,而在线程在运行时,寄存器就为线程的上下文切换提供存储环境,从而保证线程的运行环境,如下图所示:
从图中可以看出,寄存器为进程和线程的运行提供了存储环境,以便于CPU执行相应的代码,而线程是根据进程的pcb和操作系统中对应的代码创建出来的,并且对于进程来说,线程的特点就是只有根据pcb创建出来的TCB,没有对应的虚拟地址空间,它们的虚拟地址空间是和进程共用的。从而导致多个线程可以同时共享同一块地址空间上的栈空间和代码段,并且操作系统通过一些列的操作,可以将代码段上的代码分配给每一个线程去执行(复杂),让每一个线程都拥有自己的上下文和栈空间,所以可以将线程看做是进程的一个执行分支。按照上图所示的话,那么该进程此时就拥有了4个执行分支,从而导致代码的执行效率大大提高。当然执行效率的提高虽然和执行分支增多有一定关系,但是使用线程的好处远远不止于此,重点在于线程的执行是并发执行,具体如何并发执行以及并发执行等细节相关知识,需要等我们深入学习之后再来详谈,此时我们只要知道,由于线程是并发执行的,所以导致线程不仅可以共享同一块地址空间,而且也可以共享同一个时间片,此时就会导致线程在调度时,不需要像进程调度时一样,需要切换地址空间,更改映射关系等!而是直接使用同一地址空间,这样就可以让线程调度成本大大降低,当然有关线程调度成本方面的问题,并不止于此,此处还涉及到一个CPU获取数据时的局部性原理,下文慢慢谈到。
2.如何理解线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体
明白了上述有关线程的概念,那么进程到底是什么呢?进程也是一个线程吗?这么理解肯定是不对的,因为线程是通过进程创建的,它们之间的关系肯定不是对等的,而应该是上下级。所以当我们谈到一个进程时,那么该进程一定是需要包含对应的执行流(线程)、地址空间,页表、物理内存等,并且对于进程的概念我们就需要进行升级,从之前的单执行流,理解为包含一大推东西的一个实体,所以也就是将进程理解为是承担分配系统资源的基本实体(如上图一般)。并且注意:同理时间片的分配,操作系统在分配系统资源时(内存资源、CPU资源等),是以进程为基本单位进行分配,只有当有了进程之后,相当于就是有了系统资源之后,我们才能根据进程去创建线程,也就是让线程去向进程申请资源,当然可以理解成是分配它的资源。同理如上图所示,在CPU看来,它识别的要么就是一个单独的进程执行流,要么就是该进程中对应的一个线程分支,而单独的一个进程执行流在我们看来和一个线程没有区别,所以对于CPU来说,调度的基本单位就是线程。
如何理解局部性原理
同理,首先明白,在CPU中不仅包括了上述所说的寄存器和之前所说的MMU(内存管理单元),其中还包括了运算器、控制器、高速缓存(cache L1,L2,L3)等!因为操作系统为了提高代码的执行效率,会将某些热点数据先加载到缓存中,也就是当我们在执行某段代码的时候,操作系统会将该代码附近的代码加载到缓存中,这样就可以让CPU上对应PC指针等指向对应执行代码的概率增大,从而提高代码执行效率,这就叫局部性原理。
总而言之,可以将线程理解为在一个进程内部独立执行的子执行单元。一个进程可以包含多个线程,每线程都有自己的执行路径和执行上下文,和对应的进程共享系统资源,并且可以实现并发机制,极大提高代码执行效率和资源管理方式。