进程控制


来源:《基于UNIX/Linux的C系统编程》、《LPI LINUX认证权威指南(第二版)》

进程控制的职责是对系统中全部进程实施有效的管理。这意味着应该具有创建新进程、阻塞进程、激活进程和撤消已经结束的进程的能力。

一、常见的Shell命令:

当打开终端时,bash shell本身也是一个进程。在shell中所键入的每个命令都会触发一个或多个进程,某些进程甚至会衍生出新进程,称为子进程。关于进程的各项属性与概念如下:

生命周期(lifetime)

进程的整个执行期间称为“生命周期”。用户于命令行触发的进程(例如ls),它们的生命周期多半相当短暂。但是对于提供公共服务的服务器进程(例如Apache web server),它们的生命周期就相当长,甚至是在系统启动之后就开始,直到关机或管理员予以结束时为止。一般而言,有daemon或server称号的程序几乎都是生命周期相当长的进程。进程结束其生命周期的状态为die,而能够促成死亡的工具程序就称为kill。

进程标识符(Process ID,简称PID)

当进程被启动时都会被赋予一个整数,称为“进程标识符”。每个进程都有自己专属的PID,各不相同,当管理员要传送信号给进程时,就是以PID来描述所要处理的进程。

用户标识符(User ID,简称UID)与组标识符(Group ID,简称GID)

进程所获得的访问权限继承自启动该进程的用户以及用户所属的组。有些长期运行的服务器进程可允许用户指定其他的UID和GID,使其在权限较低的环境下运行。

父进程(parent process)与子进程(child process)

在系统启动期间,内核所启动的第一个进程是init,其PID固定为1,它是系统上所有其他进程的终极始祖。我们使用的shell其实是init的一个子进程,而用户通过shell所启动的每一个进程都是shell的子进程(shell自己是他们的父进程)。注:shell的内置命令,像是alias、bg、cd、echo、jobs和test,并不会被启动成子进程,因为它们原本就是shell进程的一部分。

父进程标识符(parent process ID,简称PPID)

子进程的父进程标识符。如果父进程已消逝,子进程的PPID属性将会变成1,也就是init的PID。

运行环境(environment或context)

内核会为每个进程准备一个工作区,进程可以从此工作区获得环境的配置信息,也就是环境变量(environment variable)。进程的运行环境继承自父进程,但是在进程的运行过程中,包括进程刚启动时所执行的初始程序,环境可能会被改变。

当前工作目录(current work directory)

若命令参数中的文件名不是完整的路径(即绝对路径),则以当前工作目录为相对起点。通常,进程的当前工作目录是用户启动进程时所在的目录位置。


ps

语法: ps [options]

说明: 依据option所描述的格式显示进程的状态信息。

常用选项:

-a  显示连接终端机的进程,包括其他用户所拥有的进程。默认情况下,ps只显示当前用户所拥有的进程。

-f  “forest”模式,以从属关系来安排进程信息的出现顺序。父进程会排列在前,其后是其子进程。例如,ps -f -C httpd可显示Apache web server的所有进程。

-l  以long format输出,所显示的内容将包括Priority(优先级)、PPID以及其他信息。

-u  以user format输出,所显示的内容将包括username以及start time(进程的起始时间)。

-w  以wide format输出,若显示的项目过多而超过终端机屏幕边界时,则转到下一行(默认行为是直接截断,不显示超出边界的项目)。通常搭配-f选项一起使用。

-x  显示没连接终端机的所有进程(通常是长期运行的服务器进程)。

-C cmd  显示命令名称为cmd的所有进程。

-U usr  显示由usr所拥有的所有进程。

范例:

在不附加任何选项的情况下,ps只列出属于用户个人、连接终端机的进程,合并-a、-u、-x三个选项,ps将以“用户格式”显示任何符合下列两个条件之一的所有的进程:属于其他用户或没连接终端机。此时,选项是否前置破折号不会影响命令执行的结果,然而这并非通则,ps的某些选项仍需前置破折号。

2015-12-24 12:28:06屏幕截图

如果你只对特定命令的进程信息有兴趣,可以使用-C选项。例如只显示Apache web server的所有进程(如果是Apache 2.x,则将httpd改为apache2):

$ ps u -C httpd

你是否注意到-C选项前置了破折号,但u选项却没有?如果在u选项前置破折号,ps将不会正确地执行。之所以会存在这种令人混淆的状况,是因为Linux系统上的ps命令被刻意设计成兼任三种系统的惯例:

  • Unix98选项: 这类选项可以合并指定,而且必须前置破折号。
  • BSD选项: 这类选项可以合并指定,而且不得前置破折号。
  • GNU长格式选项: 这类选项指定时必须前置两个破折号。

以上三种不同形式的选项可以自由混合使用。如果不使用-C选项的话,你可能会希望以自己常用的选项来执行ps并且通过管道将输出导入grep,搜索进程的名称、PID或是任意你所知道的与进程有关的属性:

$ ps -aux | grep apache2

2015-12-24 12:33:47屏幕截图


pstree

语法: pstree [options] [pid|user]

说明: pstree命令的功能类似于ps -f的”forest”模式。此命令会以树状格式来呈现进程之间的从属关系。当想了解父/子进程间的从属关系,pstree将会是一个非常好用的工具。若指定了PID,则pstree会将PID代表的进程当成树根(root),否则会以init进程为树根。若指定了用户名称,则pstree会列出该用户所拥有的所有进程。显示进程树时,pstree会使用类似线条的字符来描绘树的结构;|代表直线,以+代表树的节点(若选用VT100的画线字符,则大部分终端机将会以实线而非虚线来显示进程树)。在默认情况下,pstree会把同名进程分支合并起来,但是在合并之处加注被省略的进程数,例如:httpd—5*[httpd]使用-c选项可关闭pstree的合并功能。

常用选项:

-a  显示进程启动时所使用的命令行自变量。

-c  停止对同名的子树(或分支)进行合并。

-G  显示进程树时以VT100的画线字符取代一般的字符。

-h  加强当前进程从属关系的显示亮度。终端机本身必须具备加强亮度的功能,使用此选项才有意义。

-n  对具有相同根源的进程而言,默认会以英文字母的顺序进行排序。此选项会让pstree改以PID值的大小来进行排序。

-p  同时显示进程的PID。

范例:

2015-12-24 12:37:21屏幕截图


top

语法: top [command-line options]

说明: top能提供类似ps的输出,不同的是top会持续更新画面,而不是显示一次之后就立即结束。当需要监控一个或多个进程的状态,或是想了解各进程耗用了多少系统资源时,top将会是个有用的工具。

2015-12-24 12:39:43屏幕截图

在top所显示的画面中,顶排是各字段的名称,诸如开机时间、系统负载、CPU状态以及内存使用量等。在默认情况下,top会依据CPU使用状态动态调整进程的高低顺序,正在被CPU运行的进程会被推到最顶端。此外,可以用TERM环境变量让top来发挥终端机的显示特性,但如果没设定此环境变量或是top不认识TERM所代表的终端机类型,top可能会无法执行。

常用的命令行选项(top选项不一定要前置破折号):

-b  以批量模式运行。在此模式下,top只输出一次信息,然后就立刻结束,让你可将输出信息送往其他程序或存入文件。通常-b选项会搭配-n选项来指定状态取样次数。当你使用了top所不支持的终端机类型时,也可以使用此模式。

-d delay  将状态取样时间的间隔设定为dalay秒,也就是每隔dalay秒就更新一次画面。默认值为5。

-i  略过闲置(idle)的进程,只列出活动中的进程。

-n num  设定状态取样次数。最后一次取样之后top会自动结束。

-q  持续取样并不断更新画面,没有任何延迟。当以root身份来执行top时,top自己可能获得最优先的执行权,而由于此选项会使得top持续保持在高能活动状态,所以它会抢占CPU的所有闲置时间。因此,以root身份执行top -q时将会严重拖累系统性能,所以不建议这么做。

-s  以安全模式运行,取消有危险性的交互命令。若以root身份执行top,此选项可避免在无意中触发了可能让你后悔的命令。

常用的交互模式命令:

在top的交互模式下,用户可通过键盘下达一些命令以改变top的运行方式。这些命令几乎都是单键命令,而其中有部分会提示用户提供进一步的信息。

Ctrl + L  重绘一次画面

h  显示辅助画面

k  kill掉进程。top会提示用户提供进程的PID,然后传送信号给它(默认的信号为15,即SIGTERM)。

n  改变进程的显示数量。top会提示你键入一个整数值。默认为0,代表不限制显示数量,直到填满画面为止。

q  结束top的执行。

r  renice进程(改变其优先级)。top将会提示用户键入进程的PID以及它的nice(谦和度)值,大于0的nice值将会降低进程的优先级,而小于0的nice值则可提升进程的优先级,但只有超级用户能够改变进程的nice程度。此命令在安全模式下会被禁用。

s  改变画面更新的延迟时间,单位为秒。top将会提示用户键入延迟时间值,可使用带小数点的秒数(如0.5)。

范例:

将画面更新周期缩短为1秒

$ top -d 1

将top的取样结果存入文件

$ top bin 5 d 1 > file1

此例中的bin 5是-b(批量)、-i(略过闲置的进程)、-n 5(取样5次)的综合。

二、系统编程中的进程控制:

1)进程的标识

Linux中读取进程标识号的函数如下:

#include <sys/types.h>
#include <unistd.h>
pid_t getpid();
pid_t getpgrp();
pid_t getppid();

函数getpid返回当前进程ID;函数getpgrp返回当前进程组ID;函数getppid返回父进程ID。类型pid_t是进程标识类型,可以用long或int代替。

Linux中读取进程用户标识号的函数如下:

#include <unistd.h>
uid_t getuid();
uid_t geteuid();
uid_t getgid();
uid_t getegid();

函数getuid返回进程实际用户ID;函数geteuid返回进程有效用户ID;函数getgid返回进程实际组ID;函数getegid返回进程有效组ID。

例1:显示当前进程ID、进程组ID、父进程ID、进程实际用户ID、进程有效用户ID、进程实际组ID和进程有效组ID。

#include <unistd.h>
#include <stdio.h>
int main()
{
    printf("pid=[%d], gid=[%d], ppid=[%d]\n", getpid(), getpgrp(), getppid());
    printf("uid=[%d], euid=[%d], gid=[%d], egid=[%d]\n", getuid(), geteuid(), getgid(), getegid());
    return 0;
}

2015-12-24 12:48:21屏幕截图

2)在一个进程中启动另一个进程

exec函数族提供了一个在进程中启动另一个程序执行的方法。这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行的脚本文件。

例2:在程序中运行另一命令”ps -ef”,要求使用函数execlp(“ps”, “-ef”, NULL)。其具体步骤为(1)程序调用execlp;(2)内核从磁盘将程序载入;(3)内核将”-ef”复制到进程;(4)内核调用main。

#include <unistd.h>
#include <stdio.h>
int main()
{
    printf("test exec ps -ef \n");
    execlp("ps", "-ef", NULL);
    printf("test is done.\n");
}

2015-12-24 12:50:45屏幕截图

此程序只有3条语句,第一句和最后一句都是打印提示信息,只有第二句是用于启动另一进程运行的。从输出结果中发现,第一句和第二句都运行正常,可最后一句的打印提示信息并没有输出。那么这条信息去哪里了呢?原来execlp函数根据指定的文件名或目录找到可执行文件后,用它来取代原调用进程,在执行完之后,原调用进程的内容除了进程号外,其他全部被新的进程替换了。

exec系统调用从当前进程中把当前程序的机器指令清楚,然后在空的进程中载入调用时指定的程序代码,最后运行这个新的程序。exec可以调整进程的内存分配并使之适应新的程序对内存的要求,相同的进程,不同的内容。

该类函数族总结如下表所示:

所需头文件 #include <unistd.h>
函数原型 int execl(const char *path, const char *arg, …);
int execv(const char *path, const char *arg[]);
int execle(const char *path, const char *arg, …, char *const envp[]);
int execve(const char *path, const char *arg[], char *const envp[]);
int execlp(const char *file, const char *arg, …);
int execvp(const char *file, const char *arg[]);
函数说明 前4位 统一为exec
第5位 l: 参数传递为逐个列举方式 execl execle execlp
v: 参数传递为构造指针数组方式 execv execve execvp
第6位 e: 可传递新进程环境变量 execle execve
p: 可执行文件查找方式为文件名 execlp execvp
函数返回值 若出错,返回-1

3)进程的创建

fork系统调用函数可以用来生成新进程。

调用fork函数生成新进程时,看似只需一条语句,但实际调用过程远没有那样轻松。生成新进程需要构建进程的正文段、数据段、堆栈段和系统数据段,这些都是需要内核去解决的。当进程调用fork,控制权转移到内核之后,内核主要做如下操作:

  1. 分配新的内存块和系统数据段;
  2. 复制原来的进程到新的进程;
  3. 向运行进程集添加新的进程;
  4. 将控制返回给两个进程。

例3:建立一个新进程。

#include <unistd.h>
#include <stdio.h>
int main()
{
    int ret, mypid;
    mypid = getpid();
    printf("Before fork: pid = %d\n", mypid);
    ret = fork();
    printf("After fork: pid = %d, return value = %d\n", getpid(), ret);
    return 0;
}

2015-12-24 12:52:56屏幕截图

由上图所示的运行结果可以看到有3行输出,分别是一条调用fork之前的pid信息()和两条调用fork之后的pid信息(),调用一次fork之后,看到函数返回两次,且两次的返回值也不一样。这是因为,程序fork运行之后,该程序就转换成进程号为()的进程了,在调用fork之前,只有这个()进程在执行,但在调用fork之后,系统中就出现了两个进程在同时执行,这两个进程共享代码段,即程序中第二个printf的位置,直到程序结束,所以看到的结果出现了两条”After…”打印信息,分别是新生成的进程()和进程()运行的第二个printf。再看返回值,当系统中出现两个进程时,原来进程()在调用fork之后,fork函数的返回值就是新生成进程的进程号(),而新进程()在调用fork之后,fork函数的返回值是0。

通常把新生成的进程称为子进程,把原进程称为父进程,其判断父子进程的依据就是fork的返回值。当返回值为0时,则该进程即为子进程,当返回值为非0正数,则该进程即为父进程(原进程)。

例4:根据fork返回值的不同打印不同信息。

#include <unistd.h>
#include <stdio.h>
int main()
{
    int ret, mypid;
    mypid = getpid();
    printf("Before fork: pid = %d\n", mypid);
    ret = fork();
    if (ret < 0)
        printf("error in fork!\n");
    else if (ret == 0)
        printf("After fork: I am the child process, ID is %d\n", getpid());
    else
        printf("After fork: I am the parent process, ID is %d\n", getpid());
    return 0;
}

2015-12-24 12:54:43屏幕截图

那可不可以子进程再去生成子进程呢?

例5:子进程再去生成子进程。

#include <unistd.h>
#include <stdio.h>
int main()
{
    printf("This is my pid: %d\n", getpid());
    fork();
    fork();
    fork();
    printf("Now, my pid: %d\n", getpid());
    return 0;
}

2015-12-24 12:58:35屏幕截图

从运行结果看到,该进程一共生成了7个子进程,且父子进程的执行顺序是不确定的。fork在生成子进程时,其子进程的数据空间、堆栈空间都是从父进程得到的一个副本,而不是共享。那么,有没有生成的子进程能与父进程共享数据段的系统调用呢?有,那就是vfork系统调用。

例6:vfork。

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
    pid_t mypid1, mypid2;
    int flag = 0;
    printf("This is parent1's pid: %d\n", getpid());
    mypid1 = vfork();
    if (mypid1 < 0)
        exit(1);
    else if (mypid1 == 0)
        printf("This is child1 process! my pid: %d\n", getpid());
    else
        printf("This is parent1 process! my pid: %d\n", getpid());
    mypid2 = vfork();
    if (mypid2 < 0)
        exit(1);
    else if (mypid2 == 0) {
        if (flag == 0)
            printf("This is child2 process! pid: %d\n", getpid());
        else
            printf("This is another child2 process! pid: %d\n", getpid());
        exit(0);
    }
    else {
        if (flag == 0)
            printf("This is parent2 process! pid: %d\n", getpid());
        else
            printf("This is parent1 process after second vfork! pid: %d\n", getpid());
        flag = 1;
        exit(0);
    }
}

2015-12-24 14:11:40屏幕截图

使用fork,不但能够创建新进程,而且能够分辨原来的进程和新建的进程。这与以往在shell中只能使用一个进程去解决处理问题的方式有很大不同,同时也与exec函数族以新进程替代旧进程去处理问题的方式不同。下表为fork函数族语法要点

所需头文件 #include<unistd.h>
函数原型 pid_t result = fork(void);
pid_t result = vfork(void);
参数
函数说明 frok: 子进程复制父进程的数据段,且父、子进程的执行次序不确定
vfork: 子进程与父进程共享数据段,且子进程先运行,父进程后运行
返回值 若调用失败,返回-1
若调用成功,返回0,表示该进程为新进程;若返回非0正数,则该进程为原进程,返回值为新进程的pid

4)让调用进程进入休眠状态

sleep系统调用函数可以使进程进入休眠状态。其函数原型如下:

#include <unistd.h>
unsigned int sleep(unsigned int seconds);

进程调用函数sleep后休眠seconds秒,直到时间结束或收到不可忽略的信号为止。

5)进程的撤消

可以使用exit系统调用函数来完成此任务。exit函数是fork函数的逆操作。fork可以用来创建一个进程,而exit则是用来撤消一个进程,并释放该进程所占用的内存,关闭所有被该进程打开的文件,释放所有内核用来管理和维护这个进程的数据结构。这些操作在各个版本的UNIX中有些不同,但其都包括以下操作:

  1. 关闭所有文件描述符和目录描述符;
  2. 将该进程的PID置为init进程的PID;
  3. 如果父进程调用wait或waitpid来等待子进程结束,则通知父进程;
  4. 向父进程发送SIGCHLD(阻塞)信号。

其函数原型如下:

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

进程调用函数exit终止自己的运行,并且释放所占的系统资源。参数status的低8位记载了进程终止的状态,进程终止后将这个状态返回给它的父进程。

对于子进程调用exit后系统该如何处理?这个子进程会在被撤销之前向内核发出消息,这个消息就存放在内核的某个特定的位置上,直到其父进程通过wait系统调用取回这个消息,并通知其子进程已经被撤销。若父进程始终不调用wait,则该消息将会一直保存在内核中。

如果父进程在子进程之前退出,那么子进程将继续运行,而不会成为“孤儿”,它们将是init进程的子女。若该父进程在被撤销之前没有调用wait,内核也会向其发送SIGCHLD消息。

若进程已经被撤销但还没有调用exit,则该进程称为幽灵进程或僵死进程(zombie)。所谓的僵死进程就是指已经被终止,但还没有从进程表中清除的进程。每个进程退出的时候,内核释放该进程所有的资源,但是仍然为其保留了一定的信息(包括PID、退出状态和运行时间等),直到父进程通过wait/waitpid来取时才释放。此时该进程处于僵死状态。系统这么做的目的主要是为了保证父进程可以获取到子进程结束时的状态信息。

例7:展示一个产生僵死进程的例子。

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
    pid_t mypid;
    if ((mypid = fork()) == 0) {
        printf("child[%d]\n", getpid());
        exit(0);
    }
    /* wait() */
    printf("parent[%d]\n", getpid());
    sleep(30);
    return 0;
}

2015-12-24 14:19:39屏幕截图

从运行结果中看到,有标记为Z的进程就是僵死进程。僵死进程是一种非常特殊的进程,如果其父进程没有响应或显式忽略子进程在撤销时向其发送的SIGCHLD信号,并且也没有调用wait或waitpid()等待子进程结束,那么子进程将一直保持僵死状态。如果这时父进程结束了,僵死的子进程将成为“孤儿进程”,并通过继给1号进程init,init负责清理系统中的僵死进程。

虽然僵死进程放弃了几乎所有的内存空间,也没有任何执行代码,也不能被调度,但是其所保留的那段识别信息不会释放,且将一直被占用。由于系统中所能使用的进程ID是有限的,如果产生了大量的僵死进程,系统将会因为没有可用的进程ID而导致进程无法创建。

为避免产生僵死进程,一般的做法是:

  • 父进程主动调用wait或waitpid等函数等待子进程结束,但这样会导致父进程被挂起。
  • 通过signal函数为SIGCHLD安装信号处理函数。若子进程结束,则父进程会收到该信号,在信号处理函数中调用wait回收。
  • 如果父进程不关心子进程什么时候结束,则可以用signal(SIGCHLD, SIG_IGN)通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收,并不再给父进程发送信号。或用sigaction函数为SIGCHLD设置SA_NOLDWAIT,这样子进程结束后,就不会进入僵死状态,代码如下:struct sigaction sa;sa.sa_handler = SIG_IGN;sa.sa_flags = SA_NOCLDWAIT;sigemptyset(&sa.sa_mask);sigaction(SIGCHLD, &sa, NULL);
  • 如果所有父进程先于子进程而终止,则它所有的子进程将转由1号进程init领养,即其所有子进程的父进程ID变为1。当子进程结束时init进程则为其释放进程表资源。

 

Leave a comment

邮箱地址不会被公开。 必填项已用*标注