我们已经知道,操作系统进行“管理”的本质是——先描述、再组织。描述是一个面向对象的过程,组织是使用数据结构的过程。而进程也是被操作系统管理的,操作系统也要对其先描述,后组织。
描述无疑就是将进程的各种信息存储在一个结构体里面,描述好之后,将描述进程的结构体用链表(也可能是其他)维护,所以查找、删除进程等等的操作,就变成了对链表的增删查改。所以,管理者实际上不需要和被管理者进行直接交流,只需要对被管理对象的数据做管理!!
操作系统对进程的管理也和上面一样,要“先描述,后组织”。
当我们打开QQ、微信 ,是为了和朋友聊天,刷朋友圈等;打开vscode,是为了进行编程或者其他操作……任何启动并运行程序的行为,操作系统都会将程序转换为进程,从而完成特定的任务!!!
如下,磁盘中有某个可执行文件(由于是在磁盘中,所以该文件关机再开机还是保存着的)。可执行文件本质上还是一个文件,也有文件的内容和属性。当我们在Linux 环境下使用 ./a.out 的时候(假设该文件名为 a.out),该文件对应的代码和数据(文件的内容)就会被加载进内存,所以CPU才能访问该文件的代码和数据。
但是,这样就算创建一个进程了吗?
答案是否定的。以学校为例子,如何才能成为一个学校的学生,只要进入学校,在学校自由行走就算是学生了吗?当然不是啦,不然学校的保安、保洁阿姨、参观人员岂不都是学生。是不是学生,最重要的就是学校是否对你进行“管理”——即学校是否拥有你的学籍档案,你的学生信息是否在学校的教务系统里面。
所以,创建一个进程,不仅要将代码和数据加载到内存里面,还要将进程的一些信息存储在结构体里面,并用某种数据结构进行维护,让操作系统进行管理。
如下图,我们绝大多数情况下,都不会只运行一个程序,所以也不会只创建一个进程。用来描述进程的结构体,在Linux环境下被称作 task_struct (早期的进程被称作任务),当然,也可以称它为pcb。每创建一个进程,就会创建一个对应的pcb存储进程独有的一些信息。然后将这些pcb以链表的形式进行维护。
比如现在有一个进程A,要对里面的数据进行修改,那么就会遍历链表,找到进程A对应的pcb,然后通过pcb对其数据进行修改。又或者,现在要运行优先级最高的进程,那么操作系统就会遍历pcb链表,找到优先级最高的进程(pcb里面包含进程的所有属性,当然也包含优先级),确定该进程要被调度,通过pcb找到该进程的代码和数据,将它的入口代码放到CPU里面,这样该进程就被运行了。
所以,对进程的管理,并不是直接对代码和数据进行管理,而是将代码和数据提取出一些特定信息,存入task_struct 结构体里面,成为一个节点,再将节点存入链表,从而管理链表。这样,对进程的管理实际上就是对pcb形成的结构体的增删查改,这样就完成了对进程管理的建模的过程。
综上所述,进程 = 当前进程的代码和数据 + pcb/task_struct
当然,上述都是对软件进行管理的解释,但是,操作系统对硬件进行管理也是类似的。
pcb提取了进程的属性,这个进程的属性和文件的属性有一点点关系,并不大。文件的属性存储的是:这个文件是谁创建的,什么时候创建的,有什么权限等等。但是为进程创建的pcb结构体,是一种内核数据结构,是由操作系统动态创建和维护的,和磁盘文件的属性没有关系。
所以,pcb里面的属性相当于是另起炉灶,里面的属性基本上和文件属性无关。
task_struct 存储的一些信息:
ps axj | head -1 && ps ajx | grep myprocess | grep -v grep
上述指令是查看自己创建的进程。
如下是系统中所有的进程,ps axj | head -1 是将第一行拿出来,即 PPID 、PID 等等。 PID (process id)表示进程的唯一标识,PPID表示该进程的父进程的唯一标识……这样方便查看各列信息代表上面意思。
&& 是逻辑与,相当于前面的指令执行成功,然后执行后面的。
ps ajx | grep myprocess 代表的是,选出自己创建的进程(可执行文件名要为myprocess,也可以根据自己的文件名修改)。
grep -v grep ,由于进程信息是 ps 打印出来,然后通过管道传给 grep ,由 grep 进行过滤,但是 grep 本身也是一个进程,所以 grep -v grep 是将 grep 的进程信息去掉。
如下图,是上述指令执行结果。我们可以看到,同一个可执行程序被执行了两次,但是其PID是不一样的,说明创建了两个进程。也就是说,如果一个可执行程序可以多次被加载到内存,那么该可执行程序内部就可以有多个进程。
ls /proc
根目录下的 proc 目录,这个目录和普通的目录并不一样。proc 是一个内存级的文件系统,只有当操作系统启动的时候,该目录才会存在,在磁盘上并不会存在这个目录。
ls -l /proc ,使用该指令查看到的信息如下,proc 目录下面也是一个个目录。目录名字就是特定进程的PID。但是用这个指令查看进程并不直观。所以一般用 ls /proc 。
如下,使用 ls /proc 查看进程就非常直观了。
当然,由于 /proc 目录以及它目录里的内容,是操作系统动态创建的,所以,当一个进程终止之后,它在 /proc 里面对应的文件夹也会被删除。
如下图,当PID为 28201 的进程还在运行的时候,是可以进入 /proc/28201 的,该目录下面存储的是 28201 这个进程的相关信息。但是使用 ctrl+c 终止进程之后,就看不到该目录下任何信息,甚至无法返回上级目录。这是因为这个目录已经被删除,/proc 下没有 28201 为目录名的文件了。
pid() 函数是获取当前进程的 pid,ppid() 函数是获取当前进程的父进程的 pid 。
如下,linux环境下执行下列的C语言代码,其运行结果如下图。
1 #include 2 #include3 int main()4 {5 while(1)6 {7 printf("我是一个进程,我的pid是:%d,我的ppid是:%d \n",getpid(),getppid());8 sleep(1);9 }10 return 0;11 }
./myprocess 这个进程的父进程 28071 ,实际上代表的是命令行解释器,也叫 bash,它本质上也是一个进程。我们在 linux 下敲的命令 ,比如 ls ,ll,grep 等等,都是由 bash 创建子进程去实现的,当然这些命令大部分也是由 C 语言实现,我们自己也可以写类似的命令,只是需要配置PATH(后面的文章会解释)。
这样设置是有很多好处的,比如,上述的这些指令,如果不是由 bash 创建子进程去完成,而是由 bash 本身完成,那么如果运行失败,bash 也就挂了。但是如果是子进程挂了,那么也不影响 bash 的运行。
如果不想用上述的两种方法创建子进程(./myporocess 、 指令),可以使用 fork() 在代码层面上直接创建新的子进程。可以使用 man 手册查看具体细节。
1 #include2 #include3 int main()4 {5 printf("AAAAAAAAAAA:\n");6 fork();7 printf("BBBBBBBBBBB:pid:%d,ppid:%d \n",getpid(),getppid());8 sleep(1); 9 return 0;10 }
运行上述代码,查看结果如下,说明 fork(); 那一行之后,确实是有两个进程。并且还是父子进程,进程 29333 的父进程 是 29332 。
但是,打印 A 是由哪一个进程执行的呢? 修改代码并编译运行之后,如下图,可以看出打印 A 是由父进程执行的。
我们可以查看一下上面进程 29758 的父进程 28071 ,它实际上就是 bash 。调用链就是:当运行 ./myprocess ,该程序就成了 bash 的子进程, 然后 ./myprocess 这个进程又创建一个子进程。其实就类似于一个树状结构。当然,进程之间一般只谈父子,不谈爷孙。
此外,父子进程谁先运行,这是由操作系统的调度选择算法来决定的,我们并不能决定。
#include
#include
#include
#includeint main()
{pid_t ret=fork();if(ret == 0){while(1){printf("我是子进程,我的pid是:%d,我的父进程是:%d \n",getpid(),getppid());sleep(1);}}else if(ret > 0) {while(1){printf("我是父进程,我的pid是:%d,我的父进程是:%d \n",getpid(),getppid());sleep(1);}}return 0;
}
执行上述代码,结果如下。但是这里惊奇地发现,if 和 else if 居然同时成立了!!!两个while循环也同时执行了!!!
但是不用惊讶,我们先从上面创建子进程的样例中总结一点东西。
前面说到,进程 = 内核数据结构 + 代码和数据 ,所以,fork 创建一个子进程,也必然要 创建对应的pcb 和 加载代码和数据到内存。但是,实际上只需要创建 pcb 即可,子进程的代码和数据是使用父进程的。
如下图,蓝色方框代表父进程的pcb,其代码和数据放在右边的黄色方框(两层来区分代码和数据)。创建子进程之后,只创建了子进程的 pcb ,这个 pcb 绝大部分信息和 父进程的pcb 是一样的,少数不同(pid,ppid 等等),然后用链表连起来。子进程的pcb 里面的指针,指向的代码和数据和父进程的是同一块。
同时,进程的运行是具有独立性的!就好像我们同时打开 浏览器、微信、qq、迅雷,随便关掉一个,并不影响其他的使用。父子进程也是两个进程,所以也具有独立性!从下图可以看出,杀掉子进程,父进程依然在执行。
可是,既然进程是互相独立的,父子进程都有各自的 pcb ,这当然没话说,但是却共用同一份代码和数据?这如何体现独立性呢?
对于代码而言,代码是只读的,无论父子进程都无法修改,所以可以说是独立的;但是对于数据而言,怎么证明它是独立的呢?
我们可以通过下面的代码来证明。先设置一个变量 x ,在一个执行流中,不改变 x 的值,输出其值和地址;在另一个执行流中,改变其值,输出其值和地址。
#include
#include
#include
#includeint main()
{int x=100; pid_t ret=fork();if(ret == 0){while(1){printf("我是子进程,我的pid是:%d,我的父进程是:%d,x的值是 %d ,地址是: %p \n",getpid(),getppid(),x,&x);sleep(1);}}else if(ret > 0) {while(1){printf("我是父进程,我的pid是:%d,我的父进程是:%d,x的值是 %d ,地址是: %p \n",getpid(),getppid(),x,&x);x=666;sleep(1);}}return 0;
}
编译运行之后,结果如下,可以看出,当一个进程修改 x 的值之后,并不会对另一个进程产生干扰。
对于数据而言:当有一个执行流想要修改数据的时候,操作系统会自动给该进程触发写时拷贝
。写时拷贝就是,当想要写入数据的时候,将这份数据拷贝到另一个地方,将写入的数据放到新的空间,不影响原来的空间。(当然这只是简单概念,具体信息要了解进程地址空间)
但是,变量x 打印出来的地址却是一样的,即使按照写时拷贝的说法,也说不通呀。那么只有一个理由,这里的地址不是物理地址(实际上是进程地址空间 / 虚拟地址)。
当函数内部准备执行 return 的时候,函数的主体功能是已经完成了的。
fork 它本质上就是操作系统提供的一个函数,提供接口给外部使用,创建子进程。当 fork 函数执行到return 语句的时候,主体功能已经完成,子进程已经被创建,共享下面的代码。而return 也是语句,所以 return 既要被父进程使用,又要被子进程使用。相当于return了两次。
至于一个变量存储了两个值,实际上是写时拷贝。