Fork ()、vfork ()、exec ()和clone ()的区别
1. 概述
系统调用为操作系统提供的服务提供接口。系统调用fork() 、vfork() 、exec() 和clone() 都用于创建和操作进程。
在本教程中,我们将讨论这些系统调用中的每一个以及它们之间的区别。
2. fork()
*进程执行*fork()系统调用来创建一个新的子进程。
**执行*fork()*调用的进程称为父进程。**创建的子进程接收一个唯一的进程标识符 ( PID ),但保留父进程的 PID 作为其父进程标识符 ( PPID )。
子进程具有与其父进程相同的数据。但是,两个进程都有单独的地址空间。
子进程创建后,父进程和子进程同时执行。*他们在fork()*系统调用之后执行下一步。
由于父进程和子进程具有不同的地址空间,因此对一个进程所做的任何修改都不会反映在另一个进程上。
**后来的改进引入了写时复制机制,它允许父进程和子进程共享相同的地址空间。**这消除了将数据复制到子进程的需要。如果任何进程修改了共享地址空间中的页面,系统会分配一个新的地址空间,允许两个进程独立运行。
2.1. 执行fork()
让我们创建一个简单的 C 程序,向我们展示*fork()*系统调用是如何工作的。
首先,我们使用Nano 编辑器创建一个名为fork_test.c 的文件:
$ nano fork_test.c
接下来,我们添加这个内容:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
int main(int argc, char **argv) {
pid_t pid = fork();
if (pid==0) {
printf("This is the Child process and pid is: %d\n",getpid());
exit(0);
} else if (pid > 0) {
printf("This is the Parent process and pid is: %d\n",getpid());
} else {
printf("Error while forking\n");
exit(EXIT_FAILURE);
}
return 0;
}
在这里,我们正在启动一个新进程并使用变量pid 来存储由fork()调用创建的子进程的进程标识符。然后我们继续检查fork()调用返回的pid的值是否等于 0。** *fork()*调用将子进程的值返回为零,以将其与父进程区分开来。子进程标识符的实际值是返回给父进程的值。**最后,我们检查错误并打印错误消息。
保存更改后,我们使用*cc 命令编译fork_test.c*:
$ cc fork_test.c
这会在工作目录中创建一个名为a.out 的可执行文件。 最后,我们可以执行a.out文件:
$ ./a.out
This is the Parent process and pid is: 69032
This is the Child process and pid is: 69033
我们可以在这里看到父进程和子进程有不同的进程标识符。
**在上面的输出中,fork()调用两次返回输出,一次在父进程中,一次在子进程中。
我们在if…else块中使用*getpid()*函数调用来获取父进程和子进程的实际 PID 。
3. vfork()
** *vfork()系统调用最早是在 BSD v3.0 中引入的。它是一个遗留系统调用,最初是作为fork()系统调用的更简单版本创建的。**这是因为在创建写时复制机制之前执行fork()*系统调用涉及从父进程复制所有内容,包括地址空间,这是非常低效的。
**与*fork()*系统调用类似,*vfork()*也创建一个与其父进程相同的子进程。但是,子进程会暂时挂起父进程,直到它终止。**这是因为两个进程使用相同的地址空间,其中包含堆栈、堆栈指针和指令指针。
*vfork()充当clone()*系统调用的特例。它创建新进程而不复制父进程的地址空间。这在面向性能的应用程序中很有用。
子进程创建后,父进程始终处于挂起状态。它保持挂起状态,直到子进程正常终止、异常终止,或者直到它执行exec系统调用启动一个新进程。
*vfork()*系统调用创建的子进程继承其父进程的属性。其中包括文件描述符、当前工作目录、信号处置等。
3.1. 执行vfork()
让我们创建一个简单的 C 程序来展示*vfork()*系统调用是如何工作的。
首先,我们创建一个名为vfork_test.c的文件:
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
pid_t pid = vfork(); //creating the child process
printf("parent process pid before if...else block: %d\n", getpid());
if (pid == 0) { //checking if this is the a child process
printf("This is the child process and pid is: %d\n\n", getpid());
exit(0);
} else if (pid > 0) { //parent process execution
printf("This is the parent process and pid is: %d\n", getpid());
} else {
printf("Error while forking\n");
exit(EXIT_FAILURE);
}
return 0;
}
在这里,我们使用变量pid 来存储由vfork()调用创建的子进程的 PID 。然后,我们在if…else块之前检查父 PID 的值。
保存更改后,让我们编译vfork_test.c:
$ cc vfork_test.c
最后,我们可以执行创建的a.out文件:
$ ./a.out
parent process pid before if...else block: 117117
This is the child process and pid is: 117117
parent process pid before if...else block: 117116
This is the parent process and pid is: 117116
*vfork()*系统调用两次返回输出,第一次在子进程中,然后在父进程中。
**由于两个进程共享相同的地址空间,我们在第一个输出中具有匹配的 PID 值。**在if else块中,子进程首先运行,因为它在执行时阻塞了父进程。
4. exec()
** *exec()*系统函数在现有进程的上下文中运行一个新进程并替换它。**这也称为覆盖。
**该函数不会创建新进程,因此 PID 不会改变。但是,新进程会替换当前进程的数据、堆、堆栈和机器码。**它将新进程加载到当前进程空间并从入口点执行它。除非出现exec()错误,否则控制永远不会返回到原始进程。 该系统函数属于一个家族函数,包括execl() 、execlp() 、execv() 、execvp() 、execle() 和execve () 。
4.1. 执行exec()
对于一个简单的测试,我们将创建两个 C 程序来展示*exec()系统调用是如何工作的。我们将使用exec()*调用从第一个程序运行第二个程序。
让我们创建第一个名为exec_test1.c的程序:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
printf("PID of exec_test1.c = %d\n", getpid());
char *args[] = {"Hello", "From", "Parent", NULL};
execv("./exec_test2", args);
printf("Back to exec_test1.c");
return 0;
}
在这里,我们正在创建一个名为*main()的函数并传入参数。在函数内部,我们在使用getpid()函数获取 PID 后打印它。然后我们声明一个字符数组,我们在其中传入三个字符串作为参数。我们正在调用execv()*系统调用,然后将执行第二个程序的结果作为参数传入。
现在让我们创建名为exec_test2.c 的第二个程序:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
printf("Hello from exec_test2.c\n");
printf("PID of exec_test2.c process is: %d\n", getpid());
return 0;
}
在这里,我们打印一条消息和*exec()*从第一个程序启动的进程的 PID。
我们使用cc命令将exec_test1.c编译为可执行文件:
$ cc exec_test1.c -o exec_test1
这会在工作目录中创建一个名为exec_test1的可执行文件。 然后,我们编译第二个程序:
$ cc exec_test2.c -o exec_test2
这会在工作目录中创建一个名为exec_test1的可执行文件。 最后,我们可以执行exec_test1 文件:
$ ./exec_test1
PID of exec_test1.c = 171939
Hello from exec_test2.c
PID of exec_test2.c process is: 171939
从上面的输出中,我们可以注意到 PID 在第二个程序的进程中没有改变。此外,未打印exec_test1.c文件中的最后一条打印语句。这是因为执行*execv()*系统调用替换了当前正在运行的进程,并且我们没有包含返回到第一个进程的方法。
5. clone()
** clone()系统调用是fork调用的升级版本。它很强大,因为它创建了一个子进程,并为父进程和子进程之间共享的数据提供了更精确的控制。**该系统调用的调用者可以控制文件描述符表、信号处理程序表以及两个进程是否共享相同的地址空间。
** clone()系统调用允许将子进程放置在不同的命名空间中。 使用*clone()系统调用带来的灵活性,我们可以选择与父进程共享地址空间,模拟vfork()*系统调用。我们还可以选择使用不同的可用标志共享文件系统信息、打开文件和信号处理程序。
这是*clone()*系统调用的签名:
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
/* pid_t *parent_tid, void *tls, pid_t *child_tid */ );
让我们分解一些部分以了解更多信息:
- *fn : 指向函数的指针
- *stack : 指向栈的最小字节
- pid_t:进程标识符(PID)
- *parent_tid:指向子进程线程标识符(TID )在父进程内存中的存储位置
- *child_tid:指向子进程线程标识符(TID)在子进程内存中的存储位置
5.1. 执行clone()
让我们创建一个简单的 C 程序来看看clone() 系统调用是如何工作的。
首先,我们创建一个名为clone_test.c的文件:
// We have to define the _GNU_SOURCE to get access to clone(2) and the CLONE_*
#define _GNU_SOURCE
#include <sched.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static int child_func(void* arg) {
char* buffer = (char*)arg;
printf("Child sees buffer = \"%s\"\n", buffer);
strcpy(buffer, "hello from child");
return 0;
}
int main(int argc, char** argv) {
// Allocate stack for child task.
const int STACK_SIZE = 65536;
char* stack = malloc(STACK_SIZE);
if (!stack) {
perror("malloc");
exit(1);
}
// When called with the command-line argument "vm", set the CLONE_VM flag on.
unsigned long flags = 0;
if (argc > 1 && !strcmp(argv[1], "vm")) {
flags |= CLONE_VM;
}
char buffer[100];
strcpy(buffer, "hello from parent");
if (clone(child_func, stack + STACK_SIZE, flags | SIGCHLD, buffer) == -1) {
perror("clone");
exit(1);
}
int status;
if (wait(&status) == -1) {
perror("wait");
exit(1);
}
printf("Child exited with status %d. buffer = \"%s\"\n", status, buffer);
return 0;
}
在这里,我们以两种方式使用clone(),一次带有CLONE_VM标志,一次没有。我们将一个缓冲区传递给子进程,子进程将一个字符串写入其中。然后我们为子进程分配一个堆栈大小并创建一个函数来检查我们是否正在使用*CLONE_VM (vm)选项执行文件。此外,我们在父进程中创建了一个 100 字节的缓冲区并将一个字符串复制到其中,然后执行clone()*系统调用并检查错误。
我们使用cc命令将exec_test.c编译为可执行文件:
$ cc clone_test.c
这会在工作目录中创建一个名为a.out 的可执行文件。 最后,我们可以执行a.out文件:
./a.out
Child sees buffer = "hello from parent"
Child exited with status 0. buffer = "hello from parent"
当我们在没有vm 参数的情况下执行它时,CLONE_VM标志不活动,并且父进程虚拟内存被克隆到子进程中。子进程可以访问缓冲区中父进程传递的消息,但是子进程写入缓冲区的任何内容都不能被父进程访问。
但是,当我们传入vm参数时,CLONE_VM处于活动状态,并且子进程共享父进程的内存。我们可以看到它写入buffer:
$ ./a.out vm
Child sees buf = "hello from parent"
Child exited with status 0. buf = "hello from child"
这次我们的消息不同了,我们可以看到子进程传递过来的消息。
6. 比较
让我们看一下这张表,其中显示了每个系统调用的概述和比较:
比较因素 | fork() | vfork() | exec() | clone() |
---|---|---|---|---|
调用 | *fork(),*创建调用进程的子进程 | vfork(),创建一个与父进程,共享一些属性的子进程 | exec(),替换调用进程 | clone(),创建一个子进程并提供对共享数据的更多控制 |
进程 ID | 父进程和子进程具有唯一 ID | 父进程和子进程具有相同的ID | 正在运行的进程和替换它的进程具有相同的 PID | 父进程和子进程具有唯一 ID ,但可以在指定时共享 |
执行 | 父子进程同时启动 | 子进程运行时父进程暂时挂起 | 父进程终止,新进程从入口点开始 | 父子进程同时启动 |