07.【Linux系统编程】进程控制(进程创建fork、进程终止exit等、进程等待waitwaitpid、进程替换execl等)
目录
- 1 进程创建
- 1.1 fork函数初识
- 1.2 fork函数返回值
- 1.3 写时拷贝
- 1.4 fork常规用法
- 1.5 fork调用失败的原因
- 2. 进程终止
- 2.1 进程退出场景(给父进程一个交代)
- 2.2 进程常见退出方法
- 2.2.1 退出码 VS 错误码
- 2.3.2 _exit函数
- 2.3.3 exit函数
- 2.3.4 _exit VS exit
- 2.3.5 return退出
- 3. 进程等待
- 3.1 进程等待必要性(为什么要等待)
- 3.2 进程等待的方法
- 3.2.1 wait方法 和 waitpid方法
- 3.2.2 获取子进程status
- 3.2.3 阻塞与非阻塞等待
- 3.3 进程等待的本质+僵尸进程存在的意义
- 4. 进程程序替换(加载器)
- 4.1 替换原理
- 4.1.1 替换函数举例
- 4.1.2 子进程替换为系统进程
- 4.1.2 子进程替换为自定义进程(证明替换原理)
- 4.2 替换函数
- 4.2.1 函数解释
- 4.2.2 命名理解
- 4.2.3 补:添加环境变量`putenv`
- 5. 总结
1 进程创建
1.1 fork函数初识
在linux
中fork
函数是非常重要的函数,它从已存在的进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>pid_t fork(void);
//返回值:子进程中返回0,父进程返回子进程id,出错返回-1
- 为什么要给子进程返回0,父进程返回子进程
pid
?为了让父进程和子进程能够轻松地、明确地知道自己是谁,从而执行不同的代码路径。- 为什么一个函数fork会有两个返回值?
- 为什么一个id即等于0,又大于0
进程调用fork
,当控制转移到内核中的fork
代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
fork
返回,开始调度器调度
当一个进程调用fork
之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程,看如下程序。
int main( void )
{pid_t pid;printf("Before: pid is %d\n", getpid());if ( (pid=fork()) == -1 )perror("fork()"),exit(1);printf("After:pid is %d, fork return %d\n", getpid(), pid);sleep(1);return 0;
}// 运行结果:
// [root@localhost linux]# ./a.out
// Before: pid is 43676
// After:pid is 43676, fork return 43677
// After:pid is 43677, fork return 0
这里看到了三行输出,一行before
,两行after
。进程43676
先打印before
消息,然后它有打印after
。另一个after
消息有43677
打印的。注意到进程43677
没有打印before
,为什么呢?如下图所示
所以,fork
之前父进程独立执行,fork
之后,父子两个执行流分别执行。注意,fork
之后,谁先执行完全由调度器决定。
1.2 fork函数返回值
- 子进程返回
0
- 父进程返回的是子进程的
pid
。
1.3 写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
因为有写时拷贝技术的存在,所以父子进程得以彻底分离离!完成了进程独立性的技术保证!写时拷贝,是一种延时申请技术,可以提高整机内存的使用率。
1.4 fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从
fork
返回后,调用exec
函数。
1.5 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
- 都是内存空间不足
2. 进程终止
进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
2.1 进程退出场景(给父进程一个交代)
- 代码运行完毕,结果正确(正常终止)
- 代码运行完毕,结果不正确(正常终止)
- 代码异常终止
2.2 进程常见退出方法
- 正常终止并退出方式:
main
的return
返回值,exit(status)
和_exit(status)
中的status
,都是进程退出码。- 可以通过
echo $?
打印最近一次的进程退出码,因为最近一次的进程退出码被父进程bash获取了。
- 从
main
使用return
返回 - 调用
exit(status)
- 调用
_exit(status)
- 异常退出(退出码无意义)
ctrl + c
,信号终止kill
杀掉程序- 程序错误退出(如:
a/0
)
2.2.1 退出码 VS 错误码
1. 退出码详解
- 退出码(退出状态)可以告诉我们最后一次执行的进程退出的状态。
- 通过
echo $?
打印最近一次的进程退出码。 - 在进程结束以后,我们可以知道进程是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码
0
时表示执行成功,没有问题。退出码为0
以外的任何代码都被视为不成功。
Linux Shell 中的主要退出码:
退出码 | 解释 |
---|---|
0 | 命令行成功执行 |
1 | 通用错误码 |
2 | 命令(或参数)使用不当 |
126 | 权限被拒绝(或)无法执行 |
127 | 未找到命令,或PATH错误 |
128+n | 命令被信号从外部终止,或遇到致命错误 |
130 | 通过Ctrl+C 或SIGINT 终止(终止代码2或键盘中断) |
143 | 通过SIGTERM 终止(默认终止) |
255/* | 退出码超过了0-255 的范围,因此重新计算(LCTT 译注:超过255 后,用退出取模) |
2. 错误码详解
- 错误码是程序中的错误信息。
- 通过
errno
获取错误码,通过strerror(errno)
或perror()
转换为可读字符串(即错误详细信息),需要程序员主动用printf
/fprinf
打印 - 错误码为
0
时程序成功运行,错误码!0
时程序有错误。
打印C标准库的错误码
#include<stdio.h>
#include<string.h>
int main()
{for(int i = 0; i < 135; i++){printf("%d->%s\n",i,strerror(i));}return 0;
}
C语言使用errno
返回对应的错误码
#include<stdio.h>
#include<string.h>
#include<errno.h>int main()
{FILE* fp = fopen("log.txt", "r");if(fp == NULL) return errno; // 将errno错误码,作为退出码返回return 0;
}
3. 退出码 VS 错误码
- 退出码是进程运行结束的状态,错误码是程序中出现的错误标志。两者无关
2.3.2 _exit函数
- 任何地方调用
_exit
,表示进程结束!!并返回给父进程bash
,子进程的退出码。
#include <unistd.h>
void _exit(int status);
// 参数:status 定义了进程的终止状态,父进程通过wait来获取该值
- 说明:虽然
status
是int
,但是仅有低8位可以被父进程所用。所以_exit(-1)
时,在终端执行echo $?
发现返回值是255
。 _exit
本身就是一个系统调用的包装。
2.3.3 exit函数
- 任何地方调用
exit
,表示进程结束!!并返回给父进程bash
,子进程的退出码。
#include <unistd.h>
void exit(int status);
exit
最后也会调用_exit
, 但在调用_exit
之前,还做了其他工作:
- 执行用户通过
atexit()
或on_exit
定义的清理函数。 - 关闭所有打开的流,刷新所有标准I/O流的缓冲区(
fflush()
所有打开的文件流)。 - 调用
_exit(status)
系统调用。
2.3.4 _exit VS exit
_exit:进程调用_exit()退出时,不会进行缓冲区的刷新。
exit:进程调用exit()退出时,会进行缓冲区的刷新。
- 我们之前谈论的缓冲区,是库缓冲区,C语言提供的缓冲区,而不是操作系统内部的缓冲区。
int main()
{printf("hello");exit(0);
}
运行结果:
[root@localhost linux]# ./a.out
hello[root@localhost linux]#int main()
{printf("hello");_exit(0);
}
运行结果:
[root@localhost linux]# ./a.out
[root@localhost linux]#
2.3.5 return退出
return
是一种更常见的退出进程方法。执行return n
等同于执行exit(n)
,因为调用main
的运行时函数会将main
的返回值当做 exit
的参数。
3. 进程等待
3.1 进程等待必要性(为什么要等待)
- 子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的
kill -9
也无能为力,因为谁也没有办法杀死一个已经死去的进程。 - 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式:1.回收子进程资源防止内存泄漏(处理回收僵尸进程)(重要),2.获取子进程退出信息(可选)。
3.2 进程等待的方法
3.2.1 wait方法 和 waitpid方法
- wait方法
#include<sys/types.h>
#include<sys/wait.h>pid_t wait(int* status);等待任意个退出的子进程
返回值:成功返回被等待Z子进程PID,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
- waitpid方法
#include<sys/types.h>
#include<sys/wait.h>
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:当正常返回的时候waitpid返回收集到的子进程的进程PID;如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:pid:pid=-1,等待任一个子进程。与wait等效。pid>0.等待其进程PID与pid参数相等的指定子进程。status: 输出型参数WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)options:默认为0,表示阻塞等待WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
- 如果子进程已经退出,调用
wait
/waitpid
时,wait
/waitpid
会立即返回,并且释放资源,获得子进程退出信息。 - 如果在任意时刻调用
wait
/waitpid
,子进程存在且正常运行,则父进程可能阻塞。(类似scanf
的状态,等待子进程退出) - 如果不存在该子进程,则立即出错返回。
3.2.2 获取子进程status
wait
和waitpid
,都有一个status
参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递
NULL
,表示不关心子进程的退出状态信息。 - 如果传整型地址,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status
不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status
低16比特位,高16位不用):
- 没有异常(三种正常退出):低8(0~7总共8位)个比特位为0,8~15位为退出状态
- 异常退出:低7个比特位为 !0 ,退出码无意义。
// 1.子进程正常退出,status返回退出状态
#include<stdio.h>
#include<unistd.h> // sleep()
#include<sys/wait.h> // wait(),waitpid()
#include<sys/types.h> // pid_t
#include<stdlib.h> // exit()
#include<errno.h> // errno
#include<string.h> // strerror()int main()
{pid_t id = fork();if(id == 0){// 子进程int cnt = 5;while(cnt){printf("我是一个子进程,pid:%d, ppid:%d\n",getpid(),getppid());sleep(1);cnt--;}exit(1);}sleep(3);// 父进程int status;pid_t rid = waitpid(id,&status,0);// 子进程退出,父进程处理僵尸进程if(rid > 0){printf("wait success, rid:%d,exit code:%d\n",rid,(status>>8)&0xFF);}else{printf("wait failed:%d:%s\n",errno,strerror(errno));}return 0;
}
// 子进程一场退出,status返回终止信号
- 子进程使用
exit(status)
和_exit(status)
正常退出,信号终止(kill -9 PID
和Ctrl+C
),异常退出(程序错误,如a/0
)。 - 进程退出码写到子进程自己的
task_struct
中【通过int exit_code
(退出码,异常退出则无意义),int exit_signal
(终止信号)变量保存】,此时子进程为僵尸状态。 - 然后父进程通过系统调用
wait
或waitpid
从子进程的task_struct
中读取退出码,子进程的task_struct
被释放且从僵尸状态变为死亡状态。
3.2.3 阻塞与非阻塞等待
waitpid
的两种等待方式:
- 非阻塞轮询:循环调用
waitpid
函数,父进程中间可以执行其他任务。- 返回值:大于0(等待结束),等于0(调用结束,但是子进程没有退出,继续轮询),小于0(失效)。
- 阻塞调用,调用
waitpid
函数后,父进程阻塞等待。- 返回值:返回子进程
PID
(子进程退出),小于0(调用出错)
示例1:父进程非阻塞轮询调用waitpid
函数,过程中执行其他函数。
- 返回值:返回子进程
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>// 函数指针类型
typedef void (*func_t)();#define NUM 5
func_t handlers[NUM+1];// 如下是任务
void DownLoad()
{printf("我是一个下载的任务...\n");
}void Flush()
{printf("我是一个刷新的任务...\n");
}void Log()
{printf("我是一个记录日志的任务...\n");
}// 注册
void registerHandler(func_t h[], func_t f)
{int i = 0;for(; i < NUM; i++){if(h[i] == NULL)break;}if(i == NUM) return;h[i] = f;h[i+1] = NULL;
}int main()
{registerHandler(handlers, DownLoad);registerHandler(handlers, Flush);registerHandler(handlers, Log);pid_t id = fork();if (id == 0){//子进程int cnt = 3;while (cnt){sleep(3);printf("我是一个子进程, pid : %d, ppid : %d\n", getpid(), getppid());sleep(1);cnt--;}exit(10);}// 父进程while (1){int status = 0;pid_t rid = waitpid(id, &status, WNOHANG);if (rid > 0){printf("wait success, rid: %d, exit code: %d, exit signal: %d\n", rid, (status >> 8) & 0xFF, status & 0x7F); // ridbreak;}else if (rid == 0){// 函数指针进行回调处理int i = 0;for (; handlers[i]; i++){handlers[i]();}printf("本轮调用结束,子进程没有退出\n");sleep(1);}else{printf("等待失败\n");break;}}
}
3.3 进程等待的本质+僵尸进程存在的意义
- 僵尸进程为何存在:子进程在死亡之后父进程没有得到子进程死亡的信息,内核知道要释放子进程的
task_struct
,但父进程不知道子进程的退出信息,所以不释放task_struct
。目的为了保证父进程一定是子进程生命周期的管理者,同时父进程知道子进程状态后好开展后续工作。(内核的设计理念:谁创建,谁负责) - 父进程等待的本质:父进程通过
wait
或waitpid
获取到子进程死亡的信息,告知内核可以释放子进程的task_struct
,从而防止内存泄漏。
4. 进程程序替换(加载器)
- fork() 之后,父子各自执行父进程代码的一部分如果子进程就想执行一个全新的程序呢?(进程替换的目的)进程的程序替换来完成这个功能!
- 程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中!(不改变原来进程的PCB、页表等,进程 = 内核数据结构+代码和数据)
// 不写
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h> int main()
{ printf("我的程序要运行了\n"); int n = execl("/usr/bin/ls","ls","-l","-a",NULL); printf("我的程序运行完毕了:%d\n",n); exit(1); return 0;
}
4.1 替换原理
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec 函数以执行另一个程序。当进程调用一种exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec
并不创建新进程,所以调用 exec
前后该进程的id
并未改变。
- 为什么没有影响父进程?
- 进程具有独立性
- 数据发生写时拷贝
4.1.1 替换函数举例
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
// path 是程序路径(要执行谁?)
// const char *arg, ... 是可变参数,命令行怎么写,就怎么传参。最后一个参数必须以NULL结尾,表明传递完成。(怎么执行?)
4.1.2 子进程替换为系统进程
// 子进程调用exec函数执行另一个程序
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>int main()
{printf("我的程序要运行了\n");if(fork() == 0){sleep(1);// childexecl("/usr/bin/ls","ls","-l","-a",NULL);exit(1);}waitpid(-1, NULL, 0);printf("我的程序运行完毕了\n");return 0;
}
4.1.2 子进程替换为自定义进程(证明替换原理)
// c++程序
#include<iostream>
#include<unistd.h>
#include<sys/types.h>int main()
{std::cout<<"hello C++,我的进程PID是: " << getpid() << std::endl;return 0;
}
// c程序,使用fork生成子进程,子进程替换为c++程序
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<sys/types.h>int main()
{printf("我的程序要运行了\n");if(fork() == 0){printf("我是子进程child,我的进程PID是:%d\n",getpid());sleep(1);// child,替换为c++程序execl("./other","other",NULL);exit(1);}waitpid(-1, NULL, 0);printf("我的程序运行完毕了\n");return 0;
}
# 运行结果
[gyy@csdn exec]$ ./proc
我的程序要运行了
我是子进程child,我的进程PID是:27428
hello C++,我的进程PID是: 27428
我的程序运行完毕了
- 运行结果可见,子进程和替换后的
c++
进程的PID
相同,即exec
不创建新进程,前后PID
不变。
4.2 替换函数
其实有六种以exec开头的函数,统称exec函数:
#include <unistd.h>
// man 3
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 *path, char *const argv[], char *const envp[]);//man 2 以上6个函数调用execve系统调用来实现
int execve(const char *filename, char *const argv[], char *const envp[]);
path
是程序路径(要执行谁?)...
是可变参数,命令行怎么写,就怎么传参。最后一个参数必须以NULL结尾,表明传递完成。(怎么执行?)argv[]
,envp[]
就相当于给替换进程的main函数传参
4.2.1 函数解释
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,原始代码的后半部分,已经不存在了。
- 如果调用出错则返回
-1
所以exec
函数只有出错的返回值,而没有成功的返回值。
4.2.2 命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list)
: 表示参数采用列表v(vector)
: 参数用数组(char* const argv = {(char*const)"ls", (char*const)"-l", (char*const)"-a", NULL};
)p(path)
: 有p
自动搜索环境变量PATH
的路径(使用系统指令替换子进程时常用)e(env)
: 表示自己维护环境变量
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 否 | 是 |
execlp | 列表 | 是(可以不写指令PATH路径) | 是 |
execle | 列表 | 否 | 否,需自己组装环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execvpe | 数组 | 是 | 否,需自己组装环境变量 |
系统调用 | |||
execve | 以上函数调用系统调用execve |
4.2.3 补:添加环境变量putenv
// other.cc
#include <iostream>
#include <cstdio>
#include <unistd.h>int main(int argc, char *argv[], char *env[])
{std::cout << "hello C++, My Pid Is: " << getpid() << std::endl;for(int i = 0; i < argc; i++){printf("argv[%d]: %s\n", i, argv[i]);}printf("\n");for(int i = 0; env[i]; i++){printf("env[%d]: %s\n", i, env[i]);}return 0;
}
// proc.c
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>char *const addenv[] = {(char *const)"MYVAL=123456789",(char *const)"MYVAL1=123456789",(char *const)"MYVAL2=123456789",NULL
};int main()
{printf("我的程序要运行了!\n");if(fork() == 0){printf("I am Child, My Pid Is: %d\n", getpid());sleep(1);char *const argv[] = {(char*const)"other",(char*const)"-a",(char*const)"-b",(char*const)"-c",(char*const)"-d",NULL};// putenv添加换几个变量for(int i = 0; addenv[i]; i++){putenv(addenv[i]);}// 声明全区的换几个变量列表extern char **environ;// 传新的全局环境变量列表(导入新的换几个变量后)execvpe("./other", argv, environ);exit(1);}waitpid(-1, NULL, 0);printf("我的程序运行完毕了\n");return 0;
}
5. 总结
在继续学习新知识前,我们来思考函数和进程之间的相似性exec
/exit
就像call
/return
一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。
这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图
一个C程序可以fork
/exec
另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)
来返回值。调用它的进程可以通过wait(&ret)
来获取exit
的返回值。