目录
一、进程创建
fork()
写时拷贝
fork的应用场景
二、进程退出
什么是进程退出码?
退出码的含义
进程退出方法
三、进程等待
进程等待的必要性
进程等待的方法
wait
waitpid
status
阻塞与非阻塞
四、进程替换
替换原理
替换函数
命名理解
简易shell编写
一、进程创建
在Linux系统下我们可以使用fork()函数为当前进程创建一个子进程。
fork()
#include<unistd.h>
pid_t fork(void);
返回值:子进程中返回0,父进程返回子进程pid,出错返回-1
在fork()系统调用中,当一个进程调用fork后,控制转移到内核中的fork代码,内核会执行以下操作:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中 fork返回
- 开始调度器调度
操作系统会为该进程创建一个几乎一模一样的子进程。当fork完成时,两个进程的内存、寄存器、程序计数器等状态都完全一致,他们的代码共享。但它们是完全独立的两个进程,拥有不同的PID和虚拟内存空间,在fork完成后它们会各种独立地执行,互不干扰。即进程具有独立性。
如果大家对进程之间如何保持独立性感兴趣可以读一下我之前所写的博客 进程地址空间
演示:
运行结果:
我们可以看到子进程对a的修改并未影响到父进程,子进程与父进程相互独立,而从getpid()与getppid()我们可以看到他们确实互为父子关系。需要注意的一点是fork之后,父进程和子进程谁先执行完全由调度器决定。
写时拷贝
当使用fork()系统调用创建一个新进程时,子进程会继承父进程的地址空间、数据、环境变量等资源。这些资源并不是立即复制给子进程的,而是让父子进程共享这些资源。只有当其中一个进程试图修改这些共享资源时,操作系统才会进行实际的复制操作,即写时拷贝。
Q:为什么要写时拷贝
A:写时拷贝可以减少不必要的内存使用,因为多个进程或线程可能只是读取共享数据,而不会对其进行修改。在这些情况下,共享同一个数据副本是有效率的。
需要注意的是,代码也会进行写时拷贝,这点我们将会在进程替换为大家介绍。
fork的应用场景
多进程并发处理任务:
在需要同时处理多个任务的情况下,可以使用fork函数创建多个子进程来并发处理这些任务。每个子进程独立运行,可以同时执行不同的任务,从而加快任务处理速度。父进程可以通过等待子进程结束并获取子进程的返回结果,从而实现多任务的并发处理。
服务器编程:
在服务器编程中,使用fork函数可以实现并发处理客户端的请求。当有新的连接请求到达服务器时,可以使用fork函数创建一个子进程来处理该连接,而父进程继续监听新的连接。这样可以同时处理多个客户端请求,提高服务器的并发性能。
创建守护进程:
fork函数也可以用于创建守护进程。守护进程是在后台运行的特殊进程,通常用于执行系统级任务。通过fork函数创建一个子进程,并在子进程中执行需要的任务,然后让父进程退出,子进程就会成为新的会话组长,从而成为一个守护进程。
shell程序:
在shell程序中,fork函数被用于创建子进程来执行用户输入的命令。当用户输入一个命令时,shell程序会使用fork函数创建一个子进程,然后在子进程中执行该命令。这样可以让shell程序在等待用户输入新的命令时,子进程可以继续执行上一个命令。
在本文我们会用所学知识实现一个简易的shell。
二、进程退出
进程退出有且仅有三种情况:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
这三种情况可分为异常退出和正常退出,前两种情况为正常退出,第三种情况为异常终止。
当程序退出时会返回一个退出码(我们可以通过 echo $? 查看进程退出码)
什么是进程退出码?
要回答这个问题,我们首先要重新建立对main函数的认知。
我们知道一个C/C++函数必须要有main函数,main函数是C/C++ 程序的标准入口。操作系统通过·调用main函数来执行程序,但main函数并不是被操作系统直接调用的。
当程序开始执行时,它首先会执行一些初始化工作,如设置全局变量、分配堆和栈空间等,然后调用 main 函数。尽管 main函数是代码的入口,但它并不是程序启动的第一个函数。在 main函数之前,还有其他的启动代码(如 __tmainCRTStartup)被执行。
启动代码是由 C 运行时库提供的,它的职责是设置程序运行所需的环境,包括初始化运行时堆、栈、全局变量等,然后调用 main函数。这个启动代码最终是由操作系统的加载器调用的,加载器负责将程序加载到内存中,并设置适当的上下文以便程序可以执行。
当 main函数执行完毕后,它会返回一个整数值,这个值被称为退出码(exit code)或返回码。这个退出码实际上是传递给操作系统的,表示程序是正常结束(通常返回0)还是出现了某种错误(返回非0值)。操作系统可以使用这个退出码来判断程序的执行状态,并在需要时进行相应的处理。
现在我们明白了退出码的本质是main函数的返回值。
退出码的含义
每一个退出码都对应一种错误,我们可以通过strerror函数获取对应的错误信息。
执行结果:
我们可以看到退出码都有对应的字符串含义,帮助用户确认执行失败的原因,
但实际上这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。
进程退出方法
正常退出:这通常发生在程序成功执行并完成了其所有任务后。在C/C++中,当main函数执行完毕并返回时,进程会正常退出。main函数的返回值就是进程的退出码,通常返回0表示成功,非0值表示某种错误或异常情况。
异常终止:这是由于程序遇到了无法处理的错误或异常情况,如运行时错误、内存溢出、除零错误等。在这种情况下,进程会突然终止,并且通常会返回一个非零的退出码,以指示程序没有成功完成。
通过系统调用退出:程序员可以使用特定的系统调用来明确结束进程。
#include<unistd.h>
void _exit(int status);
#include<unistd.h>
void exit(int status);
参数:status 定义了进程的终止状态,父进程通过wait来获取该值
在C/C++中,可以使用exit()函数来结束程序。当调用exit()时,程序会立即终止,并且传递给exit()的参数将作为进程的退出码。_exit()和也是可以用来结束进程的函数,但它的行为与exit()略有不同。例如,_exit()会立即结束进程,而不会执行任何后续的清理操作,如关闭文件描述符或执行已注册的终止处理程序。
exit最后也会调用exit, 但在调用exit之前,还做了其他工作:
1. 执行用户通过 atexit或on_exit定义的清理函数。
2. 关闭所有打开的流,所有的缓存数据均被写入
3. 调用_exit
实例:
可以看到_exit()并不会刷新缓冲区。
三、进程等待
进程等待的必要性
- 防止僵尸进程:当子进程结束后,如果父进程没有对其进行处理(即没有读取子进程的退出状态信息),那么这个子进程就会成为僵尸进程。僵尸进程会占用系统资源,并且不能被操作系统正常清理,从而导致内存泄漏等问题。通过进程等待,父进程可以及时回收子进程的资源,避免僵尸进程的产生。这个问题我们在之前的博客Linux进程状态讨论过。
- 获取子进程执行结果:父进程通常需要知道子进程的执行结果,以便根据执行结果进行后续的操作。例如,父进程可能需要检查子进程是否成功完成了任务,或者获取子进程处理的数据结果。通过进程等待,父进程可以获取子进程的退出状态信息,从而了解子进程的执行结果。
- 同步进程执行顺序:在某些情况下,父进程需要等待子进程完成某些任务后才能继续执行。例如,父进程可能需要等待子进程生成某个文件或数据,然后再对这些文件或数据进行处理。通过进程等待,父进程可以控制自己的执行顺序,确保在继续执行前子进程已经完成了必要的任务。
进程等待的方法
wait
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
返回值: 成功返回被等待进程pid,失败返回-1。
参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
wait是一个系统调用,用于使父进程等待一个子进程的结束。它会阻塞父进程,直到有一个子进程结束为止。一旦有子进程结束,wait()会返回该子进程的进程ID,同时父进程可以获取子进程的退出状态。
waitpid
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出) WEXITSTATUS(status):
若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进 程的ID。
waitpid(),提供了更多的控制选项。除了具备 wait()
的功能外,它还允许父进程指定等待哪个(些)子进程,以及如何等待。
注意:
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回
status
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。 如果传递NULL,表示不关心子进程的退出状态信息。 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。 status不能简单的当作整形来看待,操作系统将它当做一个位图,具体细节如下图(只研究status低16比特位)
我们可以使用位操作,或者宏来获取status中储存的信息
WIFEXITED(status):
功能:检查子进程是否是正常退出(即调用了
exit()
函数或从main()
返回)。返回值:如果是正常退出,则返回非零值(通常为1),否则返回0。
用途:这可以帮助父进程区分子进程是正常结束还是因为其他原因(如信号)终止。
WEXITSTATUS(status):
功能:如果
WIFEXITED(status)
返回非零值,表明子进程是正常退出的,此宏可以提取子进程的退出状态码。返回值:返回子进程的退出状态码,范围通常是0到255。
用途:可以让父进程知道子进程是如何结束的,根据退出码执行不同的后续操作。
WIFSIGNALED(status):
功能:检查子进程是否是因为接收到信号而终止的。
返回值:如果是信号导致的终止,则返回非零值(通常为1),否则返回0。
用途:帮助父进程识别子进程是否是异常结束,并可能需要采取相应的错误处理措施。
WTERMSIG(status):
功能:如果
WIFSIGNALED(status)
返回非零值,表明子进程是被信号终止的,此宏可以获取导致终止的信号编号。返回值:返回导致子进程终止的信号编号。
用途:父进程可以根据不同的信号采取相应的处理逻辑,比如重新启动子进程、记录错误日志等。
阻塞与非阻塞
waitpid中的options可以控制父进程等待的方式,当options为0时为阻塞式等待,当options为WNOHANG时为非阻塞式等待
阻塞式等待:
当一个进程或线程执行阻塞等待操作时,它会暂停当前的执行,直到等待的条件满足(如子进程终止、I/O操作完成或其他特定事件发生)。在此期间,该进程或线程不会消耗CPU时间,而是被操作系统挂起,直到等待的事件发生。
特点:简单易用,不需要编写额外的逻辑来检查事件状态。但如果等待时间过长,可能会导致资源浪费,尤其是对于需要高响应性的应用程序。
非阻塞等待:
非阻塞等待允许进程或线程在等待某个事件的同时,继续执行其他任务。这意味着调用不会立即阻塞调用者,如果所等待的事件尚未发生,函数会立即返回一个指示状态(如错误代码或特殊值),而不是等待。
特点:提高了程序的响应性和并发性,因为调用者不必等待就可以进行其他工作。但是,这也意味着需要额外的逻辑来处理未完成的事件,如轮询、事件通知或使用异步回调。
四、进程替换
替换原理
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动 例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
替换函数
有六种以exec开头的函数,统称exec函数:
#include<unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。 如果调用出错则返回-1 所以exec函数只有出错的返回值而没有成功的返回值。
命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量
事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve.
这些函数之间的关系如下图所示。
简易shell编写
要写一个shell,需要循环以下过程:
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
#define MAX_CMD 1024
#define MAX_ARGS 32
char command[MAX_CMD];
// 内置命令处理
int handle_builtin_commands(char *cmd) {
if (strcmp(cmd, "exit") == 0) {
printf("退出shell。\n");
return 1; // 表示已处理内置命令
}
return 0; // 未处理,需要外部执行
}
// 解析命令
char **do_parse(char *buff) {
static char *argv[MAX_ARGS + 1];
int argc = 0;
char *ptr = buff;
while(*ptr != '\0' && argc < MAX_ARGS) {
while(isspace(*ptr)) ptr++; // 跳过前导空白
if (*ptr == '\0') break;
argv[argc++] = ptr;
while(*ptr != '\0' && !isspace(*ptr)) ptr++; // 找到参数结束
*ptr++ = '\0'; // 结束当前参数
}
argv[argc] = NULL; // 结束标志
return argv;
}
// 执行命令
int do_exec(char *buff) {
char **argv = do_parse(buff);
if (argv[0] == NULL) return -1;
if (handle_builtin_commands(argv[0])) {
return 0; // 内置命令已处理,直接返回
}
pid_t pid = fork();
if (pid < 0) {
perror("fork失败");
return -1;
} else if (pid == 0) { // 子进程
execvp(argv[0], argv);
perror("执行命令失败");
exit(EXIT_FAILURE); // 如果execvp失败,则子进程退出
} else { // 父进程
waitpid(pid, NULL, 0); // 等待子进程结束
}
return 0;
}
// 展示命令提示符并读取用户输入
int do_face() {
memset(command, 0x00, MAX_CMD);
printf("minishell$ ");
fflush(stdout);
if(scanf("%[^\n]%*c", command) == EOF) {
// EOF检测,用户可能使用Ctrl+D退出
printf("\n");
return 1; // 表示用户结束输入
}
return 0;
}
int main() {
while(1) {
if (do_face()) {
printf("再见!\n");
break; // 用户结束输入,退出循环
}
if (do_exec(command) < 0) {
printf("命令执行失败,请重试。\n");
}
}
return 0;
}
效果展示: