一、进程父子关系1.1 进程树我们的进程是一个单亲繁殖系统类似于 B 树我们还没有学一棵多叉树从根结点出发延伸出多个子进程。譬如我们一切的命令都有一个共同的父进程 bash。1.2 子进程的创建那么进程应该如何创建呢我们先来介绍一个创建子进程的系统调用fork头文件#include unistd.h快速了解一下这个系统调用一句话核心 ——fork()就是进程的分身术调用一次原地复制出一个几乎一模一样的子进程让程序实现真正的分头行动。作用创建分身产生子进程继承父进程的代码和数据并发处理让父子进程同时运行互不干扰地处理不同任务环境隔离子进程在独立空间运行崩溃不影响父进程。它在 2 号手册中属于系统调用。我们先写一个代码然后慢慢讲voidFunc_Test_Process(){pid_t_idfork();if(_id0){printf(创建进程失败\n);}//子进程elseif(_id0){while(1){printf(我是一个子进程我的pid是: %d ,父进程的ppid是: %d .\n,getpid(),getppid());sleep(1);}}elseif(_id0){while(1){printf(我是一个父进程我的pid是: %d ,父进程的ppid是: %d .\n,getpid(),getppid());sleep(1);}}}如上父子进程都拿着各自的_id去执行对应的代码。我们的父进程创建一个子进程默认是将父进程的 PCB 中的数据拷贝一份给子进程并修改一部分内容比如 pid 和 ppid。于是子进程自然而然地就会指向父进程的那段数据与代码于是子进程也会去执行父进程后面的那一段代码相当于有两个进程同时执行代码。这里父进程获取到子进程的 pid并且子进程获取的 id 结果是 0为什么先举一个例子一个父亲可以有很多孩子但是一个孩子只有一个父亲。父亲叫孩子过来必须要叫全名不能只说孩子过来否则就分不清要谁过来了而孩子叫父亲只需要叫父亲过来就好了。父进程必须获得每一个子进程的 pid方便做管理子进程不必获得父进程的 pid因为子进程可以用 ppid。看到这里一定有很多问题没错它确实颠覆了你从 C 到 C 到数据结构以来的代码经验。于是我们有三个问题需要解决这些问题看似都违反了 C 的语言规则。if 和 else 为什么可以同时执行一个局部变量为什么可以同时等于 0 和大于 0一个函数为什么可以有两个返回值1.3 一个函数为什么可以有两个返回值我们首先来解决第三个问题一个函数为什么可以有两个返回值为什么 fork 可以返回两个值首先我想要问你一个问题一个函数到达了 return它的核心功能做完了没有答案是做完了fork 内部也是如此也就是说fork 在 return 之前就已经完成了申请新的 PCB拷贝父 PCB 给子进程子 PCB 放入进程 list甚至放入调度队列中。那么精确到语句层面子进程被创建的时间结点一定在 return 之前于是 fork 内部必然会有两个进程返回两个值。1.4 if 和 else 为什么可以同时执行在之前的认知里if 和 else 永远互斥。但在 Linux 系统编程中这个规则被进程这个维度打破了。我们要明确一个概念代码是共享的但执行流是独立的。正如前面所说当 fork 函数核心逻辑执行完毕时系统里已经存在两个独立的执行流父进程和子进程。父进程拿着 fork 返回的子进程 PID继续向下执行。它遇到了if(_id 0)逻辑判断为真于是进入该分支子进程拿着 fork 返回的 0也继续向下执行。它遇到了if(_id 0)逻辑判断为真于是进入该分支。所以并不是在一个进程里同时执行了两个分支而是两个进程各自执行了属于自己的那个分支。由于它们几乎同时在屏幕上打印才给了你一种代码逻辑被违背的错觉。1.5 一个局部变量为什么可以同时等于 0 和大于 0这可能是最令你崩溃的地方同一个变量_id怎么可能既是 0 又是进程号前面说了代码数据是共享的为什么一个值既等于 0 又大于 0这里涉及到了 Linux 内存管理的核心技术 —— 写时拷贝。初始状态fork 刚完成时子进程的页表指向的物理内存和父进程是同一块。也就是说它们最初确实共享同一个_id的内存空间发生写入当 fork 准备返回结果并写入_id变量时操作系统发现有两个进程尝试操作这块内存触发拷贝为了保证进程的独立性互不干扰操作系统会为子进程重新开辟一块物理空间把父进程的内容拷贝过去然后修改子进程页表的映射关系。你在父进程里看_id它映射的是一块存着子进程 PID 的物理内存你在子进程里看_id它映射的是另一块存着 0 的物理内存。由上我们其实还能得出一个结论进程具有独立性。两个进程的 PCB 数据结构是独立的代码不可修改数据又有写时拷贝护着。二、操作系统的进程状态如下图精辟地总结了课本上提到的进程状态确实有些复杂。不过这张图讲解的是操作系统的进程状态的总理论而我们今天要讲的 Linux 进程状态是一款具体的操作系统。三、Linux 中的进程状态3.1 进程的状态有哪些为了弄明白正在运行的进程是什么意思我们需要知道进程的不同状态。一个进程可以有几个状态在 Linux 内核里进程有时候也叫做任务。下面的状态在 kernel 源代码里定义/* * The task state array is a strange bitmap of reasons to sleep. * Thus running is zero, and you can test for combinations of * others with simple bit tests. */staticconstchar*consttask_state_array[]{R (running),/*0 */S (sleeping),/*1 */D (disk sleep),/*2 */T (stopped),/*4 */t (tracing stop),/*8 */X (dead),/*16 */Z (zombie),/*32 */};R 运行状态running并不意味着进程一定在运行中它表明进程要么是在运行中要么在运行队列里S 睡眠状态sleeping意味着进程在等待事件完成这里的睡眠有时候也叫做可中断睡眠interruptible sleepD 磁盘休眠状态Disk sleep有时候也叫不可中断睡眠状态uninterruptible sleep在这个状态的进程通常会等待 IO 的结束T 停止状态stopped可以通过发送 SIGSTOP 信号给进程来停止T进程这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行X 死亡状态dead这个状态只是一个返回状态你不会在任务列表里看到这个状态Z 僵尸状态Zombie当子进程退出时内核会释放该进程所有的代码和数据内存但允许它的进程控制块PCB即task_struct还保留在系统内核中。我们具体一个一个讲。3.2 调度队列 FIFO 算法 进程运行状态R在谈论进程的运行状态之前我需要先讲一讲进程的调度队列我们上一篇中讲到了进程是由 PCB 代码与数据构成的进程的 PCB 里面有一个指针指向下一个 PCB于是就形成了全局进程链表。我们这里讲的队列和全局链表非常相似为什么它既是链表又是队列我们后面会讲。先抛出一个结论“进程在调度队列里面 进程处于运行状态”。通过进程的调度队列我们也得到一个名词“进程的 FIFO 算法”——进程按照队列的规则先进先出依次被 CPU 调用执行。3.3 硬件的设备建模 进程的阻塞状态1. 硬件的设备建模我们的计算机它的摄像头、麦克风、键盘鼠标、显示器以及磁盘网卡等等一系列硬件都是操作系统通过硬件驱动调用的。然而硬件驱动调用硬件不是直接访问硬件本身实际上其内部也经历了先描述再组织的过程操作系统将它们的数据封装成为一个结构体然后将它们以某种数据结构组织在一起。2. 阻塞状态每一个结点都有一个等待队列。输出一个结论进程在等待队列中的时候就是阻塞状态。具体来说当一个进程正在运行但它发起的某个请求通常是 I/O 请求暂时无法得到满足或者必须等待某个事件如磁盘数据读取、网卡数据到达发生时操作系统会剥夺该进程的 CPU 使用权将其从运行队列Run Queue中移出。然后该进程就会被链入对应设备的等待队列当中等待设备就绪。什么是就绪这很简单我们的键盘scanf的时候你不输入就是在等待、磁盘读入文件还没准备好给你读这些都是设备就绪。3.4 交换分区 进程挂起状态Linux 中没有描述挂起首先你要知道这是一种非常极端的情况一般不会发生。什么时候会发生呢你的操作系统认识到内存资源严重不足的时候。1. 交换分区交换分区我们首先需要了解的概念。它是磁盘中的一个概念在磁盘中有这么一块临时区域用来临时存放从内存中交换过来的代码。2. 挂起状态那么当程序挂起就是操作系统将程序的代码和数据从内存中提取出来挂在这个交换分区当中以此来腾出空间给其他进程使用缓解内存压力这个过程中 PCB 不会动。那么谁会被优先带走呢答案是正在阻塞的进程。它们不着急使用 CPU 资源所以先挂起。在极端中的极端情况有可能运行队列中靠后的一些进程的代码和数据也会被挂起来当然这很少见。3.5 进程的状态变化从运行到阻塞从阻塞到挂起从运行到挂起以及它们所有的反方向在之前我们全部讲完了。现在给一张总结图四、深入理解内核链表4.1 内核链表不是传统链表我们 Linux 中的一个个 PCB它们被链接起来形成一个链表这没有错但是操作系统比我们想的复杂多了。Linux 内核使用的链表不是传统的链表传统的链表我们可以画一个潦草的示意图一个带头循环双向链表每一个结点里面都会有两个struct Node*指针指向上一个/下一个结点。那么 Linux 的内核链表呢试想一下把前后指针独立封装一下让它和你的数据解耦会发生什么这就是 Linux 内核链表的想法。structlist_head{structlist_head*next,*prev;};structtask_struct{intx,y,z;structlist_headlinks;...};也即是说现在这个链表本身依赖的不是指针而是这样一个连接器结构体它来帮助前后 PCB 建立联系。4.2 为什么内核链表要这么设计1. 更方便的偏移量计算 —— offsetof 宏现在如果你手里只有一个指向links成员的指针想要拿到整个task_struct的首地址你就必须知道links在这个结构体里往后挪了多少距离。结构体首地址 当前地址 − 偏移量有一种非常通用的计算方式offset (((struct task_struct *)0)-links)首先人为设定起点将 0 强制转换为结构体指针相当于在内存地址 0 的位置虚拟出一个结构体算位置访问成员links并取地址编译器会根据内存对齐规则算出它相对于起点即地址 0的距离空位即偏移因为起点是 0所以得到的成员地址在数值上就精准等于它在结构体里的偏移量。C 自带一个宏可以直接获取这个偏移量上面的讲解就是它的底层。首先包含头文件#include stddef.h第一个参数填结构体类型第二个填成员名。size_toffsetoffsetof(structtask_struct,links);2. 物理上在物理内存中一个进程有且仅有一个task_struct进程控制块 PCB实例。这个结构体非常庞大包含了一个进程的所有核心信息PID、状态、优先级等。它就像一个巨大的多功能插线板身上长满了各种不同的接口 —— 这些接口就是内嵌在其中的struct list_head成员变量。3. 逻辑上虽然所有的插口都长在同一个 PCB 上但每个插口连接的都是不同的、相互独立的、没有交集的独立链表。这就是著名的用绳子穿过实体的设计全局链表Tasks List像一根红色的绳子横向穿过每个 PCB 的tasks插口。只要进程存活这条线就永远不解绑内核通过它来遍历系统中的所有进程运行队列Runqueue像一根橙色的绳子只穿过那些状态为就绪Ready或运行Running的 PCB 的run_list插口。调度器Scheduler只在这条线上挑选进程去 CPU 上执行某一条等待队列Wait Queue设备千千万等待队列的蓝线也有千千万。每个硬件设备网卡、键盘、磁盘等都有自己专属的一条等待队列线。4.3 再谈状态变化当一个进程从运行态变成阻塞态例如等待读取磁盘文件时内核绝对不会去搬动或复制这个巨大的 PCB它在幕后只干了两件事解绑断开旧线调用list_del把该 PCB 负责运行队列的run_list钩子从橙线上取下来重连挂上新线调用list_add把该 PCB 负责等待队列的wait_list钩子挂到该磁盘设备特有的那条蓝色等待队列线上。4.4 总结实体唯一内存中只有task_struct一个实体静静地呆着状态改变 关系解绑与重组进程所谓的状态流动物理本质上只是不同颜色的绳子链表指针在 PCB 的不同插口上进行解绑和重新勾连的过程高效率这种设计让进程状态切换变成了极其高效的指针操作O(1) 复杂度不需要任何内存拷贝完美体现了 Linux 内核扁平化且高效的管理哲学。五、运行 R 与睡眠 S 状态5.1 具体看 R 和 S 状态Linux 的 Sleep 就相当于操作系统的阻塞状态。voidFunc_Test_Getpid(){// chdir(/home/ljm); // 将进程的工作目录切换到 /home/ljm// fopen(hello.txt, a); // 此时文件将创建在 /home/ljm 目录while(1){sleep(1);// 获取当前进程的pidprintf(我是一个进程!我的pid: %d,我的ppid: %d\n,getpid(),getppid());}}while:;dopsaxj|head-1psaxj|grepmyprocess|grep-vgrep;sleep1;done为什么我们查到的进程状态都是 S因为我们的进程是一个循环打印假设执行一次是 1 毫秒因为 IO 很慢所以在这毫秒中 99% 的时间是在等待显示屏就绪1% 的时间是拿来打印于是运气不好我们查不出来。voidFunc_Test_Getpid(){while(1){// 不进行任何 IO 操作}}不让他 IO查出来全部都是 R。5.2 前后台进程如果进程在前台那么状态前面就有一个号否则没有。此时输入什么东西都没有用。加上表示当前进程在后台启动可以输入指令。./myprocess那么如何杀死呢只能通过发送信号。kill-932456六、停止 T 与追踪停止状态 tt表示进程处于正在被追踪而停止的状态。虽然它也属于停止状态但它与大写 T 有细微的语义区别大写 TStopped通常是用户主动发送信号如 CtrlZ让进程停下的小写 tTracing stop专门用于 Debugger调试器环境。当进程被系统追踪比如你在用 gdb 调试时进程在等待调试器发送下一个指令c/s期间状态会显示为小写的 t。关于 19 号信号稍微补充一下信号还没有学在 Linux 信号机制中19 号信号是 SIGSTOP。SIGSTOP 的唯一作用就是强制停止挂起一个进程。当进程接收到这个信号时它的状态会立即转变为 TStopped。你按下 CtrlZ 也是传递这个信号给进程。七、深度睡眠 D7.1 对比一般睡眠状态浅睡眠 S比如有一个scanf在等待阻塞然后我们直接按 CtrlC 就可以 kill 它或者执行指令kill -9 PID杀死这个是信号的知识后面会讲。但是深度睡眠 D 状态怎么都不会被杀死。如何杀掉 D 进程1. 等它自己醒来2. 物理方法重启计算机/断电。7.2 深度睡眠的意义场景一个进程正在向磁盘写入数据。但是磁盘写入数据的速度超级慢相对于进程而言于是进程就要去等待磁盘进入 S 状态。等待过程中可能发生一种非常极端的内存不足情况这个时候 OS 就要开始杀没有用的进程。这个时候可能会误杀这个正在摆烂的进程。于是这个时候磁盘还没有读完数据但是进程已经被干掉了磁盘拿着数据不知道该怎么办于是就只能把数据丢弃了。这个时候用户还不知道于是就丢失了数据。如果这个数据是医院里几千个病人的病历或者是银行的转账信息这是非常危险的。为了防止操作系统发生上述情况一种新的状态被设计出来就像一个免死金牌 —— D 状态不可被杀死。八、僵尸进程8.1 僵尸进程概念在 Linux 系统中进程的生命周期并不是随着代码执行完毕就画上句号的。当一个子进程退出后它会进入一个特殊的过渡状态 —— 僵尸状态Z 状态Zombie。8.2 引入你正走在路上一个人突然从你身边跑过突然摔地上猝死了 —— 此时他虽已失去生命体征但由于死因不明遗体和随身物品对应子进程的task_struct、退出码和信号必须原封不动地保留在原地任何人不得擅自收尸这个在原地等待验尸的阶段就是僵尸状态。直到你报警后警察赶到在现场拍照、翻看口袋、登记完死亡信息并查明死因对应父进程调用waitpid获取退出信息这才挥手让救护车把遗体拉走火化此时死者在世间留下的最后痕迹被彻底抹去这才真正进入了死亡状态X 状态。8.3 僵尸进程的定义与产生原因我们创建子进程的目的本质上是为了让它帮我们完成某种任务。既然是完成任务那么任务的结果如何是成功了、失败了还是异常崩溃了父进程必须得知道。因此当子进程退出时内核会释放该进程所有的代码和数据内存但允许它的进程控制块PCB即task_struct暂时保留在系统内核中。核心结论你的数据和代码可以释放掉但是你的 PCB 不能走保持 Z 状态就是为了让父进程来读取子进程退出时的相关信息。8.4 退出信息存在哪里在 Linux 内核的task_struct结构体中专门有几个字段用来维护进程的退出状态和信息structtask_struct{// ...longexit_state;// 进程的退出状态如 EXIT_ZOMBIE, EXIT_DEADintexit_code;// 退出数字如 exit(0) 中的 0intexit_signal;// 退出信号如果是被信号所杀则记录该信号// ...};当子进程变为僵尸状态时exit_state会被设置为相应的值而父进程通过调用wait()或waitpid()系统调用后面进程等待的时候讲就能从这个task_struct中拿走exit_code和exit_signal。8.5 僵尸进程的危害内存泄漏1. 为什么会引发内存泄漏如果父进程一直处于忙碌状态或者压根不管、不回收、不获取子进程的退出信息那么这个子进程的 Z 状态就会一直存在虽然子进程的用户空间内存被释放了但它的task_struct依然常驻在内核空间中。task_struct本身也是一个结构体对象是要占用物理内存的。如果一个父进程源源不断地创建子进程退出后又从不回收那么内核中的task_struct就会堆积得越来越多从而引发严重的内核内存泄漏你的程序会越用越卡。2. 进程退出了内存泄漏还在吗如果是子进程自身在用户空间申请的内存如malloc随着子进程的退出这部分内存会被操作系统强制回收因此不需要担心由于 Z 状态残留的内核task_struct只要父进程不退出、不回收这部分内核内存就永远存在常驻内存的进程更危险类似服务器后台服务Nginx、Database 等这类需要常年运行的进程如果产生了僵尸子进程会持续蚕食内核内存最终可能导致系统因内存耗尽而崩溃。8.6 观察与模拟验证 Z 状态我们可以编写一段代码来模拟这种子进程先退出父进程一直不管的场景。voidFunc_Test_Process(){pid_t_idfork();if(_id0){printf(创建进程失败\n);}//子进程elseif(_id0){// 子进程只活 5 秒intcount5;while(count--){printf(我是一个子进程我的pid是: %d ,父进程的ppid是: %d .\n,getpid(),getppid());sleep(1);}printf(我是子进程我现在要退出了\n);exit(-1);}elseif(_id0){while(1){printf(我是一个父进程我的pid是: %d ,父进程的ppid是: %d .\n,getpid(),getppid());sleep(1);}}}监控脚本while:;dopsaxj|head-1psaxj|grepmyprocess|grep-vgrep;sleep1;done12056128171281712056pts/312817S10000:00 ./myprocess12817128181281712056pts/312817Z10000:00[myprocess]defunct当子进程结束这 5 秒后你会观察到如下的现象子进程的 STAT 栏从 S睡眠状态变成了 Z僵尸状态子进程的 COMMAND 栏后面多出了一个特殊的标记[myprocess] defunctdefunct 意为死者、不存在的即僵尸进程的标志。至于如何解决僵尸进程我们后面通过waitpid()来解决。九、扩展内核结构体的申请与 SLAB 技术当父进程终于调用waitpid()拿走了退出信息后这个残留的task_struct是怎么被销毁的呢这就涉及到 Linux 内核的高效内存管理机制了。9.1 什么是 SLAB 技术在 Linux 操作系统中类似task_struct、mm_struct、file这样的内核结构体对象在系统运行期间会被频繁地创建如频繁 fork和销毁。如果每次创建都直接向操作系统申请新的物理页销毁时又直接释放掉这种底层的内存申请与释放操作会涉及到大量的内核页表映射和虚实地址转换成本极高极其耗时。为了解决这个问题Linux 内核引入了 SLAB 内存分配器SLAB / SLUB / SLOB技术。9.2 数据结构对象的缓存机制SLAB 技术的本质就是内核级的数据结构对象池缓存回收不等于彻底销毁当一个进程的僵尸状态结束内核要释放其task_struct时并不会直接把这块内存还给物理内存管理系统而是将其状态标记为空闲unused并送入一个 unused 队列或名为 slab 的缓存池当中创建时的极致复用当下次系统又有新的进程被创建需要新的task_struct时内核会直接去这个缓存池里捞一个现成的、被标记为 unused 的内存块出来稍微修改一下内部的属性即可使用。通过这种对象缓存的思路内核规避了频繁申请释放物理内存的开销极大地提高了系统创建和销毁进程的效率。十、孤儿进程Orphan Process在多进程的家族体系中除了子进程先走父进程不管导致的僵尸进程外还存在一种截然相反的特殊场景父进程先于子进程一步退出留下子进程还在孤独地运行。10.1 孤儿进程的定义与产生原因在 Linux 系统中如果一个父进程由于提前执行完毕、异常崩溃或被信号杀掉而退出而它的一个或多个子进程仍在继续运行那么这些失去父进程的子进程就沦为了孤儿进程。10.2 怎么解决**操作系统是一个极其注重安全与稳定的系统绝不允许任何进程在没有监护人的情况下自由游荡。**因为如果这些子进程没人管当它们运行结束退出并进入僵尸状态Z 状态时将永远没有父进程来为它们收尸最终必然会引发内存泄漏。为了解决这个问题Linux 内核设计了一套领养Adoption机制只要父进程先退出操作系统OS就会强制指派 1 号进程领养剩下的所有子进程1 号进程在老版本的 Linux 中是 init 进程而在现代 Linux 发行版如 CentOS 7 及以上、Ubuntu 等中它通常被称为 systemd。10.3 1 号进程systemd1 号进程是 Linux 内核启动后创建的第一个用户空间进程。它是整个系统所有用户进程的老祖宗专门用来帮内核打理登录认证、系统服务管理如处理各种设备和系统初始化问题等。当我们登录操作系统的时候系统就会为我们自动创建一个 bash这个 bash 的创建者就是 1 号进程。systemd 是它的名字它就是操作系统的一部分。正因为它的地位极其特殊由它来接管孤儿进程最合适不过 —— 1 号进程会永远驻留内存并且在孤儿进程退出时会非常尽职尽责地自动调用wait()族函数释放它们占用的内核资源绝不产生内存泄漏。10.4 观察与模拟验证孤儿进程我们可以通过编写一段 C 语言代码来重现父进程活 5 秒后主动退出子进程进入死循环的场景。//孤儿进程代码voidFunc_Test_Process(){pid_t_idfork();if(_id0){printf(创建进程失败\n);}//子进程elseif(_id0){while(1){printf(我是一个子进程我的pid是: %d ,父进程的ppid是: %d .\n,getpid(),getppid());sleep(1);}}elseif(_id0){// 父进程只活 5 秒intcount5;while(count--){printf(我是一个父进程我的pid是: %d ,父进程的ppid是: %d .\n,getpid(),getppid());sleep(1);}printf(我是父进程我现在要退出了\n);exit(-1);}}在前 5 秒子进程的 ppid 为 13894即它的亲生父亲在第 5 秒后父进程准时退出命令行回到了 Shell 提示符处。然而此时子进程依然在疯狂刷屏打印且其 ppid 瞬间变为了 1这证明它已经被 1 号进程 systemd 成功领养。10.5 为什么父进程退出不会变成孤儿有人会问既然父进程退出时子进程还在运行子进程会变成孤儿那父进程退出时它的亲生父亲也就是命令行终端 bash 进程不也还在运行吗父进程怎么没变成孤儿原因很简单父进程退出时它的子进程代码中的 child还没有退出所以子进程成了孤儿。而父进程在退出时它的父亲 bash 一直存活在后台盯着它呢。父进程一旦退出bash 会秒回收这个父进程的退出信息因此父进程会直接被清理干净根本没有机会触发孤儿机制。10.6 前台进程与后台进程的转变注意看你在测试孤儿进程时的终端输入当父进程退出、控制台显示出提示符后无论你疯狂按 CtrlC子进程都完全无动于衷依然在屏幕上刷屏输出这是因为子进程在被 1 号进程领养后其状态发生了一个隐蔽的转变阶段状态标识归属终端控制权键盘 CtrlC屏幕输出能力阶段 1S / R父进程健在前台进程占据终端可以被 CtrlC 终止可以向显示器输出阶段 2S / R被 1 号进程领养后台进程失去终端控制只能用信号强杀依然可以向显示器刷屏输出
阅读完成 · 觉得有帮助?