进程概念(一)

我们已经知道了操作系统是一款做管理的软件,主要有内存管理,进程管理,文件管理,驱动管理这四大主要职责。接下来我们对进程管理进行学习。
进程学习

基本概念

  • 书本概念: 程序的一个执行实例,正在执行的程序等。
  • 内核观点: 担当分配系统资源(CPU时间,内存)的实体。

可以浅浅的理解为:加载到内存中的程序叫做进程(也称任务,如windows的任务管理器)
任务

描述进程-PCB

  • 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
  • 课本上称之为PCB(process control block)Linux操作系统下的PCB是: task_struct

task_struct——PCB的一种

  • 在Linux中描述进程的结构体叫做task_struct(Linux用C语言写的,所以他的类或者说结构体只有struct)。
  • task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
  • Linux中 task_struc对象由双向链表组织而成。但并不只有一种数据结构。

task_ struct内容分类

人认知一个事物基本都是通过了解事物的属性来认知事物的,如:介绍一个人都是从身高,体重,五官这些属性来介绍的。所以计算机去描述一个进程也是如此,所以描述进程的结构体task_struct必然包含了进程的多种属性。 如下:

  • 标示符: 描述本进程的唯一标示符,用来区别其他进程。
  • 状态: 任务状态,退出代码,退出信号等。
  • 优先级: 相对于其他进程的优先级。
  • 程序计数器: 程序中即将被执行的下一条指令的地址。
  • 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
  • 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
  • I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
  • 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
  • 其他信息。

再谈进程

一开始说进程是加载到内存的可执行程序,之后又谈了PCB(Linux下为task_struct),两者都好像与进程相关,那他们有什么联系呢?

task_struct:

描述进程的结构体

加载到内存的可执行程序:

包含代码和数据

认识事物都是从一个事物的属性开始的,计算机当然也不例外,所以计算机只能认识task_struct这个描述进程的结构体,但是只有属性没有数据代码也是扯淡啊,那不就是一个空壳了。

所以当一个可执行程序被加载到内存,操作系统会创建一个task_struct结构体的对象,该对象指向这个可执行程序。所以,进程应该是:内核PCB数据结构对象(task_struct对象)+代码和数据(可执行程序)
这个也相当好理解,如:如何证明你是一个学生?
在学校的就一定是学生吗?如保安,不在学校的就一定不是学生吗?如放假回家的学生。所以啊,在不在学校不是证明你是不是学生的方法。我们如果是该校的学生,那么我们的学籍信息一定会在学校的教务系统里,或者说在学信网里。这才是你有学生身份的最有力证明。所以内核PCB数据结构对象就类似在教务系统里的学籍信息,学生本人就是可执行程序。

进程的创建

我们现在认为一个进程是由内核PCB数据结构对象(task_struct对象)+代码和数据(可执行程序) 组成的。当一个可执行程序(数据和代码)加载到内存后,会创建一个内核PCB数据结构对象,该对象指向这块数据。
进程创建

组织进程

所有运行在系统里的进程都以task_struct链表的形式存在内核里。
当进程有多个时,就需要组织进程,而组织进程的目的就是为了管理进程,提到管理,就应该想到六字真言——先描述,再管理

task_struct结构体对象已经帮我们描述好了进程的属性信息,接下来的组织进程,上面也已经提到了进程在Linux操作系统下是以双向链表的形式组织的。经此操作,对进程的管理就变成了对链表的增删查改操作,可以高效率运行进程。

  • 注意:用链表组织进程时是链接内核PCB数据结构对象代码和数据是不用连接的,有了PCB对象就能找到对应的数据和代码。 也正因为这样操作系统管理进程就变为管理PCB对象。

描述进程

查看进程

系统目录下查看

进程的信息可以通过 根目录下的proc 系统文件夹通过ls指令查看。
ls查看进程
文件夹当中包含大量进程信息,其中有些子目录的目录名为数字。
ls查看

ps指令查看

还可以通过ps指令查看
选项:

  • -aux
    aux

  • axj
    axj
    直接使用的话会见全部进程展示出来,所以一般搭配grep和管道使用,展示指定的进程。如运行一个名为myproc的程序。
    ps配合使用

ps查看指令:使用时proc改为自己要查询的进程即可。

ps ajx | head -1 && ps ajx | grep proc | grep -v grep

kill指令控制进程

可以使用指令kill -l查看kill选项。
kill

  • -9:结束一个进程。
  • -18:恢复一个进程。
  • -19:停止一个进程。

kill

通过系统调用获取进程标示符

进程标识符

  • (子)进程id(PID)
  • 父进程id(PPID)

使用man手册查询

man getpid

geipid

通过getpid和getppid两个函数分别获取进程的PID和PPID,代码如下:

 #include <stdio.h>     
 #include <unistd.h>       
 #include <sys/types.h>    
                
int main()    
  {                           
      pid_t id = getpid();    
      while(1)    
      {              
          printf("I am a process, PID: %d, PPID:%d\n", getpid(), getppid());                
          sleep(1);    
      }            
      return 0;    
  } 

getpid

通过系统调用创建进程——fork初识

可以通过系统调用使用fork函数,其功能为创建一个子进程。

  • fork有两个返回值
  • 父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)

使用man手册查询fork

man fork

fork介绍

在使用fork后会创建一个子进程,使得fork之后的一份代码会跑两次。

使用示例

#include<stdio.h>    
#include<unistd.h>    
#include <sys/types.h>    
int main()    
{    
   int ret = fork();    
   printf("hello proc : %d!, ret: %d\n", getpid(), ret);    
   sleep(1);                                                           
       
   return 0;    
} 

fork
可以看到使用fork之后的代码确实跑了两次。

  • 使用fork后一般使用if进行分流,以此来区分父子进程。

接下来我们升级一下代码,进一步了解fork。

#include<stdio.h>
#include<unistd.h>
#include <sys/types.h>
int main()
{
  // int ret = fork();
  // printf("hello proc : %d!, ret: %d\n", getpid(), ret);
  // sleep(1);
    printf("begin: 我是一个进程, pid: %d, ppid: %d\n", getpid(), getppid());

   sleep(5);
   printf("我是后续的代码\n");
   pid_t id = fork();

   sleep(1);
   if(id == 0)
   {
      // 子进程
      while(1)
      {
          printf("我是子进程, pid: %d, ppid: %d\n", getpid(), getppid());
          sleep(1);                                                                                                                              
      }
   }
    else if(id > 0)
   {
      //父进程                                                                                                                                   
      while(1)
     {
          printf("我是父进程,pid: %d, ppid: %d\n", getpid(), getppid());
          sleep(1);
      }
   }
   else
   { 
       printf("error\n");
   }

   return 0;
}

为了更好观察,我们开多一个窗口使用脚本实时监视进程状态。
运行的程序名为myfork,监视时自己修改要监视的对应程序名。

while :; do ps axj | head -1 && ps axj | grep -v grep | grep myfork ; sleep 1; echo "################################################" ; done

fork

fork的基本原理

现象:
运行上述代码后我们发现fork确实创建了一个子进程,由一个myproc进程变为两个,并且其PID和PPID也是对应的。但我们发现代码明明是一个死循环,理应只运行一段死循环代码,但是实际确是运行了两段死循环代码,这就有点超出我们一般的认知了,所以对以上代码提出四个问题。

  1. 为什么fork要给子进程返回0,给父进程放回子进程PID?

这个比较好理解,就如古代的皇帝,他有很多的孩子,需要识别谁是谁,而皇子公主们只有这一个爹。子进程到结束时是需要父进程进行回收的,所以父进程需要知道具体是哪个进程。

  1. 一个函数是如何做到返回两次的?如何理解返回两次?

在一开始介绍fork的时候已经介绍了,父子进程是共享同一份代码的,return语句当然也是代码,所以就会有分为父子进程的两次返回。一般使用fork创建子进程是为了做与父进程做不一样的事,所以一般会使用if来判断父子进程,进而达到分开两者的目的。因此就需要接受fork的变量能够接受两个返回值,达到区分的目的。

  1. 一个变量怎么会有不同的内容?

接受fork返回值的变量为什么会有两个值,这个问题需要到接下来的进程地址空间才能解决。

  1. fork函数究竟干了什么?为什么会执行两段死循环代码?

fork函数在功能上创建了一个子进程,当该子进程一被创建出来后就会被调度器调度执行,这也是为什么在代码层面上明明是一个死循环,但是却仍然运行了另一个判断语句,这是因为他们是两个不同的进程,任何平台下运行的进程都是具有独立性的。至于父子进程谁先调度是不确定的,这个由调度器决定,从上面的截图看父子进程的调度顺序也不是不确定的。

调度顺序
fork的应用——bash
其实我们的bash就是众多指令的父进程。
查看父进程

  • 所以我们可以大胆推测,bash的实现一定调用了fork
  • 只要一直查找bash及其以上的父进程,一层层往上找,最后会找到操作系统(真大爹)PID为1。

写时拷贝

如何理解父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)?
在使用fork创建一个子进程后,是会在内存中多一份内核PCB数据结构对象(task_struct对象)+代码和数据(可执行程序) 从而创建出子进程吗?要知道内存空间有限的,当数据和代码量较大时,创建多个子进程将会占据大量内存空间。这显然不合理也不现实。

实际上,操作系统管理进程是通过管理PCB对象实现的,所以,fork创建子进程会创建一个新的PCB对象,并且指向父进程的数据代码,由于代码是不可修改的,所以会共享同一份代码;但数据有可能被修改,所以父子进程对应的数据有可能不同。但是为了节省空间,父子进程的数据都会指向同一份,当子进程数据不一样时,会对这份不同的数据进行写时拷贝,在内存中为之开辟一块空间,由子进程管理。

数据会不一样是原因是:创建子进程目的一般是为了做一些与父进程不一样的事,这时数据就有可能不一样。写时拷贝就是一种赌博式的拷贝方式,数据要是一样,那它就赢了,如果不一样,它再去拷贝,也无伤大雅,本质是对内存空间的极致利用。

写时拷贝

进程状态

为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。

一般操作系统学科运行状态
运行状态

运行状态:

处于运行队列的进程状态为运行。

阻塞状态:

处于等待队列的进程状态为阻塞。如当设备所需资源未准备好或者等待资源时,就叫做阻塞状态。

挂起状态:

当内存不够时,处于阻塞状态的的数据会换出到外设,但其对应的PCB对象仍在内存中,当运行到他时再将数据换入。这种状态称为挂起状态。

接下来看看下面的状态在kernel源代码里定义:

/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
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

R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
一个进程加载到CPU运行并不是一直执行完毕才把自己放下来的,运行状态表明该进程可以随时被调度器调度执行。

#include<stdio.h>    
    
int main()    
{    
    while(1)    
    {    
        printf("hello CDSN\n");    
    }    
                                                                             
    return 0;    
} 

运行上面的程序,会发现并不是R状态。
运行状态R

要想查看R状态也简单。死循环并且不进行IO流即可。
R状态

浅睡眠状态——S

S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。Linux中的S状态其实对应的就是阻塞状态。

阻塞状态时设备正在等待资源的输入,如以下代码:

#include<stdio.h>    
    
int main()    
{     
    int n;    
    printf("请输入n的值:\n");    
    scanf("%d",&n);    
    printf("%d\n",n);    
                                                                             
    return 0;    
}  

S状态

深度睡眠状态——D

D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
该状态的最大特点就是就连操作系统也无权在该状态下结束进程,只能等数据读写结束才能结束该进程。一般在将数据读写到外设时,为了确保数据不丢失,但一般只在内存达到极限时才会出现,而且也不是一个好迹象。

停止状态——T

t/T状态实际上差别不大。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。

如:使用kill指令停止某一进程。
T状态
使用gdb调试:运行到断点处也是让该进程停止。
调试暂停

僵尸状态——Z

当一个进程将要退出的时候,在系统层面,该进程曾经申请的资源并不是立即被释放,而是要暂时存储一段时间,以供操作系统或是其父进程进行读取,如果退出信息一直未被读取,则相关数据是不会被释放掉的,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态(zombie)。
首先,僵尸状态的存在是必要的,因为进程被创建的目的就是完成某项任务,那么当任务完成的时候,调用方是应该知道任务的完成情况的,所以必须存在僵尸状态,使得调用方得知任务的完成情况,以便进行相应的后续操作。
当一个进程结束时并不会进入X状态,而是先变为僵尸状态Z。

死亡状态——X

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

僵尸进程

什么是僵尸进程

僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死(尸)进程。
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。

如:子进程先比父进程先结束,而且父进程没做回收工作。

#include<stdio.h>
#include<unistd.h>
#include <sys/types.h>
    
int main()    
{    
    printf("我是一个进程 PID:%d,PPID:%d\n",getpid(),getppid());    
    pid_t id=fork();    
    if(id==0)//子    
    {    
        int n=5;    
        while(n)    
        {    
            printf("我是一个子进程 PID:%d,PPID:%d\n",getpid(),getppid());
            sleep(1);    
            --n;                                                             
        }    
    }    
    if(id>0)//父    
    {    
        while(1)    
        {    
            printf("我是一个父进程 PID:%d,PPID:%d\n",getpid(),getppid());
            sleep(1);    
        }    
    }    
    
    return 0;
}

Z状态

僵尸进程危害

  • 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就会一直处于Z状态。
  • 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护!无法释放。
  • 那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费!因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
  • 由于以上原因,导致内存资源无法被释放,就会造成内存泄漏!

僵尸进程是因为子进程结束了但父进程没有对其进行内存资源的释放,那如果父进程先结束呢?这就是接下来要介绍的——孤儿进程

孤儿进程

父进程先退出,子进程就称之为“孤儿进程”。

父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?

孤儿进程会被PID为1的init进程领养,最终由init进程回收。
init进程为操作系统本身。

演示

int main()
{
    printf("我是一个进程 PID:%d,PPID:%d\n",getpid(),getppid());    
    pid_t id=fork();    
    if(id==0)//子    
    {    
        //int n=5;    
        while(1)    
        {    
            printf("我是一个子进程 PID:%d,PPID:%d\n",getpid(),getppid());
            sleep(1);    
            //--n;    
        }    
    }    
    if(id>0)//父    
    {    
        int n=5;    
        while(n)    
        {                                                                    
            printf("我是一个父进程 PID:%d,PPID:%d\n",getpid(),getppid());
            sleep(1);    
            --n;
        }    
    }    

    return 0;
}

孤儿进程

好了,这就是对进程概念的初步学习;本文以当前视角为更好了解和学习进程等相关概念,对进程进行初步介绍,但不是全部,可能中间仍有一些细节等待发现。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值