【Linux】 Linux 进程控制

[复制链接]
发表于 昨天 09:46 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
本篇笔记按 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

  1. void test1(){
  2.     int ret = fork();
  3.     std::cout << "pid = " << getpid() << ", fork()  = " << ret << std::endl;
  4. }
复制代码

从运行结果能看到,父进程拿到的值是子进程的 pid,子进程那边拿到的就是 0。



调用 fork 之后,新进程才从这一句往下开始执行。父子两部分执行流从这里分开,各跑各的。



fork 之前父进程独立运行,fork 之后父子两个执行流分别执行。要注意,fork 之后谁先跑完全由调度器说了算。

三、写时拷贝是怎么触发的

正常情况下父子进程的代码是共享的,只要任何一方不写入,数据也继续保持共享;一旦有一方动手写,就切到写时拷贝,各自持有一份副本,过程如下面那张图。




  • 修改内容之前,父子进程的数据和代码都是共享的
  • 任意一方试图写入时,操作系统会捕捉到缺页中断
  • 所谓缺页中断,是指计算机执行程序过程中出现异常情况或特殊请求时,暂停现行程序转去处理,处理完再回到断点继续执行
  • 随后操作系统重新分配一块空间,把旧空间的数据拷过去,同时重新映射页表


四、fork 的典型用法与失败原因


  • 父进程想复制自己,让父子同时执行不同的代码段。典型场景是父进程等客户端请求,来一个请求就 fork 一个子进程去处理
  • 一个进程要换去执行另一个程序,典型做法就是子进程从 fork 返回后调用 exec 系列函数


调用失败一般有两个原因:


  • 系统里进程太多,内存严重不足,没法再加载数据
  • 实际用户的进程数超过了系统的限制


五、退出场景与退出码

进程一旦退出,只有三种情况:


  • 代码跑完,结果正确
  • 代码跑完,结果不正确
  • 代码异常终止


用户怎么知道某个进程是以什么形式退出的?靠退出码。Linux 里程序可以在终止时把一个值传给父进程,这个值就叫退出码,我们靠它判断进程状态。平时写的 return 0,本质上就是给进程安排了一个退出码。

取最近一次进程退出码,用 echo $? 就够了。
  1. echo $?
复制代码


  • 上一进程是正常退出,所以结果是 0
  • 每条指令背后都是一个进程,敲错指令也会拿到错误的返回值,比如输入 lsss,返回值是 127




六、退出码的含义怎么查

用 strerror() 就能查出对应返回值的含义。
  1. void test2(){
  2.     for(int i = 0;i<140;++i){
  3.         std::cout << "error[" << i << "] = " << strerror(i) << std::endl;
  4.     }
  5. }
复制代码

实际上只有 133 以内才有意义,打印结果如下。




七、三种主动退出方式

return 退出

前面说过 main 函数靠 return 退出进程,这里再强调一次区分:非 main 函数的 return 只是函数返回,main 函数的 return 才是进程退出。

exit() 退出


  • exit 可以在程序任何位置退出进程,这点跟 return 一样,而且它会刷新缓冲区

  1. void test3(){
  2.     std::cout << "test3 exit(100)" <<  std::endl;
  3.     sleep(1);
  4.     exit(100);
  5. }
复制代码

查一下返回值,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:最基础的等待方式

函数原型以及所需头文件:
  1. #include <sys/types.h>
  2. #include <sys/wait.h>
  3. pid_t wait(int *status);
复制代码


  • 返回值:等待成功返回被等进程的 PID,等待失败返回 -1
  • 参数:输出型参数,用来取子进程退出状态,不关心的话传 NULL 就行
  • 下面这段代码建一个子进程,让它跑完任务自动退出,父进程等它结束并回收内存

  1. void test4(){
  2.     pid_t id = fork();
  3.     if(id == 0){
  4.         for(int i =0 ;i< 5 ; ++i){
  5.             std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
  6.             sleep(1);
  7.         }
  8.         exit(0);
  9.     }
  10.     else{
  11.         sleep(10);
  12.         std::cout << "father wait begin..." << std::endl;
  13.         pid_t cur = wait(NULL);
  14.         if(cur > 0){
  15.             std::cout << "father wait: "<< cur << " sucess" << std::endl;
  16.         }
  17.         else{
  18.             std::cout << "father wait failed!" << std::endl;
  19.         }
  20.         sleep(10);
  21.     }
  22. }
复制代码

想盯住进程状态,可以用下面这个 shell 脚本轮询:
  1. while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; echo "**********************"; done
复制代码

可以看到跑完之后子进程退出、被父进程回收,最后父进程自己也退出了。



运行结果如下。



十、waitpid:能指定进程和等待方式

函数原型以及所需头文件:
  1. #include <sys/types.h>
  2. #include <sys/wait.h>
  3. 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,会一直阻塞在这里。
  1. void test5()
  2. {
  3.     pid_t id = fork();
  4.     if (id == 0)
  5.     {
  6.         for (int i = 0; i < 5; ++i)
  7.         {
  8.             std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
  9.             sleep(1);
  10.         }
  11.         exit(100);
  12.     }
  13.     sleep(10);
  14.     std::cout << "father wait begin..." << std::endl;
  15.     // pid_t cur = wait(NULL);
  16.     int status = 0;
  17.     pid_t cur = waitpid(-1, &status, 0); // 等待任意一个子进程
  18.     if (cur > 0)
  19.     {
  20.         std::cout << "father wait: " << cur << " sucess, WIFEXITED(status) = " << WIFEXITED(status)
  21.          <<",WEXITSTATUS(status) = "<< WEXITSTATUS(status) <<  std::endl;
  22.     }
  23.     else
  24.     {
  25.         std::cout << "father wait failed!" << std::endl;
  26.     }
  27.     sleep(10);
  28. }
复制代码

运行结果如下,通过 WIFEXITED 宏和 WEXITSTATUS 可以检查子进程是否正常退出以及退出码是多少。



十一、status 低 16 位到底装了什么

什么是 status

int status 是一个输出型参数。所谓获取子进程的 status,拿的就是子进程退出时的那一整套信息;先让子进程分别以 exit(0) 和 exit(10) 结束,父进程读 status 值判断退出状态。

status 的构成


  • status 是 32 个比特位组成的整数,现阶段只用低 16 位表示进程退出的结果
  • 下面这张图就是 status 低 16 位的布局

  1. status exit_code = (status >> 8) & 0xFF; //退出码
  2. status exit_code = status7 & 0x7F;       //退出信号
复制代码




  • 进程正常退出有两种情况,与退出码有关;异常退出则与信号有关
  • 所以这里要拿到两组信息:退出码与信号
  • 没有收到信号,就说明代码是正常跑完的,再判断退出码看究竟是什么原因让进程结束
  • 反之就是异常退出,退出码也就没必要关心了

  1. void test6()
  2. {
  3.     pid_t id = fork();
  4.     if (id == 0)
  5.     {
  6.         for (int i = 0; i < 5; ++i)
  7.         {
  8.             std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
  9.             sleep(1);
  10.         }
  11.         exit(100);
  12.     }
  13.     sleep(10);
  14.     std::cout << "father wait begin..." << std::endl;
  15.     // pid_t cur = wait(NULL);
  16.     int status = 0;
  17.     pid_t cur = waitpid(-1, &status, 0); // 等待任意一个子进程
  18.     if (cur > 0)
  19.     {
  20.         std::cout << "father wait: " << cur << " sucess, status = " << status << std::endl;
  21.         std::cout << "exit_code = " << ((status >> 8)& 0xff) << ", exit_signal = " <<(status & 0x7f) << std::endl;
  22.     }
  23.     else
  24.     {
  25.         std::cout << "father wait failed!" << std::endl;
  26.     }
  27.     sleep(10);
  28. }
复制代码


  • 正常退出时退出码是 100,终止信号可以忽略





  • 进程运行过程中用 kill -9 命令终止,可以看到终止信号为 9





  • 查 shell 指令,发现 9 对应的是 SIGKILL

  1. kill -l
复制代码




  • 这里可以用 WIFEXITED 宏和 WEXITSTATUS 宏检查是不是正常退出,以及正常退出的返回值

  1. pid_t cur = waitpid(-1, &status, 0); // 等待任意一个子进程
  2. if (cur > 0)
  3. {
  4.         std::cout << "father wait: " << cur << " sucess, WIFEXITED(status) = " << WIFEXITED(status)
  5.          <<",WEXITSTATUS(status) = "<< WEXITSTATUS(status) <<  std::endl;
  6. }
  7. else
  8. {
  9.         std::cout << "father wait failed!" << std::endl;
  10. }
复制代码

阻塞等待与非阻塞等待


  • 这里说的阻塞等待和非阻塞等待,其实就是 waitpid 的第三个参数。前面一直传 0,是默认行为,属于阻塞等待:父进程就干等子进程,什么都不做,直到子进程正常退出
  • 传 WNOHANG 就是非阻塞等待:子进程没结束时父进程先去干自己的事,等它结束、内核拿到退出信号再把父进程从等待队列调回运行队列,由父进程取走退出码和退出信号


判断返回值就能知道子进程是否已经结束:没结束就继续做父进程自己的事,否则回收子进程。
  1. void test7()
  2. {
  3.     pid_t id = fork();
  4.     if (id == 0)
  5.     {
  6.         for (int i = 0; i < 10; ++i)
  7.         {
  8.             std::cout << "child id = " << getpid() << ", i = " << i << std::endl;
  9.             sleep(1);
  10.         }
  11.         exit(100);
  12.     }
  13.     std::cout << "father wait begin..." << std::endl;
  14.     while (true)
  15.     {
  16.         int status = 0;
  17.         pid_t cur = waitpid(-1, &status,WNOHANG); // WNOHANG = 1,非阻塞
  18.         if (cur > 0)
  19.         {
  20.             std::cout << "father wait: " << cur << " sucess, status = " << status << std::endl;
  21.             std::cout << "exit_code = " << ((status >> 8) & 0xff) << ", exit_signal = " << (status & 0x7f) << std::endl;
  22.             break;
  23.         }
  24.         else if(cur == 0)
  25.         {
  26.             std::cout << "do father process things" << std::endl;
  27.             sleep(1);
  28.         }
  29.         else{
  30.             std::cout << "father wait failed!" << std::endl;
  31.             break;
  32.         }
  33.     }
  34. }
复制代码

运行结果如下。



十二、exec 程序替换的原理


  • fork 出来的子进程执行的是和父进程相同的程序(只是走的代码分支可能不同),一般要再调一个 exec 函数去执行另一个程序
  • 进程一旦调用某个 exec 函数,它用户空间的代码和数据会被新程序完全替换,从新程序的启动例程开始执行
  • exec 并不创建新进程,所以调用 exec 前后这个进程的 id 并没有改变





  • 从图里可以看出,程序替换前后进程本身没有任何变化,变的只是它执行的代码
  • 子进程做程序替换会不会影响父进程的代码和数据?进程是有独立性的,父子虽然共享代码和数据,但一旦调用替换函数导致代码和数据被修改,就会触发写时拷贝。所以子进程怎么替换都不会影响父进程


十三、exec 系列六个函数怎么记

有六种以 exec 开头的函数,统称 exec 函数,头文件都是 #include <unistd.h>。

execl 函数
  1. int execl(const char *path, const char *arg, ...);
  2. // path --- 可执行程序的路径
  3. // arg --- 可变参数列表,表示你要如何执行这个程序,并以NULL结尾
  4. // 例如:
  5. execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
复制代码

execlp 函数
  1. int execlp(const char *file, const char *arg, ...);
  2. // file --- 可执行程序的名字
  3. // arg --- 可变参数列表,表示你要如何执行这个程序,并以NULL结尾
  4. // 例如:
  5. execlp("ls", "ls", "-a", "-l", NULL);
复制代码

execle 函数
  1. int execle(const char *path, const char *arg, ..., char * const envp[]);
  2. // path --- 可执行程序的路径
  3. // arg ---  可变参数列表,表示你要如何执行这个程序,并以NULL结尾
  4. // envp --- 自己维护的环境变量
  5. // 例如:
  6. char* envp[] = { "Myval=12345", NULL };
  7. execle("./myexe", "myexe", NULL, Myval);
复制代码

execv 函数
  1. int execv(const char *path, char *const argv[]);
  2. // path --- 你要执行程序的路径
  3. // argv --- 指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
  4. // 例如:
  5. char* argv[] = { "ls", "-a", "-l", NULL };
  6. execv("/usr/bin/ls", argv);
复制代码

execvp 函数
  1. int execvp(const char *file, char *const argv[]);
  2. // file --- 你要执行程序的名字
  3. // argv --- 指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
  4. // 例如:
  5. char* argv[] = { "ls", "-a", "-l", NULL };
  6. execvp("ls", argv);
复制代码

execve 函数
  1. int execvpe(const char *file, char *const argv[], char *const envp[]);
  2. // file --- 你要执行程序的路径
  3. // argv --- 指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
  4. // envp --- 自己维护的环境变量
  5. //例如:
  6. char* argv[] = { "mycmd", NULL };
  7. char* envp[] = { "Myval=12345", NULL };
  8. execve("./myexe", argv, envp);
复制代码

函数解释


  • 这些函数如果调用成功就加载新程序从启动代码开始执行,不再返回
  • 调用失败则返回 -1
  • 所以 exec 函数只有失败的返回值而没有成功的返回值,也就是说,exec 系列函数只要返回了,就意味着调用失败


函数名参数格式是否带路径是否使用当前环境变量execl列表不是是execlp列表是是execle列表不是不是,须自行维护环境变量execv数组不是是execvp数组是是execve数组不是不是,须自行维护环境变量
  1. void test8(){
  2.     char *argv[] = {"ls","-a","-l",NULL};
  3.     execl("/usr/bin/ls","ls","-a","-l",NULL); //可变参,NULL结尾
  4.     execv("/usr/bin/ls",argv); //字符串数组形式
  5.     execlp("ls","ls","-a","-l"); //文件名+可变参
  6.     execvp("ls",argv); //字符串数组形式
  7.     //可执行文件路径
  8.     char* argv_[] = {"./process-test","-test2",NULL};
  9.     char* env_[] = {NULL};
  10.     execvpe("./process-test",argv_,env_);
  11. }
复制代码



这些函数原型看着容易混,摸到规律就好记了。


  • 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
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表