在linux系统中,对于进程管理,文件系统和设备驱动,都是通过先描述再组织的方式进行管理。那么,什么是先描述,什么是后组织呢?
简单来说,先描述的意思就是,将数据,通过结构体进行封装,实现将数据给对象化。后组织,就是将这些对象,通过数据结构(链表,队列,堆栈)进行连接,使其建立联系,实现高效关联。
进程概念
接下来,我们来了解一下,什么叫做进程。在课本上,进程的描述颇为复杂,但是我们在这,就对进程下一个绝对正确的定义:
进程=内核数据机构对象+自己的代码与数据。
在课本上,我们将这个对象称之为PCB(process control block),这个对象就是用来描述进程的属性的。在linux系统中,PCB就是task_struct,这是linux底层用c++封装的一种数据结构,它存放于RAM中。所有运行在系统的进程都以task_struct双链表的形式存放在内核中,因此,我们通过一个进程,理论上可以找到其他的进程。我们需要知道的是,我们历史上执行的所有指令,工具,gcc编译后的程序,都是进程。
所有的进程都存放在/proc/这个目录下,如果我们对这个目录进行ls,我们会看到一堆数字,此时我们再直接执行ps -ajx ,我们也会也会看到一堆数据,其中,PID的数据就是/proc目录下的对应的数字。此时我们再ls -l,我们会看到两个符号链接,一个是exe,一个是cwd。exe,我们在Windows系统中也非常常见了,他就是可执行文件,通过ls -l,我们会看到他后面跟着一个地址,就是指向那个可执行文件。接着是cwd,他的意思是当前工作目录,指向的是进程当前所在的目录。我们通过chdir可以改变这个路径的指向。
在c语言中,我们可以通过fork来创建子进程,fork一种写时拷贝的显著例子。那么,写时拷贝是什么呢?简单来说,当我们在父进程创建一个子进程的时候,理论上我们需要把父进程的资源全部复制一遍给子进程,但是如果父进程很大(几个G)时,这样子会很浪费时间和空间,因此,在只读的情况下,此时子进程和父进程是共用父进程的资源的。当其中一个进程需要改数据,动资源的时候,会触发缺页中断,此时内核才会申请新的空间,把数据进行复制,保证父子进程都有自己的空间。我们举一个生活中的例子:
想象你和同学合写一份报告:
没有 COW:我先复印 50 页纸给你。你拿过去可能一张没改,这 50 页纸就浪费了。
使用 COW:我发给你一个只读链接。
如果你只是看,我们共用这一份云端文档。
一旦你想在某一段加上你的名字,系统瞬间帮你生成一个这页文档的私人副本供你编辑。
这就是写时拷贝的精妙之处了。然后这里,我们还需要注意的是,如果在新创空间之前,我们把父进程给关了,此时原共享的资源就属于子进程了,并且该资源的权限由“只读”变成了“可写”。
接下来我们再来讨论fork的返回值问题,其实其本质也是写实拷贝。当我们运行如下代码时,我们会发现终端会同时处理父子进程的逻辑,为什么fork会返回两个值呢?其实,并不是fork返回了两个值,而是父子进程,各返回了一个值。fork以后,父进程会返回子进程的pid,因为一个父进程可能有多个子进程,它需要知道现在生成的这个子进程是谁。子进程会返回0,因为子进程只有一个父进程,他只需要通过get_ppid()函数就能知道他的父进程是谁,因此返回0就可以。刚 fork 完:父子进程的变量id理论上指向同一个物理地址。写入返回值:内核在返回时,实际上是在向id变量写入数据。因此,在最后返回PID的时候,实际上是两个进程再交互工作。
pid_t id = fork(); if (id < 0) { // 出错 } else if (id == 0) { // 子进程逻辑 } else { // 父进程逻辑 }进程状态
在linux中,进程主要有以下几种状态:
1.R running(运行):代表进程处于运行状态或在在运行队列里,这处决于系统本身。
2.S sleeping(睡眠):通常也会被称之为阻塞。其是一种等待状态,在等待某种事件完成,该状态可以被系统强行中断,其资源此时依然保存在ram中,比如,若此时系统负载过高,系统可能就会强行释放睡眠状态的资源。
3.D Disk sleep(磁盘睡眠):有时候也叫不可中断睡眠状态(uninterruptiblesleep),在这个 状态的进程通常会等待IO的结束,无法用kill -9杀死进程。
4.T stop(停止):相比于睡眠状态,stop状态更多是外部强制中断暂停,比如我在在运行程序时,可以使用ctrl +z 的方法强行中断暂停某个进程,也可以敲SIGSOT进行暂停。此时,再按下fg(前台运行)或者bg(后台运行)即可恢复进程。
5.t tracing stop(可追踪停止):该状态和T状态类型,但是这个状态通常出现在调试阶段,当我们使用gdb等调试软件调试的时候,再通过ps查状态,就能看到小t.
6.Zzombie(僵尸):僵尸状态是进程退出后、彻底消失前的一个过渡状态。只要子进程退出,父进程还在运行但未调用wait读取退出状态,子进程就会成为僵尸进程,导致 PID 等内核资源无法回收(造成内核内存泄漏)。
如果父进程在未收尸的情况下退出,这些僵尸子进程会被1号进程(init/sysemd)领养。1号进程会自动读取它们的退出状态并释放剩余资源,从而清理掉这些僵尸
7.X dead(死亡):就是进程完全结束,但是一般我们看不到这个状态,在僵尸状态以后立刻就是X状态。
ps aux / ps axj我们日常可以使用上述命令查询进程状态。
其实在状态之外,还有一种现象叫做挂起,挂起和阻塞有些不同,挂起指的是当CPU的内存严重不足时,内核会将RAM中的存储给放到磁盘中一个叫swap空间中,此时,他的PCB还在内核的队列中,但是他的资源已经指向了磁盘的一处swap空间。当后续CPU想要运行该进程时,需要先将相关资源从磁盘中再swap回来到RAM中,然后才能开始运行该进程。
每个CPU核心都会存在一个runqueue,这个队列里面会存放那些处于running状态的进程,cpu核心会根据该队列的顺序,依次执行进程。当某个进程进入了S/D状态,那么就会从runqueue脱离出来,转而进入waitqueue队列。因此,进程的变化,表现之一,就是在不同队列中进行流动,实现对相关数据结构的增删查改。
那么,我们现在再回去看一下,linux的PCB中,双链表到底是怎么实现的呢?其实,在task_struct中,双链表并不是直接以prenode和nextnode节点直接连接的,task_truct内部封存了一个struct某一个list_head的结构体,这个对象里面只存prenode和nextnode,为什么要这么做呢?这是为了让某一个task_struct,能隶属于不同的数据结构。比如,我现在一个task_strcut,他可能要在系统的双链表中,也有可能要在运行队列中,如果只有直接使用prenode和nextnode,将会非常痛苦,此时,我们只要往里面再加一个list_head就可以了。有人会问了,这样设计,不会导致手里只有一个list_head的指针,怎么知道它属于哪个 task_truct 呢?这一点,我们就需要回到c语言中结构体的基本知识了。当我提到“内存对齐”,“漂移量”的时候,你应该就懂了。
struct task_struct { struct list_head tasks; // 全局链表的“钩子” struct list_head run_list; // 运行队列的“钩子” struct list_head children; // 子进程链表的“钩子” };