本篇文章为作者学习Linux时所作总结,可能会有总结不足或疏漏之处,如有发现请各路大神批评指正
什么是进程?
课本上说,进程是程序的一个执行实例,正在执行的程序。对,也不对,我称之为正确的废话。在这里先给出定义:进程 = 内核数据结构 + 代码和数据。为什么这么说呢?请接着往下看。
进程的描述
我们平时所编写的代码,在编译和链接之后都会形成一个xxx.exe的文件(可执行文件)。其实该文件就是磁盘中的一个文件,根据冯诺依曼体系结构,当我们双击该文件(Windows)或者 ./ (Linux)时,该文件在运行之前会被从磁盘加载到内存(相当于拷贝了一份)。就我们先前说的课本上进程的定义,该文件现在已经是一个进程了,在该进程的整个生命周期(创建、就绪、阻塞、运行、终止)内,操作系统会一直管理着它。
而操作系统又是怎么管理进程的呢?其实是通过进程的属性来管理进程的,在操作系统加载进程时,不止是加载可执行程序到内存,为了方便操作系统管理,在加载时,还为进程创建了一种内核级的数据结构,称之为PCB(process control block)。为什么需要有PCB(task_struct)呢?因为OS要管理进程,管理进程就需要先描述、在组织。Linux下的PCB是: task_struct(任务结构体)
每一个task_struct都包含对应进程的所有属性,而所有的task_struct通过struct task_struct* next指针连接在一起,形成了一个名为struct task_struct* tasks_list的单链表,所以对进程的管理工作就变成了对链表的增删查改!!!为什么说进程是一个运行起来的程序?因为进程会被根据task_struct属性,被OS调度器调度,运行的
注意:task_struct结构体跟磁盘没有任何关系,该结构是操作系统内自己定义的类型。
task_ struct内容分类——标示符
标识符是描述进程的唯一标识,用于区别其他进程。我们把这种标识叫做PID,如果我们想要获取某个进程的PID可以通过系统调用getpid()来获取,下图是getpid()的介绍和使用方法
// 获取pid
pid_t id = getpid();
我们若想终止掉某个进程,可以直接ctrl + c,也可以使用系统命令kill,具体格式为kill -9 pid
在linux中用ps命令检索进程的相关信息,所有正在运行的进程都会在 /proc文件夹(内存级文件)里面形成一个子文件夹。如果将正在运行的进程终止,其相应的文件夹也会消失
其实在Linux中,除了提供了getpid()来获取进程的PID,还提供了getppid()来获取父进程的PID。其原型为:pid_t getppid(void);。
我们使用下面代码来测试getppid():
// 获取父进程id
int main()
{
pid_t id = getpid();
pid_t ppid = getppid();
while(1)
printf("hello world, I am a peocess,pid:%d, ppid:%d\n",id,ppid);
return 0;
}
通过多次运行、终止进程,我们发现子进程的pid每次运行都不一样,而父进程的ppid怎么每次都是同一个值啊。其实在Linux系统命令行中,执行命令/程序,本质是bash(请参考bash的手册–在提示符下键入 man bash)进程,创建的子进程,由子进程来执行我们的代码。所以这个ppid为11677的父进程,就是bash!
可是bash进程是怎样创建子进程的呢?下面我们就来聊一聊系统调用fork()函数
fork函数
上图中的返回值说明不太好理解,我们来看一段代码
// 初见fork函数
int main()
{
printf("I am a process, pid:%d, ppid:%d\n",getpid(), getppid());
pid_t id = fork();
printf("I am a 分支! pid:%d, ppid:%d\n",getpid(), getppid());
}
根据运行结果我们可以发现,fork函数调用之后,我们竟然输出了两条printf语句唉,这是为什么呢?结合我们上面提到过的返回值说明,我们可以大胆推测fork函数有两个返回值,这两个返回值分别执行一条printf语句!!也就是说fork之后产生了一个新的进程,而且这个进程跟父进程是连续的!我们再来看一段代码
// fork的返回值
int main()
{
pid_t id = fork();
if(id > 0) // 分别打印 此进程pid、父进程pid、返回值id
printf("我是父进程,pid:%d, ppid:%d, ret id:%d\n",getpid(), getppid(),id);
else if(id == 0)
printf("我是子进程,pid:%d, ppid:%d, ret id:%d\n",getpid(), getppid(),id);
}
咦?if条件判断语句竟然同时成立了,不但没有报错,而且运行的还挺好。这更加证明了这是两个执行流,所以上面我们猜测的fork函数有两个返回值得到了验证!!!请结合运行结果理解。
一般而言这两个进程的代码是共享的,但是数据是各自私有的一份(结合上图理解)。
总结:fork()执行之后会有两个进程,这两个进程是父子关系,在父进程中fork返回新创建子进程的进程ID;在子进程中fork返回0;如果出现错误fork将返回-1。
谈谈创建子进程的过程
我们前面已经知道,每一个进程都有一个与自己相对应的task_struct,而新创建的子进程的task_struct是怎么来的呢?
其实是拷贝父进程的,只不过拷贝之后又调整了子进程的task_struct的部分属性,然后再将task_struct链入到进程列表中,此时,子进程就已经创建完毕了,可以随时被调度了。所以,当执行return语句时,父进程执行一次,子进程也执行一次。这也解释了fork()函数有两个返回值的原因
task_ struct内容分类——状态
进程状态
进程共有五种基本状态,其中新建和终止最好理解。新建状态,进程在新建时需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。终止状态,进程结束,或出现错误,或被系统终止,进入终止状态,无法再执行。今天我们重点谈谈其余的三种状态
在操作系统中,每一个CPU都对应着一个struct runqueue的结构体,其结构体包含着一些基本信息和指向一个进程PCB的指针,多个这样的进程就形成了一个FIFO的调度运行队列(结合上图理解)。通过该队列就可以进行时间片轮转(当进程1时间片到了之后,会被操作系统从运行队列中剥离下来,尾插到进程3的后面,重复此过程直至运行结束)。那么什么是运行状态呢?只要进程在运行队列中,该进程就叫做运行状态。其本质是,我已经准备好了,可以被CPU随时调度。所以也有资料说就绪和运行状态是合二为一的。
我们知道操作系统会管理底层的硬件,那么是如何管理的呢?先描述,在组织!在操作系统中,每一个硬件都对应着一个struct device的结构体,而每个结构体中都包含着一个指针,指向下一个struct device。从而形成一个链表,将对硬件的管理转变成对链表的增删查改!
假设我们的CPU正在执行的代码中有scanf语句,这时操作系统就需要从键盘获取数据,如果键盘数据没有准备好,那么我们的CPU不可能一直卡在scanf处,等待数据输入。此时,该进程就会被从调度队列中剥离下来,插入到等待队列中,让你在键盘的等待队列中等待,当获取到数据之后再把你尾插到调度队列中(结合上图理解)。只要进程在等待队列中,该进程就叫做阻塞状态。其实运行和阻塞的本质就是:让不同的进程,处在不同的队列中!
Linux进程状态
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有多种状态,下面我们来看看 状态在kernel源代码里的定义:
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 */ //僵尸状态
};
先来一段代码
int main()
{
int cnt = 0;
while(1)
printf("hello world, cnt = %d\n",cnt++);
}
上述代码非常简单,就是一个死循环,我们多次运行该程序,并通过命令(ps axj | head -1 && ps axj | grep 可执行文件名)在另一个终端查看该进程的状态,如下图
唉?怎么回事啊,程序不是一直在跑吗?为什么不是R状态而是S状态呢?其实是printf语句一直在做I/O,大家知道做I/O是非常慢的,而CPU的运行速度是非常非常快的,所以上面代码99%的时间一直在做I/O,而做I/O本质上就是等待,所以我们得到了上图中的结果就没有什么奇怪的了。
如果在上述代码中,在printf语句前面加上代码scanf语句,运行代码,此时是什么状态呢?没错,就是S状态!
S状态和D状态都是休眠状态,只不过S状态是浅睡眠(等待键盘等设备),可以被操作系统直接杀死;D状态是深度睡眠(等待磁盘等设备),只有“睡眠结束”,该状态才退出。
T状态有两种情况:一是进程做了非法但不致命的操作,被OS暂停了;二是将正在运行的进程通过[kill -19 进程PID]命令进行暂停,通过[kill -18 进程PID]命令就可以继续运行进程,只不过继续运行之后,该进程由前台进程变为了后台进程。t 状态,当进程被gdb追踪时,断点停下,进程状态为 t
死亡状态与僵尸状态
现在我们谈谈进程死亡的话题。
在此之前,我们先来谈谈进程为什么会被创建?进程创建出来,是为了完成用户的任务的。所以,如果进程退出了,我怎么知道任务完成了没有?如何通过进程的执行结果,告知父进程/操作系统,我的任务完成的怎么样了呢?
举个例子,我们平时写代码时,总是要先写一个main函数,而函数的最后一句代码总是return 0;这句代码的意思是什么呢?其实这句代码就是告诉父进程/操作系统程序执行的结果如何了。返回0,代表程序正常结束;若返回非0,则说明出现错误。
再举一个例子,若你在马路上碰见一个倒地不起的老大爷,你拨打110之后,警察来到现场之后,第一时间应该是封锁现场,然后法医从老大爷身上提取有效信息来判断老大爷是自然死亡还是其他原因死亡,之后才会通知老大爷家属来处理后续的事情。
其实从倒下——通知家属这段时间里,老大爷已经死亡,为什么不能立马抬走呢?因为警察和法医需要给社会和家属一个交代,这里的老大爷就是进程,社会就是操作系统,家属就是父进程。我们把这段时间中进程的状态称为僵尸状态。僵尸之后的进程状态才能称为X状态。
为什么在Linux系统中要设置僵尸状态呢?是为了维护退出信息,方便操纵系统和父进程进行查询!进程退出时,做了三件事情:1.立即释放进程对应的程序信息数据,因为代码不会执行了;2.进程退出时,要有退出信息(进程退出码)保存在自己的task_struct内部;3.管理结构task_struct必须被OS维护起来,方便用户未来进行获取进程退出的信息。
僵尸进程的危害
进程的退出状态必须被维持下去,因为他要告诉父进程,你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就会一直处于Z状态
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB就会一直维护
如果一个父进程创建了很多子进程,但就是不回收,就会造成内存资源的浪费(内存泄漏),因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
孤儿进程
如果说僵尸进程是父在,子退;那么孤儿进程就是父退,子在。
上图表示通过kill命令将父进程21689杀死的前后对比,我们可以清楚的看到父进程死亡之后,其子进程21690的父进程变为了1。这个1是谁呢?我们目前认为是操作系统就可以了。我们把父退,子在,但子进程会被系统自动领养的进程 ,叫做孤儿进程。
task_ struct内容分类——优先级
什么是优先级?优先级是获得某种资源的先后顺序。为什么要有优先级?因为要获取的目标资源比较少!比如说,CPU就一个,而进程却可以有多个,所以就会出现抢夺资源的情况,优先级的作用就在这里得到了体现。在Linux中进程的优先级属性,就是几个特定的int变量,保存在task_struct中,优先级数字越小,代表优先级越高。
上图是我们通过ps -al命令查看正在运行的进程的相关信息(图中的各项数据注释的很清楚)。
我们再来看一张图,上图清楚的说明了nice的取值范围为[-20, 19],而且每一次的优先级改变都是从PRI为80开始计算的,意思就是每次计算都会对PRI进行重置。
task_ struct内容分类——上下文数据
想要谈上下文数据,就离不开进程切换。
进程切换
再谈进程切换之前,先补充一下知识储备:
1.时间片到了,进程就要被切换。
2.Linux是基于时间片,进行调度轮转的
3.一个进程在时间片到了的时候,并不一定跑完了,可以在任何地方被重新调度切换
切换过程
- 程序在运行时,会有很多的临时数据,都被放在CPU的寄存器中保存
- CPU内部的寄存器数据,是进程执行的瞬时状态信息数据,我们把该数据称为上下文数据
- CPU内有很多个寄存器,这些寄存器构成一套寄存器。寄存器 != 寄存器里面的数据,这些数据为上下文数据。
进程切换的核心就是进程上下文数据的保存和恢复。
当编译好的代码和数据被加载到内存,而此刻CPU调度的进程current指针刚好指向进程1,那么PC指针就会指向第一条指令的地址(图中的圈1),接着IR寄存器读取相应的汇编代码(图中的圈2),然后放到控制器中执行(图中的圈3),此时,PC指针不在指向第一条指令的地址,转而指向第二条指令的地址,接着把IR寄存器的数据放在eax寄存器中。循环往复,直至程序结束为止。而图中的10、20、0x10这些数据就叫做当前进程的硬件上下文!
假设当上述进程执行到代码0X10地址处时时间片到了,内存中又来了一个进程2,此时的current指针就指向了进程2,把进程1放回了调度队列,如果不做任何的数据保护,直接就开始在CPU上执行进程2的代码,那么就会把CPU上关于进程1的代码数据覆盖掉!
而当进程2的时间片到了之后,current指针又会指向进程1,这时就会出现一个非常尴尬的事情——我进程1之前的临时数据被覆盖了,我应该从哪里开始执行呢?难道重新从main函数地址开始执行吗?此时,调度就已经出现问题了。
结论:不做保护,是无法完成多进程之间的调度与切换的!
我们再来看看做保护的情况,在进程1时间片到了之后,进程1没有着急走,而是在自己的task_struct内部找了块空间把CPU中临时数据保存起来,然后才回到调度队列。接着进程2开始在CPU执行代码,时间片到了之后,进程2也在自己的task_struct中找了块空间将自己的临时数据保存起来。现在又切回到进程1,进程1不是立即就开始执行自己的代码,而是先将自己之前保存的临时数据恢复出来,然后再继续执行代码,这样就可以调度轮转了。task_struct2同理。
所以上面所说的进程上下文数据的保存和恢复本质就是:切走,将相关寄存器的内容,保存起来;切回,将历史保存的寄存器数据,恢复到寄存器中。所有的进程都要做这个工作。
进程调度
我们之前在进程状态模块所说的FIFO调度队列,只是为了更容易理解,实际上FIFO调度队列根本很少见,只有在特别老的版本的内核才有可能遇见。原因很简单,当用户改变了进程的优先级,FIFO队列怎么处理?所以我们来见识一下Linux下真实的调度算法。ps. 这里我们只是浅浅了解一下,不做深入细致的探讨。
Linux2.6内核进程调度队列
Linux下真实的调度算法是怎样处理优先级的问题的呢?
上图是一个指针数组,共包含140个struct task_struct* 类型的指针,前100个我们不需要考虑,只需考虑后40个。为什么偏偏只考虑40个呢?我们在进程优先级模块曾说过,nice的取值范围是[-20, 19]正好也是40个数字,难道它们之间有什么关联?没错这40个指针就对应nice的取值范围。
我们多多少少都了解过哈希结构,其实这里就是利用哈希桶,将相同优先级的进程映射到同一个指针下面,如下图。
实际上,在内存里面有两个这样的数组,用指针*active(活跃)指向一个数组,用指针expired(过期)指向另外一个数组。而CPU调度只会从active所指的队列里选择进程进行调度,调度有三种情况:1.运行之后退出了(我们不用考虑)、2.不退出,但是时间片到了、3.有新的进程产生了。
对于第2第3种情况的进程,不再链入到active队列,而是链接到expired队列中。所以active队列中的进程只会越来越少,不会增多。也就是说active队列一定会有为空的时候,等active队列为空时,操作系统只需做一件事,就是交换active指针和expired指针的所指向空间的地址。此时,之前的active队列变为了expired队列,expired队列变为了active队列。然后CPU继续进行调度,重复此过程。
怎么样?现在了解了进程的切换与调度,再来看文章开头所说的“进程是程序的一个执行实例,正在执行的程序”有没有更深刻的理解呢?进程在运行的时候,你脑子里有没有一幅关于进程切换调度的动图呢?嘻嘻~~~
由于篇幅太长,进程的相关知识点又特别多,本篇文章就到此结束了,如果你感觉本篇文章对你有所帮助的话,不要忘记三连哟~~~