操作系统——Linux进程概念、控制及相关知识的理解

一、引言

        在学习Linux的过程中,进程这一概念,理解起来是有一定难度的,知识点也比较多,但这一部分知识点又相当重要。因此专门写一篇文章,整理一下Linux进程的知识,分享的同时,也方便后面复习。

二、知识点

        1.前置知识--冯诺依曼体系结构

        首先要讲到计算机的基本组成结构,也就是大名鼎鼎的冯诺依曼结构。该结构提出了组成计算机的五大基本结构:控制器、运算器、存储器、输入设备、输出设备。在计算机领域公认的最科学的结构。他们之间的信号传输和控制关系,如图1-1

591313eba2214a6088576f336d7f0973.png

其中:

        输入设备:键盘、麦克风、摄像头等,用来读取外界输入给计算机的信号;

        输出设备:屏幕、音响、打印机等,将计算机处理后的信号反馈给外界的设备;

        存储器:在该结构中,特指的是计算机的内部存储(狭义上的内存)。存储器不同于外部存储(磁盘)的是,存储器直接与结构中的其他设备链接,而外部存储只能先与存储器链接。换句话说,存储器就是整个结构中的枢纽站。

        控制器:用以控制和协调各个设备之间的工作进程,科学合理高效地分配给各个设备任务。是整个结构中最重要的设备,整个机器的leader。

        运算器:只负责数据的运算,控制器给运算器送什么数据,运算器就算什么,除了运算,它不做任何工作。

=================================分割线===================================

        通过上面的简单介绍,可以看出,本文章要讲的关于Linux进程方面的知识,主要就是围绕控制器展开的。

        2.前置知识--操作系统(OS)

        Linux是一个操作系统,ios,Android,Windows也都是操作系统。操作系统是一个广义上的概念,要理解他又要先上一张计算机结构图,2-1。

f30832cf5c364defa9a2c48891adbfff.png

        可以看到,我们手里的电脑,按照层级可以划分成这么多层。如果将一台计算机比作一家开发软件的科技公司:

        底层硬件:公司的电脑,如果没员工用,它就是废铁,一旦有人用,它就是生产力。硬盘、网卡、CPU、GPU等等都是同理,需要相应的驱动程序才能使用它们;

        驱动程序:用电脑的员工,他们能够按照领导的要求,用电脑完成任务。驱动程序接收操作系统的指令,控制底层硬件完成相应的任务。如我们电脑上的显卡驱动、硬盘驱动,学单片机时的接口驱动等等;

        操作系统:公司的领导,接收客户的需求,管理下属员工的工作。操作系统就是整个计算机组成机构中的leader,向上要提供给用户系统接口,接收用户的指令和请求,向下要管理计算机的硬件设备,起到软硬件协调的作用。是专门用来“搞管理”的软件,如Linux、Mac、Windows等等;

        系统调用接口:领导提供给客户的联系方式。由编写操作系统代码的开发人员提供的对外接口,用户只有通过系统调用接口,才能和计算机建立联系,向计算机发送自己的请求;

        用户操作接口:客户通过领导的联系方式,加上了领导的微信。用户觉得系统提供的接口太不方便,在此基础上对接口进行封装,形成了用户的操作接口。如shall,lib;

        用户:客户。自然指的就是我们这些用电脑的人,我们要学习操作接口,才能更好的使用计算机。

=================================分割线===================================

        总的来说,我们学习操作系统的同时,也是在学习整个计算机领域的抽象知识。操作系统就像是计算机中的哲学,是我们学习并认识计算机的过程中,尤为重要的一门学科。所以,操作系统的知识体系庞大,知识点众多,对操作系统的学习,一定会伴随我们的整个计算机学习生涯。

        3.进程的基本理解

        把计算机比作一个人,我们人类在日常生活中,需要做各种各样的工作,如洗衣做饭、读书学习、娱乐休息等。计算机同样要处理各种来自用户的请求,完成各种任务,这一个个任务,就是进程。如我们写的main函数,执行起来就是一个进程。

        对于一个人而言,在某一刻时间点,是不可能同时做两件事的,(单核的)计算机同理,在单位时间里也只能执行一个进程,而一台计算机在一段时间内往往会需要同时处理很多进程,这就需要对若干进程进行排队,有先后顺序的执行。于是操作系统就起到了进程管理的作用。

        操作系统如何管理?

        我们同样作假设一个场景,现在有两个工作,洗衣服和扫地,我们可以先把衣服放洗衣机里洗着,同时我们去扫地,当扫到一半听到洗衣机洗完了,我们去把衣服挂起来,再回来继续扫地。在这个场景中,我们能够管理这两项工作的先后顺序,因为我们知道这两种工作的属性(洗衣机工作时不需要我们有任何操作,扫地需要一直操作但可以中途暂停)。

        对应到操作系统,它也要先对各个进程的属性有个基本的描述,操作系统通过进程的不同属性,来实现对各个进程的管理,以及进程与进程间的协调。而在计算机中,我们用一个结构体,来描述进程的属性,这个结构体称为-‘PCB’(process control block -- 进程控制块),在Linux中,PCB的结构体名称叫“task_struct”。要注意的是,task_struct ≠ PCB,task_struct是PCB的一种!

每个进程都有它自己的task_struct,其中存放着这个进程的属性内容:

        标示符:         描述本进程的唯一标示符,用来区别其他进程。
        状态:            任务状态,退出代码,退出信号等。
        优先级:         相对于其他进程的优先级。
        程序计数器:  程序中即将被执行的下一条指令的地址。
        内存指针:     包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
        上下文数据:  进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
        I/O状态:     包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
        记账信息:     可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
        其他信息

我们可以通过ps指令,查看计算机内的进程信息

ps ajx

43afda89000346e5b0e739e053344a8c.png

我们首先要关注的是这些信息的前两列,pid和ppid。

        pid:进程id,每个进程都有独属于自己的id,用于区分其他进程;

        ppid:当前进程的父进程id。一个进程有时会再衍生出一个进程,衍生出来的进程就是当前进程的子进程,当前进程就是子进程的父进程。两个进程为父子关系(这个关系很重要,后面讲进程的状态和控制时会经常提到)。

为了更加直观地看到父子进程,也为了后面的学习,我们引入一个函数:

我们先初步理解fork();

使当前进程产生一个子进程,两个进程代码共享,数据各自独立。

fork有两个返回值:

        在父进程中返回所产生的子进程的pid

        在子进程中返回0

接下来,我们用一段代码做个fork的实验

#include<stdio.h>
#include<unistd.h>

int main(){
    printf("我是父进程\n");
    pid_t id = fork();
    if(id < 0){
        printf("子进程创建失败\n");
    }
    else if(id == 0){
        while(1){
            printf("我是子进程: pid: %d, ppid: %d\n",getpid(),getppid());
            sleep(1);                                                                                                                                                                  
        }
    }
    else{
        while(1){
            printf("我是父进程: pid: %d, ppid: %d\n",getpid(),getppid());
            sleep(1);
        }
    }
    return 0;
}

62fab420b53c48b18f6c5630839dc876.png

可以看到父进程的pid,就是子进程的ppid;子进程的pid等于ppid+1

我们可以用ps指令查看进程的pid和ppid,来验证这一点

ps ajx | grep mytest

 112704360e084dcdbbb0aa84db5020ef.png

=================================分割线===================================

        由此我们总结一下:通常的在计算机工作时,往往要处理极其多的进程,为了能合理地分配计算机资源,操作系统要对进程队列中的各个进程进行调度。计算机内的进程调度是由操作系统管理的,操作系统通过“先描述,再组织”的方式,通过直接管理进程的PCB,间接的管理每个进程。每个进程有自己的PCB用以存放自己的属性,有自己的进程id:pid用以表示自己的唯一性。执行fork方法可以让当前进程产生一个子进程,两进程之间是父子关系,子进程的ppid是父进程的pid,子进程的pid是ppid+1。父子进程代码共享,但数据是各自拷贝一份到内存,也就是我们后面即将讲的“写时拷贝”的概念。

4.写时拷贝

        为了更好的理解一台计算机在处理多个进程时是如何工作的,以及为什么一个fork函数会有两个返回值,一个变量同时可以等于两个值,我们需要介绍一下fork创建进程的工作原理。

        还是要图文结合。

0d87989bfc6d4d729f06684dfbc2f628.png

这是父进程产生子进程时,父进程执行fork前,只有自己一个进程,执行fork后,以自己的内存数据信息为模版,拷贝出一个子进程。子进程与当前的父进程执行进度一致,于是子进程不再执行fork前的程序。

当父进程产生子进程时,操作系统为了最大程度节约资源,并不会立刻把父进程在内存中的数据全部拷贝给子进程。就像上文说的,父子进程共用同一代码块,即代码在内存中只有一份:feebfb67b5074b11994cc567fb1d6185.png

代码以及静态变量等,程序中不变的块,虽然只有一份,但并不会使两进程之间相互干扰。可两进程各自的变量,有可能一样,也有可能不一样!操作系统为了最大程度提高资源利用率,并不会贸然给子进程重新开辟空间,将父进程全部变量拷贝到新空间中,但又不能不开辟新空间。于是操作系统使用“写时拷贝”技术,顾名思义,就是当出现 子进程需要与父进程产生不同数据 的情况时,操作系统会将 子进程不同于父进程的数据 进行拷贝,以此来最大程度节省空间,同时又保证了父子两进程之间的独立性

73675ab7b0f94ec88d8966a454e0331f.png

图画的比较随意,但意思是这个意思。、

        于是我们既理解的一个进程在产生一个进程时,内存级别上的工作原理,也能理解了为什么同一时间fork有两个返回值。换句话说,虽然两个变量是同一个变量,但由于在内存中的存储位置不一样,所以即使值不一样,也不会影响两个进程各自的独立性。用俗话说就是,操作系统也懂得该省省该花花。

=================================分割线===================================

        上文既然说,父子进程中,一个变量产生两个不同的值时,操作系统会进行“写时拷贝”,将变化的值放到一块新内存中,那我们就做个实验:

705249f32c2142f49b3cdb77eafd3044.png

我们在程序中显示一下父子进程中各自的id变量的地址

c9139f6194274b519033ebbf34ce0647.png

于是我们发现,父子进程中的id变量的地址竟然一样,这是否与上文的观点矛盾???并不!!!

        接下来讲到的 进程地址空间 可以解释这一现象。

5.进程地址空间

        首先要说明的是,父子两进程中值不相同的同一变量,的确会在空间中存储在不同的位置。而刚才看到的,这个变量在两进程中的地址一样,这一现象也没有任何问题。那么也就是说,我们看到的变量地址,不是真实内存中的物理地址,而是进程为我们虚拟出来了一个地址!!!

        我们在之前学习C语言时,讲到了一个小知识点:直接定义的变量,被保存在栈区,而malloc或new出来的变量则在堆区。那么这里的堆和栈是什么概念呢?以此引出我们的话题--进程的地址空间。

老规矩,先上图,5-1

1c777f0576814267a0f6ffc0cf1815bb.png

在图中我们能看到熟悉的栈和堆,下面介绍一下其他部分:

其中正文代码区存的自然就是我们写的代码,也包括字符常量,const修饰或static修饰的变量也都在其中,所以我们也能知道,这个区域是“只读的”。

堆和栈是相对而生的,也就是说,堆是从下向上生长的,栈是从上向下生长的,我们可以用一段代码验证这一点。

int main(){
    int a;
    int b;
    int *c=(int*)malloc(4);
    int *d=(int*)malloc(4);

    printf("pa=%p\n",&a);
    printf("pb=%p\n",&b);
    printf("pc=%p\n",c);
    printf("pd=%p\n",d);

    return 0;
}

28fc92f565dd487889a516e656b66392.png

        我们可以很直观地看到,用malloc申请的空间地址,比直接用int变量申请的空间地址要小得多;

        因为int变量在栈区申请,所以先申请的 a 比后申请的 b 地址大;

        因为malloc在堆区申请,所以先申请的 c 比后申请的 d 地址小;

        知道了这些,我们还需要知道,每一个进程,都有属于自己的地址空间,也就是刚才的这个结构。也就是说,当前计算机有一万个进程,就有一万个地址空间?那计算机的内存有这么的吗?一个地址空间就有4个GB的大小,内存怎么可能装得下这么多地址空间呢?

        所以引出下一个话题,真实的物理内存对地址空间的“画饼”机制。

        首先我们知道,物理内存(也就是我们的内存条)一般都是16GB或32GB等等,存储量不算大,而一个进程的地址空间从 0x00000... 到 0xFFFFF... 就有4个GB的大小,所以为了在内存中能够装载更多的进程地址空间,就需要物理内存对进程空间进行“画饼”。

        简单说就是,物理内存告诉进程,我这有很多内存,给你有4个G内存的使用权,但我知道 你进程很多时候用不完4个G。我为了节省我的空间,我和操作系统约定好,你进程用多少内存,我从我的内存中找多少给你,最多给你4个G。比如你现在要用512MB,我可以给你一大块连续的内存,也可以给你很多块分散的小内存,只要保证给你足够512MB大小的内存就行。

        但是由于物理内存给进程的空间可能连续也可能不连续,如果进程直接用物理内存的地址进行内存管理,就会极其不方便。于是进程有了属于自己的地址空间,地址空间虚拟出了4个G的连续地址,每个地址都可以通过页表的方式,映射到真实的物理内存上。

24514ec13e474c44850cabbd3e63fe80.png

既然我们知道,地址空间和物理内存之间有映射关系,那么映射关系怎么去维护?

        答:区域划分。地址空间是一种内核数据结构,我们可以在Linux内核代码中找到他。结构中存放着空间中各个区域的起始地址和结束地址。

        由此我们可以总结一下,也回答最开始的问题。fork之所以能有两个返回值,一个变量之所以能有两个不同的值,归根结底就是,我们看到的变量及其地址,都是在虚拟的进程地址空间中的,并不是真实的物理地址中,而在物理空间中,两个进程中的同一个变量已经被放在两块不同的内存中,因此两个进程的同一变量之间并不影响,也就是说,两个进程之间,可能有共用的内存空间,但进程之间一定互不影响,相互独立。

        

地址空间的意义;为什么要有地址空间:

        答:①从上层视角看,完全感知不到物理内存的存在。也就是说,上层无法通过直接访问物理内存的方式,破坏计算机中的其他存储数据。以此保证计算机存储机制的安全性。

               ②方便了进程内部的空间管理。

               ③实现了进程空间管理和物理内存管理之间的解耦合,便于开发和维护。

=================================分割线===================================

        关于进程,还有一个重要的知识点:

6.进程状态

        状态,顾名思义,一个进程从产生,到销毁,之间会经历很多状态的变化。我们逐一来介绍进程的几种状态:

        新建:字面意思,当一个进程被创建时,这个进程就处于新建状态,一般这个状态就一瞬间;

        运行(R):运行状态如果细分,可以分为“运行”和“就绪”状态。当进程的PCB(task_struct)处在CPU的运行队列中,该进程就处在就绪状态;当进程正在被CPU执行,就是运行状态。一般将两状态共称为运行状态。

        阻塞/等待(W):进程由于某种原因暂时停止运行,例如所等待的非CPU资源还未就绪时,CPU不能运行它,因此它处于阻塞/等待状态;

        挂起:当内存不足时,OS为了机器能够运行,会适当把一些进程的代码和数据从内存中置换到磁盘中,该进程此时就处于挂起状态;

        退出:字面意思,进程运行完就退出了,同样的,退出状态一般发生在一瞬间;

        僵尸(Z):当进程运行结束并退出,但它的父进程并没有回收它,OS也不释放它,该进程仍在被检测,此时就处于僵尸状态。这是一个十分危险且难处理的状态;

        孤儿进程:当前进程的父进程已经运行结束并退出,但当前进程还未运行结束,该进程就是孤儿进程。孤儿进程会被1号进程(OS本身)所领养,以至于能够在进程退出后被回收释放。

僵尸状态

僵尸状态是一个十分流氓的状态,杀不死放不掉,即使用万能的kill -9指令也无法结束它。所以它的的存在对计算机有极大危害:

        1.子进程退出后,父进程一直不回收,也就是说,父进程一直不知道子进程的运行结果(结果正确/错误、运行异常等)。对父进程而言,创建了子进程去执行任务,却一直不知道它的结果;

        2.进程运行结束退出后,需要父进程回收子进程的退出信息,而僵尸进程没有父进程回收它。因此计算机仍要继续维护该进程的PCB,Z进程一直不退出,就一直维护,浪费计算资源;

        3.如果一个父进程创建了很多的子进程,又都不回收,这么多子进程一定会造成计算机空间内存的浪费。

        4.包括内存泄漏等其他问题。

进程退出

一般进程的退出有三种场景:

        代码运行完毕,结果正确

        代码运行完毕,结果不正确

        代码异常终止

手动退出进程:调用exit或_exit函数。

        其中,exit是由_exit封装而成的函数。exit在执行_exit前,会先执行清理程序:冲刷缓冲区,关闭流等操作。

        我们来对比一下

进程等待

        为了避免出现父进程提前结束,子进程无法被回收成为僵尸进程的情况出现,我们让父进程调用wait或waitpid系统接口,进入等待状态,等待子进程退出并回收它。

#include <sys/types.h>

#include <sys/wait.h>

pid_t wait(int* status);

返回值:

        成功 返回子进程pid,失败 返回-1。

参数:

        status 是输入型参数,由操作系统自动填写,调用函数时只需填入“status”即可。

status的详细说明如下:

  1. 如果子进程正常退出(使用exit退出),那么status就是子进程的退出状态码,可以通过WEXITSTATUS(status)来获取。如果子进程没有调用exit而是被信号杀死,那么status也包含了子进程被哪个信号杀死的信息,可以通过WIFSIGNALED(status)和WTERMSIG(status)来获取。

  2. 如果子进程被暂停了,比如收到了SIGSTOP信号,那么status包含了子进程被暂停的信号信息,可以通过WIFSTOPPED(status)和WSTOPSIG(status)来获取。

  3. 如果子进程产生了core dump文件,那么status包含了core dump文件的信息,可以通过WIFCOREDUMP(status)来判断。

  4. 如果waitpid函数调用失败,那么status的值没有定义。

pid_t waitpid(pid_t pid, int* status, int options);

返回值:

        正常 返回收集到的子进程的pid;

        若设置了WNOHANG,而调用中发现没有可收集的子进程,返回0;

        错误 返回-1,并将errno设定成相应的错误码。

参数:

        options 是一个整型变量,用于设置进程等待的行为和选项

options参数具体的取值和作用如下:

  1. WNOHANG:如果没有子进程退出或暂停,立即返回0,而不是阻塞等待。这样可以在没有可等待子进程时立即返回,继续执行其他操作。

  2. WUNTRACED:如果子进程被暂停(但没有退出),则返回其状态信息。这通常用于监控子进程是否被暂停,比如收到了SIGSTOP信号。

  3. WCONTINUED:如果子进程被继续执行(之前被暂停),则返回其状态信息。这通常用于监控子进程是否被继续执行,比如收到了SIGCONT信号。

  4. WSTOPPED:如果子进程被暂停,则返回其状态信息。与WUNTRACED类似,但只返回暂停的子进程状态,不包括已经退出的子进程。

  5. WEXITED:只等待已经退出的子进程。如果没有子进程退出,则waitpid函数会立即返回,并且status将保持不变。

  6. WCONTINUED:只等待继续执行的子进程。如果没有子进程被继续执行,则waitpid函数会立即返回,并且status将保持不变。

值得注意的是:

        options参数可以通过位运算来组合使用,比如使用WNOHANG | WUNTRACED来设置多个等待选项。

        如果options参数为0,则waitpid函数将会阻塞等待子进程的退出或暂停,直到有子进程退出或暂停为止。


7.进程替换

        我们由进程状态,引出僵尸进程,再到子进程的退出和父进程的等待。有了这些前置知识,下面介绍一个重要知识点——进程的替换。

        顾名思义,之前我们说,子进程和父进程共用一套代码,如果我们想让子进程在代码上也与父进程独立开,就需要让子进程的替换成别的进程。简单来说,就是让子进程不再执行从父进程那里继承下来的代码,而是执行一段新的代码。

        具体如何实现?——替换函数!

        #include <unistd.h>

        extern char **environ;

        int execl( const char *path, const char *arg, ...);
        int execlp( const char *file, const char *arg, ...);
        int execle( const char *path, const char *arg , ..., char * const envp[]);
        int execv( const char *path, char *const argv[]);
        int execvp( const char *file, char *const argv[]);
        int execve( const char *path, char *const argv[],  char *const envp[]);

函数名的解释与理解:

都是:exec 开头

l(list):表示参数采用列表的方式输入

v(vector):参数采用数组的方式

p(path):自动搜索环境变量

e(env):需要手动维护环境变量

事实上,只有execve是真正的系统调用接口,在man手册的第2张,其他五个都是execve基础上的封装函数,在man手册的第3张。

8.实现简单的shell

        结合以上的知识,我们可以实现一个简单的shell,具体的步骤如下:

1.获取 并 解析命令行

2.使用fork建立子进程

3.替换子进程

4.父进程等待子进程退出


#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<ctype.h>
#include<sys/types.h>
#include<sys/wait.h>

#define MAX_CMD 1024
char command[MAX_CMD];
int myshell(void){
    memset(command, 0x00, MAX_CMD);
    printf("[myshell]$ ");
    fflush(stdout);
    if(scanf("%[^\n]%*c",command) == 0){
        getchar();
        return -1;
    }
    return 0;
}

char **do_parse(char *buff)
{
    int argc = 0;
    static char *argv[32];
    char *ptr = buff;
    while(*ptr != '\0') {
        if (!isspace(*ptr)) {
            argv[argc++] = ptr;
            while((!isspace(*ptr)) && (*ptr) != '\0') {
            ptr++;
            }
        }else {
            while(isspace(*ptr)) {
            *ptr = '\0';
             ptr++;
            }
        }
    }
    argv[argc] = NULL;
    return argv;
}

int do_exec(char *buff){
    char **argv = {NULL};

    int pid =fork();
    if(pid == 0){
        argv = do_parse(buff);
        if(argv[0] == NULL){
            exit(-1);
        }
        execvp(argv[0], argv);
    }
    else{
        waitpid(pid, NULL, 0);
    }
    return 0;
}

int main(int argc, char *argv[]){
    while(1){
        if(myshell() < 0){
            continue;
        }
        do_exec(command);
    }

    return 0;
}


进程的内容还有很多细节的东西没有写到,想起再来补。。。

  • 24
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值