说到进程,书里经常给出简短的话对其解释:一个运行起来(加载到内存)的程序就是进程
那么为什么呢?在了解操作系统是如何实现进程管理之前,我们先来具体了解进程到底是什么
由上篇博客我们已经知道,任何程序在运行时都需先加载到内存,CPU只与内存进行交互。假如我们现在写了一个程序,而程序的本质就是一个存放在磁盘上的文件。所以当我们要运行程序时,程序首先需要加载到内存中。
此时就有问题了❓
在加载这个文件时,此时会不会有其他文件也在加载中?那要先加载哪一个?同时许多程序在加载时,每个程序要给其调度多少资源?此时内存中已运行结束的程序缓存要不要清除?…
此时此刻那叫一个手忙脚乱,因此我们的管理大师 — 操作系统说:”无所谓,我会出手“
操作系统管理的理念是什么? — 先描述,再组织
所以操作系统首先把所有的进程都描述起来,而为了描述每一个进程,就产生了PCB
每个进程都有其属性,比如:加载的紧急程度、需要调用什么资源、占用多少空间…所以在管理进程时,操作系统会根据每个进程的需求去给其添加相应的属性,然后将其打包起来变成一个个”进程控制块“,又叫PCB。
在windows操作系统上,这些进程控制块直接称为PCB
而在linux操作系统中,这些进程控制块被命名为:task_struct
task_struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
描述完之后,操作系统便利用数据结构对其进行组织。比如通过链表,将每个PCB链接起来
所以到最后,所谓的进程管理,变成了对进程对应的PCB进行相关的管理,再通过数据结构转化为对链表的增删查改。
综上所述,准确来说
进程 = 内核数据结构(task_struct)+进程对应的磁盘代码
✏️借助管道使用 ps axj 指令配合 grep 指令来查看指定进程
我们可以在Linux中写一段死循环的程序,便于我们查看进程
#include
#include
#include
int main()
{
while(1)
{
printf("我是一个进程,我的id是:%d,我的父进程id是%d\n",getpid(),getppid());
sleep(1);
}
return 0;
}
❓当我们中止进程再重新加载,id又会是什么呢?
由图我们可以发现,当我们重新运行程序时,此程序进程的id会与之前不同。因为重新运行也即是重新从磁盘中加载到内存,所以进程的id便会改变。但是我们发现它的父进程id是不变的?那到底是谁来产生的子进程呢?
通过具体查看进程,我们发现,test程序的父进程是bash
,bash
也即是shell外壳。这也证明了在执行指令时,shell为了防止程序有误奔溃,导致自身奔溃,所以安全起见,在运行程序时,shell不会自己去执行指令,而是不断地去派生子进程去执行。所以这也是每个程序进程在不断重新运行时,其父进程保持不变的原因。
认识完子进程父进程,我们是否能够自己手动去创建子进程呢?系统调用接口提供了 fork 让我们实现这个目的
fork 在进程管理中能够起到许多作用,而我们目前作为初步认识进程管理,也不对fork进行过多展开,初识fork即可。
在Linux中我们可以通过指令 man 了解fork的用法
简而言之,fork能够创建子进程✏️我们通过代码验证一下
代码的运行结果发现,printf( )函数我们只写了一次,但是运行结果却调用了2次,正是因为fork执行创建了子进程后,会有两个进程去执行fork( )之后的代码
发现第二行的进程的父进程正是第一行进程的id,所以我们得知第二行执行打印的那个进程,正是我们通过fork创建出来的子进程。然后我们通过查看第一行打印的父进程,发现是bash。因此具体运行的逻辑是:shell派生了子进程去执行fork指令,fork指令又再创建了一个子进程
了解fork函数的用法后浅试了一下,的确能够创建子进程✔️而如图对fork函数的介绍,返回值是
pid_t
,可等价为int
。
而fork的灵魂用法,需要我们细细研究fork的返回值,也即使fork介绍的第二张截图大致意思便是:当子进程创建成功时,会给父进程返回子进程的pid,给子进程返回0;创建失败时,给父进程返回-1
如图程序,我们定义一个变量用于接收fork函数的返回值,函数执行结果我们发现一个十分令人惊奇的现象就是:
- fork函数有两个返回值
- fork函数的两个返回值一定不相同(若成功创建子进程)
一个函数有两个不同的返回值,这与我们之前学习C语言的知识那叫一个与众不同,如此这样利用fork函数便能实现多进程!
综上所述
计算机的日常使用中,会同时运行很多程序,也就意味着会有许多进程加载到内存需要CPU等系统硬件去运行。那么操作系统这位管理大师为了管理好这些进程,则需要根据各种具体情况去做出相对于的管理。于是乎在task_struct(PCB)
便定义了许多进程状态信息,可以理解为是操作系统对进程的一种“标记”,便于操作系统进行管理。
在普适的操作系统上,进程状态有:运行、挂起、阻塞、新建、就绪、等待、死亡;不同书籍对于进程状态的命名可能没有完全一致,但进程状态的原理是相同的。而一些操作系统除了这些基础的进程状态以外,可能还会有其他一些属于自己的定义。
在普遍操作系统层面上,进程状态大致有:运行、挂起、阻塞、新建、就绪、等待、死亡。这些进程状态本质上是为了满足不同的运行场景。今天我们具体介绍其中较难的运行、阻塞、挂起✏️
一般计算机只有一个CPU,但是进程却有很多,所以为了便于管理,操作系统给CPU安排了一个“运行队列”,需要占用CPU的进程就按顺序在运行队列中排队。虽然CPU只有一个,但是CPU又是出了名的快!CPU运行每个进程时都很快,所以那些需要占用CPU资源的进程就得随时准备着,进入CPU的运行队列准备CPU的处理。
举个例子
假如现在你投了简历给腾讯,想要参加面试。而投简历的人非常多,腾讯那边需要筛选以及按顺序安排一面,而你投了简历之后也不知道多久才能收到通知,也不确定简历是否能够通过,所以便开始放松自我享受生活。而有一天腾讯打电话给你说:小伙子你的简历很不错,接下来这几天你等面试官打电话给你然后进行一面!而收到通知的你便开始复习,随时准备迎接面试的到来。
所以,不只是在CPU中正在运行的进程属于运行状态,在运行队列中随时准备着的进程,也属于处在运行状态。除此之外,像你参加面试,不是你本人
跑去公司排队,那样腾讯大楼得被挤爆,而是能够代表你的“你的简历
"在排队。同理,在排队的不是一个个可执行文件,而是带有那些程序属性信息的PBC
在排队。让进程入队列的本质是将该进程的task_struct结构体对象放入运行队列中。
各个进程除了会占用CPU,也可能随时随地会占用外设的资源,但是各个外设的速度却远远比不上CPU的速度。
所以有可能当CPU在运行进程的过程中,进程同时也需要去调用磁盘的资源,但是磁盘等外设对于进程来说也是狼多肉少,并且这些外设的速度对于CPU来说慢得多,所以进程去访问磁盘也需要排队!难不成当在CPU中运行一半的进程需要排队访问磁盘时,CPU会等他?那别人怎么办?
就好比众人在银行排队处理业务,有人业务办一半发现身份证忘记拿了,需要回家去取身份证,难道整条队伍的人还有银行的工作人员全部停下来等那个人去拿身份证吗?
因此,处于运行状态的进程由于发生某些事件而暂时无法继续执行,将此进程的PCB从CPU的运行队列中剥离出来,放入其他等待队列中的情况,便称此进程处于阻塞状态。
进程状态其实并不是什么很高深莫测的东西,进程的状态其实就是进程内部的一种属性,也就是task_struct结构体中的一个变量值,可能这个int变量值为1就代表着运行状态、值为2就代表阻塞状态,3就代表…操作系统根据各种具体的情况做出对应的管理措施,同时给进程标记上不同的状态便于后续管理
我们知道,进程的产生本质上为了对加载到内存的程序的管理,而处于阻塞状态的进程由于需要等待某种资源,所以其对应的代码和数据在短期内并不会被执行,但是其仍然已加载到内存中而内存的空间相比于磁盘空间来说可谓是小巫见大巫,所以当内存空间不足时,操作系统就会将这些处于阻塞状态的进程对应的代码和数据拷贝到磁盘中,然后释放内存中的那一份,从而节省内存空间。
上述这种由于内存空间不足,操作系统将那些正在等待资源,短期内不会运行的进程对应的代码数据放到磁盘以节省内存空间的状态被称为挂起状态
对进程挂起,移动的是进程对应的代码数据,进程的PCB仍处在等待队列中,当进程结束等待转变为其他状态时,操作系统会将其对应的代码数据重新加载到内存中继续运行,其本质上是对内存数据的唤入和唤出。
阻塞不一定挂起,挂起也不一定阻塞。是否挂起取决于内存空间状况,可能进程一新建就被挂起 — 新建挂起、就绪挂起、运行挂起…
上面我们分享的是普遍操作系统中进程的状态,而个别操作系统会有其特别的状态定义,下面我们来学习Linux操作系统下的进程状态。
Linux内核源代码对进程状态的定义:
/*
* 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.
*/
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 */
};
看完Linux中对各个进程状态的定义,可以看到Linux中进程一共有七种状态:运行、睡眠、深度睡眠、暂停、追踪暂停、死亡、僵尸
运行状态也就是进程的PCB处于CPU的运行队列中,我们可以编写一个死循环程序,查看一下此程序的进程状态是什么
STAT表示“状态”,由图可见test程序的进程处于运行状态
当我们在死循环的程序中加入了打印指令,我们发现程序的进程变为睡眠状态
而Linux中的睡眠状态(S)其实就是我们上文提到的阻塞状态
可是为什么左边的窗口程序一直运行着,进程状态却还是睡眠状态呢?
原因在于我们使用ps axj指令查看进程状态时,查看的只是执行指令的那一瞬间那一刻进程的状态,是不会动态变化的。而CPU的速度是远远远远快于其他外设的速度,也就是我们程序中进行的简单计算,CPU计算好后却得等外设将其结果打印出来✏️此程序的进程可能99%的时间是在等待其他硬件资源的调控,只有1%的时间在进行加法运算以及打印,因此当我们每次查询进程时,基本都是处于阻塞状态。
在上文提到,当内存空间不足时,操作系统会将一部分进程挂起来节省内存空间;但是如果挂起了许多进程后,内存空间仍然严重不足以供正在运行的进程使用时,操作系统会主动杀掉某些没有在运行的进程。
有一个进程运行后产生了10万条数据,这些数据是软件中用户的重要信息,而此时进程A就跟磁盘说:磁盘快出来,这10万条数据可重要了,你快点找个合适的空间存进去。磁盘慢悠悠地出来答应了进程A,但是磁盘的速度很慢,10万条数据得存好一会,进程A就在内存中翘起二郎腿等着磁盘把这些数据拷贝进去。这时候,内存中有其他进程正在运行着发现内存空间严重不足,操作系统就着急忙慌地开始管理起来,当操作系统看到在内存即将爆满时,还有个大胖子进程A在内存中没事做不知道在等什么,于是操作系统一气之下就把进程A干掉了。干掉了进程A,磁盘写入一半发现写入失败了,往内存中喊了好几声问进程A怎么回事啊?但是喊了半天却没有任何回应,于是磁盘就稀里糊涂地去做别的事不管了。而当有一天,用户打开软件想要查看数据时发现,所有数据都不见了或是缺失了。这便导致了严重的问题 - 重要的用户数据丢失了
因此为了避免这种情况的发生,Linux设计出了深度睡眠(D)状态,就相当于给某个进程一张免死金牌,让操作系统在不得不杀掉一些进程时不能杀掉这些处于深度睡眠状态的进程。处在深度睡眠状态的进程既不能被用户给杀掉,也不能被操作系统杀掉,只能通过断电或等待进程自己醒来!
注:一般情况下只有在高IO时才会导致进程进入深度睡眠状态
暂停状态,顾名思义就是进程运行到一半暂停了,也属于阻塞状态的一种。我们可以使用kill指令来让一个进程从运行状态变为暂停状态。
kill指令有许多选项,我们目前只需了解部分选项即可
kill -9 -----> KILL (杀掉进程)可用于我们手动将某个进程杀掉
kill -19 ------> STOP(暂停进程)可用于我们手动将某个进程暂停
kill -18 -------> CONT(continue 继续进程)可用于我们手动将某个暂停的进程继续
细心的兄弟可能会注意到:进程一开始处于运行状态是R+,在暂停或继续之后,进程状态的+号便消失了
进程状态后面的+号表示此进程是一个前台进程,若没有+号则表示此进程是后台进程
前台进程我们可以通过Ctrl+c将其终止,也可以用 kill -9 指令将其杀死;
而对于后台进程我们只能通过kill -9指令将其杀死。
追踪暂停状态是一种特殊的暂停状态,顾名思义就是此进程正在被追踪,比如我们使用gdb调试进程
死亡状态即使该进程已经死亡,代表该进程已经不再运行,对应的PCB以及代码数据将会全部被操作系统回收
举个例子
有个人走在大街上突然倒在地上die了,路人看到了便打110和120。而当警察和医生赶到现场时,医生判断此人已经死亡抢救无效了。那么此时难道警察会决定立刻把这人拉走吗,然后通知家属?死的不明不白的要怎么给家属一个交代怎么给社会一个交代呢?所以警察会立刻拉起警戒线,叫法医进行采样等分析,了解清楚死亡的原因,等到真相大白才对其尸体进行下一步处理。
进程也是如此,进程也会死亡,可能是正常运行结束后死亡,也可能是出现bug奔溃…运行进程的目的便是帮使用者完成某些任务,而任务是否完成结果如何总得让人知道吧?
所以,一个进程在死亡后,不能立刻释放其全部资源❌对应的代码和数据可以释放,但是代表该进程属性状态的PCB应该继续保留,等待调用其进程的父进程或操作系统来读取。
进程在死亡后等待父进程或者操作系统来检测或回收PCB的状态,便是僵尸状态
处于僵尸状态的进程便是僵尸进程
我们创建了父子进程后,使用kill指令将子进程杀掉后,由于父进程没有对子进程的退出状态进行检测回收,所以子进程进入了Z状态;子进程不再运行的同时,子进程后面提示了(失效的、不再使用的),此时如果父进程一直不对子进程进行检测回收,那么子进程就会一直处于一种僵尸进程的状态。
僵尸进程危害
我们发现,当子进程变为孤儿进程后,它的父进程id变为1,1便是操作系统所以当进程变为孤儿进程时操作系统就会领养它
此外,进程变为孤儿进程后,也会变成后台进程,只能通过kill指令才能令其进程结束运行
在linux或者unix系统中,用ps –l命令则会类似输出以下几个内容:
我们很容易注意到其中几个重要信息:
-20至19
,一共40个级别,而PRI初始默认为80,所以进程的优先级取值范围为60至99
用top命令查看及其更改已存在进程的nice: