马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
本篇笔记按 fork 创建进程、进程退出、进程等待、程序替换四条线,把 Linux 进程控制里最容易被绕晕的几个点逐个拆开:fork 的三种返回值、写时拷贝的触发过程、退出码与 status 低 16 位的构成、wait 和 waitpid 的差别,以及 exec 系列六个函数到底怎么记。每段都配了可编译的示例代码与运行结果截图,照着敲一遍基本就通了。参考博客:https://blog.csdn.net/sjsjnsjnn/article/details/125581083
一、fork 函数如何创建进程
fork() 是 Linux 进程编程里最基本也最要紧的一个函数,它从已经存在的进程里新建一个进程:原来的那个叫父进程,新建的叫子进程。进程一旦调用 fork,控制权就转进内核的 fork 代码,内核要做四件事:
- 给子进程分配新的内存块和内核数据结构
- 把父进程的部分数据结构内容复制到子进程
- 把子进程加进系统的进程列表
- fork 返回,接下来由调度器调度
二、fork 的返回值分三种
- 给父进程返回的是子进程的 PID
- 给子进程返回 0
- 子进程创建失败时返回 -1
- void test1(){
- int ret = fork();
- std::cout << "pid = " << getpid() << ", fork() = " << ret << std::endl;
- }
复制代码
从运行结果能看到,父进程拿到的值是子进程的 pid,子进程那边拿到的就是 0。
调用 fork 之后,新进程才从这一句往下开始执行。父子两部分执行流从这里分开,各跑各的。
fork 之前父进程独立运行,fork 之后父子两个执行流分别执行。要注意,fork 之后谁先跑完全由调度器说了算。
三、写时拷贝是怎么触发的
正常情况下父子进程的代码是共享的,只要任何一方不写入,数据也继续保持共享;一旦有一方动手写,就切到写时拷贝,各自持有一份副本,过程如下面那张图。
- 修改内容之前,父子进程的数据和代码都是共享的
- 任意一方试图写入时,操作系统会捕捉到缺页中断
- 所谓缺页中断,是指计算机执行程序过程中出现异常情况或特殊请求时,暂停现行程序转去处理,处理完再回到断点继续执行
- 随后操作系统重新分配一块空间,把旧空间的数据拷过去,同时重新映射页表
四、fork 的典型用法与失败原因
- 父进程想复制自己,让父子同时执行不同的代码段。典型场景是父进程等客户端请求,来一个请求就 fork 一个子进程去处理
- 一个进程要换去执行另一个程序,典型做法就是子进程从 fork 返回后调用 exec 系列函数
调用失败一般有两个原因:
- 系统里进程太多,内存严重不足,没法再加载数据
- 实际用户的进程数超过了系统的限制
五、退出场景与退出码
进程一旦退出,只有三种情况:
- 代码跑完,结果正确
- 代码跑完,结果不正确
- 代码异常终止
用户怎么知道某个进程是以什么形式退出的?靠退出码。Linux 里程序可以在终止时把一个值传给父进程,这个值就叫退出码,我们靠它判断进程状态。平时写的 return 0,本质上就是给进程安排了一个退出码。
取最近一次进程退出码,用 echo $? 就够了。
- 上一进程是正常退出,所以结果是 0
- 每条指令背后都是一个进程,敲错指令也会拿到错误的返回值,比如输入 lsss,返回值是 127
六、退出码的含义怎么查
用 strerror() 就能查出对应返回值的含义。
- void test2(){
- for(int i = 0;i<140;++i){
- std::cout << "error[" << i << "] = " << strerror(i) << std::endl;
- }
- }
复制代码
实际上只有 133 以内才有意义,打印结果如下。
七、三种主动退出方式
return 退出
前面说过 main 函数靠 return 退出进程,这里再强调一次区分:非 main 函数的 return 只是函数返回,main 函数的 return 才是进程退出。
exit() 退出
- exit 可以在程序任何位置退出进程,这点跟 return 一样,而且它会刷新缓冲区
- void test3(){
- std::cout << "test3 exit(100)" << std::endl;
- sleep(1);
- exit(100);
- }
复制代码
查一下返回值,exit(100) 退出后拿到的就是 100。
_exit 退出
- 除了上面两种,还能用 _exit 让进程退出
- _exit 同样能在任意位置终止进程,但它是强制终止,不做后续收尾,比如不刷新缓冲区
exit 最后也会调 _exit,只不过在调之前它多干了几件事:
- 执行用户通过 atexit 或 on_exit 登记的清理函数
- 关闭所有打开的流,把缓存数据全部写出
- 调用 _exit
return、exit 和 _exit 的区别
- _exit() 执行后立刻返回内核,exit() 要先做一批清除操作再交控制权给内核
- 调用 _exit() 会关掉进程所有文件描述符、清理内存以及做其他内核整理,但不会刷新 stdin、stdout、stderr 这些流;exit() 相当于在 _exit() 外面包了一层,先刷新流再调 _exit
- return 是更常用的退出方式,执行 return(num)等价于执行 exit(num),因为调用 main 的运行时函数会把 main 的返回值当作 exit 的参数
异常退出
- 上面讲的都是正常退出的情况,跟退出码有关
- 异常退出是程序跑到一半因为地址访问错误、或者被主动终止(比如 ctrl+c、kill)而结束,它也有对应的错误码,里面装的其实是进程的终止信号,下面会讲到
八、父进程为什么必须等子进程
- 子进程退出后父进程不去收它的退出信息,就可能产生僵尸进程,进而造成内存泄漏
- 进程一旦变成僵尸状态,kill -9 也救不回来,死掉的程序没人杀得掉
- 父进程交给子进程的任务干得怎么样得知道,比如有没有跑完、结果对不对、是不是正常退出
- 父进程靠等待回收子进程资源,同时把退出信息取回来
九、wait:最基础的等待方式
函数原型以及所需头文件:
- #include <sys/types.h>
- #include <sys/wait.h>
- pid_t wait(int *status);
复制代码
- 返回值:等待成功返回被等进程的 PID,等待失败返回 -1
- 参数:输出型参数,用来取子进程退出状态,不关心的话传 NULL 就行
- 下面这段代码建一个子进程,让它跑完任务自动退出,父进程等它结束并回收内存
- void test4(){
- pid_t id = fork();
- if(id == 0){
- for(int i =0 ;i< 5 ; ++i){
- std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
- sleep(1);
- }
- exit(0);
- }
- else{
- sleep(10);
- std::cout << "father wait begin..." << std::endl;
- pid_t cur = wait(NULL);
- if(cur > 0){
- std::cout << "father wait: "<< cur << " sucess" << std::endl;
- }
- else{
- std::cout << "father wait failed!" << std::endl;
- }
- sleep(10);
- }
- }
复制代码
想盯住进程状态,可以用下面这个 shell 脚本轮询:
- while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; echo "**********************"; done
复制代码
可以看到跑完之后子进程退出、被父进程回收,最后父进程自己也退出了。
运行结果如下。
十、waitpid:能指定进程和等待方式
函数原型以及所需头文件:
- #include <sys/types.h>
- #include <sys/wait.h>
- pid_t waitpid(pid_t pid, int *status, int options);
复制代码
返回值:
- 正常返回时 waitpid 返回收集到的子进程的进程 ID
- 传了 WNOHANG,而调用时没有已退出的子进程可收集,返回 0
- 调用出错返回 -1,这时 errno 会被设成相应的值以指示错误所在
参数:
- pid:pid = -1 等待任意一个子进程,跟 wait 等效;pid > 0 等待其进程 ID 与 pid 相等的子进程
- status:WIFEXITED(status) 为真表示子进程是正常终止,用来判断进程是不是正常退出;WEXITSTATUS(status) 在 WIFEXITED 非零时取出子进程退出码
- options:WNOHANG 表示 pid 指定的子进程还没结束时 waitpid 直接返回 0 不等待;若正常结束则返回该子进程的 PID
下面这段代码等待任意一个子进程并回收它,options 传 0,会一直阻塞在这里。
- void test5()
- {
- pid_t id = fork();
- if (id == 0)
- {
- for (int i = 0; i < 5; ++i)
- {
- std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
- sleep(1);
- }
- exit(100);
- }
- sleep(10);
- std::cout << "father wait begin..." << std::endl;
- // pid_t cur = wait(NULL);
- int status = 0;
- pid_t cur = waitpid(-1, &status, 0); // 等待任意一个子进程
- if (cur > 0)
- {
- std::cout << "father wait: " << cur << " sucess, WIFEXITED(status) = " << WIFEXITED(status)
- <<",WEXITSTATUS(status) = "<< WEXITSTATUS(status) << std::endl;
- }
- else
- {
- std::cout << "father wait failed!" << std::endl;
- }
- sleep(10);
- }
复制代码
运行结果如下,通过 WIFEXITED 宏和 WEXITSTATUS 可以检查子进程是否正常退出以及退出码是多少。
十一、status 低 16 位到底装了什么
什么是 status
int status 是一个输出型参数。所谓获取子进程的 status,拿的就是子进程退出时的那一整套信息;先让子进程分别以 exit(0) 和 exit(10) 结束,父进程读 status 值判断退出状态。
status 的构成
- status 是 32 个比特位组成的整数,现阶段只用低 16 位表示进程退出的结果
- 下面这张图就是 status 低 16 位的布局
- status exit_code = (status >> 8) & 0xFF; //退出码
- status exit_code = status7 & 0x7F; //退出信号
复制代码
- 进程正常退出有两种情况,与退出码有关;异常退出则与信号有关
- 所以这里要拿到两组信息:退出码与信号
- 没有收到信号,就说明代码是正常跑完的,再判断退出码看究竟是什么原因让进程结束
- 反之就是异常退出,退出码也就没必要关心了
- void test6()
- {
- pid_t id = fork();
- if (id == 0)
- {
- for (int i = 0; i < 5; ++i)
- {
- std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
- sleep(1);
- }
- exit(100);
- }
- sleep(10);
- std::cout << "father wait begin..." << std::endl;
- // pid_t cur = wait(NULL);
- int status = 0;
- pid_t cur = waitpid(-1, &status, 0); // 等待任意一个子进程
- if (cur > 0)
- {
- std::cout << "father wait: " << cur << " sucess, status = " << status << std::endl;
- std::cout << "exit_code = " << ((status >> 8)& 0xff) << ", exit_signal = " <<(status & 0x7f) << std::endl;
- }
- else
- {
- std::cout << "father wait failed!" << std::endl;
- }
- sleep(10);
- }
复制代码
- 进程运行过程中用 kill -9 命令终止,可以看到终止信号为 9
- 查 shell 指令,发现 9 对应的是 SIGKILL
- 这里可以用 WIFEXITED 宏和 WEXITSTATUS 宏检查是不是正常退出,以及正常退出的返回值
- pid_t cur = waitpid(-1, &status, 0); // 等待任意一个子进程
- if (cur > 0)
- {
- std::cout << "father wait: " << cur << " sucess, WIFEXITED(status) = " << WIFEXITED(status)
- <<",WEXITSTATUS(status) = "<< WEXITSTATUS(status) << std::endl;
- }
- else
- {
- std::cout << "father wait failed!" << std::endl;
- }
复制代码
阻塞等待与非阻塞等待
- 这里说的阻塞等待和非阻塞等待,其实就是 waitpid 的第三个参数。前面一直传 0,是默认行为,属于阻塞等待:父进程就干等子进程,什么都不做,直到子进程正常退出
- 传 WNOHANG 就是非阻塞等待:子进程没结束时父进程先去干自己的事,等它结束、内核拿到退出信号再把父进程从等待队列调回运行队列,由父进程取走退出码和退出信号
判断返回值就能知道子进程是否已经结束:没结束就继续做父进程自己的事,否则回收子进程。
- void test7()
- {
- pid_t id = fork();
- if (id == 0)
- {
- for (int i = 0; i < 10; ++i)
- {
- std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
- sleep(1);
- }
- exit(100);
- }
- std::cout << "father wait begin..." << std::endl;
- while (true)
- {
- int status = 0;
- pid_t cur = waitpid(-1, &status,WNOHANG); // WNOHANG = 1,非阻塞
- if (cur > 0)
- {
- std::cout << "father wait: " << cur << " sucess, status = " << status << std::endl;
- std::cout << "exit_code = " << ((status >> 8) & 0xff) << ", exit_signal = " << (status & 0x7f) << std::endl;
- break;
- }
- else if(cur == 0)
- {
- std::cout << "do father process things" << std::endl;
- sleep(1);
- }
- else{
- std::cout << "father wait failed!" << std::endl;
- break;
- }
- }
- }
复制代码
运行结果如下。
十二、exec 程序替换的原理
- fork 出来的子进程执行的是和父进程相同的程序(只是走的代码分支可能不同),一般要再调一个 exec 函数去执行另一个程序
- 进程一旦调用某个 exec 函数,它用户空间的代码和数据会被新程序完全替换,从新程序的启动例程开始执行
- exec 并不创建新进程,所以调用 exec 前后这个进程的 id 并没有改变
- 从图里可以看出,程序替换前后进程本身没有任何变化,变的只是它执行的代码
- 子进程做程序替换会不会影响父进程的代码和数据?进程是有独立性的,父子虽然共享代码和数据,但一旦调用替换函数导致代码和数据被修改,就会触发写时拷贝。所以子进程怎么替换都不会影响父进程
十三、exec 系列六个函数怎么记
有六种以 exec 开头的函数,统称 exec 函数,头文件都是 #include <unistd.h>。
execl 函数
- int execl(const char *path, const char *arg, ...);
- // path --- 可执行程序的路径
- // arg --- 可变参数列表,表示你要如何执行这个程序,并以NULL结尾
- // 例如:
- execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
复制代码
execlp 函数
- int execlp(const char *file, const char *arg, ...);
- // file --- 可执行程序的名字
- // arg --- 可变参数列表,表示你要如何执行这个程序,并以NULL结尾
- // 例如:
- execlp("ls", "ls", "-a", "-l", NULL);
复制代码
execle 函数
- int execle(const char *path, const char *arg, ..., char * const envp[]);
- // path --- 可执行程序的路径
- // arg --- 可变参数列表,表示你要如何执行这个程序,并以NULL结尾
- // envp --- 自己维护的环境变量
- // 例如:
- char* envp[] = { "Myval=12345", NULL };
- execle("./myexe", "myexe", NULL, Myval);
复制代码
execv 函数
- int execv(const char *path, char *const argv[]);
- // path --- 你要执行程序的路径
- // argv --- 指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
- // 例如:
- char* argv[] = { "ls", "-a", "-l", NULL };
- execv("/usr/bin/ls", argv);
复制代码
execvp 函数
- int execvp(const char *file, char *const argv[]);
- // file --- 你要执行程序的名字
- // argv --- 指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
- // 例如:
- char* argv[] = { "ls", "-a", "-l", NULL };
- execvp("ls", argv);
复制代码
execve 函数
- int execvpe(const char *file, char *const argv[], char *const envp[]);
- // file --- 你要执行程序的路径
- // argv --- 指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
- // envp --- 自己维护的环境变量
- //例如:
- char* argv[] = { "mycmd", NULL };
- char* envp[] = { "Myval=12345", NULL };
- execve("./myexe", argv, envp);
复制代码
函数解释
- 这些函数如果调用成功就加载新程序从启动代码开始执行,不再返回
- 调用失败则返回 -1
- 所以 exec 函数只有失败的返回值而没有成功的返回值,也就是说,exec 系列函数只要返回了,就意味着调用失败
函数名参数格式是否带路径是否使用当前环境变量execl列表不是是execlp列表是是execle列表不是不是,须自行维护环境变量execv数组不是是execvp数组是是execve数组不是不是,须自行维护环境变量
- void test8(){
- char *argv[] = {"ls","-a","-l",NULL};
- execl("/usr/bin/ls","ls","-a","-l",NULL); //可变参,NULL结尾
- execv("/usr/bin/ls",argv); //字符串数组形式
- execlp("ls","ls","-a","-l"); //文件名+可变参
- execvp("ls",argv); //字符串数组形式
- //可执行文件路径
- char* argv_[] = {"./process-test","-test2",NULL};
- char* env_[] = {NULL};
- execvpe("./process-test",argv_,env_);
- }
复制代码
这些函数原型看着容易混,摸到规律就好记了。
- l(list):表示参数采用列表
- v(vector):参数用数组
- p(path):有 p 会自动搜索环境变量 PATH
- e(env):表示自己维护环境变量
事实上只有 execve 才是真正的系统调用,其它五个函数最终都是调用的 execve,所以 execve 在 man 手册的第 2 节,而其它五个函数在 man 手册的第 3 节,也就是说另外五个函数实际上是对系统调用 execve 做了封装,用来满足不同用户、不同调用场景的需要。
本篇按创建、退出、等待、替换四条线把 Linux 进程控制串了一遍,照着示例代码自己编译运行一遍,fork 和 waitpid 基本就能吃透。
编辑提示
建议把 test1 到 test8 逐个编译跑一遍,再配上 ps axj 轮询脚本观察僵尸进程的产生与回收,比只看截图记得牢。更多资料:https://github.com/0voice
|