目录

一、引言

二、进程

1、基本概念

2、进程PCB

3、进程组织

4、进程查看

5、进程标示符

6、fork

7、进程状态

(1)僵尸进程

(2)孤儿进程

8、进程优先级

(1)基本概念

(2)更改进程优先级

9、竞争、独立、并行、并发

10、进程切换

11、进程调度

12、进程地址空间

13、进程终止

14、进程等待

(1)wait

(2)waitpid

三、结语

一、引言

进程是Linux中最重要的抽象概念之一,进程是程序运行的实体,也是系统资源分配的基本单位,理解进程的本质、创建机制、状态转换以及资源回收,是掌握Linux的基石,本文将深入剖析Linux进程的完整生命周期,从通过fork系统调用创建子进程,到分析进程的不同状态僵尸进程与孤儿进程,最后到进程退出与wait资源回收,带你全方面理解进程概念及实现进程控制。

二、进程

1、基本概念

进程的一般描述为程序的一个执行实例,正在执行的程序,而在内核角度,进程是担当分配系统资源(CPU时间、内存)的实体,进程=内核数据结构(task_struct)+自身的代码和数据。

2、进程PCB

进程相关信息放在一个叫做进程控制块的数据结构中,可理解为进程属性的集合,也称为PCB,Linux下的进程PCB为task_struct,task_struct是Linux内核的一种数据结构类型,它会被装载到RAM中,并包含进程的相关信息。

task_struct中进程的相关信息主要包括:

标示符:描述该进程的唯一标示符,用来区别其他进程。

状态:包括任务状态,退出码,退出信号等。

优先级:相对于其他进程的优先级。

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

内存指针:包括程序代码和进程相关数据的指针,以及和其他进程共享内存块的指针。

上下文数据:进程执行时处理器的寄存器中的数据。

I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。

3、进程组织

可在linux内核源代码中找到task_struct进程控制块,所有在系统中运行的进程都以task_struct双向链表的形式存在内核里,如下图所示:

4、进程查看

进程的信息可通过/proc系统文件夹查看

如要获取PID为1的进程信息,则需要查看/proc/1文件夹

也可通过top、ps这些用户级工具来获取进程信息

#include<stdio.h> 
#include<sys/types.h> 
#include<unistd.h>
int main() 
{
  while(1)
  {
    sleep(1);
  }
  return 0; 
}

5、进程标示符

PID:进程id,PPID:父进程id,可通过系统调用getpid、getppid来获取相应的进程标示符

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
  printf("pid: %d\n", getpid());
  printf("ppid: %d\n", getppid());
  return 0;
}

getpid获取的为该进程的进程标示符,getppid获取的则是父进程的进程标示符

6、fork

通过系统调用fork可以创建子进程,当程序调用fork后,内核会生成子进程,并分配新的内存块和内核数据结构给子进程,将子进程添加到系统进程列表当中,该子进程是原进程的一个副本,子进程会复制其父进程的相关数据和代码,父子进程代码共享,数据各自开辟空间,私有一份,采用写

时拷贝,父子进程从fork返回的那一刻开始,同时监听返回结果,当一个进程调用fork之后,就有两个二进制代码相同的进程,即父子进程,它们都运行到相同的地方,但执行不同的代码路径,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。

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

可以看到,printf执行了两次,父子进程为两个独立的执行流,fork之后,父子进程谁先执行由调度器决定。

fork有一个非常重要的特性:调用一次,返回两次,在子进程中,fork返回0,在父进程中,fork返回子进程的PID(PID>0),若出错,则fork返回-1,fork之后通常要用if进行分流,通过fork返回值,就能区分父子进程

#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
  int ret = fork();
  if(ret < 0)
  {
   perror("fork");
   return 1;
  }
  else if(ret == 0)
  { 
   printf("I am child : %d!, ret: %d\n", getpid(), ret);
  }
  else
  { 
   printf("I am father : %d!, ret: %d\n", getpid(), ret);
  }
  sleep(1);
  return 0;
}
 

ret==0即为子进程,反之则为父进程,运行结果如下:

可以看到fork返回了两次,返回两次本质上是父子进程两个独立的执行实体,各自返回了一次,这两个返回值是父子进程的task_struct在内核返回前,通过其返回值寄存器拿到的,子进程返回0,父进程返回其子进程的PID。

7、进程状态

进程有多种不同的状态,进程状态在内核源码中也有相关说明

/* *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)", 
  "S (sleeping)", 
  "D (disk sleep)", 
  "T (stopped)", 
  "t (tracing stop)", 
  "X (dead)", 
  "Z (zombie)", 
};

R(running)运行状态:并不意味着进程一定在运行中,R表明进程要么是在运行中要么在运行队列里。

S(sleeping)睡眠状态:意味着进程在等待事件完成,也叫作可中断睡眠。

D(Disk sleep)磁盘休眠状态:也叫作不可中断睡眠状态,在这个状态的进程通常会等待IO的结束。

T(stopped)停止状态:可通过发送SIGSTOP信号给进程来停止进程,被暂停的进程可通过发送SIGCONT信号让进程继续运行。

t(tracing stop)暂停状态:表示进程在调试时被暂停。

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

Z(zombie)僵尸状态:表示进程结束运行后,进程仍占据进程表的一个位置,即僵尸进程。

进程状态查看:

ps aux   #进程状态查看
ps axj   

a:显示一个终端所有的进程,包括其他用户的进程。

x:显示没有控制终端的进程,如后台运行的守护进程。

j:显示进程归属的进程组ID、会话ID、父进程ID,以及与进程控制相关的信息。

u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等。

(1)僵尸进程

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

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

子进程运行5秒后退出,父进程为无限循环,没有回收子进程的退出状态,故子进程退出后变为僵尸进程,只要父进程不结束,子进程的僵尸状态就会一直持续,可以在另一个终端下通过以下命令查看子进程的状态信息

while :; do ps ajx | grep code | grep -v grep; sleep 1; done

while :;为无限循环,返回真,ps ajx显示所有进程信息,grep code过滤出包含code的行,grep -v grep排除掉刚执行的grep命令自身,每1s执行一次循环,结果如下:

阶段一:正常运行(前5秒)

可以看到前5秒父子进程都在运行,状态为S,处于睡眠状态

阶段二:子进程退出(5秒后)

5秒后子进程退出,父进程仍在循环,并没有回收子进程的相关退出信息,故子进程进入僵尸状态,状态为Z

僵尸进程危害:

子进程的退出状态必须被维持下去,因为子进程需告诉父进程其进程退出信息,如果父进程一直不读取,那么子进程就会一直处于Z状态。

维护进程退出状态本身就需要用数据维护,也属于进程基本信息,保存在task_struct中,如果进程一直处于Z状态不退出,那么task_struct就一直都要维护。

如果一个父进程创建了很多子进程,但都不回收子进程的退出信息,就会造成内存资源的浪费及内存泄漏,这就是僵尸进程的危害。

(2)孤儿进程

如果父进程先于子进程退出,那么子进程就会变为孤儿进程,孤儿进程将被1号init/systemd进程领养,并由1号进程进行回收。

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

运行5秒后,父进程退出,父进程先于子进程退出,此时子进程就变为了孤儿进程,将由1号进程领养,负责在它退出时回收资源,同样可在另一个终端通过以下命令来查看子进程的相关状态信息

while :; do ps ajx | grep code | grep -v grep; sleep 1; done

阶段一:正常运行(前5秒)

前5秒父子进程正常运行,状态为S

阶段二:父进程先退出(5~10秒)

5秒后,父进程退出,子进程变为孤儿进程,此时子进程将由1号进程领养,可以看到子进程的ppid变为1。

阶段三:子进程退出(10秒后)

10秒后子进程退出,将由init进程负责在子进程退出时回收资源,不会产生僵尸进程。

8、进程优先级

(1)基本概念

CPU资源分配的先后顺序,就是进程的优先级,优先级高的进程有优先执行权利。

通过ps -l可以观察进程优先级的相关信息

UID:执行者的身份

PID:进程代号

PPID:父进程代号

PRI:进程可被执行的优先级,值越小优先级越高

NI:进程的nice值,表示进程可被执行的优先级的修正数值,PRI越小越快被执行,加入nice值后,将会使PRI变为:PRI(new)=PRI(old)+nice,当nice值为负值时,则该程序PRI将变小,优先级将变高,越快被执行。

调整进程优先级,就是调整进程nice值,nice取值范围为-20~19,共40个级别。

需要注意的是,进程的nice值不是进程的优先级,nice与进程优先级不是一个概念,但是进程nice值会影响到进程的优先级变化,可以理解为nice值是进程优先级的修正数据。

(2)更改进程优先级

top命令可以更改已存在进程的nice值,进入top后输入r,再输入进程的PID,输入nice值,即可完成进程优先级的修改,过程如下所示:

如上所示,top修改PID为1049的进程,修改其nice值为10,则NI为10,PR=20(原PR值)+10=30。

此外,nice、renice指令、系统函数setpriority、getpriority也可调整进程的优先级。

9、竞争、独立、并行、并发

竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,故进程之间具有竞争性,为了高效完成任务,更容易竞争相关资源,便有了优先级。

独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。

并行:多个进程在多个CPU下分别同时运行,称为并行。

并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称为并发。

10、进程切换

CPU上下文切换:其本质是进程切换,或者CPU寄存器切换,当代计算机为分时操作系统,每个进程都有它自己的时间片,时间片本质上是一个计数器,当时间片到达,进程就被操作系统从CPU上剥离下来,当多任务内核决定运行其他的进程时,它会保存正在运行进程的当前状态,也就是CPU寄存器中的全部内容,这些内容被保存在进程自己的堆栈中,入栈工作完成后就把下一个将要运行的进程的当前状况从该进程的栈中重新装入CPU寄存器,并开始下一个进程的运行。

11、进程调度

优先级:

普通优先级:100~139 (一般所说的优先级为普通优先级)

实时优先级:0~99

一个CPU拥有一个运行队列(runqueue),如果有多个CPU就要考虑进程个数的负载均衡问题

时间片还没有结束的所有进程都按照优先级放在该队列,nr_active表示总共有多少个运行状态的进程,queue[140]中的一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,数组下标就是该进程的优先级。

过期队列和活动队列结构一模一样,过期队列上放置的进程,都是时间片耗尽的进程,当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算。

active指针指向活动队列,expired指针指向过期队列,在合适的时间段,active和expired指针将交换二者的内容,由此产生一批新的活动进程。

进程调度过程为:CPU从0下标开始遍历queue[140],直到找到第一个非空队列,则该非空队列为优先级最高的队列,选中该队列的第一个进程,开始运行,进程调度完成。

bitmap[5]位图一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用这140个比特位表示队列是否为空,这样就可以大大提高查找的效率。

在系统当中查找一个最合适调度的进程时间复杂度是一个常数,不随着进程增多而导致时间成本增加,即进程调度大O(1)算法。

12、进程地址空间

先来运行以下程序,观察父子进程gval变量的变化情况

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int gval=100;
int main()
{
    pid_t id=fork();
    if(id==0)
    {
        while(1)
        {
            printf("子:gval:%d,&gval:%p,pid:%d,ppid:%d\n",gval,&gval,getpid(),getppid());
            sleep(1);
            gval++;
        }
    }
    else
    {
        while(1)
        {
            printf("父:gval:%d,&gval:%p,pid:%d,ppid:%d\n",gval,&gval,getpid(),getppid());
            sleep(1);
        }
    }
    return 0;
}

可以看到,父子进程输出地址是一致的,但gval变量的值不一样,说明父子进程输出的变量绝对不是同一个变量,但地址是一样的,则该地址不是物理地址,为虚拟地址,OS负责将虚拟地址转化为物理地址,进程地址空间为虚拟地址空间,如下所示:

父子进程代码共享,当父子进程不写入时,数据也是共享的,当任意一方试图写入时,便以写时拷贝的方式各自一份副本,可以看出,同一个变量,地址相同,即虚拟地址相同,内容不同本质其实是被映射到了不同的物理地址,描述linux下进程地址空间的结构体为mm_struct(内存描述符),每个进程只有一个mm_struct结构,task_struct有一个指向该进程mm_struct的结构体指针。

可以说,mm_struct结构是对整个用户空间的描述,每一个进程都有自己独立的mm_struct,即每一个进程都有自己独立的地址空间互不干扰,task_struct、mm_struct在进程地址空间的分布如下:

虚拟空间的组织方式有两种:

1、当虚拟区较少时采取单链表,由mmap指针指向该链表

2、当虚拟区较多时采取红黑树进行管理,由mm_rb指向该红黑树。

linux内核使用vm_area_struct结构来表示一个独立的虚拟内存区域(VMA),由于每个不同的虚拟内存区域功能和内部机制都不同,因此一个进程需使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。

可以对进程地址空间进行更细致的描述,如下图所示:

13、进程终止

进程终止的本质是释放系统资源,即释放进程申请的相关内核数据结构和对应的数据和代码。

进程退出场景:

代码运行完毕,结果正确

代码运行完毕,结果不正确

代码异常终止

进程常见退出方法:

正常终止(可通过echo $?查看进程退出码):

1、从main返回

2、调用exit

3、_exit

异常退出:

Ctrl+c,信号终止

退出码:

退出码可以得知最后一次执行命令的状态,命令结束以后,可以知道命令是成功完成的还是以错误结束的,程序返回退出代码0时表示执行成功,代码 1 和 0 以外的任何代码都视为不成功。

Linux的主要退出码:

退出码0表示命令执行无误,这是完成命令的理想状态

退出码1表示不被允许的操作,如在没有sudo权限的情况下使用yum,除0等操作。

可使用strerror函数来获取退出码对应的描述。

_exit退出函数:

#include<unistd.h> 
void _exit(int status); //status 定义了进程的终⽌状态,⽗进程通过wait来获取该值

status仅有低8位可以被父进程使用,当_exit(-1)时,在终端执行$?结果为255。

exit函数:

#include <stdlib.h>
void exit(int status);

使用exit退出进程最后也会调用_exit,具体过程为:

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

2、关闭所有打开的流,所有的缓冲数据均被写入。

3、调用_exit

通过以下程序可以看出exit和_exit的核心区别:

#include<stdio.h>
#include<stdlib.h>
int main()
{
    printf("hello!");
    exit(0);
    return 0;
}

exit为库函数,调用exit函数,程序将执行清理,并进行缓冲区的刷新,调用atexit,由于进行了行缓冲区的刷新,故显示hello!字符串。

#include<stdio.h>
#include<unistd.h>
int main()
{
    printf("hello!");
    _exit(0);
    return 0;
}

而_exit为系统调用,调用_exit接口,系统将直接进入内核,立即终止,不会进行缓冲区的刷新,故不显示hello!字符串。

return:

return则是一种更常见的退出进程方法,执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当作exit的参数。

14、进程等待

父进程需要通过进程等待的方式,回收子进程资源,获取子进程退出信息。

进程等待的方法:

(1)wait

#include<sys/types.h> 
#include<sys/wait.h>
pid_t wait(int* status);

返回值:wait等待成功返回被等待进程的pid,失败则返回-1。

参数:为输出型参数,获取子进程的退出状态,若不关心子进程的退出状态可设置为NULL。

#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
int main()
{
    pid_t id=fork();
    if(id==0)
    {
        int count=3;
        while(count)
        {
            printf("我是子进程,pid:%d,ppid:%d\n",getpid(),getppid());
            sleep(1);
            count--;
        }
        exit(0);
    }
    while(1)
    {
        pid_t rid=wait(NULL);
        if(rid>0)
        {
            printf("wait success!,rid:%d\n",rid);
            break;
        }
        else
        {
            break;
        }
    }
    return 0;
}

父进程通过wait(NULL)等待子进程退出,若子进程未退出,则父进程会一直阻塞等待,直到子进程退出,3s后子进程exit退出,父进程通过wait返回,获取子进程退出信息,并进行子进程的回收,等待成功,结果如下:

(2)waitpid

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

返回值:

正常返回为waitpid收集到子进程的pid

如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0。

如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。

参数:

pidpid=-1,等待任一个子进程,与wait等效。

        pid>0,等待其进程ID与pid相等的子进程。

status:输出型参数,WIFEXITED(status):若为正常终止子进程返回的状态,则为真,用于查看进程是否正常退出。WEXISTATUS(status):若WIFEXITED非零,提取子进程退出码,用于查看进程退出码。

option:默认为0,表示阻塞等待。WNOHANG:若pid指定的子进程没有结束,则waitpid函数返回0,不予以等待,若正常结束,则返回该子进程的pid。

如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。如果不存在该子进程,则立即出错返回。

wait和waitpid,都有一个status参数,该参数为输出型参数,由操作系统填充,如果传NULL,则表示不关心子进程的退出状态信息,否则,操作系统将根据该参数,将子进程的退出信息反馈给父进程。status不能简单的当作整形来看待,须当作位图来处理,具体细节见下图,只研究status的低16比特位,status的位图结构如下所示:

#include<stdio.h>
#include<errno.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
    pid_t id=fork();
    if(id==0)
    {
        int count=3;
        while(count)
        {
            printf("我是子进程,pid:%d,ppid:%d\n",getpid(),getppid());
            sleep(1);
            count--;
        }
        exit(10);
    }
    while(1)
    {
        int status=0;
        pid_t rid=waitpid(id,&status,0);
        if(rid>0)
        {
            printf("wait success!,rid:%d,exit code:%d,exit signal:%d\n",rid,(status>>8)&0xFF,(status)&0x7F);
            break;
        }
         else
        {
            break;
        }
    }
    return 0;
}

子进程执行3秒后退出,退出码为10,父进程通过waitpid等待子进程退出,并获取子进程的退出信息,结合status的位图结构可知,(status>>8)&(0xFF)用于获取status的高8位,即子进程的退出码,(status)&(0x7F)用于获取status的低7位,即子进程的终止信号,结果如下所示:

子进程的退出码为10,终止信号为0,表示子进程正常运行结束。waitpid(id,&status,0)表示父进程阻塞等待子进程退出,除了阻塞等待方式,还可以进行非阻塞等待,父进程在等待子进程的同时还可以执行其他的任务。

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
typedef void (*func_t)();
#define NUM 5
func_t handler[NUM+1];
void download()
{
    printf("我是一个下载的任务\n");
}
void flush()
{
    printf("我是一个刷新的任务\n");
}
void Log()
{
    printf("我是一个记录日志的任务\n");
}
void registerhandler(func_t h[],func_t f)
{
    int i=0;
    for(;i<NUM;i++)
    {
        if(h[i]==NULL)
        break;
    }
    if(i==NUM) return;
    h[i]=f;
    h[i+1]=NULL;
}
int main()
{
    registerhandler(handler,download);
    registerhandler(handler,flush);
    registerhandler(handler,Log);
    pid_t id=fork();
    if(id==0)
    {
        int count=3;
        while(count)
        {
            printf("我是子进程,pid:%d,ppid:%d\n",getpid(),getppid());
            sleep(1);
            count--;
        }
        exit(10);
    }
    while(1)
    {
        int status=0;
        pid_t rid=waitpid(id,&status,WNOHANG);
        if(rid>0)
        {
            printf("wait success!,rid:%d,exit code:%d,exit signal:%d\n",rid,(status>>8)&0xFF,(status)&0x7F);
            break;
        }
        else if(rid==0)
        {
            int i=0;
            for(;handler[i];i++)
            {
                handler[i]();
            }
            printf("本轮调度结束,子进程没有退出\n");
            sleep(1);
        }
        else
        {
            printf("等待失败\n");
            break;
        }
    }
    return 0;
}

父进程通过waitpid(id,&status,WNOHANG)非阻塞等待子进程,WNOHANG表示非阻塞等待,父进程非阻塞轮询子进程,rid为0时,子进程仍在运行,与此同时父进程通过函数指针回调的方式来执行任务队列,当rid>0时,子进程退出,父进程获取子进程的退出信息并退出,结果如下所示:

三、结语

本文主要围绕Linux进程展开介绍,进程是linux最重要的抽象概念之一,进程不仅是程序的执行实体,更是资源分配的基本单位,从进程的基本概念出发,到进程PCB、进程的相关描述、fork系统调用创建进程,进程状态(僵尸、孤儿进程)、进程优先级、进程地址空间及进程的相关控制、进程等待,再到非阻塞结合任务调度的应用,进程是操作系统对CPU、内存、I/O等硬件资源的最高层次抽象,是资源管理、调度执行、并发实现的统一载体,进程的意义在于进程让程序有了生命,是操作系统的灵魂所在!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐