【Linux】进程控制(一)——进程创建、进程终止与进程等待
【Linux】进程控制一——进程创建、进程终止与进程等待文章目录【Linux】进程控制一——进程创建、进程终止与进程等待一、 进程创建1、 fork函数2、 写时拷贝COW3、 Fork使用场景二、 进程终止1、 进程中止内核在做什么2、 进程终止的三种场景3、 三种进程正常退出方式4、 exit与_exit对比5、 Linux退出码含义三、 进程等待1、为什么需要进程等待2、wait()3、waitpid4、status位图只用16位置5、子进程正常退出测试6、子进程异常退出测试7、waitpid 非阻塞轮询WNOHANG测试8、批量创建子进程全部回收一、 进程创建1、 fork函数Linux中fork函数是非常重要的函数fork () 会以调用它的当前进程为模板复制出一个全新的独立子进程实现一个程序两份执行流同时跑。新进程为子进程原进程为父进程函数原型#includeunistd.hpid_tfork(void);Linux中定义typedef int pid_tpid_t本质是整形int专门用来存储进程PID返回值为 -1说明fork调用失败无法创建子进程返回值为0说明fork成功当前执行流属于子进程返回值为大于0的正整数说明当前执行流属于父进程返回值就是新建子进程的 PID三个小问题(1) 为什么子进程返回 0父进程返回子进程 PID子进程没有后代用0来标记很方便父进程可能创建多个子进程要想管理子进程必须拿到每一个子进程唯一 PID后续用来等待回收、发送信号、管控子进程(2) 为什么一个 fork 函数会有两个返回值fork不是普通函数调用后会执行拷贝构造首先内核会分配新task_struct进程控制块 PCB、虚拟地址空间给子进程接着完整拷贝父进程页表、代码段、数据段给子进程再将将子进程加入 CPU 调度队列此时会有两个独立的进程父进程和子进程会同时返回父子进程各自拿到一份返回值所以一个调用产生两个返回结果3 为什么同一个变量pid既等于 0又大于 0pid变量在父子进程虚拟地址空间中是两份独立副本父进程的pid存的是子进程 PID0子进程的pid存的是 0每个进程的虚拟地址隔离互不干扰#includestdio.h#includeunistd.h#includestdlib.h#includesys/types.hintmain(){printf(fork前:父进程PID%d\n,getpid());pid_t idfork();if(id-1){perror(fork failed);exit(1);}elseif(id0){printf(我是子进程PID%d,我的父进程是%d\n,getpid(),getppid());}else{printf(我是父进程PID%d\n,getpid());}return0;}2、 写时拷贝COWfork() 创建子进程时一开始根本不复制物理内存父子进程共用同一份物理内存只有某一方修改数据的瞬间操作系统才单独复制一份内存给它这就叫写时拷贝fork刚创建完时内核给子进程创建独立的虚拟地址空间、PCB、页表父子两个进程的页表全部指向同一块物理内存操作系统把这块内存标记为只读权限任意一个进程父/子尝试修改全局/局部变量时触发写时构造。CPU访问只读内存触发缺页异常操作系统开辟一块新物理内存把要修改的那一页数据单独复制过去新拷贝出来的这一页权限直接设置为可读可写。修改进程的页表映射到新内存另一个进程依旧指向老内存。两块内存彻底隔离互不干扰之后两个进程各自操作自己的内存修改任何数据都不会影响对方达到进程地址空间完全隔离的设计目标fork 的时候如果没有写时拷贝创建子进程就要把父进程全部物理内存完整复制一份。父进程占用几百 MB 内存fork 就要一次性拷贝几百 MB耗时、浪费物理内存写时拷贝核心思想先共享真正修改的时候才复制fork 系统调用瞬间完成不复制任何用户数据物理内存。仅仅拷贝页表很小父子进程共享全部物理页面。哪怕父进程占用很大内存fork 依旧飞快#includestdio.h#includeunistd.h#includesys/types.hintglobal_val100;intmain(){pid_t pidfork();if(pid0){perror(fork fail);return1;}elseif(pid0){// 子进程修改全局变量触发写时拷贝global_val200;printf(子进程 pid:%d, global_val %d\n,getpid(),global_val);}else{sleep(1);// 等子进程先执行完修改printf(父进程 pid:%d, global_val %d\n,getpid(),global_val);}return0;}fork 完成瞬间父子进程虚拟地址中global_val指向同一块物理内存页页面权限为只读当子进程执行 global_val 200; 写操作触发写保护缺页异常。操作系统执行写时拷贝复制这一页物理内存子进程页表映射到新的可读可写物理页。父进程页表映射关系完全不变依旧访问原来物理页。所以子进程修改的是自己副本的数据父进程的global_val仍然是 100实现进程数据隔离3、 Fork使用场景服务器多进程并发处理父进程负责监听客户端连接每接收到一个用户请求就 fork() 生成一个子进程由子进程单独处理这个客户端的全部业务父进程立刻回头继续等待下一个连接优点进程之间相互隔离某个子进程崩溃不会把整个服务器搞挂稳定性更强fork exec 执行外部程序Linux Shell 底层原理我们现在敲的终端敲所有命令本质都是这套流程。shell父进程调用 fork() 创建子进程子进程调用 exec 系列函数execl/execvp彻底替换自身代码加载磁盘上的可执行程序ls、pwd、gcc、cd父进程用 wait() 阻塞等待子进程运行完毕回收资源再回到命令行等待你输入下一条指令了解即可我们后边再详细介绍fork 调用失败的两种原因系统全局运行进程总数达到内核上限整个操作系统所有用户加起来的总进程数超了内核设定最大值系统级全局限制当前登录用户创建的进程数量超出操作系统资源限制当前这一个登录账号自己创建的进程数量超限用户级单独配额限制二、 进程终止1、 进程中止内核在做什么当一个进程结束运行Linux 内核会执行固定回收逻辑父进程正常运行时释放进程运行时申请的堆内存、打开的文件描述符、占用的 CPU/IO 等所有系统资源但是保留 PCB进程控制块不销毁PCB 里存放着本次进程的退出状态、退出码、终止信号等核心信息之后内核会一直等待父进程调用 wait() / waitpid() 读取 PCB 里的退出信息。如果父进程长期不去读取这份数据残留的 PCB 会一直占用内核空间这个进程就变成僵尸进程。PCB残留会造成内存泄漏且kill -9无法杀死已经终止的僵尸进程如果父进程迟迟不回收僵尸子进程父进程自己先终止运行所有未被回收的僵尸子进程会被系统 1 号 init/systemd 进程领养成为它们的新父进程1 号进程会自动、循环调用 wait 去批量回收所有养子的僵尸 PCB最终所有僵尸全部被内核清理干净2、 进程终止的三种场景场景 1代码完整跑完执行结果正确程序所有代码正常走完没有任何异常、中断、崩溃最终通过 return 0 或 exit(0) 主动退出退出码为 0代表程序执行成功场景 2代码完整执行完毕但执行结果错误程序本身没有崩溃、没有被系统信号打断完整跑完所有代码但内部业务逻辑不符合预期例如文件打开失败、参数校验不通过、计算结果错误等等此时我们执行非 0 退出码标记错误类型比如 exit(10)用来区分不同错误类型场景3代码未执行完毕异常终止程序运行中途被外部力量强制打断没有执行到 return、exit 等主动退出语句异常的根本原因进程接收到操作系统下发的终止信号举个例子终端按下 CtrlC内核发送 SIGINT 信号终止进程代码空指针访问、数组越界触发段错误内核发送 SIGSEGV 杀死进程手动执行 kill 命令向进程发送终止信号无论哪一种场景结束内核都会用两个通用参数完整描述退出结果exit_code进程主动退出时携带的退出码sig导致进程终止的信号编号判断逻辑sig 0进程属于主动正常退出对应场景 1、场景 2。只需解析 exit_code0 代表执行成功非 0 代表执行错误sig ≠ 0进程被信号异常终止对应场景 3。exit_code 不再具备参考价值只需要通过信号编号找到错误原因3、 三种进程正常退出方式方式 1main 函数 return 返回在 main 函数中执行 return n进程退出且main 的返回值会被当作整个进程的全局退出参数注意如果在非main函数内部执行return只是当前子函数调用结果并没有终止整个进程方式 2库函数 exit ()在全程序任意位置调用exit(n)都会结束进程return 只能依靠逐层返回到 main 才能退出程序但 exit() 在任意子函数、任意代码分支中调用都会立刻终结整个进程不需要代码层层回溯头文件依赖#include stdlib.h函数原型void exit(int status);exit(n) 传给内核的退出数字 n只保留低 8 位0~255高位直接丢掉可以使用系统本来就设置好的退出值也可以自定义退出值数字实际代表的含义比如自己定义exit(10)为文件打开失败方式3系统调用 _exit ()在全程序任意位置调用_exit(n)都会结束进程但是不会刷新缓冲区可能会导致数据丢失_exit 的 status 参数同样只保留低 8 位如果执行 _exit(-1)父进程最终读取到的退出码为 2554、 exit与_exit对比exit执行有三个流程执行清理函数atexit()这个就像一个退出待办清单用于完成收尾工作例如释放动态内存、断开网络连接、输出退出日志等自动刷新IO 缓冲区、关闭所有已打开的文件流exit的退出功能其实是靠封装内核的 _exit() 实现的底层调用_exit()真正进入内核由操作系统完成真正的进程资源销毁_exit执行逻辑_exit() 是 Linux 内核原生系统调用依赖头文件 unistd.h直接进入内核空间执行_exit()不会执行任何清理函数也不会刷新IO缓冲区直接强制终止进程5、 Linux退出码含义exit(n) 里的 n只是原始退出值wait(status) 里的 status才是退出码退出码的高8位存放的是exit(n)中的n。n只是退出码的一部分一个程序运行结束后可以在终端执行echo ? 打印上一个进程的退出码且只保留低 8 位范围只能 0~255exit(256) 等价 exit(0)系统标准退出码退出码官方含义0命令 / 程序正常完整执行完毕1通用未知运行错误2命令参数使用格式不当126权限不足文件无法执行127系统找不到对应的可执行命令130CtrlC 发送 SIGINT 信号终止进程143kill 默认 SIGTERM 信号杀死进程三、 进程等待1、为什么需要进程等待fork 创建子进程后会出现一个重要的遗留问题子进程执行完毕调用 exit() 退出时并不会直接被操作系统彻底销毁。它的PCB 进程控制块会短暂驻留在内核中记录自身退出码、终止信号等信息此时该进程就叫僵尸进程僵尸进程无法用 kill 命令杀死持续堆积会占用系统进程表资源最终导致系统无法创建新进程想要释放僵尸进程、回收内核 PCB 资源唯一合法方式就是父进程调用 wait() 或waitpid() 阻塞等待 / 非阻塞等待子进程退出并读取子进程退出状态还有一个比较重要的原因是如果父进程给子进程安排了任务我们就需要知道这个任务完成的怎样结果对还是不对或者是否正常退出。阻塞等待父进程调用函数之后原地卡死、停住不动啥也不干一直傻傻等到某个子进程彻底结束运行才继续往下执行自己的代码wait() 默认就是永久阻塞waitpid(…, 0) 第三个参数填 0也是阻塞非阻塞等待WNOHANG父进程调用函数只做一次快速检查如果子进程还在跑函数立刻返回 0父进程不卡住转头去执行自己别的业务比如打印日志、操作数据库如果子进程刚好退出了直接回收子进程拿到结果waitpid(…, WNOHANG)最后一个参数需要填WNOHANG基于这个底层根源进程等待有三大价值销毁僵尸进程避免系统内存与进程表泄漏最重要获取子进程退出结果区分子进程是正常执行完毕退出还是被异常信号强制杀死管控父子进程执行流父进程可以挂起自身逻辑等所有子进程全部运行结束后再继续执行后续代码2、wait()函数原型#includesys/wait.hpid_twait(int*status);参数int * status位图参数操作系统会把子进程退出的状态信息填充到这块内存中如果不关心子进程如何退出直接出传入NULL就行返回值:成功返回被回收的子进程PID失败返回-1比如当前进程没有任何子进程wait执行时会永久阻塞等待直到任意一个子进程退出只要有一个子进程变为僵尸状态wait 立刻回收并返回。如果没有子进程则直接报错wait局限性只能随机等待任意子进程无法指定等待某一个 PID 的子进程只能阻塞等待父进程卡死无法执行自己的代码如果批量创建多个子进程管理起来非常麻烦#includestdio.h#includeunistd.h#includesys/wait.h#includestdlib.hvoidchild_code(){printf(子进程运行中...\n);sleep(3);exit(10);// 子进程退出携带退出码10}voidparent_code(){ints;printf(父进程调用wait开始阻塞等待\n);wait(s);printf(子进程已退出父进程解除阻塞子进程退出码%d\n,WEXITSTATUS(s));}intmain(void){pid_t pidfork();if(pid0){// 子进程分支child_code();}else{// 父进程分支parent_code();}return0;}3、waitpid函数原型#includesys/wait.hpid_twaitpid(pid_t pid,int*status,intoptions);参数pid可以控制等待哪一个子进程pid 0直接匹配该PID的子进程pid -1等待任意一个子进程功能完全等价于 wait()pid 0等待和父进程属于同一个进程组的任意子进程pid -1等待绝对值对应进程组内的任意子进程进程组Linux 里把一批有关联的进程打包成一个小组这个整体就叫进程组给组分配一个编号叫 PGID进程组 ID在终端敲一条管道命令比如ls-l|greptxtls 和 grep 两个进程自动归入同一个进程组PGID 相同绝大多数我们写的简单 fork() 单进程程序,所有子进程默认继承父进程的进程组 ID也就是都和父进程同组参数status和 wait 完全通用同样是操作系统填充的状态位图需要用系统提供的固定宏去解析里面存储的二进制信息后面单独讲底层结构参数options控制阻塞 / 非阻塞模式options 0阻塞模式子进程未退出时父进程原地卡死等待options WNOHANG非阻塞轮询模式立刻检测一次子进程状态子进程还在运行直接返回 0父进程可以往下执行自己的业务代码子进程已经退出返回子进程 PID 完成回收waitpid 返回值返回 0成功回收子进程返回值为被回收子进程 PID返回 0仅开启 WNOHANG 非阻塞时生效代表子进程仍在运行中返回 -1函数调用出错无子进程、参数非法等4、status位图只用16位置前面我们介绍了进程终止的3种场景当进程终止时操作系统内核会将子进程的退出码 exit_code 以及导致进程终止的信号编号 sig 这两组关键信息以位图分段存储的形式打包存放在同一个整型变量 status 当中。之后父进程通过调用 wait 或者 waitpid 系统调用获取到这个承载了完整终止信息的 status 变量再借助系统预先定义好的解析宏从 status 里分别提取出原始退出码和终止信号以此反向判定子进程具体的终止原因与运行结果sig 0说明子进程主动退出场景 1、场景 2只看 exit_codesig ! 0 说明信号杀死异常退出场景 3只看信号编号WEXITSTATUSWEXITSTATUS是 Linux 系统给我们定义的一个宏可以理解成官方封装好的工具函数头文件#include sys/wait.h作用从 wait/waitpid 拿到的 status 变量里提取子进程正常退出时的数字退出码为什么非要用它不能直接 status 打印status 并不是普通整数变量它是一个 int 类型的位图二进制打包数据一个整数分段存储两块完全不同的数据低 7 位终止信号 sig子进程是不是被信号杀死、是被几号信号杀死的。如果为0就是正常退出第 8 位core dump 标志位是否生成崩溃转储文件高 8 位退出码 exit_code仅子进程正常调用直接 printf(“%d”, status) 出来的是混合后的乱数根本看不到真实退出码所以必须用系统提供的宏拆开解读配套解析宏对应位图结构处理【子进程正常退出】的两个宏WIFEXITED(status)功能判断函数检查子进程是不是通过 exit()、_exit() 或者 main 函数里return正常走完程序退出的返回 真非 0正常主动退出此时 status 低 7 位信号自动为 0返回 假0进程是被外部信号强行杀死的走第二组逻辑WEXITSTATUS(status)功能取值提取函数只有上面 WIFEXITED(status) 返回为真的时候它才有意义作用是从 status 高 8 位中抠出当初子进程 exit(n) 里面填写的数字n也就是 0~255 的自定义退出码注意如果进程是被信号杀死的调用这个宏拿到的数据是无效垃圾值处理【子进程被信号异常杀死】的两个宏3.WIFSIGNALED(status)功能判断子进程是否被信号终止比如CtrlC、段错误 SIGSEGV、kill命令返回真被信号干掉返回假正常退出WTERMSIG(status)功能仅在WIFSIGNALED为真时使用从 status 的低 7 位二进制里提取出杀死进程的那个信号的编号这种场景下子进程根本没有执行exit(n)所以高 8 位的退出码区域是空的、完全没有参考意义5、子进程正常退出测试#includestdio.h#includeunistd.h#includesys/wait.h#includesys/types.h#includestdlib.hintmain(){pid_t pidfork();if(pid0){// 测试1正常退出自定义退出码5exit(5);}intstatus;wait(status);// 第一组判断是否正常exit/return退出if(WIFEXITED(status)){intcodeWEXITSTATUS(status);printf(子进程正常退出提取退出码%d\n,code);}return0;}6、子进程异常退出测试子进程被信号异常终止时父进程通过 wait 状态宏可以识别出子进程是被哪个信号杀死#includestdio.h#includeunistd.h#includesys/wait.h#includesys/types.h#includestdlib.hintmain(){pid_tpidfork();if(pid0){// 测试2后台运行程序再用kill杀死子进程测试信号终止// 不直接用Ctrlc避免影响父进程导致程序直接退出printf(子进程pid%d\n,getpid());while(1)sleep(1);}intstatus;wait(status);// 第二组判断是否被信号异常杀死if(WIFSIGNALED(status)){intsig_numWTERMSIG(status);printf(子进程被信号终止信号编号%d\n,sig_num);}return0;}如果直接终端按CtrlC信号会发给整个进程组父子会一起被杀掉父进程的wait根本没机会解析 status看不到打印结果7、waitpid 非阻塞轮询WNOHANG测试#includestdio.h#includeunistd.h#includesys/wait.h#includestdlib.hintmain(){pid_tpidfork();if(pid0){sleep(3);// 子进程3秒后才退出exit(10);}intstatus;while(1){// WNOHANG 非阻塞子进程没退出立刻返回0父进程可以做其他事pid_tretwaitpid(pid,status,WNOHANG);if(ret0){printf(子进程还在运行父进程做其他任务...\n);sleep(1);}elseif(ret0){printf(成功回收子进程退出码%d\n,WEXITSTATUS(status));break;}}return0;}8、批量创建子进程全部回收前面我们演示的都是单个子进程的创建与回收。但实际开发场景经常需要批量创建一批子进程并行处理任务#includestdio.h#includeunistd.h#includesys/wait.h#includeiostream#includevector#includestdlib.h//函数指针类型重定义//把void(*)()这个复杂的函数指针类型起别名为callback_t//callback_t 就是一个类型名代表指向返回值void不带参数的函数的指针类型typedefvoid(*callback_t)();voidTask(){intcnt3;while(cnt--){printf(子进程执行任务 pid:%d\n,getpid());sleep(1);}}voidCreateChildren(intnum,std::vectorpid_t*sub,callback_t cb){for(inti0;inum;i){pid_t pidfork();if(pid0){cb();//把业务任务”打包传递“给子进程exit(0);//子进程一定要exit防止继续fork生成孙子进程}sub-push_back(pid);//往数组插入子进程pid}}voidWaitAll(conststd::vectorpid_tsubs){for(autopid:subs){intstatus0;waitpid(pid,status,0);if(WIFEXITED(status)){printf(回收子进程%d,退出码:%d\n,pid,WEXITSTATUS(status));}}}intmain(){std::vectorpid_tchildren;CreateChildren(3,children,Task);WaitAll(children);return0;}运行程序可以看到 3 个子进程并行执行任务每个子进程循环打印 3 次日志。全部任务执行完成后父进程依次回收每一个子进程打印子进程 PID 与退出码 0。退出码为 0代表子进程业务逻辑正常退出typedef void ( * callback_t)(); 函数指针别名callback_t 是一个自定义类型用来保存返回值为 void、没有参数的函数地址这么做可以把希望子进程实现的工作可以把函数作为参数传给CreateChildren实现解耦创建进程的代码只负责fork后续更换业务只需要写新的函数传入即可注意传参写Task函数地址不能写Task()Task()是立刻调用函数CreateChildren 创建子进程fork()之后分出父子两条执行流子进程进入pid 0分支执行业务cb()然后调用exit(0)直接终止。子进程不会执行后面的 push_back父进程得到大于 0 的子进程 PID跳过 if 分支执行push_back(pid)把子进程 PID 存入 vectorfork 调用完成的一瞬间父进程就拿到子进程的 PID 数值。哪怕子进程很快执行 exit 变成僵尸进程这个 PID 编号依然有效。父进程只是保存这个数字不依赖子进程是否还在运行子进程的exit(0)绝对不能省略如果没有 exit子进程结束任务后会继续向下执行 for 循环继续调用 fork不停创建孙子、重孙子进程造成进程爆炸WaitAll 精准回收所有子进程vector 里面存放了所有我们创建出来的子进程 pid循环遍历调用waitpid(pid, status, 0)指定具体 pid 阻塞等待做到精准回收我们自己创建的子进程通过WIFEXITED、WEXITSTATUS解析 status拿到每个子进程的退出码完成回收避免僵尸进程驻留内核