Linux 系统主要使用 C 语言和汇编语言编写,其核心功能通过系统调用(System Calls)向用户空间开放。C 语言标准库(libc)对这些系统调用进行了封装,方便开发者调用。在进程管理领域,fork()、exec() 系列和 wait() 是最基础且最重要的三个系统调用。
fork(): 用于创建一个新的子进程。exec(): 用于在进程中执行一个新的可执行文件。wait(): 用于使父进程阻塞,等待子进程的状态改变(如终止)。
本文将详细探讨如何使用这些系统调用,并以此一窥 Linux 进程的运行机制。建议配合进程原理一文共同阅读,效果更佳。
fork():创建进程
fork() 系统调用用于创建一个子进程。该函数在父进程中返回子进程的 PID(进程 ID),在子进程中返回 0,如果创建失败则返回一个负数。
我们来看一段代码示例:
1 |
|
猜一下输出结果中,父进程输出的苹果数量是 1 还是 5?
当父进程调用 fork() 时,操作系统会创建一个几乎完全一样的子进程。父进程的地址空间、代码段、数据段、堆和栈等资源都会被“复制”给子进程。 这种“复制”意味着子进程拥有独立的内存空间,它对变量的修改不会影响到父进程。
其过程如下图所示:
因此,父进程依然拥有 5 个苹果,而子进程拥有独立的 5 个苹果并吃掉了其中的 4 个。答案是 5,你猜对了嘛?
需要注意的是,在代码层面上,fork() 返回的 pid 是区分父子进程的唯一依据。子进程的 pid 变量值为 0,而父进程的 pid 变量值为子进程的实际 PID(大于 0)。我们不能说子进程的作用域仅仅局限于 if(pid == 0) 语句块内。事实上,fork() 之后的所有代码,父子进程都会执行,除非在代码中进行了逻辑判断。
下面的代码可以更好地证明父子进程互不干扰,且均会执行 if 块之外的代码:
1 |
|
Shell 终端会得到两个输出(顺序可能随机):
1 | There are 5 apples |
前一个输出通常来自父进程,苹果数量未变;后一个输出来自子进程,吃掉了 4 个。这证明了即使在 if 语句外面,代码也是被父子两进程分别执行的。if 条件仅仅用于辅助判断哪些代码应该在子进程逻辑中执行,哪些在父进程逻辑中执行。接下来我们讨论 exec()。
exec() 系列:执行新程序
exec() 并不是一个单一的函数,而是一系列相关系统调用函数的总称(包括 execl(), execlp(), execle(), execv(), execvp(), execvpe())。它们的主要作用是用一个新的程序文件替换当前进程的映像。简单来说,exec() 可以用来运行一个全新的程序。
面对这么多函数,该如何选择?可以通过函数名的后缀来确定:
l(list): 代表参数以列表形式逐个传入,最后一个参数必须是NULL。v(vector): 代表参数以字符串数组(向量)的形式传入。e(environment): 表示可以为新程序设置环境变量,不带e则继承当前进程的环境变量。p(path): 表示输入的是可执行文件的文件名,函数会根据$PATH环境变量来查找该文件。如果不带p,则必须输入可执行文件的完整路径。
下面这段代码演示了 execlp() 和 execvp() 的区别。程序中,子进程和父进程最终都会执行同样的命令(ls -la),得到相同的输出结果。不同之处在于,子进程使用 execlp(),它接受参数列表;而父进程在等待子进程结束后,使用 execvp(),它接受参数数组和文件名。你可以尝试修改代码,使用其他 exec 函数来观察效果。
1 |
|
在 exec 系列函数中,NULL 参数的作用是标志参数列表或数组的结束。
wait():进程同步与资源回收
我们在上面的代码中已经使用过 wait(),但没有详细介绍。它的典型用法是与 fork() 搭配使用。wait(NULL) 的意思是使调用进程(父进程)阻塞,直到其任意一个子进程状态改变(通常是终止)。
如果没有使用 wait(),父进程可能先于子进程终止,此时子进程会变成“孤儿进程”,被 init 进程接管。更严重的情况是,如果子进程终止了,但父进程没有调用 wait() 来读取它的退出状态,子进程在系统进程表中依然占用一个位置,直到父进程结束。这种状态的子进程被称为僵尸进程 (zombie process)[^1]。
你可能对第一次写 Hello World 时的 return 0; 还有印象。那时课本上只告诉你:“return 0 代表函数的返回值是 0”,但你可能无法理解为什么要返回它,或者是返回给谁。现在理解了父子进程的概念,你就会明白这个 0 是返回给父进程的,用于接收子进程的退出状态。
要获取这个返回值,我们需要定义一个整型变量 status,并将它的地址传递给 wait()。之后,我们可以使用宏来解析这个变量,确定子进程的退出状态。
1 |
|
WEXITSTATUS 是一个预定义的宏,用于在确定子进程正常终止(WIFEXITED(status) 为真)后,查看其返回的值。还有很多其他的宏可以检查不同的状态改变(例如被信号终止、暂停等),详情可以点这里查看 manual 手册。
这里有几个关键点,一定不要混淆:
wait()的返回值不是子进程的退出状态,而是状态发生改变的子进程的 PID。status变量本身并不直接等于子进程的返回值,它包含了有关子进程退出状态的多位信息,需要通过宏来提取。
另外需要说明的是,wait() 函数并不仅仅是在等待子进程终止,而是在子进程发生状态转移(state change)的时候返回。根据文档提示:
A state change is considered to be: the child terminated; the child was stopped by a signal; or the child was resumed by a signal[^2]。
也就是说,进程在子进程终止、被信号暂停(stop)、以及被信号重新激活(resume)的时候,都算作状态转移,都会使 wait() 返回。
总结
- 创建子进程:使用
fork()。父进程复刻自身,通过返回值区分父子进程逻辑。 - 执行新程序:使用
exec()系列函数。它会用新程序的映像替换当前进程的内容。 - 进程同步与资源回收:父进程使用
wait()或waitpid()来等待子进程状态改变,防止僵尸进程的产生,并获取子进程的退出码。