实习报告:僵尸进程研究

僵尸进程研究报告

目录

  • 僵尸进程研究报告
  • 摘要
  • 背景知识
    • 1.1 Linux进程的组成
    • 1.2 Linux进程的生命过程
      • 1.2.1 Linux 进程的创建
  • 僵尸进程
    • 2.1 僵尸进程产生的原因
    • 2.2 僵尸进程产生的危害
      • 2.2.1 浪费资源
      • 2.2.2 占用端口
      • 2.2.3 导致父进程死锁
    • 2.3 解决僵尸进程
      • 2.3.1 父进程调用wait()函数来处理僵尸进程
      • 2.3.2 父进程调用waitpid()函数来处理僵尸进程
      • 2.3.3 在信号处理程序中处理僵尸进程
      • 2.3.4 把僵尸进程移交给init进程处理
    • 2.4 Windows中的僵尸进程
  • 文章名词表
  • 附录

摘要

在Unix以及类Unix系统中,存在一种称为“僵死”的非正常的进程状态,通常称处在这种非正常状态的进程为“僵尸进程”。僵尸进程就是当子进程完成了它全部操作并已经发出了退出的系统调用,此时该进程处于终止状态,但该进程却仍然存在进程表中。僵尸进程产生的原因通常是由于该子进程的父进程没有及时执行对子进程的回收操作而导致的。
僵尸进程的存在,轻则导致不必要的资源占用,重则导致父进程以非正常状态结束等危害,在实际开发中应避免僵尸进程产生。
本篇研究报告坚持问题导向,在Linux环境下,以C语言为主,从僵尸进程在实际开发的过程中可能导致的问题的角度来研究僵尸进程。

关键词: 僵尸进程 问题导向 Linux C语言

背景知识

本章节主要介绍研究僵尸进程所需的各种前置知识,如Linux进程的组成,POSIX对多进程定义的函数及其解释说明等。

1.1 Linux进程的组成

程序是存储于磁盘中某处的一个可执行文件,系统内核使用exec函数,将程序读入内存,并执行程序,程序的执行示例则被称为进程。Linux是一个支持多进程的系统,每个进程都是一个独立运行的单位,各个进程都运行在独立的虚拟地址空间,进程之间具有并行性、互不干扰等特点。
从物理存储结构的角度上看,Linux进程由三部分组成,分别是正文段、初始化数据段、未初始化数据段、堆和栈。正文段用于存储CPU执行的机器指令部分,初始化数据段用于存储程序需要明确地赋予初值的变量,未初始化数据段用于存放初始化为0或空指针,栈用于保存自动变量以及每次函数调用时所需保存的信息,堆用于保存动态存储分配的信息,其具体排布如下图所示。
实习报告:僵尸进程研究_第1张图片

图1.1(1) Linux进程物理结构图
从数据结构的角度上看,操作系统采用进程控制块来管理不同的进程,在Linux的进程控制块中,一个进程主要包含如下信息:进程ID标识符、进程状态、进程优先级、程序计数器、内存指针、上下文数据、IO状态信息以及记账信息。进程ID标识符是用来保存与进程相关的唯一标识符,用于区别不同进程。进程状态用于描述进程的状态,在Linux的进程模型中的进程有阻塞、挂起等多个状态。进程优先级保存与任务调度有关的信息。程序计数器存放程序中即将被执行的下一条指令的地址。上下文数据用于保存进程执行时处理器的寄存器中的数据。IO状态信息保存显示的IO请求,分配给进程的IO设备和进程使用的文件列表。记账信息用于保存处理机时间总和等信息。
实习报告:僵尸进程研究_第2张图片
图1.1(2) top命令现实的部分进程数据结构

1.2 Linux进程的生命过程

本章节主要介绍Linux进程的生命过程,了解在Linux系统中进程是如何被创建和销毁的。至于进程的调度,由于对僵尸进程的研究中进程的调度属于次要地位,所以有关于进程调度的知识不在这里展开了。

1.2.1 Linux 进程的创建

在Linux系统中,除去特殊的守护进程,其余的每一个进程都是由其父类进程派生出来的,所以在Linux中存在一种叫进程树的东西,里面记录了进程直接的从属关系,进程树图示如下。
在这里插入图片描述
图 1.2.1 (1) 进程树示意图
从上图可看出,Init进程是所有进程的祖先,进程皆由Init进程派生而来。Init进程由系统内核直接创建于用户空间中,系统再启动其它进程。在系统启动完成后,Init将变成为守护进程监视系统其他进程。Init进程是Linux中必不可少的一部分。
Linux系统提供了fork()函数这个API来实现新进程的创建,一个进程若要创建子进程,必先调用fork()函数。在父进程调用fork()函数之后,系统中会出现两个几乎一样的进程,这两个进程的执行顺序和系统的调度策略有关系。但是每个进程都有独特的进程ID,所以这两个进程的进程ID是不同的。
在fork()函数执行后,由于子进程是父进程的一份拷贝,所以子进程的执行逻辑也会从父进程的fork()函数执行后开始。若希望子进程从头开始执行程序,Linux还提供了exec()函数的API,该函数的作用就是令子进程从头开始执行。
在子进程逻辑结束之后,子进程调用exit()函数来结束自己,此时,父进程应该有一个wait()或者waitpid()函数来侦听子进程发送的退出信号,并执行释放子进程所占用资源的逻辑。
综上所述,在Linux系统中一个正常的进程生命流程应该是这样的:
实习报告:僵尸进程研究_第3张图片
图 1.2.1 (2)进程生命流程示意图

僵尸进程

本章节正式进入关于僵尸进程的研究。本章节先介绍了僵尸进程产生的原因,阐述了僵尸进程可能产生的三点危害,并介绍了解决僵尸进程的方法。

2.1 僵尸进程产生的原因

如1.2.1章节所述,子进程的逻辑结束后,应该有父进程执行waitid()或者waitpid()命令来侦听子进程运行exit()发送出来的SIGCHLD信号。倘若父进程忽略了这个SIGCHLD信号,子进程就会变成僵尸进程,如下图所示。
实习报告:僵尸进程研究_第4张图片
图2.1(1)僵尸进程产生示意图

2.2 僵尸进程产生的危害

2.2.1 浪费资源

进程处于僵死状态,进程得不到回收,僵尸进程就会占据着它申请的内存不释放,造成内存资源的浪费。这可能导致两个隐患,假如进程所占用资源较大,在进程ID号枯竭前可能导致系统无法再为新进程分配内存,假如进程占用资源较少,就会导致系统可用资源随时间变少。
我们先讨论单个进程占用资源较大的情况,在该实验中,每个子进程都会调用malloc方法申请一块极大的内存空间,程序流程图如下。
实习报告:僵尸进程研究_第5张图片
图2.2.1 (1)资源占用程序流程图
实习报告:僵尸进程研究_第6张图片
图2.2.1 (2)内存资源不足错误信息
从上图可看出,物理内存空间还有大量剩余,但是系统还是抛出了内存资源不足的错误,说明导致这个错误出现的原因不完全是僵尸进程的资源占用。
在Linux系统中为了防止内存过度占用,系统有一个虚拟内存与物理内存占比的限制。当子进程变成僵尸进程时,系统就会把子进程所申请的内存资源移动到交换空间,为了证实这个猜想,程序被更变为人为操控逐步增长。
运行“cat /proc/meminfo |grep -i commit”命令,查询得该环境允许进程申请的虚拟内存最大可以达到20g左右,通过人为控制,观察内存占用结果,结果如下。
实习报告:僵尸进程研究_第7张图片

图2.2.1 (3)内存资源信息
可看出,当进程占用资源越来越大,最终占用了约40g内存(物理内存20g+虚拟内存20g),父进程抛出了错误信息。

2.2.2 占用端口

本次试验的思路是父进程执行fork()函数创建子进程,每个子进程通过JNI调用java代码。在java代码中,java虚拟机会扫描系统可用端口并保持占用,观察系统端口占用结果,程序流程图如下。
实习报告:僵尸进程研究_第8张图片
图2.2.2 (1)占用端口程序流程图
但该程序在编写的时候遇到了严重的问题,由于C++语言的标准改变,原有的C/C++语言调用Java虚拟机所需头文件中的创建虚拟机的方法失效。知道目前,本人还在研究改进方法。

2.2.3 导致父进程死锁

在Linux系统中,内核给每个用户分配的最大的进程数量是有限制的,倘若不结束僵尸进程,该僵尸进程就会白白占据一个进程号,从而导致父进程无法申请新的进程号而导致父进程死锁。在该实验程序中,父进程会一直调用fork()函数来创建子进程,子进程再调用fork()函数不会创建子进程,父进程不执行wait()或者waitpid()函数来接收SIGCHLD信号,观察实验结果,程序流程图如下:
实习报告:僵尸进程研究_第9张图片
图2.2.3(1)父进程死锁程序流程图
运行该程序,我们可以通过top命令看到系统中的僵尸进程不断增加,下图显示了在某一时刻的系统状态,此时系统中一共有10017个僵尸进程。
实习报告:僵尸进程研究_第10张图片
图2.2.3(2)僵尸进程数量示意图
在执行一段时间后,由于系统内没有可分配的进程ID,所以该程序会的fork()不断报函数会不断返回错误,在终端上就会显示“fork error:: Resource temporarily unavailable”,表示已经没有新的进程号可用,父进程无法申请到新的进程ID,父进程在此处死锁。
实习报告:僵尸进程研究_第11张图片
图2.2.3(3)终端信息图
图2.2.3(3)显示系统已经产生了32390个僵尸进程,我们通过cat命令查询“pid_max”文件可知该环境内最大的进程数量限制为32768个,除去其它系统必须进程,32390确实是该环境内可给其它进程分配的最大的进程数量,该实验达成目的,实验具体说明文档和代码见附件。

2.3 解决僵尸进程

通过对Linux进程处理机制的研究,本章节提出了几种处理僵尸进程的方法。处理僵尸进程的方法主要分为两大类:父进程主动处理僵尸进程以及移交给其它进程来处理僵尸进程。

2.3.1 父进程调用wait()函数来处理僵尸进程

wait()函数是一个系统调用函数,父进程调用了wait()函数就会阻塞自己,等待子进程发送SIGCHLD信号,收到SIGCHLD信号后,wait()函数就会收集这个子进程的信息,并把子进程彻底销毁后返回。倘若子进程一直没有发送SIGCHLD信号,父进程就会一直阻塞在wait()函数这里直到某个子进程发送SIGCHLD信号为止。值得注意的是,wait()函数一般与fork()函数一一配对使用。倘若只有一个wait()函数而又多个子进程,子进程几乎同时结束,那么wait()函数只会处理最先到达的SIGCHLD信号,回收发送最先到达的SIGCHLD信号的子进程,剩余的子进程就会变成僵尸进程,实验程序流程图如下。
实习报告:僵尸进程研究_第12张图片
图2.3.1(1)wait()实验流程图
随着进程不断运行,新的子进程不断被创建销毁,由于Linux系统对被销毁的进程的ID号会保留一段时间,所以子进程的ID会在空的进程的ID池中不断往复增长,实验结果如下图。
实习报告:僵尸进程研究_第13张图片
图2.3.1(1)wait()实验结果图
可见,系统不再有僵尸进程出现。

2.3.2 父进程调用waitpid()函数来处理僵尸进程

waitpid()函数相当于增强版的wait()函数,两者的调用是大致相同的,但是waitpid()函数多出了pid以及options两个参数,让waitpid()更加灵活,waitpid()函数是非阻塞的。当waitpid()的参数pid大于0时,waitpid()只等待给定的pid的子进程的SIGCHLD信号;当pid等于-1时,waitpid()函数等效于wait()函数;当pid等于0时,waitpid()函数等待同一个进程组中的任何子进程的SIGCHLD信号;当pid小于-1时,父进程等待一个指定进程组中的任何子进程,实验流程图如下。
实习报告:僵尸进程研究_第14张图片
图2.3.2(1)waitpid()实验流程图
当我们把pid参数设置成fork()函数的返回值时,子进程正常结束被回收,且父进程未被阻塞,结果如下图。
实习报告:僵尸进程研究_第15张图片
图2.3.2(2)waitpid()实验结果图
当我们把pid参数设置成一个随机值时,随机值的进程ID的子进程正常结束被回收,且父进程未被阻塞,但是其它进程ID的子进程未被正常回收,产生大量僵尸进程。

2.3.3 在信号处理程序中处理僵尸进程

如果父进程业务繁忙以至于不能抽出时间来处理子进程的回收,我们可以把子进程回收的业务逻辑转移到信号处理的函数中,程序流程图如下。
实习报告:僵尸进程研究_第16张图片
图2.3.2(1)信号处理函数流程图

从结果可看出,系统未出现僵尸进程,而父进程也未阻塞。
实习报告:僵尸进程研究_第17张图片
图2.3.2(2)信号处理函数结果图
实习报告:僵尸进程研究_第18张图片
图2.3.2(3)信号处理函数结果图

2.3.4 把僵尸进程移交给init进程处理

在Linux系统中,倘若父进程比子进程先结束,子进程就会变成孤儿进程,即子进程的数据结构中所记录的父进程变成init进程。我们也可以把子进程移交给init进程处理,init本用于处理孤儿进程的回收,我们通过把子进程变成孤儿进程来间接达到处理僵尸进程的目的。这种方式就要求我们结束父进程的运行。所以我们在生产环境中需要定期重启系统。

2.4 Windows中的僵尸进程

通过查阅《深入解析Windows操作系统》一书可知,Windows系统远比Linux系统复杂。Windows中的进程是以消息为驱动的,进程之间的继承关系很弱。当一个进程需要创建另一个进程时,父进程通过令牌来传递相关的权限,之后这两个进程相互独立。所以在Windows系统中不存在僵尸进程的概念。

文章名词表

Unix 一种操作系统
Linux 一种操作系统
exec Linux的一种内核调用函数
fork() Linux系统中用于创建子进程的函数
exit() Linux系统中用于退出当前进程的函数
pid Linux系统中进程的标识符
init Linux系统中一种守护进程
top命令 用于查看系统资源占用情况的命令

附录

程序说明文档详见:程序文档

你可能感兴趣的:(GIG,僵尸进程)