进程
- 知识铺垫
- 冯诺依曼体系结构
- 操作系统(OS)
- 进程概念
- 进程的查看
- ps 命令
- 获取进程 pid
- 文件内查看进程
- 终止进程的方式
- kill命令
- 快捷键
- 进程的创建 fork
- fork 返回值问题
- 进程状态
- 运行状态 :R
- 休眠状态:S (可中断)和 D(不可中断)
- 暂停状态:T 和 t(追踪式暂停)
- 死亡状态:X
- 僵尸状态:Z
- 孤儿进程
知识铺垫
冯诺依曼体系结构
- 冯诺依曼提出程序存储执行以及计算机由五个部分组成:输入设备、输出设备、存储器、运算器、控制器。
结构如下:
输入设备:键盘、鼠标、麦克风
输出设备:显示屏、扬声器、打印机
这里的存储器指的是: 内存
输入设备 和 输出设备 统称为外围设备,外围设备交互速度一般是比较慢的;
存储器也被称为内存,相较于外设数据的访问速度是快得多。最明显的例子:磁盘 和 内存;
中央处理器(CPU)访问数据的速度是最快的。
在数据层面上,中央处理器(CPU)是不会对输入设备和输出设备进行直接交互的,而是直接对内存进行交互;而外设只会对内存进行打交道。
要交互也不是不行,但是整体的效率就会以外设为主了。为什么这么说呢?
如果将外设之间与CPU直接进行数据交互,我们都知道CPU的计算速度是很快的,从外设中获取数据时,得让外设处理完成后才能得到数据,那么就会出现木桶效应。所以说CPU直接对外设进行交互的话整体数据的访问速率就会以外设为主了。
因为有了内存的存在,我们可以对数据进行预加载处理:CPU在对数据进行计算的时候,可以直接在内存中获取数据,就不需要访问外设了。从而提高了数据计算的效率。
操作系统(OS)
- 操作系统:一款对软硬件做管理的软件
操作系统包括有:内核 和 其他程序
内核:进程管理,内存管理,文件管理,驱动管理
其他程序:函数库, shell程序
设计操作系统的目的:下至与硬件进行交互,管理所有的软硬件资源;上至为用户程序提供一个良好的执行环境。
操作系统是一款纯正搞管理的软件。为什么这么说呢?
管理者在管理一部分事务时,管理者和被管理者其实是没有之间进行沟通的。
例如:校长在管理一所偌的大学校时,校长会直接对每位学生进行管理吗?并不会,如果校长针对每位学生都做管理的话,那么他将忙的不可开交。那么校长是如何管理好一所学校的呢?他只需要将他需要管理的方式下达给主任,然后主任在将需要管理的方式在下达到辅导员,每个辅导员又会将信息转达到班长上,最后每位学生都会接收到对应的要求。之后辅导员会将每位学生的被管理的信息汇总起来,传达给主任,然后各位主任会将所有学生的信息都交到校长手上。至此,校长就对学校内的所有学生信息进行处理,将每个学生描述成一个对象,将这些对象用链表组织连接起来形成一个表格,对整个表格的学生信息进行增删查改,从而起到管理者管理被管理者的效果。
操作系统的管理也是如此,对软硬件做管理时,OS并不会直接对这些软硬件进行管理做处理,会先将这些软硬件先用struct
结构体进行属性封装描述成一个个对象,然后对这些结构体对象用链表或更高效的数据结构组织起来进行管理。
可以说操作系统中管理的本质就是:先描述,再组织。
-
系统调用:在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分
由操作系统提供的接口叫做系统调用。 -
库函数:开发者对部分系统调用进行适度封装,从而形成库
有了上述知识铺垫那么就很容易理解进程了。
进程概念
可执行文件的本质其实是一个普通的二进制文件。文件存储在磁盘当中(文件 = 文件内容 + 文件属性)。
当在Windows下对一个可执行文件进行双击,又或者在Linux命令行下进行 ./
执行可执行文件时,其实就是将文件的文件内容加载到内存中。
现在来分析一句话:将磁盘中的可执行文件内容加载到内存,这个可执行文件的就是被操作系统管理的进程。这句话准确吗?
诸如,一个外国人进入不属于他的国家中,那么就可以说这个人就是这个国家的公民吗?
这个说法是不对的。一个合法的公民需要有这个国家的公民身份证,接受这个国家的法律约束等等。满足这个国家对这个人的管束才能说这个人是属于这个国家的公民。
进程也是如此,当一个可执行文件的代码被加载到内存中时,需要被操作系统管理起来,才能被称为进程。
当然这样说进程概念还是不够具体的。
加载到内存中的可执行文件并不是只有一个,会有许许多多的二进制文件被加载到内存中。面对内存中如此多且杂乱的程序,操作系统该如何去管理呢?
对于进程的管理,OS当然是进行:先描述,再组织。
- 描述:在每个可执行程序加载到内存时,在OS内核里面,会对这些程序的数据或代码创建一个数据结构对象
PCB
,PCB
在Linux操作系统中的名称为:task_struct
。这些PCB
对象中会提取进程中的所有属性,在PCB
的结构体中保存有指向该进程的地址,方便找到进程在内存中的位置。每个进程都有属于自己的PCB
,这就是OS对进程描述的过程。
- 组织:每个
PCB
都有指向下一个PCB
的结构体指针,诸多PCB
之间就可以通过链表的方式组织起来。OS通过管理这些PCB
从而达到间接管理到各个进程的效果。结束一个进程就好比,在PCB
链表中找到对应PCB
进程属性节点,然后对该节点进行释放删除的操作,对每个进程的管理就变成了对PCB
链表的 增删查改。
因此进程概念应该是这样的:
- 进程:内核关于进程的数据结构加上当前进程的代码和数据
进程的查看
ps 命令
ps ajx #查看当前机器下所有正在运行的进程
提示:在使用 ps
查看进程命令时应该和 grep
过滤行代码进行配合,方便我们查找进程的信息。
在这里简单介绍一下:pid
和 ppid
pid:当前进程的进程编号
ppid:当前进程的父进程编号
获取进程 pid
在C语言中,可以通过 getpid()
来获取 pid
编号;通过 getppid()
来获取 ppid
编号。
在使用这些函数前需要包含库函数:#include <unistd.h>
下面来举个例子:
编译运行:
运用 ps 命令来查看一下对应的进程状态:
ps ajx | head -1 && ps ajx | grep 2964 | grep -v grep
上述命令行中,grep也是一个进程,在同时输入ps命令和grep命令时会将grep进程也显示到终端,因此在这里需要将grep进程过滤掉:
仔细观察上述代码,在创建的./myporcess
程序中,出现一个 ppid
编号为:29043 的父进程( bash 进程)。
为什么会出现 bash 进程呢?
bash
是一个命令处理器,能够处理用户输入命令的进程。每当用户输入一个命令时,都是在 bash
进程中创建一个子进程
这也是为什么新创建程序的中会存在 bash 进程的原因。
文件内查看进程
在根目录下存在一个叫 proc 的目录,该目录是保存进程相关属性的目录,属于内存级别的目录,只有当OS启动的时候该目录才会存在。
ll /proc
查看 proc 目录,可以看到很多蓝色数字编号的目录,这些编号正是操作系统中正在运行的进程编号 pid。
还是上述代码为例子,当新运行起来一个程序时在 proc目录下就会创建一个进程的目录:
查看 proc 目录下的 编号为32217的pid目录:
在编号为32217的pid这个文件下就是该进程的所有属性内容了。
当我们终止 pid为32217这个进程后,再次查看 proc 目录下的文件就会发现编号为 32217 的目录不存在了:
终止进程的方式
kill命令
使用 kill 命令不仅可以杀死前台进程,后台进程也可以被杀掉
- 方法一:利用
kill
命令,使用终止信号-9
kill -9 进程编号
- 方法二:
killall
killall 进程名称
快捷键
- 利用快捷键方式:
ctrl + c
进程的创建 fork
通常情况下,在window下双击一个可执行文件 或者 在Linux下 ./
执行一个可执行文件,都是将二进制文件加载到内存,然后被操作系统管理起来,变成一个进程。
有没有一种方式就是手动创建进程呢?答案是可以的。
在Linux下可以通过调用 fork
函数来创建子进程,使用该函数时需要包含 #include<sys/types.h>
头文件。
下面来举个例子,编写这样一串代码:
编译后结果如下:
结果很奇怪,为什么会打印出两行b的结果?
代码是不会骗人的,在代码执行过程中,代码执行过程是顺序执行的。
下面来查对应的 pid 和 ppid ,就可以一目了然:
运行:
第一行b中的 pid:742 指代是 myporcess 进程的编号,可以由打印a的pid推断出来
第二行b中的 pid:743 指代是 myporcess 进程创建的子进程编号,可以由第二行 b 的 ppid :742 推断出来
fork 单词含义为分支的意思,上述代码中在打印a和b之间调用了 fork 函数,此时代码就不会按照原来的线性规则执行,会在调用 fork 函数之后创建一个新的执行流,原来的执行流会继续执行后续代码,新的执行流也会在 fork函数调用完后,继续执行后续代码。这也是终端为什么会打印出两行b的原因。
上述代码只是举例,当然正常情况下 fork 不会去这样使用,会根据 fork 函数的返回值判断进行使用。
下面来介绍一下 fork 函数返回值:
#include <unistd.h>
pid_t fork(void);
当父进程创建成功了后,会将子进程的 pid 返回给父进程,子进程的返回值为0,失败返回-1
还是上述代码,但是进行稍微修改,将fork返回值、地址进行打印显示:
结果如下:
父子进程执行先后顺序是不一定的,不同机器下会有不同结果,这个完全取决于调度器的调度管理。
上面的进程创建是成功的,因此可以看到的结果是:
在第一行b的打印结果中,父进程在fork函数执行后, ret 接收到了子进程的pid 1436
在第二行b的打印结果中,子进程在fork函数执行后, ret 接收到了返回值为:0
但是为什么 ret 的地址会一样呢?甚至奇怪一个函数会有两个返回值。
地址一样是关乎到进程地址空间的话题再后续会讲到这里先不提。不过可以确定的是这里的地址并不是物理上的地址,而是虚拟地址。
首先来简单使用一下 fork 函数的使用场景,再来回答为什么一个函数会有两个返回值的问题。
根据fork 的返回值进行 if 条件判断,限定父子进程执行代码:
父进程执行一次,停顿两秒,子进程执行一次,停顿一秒。运行结果如下:
前面提到过执行流问题问题,在 fork 函数之后会出现两个执行流。
上面代码的也演示了,可以通过 if-else 分支语句来控制执行流。
正常的C语言,是不能同时满足 if 和 else,而且还是不能同时执行两个 while,在这里为什么又可以了呢?
因为,在 fork函数执行后,所创建的子进程会共享同一份代码。
创建进程时,是将磁盘中的文件和数据加载到内存,然后OS通过创建PCB结构体对象来管理这些加载到内存中的数据和代码。PCB结构体中存储的是加载到内存中的数据和代码的属性,记住了是属性。当我们在执行 fork 函数时,就会创建子进程,创建方式就是:将父进程的PCB拷贝一份下来(当然子进程的PCB也有属于自己的私有属性),父进程的PCB指向内存中对应进程的数据和代码,子进程的PCB也是如此,这也是为什么fork 函数之后会公用同一份代码的原因了。
这里的子进程PCB的创建方式只能说是比较笼统介绍一下,具体会更加复杂一些。
每个进程之间是独立的,一个进程的运行是不会影响到另一个进程的运行。
举例:将上面的代码跑起来
此时,再将2066杀掉
可以看到,2065 进程不受影响。
加载到内存中的文件内容是:代码 和 数据。代码是写死的,没有人就是在代码运行时会将代码更改掉,因此每次运行代码时是只读的。那么加载到内存中的数据呢?
下面再来看个问题:同时共享同一份代码,一个进程在运行时修改数据内容时,另一个进程会被影响吗?
在 fork 函数之前创建一个x变量,在父进程打印一次内容后将x值进行修改,运行看看结果:
fork 函数值后,代码是公用的。当有一个执行流尝试更改数据内容时,OS会自动触发一种机制,这个机制叫做写实拷贝。写实拷贝会将原有的数据内容拷贝到父进程或是子进程当中,这样就可以保证各个进程之间的独立性。
fork 返回值问题
一个函数最多一个返回值,为什么 fork 函数可以出现两个返回值?
当一个函数执行到 return 语句的时候可以说该函数的主体功能是已经完成的了。
fork 是一个系统调用的函数,功能是创建一个子进程,将父进程的PCB进行拷贝,然后形成子进程的PCB,当fork函数执行到 的return语句时,说明上述功能就是已经执行完的了。那么就形成了两个执行流:一个是父进程的执行流,另一个是子进程的执行流。但是 fork函数还没有完,还需要执行完最后一句return语句,因此就形成了一个函数会出翔两个return返回值的现象。
进程状态
在讲进程状态前,先来提两个概念:阻塞 和 挂起
- 阻塞:进程因为等待某种条件就绪,从而导致一种不推进的状态(进程等待某种资源就绪的过程)
进程阻塞在用户角度看了就是运行的程序卡住了,进程阻塞就是在等待某种资源就绪。这些资源包括有 硬件资源、系统调用、内存资源、I/O操作等等。资源是有限的,当进程发生阻塞时就是有其他进程正在使用这些资源,只有等资源被其他进程用完后才能轮到自己。
举例:当我们下载一个比较大的文件时,突然有一段时间网断了。此时,CUP会将该进程停止运行,等什么时候网络资源空闲了再来运行该进程。下载的进程就被中断了被称为该进程阻塞,这个阻塞过程就是等待网络资源的过程。
OS是一款搞管理的软件,不仅仅是进程会被管理,硬件也会被管理起来。OS搞管理的本质就是先描述,再组织。
OS为了管理好硬件,会将每一个硬件都创建对应的结构体对象,然后通过数据结构的模型将这些结构体对象链接起来。
每一个硬件对应就是一个结构体对象节点。
在CPU运行的进程不只有一个,当某个进程缺少资源时,CPU会要求该进程先获取对应的资源,此时的OS会将当前进程的PCB链接到对应资源尾部,等待该资源能被自己利用。当资源获取到后,OS会将该进程的PCB链接回CPU继续运行起来。
PCB可以被维护到不同的结构体对象的队列中!!!
- 挂起:操作系统在管理内存时,会将闲置的某个进程的数据和代码暂时性的存放到磁盘中(闲置:表示进程再等待某种资源的过程)。内存中只保存该进程的PCB结构体对象,这个状态叫做挂起状态。
当该进程获取到资源时,操作系统再将该进程的数据和代码加载到内存中,进而可以达到减少内存资源开销的作用。
挂起状态严格意义上被称为阻塞挂起
下面以Linux操作系统进程状态展开分析:
在Linux中,task_struct 表示进程的PCB。task_struct 是一个结构体内部会包含进程的诸多属性,其中就包含有状态属性
struct task_struct
{int status; //状态属性//... 其他属性
}
其中,不同的状态会被定义成不同的宏。例如:
#define R 0
#define S 1
#define D 2
#define T 4
#define t 8
#define X 16
#define Z 32
下面来简单介绍不同的进程状态:
运行状态 :R
进程处于 R 状态时,并不是就在 CPU 正在运行。在 CPU 上运行的进程并不是就只有一个,会有很多进程。
为了调度运行这些进程,操作系统会维护一个叫进程的运行队列 (简化伪代码:struct task_struct* runqueuq )CPU只需要调用该队列中的进程即可。进程在该队列中,那么该进程就被称为运行状态。
所以,看一个进程处于什么状态时,就看这个进程在哪里排队。
举例看一下正在运行的进程的状态:
直接一个死循环,运行查看对应进程状态:
休眠状态:S (可中断)和 D(不可中断)
那么,下面将代码更改一小部分,添加一行打印代码:
运行后,查看一下对应进程状态:
为什么会这样?进程明明就是正在死循环打印。
死循环是没有问题,当我们在往显示屏打印内容的时候就是在访问硬件资源,访问期间该进程就是在等待显示器的资源。OS会将该进程的PCB就会被链接到硬件资源的队列中进行排队,CPU计算速度是很快的,所以该进程的大部分时间都是在等待资源的过程,只有那么一瞬间是被执行的
可以说在上万次查看该进程的状态时,只有那么一瞬间是运行状态。大部分时间都是在等待资源的过程,所以查看进程时的状态会被显示为S+状态。
除了上面例子,最典型的就是使用 scanf 函数时,该进程在为了获取键盘输入资源时,将会卡在终端,等待用户的输入。
所以可以说:休眠状态本质就是一种阻塞状态
- 进程D状态:不可中断的休眠状态
这种状态通常发生在等待磁盘I/O操作完成的进程上,因为进程正在与硬件交互,且这个交互过程不允许被其他进程或中断打断。在此状态下,进程无法被信号中断,很多时候甚至连关机都很难关机,若要强行中断只能是拔电源,但是会造成磁盘数据丢失。
一般 D 状态出现的概率非常非常小,若是电脑出现了D的进程状态,说明该电脑快宕机了,内存吃紧,OS腾不开空间。
暂停状态:T 和 t(追踪式暂停)
-
T状态:将正在运行的进程设置为暂停状态,一般为用户需求。
-
使用 kill 命令,向指定的进程发送 -19 信号(19 号信号为 SIGSTOP 意思表示为暂停一个进程)。
kill -19 进程编号
以代码为例:
编译运行:
当前进程处于 S+ 状态,上面提到过,这里不在赘述。下面将该进程置于暂停状态:
如果要将该进程恢复,那么就可以使用 18 号信号(SIGCONT)让当前进程继续运行
kill -18 进程编号
注意:使用该信号后,会将进程处于后台运行(后台运行的进程会显示为 S
状态,没有+
的符号 )。在没有 +
号状态下的进程我们使用快捷键 Ctrl+c
是杀不了后台进程的,此时只能使用 kill 的 9 号信号才能杀掉。前台运行的进程,都可以使用快捷键 Ctrl + c
杀掉
- t 状态:追踪式暂停状态
最典型的例子就是:在调试代码时,只要在某行代码处设置了断点,那么调试中就不会将所有代码一次性跑完,会卡在断点处。这个暂停方式就是追踪式暂停。
以上面代码为例子,用gdb进入调试状态:
在第 10 行代码打个断点,然后输入 r 命令将代码调试起来:
此时再来查看当前进程状态:
死亡状态:X
进程处于终止状态表示死亡状态,这个状态过程是一瞬间,在进程中很难查看到,终止的一瞬间就结束了。
僵尸状态:Z
- 进程退出码:一个用于表示进程终止原因的数字代码
创建进程是为了计算机能够帮助我们去办事,办事的结果无疑就是两个:完成和未完成。
对于一些进程,我们会寻求他们执行后的结果,也就是事办的怎么样了?
我们编写的代码都是以 main函数为入口,然后完成程序的主体功能,再到 return 语句结束,不过一般我们自己写的main函数的返回值都会设置成返回 0。
其实 main 函数的返回值是进程的退出码。
查看当前进程退出码的方式:
echo $?
在Linux输入的代码也是进程,我们可以尝试输入一条命令后查看进程退出码:
如果我们输入命令时带入错误选项,结果会报错,此时再来查看一下进程的退出码:
进程退出码:2 表示 ls 命令执行后未能执行正常的工作。
进程退出码是为了获取进程工作的结果。如果一个进程退出后直接就是 X 死亡状态,那么父进程或者就是OS能够获得进程退出码吗?
答案是不行的。
- 僵尸状态:在Linux中,一般进程退出后并不会即可退出,它会维持一个状态,这个状态被称为僵尸状态(状态的维持是为了OS或者是父进程去获取当前退出进程的退出码)
举个简单的例子:当一个人正常非正常死亡,警察为了调查。不会立即将尸体交还给家属,会将尸体交给法医去调查死因结果,等到结果出来后才会交给家属处理。
下面用代码来看看进程的僵尸状态,创建子进程:
运行,查看进程状态:
此时将子进程杀掉:
僵尸状态的维持是需要消耗内存的,就好比动态开辟的内存空间,如果使用了内存空间不去释放就会导致内存泄漏。
操作系统为了避免内存泄漏问题,会将僵尸进程进行回收处理,当然我们也可以自己手动回收,回收方式会在后续博客中提及。
孤儿进程
- 孤儿进程:父进程创建子进程,当父进程被提前终止运行,这个子进程就被称为孤儿进程
上面提到过,一个进程的终止并不会直接就被干掉,会将进程结束的退出码返回给父进程。
如果父进程被干掉了,bash进程会来接收父进程的退出码。问题来了,子进程的退出码谁来接收?
下面来举个例子:
父子进程同时运行,当5秒后父进程结束运行,再来查看结果:
下面是进程在第五秒前和第五秒后的进程状态:
可以看到子进程(11673),在父进程结束运行后 ,子进程的 ppid 直接变成了 1 ,进程状态也由 S+ 变成了 S。
进程 1 代表的是 Init 进程,当一个进程结束运行后会进入僵尸状态,这是为了让父进程获取退出码。上面的父进程结束后,子进程变成了孤儿进程,为了能够让子进程在结束后的僵尸状态得到回收,Init (1号)进程就充当了领养孤儿的角色。在子进程退出后,子进程的退出码得到处理,子进程的僵尸状态会被Init 进程回收,从而避免进程僵尸没有及时回收造成的危害。
这篇博客内容就介绍到这里,感谢观看!