linux 进程控制(一) (fork进程创建,exit进程终止)

linux 进程控制(一) (fork进程创建,exit进程终止) 目录一、进程创建fork函数使用for循环同时创建多个进程写时拷贝深入理解代码和数据再次理解写时拷贝二、进程终止进程退出场景代码运行完毕结果正确或不正确时:申请内存错误演示除零错误演示空指针访问错误演示代码异常终止时:总结进程退出方法正常退出return和exit的区别exit和_exit的区别异常退出三、总结一、进程创建fork函数fork函数是作用是从一个已存在的进程中创建一个新进程新进程叫做子进程原来的进程叫做父进程使用fork函数的时候需要包头文件#include unistd.h对于fork函数给父进程返回子进程的pid给父进程返回0调用fork函数之后操作系统会做如下操作分配新的内存和内核数据结构task_structPCBmm_struct进程地址空间页表给子进程将父进程的内核数据结构的字段内容拷贝给子进程内核数据结构对应的字段上将子进程添加到CPU的运行队列中fork函数返回调度器开始调度fork之前父进程单独执行fork之后父子进程二进制代码相同即父子进程共用代码并且如果不适用if(id 0)进行区分父进程和子进程那么父子进程会执行到相同的地方父子进程两个执行流分别执行所以会执行打印两次after。使用if(id 0)区分父进程和子进程父进程和子进程会去执行不同的代码块执行不同的执行流运行结果如下父进程和子进程分别执行不同的代码块执行不同的执行流fork也有可能会创建子进程失败如果fork失败则会返回小于0的数通常我们不进行判断因为一般很少情况下会创建子进程失败当fork失败的原因可能是操作系统的进程过多或者是实际用户的进程数超过了限制。使用for循环同时创建多个进程我们使用for循环同时创建5个子进程这里使用for循环循环5次在循环内存调用fork函数最开始只有父进程进行调用fork函数那么使用变量id接收fork函数的返回值之后由于fork函数会有两个返回值给子进程返回0给父进程返回子进程的pid那么我们就可以利用这个特性通过id 0判断出子进程进而让子进程去调用runChild函数其中这个runChild函数可以循环10次使用sleep间隔1秒打印进程的pid和ppid当子进程调用完成runChild函数之后这里我们仅仅希望只有最开始的一个父进程在for循环内调用5次fork创建5个子进程所以为了避免子进程调用完成runChild函数之后继续循环创建子进程造成无限套娃所以这里我们使用exit(0)终止子进程由于这个exit(0)是在if判断语句内所以并不会终止父进程父进程由于调用完成fork之后得到的id值是子进程的pid所以id不为0不会进行if语句内部会继续执行for循环继续去fork创建下一个子进程由于程序的执行速度十分十分快那么执行5次循环所以几乎可以说是在同一时间内父进程调用fork这5个子进程就被创建出来了。所以会在同一时间内出现5个子进程同时运行。接着我们复制ssh渠道使用如下脚本指令监视我们的进程mycommand这样可以进一步直观的确定的确是有5个子进程被创建出来了并且也可以观察到父进程和子进程的进程状态。//脚本指令 while :; do ps ajx | head -1 ps axj | grep mycommand | grep -v grep; echo -------------------------------; sleep 1; done并且如果在同一进程中使用fork连续创建子进程那么子进程的pid一般都是连续的所以右就可以监视到有1676616769167681676716770这5个子进程并且这5个子进程的父进程都是16765。这时候我们再观察左边但是左边中并不是按照子进程被创建出来的顺序即子进程的pid1676616767167681676916770的顺序进行调度的相反调度顺序是乱序的。其实进程的调度顺序并不能由我们来决定而是由调度器决定的因为子进程被创建出来后它们的优先级是一样的所以它们执行的顺序就取决于谁被调度器优先放到CPU的运行队列里谁先被调度器放到CPU的运行队列的早那么谁就优先被调度由于子进程的优先级都相同所以究竟是谁先被放入具体是由调度器决定的所以fork之后进程谁先执行完全是由调度器决定的。子进程执行 runChild() 时会循环10次 cnt10 每次打印信息 sleep(1) 10秒后循环结束。循环结束后子进程执行 exit(0) 主动退出。而父进程那边 main() 最后是 sleep(1000) 睡1000秒这期间父进程没调用 wait() / waitpid() 回收子进程所以子进程退出后就变成了僵尸进程 defunct 。这里不用管我们ctrl c退出就行。写时拷贝通常父子进程代码共享当父子进程不写入数据的时候数据是共享的当任意一方试图写入的时候便另外开一份空间以拷贝的方式拷贝一份数据进行修改原有的空间的数据属于未进行修改的那一方这样两方便各自有一份数据了那么接下来我们研究一下写时拷贝中操作系统是如何知道的父进程和子进程中的任意一个进行修改数据的时候怎么样触发的写时拷贝?当子进程通过 fork() 创建时操作系统会让它与父进程共享代码和数据的物理内存同时完成以下内核结构的拷贝1. 子进程的 task_struct PCB、 mm_struct 进程地址空间、页表都会以父进程的对应结构为模板完整拷贝。2. 子进程页表中的“虚拟地址→物理地址”映射关系也完全复制父进程的页表代码段子进程的代码虚拟地址与父进程对应虚拟地址映射到同一块物理内存且操作系统会把这块物理内存的权限设为只读因为代码是不可修改的父子共享即可。数据段子进程的数据虚拟地址同样与父进程对应虚拟地址映射到同一块物理内存但操作系统会做特殊处理将父、子进程双方页表中该数据虚拟地址对应的物理内存权限统一改为只读原本父进程的数据物理内存是“可读可写”的。这样处理后父子进程的代码、数据看似是“各自独立”的进程地址空间但实际共用同一块物理内存而“只读”权限正是写时拷贝的触发前提。那么当子进程或者父进程尝试对数据的物理地址上的内容做写入的时候这里以子进程为例当子进程尝试对子进程页表的数据虚拟地址对应的物理内存内容做写入时由于该物理地址的权限是只读的此时写入会触发写保护异常。操作系统不会对此报错而是将其转化为写时拷贝的触发信号1. 操作系统为子进程重新申请一块新的物理内存空间2. 将原物理地址中的数据完整拷贝到新空间3. 修改子进程的页表把对应数据虚拟地址的映射关系从“原物理地址”改为“新物理地址”4. 将子进程新物理地址的权限设为可读可写父进程原物理地址的权限仍保持只读。此时子进程就可以在新空间上修改数据了而父进程的原物理地址数据不受影响且权限仍为只读——直到父进程自己尝试写数据时才会触发父进程自己的写时拷贝流程重复上述步骤为父进程分配新物理页。最终只有被修改的数据父子进程会拥有独立的物理空间未被修改的数据仍然共用同一块物理空间。深入理解代码和数据代码是“编译后固定、运行时只能读的指令”数据是“编译时可设初始值、运行时能改的内容”——这就是操作系统对代码共享、数据写时拷贝的根本原因。1. 修改时机代码只在编译前能改比如删if、改printf运行时指令固定数据编译前能改初始值比如a10改a20运行时还能实时改比如a。2. 运行时权限代码运行时是“只读”改了程序崩所以父子直接共用数据运行时是“可读写”父子可能改得不一样所以用写时拷贝按需拷贝。思考一下代码是不会进行修改的所以父进程和子进程共用同一份代码也不会对代码进行修改那么父子进程共用一份代码我可以理解那么对于数据由于子进程有可能会有修改数据的需求我不想使用写时拷贝那么我在技术层面上可不可以无脑的将父进程的数据给子进程拷贝一份不要这个写时拷贝其实在技术层面上是完全可以实现的但是我们还应该看到另外一方面如果父进程有100个字节的数据父进程和子进程共用数据子进程想要修改的数据只有一个那么采用写时拷贝那么仅仅多开一个数据的空间其实对于要修改的数据是一定要进行开空间拷贝进行修改的只是对于写时拷贝是一种延时拷贝的方式如果子进程采用无脑拷贝的方式那么就会去拷贝100个数据相对于写时拷贝就会多拷贝了98个数据此时在内存中会出现重复数据而且拷贝这些数据相对于拷贝一个数据多花费了很多时间并且这多拷贝的数据我子进程还不会进行修改甚至还会有数据我子进程根本不会进行修改所以对于内存空间来讲这是一种很大的浪费我们知道现代操作系统是不会去做任何一件浪费时间和空间的事情所以操作系统一定会去采用前一种写时拷贝的方式而不会采用无脑拷贝方式。采用写时拷贝相对于无脑拷贝的方式可以有效的节省内存空间并且减少拷贝提高效率再次理解写时拷贝只要用fork创建子进程系统就会把父子共享的数据原父进程是可读可写的统一改成只读之后谁想改数据谁就触发写时拷贝。关键细节在于:1. 父进程单独运行时数据段确实是可读可写的2. fork创建子进程瞬间操作系统会做两件事拷贝父进程的页表让父子数据映射同一块物理内存强制把“父子双方页表中所有共享数据对应的物理内存权限”从原来的“可读可写”改成只读这是fork的核心操作之一3. 后续不管父进程还是子进程只要想改数据写操作就会因“物理内存是只读”触发写保护异常——操作系统捕获后就给修改方分配新物理页、拷贝数据、把新页权限改回“可读可写”完成写时拷贝。简单说fork一执行数据权限就“临时锁成只读”谁要开锁写数据就得先“复制一份数据”——这就是写时拷贝的完整触发链路~还有写时拷贝整个过程从头到尾只和数据段有关与代码段无关1. 代码段从头到尾都是只读和写时拷贝毫无关系不管是父进程单独运行还是fork创建子进程后代码段的权限永远是只读。因为代码段是“指令”谁都不能改父子本来就共享且无需拷贝所以写时拷贝根本涉及不到它。2. 写时拷贝只针对“数据段”全程和代码段没关系写时拷贝的所有操作fork时改权限为只读、写操作触发异常、拷贝新物理页都只作用于“可读写的数据”。代码段既不需要改权限也不需要拷贝和整个流程无关。二、进程终止进程退出场景1. 代码运行完毕结果正确2. 代码运行完毕结果不正确3. 代码异常终止代码运行完毕结果正确或不正确时:在我们最初学习编程的时候总是要在main函数的最后编写一个return 0可是我们貌似没有去探索过为什么要编写这个return 0这个return 0的作用是什么这个return 0返回的0给谁了我可不可以return 1return 2等等其实这个return 0返回值返回的数字叫做进程的退出码用来表示进程是否运行成功是否是正确的结果如果是正确的结果那么返回0如果是不正确的结果那么用不同的数字来表示不同的出错原因我们可以在命令行中使用echo $来获取最近一次进程运行返回的退出码全局变量errno它是用来存储最近一次的错误码这里应该注意区分一下一个程序可能出现多次错误码但是只能有一个退出码当程序出现错误的时候例如除零错误空指针的访问以及申请内存错误等当程序出现一次错误就会发出一次错误码而errno就可以捕获并存储最近一次的错误码错误码用于程序内部获取出现错误的编号从0开始编号那么我们究竟有多少个退出码呢strerror用于打印错误码的对应字符串描述信息我们可以借助strerror使用循环来打印一下:这里由于全部截图出来篇幅较多所以小编仅截出开头和结尾我们可以看出错误码从0到133共有134个错误码其实操作系统提供退出码和错误码它们的描述是有对应关系的申请内存错误演示例如程序出现的错误这里小编演示申请内存错误那么程序出现了错误之后errno就会自动更新当前错误的错误码其实我们可以将错误码的值赋值给退出码这样我们就可以使用strerror打印错误码然后将退出码以进程的角度返回运行结果:这样就使我们程序出现的错误原因回显并且退出码也返回对应的错误码表示错误的编号在有些场景中我们并不需要类似于这样打印错误信息而是采用返回退出码的形式告诉用户程序的错误信息除零错误演示Floating point exception即除零错误空指针访问错误演示Segmentation fault即段错误即越界访问空指针的访问关于进程退出的场景有代码运行完毕结果正确以及代码运行完毕结果不正确那么代码运行结束对于结果的正确与否都统一使用进程的退出码来进行判定如果进程的退出码为0那么结果正确如果进程的退出码为非0那么结果错误对于具体的错误的原因是由return返回不同值的数字退出码来表示。这个退出码是给谁的其实是给父进程的那么在进程中通常是父进程需要关心当前进程的运行情况其实父进程也是替用户办事的创建子进程其实是用户想要进行创建父进程为用户创建子进程那么对于一些不进行打印的场景可以使用进程返回退出码的形式将进程出错的对应退出码返回给父进程在命令行中创建的进程的父进程是bash这样用户就可以使用echo $?查看最近一次的进程的退出码这样当进程出现错误的时候用户获取到退出码便于用户针对退出码对应的错误信息做出下一步的策略例如调整代码逻辑重新运行等。代码异常终止时:对于进程退出的场景还有进程异常终止的场景异常的本质就是代码可能没有跑完当代码没有跑完进程便异常终止了此时进程仍然没有执行到main函数的return语句所以此时进程的退出码便没有了意义那么此时我们就不关心进程的退出码了。进程出现异常本质是由于我们的进程出现了异常kill 命令会向指定的进程或进程组发送指定的信号。//kill的使用 kill -选项 进程的pid我们使用kill -l看一下kill可以发送什么信号- 9) SIGKILL强制终止进程无法被捕获、忽略或处理是“必杀”信号常用于强制结束无响应进程。- 15) SIGTERM默认终止信号进程可以捕获/处理比如做资源清理若进程未处理则终止。- 6) SIGABRT进程主动调用 abort() 触发通常用于程序异常时的自我终止。- 8) SIGFPE浮点运算错误比如除0、数值溢出。- 11) SIGSEGV段错误非法访问内存比如越界读写、访问空指针。- 4) SIGILL非法指令程序执行了无效的CPU指令比如代码损坏。- 2) SIGINT终端中断信号对应键盘 CtrlC 进程可捕获并处理。- 1) SIGHUP终端挂起信号比如终端关闭时发送部分进程会用它重新加载配置如Nginx。- 19) SIGSTOP暂停进程无法被捕获/忽略18) SIGCONT恢复被暂停的进程。这些信号是Linux中进程管理、调试的核心尤其是 SIGKILL(9) 、 SIGTERM(15) 、 SIGINT(2) 、 SIGSEGV(11) 最常用。我们可以使用kill指令给进程发送信号进程接收到我们的信号之后就会出现异常终止的情况接下来我们演示9号信号杀死进程再演示使用8号信号给进程发送除零错误的信号进而进程接收到信号后就会异常终止再演示使用11号信号给进程发送段错误的信号进而进程接收到信号后就会异常终止那么通过上面的一系列演示之后我们可以得出进程出现异常终止本质是由于进程收到了对应的信号总结代码运行完毕结果正确代码运行完毕结果不正确代码异常终止1. 代码跑完(代码运行期间没有收到信号) 0 return 0 - signumber: 0 退出码: 02. signumber(信号) : 0 退出码:03. signumber : !0 退出码无意义进程执行的结果状态可以用两个数字表示: int sig, int exit_code; → 用户不需要维护那么对于上面异常出现的三种场景由于异常终止出现了之后进程就有可能连return语句都没有执行到所以进程的退出码就没有了意义所以我们应该使用信号操作检测进程异常终止如果没有进程的异常终止说明代码运行完毕此时进程会返回对应的退出码这时候进程的退出码有意义所以我们可以使用退出码了如果退出码为0那么说明进程结果正确如果退出码非0那么说明结果不正确进而我们就可以使用这个退出码的数字去对应具体的错误信息得知我们的进程发生了什么错误去根据错误信息处理对应的错误进程退出方法进程退出的方法有两种 : 一种是正常退出 , 一种是异常退出正常退出从main函数return返回调用exit调用_exitreturn和exit的区别exit可以引起一个进程终止传入exit的参数其实就是进程的退出码那么我们先看return的情况:那么我们再看exit:那么我们可以看到在main函数内return和exit实际上并没有任何区别都是用于终止进程返回退出码。那么在其它函数中呢return和exit的作用仍然相同吗同样的我们先看在其它函数中return的情况:我们可以看出在其它函数return之后return不会直接终止进程而是会返回当前函数回到进行调用的函数中去继续执行那么接下来我们看一下在其它函数执行exit的情况:我们可以看到在其它函数中执行了exit函数之后此时会直接在调用exit的地方终止进程并且返回退出码总结:所以在main函数中return和exit没有区别都是终止进程返回退出码在任意位置exit被调用都表示进程直接退出但是return在其它函数中被调用只表示当前函数返回exit和_exit的区别先看_exit:我们的hello linux并没有打印原因是我们调用的printf是将数据写入缓冲区即hello linux被放入到了缓冲区当遇到\n或者进程return或者exit会自动刷新缓冲区的内容这里我们既没有\n也没有return也没有exit所以自然而然我们的hello linux并不会打印所以我们可以得出_exit并不会刷新缓冲区的内容再看exit:我们可以看到我们的hello linux被打印出来了即hello linux由于我们调用了exit之后从缓冲区中将hello linux刷新出来了其实从头文件中我们就可以看出来了exit的头文件是#include stdlib.h而_exit的头文件是#include unistd.h即_exit是系统调用函数exit只是一个普通函数exit 和 _exit 都是用于终止进程的函数但核心区别在于是否触发“进程退出清理操作”具体如下1. 头文件与归属exit 属于标准C库函数头文件是 stdlib.h _exit 属于系统调用内核提供头文件是 unistd.h 。2. 核心区别在于是否清理用户态资源exit 标准退出终止进程前会执行用户态的清理操作1. 刷新并关闭所有打开的标准I/O流比如 printf 缓冲区未输出的内容会被强制刷新2. 调用通过 atexit / on_exit 注册的“退出处理函数”3. 最后调用 _exit 进入内核态终止进程。_exit 直接退出直接进入内核态终止进程不做任何用户态的清理操作- 不会刷新I/O缓冲区比如 printf 的内容若还在缓冲区会直接丢失- 不会执行 atexit 注册的函数。3. 适用场景exit 用于普通进程的“正常退出”需要保证I/O数据完整、资源清理完成的场景比如命令行程序、普通应用。_exit 用于子进程退出比如 fork 后的子进程避免与父进程重复清理资源比如避免子进程刷新父进程的I/O缓冲区或需要“立即终止、不做额外操作”的场景。异常退出收到系统信号退出(比如段错误,kill-9,除0错误,野指针错误)三、总结本文主要探讨了进程创建和终止的相关机制。在进程创建方面详细介绍了fork函数的工作原理包括父子进程的内存分配、内核数据结构复制以及写时拷贝技术。在进程终止方面分析了三种退出场景运行成功、运行失败、异常终止比较了return、exit和_exit的区别并解释了缓冲区刷新机制。文章还讨论了进程异常终止的原因介绍了常见信号及其作用如SIGKILL、SIGTERM等。通过实验演示了不同终止方式的行为差异帮助深入理解进程管理机制。