目录

1.1 引出进程

1.2 进程的基本概念

1.3 理解进程

1.3.1 描述进程

1.3.2 组织进程

二、查看进程

2.1 通过ps命令查看进程

2.2 杀掉进程

2.2.1 使用ctrl+c

2.2.2 使用kill -9 pid

2.3 通过ls / proc命令查看进程

2.4 查看进程父进程的信息

2.5 总结

三、如何创建进程

3.1 fork函数

3.2 fork函数的返回值

3.3 fork函数的三个问题

3.3.1 为什么fork给父进程返回子进程pid,给子进程返回0

3.3.2 什么一个函数会返回两次,如何理解

3.3.3 为什么一个变量即==0,又大于0,导致if else同时成立

3.4 总结


1.1 引出进程

首先我们来看一个问题:

操作系统能不能一次运行多个程序呢?---------当然可以啦!!

因为运行的程序有很多,所以 OS 需要将这些运行的程序管理起来。
我们将这些正在运行的程序称之为进程。(注意:是正在运行的程序叫进程,而不是程序本身

  • 如何管理这些进程呢?—— 先描述,再组织!
  • 操作系统会创建一个描述和控制该进程的结构体。这个结构体称之为进程控制块(PCB,Processing Control Block),里面包含了该进程几乎所有的属性信息,同时通过进程控制块也可以找到该进程的代码和数据。
  • 在 Linux 中,进程控制块就是 struct task_struct 结构体。
  • 描述好所有进程了,还需要将所有进程的 PCB 给组织起来(通过双链表的方式),此时操作系统只需要拿到双链表的头指针,就可以找到所有进程的 PCB。
  • OS 把对进程的管理就转换成了,对数据结构中 PCB 的管理,即对双链表的增删查改操作

假设这里有一个可执行程序 test,它存储在磁盘上,就是一个普通文件,当我们 ./test 运行此程序,操作系统会做以下事情:将该程序从磁盘加载到内存中,并为该程序创建对应的进程,申请进程控制块(PCB)。


总结:

  1. 为什么要存在 PCB 呢?—— 因为 OS 要对进程进行管理。
  2. 目前对于进程的理解:进程 = 程序(代码 + 数据) + 内核申请的与该进程对应的数据结构(PCB)。

1.2 进程的基本概念

首先读者要知道的是,什么是进程?

【课本概念】:程序的一个执行实例,正在执行的程序等
【内核观点】:担当分配系统资源(CPU时间,内存)的实体
        对于课本中的观点大家可能会觉得难以理解,为何正在执行的程序就是一个进程呢。我们可以在Windows下按[Ctrl + shift + ESC]打开任务管理器查看一下:

       可以看到左上角的这个【进程】标志,代表呢我们下面所运行的程序都是一个进程

这也就表明了在一个操作系统中不仅只能运行一个进程,还可以运行多个进程

但是呢,进程不仅仅可以像上面这样去理解。我们来思考一个问题:程序是文件吗?

相信读者肯定很清楚,文件是存放在磁盘中的,磁盘呢则是属于外设。这一块我们在冯·诺依曼体系结构有讲得很清楚,对于CPU来说,它是只会和内存打交到的,所以磁盘中的数据需要先加载到内存中才可以被执行
 那么,当可执行文件被加载到内存中时,该程序就成为了一个【进程】

总结:

进程 = 程序(代码 + 数据) + 内核申请的与该进程对应的数据结构(PCB)

1.3 理解进程

在了解了进程的基本概念后,我们来更加进一步地认识一下进程管理是如何实现的?

  • 那肯定是---- 先描述进程----在组织进程

1.3.1 描述进程

在 Linux 中,我们使用一个结构体去描述一个进程,因为 Linux 的内核源代码都是使用C语言来写的,而 结构体 则是C语言里面的一些知识,不懂的同学可以先去了解一下。并且呢这个结构体还有名字的,它叫做【进程控制块】----- PCB

  • 课本中的叫法呢是:PCB(Process Control Block)
  • Linux操作系统下的PCB是:task_struct

这个结构体呢就是组织了各种各样的属性,才可以去很好地描述一个进程


以下就是这个task_struct的所有结构信息:(后续我们会逐一讲解!)

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

转换为代码形式的话就可以是下面这样

struct PCB{
    进程的编号
    进程的状态
    进程的优先级
    ...
    相关的指针信息
};

既然知道了如何去描述一个PCB结构体,我们就要来知道操作系统对一个进程总共会做哪些事情

  1. 为该进程创建对应的PCB对象
  2. 将该进程的代码和数据加载到内存中

所以,很多教科书在介绍进程的时候只会说它在计算机内部是一个PCB对象,其实对于一个进程来说:应该是由操作系统为其创建出来的 PCB对象 + 其数据代码 组成的


总结:

【进程】 = 内核PCB数据结构对象(描述你这个进程所有的属性值) + 你自己的代码和数据(在磁盘当中所形成的可执行程序)

1.3.2 组织进程

上面谈到了操作系统如何利用PCB去描述一个相关的进程,接下去我们来说说如何去组织进程

      大学的校园我们都知道,比中学的大多了。但是呢在学校里每个学生他的人身是自由的,不可能每个人拿着一个牌子,上面自己写上个人的基本信息,这是不现实的。比方说学院的辅导员想要找一个学生的话,一定是通过在学籍系统中找到这个学生的所在行记录,才可以对其进行相关的操作,读者可以认为这个信息记录即为我们在上面所讲到的【PCB对象】
         因此操作系统如果要去管理一个进程的话只需要找到某个进程的PCB对象即可,通过这个PCB对象就可以找到相对应的代码和数据

  • 那这个PCB结构体中就一定存在一个东西叫做结构体指针,里面保存了某个内容的地址,方便我们很快地知道其它对应的PCB信息

struct PCB{
    进程的编号
    进程的状态
    进程的优先级
    ...
    相关的指针信息
    struct PCB* next;
};

这样可能还是抽象了一点,我们再举个形象点的案例 

  • 读者可能是在校大学生,亦或者是已经工作的人。但是无论是谁都要去投递简历找工作。如果读者有当过面试官的话一定可以知道每天公司都会收到成千上百份的简历,因为面试官无法看到投递者这个人,所以其只能通过简历来识别这个人,那么读者就可以认为每一份简历就相当于是每个进程所专属的【PCB对象】
  • 所以面试官对于面试者的管理就是对简历的管理。让进程去某个队列里排队,不是让代码去排队,而是进程的PCB结构体对象去排队

 我在一开始的时候就讲到过,在内存中不仅仅是跑着一个进程,而是有可能会存在多个进程。那对于这多个进程要如何去起到关联呢?

这一点其实我们在讲操作系统的时候也有讲到过,我们学习编程语言,像C/C++、Java,可以帮助我们去很好地描述一个抽象的事物;我们学习数据结构与算法,可以帮助我们更好地去组织相同的对象数据
那么在Linux内核中,最基本的组织进程的是用task_struct的方式,采用【双向链表】组织的

那有了上面的这个理论,操作系统在对于进程的管理就转换成了对于链表的增删查改 

二、查看进程

明白了操作系统如何去描述并组织进程,接下去我们就切身地来看一看进程长什么样吧

makefile文件:

  1 code:code.c
  2     gcc -o $@ $^
  3 .PHONY:clean
  4 clean:
  5     rm -f code
             

测试代码:

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 
  4 int main()
  5 {
  6     while(1)
  7     {
  8         printf("我是一个进程!!\n");
  9         sleep(1);
 10     }
 11     return 0;
 12 }

我们使用make自动化工具进行编译,然后运行,如下所示:

按ctrl + c可以结束运行,

我们历史上执行的所有指令,工具,自己的程序,运行起来,全部都是进程!!

现在我们运行code这个程序,那我们如何查看这个进程的属性呢?

下面我们来学习一个系统调用,getpid,这个系统调用的作用是返回该进程的pid,我们使用man指令介绍,如下所示:

谁调用这个函数,就返回这个进程的pid。那么从哪里得到这个进程的pid呢?

答案是从PCB进程控制块中得到。

我们修改测试代码,让程序执行的时候,显示出进程的pid,如下所示:

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<sys/types.h>
  4 
  5 int main()
  6 {
  7     while(1)
  8     {
  9         printf("我是一个进程!!!我的pid:%d\n",getpid());
 10         sleep(1);
 11     }
 12     return 0;
 13 }
 

运行如下所示:

2.1 通过ps命令查看进程

我们再次打开一个xshell,让上面的那个xshell运行程序,让新打开的xshell来查看运行了那些进程。

使用ps axj可以查看系统里面运行的进程,如下所示:

但是linux系统里面运行的进程太多了,我们现在想要查看我们刚刚运行的进程,可以使用 ps axj | grep code,如下所示:

使用 ps axj | grep code之后,就显示了code进程相关的信息,但是我们并不知道这些信息是什么,可以使用ps axj | head -1来显示属性列,然后在后面写上分号,再次写上ps axj | grep code,如下所示:

也可以使用&&,如下所示:ps axj | head -1 && ps axj | grep code

当我们查code进程的时候,那第二行是什么呢?

当我们查进程的时候,这个grep选项总是会被显示出来,这是因为整个命令从左向右查的时候,grep也是一个命令,我们上面刚刚说过,linux系统下的指令执行起来也是一个进程,grep过滤条件里面本来就包含了code,所以grep进程也会显示出来。

如果不想要显示grep,可以使用如下指令:ps axj | head -1 && ps axj | grep code | grep -v grep

2.2 杀掉进程

2.2.1 使用ctrl+c

当我们进程运行的时候,我们可以使用ctrl+c来杀掉进程,如下所示:

然后我们再次查看进程的时候就没有了,如下所示:

2.2.2 使用kill -9 pid

我们把进程运行起来之后,如下所示:

使用kill -9 2888380杀掉进程,如下所示:

2.3 通过ls / proc命令查看进程

我们将刚刚的程序运行,然后使用ls /proc来查看进程,如下所示:

我们可以通过文件的方式去查看进程,操作系统不仅仅可以把磁盘上的文件使用ls这种命令展现出来,还可以把内存的数据也以文件的方式呈现出来,让我们可以动态的看见内存的相关数据。

像proc就是内存级别的文件系统,跟磁盘没有关系,里面放的是当前系统实时的进程信息。

但是呢,上面这些呢是全部的进程,若我们只是想要查看某个进程的话就要根据其PID值去进行对应的查找。这个PID呢就是我们在上面在介绍task_struct这个Linux下的PCB结构体的时候所讲到的【标识符】这个东西,它是 描述本进程的唯一标示符,用来区别其他进程

然后我们查看这个文件的详细信息,如下所示:ls /proc/2909582 -l

这里我们主要关注两个:一个是cwd目录,另一个则是exe

cwd叫做current work dir,表示进程当前工作目录。

这个exe对应的是进程的可执行文件。一个进程被cpu执行的时候,它知道自己是从哪里来的。是启动哪一个程序时,才有了我这个进程。

我们可以将这个文件删了,程序现在还是能运行的,这是为什么呢?前面刚刚说过,cpu是不访问磁盘的,我们执行这个程序时,已经把这个程序的数据拷贝到内存了,我们删除这个文件是磁盘的文件,内存中还是有这个程序的相关数据和代码的,所以还是能正常执行进程。

当我们杀掉进程之后,这个文件就不存在了,如下所示:

2.4 查看进程父进程的信息

我们执行code可执行程序,创建一个进程,那这个进程是谁创建的呢?

答案是父进程。

在上面,我们使用man来查看getpid的时候,有一个getppid,,如下所示:

这个getppid就是查看这个进程父进程的pid,我们编写代码,来查看父进程id,如下所示:

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<sys/types.h>
  4 
  5 int main()
  6 {
  7     while(1)
  8     {
  9         printf("我是一个进程!!!我的pid:%d,我的父进程pid:%d\n",getpid(),getppid());
 10         sleep(1);
 11     }
 12     return 0;
 13 }

运行如下所示:

我们运行code程序之后,系统给我创建了一个进程,进程pid是2918243,父进程pid是2621879.

然后我们重新运行几次,如下所示:

我们发现我自己的进程pid是一直变化的,而父进程pid是不变的。

其实的话,我自己的进程重新启动之后,pid发生了变化这个现象是很正常的,每次重新启动进程其 PID 值是会出现不同的情况

举个很简单的例子来说吧,小王在高考结束完后上了一所不是很理想的大学,在开学前两天时学习为其分配了对应的学号。但是呢小王却并不满意自己所待的这个学校,所以就退了学继续参加高考,在又一次的高考结束后他还是被原来的这所学校给录取。但是呢我们可以知道,即使你进了一个学校两次,但是学号却不一定是一样的
 这也就是为什么一个进程在重新启动后会出现不同PID值的原因。

但是为什么父进程的pid不变呢?我们的父进程是谁啊?

我们使用父进程pid来查看进程,如下所示:ps axj| head -1&&ps axj | grep 2621879 | grep -v grep

我们发现我们自己启动的程序,是bash进程创建的!!

也就是我自己启动的程序,父进程都是bash。bash就是命令行解释器。命令行解释器本质上就是一个进程。

其实我们每一次登录我们的云服务器时,操作系统会给每一个登录用户分配一个bash,也就是说,我们每次登录一次,就会创建一个bash进程。

现在我们理解了bash是一个进程,我们在使用xshell连接我们的云服务器的时候,

光标的前面是什么?

这就是bash打印出来的字符串,bash进程打印出来之后,它就在这里等着,如果我们没有输入任何命令,bash就卡在这里了。所以我们命令行输入的所有命令,都是喂给了bash进程,bash拿到命令就给我们分析了,然后把结果返回给我们。

原来bash就是一个进程啊,先打印一个字符串,然后等待我们输入命令,帮我们执行,最后把运行结果返回给我。

所以我们命令行上执行的ls,pwd,touch等等这些命令,执行后都会创建相应的进程,父进程都是bash!!

2.5 总结

1:在程序里面使用系统调用函数getpid查看进程的pid

2:使用 ps ajx

        ps axj | grep code

        ps axj | head -1 && ps axj | grep code

        ps axj | head -1 && ps axj | grep code | grep -v grep

3:使用ls /proc

4:ctrl+c杀死进程

5:kill -9 pid 杀死pid进程

三、如何创建进程

创建新进程在Linux的下是由父进程来完成的,创建完成的新进程是子进程。
新进程的地址空间有两种可能性:

  • 子进程是父进程的复制品(除了PID和task_struct是子进程自己的,其余的都从父进程复制而来)
  • 子进程装入另一个程序。

fork函数用于创建一个新的进程,使用fork函数来创建一个进程时,子进程只是完全复制父进程的资源。这样得到的子进程和父进程是独立的,具有良好的并发性。但是进程间通信需要专门的机制。

之前我们在Linux下启动一个进程的时候利用的是./可执行程序那是否有其他办法去启动一个进程呢? 

当然是有的,那就是使用fork()这个函数。

3.1 fork函数

在使用之前呢我们要先去查看一下这个函数该如何使用------ 使用man 手册查询一下 fork 函数的使用。使用命令man 2 fork,如下所示:

fork的作用是创建一个子进程,需要包含sys/types.h和unistd.h的头文件。

测试代码如下:

  1 #include<stdio.h>
  2 #include<sys/types.h>
  3 #include<unistd.h>
  4 
  5 int main()
  6 {
  7     printf("父进程开始运行,pid=%d\n",getpid());
  8     fork();
  9     printf("进程开始运行,pod=%d\n",getpid());
 10     return 0;
 11 }
~      

我们编译运行一下,如下所示:

为什么第二个printf语句会被执行两次呢?

当程序开始执行的时候,会先打印第一句,此时只有一个执行流,然后调用fork函数创建一个子进程,一旦fork函数执行完成之后,就会有两个执行流了。这两个执行流,也就是父进程和子进程都会执行后续的代码,所以会执行两次,并且pid的值应该不同,父进程执行getpid会返回父进程的pid。子进程执行getpid函数会返回子进程的pid。

下面我们来说明一下原理,一个进程等于PCB加上自己的代码和数据,使用fork创建子进程之后,系统里面就多了一个进程,进程需要有PCB+自己的代码和数据,那么子进程的PCB,代码和数据从哪里来呢?

答案是从父进程那里拷贝来的,大部分数据是一样的,只有少数的属性不一样,例如pid和ppid等等。

子进程创建的时候,默认会执行父进程的代码和数据,所以子进程在被调度的时候,子进程就会执行父进程之后的代码,第二个printf语句就会被执行两次。

子进程被创建,子进程现在是没有自己的代码和数据的,因为目前,没有给子进程加载新的代码和数据。所以默认共享父进程的代码和数据。

虽然子进程能够看见第一个printf语句,但是父进程以及执行过了,子进程不需要再次执行。

3.2 fork函数的返回值

在3.1 我们简单使用了fork函数来创建一个子进程,那么fork函数的返回值是什么呢?

我们使用man来查看一下,如下所示:

这句话描述的是,在成功创建子进程的情况下,会把子进程的pid返回给父进程,0被返回给子进程,如果创建子进程失败,-1被返回给父进程。

这句话的意思是fork函数会返回两个不同的值!!!函数怎么能返回两个不同的值呢?

现在我们想让父子进程来执行不同的代码逻辑,代码如下所示:

  1 #include<stdio.h>
  2 #include<sys/types.h>
  3 #include<unistd.h>
  4 
  5 int main()
  6 {
  7     printf("父进程开始运行,pid=%d\n",getpid());
  8     pid_t id=fork();
  9     if(id<0)
 10     {
 11         perror("fork fail");
 12     }else if(id==0)
 13     {
 14         //子进程逻辑
 15         while(1)
 16         {
 17             sleep(1);
 18             printf("我是一个子进程!,我的pid:%d,我的父进程id:%d\n",getpid(),getppid());
 19         }
 20     }else
 21     {
 22         //父进程逻辑
 23         while(1)
 24         {
 25             sleep(1);
 26             printf("我是一父进程进程!,我的pid:%d,我的父进程id:%d\n",getpid(),getppid());
 27         }
 28     }
 29     return 0;
 30 }

运行如下所示:

父进程的pid是3209461,父进程的父进程id是2621979,也就是bash进程。

子进程的pid是3209462,子进程的父进程id是3209461。

父子进程执行了不同的代码块!!

在fork之后,父子代码是共享的,只不过父进程认为自己的返回值是大于0,执行大于0的逻辑,子进程认为自己的返回值是0,执行==0的逻辑。所以就能做到父子进程执行不同的代码块。

现在就有以下几个问题,

1:为什么fork给父进程返回子进程pid,给子进程返回0

2:为什么一个函数会返回两次,如何理解

3:为什么一个变量即==0,又大于0,导致if else同时成立

3.3 fork函数的三个问题

3.3.1 为什么fork给父进程返回子进程pid,给子进程返回0

上面我们说到当进程的代码执行到fork()函数的时候,会将执行流一分为二,父子进程通过不同的 id 返回值来区分,以此执行不同的代码块。那其实很好理解了:因为父子进程是两个不同的进程,所以需要根据这个不同的返回值来进程区别

那为什么父进程得到的是子进程的PID,但是子进程却是0呢,为什么不可以倒过来?

一个父亲可以有多个孩子,但是呢一个孩子却只能有一个父亲, 父亲所获取到的返回值是子进程的PID是由于他要靠不同的PID值来区分不同的孩子;但子进程的返回值都是0的原因在于,他一定只对应着某一个父进程,只需让父进程知道它被成功创建出来了即可

3.3.2 什么一个函数会返回两次,如何理解

如果一个函数已经执行到了return了,这个函数的核心功能做完了嘛?答案是已经做完了。

fork函数的核心功能是创建一个子进程,当执行到return之后,这个子进程是不是已经被创建了。

在执行return之前,子进程已经被创建了,是不是申请新的PCB了,然后拷贝父进程PCB,甚至将子进程放入调度队列中,也可能正在执行这个子进程,return是一个语句嘛?答案肯定是的,父子进程是执行相同的代码和数据的,所以子进程会执行这个return,同样的父进程也会执行这个return语句。所以return执行两次,父子进程各自返回一次。

3.3.3 为什么一个变量即==0,又大于0,导致if else同时成立

在我们上面的代码中,我们会发现 id 这个变量会有两个不同的值?这是为什么呢?

父进程创建子进程之后,子进程共享父进程的代码,那么父进程的数据也是共享的嘛?

答案也是共享的。

那么子进程不就可以修改父进程的变量了吗?例如父进程里面有一个全局变量,然后子进程修改这个变量,不就导致父进程的执行逻辑发生变化了吗?

虽然父子进程共享数据,但是一旦有哪一个进程想要修改数据,操作系统就会把要修改的数据在底层拷贝一份,让目标进程修改这个拷贝,所以父子进程修改数据,不影响彼此的逻辑。这个概念叫写时拷贝!!。

测试代码如下:

  1 #include<stdio.h>
  2 #include<sys/types.h>
  3 #include<unistd.h>
  4 
  5 int gval=100;
  6 
  7 int main()
  8 {
  9     printf("父进程开始运行,pid=%d\n",getpid());
 10     pid_t id=fork();
 11     if(id<0)
 12     {
 13         perror("fork fail");
 14     }else if(id==0)
 15     {
 16         //子进程逻辑
 17         printf("我是一个子进程!我的pid:%d,我的父进程id:%d,gval=%d\n",getpid(),getppid(),gval);
 18         sleep(5);
 19         while(1)
 20         {
 21             sleep(1);
 22             printf("子进程修改变量:%d->%d\n",gval,gval+10);
 23             gval+=10;
 24             printf("我是一个子进程!我的pid:%d,我的父进程id:%d,gavl=%d\n",getpid(),getppid(),gval);
 25         }
 26     }else
 27     {
 28         //父进程逻辑
 29         while(1)
 30         {
 31             sleep(1);
 32             printf("我是一个父进程!我的pid:%d,我的父进程id:%d,gval=%d\n",getpid(),getppid(),gval);
 33         }
 34     }
 35     return 0;
 36 }

运行如下所示:

我们可以看到,刚开始的时候父子进程的gval都是100,然后子进程开始修改gval,发现子进程的gval变化了,而父进程的gval还是100!!

当进程里数据没有发生修改时。那么子进程和父进程共享代码和数据!如果有一个进程的数据发生了修改,那么会对数据发送写时拷贝,而代码依旧是共享的。

现在回到上面的代码,id是变量嘛??父子进程是不是都可以访问这个id。

在子进程里面,fork会返回0,就需要修改id的值,此时就发生了写时拷贝。

在父进程记录,fork会返回子进程的pid,同样也需要修改id的值,也发生的写时拷贝。

这就解释了一个变量为什么会有不同的值!!!!

3.4 总结

1.子进程是由父进程创建的
2.子进程和父进程一般情况下共享代码和数据,但如果有一方的数据或代码被修改,那么操作系统会写时拷贝一份。
3.fork函数不是真的有2个返回值,而是两个进程都在fork函数执行了返回操作。
4.fork返回父进程子进程的PID,给子进程返回0,进程创建失败返回-1

 

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐