Linux 第十二讲 --- 进程篇(二) 初识进程
前言:
在上一讲中,我们深入探讨了计算机系统的基石——冯诺依曼体系结构,理解了程序在计算机中是为啥要通过存储、运算、控制和输入/输出模块协同工作的。从这一讲开始,我们将从静态的硬件视角转向动态的软件视角,聚焦于操作系统的核心概念之一:进程。
进程是程序的一次动态执行过程,是操作系统进行资源分配和调度的基本单位。如果说程序是存储在磁盘上的“菜谱”,那么进程就是厨师按照菜谱烹饪的整个过程。本讲将带大家初步认识进程的本质及其在Linux中的基础操作。
目录
前言:
一、进程概念
1.1 描述进程
二、进程描述结构体---PCB
2.1 PCB的概念
2.2 task_struct内容分类
三、查看进程信息
3.1 通过系统目录查看
3.2 使用ps指令
3.3 通过系统调用接口获取进程的PID和PPID
3.4 杀死进程
四、第一个系统调用接口---fork
4.1 fork函数创建子进程
4.2 使用if进行分流
总结:
一、进程概念
进程 在课本中的描述一般为:程序的一个执行实例,正在执行的程序,或者是一个程序运行起来(程序被加载到内存)就是进程,进程的内核观点是担当分配系统资源(CPU时间,内存)的实体。但是这些概念都是为了方便学生理解操作系统的概念,但是在实际的Linux操作系统当中我们应该如何去描述一个进程呢?
1.1 描述进程
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
课本上称之为PCB(process control block), Linux操作系统下的PCB是: task_struct
当我们把多个程序加载到内存中时,操作系统会对这些程序进行先描述,后组织。那么操作系统是如何对我们的程序进行描述和组织的呢?
我们将写好的代码编译链接形成可执行程序存放在磁盘上,然后我们运行这个程序时,需要先将这个程序加载到内存中,然后通过CPU进行运算。当我们的程序加载到内存中时,操作系统会对我们的程序进行管理,对程序的管理方法我们在上面也提及到了:先描述,在组织。
操作系统在对我们的进程进行先描述,后组织的时候,会先将我们的程序的共有属性创建一个结构体,然后对我们的每一个进程创建一个结构体对象,这就是先描述的过程。接下来我们的操作系统会使用特性的数据结构(比如链表)将我们的结构体对象组织起来,这就是后组织的过程。然后我们的操作系统对进程的管理就会转换成对特定数据结构的管理。当然了,这个描述和组织进程的东西就被称为进程控制块(PCB)。
所以,在这里我们也引出了进程真正的概念:进程=内核关于进程的相关数据结构+当前进程的代码和数据。
然后操作系统对于对于进程管理,就变成了对于进程控制块的管理,我们在内核中一般是使用双向链表管理,这样一来,操作系统只要拿到这个双链表的头指针,便可以访问到所有的PCB。
此后,操作系统对各个进程的管理就变成了对这条双链表的一系列操作。
例如创建一个进程实际上就是先将该进程的代码和数据加载到内存,紧接着操作系统对该进程进行描述形成对应的PCB,并将这个PCB插入到该双链表当中。而退出一个进程实际上就是先将该进程的PCB从该双链表当中删除,然后操作系统再将内存当中属于该进程的代码和数据进行释放或是置为无效。
总的来说,操作系统对进程的管理实际上就变成了对该双链表的增、删、查、改等操作。
二、进程描述结构体---PCB
2.1 PCB的概念
进程控制块(PCB)是描述进程的,在C++当中我们称之为面向对象,而在C语言当中我们称之为结构体,既然Linux操作系统是用C语言进行编写的,那么Linux当中的进程控制块必定是用结构体来实现的。
PCB实际上是操作系统学科对进程控制块的统称,在Linux中描述进程的结构体叫做task_struct。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含进程的信息。
2.2 task_struct内容分类
task_struct就是Linux当中的进程控制块,task_struct当中主要包含以下信息:
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器(pc): 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
- 其他信息。
其中关于上面的属性有的一部分会在后面的课程有所介绍。
三、查看进程信息
3.1 通过系统目录查看
在根目录下有一个名为proc的系统文件夹。
root@iZn4a2tqizasgm5y4xgt22Z:/# ll
total 72
drwxr-xr-x 19 root root 4096 Apr 27 13:14 ./
drwxr-xr-x 19 root root 4096 Apr 27 13:14 ../
lrwxrwxrwx 1 root root 7 Apr 21 2022 bin -> usr/bin/
drwxr-xr-x 4 root root 4096 Apr 28 06:55 boot/
drwxr-xr-x 19 root root 4020 May 7 04:46 dev/
drwxr-xr-x 110 root root 4096 May 17 06:14 etc/
drwxr-xr-x 4 root root 4096 Apr 29 11:27 home/
lrwxrwxrwx 1 root root 7 Apr 21 2022 lib -> usr/lib/
lrwxrwxrwx 1 root root 9 Apr 21 2022 lib32 -> usr/lib32/
lrwxrwxrwx 1 root root 9 Apr 21 2022 lib64 -> usr/lib64/
lrwxrwxrwx 1 root root 10 Apr 21 2022 libx32 -> usr/libx32/
drwx------ 2 root root 16384 Jun 3 2024 lost+found/
drwxr-xr-x 2 root root 4096 Apr 21 2022 media/
drwxr-xr-x 2 root root 4096 Apr 21 2022 mnt/
drwxr-xr-x 2 root root 4096 Apr 21 2022 opt/
dr-xr-xr-x 183 root root 0 Apr 29 11:10 proc/(这里哟!!!)
drwx------ 10 root root 4096 May 12 20:19 root/
drwxr-xr-x 35 root root 1080 May 21 22:41 run/
lrwxrwxrwx 1 root root 8 Apr 21 2022 sbin -> usr/sbin/
drwxr-xr-x 8 root root 4096 Apr 27 14:20 snap/
drwxr-xr-x 2 root root 4096 Apr 21 2022 srv/
dr-xr-xr-x 13 root root 0 Apr 29 11:10 sys/
drwxrwxrwt 16 root root 4096 May 21 22:00 tmp/
drwxr-xr-x 14 root root 4096 Apr 21 2022 usr/
drwxr-xr-x 13 root root 4096 Apr 21 2022 var
我们可以看到,文件夹当中包含大量进程信息,其中有些子目录的目录名为数字。
这些数字其实是某一进程的PID,对应文件夹当中记录着对应进程的各种信息。我们若想查看PID为1的进程的进程信息,则查看名字为1的文件夹即可。
3.2 使用ps指令
通过ps命令查看
单独使用ps命令再带上 aux选项,会显示所有进程信息。
ps -aux
root@iZn4a2tqizasgm5y4xgt22Z:/proc# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.7 167724 12824 ? Ss Apr29 0:31 /sbin/init noibrs
root 2 0.0 0.0 0 0 ? S Apr29 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? I< Apr29 0:00 [rcu_gp]
root 4 0.0 0.0 0 0 ? I< Apr29 0:00 [rcu_par_gp]
root 5 0.0 0.0 0 0 ? I< Apr29 0:00 [slub_flushwq]
root 6 0.0 0.0 0 0 ? I< Apr29 0:00 [netns]
root 8 0.0 0.0 0 0 ? I< Apr29 0:00 [kworker/0:0H-events_highpri]
root 10 0.0 0.0 0 0 ? I< Apr29 0:00 [mm_percpu_wq]
root 11 0.0 0.0 0 0 ? S Apr29 0:00 [rcu_tasks_rude_]
root 12 0.0 0.0 0 0 ? S Apr29 0:00 [rcu_tasks_trace]
root 13 0.0 0.0 0 0 ? S Apr29 0:06 [ksoftirqd/0]
root 14 0.0 0.0 0 0 ? I Apr29 5:18 [rcu_sched]
root 15 0.0 0.0 0 0 ? S Apr29 0:03 [migration/0]
root 16 0.0 0.0 0 0 ? S Apr29 0:00 [idle_inject/0]
root 18 0.0 0.0 0 0 ? S Apr29 0:00 [cpuhp/0]
root 19 0.0 0.0 0 0 ? S Apr29 0:00 [cpuhp/1]
root 20 0.0 0.0 0 0 ? S Apr29 0:00 [idle_inject/1]
root 21 0.0 0.0 0 0 ? S Apr29 0:03 [migration/1]
root 22 0.0 0.0 0 0 ? S Apr29 0:03 [ksoftirqd/1]
root 24 0.0 0.0 0 0 ? I< Apr29 0:00 [kworker/1:0H-events_highpri]
root 25 0.0 0.0 0 0 ? S Apr29 0:00 [kdevtmpfs]
root 26 0.0 0.0 0 0 ? I< Apr29 0:00 [inet_frag_wq]
root 27 0.0 0.0 0 0 ? S Apr29 0:00 [kauditd]
root 29 0.0 0.0 0 0 ? S Apr29 0:00 [khungtaskd]
……………………
当然这种形式的进程信息,我们还要自己去找,这明显太麻烦了。
我们可以将ps命令与grep命令搭配使用,即可只显示某一进程的信息。
这里我们还可以看到在我们的bash进程的下面还有一个grep进程,这是因为grep指令也是一个进程,进程在调度的时候是具有动态属性的。这里我们还需要解释一个概念那就是PID和PPID的概念,操作系统里指进程识别号,也就是进程标识符。操作系统里每打开一个程序都会创建一个进程ID,即PID。 当然了,PPID就是父进程的进程ID号。
当然对于什么是pid,什么是ppid大家可能还是没有理解,后面也有专门的介绍。
3.3 通过系统调用接口获取进程的PID和PPID
通过使用系统调用函数,getpid和getppid即可分别获取进程的PID和PPID。
我们可以通过一段代码来进行测试。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>int main()
{while(1){printf("这是一个用于打印进程的pid与ppid的测试代码\n");printf("pid:%d\n",getpid());printf("ppid:%d\n",getppid());sleep(1);}return 0;
}
当运行该代码生成的可执行程序后,便可循环打印该进程的PID和PPID。
root@iZn4a2tqizasgm5y4xgt22Z:/home/hu/linux---test/Process# ./ps
这是一个用于打印进程的pid与ppid的测试代码
pid:150937
ppid:150559
这是一个用于打印进程的pid与ppid的测试代码
pid:150937
ppid:150559
这是一个用于打印进程的pid与ppid的测试代码
pid:150937
ppid:150559
这是一个用于打印进程的pid与ppid的测试代码
pid:150937
ppid:150559
^C
3.4 杀死进程
我们要结束我们的进程可以有两种方法:直接按ctrl+c结束进程或者杀死进程。
杀死进程需要的命令是:kill -9 进程标识符
hu@iZn4a2tqizasgm5y4xgt22Z:~/linux---test/Process$ ./ps
这是一个用于打印进程的pid与ppid的测试代码
pid:152649
ppid:152631
这是一个用于打印进程的pid与ppid的测试代码
pid:152649
ppid:152631
这是一个用于打印进程的pid与ppid的测试代码
pid:152649
ppid:152631
^C
hu@iZn4a2tqizasgm5y4xgt22Z:~/linux---test/Process$
当然这种方法只能针对命令行当前的进程,所以如果一个进程在后端运行,无法接受我们的命令怎么办呢?
我们可以直接使用kill命令,强杀某个进程。
四、第一个系统调用接口---fork
4.1 fork函数创建子进程
fork是一个系统调用级别的函数,其功能就是创建一个子进程。
例如,运行以下代码:
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>int main()
{fork();while(1){printf("这是一个用于打印进程的pid与ppid的试代码\n");printf("pid:%d\n",getpid());printf("ppid:%d\n",getppid());sleep(1);}return 0;
}
若是代码当中没有fork函数,我们都知道代码的运行结果就是循环打印该进程的PID和PPID。而加入了fork函数后,代码运行结果如下:
hu@iZn4a2tqizasgm5y4xgt22Z:~/linux---test/Process$ ./ps
这是一个用于打印进程的pid与ppid的试代码
pid:152825
ppid:152631
这是一个用于打印进程的pid与ppid的试代码
pid:152826
ppid:152825
这是一个用于打印进程的pid与ppid的试代码
pid:152825
ppid:152631
这是一个用于打印进程的pid与ppid的试代码
pid:152826
ppid:152825
运行结果是循环打印两种数据,第一种数据是该进程的PID和PPID,第二种数据是代码中fork函数创建的子进程的PID和PPID。
我们可以发现fork函数创建的进程的PPID就是ps进程的PID,也就是说ps进程与fork函数创建的进程之间是存在某种关系的。而在Linux进程当中我们把这种关系称为父子关系,表示是父亲创造了儿子一样。
每出现一个进程,操作系统就会为其创建PCB,fork函数创建的进程也不例外。
我们知道父进程的数据是操作系统将磁盘上的数据代码加载到内存上的,那么fork函数创建的子进程的代码和数据又从何而来呢?
子进程会直接继承父进程的数据与代码。
使用fork函数创建子进程后,在fork函数被调用之前的代码被父进程执行,而fork函数之后的代码,则默认情况下父子进程都可以执行。需要注意的是,父子进程虽然代码共享,但是父子进程的数据各自开辟空间(采用写时拷贝)。
当然操作系统为了减小开销,如果父子进程没有对这些继承下来的数据进行修改只是访问的话,系统也不必为两个进程开辟新的内容了。
小贴士: 使用fork函数创建子进程后就有了两个进程,这两个进程被操作系统调度的顺序是不确定的,这取决于操作系统调度算法的具体实现。
4.2 使用if进行分流
上面说到,fork函数创建出来的子进程与其父进程共同使用一份代码,但我们如果真的让父子进程做相同的事情,那么创建子进程就没有什么意义了。
实际上,在fork之后我们通常使用if语句进行分流,即让父进程和子进程做不同的事。
fork函数的返回值:
1、如果子进程创建成功,在父进程中返回子进程的PID,而在子进程中返回0。
2、如果子进程创建失败,则在父进程中返回 -1。
既然父进程和子进程获取到fork函数的返回值不同,那么我们就可以据此来让父子进程执行不同的代码,从而做不同的事。
例如,以下代码:
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>int main()
{pid_t id=fork();while(1){printf("这是一个用于打印进程的pid与ppid的试代码\n");printf("pid:%d\n",getpid());printf("ppid:%d\n",getppid());if(id >0){printf("father doing sth\n");}else if(id == 0){//等于0,说明是子进程printf("son doing sth\n");}sleep(1);}return 0;
}
fork创建出子进程后,父进程会进入到 if 语句的循环打印当中,而子进程会进入到else if 语句的循环打印当中。
hu@iZn4a2tqizasgm5y4xgt22Z:~/linux---test/Process$ ./ps
这是一个用于打印进程的pid与ppid的试代码
pid:152852
ppid:152631
father doing sth
这是一个用于打印进程的pid与ppid的试代码
pid:152853
ppid:152852
son doing sth
这是一个用于打印进程的pid与ppid的试代码
pid:152852
ppid:152631
father doing sth
这是一个用于打印进程的pid与ppid的试代码
pid:152853
ppid:152852
son doing sth
这里也给大家提供几个结论,但是关于为什么这些结论是正确的,我们要在后面的博客慢慢讲解了。
结论:
- fork之后,执行流会变成2个执行流。
- fork之后,谁先运行由调度器决定。
- fork之后的代码共享,通常通过
if
和elseif
来进行执行流分流。
总结:
从冯诺依曼结构的静态存储,到进程的动态执行,我们看到了计算机系统从硬件到软件的完美衔接。理解进程是掌握操作系统多任务管理的钥匙,而状态转换则是这把钥匙的核心齿纹。下一讲,我们将揭开进程状态的神秘面纱,继续探索Linux系统高效运行的奥秘。