3.6 与进程相关的系统调用及其应用

以上介绍的是操作系统内核对进程所进行的管理。下面从编程者的角度来说明开发人员如何利用内核提供的系统调用进行程序的开发。这一方面有助于读者对操作系统内部的进一步了解,另一方面有助于读者在应用程序的开发中充分利用系统调用来提升程序的质量。

前面我们已经对getpid 、fork、exec等系统调用有初步了解,下面在对这些系统调用进一步要了解的基础上,另外介绍几个系统调用。

此外,在这里要说明的是每个系统调用在返回时除了返回正常值外,还要返回错误码。Linux为了防止与正常的返回值混淆,并不直接返回错误码,而是将错误码放入一个名为errno的全局变量中。如果一个系统调用失败,就可以读出errno的值来确定问题所在。errno不同数值所代表的错误消息定义在errno.h中,可以通过命令"man 3 errno"来察看它们。

3.6.1 fork系统调用

如前所述,fork系统调用的作用是复制一个进程。当一个进程调用它时,就出现两个几乎一模一样的进程,我们也由此得到了一个新进程。据说fork的名字就是来源于这个与叉子的形状颇有几分相似的工作流程。

我们回头看2.1.4节的进程举例。再次看到这个程序的时候,必须明确知道,在语句pid=fork()之前,只有一个进程在执行这段代码。当执行到fork()时,就陷入内核,具体说就是执行内核中的do_fork()函数。于是,在这条语句之后,就变成两个进程在执行了。

fork可能有三种不同的返回值:

(1) 父进程中,fork返回新创建子进程的进程ID;

(2) 子进程中,fork返回0;

(3) 如果出现错误,fork返回一个负值;

fork出错可能有两种原因:(1)当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。(2)系统内存不足,这时errno的值被设置为ENOMEM。fork系统调用出错的可能性很小,而且如果出错,一般都为第一种错误。如果出现第二种错误,说明系统已经没有可分配的内存,正处于崩溃的边缘,这种情况对Linux来说是很罕见的。

3.6.2 exec系统调用

如果调用fork后,子进程和父进程几乎完全一样,而系统中产生新进程唯一的方法就是fork,那岂不是系统中所有的进程都要一模一样吗?那我们要执行新的应用程序时候怎么办?多数情况下,执行完fork后,子进程需要执行与父进程不同的代码。例如,对于一个shell,它首先从终端读取命令,然后创建一个子进程来执行该命令,shell进程等待子进程执行完毕,然后再读取下一条命令。为了等待子进程结束,父进程执行一条wait系统调用。该系统调用使父进程阻塞,直到它的任一个子进程结束。 现在再来看shell如何使用fork。当键入一条命令时,shell首先创建一个子进程。用户的命令就是由该子进程执行,这是通过调用exec系统调用实现的。一个高度简化的shell框架如下:

while(TURE)                             /*TURE为1,无限循环*/
 read_command(command, parameters);    /*从终端读取命令*/
if (fork()!=0){                        /*创建子进程*/
  /* Parent code*/
  wait(NULL);                    /*等待子进程结束*/
} else {
 /*Child code*/
exec(command, parameters,0);    /*执行命令*/
  }  
}

wait系统调用等待子进程的结束。Exec有三个参数:待执行的文件名、指向参数数组的指针和指向环境变量的指针。系统提供了若干例程来简化这些参数的使用,包括execl, execv, execle和execve。本书采用exec来泛指所有这些系统调用。

exec函数族的作用是根据指定的文件名找到可执行文件,换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行的脚本文件。

与一般情况不同,exec函数族的函数执行成功后不会返回,因为调用进程的实体都已经被新的内容取代,只留下进程ID等一些表面上的信息仍保持原样,颇有些神似“三十六计”中的“金蝉脱壳”。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回一个-1,从原程序的调用点接着往下执行。

现在我们应该明白Linux下是如何执行新程序的,每当有进程认为自己不能为系统和用户做出任何贡献了,他就可以发挥最后一点余热,调用任何一个exec,让自己以新的面貌重生;或者,更普遍的情况是,如果一个进程想执行另一个程序,它就可以fork出一个新进程,然后调用任何一个exec,这样看起来就好像通过执行应用程序而产生了一个新进程一样。

事实上第二种情况被应用得如此普遍,以至于Linux专门为其作了优化,这就是我们前面所说的“写时复制"技术,使得fork结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样如果下一条语句是exec,它就不会白白作无用功了,也就提高了效率。

3.6.3 wait系统调用

进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,释放其PCB,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。

1.参数为空

wait的函数原型为:pid_t wait(int *status)

其中参数status用来保存被收集进程退出时的一些状态,它是一个指向int类型的指针。但如果我们对这个子进程是如何死掉的毫不在意,只想把这个僵尸进程消灭掉,(事实上绝大多数情况下,我们都会这样想),我们就可以设定这个参数为NULL,就像下面这样: pid = wait(NULL);

如果成功,wait会返回被收集的子进程的进程ID,如果调用进程没有子进程,调用就会失败,此时wait返回-1,同时errno被置为ECHILD。

下面就让我们用一个例子来实战应用一下wait调用:

/* wait1.c*/
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
main()
{
    pid_t pc, pr;
    pc=fork();
    if(pc<0)         /* 如果出错 */
        printf("error ocurred!\n");
    else if(pc==0){        /* 如果是子进程 */ 
        printf("This is child process with pid of %d\n",getpid());
        sleep(10);    /* 睡眠10秒钟 */
    }
    else{            /* 如果是父进程 */
        pr=wait(NULL);    /* 在这里等待 */
        printf("I catched a child process with pid of %d\n",pr);
    }        
    exit(0);
}

编译并运行该程序:

 $ cc wait1.c -o wait1
 $ ./wait1
This is child process with pid of 1508
I catched a child process with pid of 1508

运行时可以明显注意到,在第2行结果打印出来前有10秒钟的等待时间,这就是我们设定的让子进程睡眠的时间,只有子进程从睡眠中苏醒过来,它才能正常退出,也就才能被父进程捕捉到。其实这里我们不管设定子进程睡眠的时间有多长,父进程都会一直等待下去,读者如果有兴趣的话,可以试着自己修改一下这个数值,看看会出现怎样的结果。

另外,某些时候,父进程要等待子进程算出结果后才进行下一步的运算,或者子进程的功能是为父进程提供了下一步执行的先决条件(例如子进程建立文件,而父进程写入数据),此时父进程就必须在某一个位置停下来,等待子进程运行结束,而如果父进程不等待而直接执行下去的话,可以想见,会出现极大的混乱。这种情况称为进程之间的同步,更准确地说,这是进程同步的一种特例。进程同步就是要协调好两个以上的进程,使之以安排好地次序依次执行。解决进程同步问题有更通用的方法,我们将在以后介绍,但对于我们假设的这种情况,则完全可以用wait系统调用简单地予以解决。

前面这段程序还说明,当fork调用成功后,父子进程各做各的事情,但当父进程的工作告一段落,需要用到子进程的结果时,它就调用wait等待,一直到子进程运行结束,然后利用子进程的结果继续执行,这样就圆满地解决了我们提出的进程同步问题。

2.参数不为空

如果参数status的值不是NULL,wait就会把子进程退出时的状态取出并存入其中,这是一个整数值(int),指出了子进程是正常退出还是被非正常结束的(一个进程也可以被其他进程用信号结束),以及正常结束时的返回值,或被哪一个信号结束的等信息。由于这些信息被存放在一个整数的不同二进制位中,所以用常规的方法读取会非常麻烦,人们就设计了一套专门的宏(macro)来完成这项工作,下面我们来说明其中最常用的两个:

(1) WIFEXITED(status) :这个宏用来指出子进程是否为正常退出的,如果是,它会返回一个非零值。(注意,这里的status为整数,而wait的参数为指向整数的指针)

(2) WEXITSTATUS(status): 当WIFEXITED返回非零值时,这个宏用来就提取子进程的返回值。

3.6.4 exit系统调用

从 exit的名字可以看出,这个系统调用是用来终止一个进程的。无论exit在程序中处于什么位置,只要执行到该系统调用就陷入内核,执行该系统调用对应的内核函数do_exit()。该函数回收与进程相关的各种内核数据结构,把进程的状态置为TASK_ZOMBIE,并把其所有的子进程都托付给init进程,最后调用schedule()函数,选择一个新的进程运行。

exit的函数原型为:void exit(int status);

exit系统调用带有一个整数类型的参数status,我们可以利用这个参数传递进程结束时的状态,比如说,该进程是正常结束的,还是出现某种意外而结束的,一般来说,0表示没有意外的正常结束;其他的数值表示进程非正常结束,出现了错误。我们在实际编程时,可以用wait系统调用接收子进程的返回值,从而针对不同的情况进行不同的处理。

这里要说明的是,在一个进程调用了exit之后,该进程并非马上就消失掉,而是仅仅变为僵尸状态。僵尸状态的进程(称其为僵死进程)是非常特殊的,虽然它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,但它的PCB还没有被释放。

僵尸进程的PCB中保存着对程序员和系统管理员非常重要的很多信息,比如,这个进程是怎么死亡的?是正常退出呢,还是出现了错误,还是被其它进程强迫退出的?其次,这个进程占用的总系统CPU时间和总用户CPU时间分别是多少?发生缺页中断的次数和收到信号的数目又是多少?这些信息都被存放在其PCB中。试想如果没有僵尸状态的进程,进程一退出,所有与之相关的信息都立刻归于无形,而此时程序员或系统管理员想知道这些信息时就束手无策了。

当一个进程调用exit已退出,但其父进程还没有调用系统调用wait对其进行收集之前的这段时间里,它会一直保持僵尸状态,利用这个特点,我们给出一个简单的小程序:

#include <sys/types.h>
#include <unistd.h>
main()
{
    pid_t pid;
    pid=fork();
    if(pid<0)    
        printf("error occurred!\n");
    else if(pid==0)  
        exit(0);
else        
        { sleep(60);    /* 睡眠60秒,这段时间里,父进程什么也干不了 */
        wait(NULL);    /* 收集僵尸进程的信息 */
            }  
}

sleep的作用是让进程睡眠指定的秒数,在这60秒内,子进程已经退出,而父进程正忙着睡觉,不可能对它进行收集,这样,我们就能保持子进程60秒的僵尸状态。

那么,我们如何收集这些信息,并终结这些僵尸的进程呢?这就要靠我们前面讲到的wait系统调用。其作用就是收集僵尸进程留下的信息,同时使这个进程彻底消失。

3.6.5进程的一生

下面让我们用一些形象的比喻,来对进程短暂的一生作一个小小的总结:

随着一句fork,一个新进程呱呱落地,但这时它只是老进程的一个克隆。然后,随着exec,新进程脱胎换骨,离家独立,开始了独立工作的职业生涯。

人有生老病死,进程也一样,它可以是自然死亡,即运行到main函数的最后一个"}",从容地离我们而去;也可以是中途退场,退场有2种方式,一种是调用exit函数,一种是在main函数内使用return,无论哪一种方式,它都可以留下留言,放在返回值里保留下来;甚至它还可能被谋杀,被其它进程通过另外一些方式结束它的生命。

进程死掉以后,会留下一个空壳,wait站好最后一班岗,打扫战场,使其最终归于无形。这就是进程完整的一生。

results matching ""

    No results matching ""