进程创建
关于进程的创建,在Linux进程状态与进程优先级部分已进行过讨论,为了保证文章的完整性,这里再进行简述。
在linux平台下,创建进程有两种方式:运行指令和使用系统调用接口,前者是在指令层面创建进程,后者是在代码层面创建进程。在C/C++代码中,使用 fork(2)
创建子进程,fork(2)的工作有3步:创建进程、填充进程内核数据结构和值返回,fork(2) 在值返回时分别在父、子进程中返回两次。关于fork(2)的使用和更多细节,请参考上述文章。
为了叙述方便,本文以 func(2) 表示func是一个2号文档的系统调用,而以 func(3) 表示func是一个3号文档的C接口。
进程终止
进程终止的三种场景
一个进程终止,无外乎三种情况:
- 代码运行完毕,结果正确。这正是我们想要的,此时不需要做其他处理。
- 代码运行完毕,结果不正确。
- 程序异常终止,代码未运行完毕。
针对第二种和第三种情况,我们需要知道程序的错误信息或异常信息,以对程序进行调整。对于第二种情况的错误信息,一般可以通过进程的退出码获悉。
进程的退出码
下面是一个经典的"Hello World"实例:
/*
代码2.1
*/
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
在C/C++程序中,main 函数是被系统中的其他函数调用的,上面的代码第8 行在 return 后,其实是将 0 返回给了 exit(3)
函数,exit(3) 的参数即为这个进程的退出码(exit code)。对于 exit(3) 函数,这是一个使进程主动退出的函数,下文会进行详谈,这个函数的参数即为进程的退出码。在 main 函数中,当执行 return 时,main 函数对应的进程已经可以认为结束,所以 return 返回一个值与用该值调用 exit(3) 是等价的。
退出码标识了进程的退出状态,规定,退出码为 0,表示程序正常结束且结果正确,否则认为程序运行错误。进程退出后,会将退出码返回给其父进程,父进程最终会将退出码转交给用户,供用户做出判断和决策。即,退出码是服务于用户的。
承上,在C/C++中,全局变量 errno
会保存最近一次C库函数运行后的退出码,同时,C接口 strerror
可以将错误码转化为错误信息(错误码描述)。
#include <string.h>
char *strerror(int errnum);
上述的,用户接收错误码,并根据错误码对程序进行调整,只针对于程序运行完毕的情况,而不考虑程序异常退出的情况。程序异常退出时,首先,其是否返回了退出码是无法确定的,假设在进程退出时没有返回有效的退出码而用户使用了这个退出码,就会使用户对程序的退出情况进行误判;其次,假设进程退出时确实返回了有效的退出码,此时用户依然无法确定这个退出码是否有效。承上,判断程序的执行结果时,首先要判断其是否异常退出,再看退出码。
可以认为,程序异常退出,本质是接收到了某种信号。相关内容会在有关进程信号的文章中讨论。
进程终止的方式
不考虑程序异常终止的情况和线程概念,有 3 种方式使进程终止:
- 从 main 函数返回。
- 调用 exit(3)。
- 调用
_exit(2)
。
#include <stdlib.h>
void exit(int status);
#include <unistd.h>
void _exit(int status);
exit(3) 是一个C语言接口,在任何地方被调用时,进程直接退出并返回退出码;_exit(2) 是一个系统调用,在任何地方被调用时,进程直接退出并返回退出码。exit(3) 与 _exit(2) 的不同之处在于,exit(3)在被调用时,会先刷新缓冲区,关闭流,再调用 exit(2) 使进程退出。即,_exit(2)与exit(2)是调用者与被调用关系。
进程等待
为什么要进行进程等待 & 什么是进程等待
进行进程等待,即是要解决三个问题:
- 如文章Linux进程状态与进程优先级所说,当一个进程退出后,如果其父进程没有查看和回收子进程,子进程就会进入僵尸状态。僵尸进程无法被杀死(kill),只能通过父进程进行进程等待来处理,进而解决僵尸进程的资源泄露问题。这一点是必须要处理的。
- 父进程创建子进程的目的,即是要让子进程完成某些任务,子进程退出后,通过进程等待,父进程可以获取子进程的退出状态以获悉子进程的任务完成情况,以最终使用户获取进程的退出情况。
- 通过进程等待可以保证父进程最后退出,避免产生孤儿进程。
进程等待,即是通过系统调用 wait(2)
或 waitpid(2)
对子进程进行进程回收与状态检测的过程。
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait和waitpid
由于 waitpid 的功能是 wait 功能的全集,所以只详细介绍waitpid,wait的使用及原理与 waitpid 同理。waitpid的函数原型如上。
参数和返回值
pid参数用来指定等待对象。这里考虑pid参数的两种情况:
- pid == -1 表示等待任意的子进程,任意的子进程僵尸,都会被waitpid 进行资源回收。
- pid > 0 等待指定的子进程,这个子进程的pid为指定的实参。只有这个指定的子进程僵尸时,waitpid才会对其进行资源回收。
status参数是用来进行状态收集的。获取子进程的退出信息,本质是获取子进程的数据,而由于进程之间的独立性,这个工作必须由操作系统(系统调用)完成。status 是一个输出型参数,由操作系统通过指针对其进行修改。承上,进程退出的情况无外乎三种:异常退出、正常退出结果正确和正常退出结果不正确,而作为父进程/用户, 最期望获得的子进程退出的信息为:
- 子进程是否异常
- 如果没有异常,结果是否正确
- 如果结果不正确,错误信息是什么
作为一个32位(32位和64位机器下)的整数,status 只有后16位被使用:
- 当进程正常退出时,status前16位的低8位为0,高8位存储退出状态。
- 当进程异常退出时,status前16位的低7位存储终止信号,第8位存储core dump标志(这里先不做讨论);高8位不被使用。
在用户层面,如果要获取 status 中的信息,可以手动进行位运算,不过最常见的做法是使用系统提供的宏。最常用的两个宏为:
WIFEXITED(int status)
判断是否异常。WEXITSTATUS(int status)
提取子进程的退出码。
如果不关心子进程的退出状态,可以将 status 置为 NULL。
options参数用来指定父进程的等待方式。options有两个值可供选择:
- options为
0
进行阻塞等待。父进程在运行至waitpid,且子进程当前还未退出,父进程便会进行阻塞等待子进程,进入子进程的阻塞队列,直至子进程终止(僵尸)。 - options为宏
WNOHANG
进行非阻塞等待。父进程在运行至waitpid时:
- 如果子进程当前还未退出,则父进程不阻塞,waitpid 直接返回 0;
- 如果子进程已经退出,则waitpid对子进程进行资源回收与状态收集。
waitpid的返回值是一个 int 类型:
- 如果返回值大于0,则返回值为被回收进程的 pid。
- 如果父进程进行非阻塞等待,且此时子进程尚未退出,则waitpid返回0。
- waitpid也会出现等待失败的情况,例如当等待的进程不是自己的子进程,此时waitpid返回 -1 。
非阻塞轮询
承上,waitpid 可以进行非阻塞等待,为了以非阻塞方式最终成功对子进程进行回收,父进程需要进行轮询,即不断调用 waitpid 对子进程进行检查。相比阻塞等待,非阻塞轮询期间父进程可以进行其他工作,灵活性更强。
/*
*代码 3.1
*/
int main()
{
pid_t id = fork();
if(id < 0) { perror("fork err"); exit(1); }
else if(id == 0)
{
/*child do work...*/
sleep(5);
exit(0);
}
else
{
int status = 0;
//父进程:进行非阻塞轮询
while(waitpid(id, &status, WNOHANG) == 0) {
printf("child still working...\n");
sleep(1);
/*father do other work...*/
}
printf("child exit\n");
exit(0);
}
return 0;
}
//运行结果:
child still working...
child still working...
child still working...
child still working...
child still working...
child exit
基本原理
wait/waitpid 的基本原理为:子进程僵尸时,代码和数据被销毁,而进程task_struct 不销毁,操作系统(waitpid/wait)通过读取子进程 task_struct 中的信息,将错误信息通过位运算集成在 status 中,并将子进程的task_struct释放。
程序替换
当用 fork 函数创建子进程后,子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程执行的程序完全被替换为新程序,这个新程序会被从其入口开始执行。这即是程序替换。调用 exec 函数不创建新进程,只是用磁盘上的一个新程序替换了当前程序(的正文段、数据段、堆段和栈段,当前进程的 task_struct 和 mm_struct 大体不变,只修改其中的部分信息。
exec系列接口
有 7 种不同的 exec 函数可供使用,用户可以根据情况进行选择调用:
#include <unistd.h>
extern char **environ;
/*1.*/ int execl(const char *path, const char *arg, ...);
/*2.*/ int execlp(const char *file, const char *arg, ...);
/*3.*/ int execle(const char *path, const char *arg, ..., char * const envp[]);
/*4.*/ int execv(const char *path, char *const argv[]);
/*5.*/ int execvp(const char *file, char *const argv[]);
/*6.*/ int execvpe(const char *file, char *const argv[], char *const envp[]);
/*7.*/ int execve(const char *filename, char *const argv[], char *const envp[]);
//上述所有函数,程序替换成功返回0,否则返回-1
其中前 6 个函数是C语言标准库提供的,第 7 个函数是2号手册中的系统调用。在实现层面,前6个接口最终都会调用最后一个系统调用。
这些 exec 函数,第一个参数需要用户指定需要新程序的位置;后面的参数需要用户指定如何执行这个新程序,一般以命令行参数说明;某些函数还会有环境变量相关的参数。观察这些 exec 函数,除了统一的 exec 前缀之外,还有以下后缀:
l
(list) 表示用户需要以命令行参数列表的形式指定程序的执行方法。命令行参数列表必须以 NULL 结尾。p
(PATH)系统会自动在环境变量 PATH 中寻找这个程序。v
(vector) 表示用户需要以命令行参数数组的形式指定程序的执行方法。命令行参数数组的最后一个元素必须是 NULL。e
(env) 表示用户需要手动组装环境变量表。每个进程的地址空间中都有一份环境变量,环境变量在进程被创建时就已经存在。进行程序替换时,默认使用原来的环境变量。exece* 需要用户手动组装环境变量表,在这个过程中可以使用当前的环境变量表environ
,也可以自定义环境变量表,对原来的环境变量表进行覆盖。自定义环境变量表的内容必须以 NULL 结束。
承上,在这些 exec 函数的执行层面,用户传入的命令行参数列表都会最终被转化为命令行参数数组,并使用 environ 环境变量,最终执行 execve 系统调用。
一个程序替换实例(mini_shell)
下面是一个模拟 shell 的mini_shell,可以实现最基本的shell功能。用户输入后,mini_shell会解析命令,将命令和命令参数存储在一个数组中,然后 fork 出一个子进程,在子进程中调用 execvp 进行程序替换,以完成用户的任务。这里暂不考虑内建命令的执行。
/*
* 代码 4.1
*/
#define LEFT "["
#define RIGHT "]"
#define COMMAND_SIZE 1024
#define ARG_MAX 50
#define PATH_LENGTH 100
#define ENV_LENGTH 100
#define DELIM_STR " \t"
int quit = 0; //shell是否退出
int last_code = 0; //最近一次命令的退出码
char command_line[COMMAND_SIZE]; //存储输入命令
char* command_vector[ARG_MAX]; //存储解析后的输入命令
const char* get_user_name()
{
return getenv("USER");
}
const char* get_host_name()
{
return getenv("HOSTNAME");
}
const char* get_pwd()
{
return getenv("PWD");
}
//普通命令执行
int normalCommand(int argCunt)
{
//fork一个子进程,并将子进程替换为欲执行命令
pid_t id = fork();
if(id < 0) { perror("fork err"); exit(1); }
else if(id == 0)
{
if(!strcmp(command_vector[0], "ls") || !strcmp(command_vector[0], "ll"))
{
command_vector[argCunt++] = "--color";
command_vector[argCunt] = NULL;
}
if(!strcmp(command_vector[0], "ll"))
{
command_vector[0] = "ls";
command_vector[argCunt++] = "-l";
command_vector[argCunt] = NULL;
} //进行程序替换
int ret = execvp(command_vector[0], command_vector);
if(ret == -1) { exit(1); }
}
else
{ //等待子进程和获取退出信息,更新最近一次的退出信息
int status = 0;
waitpid(id, &status, 0);
last_code = WEXITSTATUS(status);
}
return 1;
}
//命令解析
int stringSplit()
{
int index = 0;
command_vector[index++] = strtok(command_line, DELIM_STR);
if(command_vector[index - 1] != NULL) while(command_vector[index++] = strtok(NULL, DELIM_STR));
return index - 1;
}
//用户交互
void interAct()
{
printf(LEFT"%s@%s %s"RIGHT" ", get_user_name(), get_host_name(), get_pwd());
fgets(command_line, COMMAND_SIZE, stdin);
command_line[strlen(command_line) - 1] = '\0';
}
void mini_shell_init()
{
filename = NULL;
}
int main()
{
while(!quit)
{
mini_shell_init();
interAct(); //用户交互(输入命令)
int argCount = stringSplit(); //命令解析
if(argCount == 0) { continue; }
normalCommand(argCount); //执行命令
}
return 0;
}
使用效果:
[@shr Tue Nov 21 16:12:05 10.28_mini_shell]$ ./testShell
[shr@VM-24-8-centos /home/shr/code/2023_y/10.28_mini_shell] ls -a -l
total 44
drwxrwxr-x 2 shr shr 4096 Nov 3 10:44 .
drwxrwxr-x 82 shr shr 4096 Nov 21 14:26 ..
-rw-rw-r-- 1 shr shr 303 Oct 29 19:58 makefile
-rw-rw-r-- 1 shr shr 5189 Nov 3 10:47 mini_shell.c
-rwxrwxr-x 1 shr shr 17960 Nov 3 10:44 testShell
-rw-rw-r-- 1 shr shr 27 Nov 3 10:02 txt
[shr@VM-24-8-centos /home/shr/code/2023_y/10.28_mini_shell] cowsay hello
_______
< hello >
-------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
[shr@VM-24-8-centos /home/shr/code/2023_y/10.28_mini_shell] exit
[@shr Tue Nov 21 16:12:26 10.28_mini_shell]$
[@shr Tue Nov 21 16:12:26 10.28_mini_shell]$
在这个过程中shell进程与子进程的运行大致情况如下:
程序替换的原理
如上文所说,进行程序替换操作时,操作系统会将程序的代码和数据加载到内存,此时进程的用户区的代码段和数据段完全被新程序替换,从新程序的程序入口处开始执行。程序替换不创建新进程,只会对进程的某些内核数据结构字段进行调整,所以进程的 PID 不变。在原来的程序中,如果 exec 执行成功,后面的、原来的代码已经被替换,不继续执行;如果 exec 执行失败,则继续执行后面的、原来的代码。
承上,exec 具有加载器的效果,因为其可以做到将硬盘中的可执行程序加载到内存中。
类似上述的 mini_shell 程序,之所以支持多进程下的程序替换,是因为进程具有独立性,当子进程调用 exec 将新程序的代码和数据进行替换时,会触发代码和数据的写时拷贝,此时子进程的程序替换不影响父进程。