目录
一、基本概念
二、查看进程
三、系统调用获取进程标示符
1、获取自己的PID
2、获取父进程的PID
四、创建进程
1、初识fork
2、使用fork的方式
五、进程状态
1、阻塞
2、挂起
3、R状态
4、S状态
5、D状态
6、T状态
6.1、kill指令
6.2、暂停进程与继续进程
6.3、杀死进程
7、X && Z 状态
在大多数课本中,对于进程概念的讲解大致是:程序的一个执行实例、正在执行的程序等等。这种说法是不够全面的,现在我来带领大家更加深入细致的了解进程。在正式学习之前,为了大家更好的理解,我先来举一个例子。
如果有一个社会人士,想要成为你们学校的学生,那么他需要满足什么样的条件呢?只需要他本人进入到你们的学校,在学校内活动就可以了吗?这显然是不行的,判断一个人是不是这个学校的学生,依据的是这个人有没有被学校所管理,学校会不会给他排课,给他计学分、发毕业证。
所以同样的,把一个可执行程序变为进程,不仅仅要把该可执行程序加载到内存中,还要让这个可执行程序被操作系统所管理。
我们编写生成的可执行程序本质上是一个普通二进制文件,储存在磁盘之中。当我们使用 ./ 运行该可执行程序时,会先把该文件的代码和数据加载到内存中,做到这一步,就相当于一个社会人士本人进入到了学校之中,但是这样就叫做进程了吗?显然不可能,这些代码和数据还需要被操作系统所管理。那么操作系统如何管理被加载到内存中的数据呢?遵循我们之前文章中提到的六个字原则:先描述,再组织。
每一个进程在加载到内存之时,操作系统会在内核之中创建一个数据结构对象,这个数据结构叫做 PCB(process control block),在Linux操作系统下的PCB是 task_struct 。这些结构体对象提取并填充了对应进程的属性,并且每个结构体对象里都有一个指向其对应代码和数据的指针。这就是先描述的过程。
这样随着多个进程加载到内存中,操作系统内核里也就有了多个描述结构体,这些结构体都叫 PCB,并以特定的数据结构连接起来。这就是再组织的过程。
此后所有对于进程的管理都被转换成了对数据结构PCB的增删查改,这是一个对进程的管理建模的过程。
总结:进程是内核关于进程的相关数据结构与当前进程的代码和数据的结合。很多课本中着重的强调了当前进程的代码和数据的部分,而忽略掉了内核中相关数据结构的部分。
由于 Linux 是使用C语言写的,所以Linux操作系统下的 task_struct 就是结构体。
我们知道文件包括内容和属性,那么在操作系统中,为了管理进程所创建的PCB中的进程属性与磁盘中文件的属性有关联吗?有关联,但是关联不大,有关联的部分包括文件属性中的权限、名称、大小等等,但大部分的属性是没有关联的。
PCB结构体是一种内核数据结构,是由操作系统重起炉灶创建和维护的,里面的进程属性和磁盘文件的属性基本没有关系。它会被装载到RAM(内存)里并且包含着进程的属性信息:
我们先编写一个程序:
并把它编译形成一个可执行文件。此时,我们使用 ./ 来运行这个可执行文件就会自动创建一个进程。
查看进程指令:
ps axj
查看当前所有系统中的进程:
如果我们只想看我们自己的进程,则可以使用 grep 来进行过滤:
ps axj | grep [进程名]
此时就可以看到有一个 myprocess 进程在进行,至于下面第二行中的 grep -color=automyprocess字样,则是因为我们在系统之中查找进程时,由于 grep 文本过滤自己也是一个进程,就会导致自己把自己也给过滤出来了,并且显示在下面,如果不想看到这一行,可以通过指令 grep -v grep 来避免显示。
现在我们再启动一次这个可执行程序,并且观察进程:
这时就可以看到两个进程在进行了。我们观察这两个进程:
可以发现其中一个属性 PID 是不同的,这说明同一个可执行程序被启动了两次,所产生的是两个不同的进程。换言之,把一个可执行程序多次加载到内存中,会存在多个进程。
除了使用 ps 命令查看进程之外,还有一种方式可以查看进程,那就是查看 proc 目录。proc 目录是一个内存级的文件系统,只有当操作系统启动的时候,它才会存在,在磁盘上并不存在 proc 目录。我们可以通过该目录查到内存相关的信息:
这一大堆蓝色的都是目录名称,其中蓝色数字就是系统中特定进程的 PID ,我们进入其中一个以新增进程的 PID 命名的目录,就可以看到所启动的进程相关的属性了:
其中有两个属性我们比较熟悉,一个是可执行程序对应的路径,一个是可执行程序的名称。这就是为什么说管理进程所创建的PCB中的进程属性与磁盘中文件的属性有小部分关联,就体现在这里。
现在我们把两个新增进程终止掉:
Ctrl + c
此时,我们再在 proc 目录中查看以进程 PID 命名的目录时,就会提示进程不存在:
所以 proc 目录里的内容是动态变化的。
获取进程自己的PID的系统调用:
getpid
通过 man 指令来查看 getpid :
通过函数说明,我们得知谁调用这个函数,就获取谁的PID。
现在我们通过编写程序来更加直观的感受一下:
运行生成的可执行程序:
就可以直接看到该进程的PID了。现在我们使用 Ctrl+c 终止该进程,然后再重新运行可执行文件生成新的进程,再来观察一下:
可以看到每次重新启动进程,PID都不一样,这是很正常的,因为每一次进程在加载启动的时候,操作系统就创建PID,PID是操作系统来维护的,线性递增。
获取父进程的PID的系统调用:
getppid
重新编写代码:
运行生成的可执行程序:
可以看到该进程的PID与PPID,现在我们使用 Ctrl+c 来终止该进程,并重新运行可执行程序生成新的进程:
可以发现进程的PID在不断的变化,而PPID却没有变化,那么PPID是谁呢?
我们通过 ps 指令查看该进程:
发现这个父进程其实是 bash ,即命令行解释器。
结论:
那么 bash 为什么要创建子进程来执行程序呢?这是为了防止我们执行的程序发生错误,如果 bash 自己来执行程序,如果程序挂了,那么 bash 也就挂了,这是相当危险的事情。
在以前,我们熟悉的创建进程的方式有两种,第一种是在Windows系统下,我们双击一个 .exe 文件,就创建了一个进程,还有一种是在Linux系统下,我们通过在命令行输入 ./ 来将程序变成进程去运行。
现在我们再来学习一种创建进程的方式,通过系统调用:
fork
我们通过实际操作来学习如何使用 fork :
编辑程序:
运行生成的可执行程序:
发现打印了一行A,却打印了两行B,这是什么原因呢?
我们修改一下程序,输出打印B这一行函数的进程与父进程:
运行可执行程序:
发现这两行进程的PID不同,可以说明这是两个不同的进程,之后又发现第二行进程的PPID,刚好是第一行进程的PID,这说明这两个进程是父子关系。此时我们就完成了创建子进程的操作。那我们如何控制父进程与子进程呢?
通过查找 man 手册,我们来研究一下 fork 函数:
手册说明:fork 的返回值类型是 pid_t(即有符号整数)。进程创建成功,子进程的PID会返回给父进程,0 会返回给子进程。进程创建失败,-1 会被返回给父进程。
我们依旧通过实际操作来理解:
运行可执行程序:
这时观察到了一个奇怪的现象:打印两次 ret 的值不同,为什么一个函数会有两个返回值呢?这两个 ret 的地址相同,说明他们是同一个变量,但是为什么打印出了两个不一样的值呢?
首先我们需要知道 fork 做了什么,进程 = 内核数据结构 + 进程的代码和数据,当我们创建子进程的时候,并不是把代码和数据又拷贝了一份,而是在内核中再创建一个子进程PCB,子进程PCB的大部分属性会以父进程PCB为模板,并把属性信息拷贝进来。
父进程的PCB指向自己的代码和数据,子进程PCB也指向同样的代码和数据。所以 fork 就相当于在内核中创建独立的PCB结构,并让子进程与父进程共享一份代码和数据。
进程在运行的时候是有独立性的,任何一个进程出现故障不会影响其他进程,父子进程运行的时候也是一样的。
代码:代码是只读的,所以进程无法修改代码,也就无法相互影响
数据:当有一个执行流尝试修改数据的时候,OS会自动给当前进程触发一个机制:写时拷贝,简单来说就是在写入的时候,OS会把该数据重新拷贝一份,此时写入、修改就在这个备份上执行,而不会修改原始数据。从而在数据上也能保持无法相互影响。
有了以上的知识储备,我们再来研究 fork 如何拥有两个返回值。我们知道,当一个函数准备执行 return 语句的时候,该函数的主体功能就已经完成了,return 语句不影响函数的功能,仅仅起到返回结果的作用。因此, fork 系统调用函数在执行 return 语句之前,子进程就已经创建完成并已经在进行中了,所以当执行 return 语句返回结果的时候,就要给父进程与子进程各自返回一份结果,即执行了两次。最终返回结果被赋值给变量 ret 的时候,OS自动触发了写时拷贝,分别把结果存入两者的备份空间中。至于为什么打印出来的 ret 的地址是相同的,这与虚拟地址有关,下面会讲。
总结:
一般情况下,我们使用 fork 创建子进程之后通常要用 if 进行分流:
编译运行:
fork之后,执行流会变成两个执行流,谁先运行由调度器决定。父子进程通过 if 分流分别执行不同的代码块。
进程在CPU上运行的时候,并不是一直在运行的,而是一个进程先在CPU上运行一会,再切换另一个进程在CPU上运行一会,不断的切换进程周而复始重复运作的。这叫做基于进程切换的分时操作系统,由于CPU的运行速度非常快,切换速度使人类感觉不到,从而使人们有种进程一直在运行的感觉。而CPU会去调用哪一个进程,是由进程的状态来决定的,一个进程可以有多个状态,我们先来说明两个最为核心的状态:阻塞和挂起。
进程因为等待某种条件就绪,而导致的一种不推进的状态叫做阻塞状态,给人们最直观的感受就是程序卡住了。换句话说,一个进程阻塞,一定是在等待某种所需要的资源就绪的过程。
想象这样一个场景,我们在下载一些资料的时候,如果网断了,CPU还有必要继续调度这个下载进程吗?肯定是没必要了,因为没有意义,此时就会把该进程设置为阻塞状态。那么这个进程是如何等待网络资源就绪的呢?
我们之前讲过,操作系统要管理网卡、磁盘等外设,是一个先描述再组织的过程,操作系统创建多个结构体类型,这里命名为 struct dev ,并把各个外设的属性信息提取填充进来,再用对应的数据结构把他们链接到一起。同样,操作系统管理大量的进程也是一个先描述再组织的过程。
当网络断开时, 需要等待网络资源的进程就会把自己的PCB从CPU的某些特定队列中拿取出来,连接到网卡设备结构体队列的尾部来排队等待网络资源:
此时,再获取到等待的资源之前,该进程不会再被CPU调度。
PCB是可以被维护在不同的队列中的。进程在等待哪种资源,就会被排列到哪种资源的队列中去。再举个例子,当我们在C语言中使用scanf 函数时,运行程序,如果我们不在键盘上输入内容,进程就会处于阻塞状态,并在键盘的结构体中排队等待资源,只有拿到数据时,进程才会再次被CPU调度。
总结:阻塞就是不被CPU调度——一定是因为当前进程需要等待某种资源就绪——一定是进程tesk_struct结构体需要在某种被OS管理的资源下排队。
如果有时候出现了内存资源紧张的情况,而且阻塞进程的PCB被接入到了所需要等待资源的结构体队列中,不被调度。这时,操作系统就会把阻塞进程的代码和数据交换到磁盘中,同时释放其所在内存中占据的空间,从而起到节省内存空间的目的。等到进程所需要的资源就绪的时候,再把该进程的代码和数据加载到内存中,交由CPU调度。
其中把进程的代码和数据由OS暂时性的交换到磁盘中时,称该进程处于挂起状态。全称为阻塞挂起状态。
以下是Linux内核源码中摘抄下来的进程状态:
static const char * const task_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 状态。但是进程是 R 状态并不一定代表其一定在CPU上运行。
一个进程是什么状态,一般也看这个进程在哪里排队。 其中在CPU的运行结构体队列中排队等待调度的进程,都是运行状态,即 R 状态。在其他资源结构体队列中排队的进程,都是阻塞状态。
现在我们写一个程序来学习进程的各种状态:
编译运行:
进程明明在运行,为什么却显示成 S 进制状态呢?
现在我们修改一下代码,把打印函数 printf 注释掉:
此时,进程的状态就是 R 状态了。
出现这种情况的原因是, printf 函数是向外设打印消息,而外设并不是随时准备就绪的,也就是说进程在执行 printf 函数时,需要在外设的结构体队列中排队等待,当外设资源就绪时,该进程才能被CPU调度,变为 R 状态。其他时间都是阻塞状态, S 状态就是一种阻塞状态。
S 状态是休眠状态,本质上是一种阻塞状态。可以按下 Ctrl+c 终止进程。
D 状态也是一种休眠状态,不可被中断休眠。
在一些进程极多、内存压力极大的情况下,OS是有权利杀掉休眠状态的进程以腾出空间保证其他进程正常运行的,这也是十分合理的。
但是在有一种情况下,这种权力变成了不合理,那就是这个休眠的进程正在磁盘区排队,向磁盘存入数据,如果这个时候OS把该进程杀掉了,就会导致磁盘存储数据出错,万一这个数据还特别重要,就会造成非常严重的后果。
为了解决这个问题,就设计出了 D 状态,处于 D 状态的进程无法被OS杀死,甚至在系统中存在 D 状态的进程时,计算机都没有办法正常关机。只有当 D 状态的进程自己苏醒的时候,这个进程才能被结束。
事实上,一般情况下不会出现 D 状态的进程的,D 状态进程一旦出现,就说明磁盘的空间已经非常的紧张,存储速度非常的慢了,需要力保写入数据的进程活着完成任务,长时间内不能被OS杀死。既然OS都已经需要主动杀死休眠的进程并且磁盘资源已经不够了,可见此时内存的情况也好不到哪里去。当系统中出现了一个 D 状态的进程,就离计算机宕机不远了。
T 状态名为暂停状态,也是一种阻塞状态。我们在调试程序时,让程序在断点处停下来,本质上就是让进程暂停!
查看 kill 指令:
kill -l
在这里主要使用编号为 9、18、19 的命令选项,功能分别为 杀死进程、继续进程、暂停进程。
我们先运行进程,并查看进程状态:
观测到的是 S+ 状态,但实际上程序已经运行了,现在使用指令:
kill -19 [进程PID]
此时进程状态就已经变成了 T 。
接着使用指令:
kill -18 [进程PID]
使进程继续进行:
观察到进程状态变回了 S 。
使用 kill 指令恢复进程后,可以发现进程状态从原来的 S+ 变为了 S ,并且使用 Ctrl+c 已经没有办法结束进程了:
进程状态的 "+" 号表示前台运行,没有 "+" 号就表示后台运行, Ctrl+c 只能结束前台运行的进程。
此时我们需要使用指令:
kill -9 [进程PID]
杀掉进程:
一般我们再写 main 函数时,会在最后写一个 return0;,这叫做进程退出码,我们使用以下指令可以查到进程退出码:
echo $?
如果一个子进程结束时,立刻退出,父进程是没有机会拿到退出结果的。所以在Linux中,进程退出时,一般不会立即彻底退出,而是要维持一个 Z 状态,也叫僵尸状态,方便后续父进程读取该子进程的退出结果。
我们编写以下程序:
运行并查看进程:
可以看到父子进程都已经在运行之中,只不过显示出来的是 S+ 状态。
现在我们杀掉子进程,再来观察子进程的状态:
此时,子进程变为 Z 状态,即僵尸状态。
如果我们不去主动回收 Z 状态的进程,那么该进程就会一直存在,操作系统就会一直维护该进程的PCB,占据内存的空间,可以理解为内存泄漏,所以僵尸进程必须要回收,具体回收的方法以后会详细讲解。
关于进程概念的相关内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!