赞
踩
目录
在Linux系统中,fork
函数是一个至关重要的功能,它用于从一个已有的进程中生成一个新的进程。生成的新进程称为子进程,而原本的进程则称为父进程。
返回值解释: 在子进程中,fork
函数会返回0;在父进程中,它返回子进程的进程ID(PID)。如果子进程的创建失败,则函数会返回-1。
当一个进程调用 fork
时,控制权会转移到内核中的 fork
代码,内核会执行以下操作:
fork
返回,并启动调度器进行进程调度。 fork
之后,父进程和子进程共享相同的代码段。这意味着在两者中执行的指令是相同的,但它们拥有独立的执行流和数据空间。以下是一个例子:
代码结果:
可以看到,Before
只输出了一次,而 After
则输出了两次。这里,Before
是由父进程打印的,而调用 fork
函数后打印的两个 After
,分别由父进程和子进程各自执行。这意味着,在 fork
之前,只有父进程在独立执行;而在 fork
之后,父进程和子进程分别在两个独立的执行流中运行。
注意:
fork
之后,父进程和子进程的执行顺序完全由调度器决定,因此无法保证谁会先执行。
fork函数为什么要给子进程返回0,给父进程返回子进程的PID?
fork
函数之所以在子进程中返回 0,而在父进程中返回子进程的 PID,是因为它们在进程间的角色和需求不同。对于子进程而言,它只有一个父进程,并且不需要特别标识这个父进程,因此返回值为 0 就足够了。这使得子进程可以通过判断返回值是否为 0 来确定自己是子进程。
对于父进程来说,它可能会创建多个子进程,因此需要一个方式来区分和管理这些子进程。
fork
返回子进程的 PID,可以让父进程明确地知道每个子进程的身份。父进程需要子进程的 PID 来执行一些特定的操作,比如等待子进程完成任务(使用wait
系统调用),或者发送信号等。这样,父进程能够有效地管理和协调其创建的子进程。
为什么fork函数有两个返回值?
在父进程调用
fork
函数后,为了创建子进程,fork
函数内部会进行一系列操作,包括:
创建子进程的进程控制块(PCB):这是一个数据结构,用于存储子进程的状态信息和管理信息,如进程ID(PID)、进程状态、寄存器内容等。
创建子进程的进程地址空间:这涉及为子进程分配独立的内存空间,使其拥有自己的代码段、数据段和堆栈段,尽管这些段的内容最初是从父进程复制过来的。
创建子进程对应的页表:页表是内存管理的重要结构,用于映射虚拟地址到物理地址。子进程需要自己的页表,以确保其内存访问的独立性。
完成这些步骤后,操作系统还会将子进程的进程控制块添加到系统的进程列表中。此时,子进程的创建过程就完成了,它成为系统中的一个独立进程,可以被调度执行。
在 fork
函数内部执行 return
语句之前,子进程的创建过程就已经完成了。此时,子进程和父进程都已经存在,并且各自有独立的执行流。因此,fork
函数的返回不仅发生在父进程中,也在子进程中。
正因为如此,fork
函数有两个返回值:在父进程中,它返回子进程的 PID;在子进程中,它返回 0。这两个不同的返回值帮助区分父进程和子进程,使得程序可以根据不同的返回值执行不同的逻辑。例如,父进程可以继续管理子进程,而子进程则可以执行特定的任务。这种设计使得进程间的协调和控制变得更加灵活和有效。
在子进程刚刚创建时,父进程和子进程的代码及数据是共享的。这意味着父进程和子进程通过页表映射到相同的物理内存区域。只有当父进程或子进程尝试修改数据时,系统才会将父进程的数据复制到一个新的内存区域,然后在新的位置上进行修改。
这种在需要进行数据修改时才进行拷贝的技术被称为写时拷贝(Copy-On-Write, COW)技术。
1、为什么数据要进行写时拷贝?
进程具有独立性。在多进程环境中,每个进程需要独占各种资源,确保在多个进程同时运行时,它们之间互不干扰。子进程的修改不能影响到父进程,以保持各进程的独立性和稳定性。
2、为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定会使用父进程的所有数据。因此,在子进程未对数据进行写入的情况下,没有必要提前对数据进行拷贝。我们应当采用按需分配的策略,即仅在需要修改数据时才进行拷贝(延时分配)。这种方法可以高效地利用内存空间。
3、代码会不会进行写时拷贝?
虽然在90%的情况下,子进程不会修改父进程的数据,但这并不意味着代码无法进行写时拷贝。例如,在进行进程替换时,系统需要进行代码的写时拷贝,以确保进程的正确性和稳定性。
fork
返回后,会调用 exec
函数来执行新的程序。fork
函数创建子进程时也可能会失败,主要有以下两种情况:
进程退出通常有三种情况:
我们知道 main
函数是程序的入口点,但实际上 main
函数只是用户级代码的入口。main
函数本身也是由其他函数调用的。例如,在 Visual Studio 2013 中,main
函数是由名为 __tmainCRTStartup
的函数调用的,而 __tmainCRTStartup
函数又是通过加载器由操作系统调用的。换句话说,main
函数是间接由操作系统调用的。
既然 main
函数是间接由操作系统调用的,那么当 main
函数执行完毕时,应当向操作系统返回相应的退出信息。这些退出信息是通过 main
函数的返回值作为退出码返回给操作系统的。通常情况下,返回值为0表示程序成功执行完毕,而非0表示程序执行过程中出现了错误。这也是为什么我们在 main
函数的最后一般会返回0。
当代码运行时,它会变成一个进程。进程结束时,main
函数的返回值实际上就是该进程的退出码。我们可以使用 echo $?
命令来查看最近一次进程退出时的退出码信息。
例如下面这个代码:
代码运行结束后,我们可以查看该进程的进程退出码。
这时便可以确定main函数是顺利执行完毕了。
为什么以0表示代码执行成功,以非0表示代码执行错误?
因为代码执行成功只有一种情况——成功即为成功——而代码执行错误可能有多种原因,例如内存空间不足、非法访问、栈溢出等。为了更好地识别错误原因,我们可以使用不同的非0退出码来分别表示这些错误情况。这样,通过检查退出码的不同值,我们可以更具体地了解程序执行失败的原因。
C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息:
运行代码后我们就可以看到各个错误码所对应的错误信息:
实际上Linux中的ls、pwd等命令都是可执行程序,使用这些命令后我们也可以查看其对应的退出码。
可以看到,这些命令成功执行后,其退出码也是0。
但是命令执行错误后,其退出码就是非0的数字,该数字具体代表某一错误信息。
注意:退出码通常都有对应的字符串含义,用于帮助用户确认执行失败的原因。然而,这些退出码的具体含义是人为规定的,在不同的环境中,相同的退出码可能具有不同的字符串含义。
在 main
函数中使用 return
语句来退出进程是我们常用的方法。这样做不仅可以结束程序的执行,还可以将退出码返回给操作系统,以指示程序的执行状态。
使用 exit
函数退出进程也是一种常用的方法。与 return
不同,exit
函数可以在程序中的任何位置调用,并在退出进程之前执行一系列重要操作:
atexit
或 on_exit
定义的清理函数,这些函数用于释放资源或进行其他清理工作。_exit
函数终止进程,这一步骤会立即结束进程,而不再执行进一步的清理操作。例如,以下代码中exit终止进程前会将缓冲区当中的数据输出。
_exit
函数通常不作为退出进程的常用方法。虽然 _exit
函数也可以在程序的任何位置调用以退出进程,但它会立即终止进程,而不会在退出之前执行任何清理工作。这意味着 _exit
函数不会执行清理函数、关闭打开的文件流或写入缓存的数据,因此其作用是直接终止进程。
例如,以下代码中使用_exit终止进程,则缓冲区当中的数据将不会被输出。
区别:
1、只有在
main
函数中的return
语句才能有效地退出进程。在子函数中的return
语句仅会返回到调用它的函数,而不会退出整个进程。相比之下,exit
函数和_exit
函数可以在代码中的任何位置被调用,以退出进程。2、使用
exit
函数退出进程时,它会执行以下操作:
- 执行用户定义的清理函数(通过
atexit
或on_exit
注册的)。- 冲刷(flush)所有打开的流,确保缓存数据被写入。
- 关闭所有打开的文件流。
- 然后再终止进程。
3、使用
_exit
函数退出进程时,它会立即终止进程,不会执行任何清理操作,如不冲刷缓冲区、不关闭流等。
联系:
1、执行
return num
在main
函数中等同于执行exit(num)
。当main
函数执行完毕时,它的返回值会被用作exit
函数的参数,从而调用exit(num)
来退出进程。2、使用
exit
函数退出进程时,它会执行以下步骤:
- 执行用户定义的清理函数(通过
atexit
或on_exit
注册的)。- 冲刷缓冲区,将所有缓存的数据写入相应的文件。
- 关闭所有打开的流,确保资源被正确释放。
- 然后,调用
_exit
函数来实际终止进程。
情况一:向进程发送信号导致进程异常退出。
例如,在进程运行过程中,如果使用 kill -9
命令向进程发送信号,或者按下 Ctrl+C
,可能会导致进程异常退出。这些信号会立即终止进程,且进程的退出通常不会执行清理操作。
情况二:代码错误导致进程运行时异常退出。
例如,代码中存在野指针问题,或者出现除以零的情况,可能会使进程在运行时异常退出。这种情况下,程序可能会因为未处理的异常或错误而崩溃,导致进程的非正常终止。
kill -9
命令也无法将其杀死,因为僵尸进程实际上已经死亡,不再执行任何操作。因此,无法对已经死去的进程进行进一步的操作。wait
或 waitpid
来实现,确保子进程的退出状态被正确处理,从而避免资源泄漏和僵尸进程的产生。 在进程等待操作中,wait
和 waitpid
函数都有一个 status
参数,该参数是一个输出型参数,由操作系统填充,用于提供子进程的退出状态信息。
status
参数传递为 NULL
,表示父进程不关心子进程的退出状态信息。status
参数,操作系统将通过该参数将子进程的退出信息反馈给父进程。 虽然 status
是一个整型变量,但不能简单地将其当作整型来看待。status
的不同比特位代表不同的信息。具体来说,我们只研究 status
的低16位,这些位的细节如下:
在 status
的低16比特位中:
1、高8位(第8到15位):表示进程的退出状态,即退出码。可以使用宏 WEXITSTATUS(status)
来提取这个退出码。
2、低8位(第0到7位):
WTERMSIG(status)
来提取。WCOREDUMP(status)
来检查。 我们可以通过一系列位操作来提取 status
中的进程退出码和退出信号。
- exitCode = (status >> 8) & 0xFF; //退出码
- exitSignal = status & 0x7F; //退出信号
对于此,系统当中提供了两个宏来获取退出码和退出信号。
- exitNormal = WIFEXITED(status); //是否正常退出
- exitCode = WEXITSTATUS(status); //获取退出码
注意:当一个进程非正常退出时,即该进程是由于信号终止的,那么该进程的退出码通常没有意义。
函数原型:
pid_t wait(int* status);
功能: 用于等待任意子进程的结束。
返回值: 如果调用成功,返回被等待进程的进程ID (
pid
),如果失败,则返回 -1。参数:
status
是一个输出参数,用于接收子进程的退出状态。如果不关心退出状态,可以将其设置为NULL
。
例如,创建子进程后,父进程可使用wait函数一直等待子进程,直到子进程退出后读取子进程的退出信息。
- #include <stdio.h>
- #include <stdlib.h>
- #include <unistd.h>
- #include <sys/wait.h>
- #include <sys/types.h>
-
- int main()
- {
- pid_t id = fork();
- if(id == 0){
- //child
- int count = 10;
- while(count--)
- {
- printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
- sleep(1);
- }
- exit(0);
- }
-
- //father
- int status = 0;
- pid_t ret = wait(&status);
- if(ret > 0)
- {
- //wait success
- printf("wait child success...\n");
- if(WIFEXITED(status))
- {
- //exit normal
- printf("exit code:%d\n", WEXITSTATUS(status));
- }
- }
- sleep(3);
- return 0;
- }
我们可以使用以下监控脚本对进程进行实时监控:
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
这时我们可以看到,当子进程退出后,父进程读取了子进程的退出信息,子进程也就不会变成僵尸进程了。
函数原型:
pid_t waitpid(pid_t pid, int* status, int options);
功能: 用于等待特定子进程的结束或任意子进程的结束。
返回值:
- 如果调用成功,返回被等待进程的进程ID (
pid
)。- 如果设置了
WNOHANG
选项,并且没有任何子进程已退出,则返回0。- 如果调用过程中出现错误,则返回 -1,此时
errno
将被设置为相应的错误码以指示问题所在。参数:
pid
:指定要等待的子进程ID。如果设置为 -1,则表示等待任意子进程。status
:输出参数,用于接收子进程的退出状态。如果不需要获取退出状态,可以将其设置为NULL
。options
:设置为WNOHANG
时,如果没有子进程结束,waitpid
会立即返回0而不进行等待。如果子进程已结束,则返回该子进程的进程ID。
例如,创建子进程后,父进程可使用waitpid函数一直等待子进程(此时将waitpid的第三个参数设置为0),直到子进程退出后读取子进程的退出信息。
- #include <stdio.h>
- #include <stdlib.h>
- #include <unistd.h>
- #include <sys/wait.h>
- #include <sys/types.h>
-
- int main()
- {
- pid_t id = fork();
- if (id == 0)
- {
- //child
- int count = 10;
- while (count--)
- {
- printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
- sleep(1);
- }
- exit(0);
- }
-
- //father
- int status = 0;
- pid_t ret = waitpid(id, &status, 0);
- if (ret >= 0)
- {
- //wait success
- printf("wait child success...\n");
- if (WIFEXITED(status))
- {
- //exit normal
- printf("exit code:%d\n", WEXITSTATUS(status));
- }
- else
- {
- //signal killed
- printf("killed by siganl %d\n", status & 0x7F);
- }
- }
- sleep(3);
- return 0;
- }
在父进程运行过程中,我们可以尝试使用kill -9命令将子进程杀死,这时父进程也能等待子进程成功。
注意: 被信号杀死而退出的进程,其退出码将没有意义。
我们还可以使用一种技术,通过创建多个子进程并让父进程依次等待每个子进程的退出,这种方法被称为多进程创建与等待模型。
例如,以下代码中同时创建了10个子进程,同时将子进程的pid放入到ids数组当中,并将这10个子进程退出时的退出码设置为该子进程pid在数组ids中的下标,之后父进程再使用waitpid函数指定等待这10个子进程。
- #include <stdio.h>
- #include <stdlib.h>
- #include <unistd.h>
- #include <sys/types.h>
- #include <sys/wait.h>
-
- int main()
- {
- pid_t ids[10];
- for (int i = 0; i < 10; i++)
- {
- pid_t id = fork();
- if (id == 0)
- {
- //child
- printf("child process created successfully...PID:%d\n", getpid());
- sleep(3);
- exit(i); //将子进程的退出码设置为该子进程PID在数组ids中的下标
- }
- //father
- ids[i] = id;
- }
- for (int i = 0; i < 10; i++)
- {
- int status = 0;
- pid_t ret = waitpid(ids[i], &status, 0);
- if (ret >= 0)
- {
- //wait child success
- printf("wiat child success..PID:%d\n", ids[i]);
- if (WIFEXITED(status))
- {
- //exit normal
- printf("exit code:%d\n", WEXITSTATUS(status));
- }
- else
- {
- //signal killed
- printf("killed by signal %d\n", status & 0x7F);
- }
- }
- }
- return 0;
- }
运行代码,这时我们便可以看到父进程同时创建多个子进程,当子进程退出后,父进程再依次读取这些子进程的退出信息。
在上面的例子中,当子进程尚未退出时,父进程会一直处于等待状态,这种等待方式被称为阻塞等待。在这种模式下,父进程无法进行其他操作,直到子进程退出。
为了避免这种情况,我们可以采用非阻塞等待的方式。这样,父进程在子进程未退出时,可以继续执行自己的任务,而在子进程退出后,再去获取子进程的退出信息。这样可以提高父进程的效率,使其在等待期间能够进行其他操作。
我们可以通过,向waitpid函数的第三个参数potions传入
WNOHANG
,这样一来,等待的子进程若是没有结束,那么waitpid函数将直接返回0,不予以等待。而等待的子进程若是正常结束,则返回该子进程的pid。
例如,父进程可以隔一段时间调用一次waitpid函数,若是等待的子进程尚未退出,则父进程可以先去做一些其他事,过一段时间再调用waitpid函数读取子进程的退出信息。
- #include <stdio.h>
- #include <stdlib.h>
- #include <unistd.h>
- #include <sys/types.h>
- #include <sys/wait.h>
-
- int main()
- {
- pid_t id = fork();
- if (id == 0)
- {
- //child
- int count = 3;
- while (count--)
- {
- printf("child do something...PID:%d, PPID:%d\n", getpid(), getppid());
- sleep(3);
- }
- exit(0);
- }
-
- //father
- while (1)
- {
- int status = 0;
- pid_t ret = waitpid(id, &status, WNOHANG);
- if (ret > 0)
- {
- printf("wait child success...\n");
- printf("exit code:%d\n", WEXITSTATUS(status));
- break;
- }
- else if (ret == 0)
- {
- printf("father do other things...\n");
- sleep(1);
- }
- else
- {
- printf("waitpid error...\n");
- break;
- }
- }
- return 0;
- }
运行结果就是,父进程每隔一段时间就去查看子进程是否退出,若未退出,则父进程先去忙自己的事情,过一段时间再来查看,直到子进程退出后读取子进程的退出信息。
使用 fork
创建子进程后,子进程会执行与父进程相同的程序(虽然可能执行不同的代码路径)。如果我们希望子进程执行一个完全不同的程序,通常需要调用 exec
函数。
当进程调用 exec
函数时,进程的用户空间代码和数据会被新程序完全替换,接着从新程序的入口点开始执行。这意味着原程序的代码和数据将被新程序的代码和数据取代。
当进行进程程序替换时,有没有创建新的进程?
在进程程序替换之后,虽然进程的用户空间代码和数据被新程序替换了,但进程的进程控制块(PCB)、进程地址空间以及页表等数据结构保持不变。这意味着,进程并没有被重新创建,而是原有的进程在物理内存中的数据和代码被新的程序所取代。因此,替换程序前后的进程标识符(PID)保持不变。
子进程进行进程程序替换后,会影响父进程的代码和数据吗?
当子进程刚被创建时,它与父进程共享代码和数据。然而,如果子进程需要进行进程程序替换,这通常意味着子进程会对其代码和数据进行修改。这时,系统会执行写时拷贝(Copy-On-Write)操作,将父子进程共享的代码和数据进行分离。这样,子进程进行程序替换时,原有的父进程的代码和数据不会受到影响,两者的代码和数据也就分离开来。
替换函数有六种以exec开头的函数,它们统称为exec函数:
1、int execl(const char *path, const char *arg, ...);
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。
例如,要执行的是ls程序。
execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);
2、int execlp(const char *file, const char *arg, ...);
第一个参数是要执行程序的名字,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。
例如,要执行的是ls程序。
execlp("ls", "ls", "-a", "-i", "-l", NULL);
3、int execle(const char *path, const char *arg, ..., char *const envp[]);
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,第三个参数是你自己设置的环境变量。
例如,你设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。
- char* myenvp[] = { "MYVAL=2024", NULL };
- execle("./mycmd", "mycmd", NULL, myenvp);
4、int execv(const char *path, char *const argv[]);
第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。
例如,要执行的是ls程序。
- char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
- execv("/usr/bin/ls", myargv);
5、int execvp(const char *file, char *const argv[]);
第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。
例如,要执行的是ls程序。
- char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
- execvp("ls", myargv);
6、int execve(const char *path, char *const argv[], char *const envp[]);
第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量。
例如,你设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。
- char* myargv[] = { "mycmd", NULL };
- char* myenvp[] = { "MYVAL=2024", NULL };
- execve("./mycmd", myargv, myenvp);
-1
。换句话说,只要 exec
系列函数返回值不为 -1
,就表示调用失败。 exec
系列函数的函数名都以 exec
开头,其后缀的含义如下:
PATH
来查找程序。 函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否,需自己组装环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 否 | 否,需自己组装环境变量 |
实际上,execve
是唯一真正的系统调用,其它五个 exec
系列函数最终都是通过 execve
实现的。因此,execve
在 man
手册的第2节,而其他五个函数则在第3节。这意味着,其他五个 exec
系列函数实际上是对系统调用 execve
的封装,以适应不同用户的调用需求。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。