深入篇【Linux】学习必备:进程理解(从底层探究进程概念/进程创建/进程状态/进程优先级)
- 一.进程概念(PCB/task_struct)
- 二.查看进程(top/ps)
- 三.创建进程(fork)
- 四.进程状态(僵尸进程/孤儿进程)
- 五.进程优先级(PRI/NI)
一.进程概念(PCB/task_struct)
1.什么叫进程呢?
一个已经加载到内存中的程序就叫做进程(任务)。
2.对于一个进程,操作系统是如何管理的呢?
先描述,再组织
任何一个程序在加载到内存时,形成真正的进程是,操作系统要先创建描述进程属性的结构体对象,即PCB。
因为我们都是从属性来认识事物的本身的,当属性够多,这一堆属性的集合,就是目标对象。所以描述进程,当然是要描述这个进程的各种属性。
3.进程=内核数据结构(PCB)+自己写的代码和数据。
①进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。在OS操作系统下这个进程控制块我们称为PCB。
②而在Linux下我们称为task_struct。里面包含了进程的所有属性,最基本的组织进程task_struct方式是采用双向链表组织。
4.进程属性都有哪些呢?
PCB或task_struct结构体的内容是什么呢?我们可以将它们分类成下面一些:
①标识符:用来描述本进程的唯一标识符,可以区别其他进程。
②状态:进程的状态是如何,是在任务状态还是在退出状态。
③优先级:进程之间是有优先级的,用来竞争CPU。
④程序计数器:程序中即将被执行的下一条指令的地址。
⑤上下文数据:进程执行时处理器的寄存器中的数据。
⑥内存指针:包括程序代码和进程相关数据的指针。
⑦I/O状态信息:包含显式的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
5.进程被描述完后是如何组织起来的?
在内核源代码中可以找到它,所有运行在系统里的进程都以task_struct链表的形式存着内核中。
二.查看进程(top/ps)
1.进程的信息可以通过/proc系统文件来查看。(进程信息都放在/proc文件中)
2.查看进程还可以用top和ps这些指令获取。
比如查看test进程信息:
比如我创建一个程序test,运行起来后这个程序加载到内存中就变成进程了。所以我们就可以通过ps指令来查看这个进程的属性。
3.我们还可以通过系统调用来获取进程的信息,比如进程的标识符。
进程id(PID)
获取自己进程的pid可以使用系统调用函数getpid().
父进程id(PPID)
获取自己父进程的ppid可以使用系统调用函数getppid().
这个进程的pid是27734这个是没有问题的(上面是27733,下面是27734其实是一个进程因为我把这个进程中断了,然后又运行起来了,最后pid不一样了,所以我们可以看出来每次运行时pid可能不同,但ppid却相同),我们可以查看一下这个pid所对应的进程。不过我更想知道这个进程的父进程是谁呢?等会我们可以查看一下。
我们可以清楚的看到27734是对应着./proc这个程序的。而它的父进程是bash。
所以我们可以明白,这个进程是由bash创建的,我们写的程序都是由bash创建进程运行的。而bash只负责创建进程,而子进程坏不坏跟它没有关系,它是不管的,所有它的ppid不变。
三.创建进程(fork)
1.我们手动输入命令,让程序加载到内存变成进程。这个方式是由bash创建进程的。
2.我们还可以通过系统调用fork创建进程,即在程序运行时创建进程。而要使用fork()创建进程,需要理解以下几点。
fork()函数—>创建进程
特点:①有两个返回值。当进程创建成功后,会将进程pid返回给父进程,将0返回给子进程。②fork之后通常要用if进行分流。
1.为什么fork()要返回不同的返回值?
返回不同的值,是为了让父子进程执行不同的代码块。父进程为了取反不同发子进程,通过pid控制子进程。
2.为什么要创建子进程呢?
为了让父和子执行不同的事情!而实现这样的方式就是返回两个返回值。
3.一个函数是如何做到有两个返回值的呢?
fork函数的主要功能就是创建子进程,进程创建成功后,也就是系统中多了一个PCB。但这个PCB并没有数据和代码。所以系统会让这个子进程共享父进程的代码。理论上数据也要共享的,但是进程之间是相互独立的,各自互不影响,所以要求父子进程不能共享同一份数据。子进程是重新开辟一块空间,将父进程的数据拷贝一份的,但这样的做法有点低效,因为子进程并不一定全部访问父进程的数据,这样就会占用内存。所以这里采用的方法是写时拷贝(用多少拷贝多少)。就是当子进程想访问父进程某个具体数据时,那就临时拷贝出来。
而fork函数里,在返回返回值之前,子进程就已经创建好了,所以这个子进程是共享fork函数的代码。也就是父进程会返回一次,子进程也会返回一次,所以最后返回两次。
4.一个变量为什么可以存不同的值?
这个涉及程序地址空间。将返回值返回给数据这个过程是在写数据,而根据上面的子进程数据是重新开辟一块空间,临时拷贝的。
所以这时候将返回值返回到不同的内存空间,父进程访问的是老数据空间,子进程访问的是一个新的拷贝空间。
5.bash是如何创建子进程的?
在理解fork之后我们肯定可以想到,bash创建子进程肯定用到了fork函数。一个进程用来打印命令行,一个进程用来创建。
1 #include <stdio.h>
2 #include <unistd.h>
3 int main()
4 {
12 //fork如果创建成功,会将当前进程的pid传给父进程,将0传给子进程。如果失败 ,则传返回-1;
13 printf("begin,我是一个进程,我的pid是%d,ppid是%d\n",getpid(),getppid()) ;
14 pid_t id= fork();
15
17 if(id==0)//子进程
18 {
19 while(1)
20 {
21
22 printf("我是子进程,我的pid是:%d,ppid是:%d\n",getpid(),getppid());
23 sleep(1);
24 }
25 }
26 else if(id>0)//父进程
27 {
28 while(1)
29 {
30 printf("我是父进程,我的pid是:%d,ppid是:%d\n",getpid(),getppid());
31 sleep(1);
32 }
33 }
34 else//创建失败
35 {
36
37 }
38 return 0;
39 }
我们可以发现父进程就是当前正在运行的进程。而当前正在运行的进程的父进程就是bash。
四.进程状态(僵尸进程/孤儿进程)
想要理解一个正在运行的进程是什么意思,我们首先要知道进程的不同状态。一个进程可以多个状态。
在操作系统学科中主要有运行状态,阻塞状态,挂起状态。
1.运行状态
通常来说,一个进程在CPU上使用资源叫做运行状态,而现实是操作系统中有很多想要运行的进程,这些进程用链表组织起来,而每个CPU都会维护一个叫做运行队列,当进程想要运行时就将自己链入到运行队列中即可。而在运行队列中的进程就可以是运行状态了。
那进程如果没有完成任务,会一直在CPU上运行吗?当然不会!每个进程都有一个叫做时间片。在运行一定时间后就会退出CPU,等下次再运行,这样就不会存着一个进程一直在CPU上跑了。也说明在一个时间段内,所有进程都会被执行一遍,这个行为叫做并发执行。
而这一过程,必定存着许多行为:把进程放上CPU,从CPU中放下进程,这一过程叫进程切换。
2.阻塞状态
一个进程在等待某种资源时就是在阻塞状态,比如当一个进程想要获取从键盘上输入的数据时,但键盘就不输入时,这个进程就得不到数据,就要一直等键盘输入数据,这时就是阻塞状态。操作系统在管理硬件资源时,也是采取先描述,再组织的方法,将每个硬件资源都描述成一个结构体对象。而且每个结构体对象里面都有一个等待队列。当一个进程在等待这个硬件资源时,就会将这个进程链入对应的等待队列中,当有多个进程等待时,就将这些进程都链入队列中,直到进程进入就绪状态才会将进程直接链入运行队列中。
3.挂起状态
要理解当一个进程处于阻塞状态时,它的数据和代码是没有使用的,但还是占用着内存资源,当操作系统内存资源严重不足时,操作系统会将处于阻塞状态的进程对应的代码和数据放入磁盘里,只保留着内核在等待队列中等待。这个进程就是挂起状态。当进程进入运行队列时,操作系统才考虑将对应的数据从磁盘中再换去到内存中。
各种进程状态的本质就是决定当前进程的PCB在哪个队列里排队!!!
以上都是操作系统学科关于进程状态的一些介绍理解,而下面则是Linux中进程状态是如何理解的。
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
R:运行状态,但并不意味着进程一定在运行中,它表明进程要么在运行中要么在运行队列里。
S:睡眠状态(浅度睡眠)意味着进程在等待着某件事完成。这个进程一定在等待某种资源。
D:休眠状态(深度睡眠)这个进程在等待过程中是不可以被调度的,不响应任何需求。让进程在等待磁盘写入完毕期间,这个进程是无法被杀掉的。在这个状态的进程通常会等待IO的结束。
T:停止状态,可以通过发送信号让进程停止即这个进程进入T状态,这个暂停的进程还可以通过发信号继续运行。
X:死亡状态。这个状态只是一个返回状态,不会任务列表中看到。
4.僵尸进程
【特点】:
当一个进程结束后并不会立刻销毁,而是会等待父进程来"访问"它,当父进程给出回应后这个进程才会真正的结束。
进程一般退出的时候,如果父进程没有生成回收子进程的信号,子进程就会一直让自己处于Z状态,即僵尸状态,进程的相关资源尤其是task_struct结构体就不能被释放。
所以当进程退出并且父进程没有读取到子进程退出的返回代码就会进入僵尸进程,僵尸进程会一终止状态保持在进程表中,并会一直等待父进程读取退出状态代码,这个状态会一直占用资源,会造成内存泄漏!
【危害】:
当父进程不读取进程的退出代码时,进程就要一直维持退出状态,而维持退出状态需要数据维持,而这个数据就在task_struct结构体对象里里,所以当进程退出状态一直维持,那么对象就无法被释放,那么就会一直占用内存资源,当父进程创建很多子进程,并且就不读取子进程的退出代码,就会造成大量的内存泄漏。
5.孤儿进程
当父进程提前退出,子进程想要退出时,肯定会进入Z状态,那这个子进程该怎么办呢?
当父进程比子进程先退出,则这个子进程就会变成孤儿进程,那这个子进程就无法退出了吗?并不是!
这个进程会被操作系统’‘领养’'即一号进程。最后这个孤儿进程会被操作系统回收。
五.进程优先级(PRI/NI)
输入命令 ps -l 后可以看到:
PRI代表着这个进程的优先级,其值越小,优先级越高,越先被执行。
NI代表这个进程nice值,nice是用来修正优先级的。
1.nice是如何修正优先级的呢?
PRI(new)=PRI(old)+nice. PRI(old)一直都是80.
2.nice值的取值范围是[-20,19]而进程的优先级范围就是[60,99],所以进程有40个优先级可以调度。
3.可以使用top命令来更改进程的优先级
输入top命令后再输入r,会要求你输入要修改进程的pid,输入完后,会要求你输入要修改的nice值是多少
4.操作系统是如何根据优先级来先后调用进程的呢?
我们知道CPU维护着一个叫运行队列的东西,运行队列中其实有两个指针数组,一个是run数组,一个是wait数组,run数组里放的是进程地址,数组开辟140个空间,其中前100个空间是给不同的进程使用,我们不用管,而后40个空间是用来存放进程地址。
数组的位置和进程的优先级是一一对应的,因为进程优先级有40种,这里也有40个空间,所以100位置上链入着优先级为60的进程地址,139位置上链入的是优先级为99的进程。
所以可以根据数组下标的不同,从上到下遍历出来的PCB就是根据优先级调度的进程。
而当有新的进程想要运行时,并不会再进入run数组里面的队列中,而是会进入wait数组的对应优先级的队列中。
运行队列里还有两个二级指针,一个是run一个是wait,run就一直指向要运行的数组,wait就一直指向新来的进程的数组。
当run中的进程都被调度完后,就会将run和wait交换。这样新来的进程又可以被调度了。
而操作系统是如何知道数组中进程是否被调度完呢?这个根据位图来实现的!
总结:
①在运行队列中有两个指针数组。
②在这个队列中的进程都是R状态。
③调度优先级的本质:把PCB链入到对应队列的子队列那一个下标中,调整优先级的本质就是调整PCB在这个数组中的顺序。
④所以在调整优先级的同时会改变PCB的存放位置,位置不同就表明优先级不同。