目录
一、进程创建
fork函数
fork函数是作用是从一个已存在的进程中创建一个新进程,新进程叫做子进程,原来的进程叫做父进程
- 使用fork函数的时候需要包头文件#include <unistd.h>
- 对于fork函数给父进程返回子进程的pid,给父进程返回0
- 调用fork函数之后,操作系统会做如下操作
- 分配新的内存和内核数据结构 (task_struct(PCB)&& mm_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个子进程被创建出来了,并且也可以观察到父进程和子进程的进程状态。
bash
//脚本指令
while :; do ps ajx | head -1 && ps axj | grep mycommand | grep -v grep; echo "-------------------------------"; sleep 1; done

- 并且如果在同一进程中使用fork连续创建子进程,那么子进程的pid一般都是连续的,所以右就可以监视到有16766,16769,16768,16767,16770这5个子进程,并且这5个子进程的父进程都是16765。
- 这时候我们再观察左边,但是左边中并不是按照子进程被创建出来的顺序,即子进程的pid16766,16767,16768,16769,16770的顺序进行调度的,相反调度顺序是乱序的。
- 其实进程的调度顺序并不能由我们来决定,而是由调度器决定的,因为子进程被创建出来后,它们的优先级是一样的,所以它们执行的顺序就取决于谁被调度器优先放到CPU的运行队列里,谁先被调度器放到CPU的运行队列的早,那么谁就优先被调度,由于子进程的优先级都相同,所以究竟是谁先被放入,具体是由调度器决定的,所以fork之后,进程谁先执行,完全是由调度器决定的。

子进程执行 runChild() 时,会循环10次( cnt=10 ),每次打印信息+ 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. 将子进程新物理地址的权限设为可读可写(父进程原物理地址的权限仍保持只读)。
此时,子进程就可以在新空间上修改数据了;而父进程的原物理地址数据不受影响,且权限仍为只读------直到父进程自己尝试写数据时,才会触发父进程自己的写时拷贝流程(重复上述步骤,为父进程分配新物理页)。最终,只有被修改的数据,父子进程会拥有独立的物理空间;未被修改的数据,仍然共用同一块物理空间。

深入理解代码和数据
代码是"编译后固定、运行时只能读的指令"
数据是"编译时可设初始值、运行时能改的内容 "------这就是操作系统对代码共享、数据写时拷贝的根本原因。
- 修改时机:
代码只在编译前能改(比如删if、改printf),运行时指令固定;
数据编译前能改初始值(比如a=10改a=20),运行时还能实时改(比如a++)。
- 运行时权限:
代码运行时是"只读"(改了程序崩),所以父子直接共用;
数据运行时是"可读写"(父子可能改得不一样),所以用写时拷贝按需拷贝。
思考一下,代码是不会进行修改的,所以父进程和子进程共用同一份代码也不会对代码进行修改,那么父子进程共用一份代码我可以理解,那么对于数据由于子进程有可能会有修改数据的需求,我不想使用写时拷贝,那么我在技术层面上可不可以无脑的将父进程的数据给子进程拷贝一份,不要这个写时拷贝?
其实在技术层面上是完全可以实现的,但是我们还应该看到另外一方面,如果父进程有100个字节的数据,父进程和子进程共用数据,子进程想要修改的数据只有一个,那么采用写时拷贝,那么仅仅多开一个数据的空间(其实对于要修改的数据是一定要进行开空间拷贝进行修改的,只是对于写时拷贝是一种延时拷贝的方式),如果子进程采用无脑拷贝的方式,那么就会去拷贝100个数据,相对于写时拷贝,就会多拷贝了98个数据,此时在内存中会出现重复数据,而且拷贝这些数据相对于拷贝一个数据多花费了很多时间,并且这多拷贝的数据我子进程还不会进行修改,甚至还会有数据我子进程根本不会进行修改,所以对于内存空间来讲这是一种很大的浪费,我们知道现代操作系统是不会去做任何一件浪费时间和空间的事情,所以操作系统一定会去采用前一种写时拷贝的方式,而不会采用无脑拷贝方式。
采用写时拷贝,相对于无脑拷贝的方式,可以有效的节省内存空间,并且减少拷贝,提高效率
再次理解写时拷贝
只要用fork创建子进程,系统就会把父子共享的数据(原父进程是可读可写的),统一改成只读;之后谁想改数据,谁就触发写时拷贝。
关键细节在于:
父进程单独运行时:数据段确实是可读可写的;
fork创建子进程瞬间:操作系统会做两件事:
拷贝父进程的页表(让父子数据映射同一块物理内存);
强制把"父子双方页表中,所有共享数据对应的物理内存权限",从原来的"可读可写"改成只读(这是fork的核心操作之一);
后续不管父进程还是子进程,只要想改数据(写操作),就会因"物理内存是只读"触发写保护异常------操作系统捕获后,就给修改方分配新物理页、拷贝数据、把新页权限改回"可读可写",完成写时拷贝。
简单说:fork一执行,数据权限就"临时锁成只读";谁要开锁(写数据),就得先"复制一份数据"------这就是写时拷贝的完整触发链路~
还有写时拷贝整个过程从头到尾只和数据段有关,与代码段无关
- 代码段:从头到尾都是只读,和写时拷贝毫无关系
- 不管是父进程单独运行,还是fork创建子进程后,代码段的权限永远是只读。因为代码段是"指令",谁都不能改,父子本来就共享且无需拷贝,所以写时拷贝根本涉及不到它。
- 写时拷贝:只针对"数据段",全程和代码段没关系
- 写时拷贝的所有操作(fork时改权限为只读、写操作触发异常、拷贝新物理页),都只作用于"可读写的数据"。代码段既不需要改权限,也不需要拷贝,和整个流程无关。
二、进程终止
进程退出场景
1. 代码运行完毕,结果正确
2. 代码运行完毕,结果不正确
3. 代码异常终止
代码运行完毕,结果正确或不正确时:

- 在我们最初学习编程的时候,总是要在main函数的最后编写一个return 0,可是我们貌似没有去探索过为什么要编写这个return 0,这个return 0的作用是什么,这个return 0返回的0给谁了,我可不可以return 1,return 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 命令会向指定的进程或进程组发送指定的信号。
bash
//kill的使用
kill -选项 进程的pid
我们使用kill -l看一下kill可以发送什么信号

- SIGKILL:强制终止进程,无法被捕获、忽略或处理,是"必杀"信号(常用于强制结束无响应进程)。
- SIGTERM:默认终止信号,进程可以捕获/处理(比如做资源清理),若进程未处理则终止。
- SIGABRT:进程主动调用 abort() 触发,通常用于程序异常时的自我终止。
- SIGFPE:浮点运算错误(比如除0、数值溢出)。
- SIGSEGV:段错误(非法访问内存,比如越界读写、访问空指针)。
- SIGILL:非法指令(程序执行了无效的CPU指令,比如代码损坏)。
- SIGINT:终端中断信号(对应键盘 Ctrl+C ),进程可捕获并处理。
- SIGHUP:终端挂起信号(比如终端关闭时发送),部分进程会用它重新加载配置(如Nginx)。
- SIGSTOP:暂停进程,无法被捕获/忽略;18) SIGCONT:恢复被暂停的进程。
这些信号是Linux中进程管理、调试的核心,尤其是 SIGKILL(9) 、 SIGTERM(15) 、 SIGINT(2) 、 SIGSEGV(11) 最常用。
我们可以使用kill指令给进程发送信号,进程接收到我们的信号之后就会出现异常终止的情况

接下来我们演示9号信号杀死进程

再演示使用8号信号给进程发送除零错误的信号,进而进程接收到信号后就会异常终止

再演示使用11号信号给进程发送段错误的信号,进而进程接收到信号后就会异常终止

那么通过上面的一系列演示之后,我们可以得出,进程出现异常终止,本质是由于进程收到了对应的信号
总结
代码运行完毕,结果正确
代码运行完毕,结果不正确
代码异常终止
代码跑完(代码运行期间,没有收到信号) 0 && return 0 -> signumber: 0 && 退出码: 0
signumber : 0 && 退出码:0
signumber : !0 && 退出码无意义
进程执行的结果状态,可以用两个数字表示: int sig, int exit_code; → 用户不需要维护
- 那么对于上面异常出现的三种场景,由于异常终止出现了之后,进程就有可能连return语句都没有执行到,所以进程的退出码就没有了意义
- 所以我们应该使用信号操作检测进程异常终止,如果没有进程的异常终止,说明代码运行完毕,此时进程会返回对应的退出码,这时候进程的退出码有意义,所以我们可以使用退出码了,如果退出码为0,那么说明进程结果正确,如果退出码非0,那么说明结果不正确,进而我们就可以使用这个退出码的数字去对应具体的错误信息,得知我们的进程发生了什么错误,去根据错误信息处理对应的错误
进程退出方法
正常退出
- 从main函数return返回
- 调用exit
- 调用_exit
return和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 都是用于终止进程的函数,但核心区别在于是否触发"进程退出清理操作" ,具体如下:
- 头文件与归属
-
exit :属于标准C库函数 ,头文件是 <stdlib.h> ;
-
_exit :属于系统调用(内核提供) ,头文件是 <unistd.h> 。
- 核心区别在于是否清理用户态资源
exit (标准退出):
终止进程前会执行用户态的清理操作:
-
刷新并关闭所有打开的标准I/O流(比如 printf 缓冲区未输出的内容会被强制刷新);
-
调用通过 atexit / on_exit 注册的"退出处理函数";
-
最后调用 _exit 进入内核态终止进程。
_exit (直接退出) :
直接进入内核态终止进程,不做任何用户态的清理操作:
-
不会刷新I/O缓冲区(比如 printf 的内容若还在缓冲区,会直接丢失);
-
不会执行 atexit 注册的函数。
- 适用场景
-
exit :用于普通进程的"正常退出 ",需要保证I/O数据完整、资源清理完成的场景(比如命令行程序、普通应用)。
-
_exit :用于子进程退出(比如 fork 后的子进程),避免与父进程重复清理资源(比如避免子进程刷新父进程的I/O缓冲区);或需要"立即终止、不做额外操作"的场景。
异常退出
ctrl + c,信号终止
三、总结
本文主要探讨了进程创建和终止的相关机制。在进程创建方面,详细介绍了fork函数的工作原理,包括父子进程的内存分配、内核数据结构复制以及写时拷贝技术。在进程终止方面,分析了三种退出场景(运行成功、运行失败、异常终止),比较了return、exit和_exit的区别,并解释了缓冲区刷新机制。文章还讨论了进程异常终止的原因,介绍了常见信号及其作用,如SIGKILL、SIGTERM等。通过实验演示了不同终止方式的行为差异,帮助深入理解进程管理机制。

















