Linux-进程初识

程序是为了完成特定任务的一系列指令的集合,进程是程序的一次动态执行,在我们打开计算机的同时,操作系统启动会创建许多进程,那么这么多进程操作系统是如何同一管理呢。操作系统会先把进程描述起来,进而把进程组织起来。

我们知道,一个程序执行时需要CPU分配程序所需要的资源,那操作系统就需要知道这个进程此时的状态以及调用栈的等等一系列状况,所以就需要一个容器来存放这些具体的参数,这个容器就是PCB。在Linux中,PCB就是task_struct。

那么,Linux中的进程的PCB都有哪些参数呢,这里罗列一部分:

1、进程标识符:用来区别于其他进程

2、状态:用于标识进程的状态,后面详细讲

3、优先级:决定进程被调度的优先级,即nice值

4、程序计数器:程序中将被执行的下一条指令的地址

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

6、I/O状态信息:分配给进程的I/O设备和被进程使用文件列表和其余的I/O请求

所以说PCB就相当于进程的大脑,CPU在调度进程其实就是在调度PCB,同样,操作系统要创建一个进程出来,首先要创建一个PCB出来,在操作系统内核中,操作系统会维护一个链表,所有进程的PCB都挂载在这个链表中,但是操作系统又怎么会知道CPU正在执行哪个进程,所以在PCB中就需要一个字段来标示进程此时的状态。

进程状态

通过查看内核当中的task_struct就可以知道该进程此时所处的状态,在task_struct中,共有七种状态:

        R运行状态(running):处于此状态并不是说进程就是在运行中,还有可能处于就绪队列里

        S睡眠状态(sleeping):进程在等待某件事的完成(可中断睡眠)

        D磁盘休眠状态(disk sleep):不可中断睡眠,通常会等待IO的结束

        T停止状态(stopped):可以通过发送SIGTOP信号来停止进程。停止后也可以通过发送SIGCONT信号来继续运行此进程

        X死亡状态(dead):只是一个返回状态,用户不会在任务列表看到这个状态

        Z僵尸状态(zombie):Linux独有的状态,通常是子进程先退出,父进程仍然运行,但是父进程没有读取到子进程的退出状态,此时子进程进入僵尸状态。

在谈父子进程之前,我们可以先来认识一个API:

pid_t fork(void);

调用这个函数操作系统会在内核中为我们创建一个进程,当前进程为父进程,以父进程的PCB为模板,把所有系统资源复制一份给子进程。子进程创建完毕之后,通过fork函数的返回值来判断父子进程。fork函数有两个返回值,父进程返回子进程的进程ID,子进程返回0,通常用if语句来做分流,以此让子进程完成相关任务,通常在网络程序中,父进程等待客户端连接,子进程处理客户端。我们可以看一份代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
     
    if(id < 0){
        perror("fork");
        exit(1);
    }
    else if(id == 0){ //child
         printf("I am child!\n"); 
    else{//father
        printf("I am father!\n");
    } 
    return 0;
}

这就是一个简单的进程创建的列子,父进程创建子进程是为了让子进程去完成一系列的任务,所以子进程有必要让父进程知道自己的状态以及任务的完成程度, 但是由于各种原因,子进程退出,父进程没有获得子进程的退出状态,理论上来说子进程退出其相应的PCB应该由操作系统释放,但是在Linux中子进程退出操作系统并不会回收其PCB,相反还会继续维护子进程的PCB,等待父进程来进行回收。

我们可以模拟一个僵尸进程出来:

#include <stdio.h>                                                                                                
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
 
    if(id < 0){
         perror("fork");
         exit(1);
    }
    else if(id == 0){ //child
         printf("child do things...!pid:%d ppid:%d\n", getpid(), getppid());
         sleep(3);
         exit(2);
    }
     else{ //father
         printf("father do things..!pid:%d ppid:%d\n", getpid(), getppid());
         sleep(10);
    }
    return 0;
}

可以看到父子进程的关系,在通过ps aux命令查看进程的状态:

 在进程进程刚启动时父子进程都是运行态,输出信息之后同时进入睡眠态

在三秒之后子进程进入僵尸态。 

进程进入僵尸态之后基本是不接受任何消息的,就连杀人不眨眼的kill -9都无法杀死僵尸,操作系统会一直维护僵尸的PCB等待父进程来进行回收,换句话说,父进程不回收僵尸子进程,僵尸进程的PCB就一直存在,但是操作系统创建的进程数是有限的,如果系统资源全都被僵尸进程所占用,那么系统的性能就会大大下降,同时还会造成内存泄露,所以我们在编程中一定要避免出现僵尸子进程,到现在我们可以画一张图来总结进程之间的状态转换:

进程优先级

我们现在使用的设备大多都是一个CPU的,但是各种程序给我们的感觉是在同步进行的,这里有必要解释两个名词,一个是并行,一个是并发。并行就是所有事物同时进行,并发则是有先后顺序的进行。在计算机系统中,CPU处理的速度远远超过我们所能反应的速度,但是同一时间CPU只能处理一个事件,为了不影响其他进程,每个进程占用CPU的时间是有限的,这个时间称作时间片。所以CPU采用并发式执行,在我们宏观上看来就是所有程序一同在执行。

那那么多进程在内核中,CPU怎么知道要先执行哪个进程,后执行哪个进程,这就需要一个指标来表示程序的优先级。我们可以用一条命令来查看系统进程的有关信息:

ps -l

输出一下结果:

UID:表示执行者的身份

PID:代表这个进程代号

PPID:表示父进程的代号

PRI:代表程序优先级,越小越早被执行

NI:代表程序nice值,可以通过nice值来调整程序的优先级

在这里我们就可以看见一个进程执行的优先级,PRI越小越早被执行,加入NI值后,会使PRI(new)=PRI(old)+nice,这样,nice值为负的时候,会让进程优先级值变小,优先级变高,进程越早被执行,所以在Linux下,调整进程优先级就是在调整nice值,nice值取值范围是-20至19,一共40个级别。值得一提的时候,PRI更改的时候每次总是以80为基准,并不会以上次天正的PRI为准。

孤儿进程

在上面我们知道父进程在运行,子进程退出会造成僵尸进程,那父进程退出会造成何种问题呢,父进程退出会使子进程变为孤儿进程,操作系统会自动把孤儿进程交给一号进程来托管。一号进程是操作系统在启动之时自动创建的一个进程,也可以把一号进程理解为根进程。具体的我们可以看代码。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

 int main()
 {
    pid_t id = fork();                                                                                            
    if(id < 0){
        perror("fork");
        exit(1);
    }   
    else if(id == 0){ //child
       printf("child do things...!pid:%d ppid:%d\n", getpid(), getppid());
       while(1);
    }   
    else{ //father
       printf("father do things..!pid:%d ppid:%d\n", getpid(), getppid());
       sleep(3);
       exit(2);
    }   
    return 0;
 }  

同时我们可以在另一个终端查看当前进程状态:

ps -ajx | head -n 1 && ps -ajx | grep a.out

在程序刚开始执行,父子进程同时运行,后父进程进入睡眠状态:

 三秒之后,父进程退出,子进程托管给1号进程:

进程地址空间 

在早起学习C语言时,我们心里应该有一张类似于下面的这张图的概念:

之前我们对于这张图的理解并不深刻,只知道在代码中的变量大概在内存中的那一块存放,但是现在我们学习了进程之后,子进程依靠父进程的PCB模板复制一份,相应的父进程变量以及变量的地址也要相应的复制一份,但是内存总共就4G,那么多进程如何分配?我们先看一段代码:

#include <stdio.h>                                                                                                
#include <stdlib.h>
#include <unistd.h>

int gol = 100;
 
int main()
{
   pid_t id = fork();

   if(id < 0){
       perror("fork");
       exit(1);
   }
   else if(id == 0){ //child
       printf("gol = %d, &gol = %p, pid:%d, ppid:%d\n",gol, &gol, getpid(), getppid());
       sleep(1);
   }
   else{ //father
       printf("gol = %d, &gol = %p, pid:%d, ppid:%d\n",gol, &gol, getpid(), getppid());
       sleep(1);
   }
   return 0;
}   

父子进程打印的全局变量和全局变量的地址都是一致的,证明我们的猜想是对的,但是如果在子进程中改变gol的值呢?在子进程中修改gol为200,得到的结果

 两个变量指向同一地址,但是打印出来的值确实截然不同的,所以父子进程输出的变量不是同一个变量,这似乎有点颠覆我们之前的认知,但是剖析更深层次,在学习Linux时为了保护操作系统内核,会在用户和内核空间之间设置一层保护shell,确保新手不会对内核造成损害,这里同样的,为了保护内存,在每个进程创建之初操作系统都会给进程创建一个虚拟内存表,本进程只能操作本进程内部的虚拟内存,而不会对物理内存造成损害,那操作系统在底层是如何实现的?这里采用的是三级映射的关系来建立虚拟内存和物理内存之间的联系。

在整个计算机系统中,硬件的处理速度远远大于软件的处理速度,所以在虚拟地址与物理地址的相互映射会采用一种更快的方法,由此,在系统内部诞生了MMU(内存管理单元),在操作系统中CPU会使用MMU来管理虚拟存储器,同时也负责虚拟地址映射为物理地址,以及提供硬件机制的内存访问授权。大部分操作系统采用的是4k大小的分页机制。所以我们之前所说的程序的地址空间都是虚拟地址空间,物理地址用户是一概看不到的,统一由OS进行管理。

 

 

 

 

猜你喜欢

转载自blog.csdn.net/yikaozhudapao/article/details/82970431