进程控制详解
本文介绍了Linux进程控制的相关知识,主要包括进程创建和终止两个部分。在进程创建方面,详细讲解了fork函数的工作原理、写时拷贝机制以及fork的常见用法和失败原因。在进程终止部分,阐述了进程终止的本质、三种终止场景(正常成功、正常失败、异常终止),并具体说明了进程退出的方法(main返回、exit、_exit)和退出码的含义。文章通过代码示例和图示,清晰展示了父子进程的执行流程和内存管理机制,

🎬 GitHub:Vect的代码仓库
文章目录
1. 进程创建
fork函数


根据文档描述:
fork函数的作用是创建一个子进程
返回值:
- >0 把子进程的pid返回给父进程
- ==0 说明创建的是子进程
- -1 说明进程创建失败
进程调用fork,当控制转移到内核中fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝给子进程
- 添加子进程到系统进程列表中
fork返回,调度器开始调度,父子进程执行顺序由调度器随机决定
看如下代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(){
printf("before: pid: %d\n",getpid());
pid_t id = fork();
if(id < 0){
perror("fork");
return 1;
}else if(id == 0){
printf("after: child, pid:%d, return val: %d\n",getpid(),id);
}else{
printf("after: father, pid:%d, return val: %d\n",getpid(),id);
}
return 0;
}
输出结果:
[vect@VM-0-11-centos process_control]$ make
gcc -o show_fork show_fork.c
[vect@VM-0-11-centos process_control]$ ./show_fork
before: pid: 9928
after: fatehr, pid:9928, return val: 9929
after: child, pid:9929, return val: 0
在frok之前,父进程执行打印,此时没有创建子进程
在fork之后,父子进程分别执行各自的打印操作
也就是说,fork之前父进程独立执行,fork之后父子进程执行流分别执行

为什么子进程返回0,父进程返回子进程的pid?
一个父进程可以有多个子进程,而一个子进程只能有一个父进程。对于父进程,子进程需要被标记,父进程可以根据子进程的pid返回值更好的管理子进程;而对于子进程来说,父进程无需被标记
为什么fork有两个返回值?
并不是
fork返回两次,而是fork调用一次会创建一个子进程,父子进程会从fork返回处继续执行,各自拿到一个返回值
写时拷贝
fork创建子进程时,内核不会立刻复制父进程的物理内存,而是做两件事:
- 共享物理内存: 父子进程共享同一块物理内存空间(包括代码和数据)
- 标记页表项为 “只读”:将父子进程页表中指向该共享物理内存的页表项权限均设置为 “只读”;同时保证父子进程的虚拟地址通过各自的页表项,映射到同一块物理内存页
当父进程或子进程试图修改这块内存时,内核触发缺页异常, 进行缺页中断,随即开始检测,判定有进程要发生写时拷贝——为要修改的的内存创建独立的物理内存副本,然后让修改方指向这个新副本,另一方仍指向原内存

详细过程如下:
fork调用,创建子进程
- 父进程调用
fork,内核创建子进程的PCB,复制父进程的页表- 内核将父子进程页表项的代码和数据标记为“只读”
- 父子进程的页表映射到同一块物理内存
- 返回两个进程的返回值,此时父子进程完全共享内存
无修改操作->持续共享内存
- 若父子进程都只读取内存,永远不触发写实拷贝
有操作修改->触发写时拷贝
当父子进程试图写入某块内存时:
- CPU触发缺页异常
- 内核捕获异常,检查是写时拷贝导致的异常
- 内核为被修改的这个内存分配新的物理内存
- 内核将原内存的内容拷贝到新的内存
- 修改触发写操作的页表项,指向新的物理内存
- 取消新内存的只读标记,原内存保持只读
- 异常处理结束,CPU重新执行写操作,此时写入新副本
fork的用法
- 一个父进程希望复制自己,使父子进程执行不同的代码段
- 一个进程要执行一个不同的程序
fork失败的原因
- 系统进程太多
- 实际用的进程数超过了限制
2. 进程终止
进程终止的本质是释放内核数据结构和对应代码与数据
有三个场景:
- 代码运行完毕,结果正确->返回0
- 代码运行完毕,结果错误->返回非0
- 代码异常终止->本质是OS用信号提前终止了进程
进程退出方法
正常终止的进程可以通过
echo $?查看最近一次进程的退出码./look_exit hh [vect@VM-0-11-centos process_control]$ echo $? 0 [vect@VM-0-11-centos process_control]$ lsls -bash: lsls: command not found [vect@VM-0-11-centos process_control]$ echo $? 127
- 从
main函数返回- 调用
exit_exit异常退出:
ctrl+c,信号终止
退出码
退出码可以告诉我们最后一次执行的进程的状态。在命令结束后,可以根据退出码知道进程以什么方式终止
Linux Shell中的重点退出码有
- 0:命令成功执行
- 1:通用错误(如权限不足、除以0)
- 126:权限被拒绝(无法执行)
- 127:命令未找到(PATH错误)
- 128+n:信号终止(n为信号编号,例如:128+2=130,对应
ctrl+c)
我们用这段代码可以获得全部的退出码:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main(){
for(int i = 0; i < 200; i++){
printf("%d:%s\n",i,strerror(i));
}
return 0;
}
进程正常退出
main函数的return
在main函数中使用return退出进程是我们最常用的方法,但是这里需要注意:
普通函数的return只代表这个函数结束,并不代表整个进程结束
_exit函数和exit函数
_exit 和exit可以在代码的任何地方退出
我们先来看区别:

分析之前我们需要搞清楚标准IO缓冲区
缓冲区的本质: 标准IO库为了减少系统调用(系统调用是昂贵的内核操作),在用户语言层面开辟的一块内存区域,暂时存放输出数据,满足条件时才把数据刷到内核(最终输出到终端/文件)
分析:
exit是标准C库函数 ->用户语言层面,_exit是系通调用->内核层面,二者核心差异是是否执行用户语言层面的清理操作,尤其是缓冲区刷新
很显然,exit会刷新完缓冲区再退出,而_exit直接退出
所以,我们现在所说的缓冲区是用户语言级别的!!!不在OS内部!!!
所以,对于用户级别的exit是封装了内核级别的_exit

3.进程等待
进程等待的必要性
- 子进程退出,变成僵尸态,等待父进程处理,若不进行处理,可能会造成内存泄漏
- 进程一旦变成僵尸态,
kill -9也无能为力,谁也没办法杀死一个已经死去的进程 - 父进程分配给子进程的任务的完成情况,需要反馈
而父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
获取子进程的status
子进程的status就是退出码,子进程终止后,向父进程返回的一个整数值,它的作用是:
- 表明子进程是正常退出还是异常终止
- 若子进程正常退出,结果是否正确,异常终止,终止原因是什么
Linux下status的二进制结构
status是一个16位的整数,位段划分如下图:

- 正常退出:高8位有效,低8位为0
- 信号终止:低8位有效,高8位无效
我们可以通过位运算的方式得到退出码:
exit_code = (status >> 8) & 0xff;退出码在高8存储,且退出码的范围是[0,255],需要将这8位数右移到低8位,然后只保留低8位,则按位与0xff->
1111 1111
exit_signal = status & 0x7f;信号存在低7位,保留低7位即可,按位与0x7f->
111 1111对此,系统提供了两个宏来获取退出码和退出信号:
#define WIFEXITED(status) (((status) & 0x7F) == 0) // 判断子进程是否正常退出 #define WEXITSTATUS(status) (((status) >> 8) & 0xFF) // 获取子进程正常退出的退出码
需要注意的是:一个进程非正常退出,说明是被信号终止,那么该进程的退出码就没有意义了
进程等待方法
wait
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
作用:
等待任意子进程
返回值:
成功返回:被等待进程的pid 失败返回:-1
参数:
输出型参数,获取子进程退出状态,不关心可以设成
NULL
#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){
// 子进程
int count = 6;
while(count--){
printf("I am child, pid: %d, ppid: %d\n",getpid(),getppid());
sleep(1);
}
exit(0);
}
// 父进程
int status = 0;
// pid_t wait(int* status);
pid_t ret = wait(&status); // 等待子进程 获取子进程返回码
if(ret > 0){
// 等待成功
printf("successful wait\n");
if(WIFEXITED(status)){ // 判断是否正常退出
printf("exit code: %d\n",WEXITSTATUS(status)); // 提取退出码
}
}
sleep(3);
return 0;
}
输出结果:
[vect@VM-0-11-centos process_control]$ gcc -o wait wait.c
[vect@VM-0-11-centos process_control]$ ./wait
I am child, pid: 4197, ppid: 4196
I am child, pid: 4197, ppid: 4196
I am child, pid: 4197, ppid: 4196
I am child, pid: 4197, ppid: 4196
I am child, pid: 4197, ppid: 4196
I am child, pid: 4197, ppid: 4196
successful wait
exit code: 0

waitpid
pid_t waitpid(pid_t pid, int* status, int options)
作用:
等待指定子进程或任意子进程
返回值:
- 正常返回时返回子进程pid
- 若设置了选项
WNOHANG,而调用中waitpid中发现没有已经退出的子进程可以收集,返回0- 调用出错,返回-1,这时
errno会被设置成相应的值指示错误参数:
PID:
pid=-1,等待任意一个子进程,与wait等效
*pid>1:等待该pid的进程status:
输出型参数,获取子进程退出状态,不关心可以设成
NULLoptions:
当设置成
WNOHANG时,若等待的子进程没有结束,直接返回0,不等待;若正常结束,返回该子进程的pid
例如,创建子进程后,父进程可以一直等待子进程,waitpid的第三个参数设置为0,直到子进程退出后读取子进程的退出信息
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main(){
pid_t id = fork();
if(id == 0){
// 子进程
int count = 10;
while(count--){
printf("I am child, pid:%d, ppid:%d\n",getpid(),getppid());
sleep(1);
}
exit(0);
}
// 父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret >= 0){ // 等待成功
printf("wait successfully\n");
if(WIFEXITED(status)){ // 正常返回
printf("exit code: %d\n",WEXITSTATUS(status));
}else{ // 异常返回 被信号杀死
printf("killed by signal, %d\n",status & 0x7F);
}
}
sleep(3);
return 0;
}

进程被杀信号杀死,也可以成功等待子进程
注意:被信号杀死的进程,其退出码没有意义
阻塞等待与非阻塞等待
阻塞等待方式
当子进程未退出时,父进程一直在等待子进程,等待期间,父进程不能做任何事情,这种等待方式称为阻塞等待
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
int main(){
pid_t pid = fork();
if(pid < 0){
printf("fork err");
exit(1);
}
else if(pid == 0){ // 子进程
printf("I am child, pid: %d\n",getpid());
sleep(5);
exit(257);
}else{
int status = 0;
pid_t ret = waitpid(-1,&status,0); // 阻塞等待,等待5s
printf("waiting...\n");
if(WIFEXITED(status) && ret == pid){
printf("wait child 5s successfully, child return code: %d\n",WEXITSTATUS(status));
}else{
printf("wait child failed\n");
exit(1);
}
}
return 1;
}
输出结果:
[vect@VM-0-11-centos process_control]$ gcc -o block_wait block_wait.c
[vect@VM-0-11-centos process_control]$ ./block_wait
I am child, pid: 17498
waiting...
wait child 5s successfully, child return code: 1
非阻塞等待的轮询检测方式
而实际上,我们完全没有必要让父进程干等着,父进程可以在等待子进程的同时做其他事情——非阻塞等待
向
waitpid的第三个参数传WNOGANG,若等待的子进程没有结束,那么waitpid函数直接返回0,不等待;若子进程正常结束,返回该子进程的pid
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
int main(){
pid_t id = fork();
if(id < 0){
perror("fork err\n");
exit(1);
}else if(id == 0){ // child
int count = 5;
while(count--){
printf("child runing... pid: %d\n",getpid());
sleep(3);
}
exit(0);
}else{ // father
int status = 0;
pid_t ret = 0;
// do while确保至少检查一次子进程状态
do {
ret = waitpid(id,&status,WNOHANG);
if(ret > 0){
printf("wait child successfully\n");
printf("child exit code:%d\n",WEXITSTATUS(status));
// 子进程已回收,退出循环
}else if(ret == 0){
// 子进程未退出,父进程处理其他任务
printf("father does other things...\n");
sleep(1);
}else{
// 等待出错(如子进程不存在)
printf("wait err...\n");
break;
}
} while (ret == 0); // 子进程未退出则继续轮询
}
return 0;
}
父进程每隔一段时间查看子进程是否退出;若为退出,父进程忙自己的事情,每隔一段时间来查看一次,直到子进程退出读取其退出信息

4. 进程程序替换
替换原理
用fork创建子进程后,子进程执行的是和父进程相同的程序(可能执行不同的代码分支),想要让子进程执行另一个程序,需要调用函数完成->exex系列函数
当进程调用exec系列函数时,该进程的空间、代码和数据完全被新程序替换,并从新程序的启动历程开始执行

当进程替换时,有没有创建新的进程?
进程程序替换之后,该进程对应的PCB、进程地址空间及页表等都没有发生改变,仅仅改变进程在物理内存中的代码和数据,所以没有创建新的进程,并且进程程序替换前后该进程的pid没有改变
ls block_wait.c Makefile other show_fork.c exec.c myexec other.c wait.c exit.c no_block_wait.c print_exit_code.c waitpid.c [vect@VM-0-11-centos process_control]$ make gcc -o myexec exec.c [vect@VM-0-11-centos process_control]$ ./myexec 我是 exec, pid: 22591 我是other, pid: 22591[vect@VM-0-11-centos process_control]$pid相同,所以没有创建新的进程
子进程进行程序替换后,会影响父进程的代码和数据吗?
不会,子进程刚被创建,和父进程共享代码和数据,但当子进程进程程序替换时,就意味着子进程需要对其数据和代码进行写入操作,这时候发生写时拷贝,此后父子进程代码和数据分离
替换函数
有六种以exec开头的函数,统称为exec函数:
#include <unistd.h>
extern char **environ;
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[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
函数解释
- 调用成功,不用返回
- 调用出错,返回-1
所以,
exec只要返回了,就是调用失败
怎么理解这些函数
l(list):参数采用列表v(vector):参数用数组p(path):有p则自动搜索环境变量PATH,不用带详细路径;不带p的函数如(execl/execv)必须传入程序完整路径,如/bin/ls,否则会报错127(命令未找到)e(env):表示自己维护环境变量
函数使用
execl("/bin/ls","ls","-al","--color",NULL);
第一个参数path是带路径的指令(因execl不带p,必须传完整路径),表示想执行谁
第二个参数是可变参数列表,表示想要怎么执行
char* argv[] = {"ls","-l",NULL};
execv("/bin/ls",argv);
第一个参数path是带路径的指令(execv不带p,需完整路径),表示想执行谁
第二个参数是char*的数组,和命令行参数的第二个参数同形式,表示想要怎么执行
execlp("ls","ls","--color",NULL);
第一个参数是不带路径的命令(execlp带p,自动搜索 PATH),表示想执行谁
第二个参数是可变参数列表,表示想要怎么执行
注意:这里同时出现两个参数ls,表示的语义不一样,参数位置也不一样
char* argv[] = {"ls","-l",NULL};
execvp("ls",argv);
这里不做解释了
char* argv[] = {"ls","-l",NULL};
execvpe("ls",argv,NULL);
第一个参数是不带路径的命令(execvpe带p,自动搜索 PATH),表示想执行谁
第二个参数是char*的数组,表示想要怎么执行
第三个参数是新的环境变量
环境变量传递细节execvpe/execlp/execvp 传NULL时继承父进程环境;execle/execve 传NULL时环境变量为空
一般来说,我们不需要手动传环境变量,系统默认的就够了,可以得到一个结论:程序替换是不影响命令行参数和环境变量的
int execve(const char *path, char *const argv[], char *const envp[]);
第一个参数是要执行程序的路径,表示想执行谁
第二个参数是一个char*数组,表示想要怎么执行
第三个参数是新的环境变量
事实上,只有execve是真正的系统调用,其他五个函数都是调用execve封装的函数。所以execve在man手册的第2节,而其它五个函数在man手册的第3节

更多推荐


所有评论(0)