【Linux系列】并发世界的基石:透彻理解 Linux 进程 — 进程概念
👓️博主简介:
文章目录
- 前言
- 一、基本概念与基本操作
- 二、描述进程 - PCB
- 三、task_struct
- 四、查看进程
- 五、通过系统调用获取进程标识符
- 六、通过系统调用创建进程 -fork初识
- 总结
前言
上一章节我们了解了我们的操作系统是什么,我们本章节就在上一节的基础上来讲讲我们进程的概念,来看看进程到底是什么东东,操作系统先放到一遍,在我们学习的过程中慢慢渗透。我们一起来看看吧。
一、基本概念与基本操作
-
课本概念:程序的执行实例,正在执行的程序等;
-
内核观点:担当分配系统资源(CPU时间、内存)的实体;
二、描述进程 - PCB
基本概念:
-
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合;
-
课本上称之为 PCB(process control block), Linux 操作系统下的 PCB 是:task_struct;
-
在 Linux 中描述进程的结构体叫做:task_struct;
-
task_struct 是 Linux 内核的一种数据结构,它会被装载到 RAM(内存)里并且包含着进程信息;
task_struct -> PCB的一种;
当我们编译好一个可执行程序后,根据冯诺依曼体系可以知道这个文件在运行前都是在磁盘上的。当我们想要运行时就得加载到内存中,相当于把我们文件的代码和数据加载到内存中。难道这就是进程了吗??
我们可以试想一下,我们的计算机在同一时间节点难道就只有一个可执行程序运行吗。答案当然是不可能的,就比如一边听歌一边打代码。而根据冯诺依曼体系来说,只要运行了就要把它放在内存中,在内存中有一大堆代码和数据,他们有的被暂停了、有的在用、有的在退出等,这么多状态,如果只是有代码和数据,操作系统根本无法对多个加载到内存的程序进行管理。所以如果只有代码和数据就算不上进程一说。
那该怎么做呢??答案显而易见,先描述,再组织。也就是说操作系统为了管理我们的代码和数据,在操作系统内会构建一个struct结构体,在这个结构体中会把我们所需要的所有数据都维护起来,这样我们就拥有了一个节点,再在最后加一个指针指向我们的代码和数据,这样我们的操作系统就方便管理了。同时拥有一个指针将不同的 struct 相连,就形成了一个加载在内存中的程序列表,我们把这个程序列表叫做进程列表。
所以说我们的进程并不是说把我们的代码和数据加载到内存中的代码和数据叫进程,而是在加上这个类,而这个类在操作系统中称之为 PCB,也是说真正的进程 = 内核数据结构对象 + 自己的代码和数据。PCB 是我们所有操作系统的统称。而在 Linux 中我们的 PCB 具体叫做 task_struct。我们进程的所有属性都能在其 PCB(进程控制块)中找到。以后我们想对进程进行管理就转化成了我们对进程列表的增删查改了。
三、task_struct
内容分类:
-
标识符:描述本进程的唯一标识符,用来区分其他进程;
-
状态:任务状态、退出码、退出信号等;
-
优先级:相对于其他进程的优先级;
-
程序计数器:程序中即将被执行的下一条指令的地址;
-
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块指针;
-
上下文数据:进程执行时处理器的寄存器中的数据;
-
I/O 状态信息:包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
-
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等;
-
其他信息…;
组织进程:
可以在内核源代码里找到它。所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。
四、查看进程
- 进程的信息可以通过 /proc 系统文件夹查看;
如:要获取 PID 为 1 的进程信息,你需要查看 /proc/1 这个文件夹。
zxl@iv-ye423qlwxsqc6ikwbogx:~$ ls /proc
- 大多数进程信息同样可以使用 top 和 ps 这些用户级工具来获取。
我们历史上执行的所有指令、工具、自己的程序,运行起来,全部都是进程!!
五、通过系统调用获取进程标识符
我们来学习一下我们第一个系统调用指令 -> getpid;
-
进程 ID(PID)
-
父进程 ID(PPID)
作用:
一个进程启动起来后获得它的标识符。
返回值:
谁调的这个进程就返回谁的进程 ID。pid_t 的返回类型是由系统提供的,本质上就是 int;获取失败就是 -1,成功就是大于 0 的一个值,表示进程 ID;
本质:
在我们的正在运行进程中都拥有 PCB(task_struct),在 PCB 中就存储了标识符这个属性,本质就是从正在运行的进程的 PCB中把我们的标识符拷贝出来让用户看到 PID 是什么。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{while(1){sleep(1);printf("我是一个进程!我的pid: %d\n", getpid());}return 0;
}
运行后可以看到我们的进程 ID 是 753007;这就更加证明了它是一个进程。但是还是不够,我们的进程已经启动起来了,所以按道理我们应该在系统层面上应该也有一批指令来让我们去查我们当前系统中我们的进程有哪些?此时我们再次打开一个 xshell,输入 ps axj 就能展示我们系统中的所有进程。
由于进程太多了,所以为了查看方便,我们使用 ps axj | grep test1 用来查我们 test1 进程。
我们就能查看到我们的 test1 进程,但是我们我们看不明白这些是什么东西怎么办?我们可以把它头打印出来 ps axj | head -1。
此时我们再把之前的指令也增加进去,中间用;隔开 ps axj | head -1;ps axj | grep test1 就能够把头和我们要查询的进程一起打印出来。也可以输入 ps axj | head -1 && ps axj | grep test1 效果是一模一样的。
我们发现,除了我们的 test1 进程,在下面还有一个 grep --color=auto test1 是什么东西?其实当我们去查询的时候我们的这个命令总是会显示出来,是因为我们的查询命令也是一个命令,当我们要把我们要查询的东西提取出来时,grep 也会运行,它运行起来也是一个进程,而且查询关键字中也包含了 test1,所以它会把自己也查询出来。
如果我们不想要看到它可以使用 grep -v grep,就是反向匹配,包含 grep 的我都不要。
杀死进程:
我们杀死进程除了可以使用 ctrl + c,还可以使用指令 kill -9 进程 PID 来杀死进程。
这样我们就能够杀死指定的进程。
我们在 Linux 中运行的所有指令本质上都是进程,只是有的运行的非常快,一运行完毕就退出。所有我们系统进行的所有指令都是通过进程来进行的。手机上的所有操作也都是进程。我们的用户可以给系统很多进程,相当于任务,所以我们 Linux 的 PCB 叫 task_struct。
我们想查询进程还可以通过我们 Linux 的 /proc 目录来查询。可以通过文件的方式来查看进程。
文件中每一个数字目录就包含了一个进程的 PID,每个数字文件的内容就包含了这个进程在运行时的动态属性,一旦进程退出,该文件会被系统自动删除。
我们来查询一下我们运行中的进程文件中有什么内容。
我们可以发现里面有一大堆的文件,这些文件就是我们当前文件运行的所有属性。我们现在主要来看这两个文件。
我们每一个进程都会有一个 exe 文件,它会记录下来我们进程对应的可执行文件的绝对路径。当我们把文件删除后可以发现它的程序还是在运行。本质是因为我们删除的是它磁盘上的文件,但是这个进程在运行所以进入了内存中,已经拷贝到内存了。当我们再次查询我们的进程属性时会发现。
我们的 exe 文件变红了,这就说明了我们的进程虽然还在,但是可执行程序已经被删除了。
我们再来看另一个文件 cwd。cwd 全称 current work dir,cwd 会记录进程是在哪个路径启动的。也就是进程的当前路径。
当然,如果我们想要去更改当前路径也不是不可以,只要使用chdir指令即可。
int main()
{chdir("/home/zxl");while(1){sleep(1);printf("我是一个进程! 我的 pid: %d\n", getpid());}return 0;
}
我们可以发现路径已经被更改了。
了解完了进程的 PID,我们再来说说 PPID 也就是父进程 ID。Linux 系统中我们所有的子进程都是由我们的父进程所创建的,没有母进程。我们的父进程可以有多个子进程,而我们的父进程也有它的父进程,所以我们的进程本质上是一个进程树形式呈现的。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{while(1){sleep(1);printf("我是一个进程!我的pid: %d, 我的父进程: %d\n", getpid(), getppid());}return 0;
}
我们可以发现,它和进程不同的地方,进程每次都会分配不同的 pid,但是父进程却都是一样的。所以我们的父进程是谁啊。我们用指令 ps axj | head -1 && ps axj | grep 父进程 ID | grep -v grep 来查询一下。
我们发现每一次启动我们父进程的都是 bash(命令行解释器),所以说 bash 本质上也是一个进程,我们的父进程就是 bash。
知识点:
OS 会给每一个登录用户分配一个 bash,-bash 表示远程登录。
六、通过系统调用创建进程 -fork初识
代码创建子进程的方式!fork()
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{printf("父进程开始运行! pid: %d\n", getpid());fork();printf("子进程开始运行! pid: %d\n", getpid());return 0;
}
在这串代码执行前我们只有一个执行流,但是当我们代码执行到 fork() 后就变成了两个执行流,且都会执行后续的代码。所以我们的第二个 printf 应该会被打印两次,而且打印的 ID 都不一样。
原理:
进程 = PCB + 自己的代码和数据;创建了一个子进程本质是系统里面多了一个进程,必定要在操作系统内创建一个子进程的PCB。子进程在创建时,会把父进程的部分属性拷贝一份,所以子进程会执行父进程后的代码,也就会出现两个 printf。
由于程序没有新加载,所以子进程就没有自己的代码和数据,都是拷贝父进程的代码和数据。
返回值:
- 成功:将子进程的 PID 返回给父进程,将 0 返回给子进程。
- 失败:返回 -1 给父进程,子进程不返回。
也就是说 fork 有两个返回值。如果想让父子进程运行不同的逻辑,我们可以通过返回值来改写我们的代码。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{printf("父进程开始运行! pid: %d\n", getpid());pid_t id = fork();if(id < 0){perror("fork");return 1;}else if(id == 0){//child;while(1){sleep(1);printf("我是一个子进程!我的pid: %d, 我的父进程: %d\n", getpid(), getppid());}}else{//father;while(1){sleep(1);printf("我是一个父进程!我的pid: %d, 我的父进程: %d\n", getpid(), getppid());}}printf("子进程开始运行! pid: %d\n", getpid());return 0;
}
运行后我们可以看到父进程和子进程分别进行不同的代码块。
所以说在这个代码运行后,我们父和子进程的代码都是共享的,只是返回值不同导致运行不同的代码块。
这个时候就会产生一些问题。
- 为什么 fork 给父子返回各自不同的返回值?
给我们的子进程返回 0 而给我们父进程返回子进程 pid 的原因是由于我们父进程可以拥有很多子进程,而我们子进程只能有一个父进程,也就是父:子 = 1 : n。所以为了父进程方便管理,就返回子进程 pid。
- 为什么一个函数会返回两次?
一个函数已经到了 return 了,说明这个函数的核心功能已经做完了。本质上,在 fork() 函数运行完毕 return 时,我们的子进程已经被创建,甚至已经被调度了。由于 return 本质也是一个语句,所以此时我们的父进程和子进程都运行到 return 时都 return,此时就返回了两次了。
- 为什么一个变量既 == 0,又 > 0,导致 if 和 else 同时成立?
当我们的子进程的程序块没有程序新加载时我们的子进程默认和父进程共享一个代码和数据,但是当我们的父进程挂掉了或者子进程进行更改了,那我们 OS 就会进行写时拷贝,把子进程和父进程的数据独立出来。我们对刚才的代码进行略微修改
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int gval = 100;
int main()
{printf("父进程开始运行! pid: %d\n", getpid());pid_t id = fork();if(id < 0){perror("fork");return 1;}else if(id == 0){//child;printf("我是一个子进程!我的pid: %d, 我的父进程: %d, gval: %d\n", getpid(), getppid(), gval);sleep(5);while(1){sleep(1);printf("子进程修改变量: %d->%d\n", gval, gval + 10);gval += 10;printf("我是一个子进程!我的pid: %d, 我的父进程: %d\n", getpid(), getppid());}}else{//father;while(1){sleep(1);printf("我是一个父进程!我的pid: %d, 我的父进程: %d, gval: %d\n", getpid(), getppid(), gval);}}printf("子进程开始运行! pid: %d\n", getpid());return 0;
}
可以看出,无论子进程怎么更改,我们的父进程都是 100 不变,也就是说,我们如果把父子任何一方进行数据修改,OS 把被修改的数据在底层拷贝一份,让目标进程修改这个拷贝!
所以说我们 fork 函数在返回时,本质就是写入变量,我们的进程发生写时拷贝,所以可以同时满足。
具体是如何,等到讲虚拟地址是才能说清。
结论:进程具有独立性(无论是子进程还是父进程挂了,都不会对另一方产生影响)。
总结
以上便是我们进程的基本概念,我们大致上是明白了我们的进程是属性 + 数据与内容,我们之后的学习,就会开始从进程的属性着手去完善我们的进程的概念。我们下一章节再见。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど