目录
引言
1、进程的概念
PCB概念
task_struct里有什么
进程的本质
2、了解进程
父进程和子进程
如何用代码创建一个子进程
为什么fork要给父子返回各自不同的返回值?
为什么一个函数会返回两次?
为什么一个变量可以既大于零又等于零,导致if和else同时成立?
3、进程状态
运行状态
阻塞状态
挂起状态
Linux的进程状态
1、R
2、S/D
3、T/t
4、Z
5、X
6、特殊状态,孤儿状态
Linux系统的内核链表
结语
引言
通过前面两篇文章的学习,我们已经大致了解了Linux系统相关的基础知识,从这篇文章开始,我们将真正进入操作系统学习的核心部分:对操作系统内核的学习。本文将讲解进程管理系统的基本概念以及如何理解进程状态。
1、进程的概念
PCB概念
当我们在命令行界面执行可执行程序时,对应的文件会从磁盘被加载到内存中,操作系统就必然要对多个被加载到内存中的程序进行管理,操作系统在内核中就会创建一个数据结构类型,也就是一个结构体,用这个结构体将程序的所有属性、代码和数据全部封装起来,除此之外,还会封装两个指针,一个指针指向内存中的程序本身,一个指针指向下一个类似的节点。内存中每有一个程序,操作系统就会创建这样一个节点。
这个数据类型的名称叫做PCB(Process Control Block),中文名为进程控制块,在Linux系统中,这个PCB的具体名称是task_struct,进程的所有属性,都可以直接或间接的通过task_struct找到。
task_struct里有什么
1、标识符:描述进程的唯一标识符,用来区别其他进程。
2、状态:任务状态,退出代码,退出信号等。
3、优先级:相较于其他进程的优先级。
4、程序计数器:程序中即将被执行的下一条指令的地址。
5、内存指针:包含程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
6、上下文数据:进程执行时处理器的寄存器中的数据。
7、I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
进程的本质
进程的本质:进程 = 内核数据结构对象 + 自己的代码和数据。将每个节点通过指针链接起来,这个由节点组成的链表就叫进程列表,对进程的管理,实际上就是对进程列表的增删查改。
2、了解进程
我们历史上执行的所有指令、工具、自己的程序,运行起来全都是进程。我们可以通过在命令行中输入命令来查看当前系统内的所有进程。
//显示当前的所有进程
ps ajx

所有进程都有属于自己的pid,我们也可以通过在程序中使用getpid来获取当前程序的pid,我们通过一个简单的C语言代码来验证一下。
#include<stdio.h>
#include<unistd.h> //getpid被包在这个头文件里
#include<sys/types.h>
int main()
{
printf("我是一个进程,我的pid是:%d\\n", getpid());
return 0;
}

进程也可以通过目录的形式进行查找,所有进程都被存储在根目录中名为proc的目录里,我们可以来打开看一下一个进程文件中都有什么,我再打开一个命令行窗口,将刚刚那个进程的代码改成无限循环。


可以看到,当我们运行程序时,在目录里也能看到对应程序的pid,进去后发现有这么多的文件,今天我们只讲解其中的两个。exe文件,里面存储的是进程对应的可执行文件,cwd里存储的是可执行文件所在的路径。
父进程和子进程
在Linux系统中,所有的进程都是由它的父进程创建的,我们除了可以获取一个文件的pid之外,还可以通过getppid来获取它的父进程pid。我们可以通过对上面代码的简单修改来验证一下。
#include<stdio.h>
#include<unistd.h> //getpid被包在这个头文件里
#include<sys/types.h>
int main()
{
printf("我是一个进程,我的pid是:%d\\n,我的父进程的pid是:%d\\n", getpid(), getppid());
return 0;
}

我们看到我们当前这个程序的ppid是2551612,那这个2551612这个id是谁呢?我们可以在进程表里搜索一下,通过我们之前讲过的命令行基本指令:
ps ajx | head -1 //获取表头; ps ajx | grep 2551612 //搜索2551612进程

我们可以看到,原来我们这个程序的父进程就是我们当前所属的终端 -bash。那么我们能不能自己创建一个子进程呢?当然是可以的。
如何用代码创建一个子进程
想要用代码创建一个子进程,我们就要用到一个系统函数 “fork” ,它的作用就是创建一个子进程,它有两个返回值,它会给父进程返回子进程的pid,给子进程返回一个0,至于为什么会这么返回我们后面再来说,我们先写一个代码来验证一下
#include<stdio.h>
#include<unistd.h> //getpid被包在这个头文件里
#include<sys/types.h>
int main()
{
pid_t p = fork();
if (p != 0)
{
printf("我是一个父进程,我的pid是;%d,我的子进程pid是:%d\\n", getpid(), p);
sleep(1); //让子进程先打印,防止变成孤儿进程
}
else
{
printf("我是一个子进程,我的pid是:%d,我的父进程的pid是:%d\\n", getpid(), getppid());
}
return 0;
}

如此,我们就成功用代码创建出了一个子进程。为什么子进程也可以执行后面的代码呢?因为自己成的task_struct相当于是将父进程拷贝了一份过来,除了表示每个进程的独立性之外的其他属性几乎一样,所以父进程task_struct内的指针指向自己的数据和代码,子进程默认也指向父进程的数据和代码,所以子进程再被调度时就会执行父进程中fork()之后的代码,这一段代码父子进程是共享的。
现在我还要抛出三个疑问:1、为什么fork要给父子返回各自不同的返回值?2、为什么一个函数可以返回两次?3、为什么一个变量可以既等于零又大于零,导致if和else同时成立?接下来我就来依次解答它们。
为什么fork要给父子返回各自不同的返回值?
因为在Linux系统里,父进程 :子进程的比例是1 :n的,所以一定要把子进程的pid返回给父进程,父进程要通过不同的pid来区分不同的子进程,而为了不让返回值一致,并且子进程自己就知道自己的pid,所以给子进程返回时就返回一个零。
为什么一个函数会返回两次?
首先,fork是一个系统内部的函数,在调用这个函数时,fork函数内部会申请新的PCB,拷贝父进程的PCB给子进程,将子进程放入进程列表中,甚至在这个时候子进程已经被放入调度列表里了,也就是运行列表,再根据我们之前讲的,子进程和父进程下方的代码会共享,这个共享甚至包括了fork函数里的return语句!所以fork函数实际上return了两次,子进程return一次,父进程return一次,这就是为什么函数会返回两次的原因。
为什么一个变量可以既大于零又等于零,导致if和else同时成立?
在了解这个问题之前,我们先要明确一个概念,就是不同进程之间是有独立性的,哪怕是父子进程也是一样,之前它们看起来一致是因为子进程里继承了父进程的数据。在表面上,父子进程之间的数据应该是共享的,但是如果父子任何一方进行修改数据,操作系统会把修改的数据在底层拷贝一份,让目标进程修改这个拷贝。这个概念叫做写时拷贝,这时候就体现出父子进程的独立性了,所以父进程和子进程里p的值实际上是不一样的,所以看上去就像是既满足了if又满足了else。
3、进程状态
要讲解Linux系统里的进程状态之前,我们需要先了解一下三种基本的进程状态,分别是运行、阻塞和挂起状态。
运行状态
当一个程序是可执行程序,开始执行后会进入调度队列中,在调度队列中的进程状态都是运行状态。
阻塞状态
在操作系统中,不仅有进程队列,调度队列,电脑链接的各种设备也会有一个设备队列,设备队列也有自己的数据结构类型,在设备节点的数据结构类型中,有一个叫做等待队列的指针,当一个进程需要通过读取设备来获取信息时,就比如说我们在使用scanf或者cin时,就需要用户用键盘输入信息,然后进行读取,在程序已经走到这一步时,这个进程就会被移出调度队列,与设备队列中对应设备的等待队列指针进行链接,等待设备输入或者输出,这个被挂在等待队列下的进程状态就被称为阻塞状态。
当设备被输入了信息时,操作系统就会第一个知道有信息输入,然后去查看对应设备的节点,如果等待队列中有进程正在等待,就将等待队列的该进程重新链接回调度队列的尾部,等该节点被调度时,就会从设备上的内容读到自己的进程中。
挂起状态
当系统的资源严重不足时,剩余空间已经很少了,操作系统就会将一些暂时不会被调度的进程,比如阻塞状态的进程中的代码和数据暂时置换到磁盘上存着,只保留节点本身,这个状态就叫做阻塞挂起状态,如果系统资源真的已经严重不足,它甚至可能将调度队列中靠后位置的进程数据也挂起,等到进程被调用时,再将磁盘上输入它的代码和数据重新加到节点的指针上。这个过程分别被称作唤入和唤出。
Linux的进程状态
1、R
R就是我们上文讲到的最普通的状态,运行状态。
2、S/D
S状态和D状态都叫做睡眠状态,其实就是我们上文所提到过的阻塞状态,不过在Linux系统中我们称其为睡眠状态。其中S为浅睡眠状态,也可以叫做可中断休眠状态,它意味着一个进程可以随时被杀掉。D则为深睡眠状态,又叫不可中断休眠,当进程与磁盘进行关键数据传输,处于高I/O状态时,杀掉进程可能会导致重要数据丢失,所以此时进程会被设为D状态。
3、T/t
暂停状态。T:当一个程序在运行过程中,在命令行中输入ctrl + z可以将程序强制暂停,此时进程的运行状态就为T。t:当一个程序在debug状态下调试时运行到程序中的断点位置时导致暂停,此时运行状态为t。
4、Z
僵尸状态。进程已经停止,但是PCB数据还没被释放时的状态,这种状态存在的目的是,一个进程在退出时,父进程需要获取子进程的退出信息,要知道子进程为什么退出,在子进程已经停止但父进程还么获取退出信息时的状态就叫做僵尸状态。
如果父进程一直不管,不回收,不获取子进程的退出信息,那么Z状态会一直存在,这就是我们之前在学习C/C++中经常提到的内存泄露问题。
5、X
死亡状态。僵尸状态后的状态,退出信息已经被父进程获取,节点信息被彻底释放后的状态就是死亡状态
6、特殊状态,孤儿状态
上面我们说过,一个进程在退出之前要先进入僵尸状态,让父进程回收退出信息,那如果父进程比子进程先退出了怎么办呢?在父子进程关系中,如果父进程先退出,子进程不能没有父进程,子进程要被1号进程领养,这个被领养的进程就叫做孤儿进程。
1号进程其实就是我们的操作系统,在被领养后,这个进程就会变成后台进程,就无法再通过ctrl + c退出了只能用kill指令直接将进程杀掉。我们可以写个代码来验证一下。
kill -9 //+进程pid
#include<stdio.h>
#include<unistd.h> //getpid被包在这个头文件里
#include<sys/types.h>
int main()
{
pid_t p = fork();
if (p != 0)
{
printf("我是一个父进程,我的pid是;%d,我的子进程pid是:%d\\n", getpid(), p);
//去掉sleep(1)
}
else
{
printf("我是一个子进程,我的pid是:%d,我的父进程的pid是:%d\\n", getpid(), getppid());
}
return 0;
}
我们将上文验证父子进程的代码复用一样,把sleep(1)这一句去掉,因为父进程先创建,所以也先结束,此时子进程就变成了孤儿进程。

Linux系统的内核链表
我再补充一个小知识点,相信很多人看到这里,都会有一个疑问:为什么总感觉一个进程的节点好像总是同时被几个链表所使用?这是怎么做到的?这时候我们就要来了解一下Linux系统内核的链表结构了。
在task_struct中,我们上文已经讲到每个节点里都会有指针,改变进程状态实际上都是通过改变指针的走向来做到的,但是task_struct中的指针和我们平常学过的指针用法不一样。系统现在内部定义了一个名为list_head的特殊变量,里面只有两个成员,两个list_head指针,next和prev,每个task_struct节点内都有该类型的变量links,节点与节点之间只有相对应的links之间互相链接,而不是指向整个节点。
这样一来,一个task_struct里就能有多个list_head变量,不同的队列可以指向一个节点中不同的list_head变量,就可以实现一个task_struct同时属于多种数据结构,既可以挂在这个链表上,又可以挂在另一个链表上,本质上就是通过链接不同的list_head变量达到的效果。
结语
这节课带大家深入了解了Linux系统中进程的基本概念、父子进程概念和进程状态,还有拓展内容Linux系统的内核链表,希望通过这篇文章能让大家对Linux系统进程的了解更加深刻。







