天天看点

《linux下进程的创建,执行,监控和终止》

概述

        这篇文章主要讲述linux下进程的相关操作,后续还会写一篇关于linux线程操作的文章。这两篇文章和我后续还要完成的一篇文章(linux下的IPC通信)组成一个完整的系列,可以说前两篇是第三篇的铺垫和基础。

第一部分:fork创建进程

================================================

        首先来看一个fork() 创建子进程的例子。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

extern void error_exit(char*);
extern void success_exit(char*);

int main(int argc, char* argv[])
{
    pid_t child_pid;

    switch(child_pid = fork()) {
    case -1:
        error_exit("fork failed.");
        break;
    case 0:
        sleep(5);
        success_exit("child exit");
        break;
    default:
        if(wait(NULL) == -1) {
            error_exit("wait failed.");
        } else {
            success_exit("child has exit.");
        }
        break;
    }
    return 0;
}

void error_exit(char* msg)
{
    printf("NG: %s\n",msg);
    exit(-1);
}

void success_exit(char* msg)
{
    printf("OK: %s\n",msg);
    exit(0);
}
           

        上面的例子是最简单的,创建子进程失败则退出,否则子进程等待5秒和打印"child exit"并退出,父进程等待子进程退出后输出“child has exit”退出。关于fork和父子进程之间的关系,这里有有以下几点需要说明:

1. fork有三类返回值:失败返回-1;成功时,子进程返回0;父进程返回子进程的PID。

2. 理论上,fork创建的子进程会继承(拷贝)一份父进程的数据段,堆栈,代码段。实际上,为了避免浪费,fork不会立即给子进程做任何拷贝。对于代码段,fork会将子进程的进程级页表项指向与父进程相同的物理内存页帧;对于数据段,堆栈段,则采用写时复制技术来处理。这是非常有意义的,因为很多时候创建子进程不是为了让子进程和父进程同时运行相同代码,而是通过exec运行其他代码。

3. 还有一点需要注意的是,父子进程完全共享fork之前打开的文件的文件偏移量和文件状态标志。说直白一点,如果子进程不执行exec操作,父子进程对fork前打开的文件的写入不会相互覆盖,而是被混杂在一起。所以如果不想这一幕发生,就需要对父子进程进行同步操作。

4. 子进程在退出时,系统会自动收回子进程的内存资源。利用这个特性,我们可以将一些复杂的操作提取出来,并创建一个子进程来专门执行这个操作,最后将结果通过文件,管道或者其他进程间通信技术传给父进程。这个应有技巧是非常有价值的,适合内存操作频繁复杂的操作场合。利用子进程来做这些操作你就不需要担心内存泄露以及堆内存过度碎片化的问题。

5. 不要对父子进程在fork之后的执行顺序做任何假设(虽然曾经有过linux的内核版本对此做出过规范),最好能明确限定他们的执行顺序,这个可以通过同步技术来实现(信号量,文件锁,管道消息,信号和wait()函数都可以)。在上述例子中,采用了两种方式来改变父子进程的执行顺序,一个是sleep(),一个是wait()。需要说明的是,sleep()并不是规范的方法,相形之下wait()显得妥当很多,即使子进程睡了5秒钟,父进程依然能有效地等待子进程先打印消息和退出。

        这里再举一个采用信号同步的例子:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <errno.h>

#define SYNC_SIG SIGUSR1

extern void error_exit(char*);
extern void success_exit(char*);
static void handler(int sig)
{
    return;
}

int main(int argc, char* argv[])
{
    pid_t child_pid;
    sigset_t block_mask,old_mask,empty_mask;
    struct sigaction sa;

    // set process's signal mask as SYNC_SIG
    sigemptyset(&block_mask);
    sigaddset(&block_mask,SYNC_SIG);
    if(sigprocmask(SIG_BLOCK,&block_mask,&old_mask) == -1)
        error_exit("process mask failed.");

    // set handler for SYNC_SIG
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sa.sa_handler = handler;
    if(sigaction(SYNC_SIG,&sa,NULL) == -1)
        error_exit("set sigaction failed.");

    switch(child_pid = fork()) {
    case -1:
        error_exit("fork failed.");
        break;
    case 0:
        sleep(5);

        // child send SYNC_SIG to parent.
        printf("child: send signal.\n");
        kill(getppid(),SYNC_SIG);
        success_exit("child exit");
        break;
    default:
        // suspend parent to wait signale from child.
        printf("parent: suspend process.\n");
        sigemptyset(&empty_mask);
        if(sigsuspend(&empty_mask) == -1 && errno != EINTR)
            error_exit("parent: suspend failed");
        printf("parent: SYNC_SIG received,restart parent.\n");

        // wait child exit first.
        if(wait(NULL) == -1)
            error_exit("wait child failed.");
        success_exit("child has exit.");
        break;
    }

    return 0;
}

void error_exit(char* msg)
{
    printf("NG: %s\n",msg);
    exit(-1);
}

void success_exit(char* msg)
{
    printf("OK: %s\n",msg);
    exit(0);
}
           

        说明,这个例子在前一个例子的基础上增加了信号同步。实际上在这个例子里有两处同步,第一处是信号同步实现的,这一同步的目的在于确保子进程中部分操作(如这里的sleep(5),当然你可以将其换成任何你想执行的操作。)先于父进程的某些操作执行。第二个同步时wait实现的,他的作用是确保子进程先于父进程退出,这样可以避免子进程成为孤儿进程。

第二部分:进程的终止

================================================

        关于进程的终止,主要涉及的函数有 _exit() , exit() , atexit() , on_exit()。下面针对进程终止说明以下几点:

1. 进程的终止分为正常和异常两种。异常终止可能是由于某些信号引起的,其中的一些信号还会导致进程产生一个核心转储文件。

2. 正常终止可以通过系统调用 _exit() 或者GNU C标准库函数exit() 实现。二者都有一个status参数,用以表示函数退出值。常用的return 函数的效果类似与 exit() 函数(这里是指在带参数的情况下,return不带参数的时候返回值则取决于C语言的版本标准以及所使用的编译器)。

3. 不管进程正常还是异常终止,内核都会执行多个清理步骤。与系统调用 _exit() 不同的是,调用库函数 exit() 正常终止一个进程时,将会引发执行通过GNU C库函数atexit() 或 on_exit() 注册的退出处理程序(这些退出处理程序在调用函数 _exit() 或者因信号终止的情况下是不会执行的。),然后刷新stdio缓冲区。

4. 关于刷新缓冲的问题,也比较有意思。先看下面的例子:

直接运行下面的代码的话,输出的结果如下:

[[email protected] process]# ./process_test_stdio
Hello World!
YSY      

将输出重定向到文件时,输出结果如下:

[[email protected] process]# ./process_test_stdio > log
[[email protected] process]# cat log
YSY
Hello World!
Hello World!      

代码如下:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

extern void error_exit(char*);
extern void success_exit(char*);

int main(int argc, char* argv[])
{
    printf("Hello World!\n");
    write(STDOUT_FILENO, "YSY\n",4);

    if(fork() == -1)
        error_exit("fork failed.\n");

    return 0;
}

void error_exit(char* msg)
{
    printf("NG: %s\n",msg);
    exit(-1);
}

void success_exit(char* msg)
{
    printf("OK: %s\n",msg);
    exit(0);
}
           

        为什么结果不一样呢?这是因为,第一种情况标准输出定向到终端,默认为行缓冲,所以fork之前就直接输出了缓冲区的内容"Hello World!"。第二种情况,输出是定义到文件的,默认是块缓冲,此时printf的内容会暂时存放在标准输出的缓冲区内,随着fork的执行,子进程拷贝了标准输出缓冲区,从而导致输出两次"Hello World!"。至于为什么"YSY"跑到前面去了,而且只有一次呢?这是因为write() 系统调用会把数据直接传递给内核高速缓冲区,fork不会复制这一缓冲区。而且写到文件的时候,内核高速缓冲区会先写进文件,而标准输出的内容则要等到进程结束时刷新标准输出缓冲区才会写入文件。

        为了避免刷缓冲区的时机问题带来的困惑,用户可以自己调用函数 fflush() 来刷新缓冲区,或者使用 setvbuf() 和 setbuf() 来关闭标准输出的缓冲功能。还可以尽量在子进程中使用_exit() 退出,因为_exit() 不会刷新标准输出的缓冲区,这样至少对上面的问题不会导致输出两次"Hello World!"。

第三部分:进程的监控

================================================

        这一部分讲的是父进程对子进程的监控操作。主要涉及到三个知识点:监控子进程的必要性(即其目的);系统调用wait()及其相关调用;SIGCHLD信号的处理。后两个知识点是父进程对子进程的监控手段。

一、监控子进程的必要性

        很多时候父进程都需要监控子进程的状态,有以下几点值得声明:

1. 父子进程之间的同步,以及检查子进程是否正常结束。比如父进程的某些操作需要等子进程结束才能执行,有时候父进程还需要获取子进程的退出状态等信息,这就需要对子进程进行监控。

2. 避免僵尸进程大量产生。子进程结束后,内核会在父进程调用wait()或者waitpid()之类的函数之前,将结束的子进程转为僵尸进程(关于僵尸进程及其危害可自行查阅资料)。父进程调用wait()或者waitpid()之类的函数之后,内核会完全清除已经结束的子进程,否则在父进程结束之后就会残留下大量的僵尸进程。

3. 避免孤儿进程出现,虽然不像僵尸进程那样对系统会带来较大影响,依然不建议父进程不管子进程状态擅自先结束执行。

二、wait()及其相关调用

        这一部分只谈 wait() 和 waitpid() 这两个系统调用,他们都可以用于监控子进程的状态。他们的定义如下:

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

        对于这两个调用的关系做以下几点说明:

1. 二者都有一个status参数用于返回子进程终止状态。

2. 二者都有一个pid_t类型的返回值,表示监控到的子进程的PID。

3. 出错时都返回-1,如果errno被设置为ECHILD则表示没有子进程可以等待,也就是说父进程的所有子进程都已结束并被父进程获取过结束状态。

4. wait() 只能按顺序等待结束的子进程(例如,调用wait之前已有多个子进程结束,则wait一次只会返回一个子进程的终止状态,顺序和子进程的结束顺序一致。),而waitpid则可以通过参数pid选择等待方式(pid>0 等待指定的子进程;pid=0 等待与调用进程同一进程组的所有子进程;pid=-1等待所有子进程;pid<-1等待进程组标示符与pid绝对值相等的所有子进程)。

5. wait() 属于阻塞式等待,直到有子进程结束才返回,而waitpid() 可以通过参数options来指定等待方式(WUNTRACED:返回已经终止的子进程和因信号而停止的子进程信息;WCONTINUED:返回因SIGCONT信号恢复执行的已停止的子进程的状态信息;WNOHANG:如果指定等待的子进程的状态未发生改变则立即返回,不会阻塞)。

        不管是wait() 还是waitpid() ,他们返回的status都可以通过头文件<sys/wait.h>中定义的一组标准宏来解析(这些宏的名字还是很好记的:WIFEXITED(status);正常结束;WIFSIGNALED(status):被信号杀死的;WIFSTOPPED(status):被信号停止的;WIFCONTINUED(status):被信号停止后有被信号SIGCONT恢复执行的)。每一个返回的status解析后只会有一个宏返回真值。

        下面举一个实例,该实例会产生一个子进程,并会时刻返回子进程的状态变迁过程。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>

extern void error_exit(char*);
extern void success_exit(char*);
extern void print_wait_status(const char*, int);

int main(int argc,char* argv[])
{
    int status;
    pid_t child_pid;

    switch(fork()) {
    case -1:
        error_exit("fork failed!");
    case 0:
        printf("child:started with pid=%d.\n",getpid());
        if(argc > 1) /* exit with the arg supplied on command line. */
            exit(atoi(argv[1]));
        else         /* wait for signal, until get kill signal. */
            for(;;)
                pause();

        error_exit("nerver be here.");
    default:
        for(;;) { /* chek child's status until child exit. */
            child_pid = waitpid(-1,&status,WUNTRACED|WCONTINUED);
            if (child_pid == -1)
                error_exit("waitpid failed!");
            printf("father:waitpid returned,child_pid=%ld,status=0x%04x (%d,%d).\n",
                   (long)child_pid,(unsigned int)status,status >> 8,status & 0xff);
            print_wait_status(NULL,status);
            if(WIFEXITED(status) || WIFSIGNALED(status))
                success_exit("child has exited");
        }
    }

    return 0;
}

void error_exit(char* msg)
{
    printf("NG: %s\n",msg);
    exit(-1);
}

void success_exit(char* msg)
{
    printf("OK: %s\n",msg);
    exit(0);
}

void print_wait_status(const char* msg, int status)
{
    if (msg != NULL) printf("%s\n",msg);

    if (WIFEXITED(status)) {
        printf("child exited: status=%d.\n",WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("child killed by signal: %d\n",WTERMSIG(status));
    } else if (WIFSTOPPED(status)) {
        printf("child stopped by signal: %d\n",WSTOPSIG(status));
    } else if (WIFCONTINUED(status)) {
        printf("child continued.\n");
    } else {
        printf("unknow status.\n");
    }
}
           

        这个例子中,如果命令行已经制定一个参数,子进程会立即以这个参数退出。

[[email protected] process]# ./process_test_waitpid 9
        child:started with pid=21109.
        father:waitpid returned,child_pid=21109,status=0x0900 (9,0).
        child exited: status=9.
        OK: child has exited      

        如果没有指定命令行参数,子进程会调用pause等待信号。父进程随时监控子进程状态变化,一旦有变化立即输出子进程的状态变化信息,直到子进程结束才退出。

        [[email protected]Brandy process]# ./process_test_waitpid &
        [1] 21111
        [[email protected] process]# child:started with pid=21112.
        [[email protected] process]# kill -STOP 21112
        [[email protected] process]# father:waitpid returned,child_pid=21112,status=0x137f (19,127).
        child stopped by signal: 19
        [[email protected] process]# kill -18 21112
        [[email protected] process]# father:waitpid returned,child_pid=21112,status=0xffff (255,255).
        child continued.
        [[email protected] process]# kill -15 21112
        [[email protected] process]# father:waitpid returned,child_pid=21112,status=0x000f (0,15).
        child killed by signal: 15
        OK: child has exited
        [1]+  Done                    ./process_test_waitpid
        [[email protected] process]#      

三、SIGCHLD信号

        前面介绍的wait和waitpid调用虽然也可以实现对子进程的监控,但是存在一些缺陷。例如大部分情况下都是阻塞模式,虽然waitpid可以设置options的为WNOHANG来轮询,还是会造成CPU资源的浪费。怎么办呢,SIGCHLD可以帮助我们解决这个问题。

        子进程在结束时,系统会向其父进程发送SIGCHLD信号。因此我们也可以通过检测这个信号来决定是否调用wait或者waitpid来返回子进程退出状态以及清理僵尸进程,即在信号SIGCHLD的处理程序中调用wait或者waitpid调用。不过设置信号SIGCHLD的处理程序有一些注意事项要强调,这很重要:

1. 当调用信号处理程序时,系统会暂时将引发调用的信号阻塞起来,并不会对SIGCHLD等标准信号进行排队处理。那么问题来了,要是相继有两个子进程结束,而我们的信号处理程序中又只调用了一次 wait() 或者 waitpid() 调用,这时可能就会有僵尸进程成为漏网之鱼不能被清理。所以在SIGCHLD信号的处理程序中最好使用下面的循环,它能够确保所有结束的进程都被清理。

        while (waitpid(-1, NULL,WNOHANG) > 0)
            continue;      

2. 还有一个问题,如果创建的子进程在SIGCHLD信号处理函数设置之前就已经结束怎么办,还能顺利检测到SIGCHLD吗?这个根据不同的系统实现是不一样的,至少SUSv3对此并未作出规定。标准做法是在子进程被创建之前就设置好SIGCHLD信号处理函数。

3. 子进程stop的时候,系统也会向父进程发送SIGCHLD,如果希望父进程忽略这中情况,可以在 sigaction() 设置SIGCHLD信号处理函数时传入SA_NOCLDSTOP标志,这样系统就不会因为子进程的停止而发出SIGCHLD信号。

4. 如果父进程对子进程的退出状态并不感兴趣,而只是单纯的希望清理僵尸进程的话,那么很简单,我们只需要显式设置SIGCHLD的处理为SIG_IGN(虽然默认系统也是忽略SIGCHLD信号,但是显式指定为SIG_IGN时的行为是不一样的)。这时系统会将终止的子进程直接删除,不会转为僵尸进程。

        我又写了一个例子,前面介绍 waitpid() 的时候也举了一个例子,当时是在父进程里面循环调用 waitpid() 直到子进程退出才结束。下面的例子是在信号SIGCHLD的处理函数里调用 waitpid() 的,父进程调用 sigsuspend() 阻塞起来等待子进程发送SIGCHLD。

        在linux上测试结果如下:参数个数决定子进程个数和子进程延时的时间。

[[email protected] process]# ./process_test_sigchld 2 3 7
child-01: pid = 32722 start.
child-02: pid = 32723 start.
child-03: pid = 32724 start.
child-01: pid = 32722 exiting.
handler: signal SIGCHLD accept.
handler: child_pid=32721, status=0x0000(0,0)child exited: status=0.
child-02: pid = 32723 exiting.
child-03: pid = 32724 exiting.
handler: return.
handler: signal SIGCHLD accept.
handler: child_pid=32721, status=0x0000(0,0)child exited: status=0.
handler: child_pid=32721, status=0x0000(0,0)child exited: status=0.
handler: return.
END: forked 3 child; accept 2 time SIGCHLD      

        代码如下:

<pre name="code" class="cpp"><pre name="code" class="plain">#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>
#include <stdlib.h>

void sigchld_handler(int);
void print_wait_status(const char*, int);

static int num_alive_child = 0;

int main(int argc, char* argv[])
{
    int j,num_child = 0,num_sig = 0;
    pid_t child_pid;
    sigset_t block_mask,empty_mask;
    struct sigaction sa;

    setbuf(stdout, NULL); /* disable buffering of stdout */

    /* 1:set handler for SIGCHLD */
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sa.sa_handler = sigchld_handler;
    if(sigaction(SIGCHLD,&sa,NULL) == -1) {
        printf("ERR: sigaction failed.\n");
        exit(-1);
    }

    /* 2:block signal SIGCHLD */
    sigemptyset(&block_mask);
    sigaddset(&block_mask,SIGCHLD);
    if(sigprocmask(SIG_SETMASK,&block_mask,NULL) == -1) {
        printf("ERR: sigprocmask failed.\n");
        exit(-1);
    }

    /* fork child process */
    for (j=1;j<argc;j++) {
        num_alive_child++;
        switch(child_pid = fork()){
        case -1:
            num_alive_child--;
            /* kill exist child and then exit will be better. */
            _exit(-1);
        case 0:
            printf("child-%02d: pid = %ld start.\n",j,(long)getpid());
            sleep((int)atoi(argv[j]));
            printf("child-%02d: pid = %ld exiting.\n",j,(long)getpid());
            _exit(0);
        default:
            num_child++;
            break;
        }
    }

    /* wait for signal SIGCHLD from child */
    sigemptyset(&empty_mask);
    while(num_alive_child > 0) {
        if(sigsuspend(&empty_mask) == -1 && errno != EINTR) {
            printf("ERR: sigsuspend failed.\n");
            exit(-1);
        }
        num_sig++;
    }

    printf("END: forked %d child; accept %d time SIGCHLD\n",num_child,num_sig);
    exit(0);
}

void sigchld_handler(int sig)
{
    int status, old_errno;
    pid_t child_pid;

    old_errno=errno;

    printf("handler: signal SIGCHLD accept.\n");

    /* 3:wait all exited child process */
    while((child_pid = waitpid(-1,&status,WNOHANG)) > 0) {
        printf("handler: child_pid=%ld, status=0x%04x(%d,%d)",
               (long)getpid(), (unsigned int)status, status >> 8, status & 0xff);
        print_wait_status(NULL,status);
        num_alive_child--;
    }

    if(child_pid == -1 && errno != ECHILD) {
        printf("handler: waitpid failed,errno=%d.\n",errno);
        exit(-1);
    }

    sleep(5);
    printf("handler: return.\n");

    errno = old_errno;
}
           

        在这个例子中,完全体现了前面强调的关于SIGCHLD信号处理函数设计的注意点。参照代码中的注释号,这里再次说明如下:

1. 函数一开始就设置好信号处理函数,避免设置信号处理函数之前有子进程结束。

2. 创建子进程之前阻塞信号SIGCHLD,以防止子进程在检查 num_aliave_child 和 执行 sigsuspend() 之间结束。如果子进程在此期间向父进程发送信号SIGCHLD,将会导致父进程永远阻塞在 sigsuspend() 里,因为此时可能已经没有子job会发送信号SIGCHLD了。

3. 在SIGCHLD信号处理函数中,使用轮训方式调用 waitpid() 来处理所有结束的子进程。因为在执行信号处理函数是,系统会阻塞SIGCHLD并且不会对其排队,所以这样做可以是的在执行信号处理函数时结束的子进程也能很好地被回收处理。

4. 还有一点上面的例子里没有体现,子进程被暂停时,父进程也能接收到SIGCHLD信号。如果希望避免这一情况,调用 sigaction() 时可以指定SA_NOCLDSTOP标志。

第四部分:新程序的执行 execve()

================================================

        execve() 调用可以将新程序加载到某一个进程的内存空间,基于这个调用,C语言提供了exec系列库函数。这里针对新程序加载的调用做以下几点说明:

1. exec() 系列函数有execve(),execle(),execlp(),execvp(),execv(),execl(),一共6个函数。他们的用法可查阅man手册,不过其命名是有规律的,这一点有助于记忆其用法。

对程序文件的描述(-,p):

        带p的表示程序文件指定时不需要补全路径,系统会按照PATH路径自动搜索。例如execlp() 和 execvp()。

对参数的描述(v,l)         :

        v表示数组,l表示列表,例如 execve() 和 execle() 的区别就在于指定参数的方式上,execve是传递的数组,execle传递的是字符串列表。

对环境变量的描述(e,-):

        带字幕e的表示可以通过一个参数向新的程序传递环境变量,否则就只能继承调用者的环境变量。

2. 解释器脚本实际上就是利用了exec() 系列函数来实现对文本格式命令的程序的执行(机器实际上只能执行二进制可执行文件,对于文本格式命令程序实际上是通过解释器调用exec() 来执行的,例如shell,以及awk,sed,perl,python,ruby。)。需要注意的还有,对于这种程序,在文本的第一行需要指出使用的解释器的路径名(以“#!”开头)。

3. exec的调用程序所打开的文件描述符在exec的执行过程中会保持打开状态,且在新程序中依然有效,这是一把双刃剑。先来说说好处,例如shell下执行一个命令,子shell在执行新的命令时直接将输出信息输出到标准输出而不需要重新打开标准输出(当然,这里的命令是非内部命令,对于shell的内部命令他是不需要fork子进程的)。再来说说坏处,那就是安全问题,最好在执行新程序之前关闭那些不必要的文件描述符。但是直接关闭的话,又不能满足exec执行失败时的恢复,因为再次打开同一文件是无法保证文件描述符一致的。这个时候就要用到“执行时关闭标志”了,可使用 fcntl() 系统调用来设置FD_CLOEXEX标志位。

4. 由于执行exec系列函数后会替换掉进程的文本段,那么调用exec之前设置的信号处理函数当然也被覆盖了。对于之前设置了信号处理函数的信号,执行exec之后内核会将他们的信号处理函数重置为SIG_DFL。信号SIGCHLD是一个特例,调用exec之前如果设置SIGCHLD信号的处理函数为SIG_IGN的话,调用之后的处理在SUSv3中是没有被规定的。所以,对于信号的处理,最通用的办法是在调用exec之前显式设置为SIG_DFL。

5. 调用exec系列函数期间,进程的信号掩码和挂起信号的设置会保存下来。这一点也是很重要的,使用的时候最好记住这个特性,以免带来疑惑。