【Linux系列】进程PCB控制管理详解

进程概念

      • 1.什么是进程
      • 2.PCB
      • 3.进程查看
      • 4.进程创建
      • 5.进程状态
      • 6.进程优先级
      • 7.进程终止
      • 8.进程等待
      • 9.进程替换
      • 10.环境变量
      • 11.程序地址空间

1.什么是进程

进程的定义有以下几条:
  1、一个正在执行的程序。
  2、一个正在计算机上执行的程序实例。
  3、能分配给处理器并由处理器执行的实体。
  4、由一组执行的指令、一个当前状态和一组相关的系统资源表征的活动单元。
  简单来说什么是进程,进程就是正在执行中的程序,是操作系统分配资源的基本单位, 而在操作系统中,操作系统为了更好的描述一个进程,于是将进程视为一些元素组成的实体,而其中最重要的两个元素是程序代码和数据集。一般来说一个程序有了程序代码和数据集就可以顺利执行了,但是操作系统说还不够,为了满足操作系统对进程的控制,例如调度,中断,执行等操作,操作系统将每个进程描述为一个叫做进程控制块(PCB) 的数据结构,在PCB中存储着操作系统对控制一个进程所需要的全部信息,可以根据PCB找到程序代码,找到程序的数据,程序获得的资源等等。所以一个进程对于操作系统来说就是一个PCB。
在Linux上PCB是一个结构体,这个结构体中保存着一个个进程的信息。系统在管理进程实际上就是在管理一个个PCB,这是系统管理进程的媒介

2.PCB

一般情况下,PCB中包含4个部分
1.进程标识信息
2.处理机状态
3.进程调度信息
4.进程控制信息
【Linux系列】进程PCB控制管理详解_第1张图片
Linux中的PCB:task_struct

1、PID:这是PCB中最为重要的信息,也是我们区分不同的进程的标志。每一个进程都有一个属于自己的ID编号这个编号就存储在PID中。

2、进程状态:任务的状态信息。

3、优先级:相比于其他进程的优先级。

4、程序计数器:由于CPU一次只能同时执行一个进程,CPU会不停的高速在各个进程中进行切换执行,一次执行一点,这个被称为CPU轮循机制。那么在不停切换的时候需要记录各个进程当前执行到的代码,因此就在PCB中进行保存。程序计数器就是记录下一次执行的命令的地址。

5、内存指针:包括程序代码和进程相关数据的指针。

6、上下文数据:进程执行时CPU寄存器中存储的数据。

7、IO状态信息:包括IO请求和分配给进程的IO设备和打开的文件等。

8、记账信息:处理的时间综合,使用时钟综合,时间限制等。

9、其他信息。
  
task_struct中的mm成员 ,所对应的struct mm_struct管理内存
mmap指向虚拟的内存空间,它是个链表,每个节点代表的是链表中的一个段, 如(有的节点代表数据段,有的节点代表代码段,有的节点代表映射段)每一段又拥有起始地址和结束地址。

操作系统的内核的话:是想访问那个空间就访问那个空间;用户的代码是不能够这样做的。

3.进程查看

在Linux中进程的信息都保存在 /proc 这个系统文件夹中,
我们可以使用 ls /proc/ 来查看进程信息。同样我们也可以用 pstop来查看进程信息,pstree:进程树。

使用top命令
在这里插入图片描述
load average : 0.00 0.01 0.05 (平均负载 ) 1分钟 5分钟 15分钟
tasks: 227个进程在运行
sleeping 休眠 stopped 暂停 zombie 僵尸进程
us 硬件消耗的cpu
sy 操作系统的cpu
在这里插入图片描述

4.进程创建

分配一个唯一的标识符,在内核中创建出task_struct,复制父进程的环境信息,给新进程分配资源,栈,堆等等,拷贝父进程的地址空间内容,将新进程放入就绪队列。

fork() 函数是有返回值的,在父进程中fork()返回子进程的PID,在子进程中返回0,由此我们可以区分父子进程。由于子进程会完全复制父进程的PID因此子进程在创建后会和父进程一起执行创建进程之后的语句直到进程结束。

#include 
#include 
int main()
{
  printf("parent pid = %d\n", getpid());
  pid_t pid = fork();//复制了父进程的PCB,因此程序计数器也会跟父进程一致,
  //因此子进程下一句执行的代码和父进程一致
  printf("child1:%d\n", pid);
  if(pid < 0)
  {
    return -1;
  }
  else if(pid == 0)//fork有返回值,子进程返回0,父进程返回的是子进程的pid
  {
    printf("child:%d\n", getpid());//得到当前进程的PID
  }
  else
  {
    printf("parent:%d\n", getpid());//得到当前进程的PID                                                         
  }
  printf("end\n");
}

运行程序后,得到以下结果
parent pid = 6200
child1:6201 //父进程返回的是子进程的pid
parent:6200
end
child1:0 //子进程返回0
child:6201
end

关于写时拷贝:
其中在创建出子进程后,代码段是不需要再次进行拷贝的,可以共享同一个物理内存,而数据段如果我们只是想要看的话不需要拷贝,一份就够了,如果想要更改这个数据,则需要重新拷贝一份。
例:一个父进程,创建出两个子进程

fork() 创建的子进程会完全复制父进程的PCB但是完全复制一份PCB有时在对空间要求很严格的情况下会导致浪费很多的空间,因此我们就有了vfork()

 vfork()函数的基本功能与fork()一致,但有不同的地方是vfork()创建完子进程会优先执行子进程,并且子进程不会完全复制父进程的PCB,为了节省空间子进程会和父进程共用一块虚拟内存,为了不导致调用栈混乱,子进程会阻塞父进程,也就是说父进程会在子进程退出或替换后才会执行。

 vfork和fork也会有调用失败的情况,比如说在系统中已经有了太多的进程或者用户的实际进程达到了上限,这样则系统不再允许我们创建新的进程。

5.进程状态

在程序执行后,每个进程都有属于自己的状态,并且我们可以通过ps -aux命令查看到进程的状态。
【Linux系列】进程PCB控制管理详解_第2张图片
基本状态分为以下几种:
1、运行状态 (R ):这个状态表明进程要么正在运行要么就在进程队列中。

2、睡眠状态(S):表示进程正在等待某个时间的完成。同时睡眠也分为可中断和不可中断的睡眠。这里的睡眠指可中断睡眠。

3、磁盘睡眠状态(D):这里的睡眠指的即是不可中断睡眠。这个状态的进程往往是在等待IO的结束。

4、停止状态(T):这里的停止状态指的是通过指令让一个进程处于暂停状态,同时我们是随时可以将其恢复继续运行的。

5、死亡状态(X):这个状态并不会在查看进程的时候显示,因为这个状态的进程已经完全停止了,是不可恢复的。
  
6.僵尸状态(Z):是由于子进程在运行中退出但父进程并未读取子进程的退出状态码所导致的,并且此时的子进程为了等待父进程读取退出状态码一直处于僵死状态,由此产生僵尸进程,直到父进程也退出。

造成僵尸进程的后果:

1、浪费资源。由于僵尸进程一直不退出,父进程一直在关心自己子进程的退出,但是迟迟不会读取,由此系统需要一直维护僵尸进程,并且如果有大量的僵尸进程也会消耗大量资源进行维护。

2、内存泄漏。由于创造进程需要消耗内存而内存一直无法得到释放,就会造成内存泄漏。

那我们如何关闭僵尸进程呢?也很简单,只需要关闭他的父进程即可。至于如何避免造成僵尸进程,我们需要用到进程等待技术,这一点我们之后再讲。
  
孤儿进程
  孤儿进程也是一种特殊的进程。他是在父进程先退出子进程还在执行的情况下造成的。子进程的父进程会从原来的父进程变成1号进程——init进程。这个进程就像是一个巨大的孤儿院,只要你有init进程回收机制,我们的孤儿进程就会被1号进程领养

6.进程优先级

在操作系统下,我们往往是多个进程同时执行,但是往往在不同的进程中拥有着优先级别,系统会将资源优先分配给优先级高的进程,这样重要的进程才不会显得卡顿。我们的进程往往分为交互式进程和批处理进程,交互式进程要求一旦用户操作就优先运行,因此它们的优先级往往要高于批处理进程。我们先用ps -elf查看一下进程的优先级。
其中有两行信息 PRI NI
【Linux系列】进程PCB控制管理详解_第3张图片
我们主要关注这两行数据,PRI和NI,它们共同决定了一个进程的优先级。PRI(优先级) = PRI(初始) + NI(NICE值),我们往往无法直接改变一个程序的PRI优先级,但是我们可以通过修改进程的NI值来达到修改进程优先级的目的。PRI值越小则优先级越高,NI值的范围为-20 ~ 19,一共40个优先级级别。

我们可以使用nice -n 新NICE值 -p PID来更改一个已有进程的NICE值,也可以使用nice -n 新NICE值 可执行程序来用一个NICE值运行一个程序。不过我们要注意修改NICE值需要root用户。

7.进程终止

进程退出往往分为以下几种方式,程序运行完毕正常退出和代码异常中止
我们平常使用的ctrl + c终止进程就是一种进程异常退出的方式,我们也可以通过调用接口和函数进行带退出状态的方式退出进程。

_exit()是一个函数,原型为:
void _exit(int status);

可以让程序带状态进行退出,status是程序的退出状态,这个状态可以通过wait()函数接收到,也可以在命令行中通过echo $?命令进行查看。

#include 
#include 
#include 
#include 
#include 

int main()
{
    int pid = fork();
    if (pid < 0) {
        perror("fork error");
        printf("fork error :%s\n", strerror(errno));
    }
    sleep(1);                                       
    _exit(-1);
}      

[root@localhost process]$ echo $?
255

明明我们返回了-1为什么终端中查看返回值却返回了255呢?

因为status只有低8位可以被父进程查看到返回值信息,也就是说返回值信息一共只有255条,如果我们返回-1,实际上返回了255。
  
exit() 也为退出函数,但是在退出进程前会对进程做很多的处理随后调用_exit()退出进程。这些退出前的处理包括:

1、执行用户自定义的清理函数——atexit或on_exit。

2、会关闭所有打开的流,所有的缓存数据都会被写入。

exit()退出进程会将缓冲区中的数据全部写入,但是_exit()却没有这些处理。

8.进程等待

所谓进程等待是父进程等待子进程的终止并且得到子进程的退出信息,防止产生僵尸进程。进程等待是一种防止僵尸进程长生的重要方法,在合适的时间进行进程等待可以防止僵尸进程,确保不会产生内存泄漏,白白浪费资源。

wait() 这个函数是最为基本的进程等待函数,原型为
pid_t wait(int *status);

返回等待到的子进程的pid,status为返回型参数,返回等待到的子进程的退出信息。但是如果在等待期间为等待到子进程会阻塞父进程一直等待子进程,因此必须要在合适的时间进行进程等待,不然也会影响父进程的执行速度。

#include 
#include 
#include 
#include 

int main()
{
    int pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(-1);
    }else if (pid == 0) {
      //child
      printf("child = %d\n", getpid());
      sleep(5);
      exit(1);
    }
    int status;
    pid_t pid2 = wait(&status);
    printf("pid = %d\n", pid2);
    return 0;
}            

[root@localhost process]$ ./wait
child = 4843
pid = 4843
status = 256

这里我们已经等待到了我们的子进程退出,但是会发现返回的信息却不对,这是为什么呢?

status的值不能单纯的看作是一个整形,而是应该用位图来理解。我们只讨论status的低16位。

status的低8位用来保存异常终止信息,如果程序正常退出则低8位为0,否则会在低8位保存终止信号以及core dump标志及产生核心转储文件的标志。而高位才会保留我们自己返回的退出信息。并且C语言已经为我们实现准备好了宏来验证低7位是否为0以及提取高位返回信息。

//WIFEXITED()验证低8为是否为0,为0则为真
 if (WIFEXITED(status)) {
        printf("child exit code:%d\n", WEXITSTATUS(status));
    }
    //WIFSIGNALED()若WIFEXITED为真则提取子进程退出码。
    if (WIFSIGNALED(status)) {
        printf("exit signal:%d\n", WTERMSIG(status));
    }
    
child exit code:1

waitpid() 比wait()功能更加全面,它拥有更多的选项,可以在不阻塞父进程的情况下进行进程等待。原型:

pid_t waitpid(pid_t pid, int *status, int options);

//  pid:    指定的进程id
//          -1  等待任意子进程
//          >0  等待指定子进程
//  status:    用于获取返回值
//  options:选项
//          WNOHANG     将waitpid设置为非阻塞
//  返回值:<0:出错     ==0:没有子进程退出   >0: 退出子进程的pid
    while (waitpid(pid, &status, WNOHANG) == 0) {
      printf("no exit~~~smoking~~\n");
      sleep(1);}

9.进程替换

我们在创建子进程时现在往往只能让子进程执行和父进程一样的代码,这样我们想让子进程去执行另外的功能就需要用到进程替换。进程替换要用到exec系函数。我们先把他们的函数原型都先列出来再进行分析。

#include 
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);

exec后跟不同的字符表示不同的功能:

1、l:参数使用列表列出,结尾用NULL表示结束。

2、p表示在环境变量中搜索参数,搜索到替换为环境变量中指定的文件。

3、v表示参数用数组传递,数组最后一个元素用NULL表示结束。

4、e表示需要自己传递环境变量。

在系统中exec族函数最终都会调用execve,系统中只有这一个是系统接口,而其他函数不过是在execve上进行了一次封装。

10.环境变量

1、echo $NAME:可以查看一个环境变量。

2、env:查看所有环境变量。

3、export:声明一个环境变量。

4、unset:删除一个环境变量。

我们在将一个程序的路径加入PATH环境变量后即可直接使用程序名执行程序。export PATH=$PATH:程序路径。
  
在程序中获取环境变量:通过命令行第三个参数

#include 
int main(int argc, char* argv[], char* env[])
{
 int i = 0;
 for(; env[i]; i++){
 printf("%s\n", env[i]);
 }
 return 0;
}

通过系统调用获得环境变量

#include 
#include 
int main()
{
 printf("%s\n", getenv("PATH"));
 return 0;
}

11.程序地址空间

对于每一个进程都会对应一个虚拟地址空间,对于32位的操作系统(其指令的位数最大为32位,因此地址码最多32位),虚拟地址空间的大小为2^{32}B即0~4GB的虚拟地址空间,其中内核空间为1GB
物理内存是如何和虚拟内存关联的呢?那就必然有着一个中间起到连接作用的工具——页表。页表是一用来记录虚拟地址与物理地址它们之间的关系,并且对内存数据进行管理。如下所示。
【Linux系列】进程PCB控制管理详解_第4张图片

你可能感兴趣的:(操作系统,linux)