Linux 进程:fork、execvp 与 wait

这篇文章通过一个简单的 C 程序,理解 Linux 中的进程、子进程,以及程序是怎样运行另一个命令的。

一、老师提供的完整代码

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

int main(int argc, char *argv[])
{
    printf("hello world (pid:%d)\n", (int)getpid());

    int rc = fork();

    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {
        printf("hello, I am child (pid:%d)\n", (int)getpid());

        char *myargs[3];
        myargs[0] = strdup("wc");
        myargs[1] = strdup("test1.c");
        myargs[2] = NULL;

        execvp(myargs[0], myargs);

        printf("this shouldn't print out\n");
    } else {
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
               rc, wc, (int)getpid());
    }

    return 0;
}

这里假设当前目录中存在 test1.c。如果文件名不同,需要同时修改代码中的文件名。

二、如何编译和运行

假设代码保存为 test1.c

gcc -o test1 test1.c
./test1

其中:

  • gcc -o test1 test1.c:把源代码 test1.c 编译成可执行程序 test1

  • ./test1:执行当前目录中的 test1 程序。

  • test1.c 是源代码,不会被 ./test1 直接执行。

程序中的子进程之后会执行:

wc test1.c

wc 会统计文件的行数、单词数和字节数。例如:

31  88  750 test1.c

这表示文件有 31 行、88 个单词、750 个字节。

三、程序的大致流程

可以把整个过程理解成这样:

运行 ./test1
    |
    | 打印启动信息
    |
    | fork()
    |
    +-- 父进程:等待子进程结束
    |
    +-- 子进程:调用 execvp()
                    |
                    +-- 执行 wc test1.c

注意:test1.c 只是被 wc 读取和统计,并没有被再次运行,所以不会出现无限循环。

四、fork():创建子进程

int rc = fork();

fork() 会把当前进程复制出一个子进程。调用之后,父进程和子进程都会从 fork() 的下一行继续执行。

可以把它想象成:一个正在运行的程序在某个时刻分成了两个进程。

fork() 的返回值在不同进程中不同:

rc < 0   // 创建失败
rc == 0  // 当前代码运行在子进程中
rc > 0   // 当前代码运行在父进程中,rc 是子进程的 PID

因此代码使用 if 分出了三种情况:

if (rc < 0) {
    // 创建失败
} else if (rc == 0) {
    // 子进程执行这里
} else {
    // 父进程执行这里
}

五、PID:进程的身份证号

getpid()

getpid() 会返回当前进程的 PID,也就是进程号。

例如:

hello world (pid:594)
hello, I am child (pid:595)

这里可以看出:父进程和子进程是两个不同的进程,因此 PID 不同。

六、execvp():替换当前程序

子进程中准备了一个参数数组:

char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("test1.c");
myargs[2] = NULL;

它相当于准备执行这条命令:

wc test1.c

然后调用:

execvp(myargs[0], myargs);

execvp() 不会创建新的进程。它做的是:把当前子进程中运行的程序替换成另一个程序。

也就是说:

子进程 PID 595:原本运行 test1
        |
        | execvp()
        v
子进程 PID 595:开始运行 wc

PID 仍然是 595,只是这个进程执行的程序变成了 wc

这就是为什么不会无限套娃:wc 只是读取文件内容,并不会重新执行这段代码,也不会再次调用这里的 fork()

如果 execvp() 执行成功,后面的代码不会执行:

printf("this shouldn't print out\n");

只有当 execvp() 执行失败时,程序才会继续执行这条 printf。实际代码中更严谨的写法是:

perror("execvp failed");
exit(1);

七、wait():父进程等待子进程

父进程执行:

int child_pid = wait(NULL);

这表示父进程暂停在这里,等待某个子进程结束。

子进程执行完 wc test1.c 后,父进程才继续执行后面的输出语句。

wait(NULL) 的返回值是已经结束的子进程 PID,不是 wc 输出的行数或单词数。老师的代码把这个变量命名为 wc,但它实际保存的是子进程 PID;如果自己写代码,改名为 child_pid 会更容易理解。

八、运行结果怎么读

一次运行可能看到类似输出:

hello world (pid:594)
hello, I am child (pid:595)
31  88  750 test1.c
hello, I am parent of 595 (wait:595) (pid:594)

它表示:

  1. 原程序启动,当前进程 PID 是 594。

  2. fork() 创建了子进程,子进程 PID 是 595。

  3. 子进程通过 execvp() 执行 wc test1.c

  4. wc 输出 test1.c 的统计结果。

  5. 父进程通过 wait(NULL) 等待子进程结束,然后输出最后一行。

九、常见误解

1. ./test1 会执行 test1.c 吗?

不会。

test1.c  -- gcc 编译 -->  test1  -- ./test1 执行 --> 程序运行

./test1 执行的是编译后生成的可执行文件 test1

2. 子进程进入 test1.c 后会再次 fork 吗?

不会。子进程没有进入并运行 test1.c,而是把 test1.c 作为普通文本文件交给 wc 读取。

3. 为什么不会无限创建进程?

因为代码中只有一次 fork(),而且子进程随后执行的是 wcwc 不会运行这段 C 代码,因此不会再次调用 fork()

4. fork()execvp() 是一回事吗?

不是:

fork()   = 创建一个子进程
execvp() = 让当前进程换成另一个程序
wait()   = 父进程等待子进程结束

十、最值得记住的一句话

fork() 负责创建子进程;
execvp() 负责让子进程执行另一个程序;
wait() 负责让父进程等待子进程结束。

这三个函数组合起来,是 Linux 中运行外部命令的一种经典方式。很多 shell 的底层工作方式,也可以用“先 fork,再 exec,最后 wait”来理解。

十一、课后练习

可以尝试做下面几件事,加深理解:

  1. test1.c 改成另一个存在的文件名,观察 wc 的输出变化。

  2. 注释掉 wait(NULL),观察父进程和子进程的输出顺序是否变化。

  3. wc 改成 ls,并把参数改成:

    myargs[0] = strdup("ls");
    myargs[1] = strdup("-l");
    myargs[2] = NULL;

    这时子进程会执行:

    ls -l
  4. 把文件名改成一个不存在的文件,观察命令报错时的表现。