Linux-进程4
目录
1.创建多进程
在进程3中有个函数fork,它能创建一个子进程,我们将继续了解fork相关性质。
首先我们创建一个fork.c文件和Makefile文件:

然后我们在Makefile里写以下东西:

我们给fork.c写上如下代码:
#include<stdio.h>
#include<unistd.h>
//创建一个多进程
const int num = 10;
int main()
{
for(int i=0;i<num;i++)
{
pid_t id = fork();
if(id == 0)
{
//子进程
while(1)
{
printf("我是子进程,pid=%d,ppid=%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
//父进程
printf("子进程被创建,pid:%d\n",id);
}
sleep(1);
}
while(1)
{
printf("我是父进程,pid=%d,ppid=%d\n",getpid(),getppid());
sleep(1);
}
return 0;
}
我们来分析一下这个代码,从程序开始执行,到pid_t id = fork()这一行会创建一个子进程,此时就有了两个进程,子进程会进入if语句的死循环,循环打印:我是子进程,我的pid,ppid;而fork给父进程返回了子进程的pid,因此会执行else语句,先打印完:子进程被创建,pid;然后休眠1秒后再循环,先创建一个子进程……当把for循环创建完num个子进程后,父进程会退出for循环,进入死循环打印:我是父进程,pid,ppid。
有一个问题,父进程和子进程谁先执行是不确定的。
结论:当我们创建出子进程之后,父子两个进程,谁先运行,不确定,由OS(操作系统)根据调度原则来确定!
但是这个谁先执行并不影响我们理解,只需要记住就行(后续可能谈到进程优先级等概念,等讲完应该理解了)。
此时我们新打开一个机器,然后执行以下指令:
ps ajx | head -1 && ps ajx | grep fork
如果正常的情况下结果应该为:

但是因为我们有一个系统调用叫fork,如果这样的话就会多出一个干扰项,因此,我们把指令改为:
ps ajx | head -1 && ps ajx | grep myfork
此时就是正确的结果:

为了能动态看到代码运行时的变化,我们需要每隔一秒循环执行上述指令,因此把指令改为:
while :; do ps ajx | head -1 && ps ajx | grep myfork; sleep 1; done
此时就能每隔一秒查找myfork进程了!

如果想要终止这个指令,只能用Ctrl+C终止,不然直接把Xshell关闭这个指令也会在后台运行这个进程!
我们在tan账号下make后./myfork有:

直到最后:

我们可以发现:父进程并不是第一个被执行,也不是最后一个被执行,这个和操作系统有关!
最终会有11个进程运行!(除了最后一行)
因此未来我们可以用这种方式来创建多进程!(Ctrl+C可结束进程)
2.进程状态的说明

什么叫做进程状态?
比如说,我今天状态不好,我想睡觉;我今天状态很好,我就想学习。∴状态决定了我接下来要做的工作。也就是说,状态决定了进程接下来要做的工作。而你要做什么工作是由你的状态来决定的!这就好比一个进程要被运行,那就需要CPU的调度。换句话说,我们学习进程的状态,操作系统就会根据你当前的状态来决定你这个进程要做什么工作。
因此,我们学完进程状态后,就要学习进程接下来要干的事(要做的工作)!
所以什么叫做状态?
朴素的理解:对于一个进程来讲呢,进程在内核中最核心的是它的task_struct,因为描述一个进程就是用task_struct结构体来描述的!而不管什么操作系统,所谓的状态在task_struct里就是一个整数,类似:
#include<stdio.h>
struct task_struct
{
int status;//描述进程的状态的变量
};
其实就是一个数字,如果为running状态就设置这个数字为1,设置阻塞状态为2……:
#define RUNNING 1
#define BLOCK 2
将来我们只要把这些状态的宏值设置到PCB的整型变量里(status),在系统层面上,就用来标识你的进程处于什么状态了!状态就是一个整数,不要理解得太复杂了!
传统操作系统教材里面,进程状态的说明:

有:创建状态,就绪状态,运行状态,阻塞状态,阻塞挂起状态,就绪挂起状态,结束状态。而这么多状态就对应不同的宏值!所谓让进程状态发生变化,本质就是修改当前task_struct的整数值(修改status)来达到改进程状态的目的!(修改PCB内部的整数)从而决定未来要做的工作!
而上面这个图如果直接查看是基本上不理解的!而这个图是操作系统理论图,所有操作系统在实现进程状态变化的时候,都要符合上面的理论!即这个图是:具体实现操作系统时,指导思想是什么!未来写软件时也是根据图来确定指导思想。光看这个图,只有指导思想,没有落地方案(不会告诉你这个状态具体是怎么实现的),因此看不懂也是很正常的!
3.运行状态
由冯·诺依曼体系结构我们知道,计算机硬件分为五大单元:输入单元,输出单元,运算器,控制器,内存。输入、输出单元我们统一称之为外设,运算器、控制器我们称之为CPU,不管是外设还是CPU,本质上就是计算机里的资源,而我们对应的进程本质上就是在竞争资源,本质就两类资源:①外设资源;②CPU资源。
我们写的代码无非就两类:①计算密集型:算法、数据结构代码,这类我们经常要使用CPU资源;②IO密集型,主要竞争外设资源。
我们现在要谈论的就是竞争CPU资源的那一类!
进程在大部分情况下都要保证自己能够竞争CPU资源,而我们在操作系统内部,所有进程都要先描述,再组织,所以每一个进程都要有它们自己的PCB和自己的代码和数据,而自己的PCB将来会想办法让我们找到对应的代码和数据,这叫做:struct task_struct。未来我们要把所有进程进行管理,那我们就必须把所有进程都用双链表把所有的PCB链接起来!因此我们就有了:管理所有进程的全局双链表!这个时候我们对进程的管理就转化成了对链表的管理(增删查改)。
既然所有的进程都要竞争CPU资源,那么在大部分操作系统内部都要给每一个CPU设置一个调度队列!(在内核当中)每一个CPU在操作系统都要有调度队列,而每个计算机可能有多个CPU,因此就有多个调度队列同时存在,因此操作系统要对调度队列本身进行管理,怎么管?先描述,再组织!也就是说操作系统内要有一种叫做:runqueue的结构体,而这个结构体可能包含队列的所有属性:
//管理调度队列的结构体
struct runqueue
{
//队列属性
int num;//有多少个进程
struct task_struct* t;
};
未来的CPU要调度,它不是直接去管理所有进程的全局双链表去调度,你的进程要竞争CPU资源,你得把你的进程的PCB链入到队列当中,我们把这种行为叫做让进程在CPU的调度队列当中进行排队!而排队时,此时进程的状态叫做r状态(run我们称为运行状态)
运行状态的定义:一个进程处于运行状态,该进程的PCB必须处在CPU的调度队列中,此时叫做r状态(运行状态)。只要在调度队列中,进程就叫做运行状态。它表明:进程当前已经准备好,随时等待CPU进行调度执行!!!
操作系统中,task_struct是如何用双链表管理起来的?
此时我们就需要看Linux内核源码了!
4.Linux内核中实现进程状态的管理
在Linux内核源码中我们搜索struct task_struct有:

传统意义上我们学的数据结构:
//双链表
struct Node
{
int data;
struct Node* next;
struct Node* prev;
};
这些双链表的next和prev指向的都是结点类型(Node),但Linux内核不是这样实现双链表,它会类似这样实现:
//Linux的模拟实现双链表
struct list_node
{
struct list_node* next;
struct list_node* prev;
};
注意:这个list_node并不包含任何数据,只包含了next和prev,然后再在task_struct里面内置一个list_node,类似:
struct task_struct
{
//所有属性
struct list_node node;
};
而这个node是task_struct内部的一个变量,里面包含了prev和next指针!
而不是这样:
struct task_struct
{
struct task_struct* prev;
struct task_struct* next;
};
这样未来你的next不是指向下一个task_struct,而是指向下一个node,prev指向前一个node。
那么在真正的Linux内核当中真的是这样吗?

list_head就是我们刚刚的list_node,我们转到定义有:

所以在Linux内核中的tasks其实就是我们刚刚说的例子中的node!
但是这样指向的话,我们tasks指向的是下一个task_struct的tasks的其实地址,相当于我们只知道task_struct中某个变量的起始地址,可是我如果想要访问task_struct的其他属性呢?也就是说:我们只知道结构体内任意一个成员的起始地址以及知道当前结构体对应的类型(知道为task_struct)
①如何得到该结构体变量的起始地址?
②如何访问该结构体变量内部的任意一个属性?
如果我知道数组中某个位置的地址,那我们就能很容易知道该数组的起始地址,本质就是拿我们的这个知道的地址-偏移量就是该数组的起始地址!
结构体也类似,只不过结构体是聚合数据类型(把它想象成一个可以存放任意多个任意类型的数组),因此想要解答第1个问题,我们需要把偏移量算出来!
在一个数组里面,数组的数字只有一个数字,但对数组取地址,它取得只是一个数字,但不是说好了,每个字节都有地址吗,不是说好了CPU访存的基本单位是字节吗,一个整数有四个字节应当有四个地址,但往往对一个数字取地址,往往取的是一个字节的地址,哪一个?
是它开辟空间时,地址空间最小的那一个,即地址最小的字节的地址,所以在C语言当中都是最小的字节地址,不管任意类型都是一样的,所以才要有类型!-----> 所以类型决定大小!
而类型就是某一个变量的偏移量。
那么怎么得到偏移量?
假设有个数字0,我把0强制转化成struct task_struct*类型:
(struct task_struct*)0
这个就相当于一个指针,假设这个task_struct有个变量c,如果想要知道变量c的地址,则变量c的地址可以表述成:
&((struct task_struct*)0->c)
这就相当于我们在地址0定义了一个task_struct变量,这个结构体变量地址从0开始的!那这个偏移量就相当于在0地址处定义了task_struct变量时c的地址!换句话说,c相对于task_struct起始地址的偏移量就是c此时的地址!(c变量在结构体中的偏移量)
但是实际上,我不是在0地址上定义的啊,所以此时我们把已知的c变量的地址实际为&c,实际的结构体变量的起始地址为:
&c - &((struct task_struct*)0->c)
所以①答案:&c-&((struct task_struct*)0->c),②答案,如果想知道a的地址,a地址为&((&c-&((struct task_struct*)0->c)->a)
转化到task_struct 中的node,因为我们只知道node的地址,所以我们知道task_struct的起始地址,因此我们知道task_struct任意成员的地址!
那么这样做带来的意义是什么?弄这么麻烦还不如之前的!
我们实现的双链表,再也与类型无关了!因为我们可以把list_node结构体内置到其他结构体里,哪怕有一个类型不为task_struct,将来也可以链入到task_struct当中。更重要的是,如果task_struct中包含的list_node不止一个呢?即如果包含多个node呢?在Linux内核中这样体现:

children把所有父子进程连接起来,sibling把所有的兄弟进程连接起来。
如果包含多个node,可以让一个PCB既属于链表,也可以同时属于其他数据结构!(队列底层可以由链表实现)
也就是说,我们可以保证当前的task_struct在管理所有进程全局双链表当中不断链的情况下,照样也能把这个task_struct添加到另一个队列里(调度队列runqueue里)。
也就是说,未来一个进程的PCB在全局链表中统一管理的同时,既可以把它放到全局链表里,也同时把它放到运行队列里了!
所以,当操作系统在加载时,新建的操作系统,刚开始是在全局链表当中,并没有把自己添加到运行队列里(runqueue),我们把这种进程叫做:就绪状态(或者叫:新建状态)!
之后要链入新进程(在runqueue里),直接把node添加到runqueue里即可,不是要把这个PCB从全局链表中断链,此时的PCB既在链表里,又在调度队列里!(两个CPU就有两个调度队列)
将来CPU在调度队列里拿task_struct就行,CPU怎么调度呢?
CPU可以在调度队列里进行先进先出,我们叫做First In First Out,简称FIFO,这个FIFO就被称为一种调度算法!我们看不上这个调度算法,因为体现不出优先级(后续要讲);我们也看不上教材上的调度算法。Linux上有自己的调度算法,FIFO是暂定的调度算法。
5.阻塞状态
什么叫阻塞?
比如,如下代码就叫做阻塞:
#include<stdio.h>
#include<unistd.h>
int main()
{
int a=0;
scanf("%d",&a);
printf("%d\n",a);
return 0;
}
当我们make后再./myfork有:

当我们./myfork它会转化成进程,被CPU调度运行了,可是执行scanf就要从键盘上获取,但是我只要不在键盘输入,不摁任何东西,也就是读取数据,条件不具备!
相当于这个图的:等待事件(等待用户输入),只要我不输入,此时进程所处的状态就叫做:阻塞状态!它没有运行,但是我输入了10,回车就有
在输入10回车后进程经历了从阻塞 ------> 运行的过程。
从硬件角度来理解,操作系统是一个做管理的软件,所以它要先描述,再组织。操作系统要对软硬件进行管理 -------- 包括硬件。操作系统对硬件的管理也要先描述再组织!
也就是说,管理硬件也有一个结构体,我们把这个结构体称为:struct hard_device,因此,这个hard_device就是描述硬件的结构体:
//硬件结构体
struct hard_device
{
int type;//硬件类型
int status;//硬件工作状态
//硬件访问方法……
struct hard_device* next;
};
我们将这些硬件结构体串联得到硬件链表:device_list,此时对硬件的管理变成了对链表的管理!
进入到scanf后它会检测键盘是否就绪(底层有系统调用),再检测是否有数据按下。如果我们按下数据后回车,就不会阻塞,进程就会运行结束,但万一键盘上没有数据就绪呢?
因为CPU在调度进程时,进程只有在对应的调度队列里,它才会被调度,如果键盘上没有数据,在操作系统层面上,该进程不应该被调度!因为调度了也读不到数据,所以我们只需要把当前进程从调度队列里断链,不要让它在调度队列里了,直接把当前进程的PCB移动到键盘的描述结构体的等待队列里。
我们把进程不会在CPU进行调度了,此时在设备描述结构体进行等待(设备描述结构体有一个等待队列struct list_head queue_node),等待设备资源,即进程阻塞!
相当于我们把该进程先从调度队列中拿下来(断链),然后再把它放到等待队列里,此时就不被调度了,那进程被卡住了,但是不影响操作系统调度其他进程!这种状态叫做阻塞。
后来我们的scanf后面还有进程也在等,无非就是把进程链入到设备的等待队列里。
在运行队列里本质是在竞争CPU资源,在设备的等待队列里本质是在竞争外设资源。
如当我体会到所有进程能够访问键盘,如果不就绪,键盘没准备好,那么当前把进程PCB链入到键盘队列里,所有的设备都要维护对应的等待队列,哪个进程需要哪个资源就在哪个队列下去等待,只要它不在CPU调度队列里,它就不会被调度,此时它就卡住了!
既然硬件没就绪,导致访问硬件的这个进程就要在阻塞队列里。
只有谁最清楚,最应该清楚,键盘又有数据呢?
操作系统最清楚!
为什么?
因为操作系统是软硬件资源的管理者!
阻塞和运行的本质:是让进程的task_struct①更改task_struct状态属性;②链入到不同的队列中(运行队列或等待队列),只要键盘输入了,操作系统就会把进程唤醒,把它重新链入到运行队列里,此时就能继续运行了!
一个进程是否被阻塞,本质是它访问某种资源时是否成功。
6.挂起状态

右边是一个进程,而系统有多个进程:

我们可以用双链表把每个进程PCB(task_struct+代码和数据)维护起来:

当然,每个PCB也可以放到运行队列里:

链起所有的进程,形成run_queue,假设我要访问键盘设备:

如果发现键盘没有就绪,链入到等待队列里:

在等待期间,操作系统可以让CPU调度其他进程,当前进程没有在运行队列里,只要当前进程目前处于阻塞状态,这个进程不会被调度的!即不会立即调度。 ------> 代码和数据都不会被访问!
如果在等待期间,内存资源严重不足了呢?
操作系统不做浪费空间和浪费时间的事,既然内存资源以及严重不足了,因此对于处于阻塞状态的代码和数据还在内存占着空间,此时就是对系统资源的浪费!
所以操作系统把处于阻塞状态的进程对应的代码和数据转移到磁盘某个空间上,再把内存对应的内存空间释放!一旦键盘准备好了,把task_struct加入到运行队列中同时把其对应的代码和数据从磁盘重新加载进入内存。
所以:在内存里只剩PCB,代码和数据都被换出了 ----- 挂起状态(触发条件:当内存资源严重不足,操作系统换出进程的代码和数据)
我们把磁盘中存放进程的代码和数据区域称为:swap分区(大小一般等价于内存大小或内存一半大小或内存1.5倍或2倍)swap分区大小不能设置太小,也不能设置太大,具体大小和系统重装时有关!
操作系统在内存有点不足时称为阻塞挂起状态(只有发生阻塞的才挂起),若内存严重不足,则除了第一个进程运行外,其余的进程处于就绪状态,也要挂起,因此称为就绪挂起状态。
那么在Linux的进程状态呢,下一篇博客再见!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)