3. 进程概念
目录
1. 冯诺依曼体系结构
2. 操作系统
3. 理解进程的一般思路
4. 查看进程
5. fork初识
6. 进程状态
6.1 一般操作系统
6.2 Linux系统是怎么维护进程状态的
7. 进程优先级
先谈硬件-再谈软件-最后谈进程。
1. 冯诺依曼体系结构
我们常见的计算机(笔记本电脑),不常见的计算机(服务器)都遵守冯诺依曼体系。
我们所认识的计算机,都是由一个个的硬件组成:
- 中央处理器(cpu):含有运算器和控制器
- 运算器:对数据进行数据运算、逻辑运算
- 控制器:对计算机硬件流程进行一定控制
- 输入单元:键盘、鼠标、磁盘、网卡
- 输出单元:显示器、打印机、磁盘、网卡
- 存储器:指的是内存,磁盘是外存。
输入设备和输出设备统称为外设-有的设备纯输入、输出,也有的既是输入也是输出。
上图中5个单元都是独立个体,各个单元用“线”连接,这种线就叫“总线”。其中:
- 内存和cpu之间的线:系统总线
- 内存和输入输出设备之间的线:IO总线
关于这个体系,必须要强调:
- CPU只能对存储器进行读写,不能直接访问输入输出设备。
- 外设要输入或输出数据,也只能写入内存或从内存中读取。
为什么呢?
核心原因在于:外设与CPU之间处理数据的速度差距太大了,如果输入输出设备直接与CPU连接,由于木桶原理,整机效率趋向于外设的效率,cpu处理完了,歇着了,其他两兄弟还忙不停。
这时候引入了内存,内存就像和事佬,催输入输出设备的同时,又能平复cpu的状态,最后整机的效率趋向于内存的效率。输入设备将数据放入内存中时,cpu可能进行其他计算,算完再处理内存中的数据,这样就可以实现硬件间的并行。这是由操作系统完成的,相当于把数据预加载到内存中,cpu直接运算就可以了,不用和外设交互。
这里的存储器是硬件级别的存储空间,正因为有存储器,才能让计算机走进千家万户。
下面我们来看一个具体的例子:
解释从登上qq,和某位朋友发信息聊天,数据的流动过程。(不考虑网络,还没学)
如果传的是一个文件,那么我的输入设备就是磁盘,把文件读取到内存中,然后.....
朋友收到后,点下载,就存到朋友的磁盘中,显示器只是显示一下。
2. 操作系统
在整个计算机软硬件架构中,操作系统的定位是:纯正的搞管理的软件-管理底层的软硬件资源。
计算机层状结构图:
最底层包括各种各样的硬件,硬件上面是驱动,硬件被软件访问需要驱动程序,再往上是操作系统。操作系统之上还有系统调用接口,用系统调用接口封装出来的库、外壳、指令供给用户使用。
系统调用和库函数的关系:上下层的调用和被调用的关系。开发者对部分系统调用进行适度封装,从而形成了库,有利于更上层的用户进行二次开发。
为什么要有操作系统?
- 帮助用户管理好下面的软硬件资源。
- 为了给用户提供一个良好(稳定、高效、安全)的运行环境。
操作系统通过管理好底层的软硬件资源(手段),为用户提供一个良好的执行环境(目的)。
操作系统为什么要提供系统调用接口呢?
操作系统里面有很多各种各样的数据,但是操作系统不相信任何用户,因为群众里面有坏人,操作系统为了保证自己的数据安全,也为了能给用户提供服务,操作系统以接口的方式给用户提供调用的入口,来获取系统内部的数据。(银行对外服务的小窗口)
这个接口是操作系统提供的用C实现的,自己内部的函数调用。这就是系统调用。
所有访问操作系统的行为,都只能通过系统调用完成。
那操作系统是怎么做到可以管理软硬件的呢?
举个例子:
- 最典型的管理者:校长
- 我们就是最典型的被管理者:学生
校长在管理学生时,并没有直接与学生见面,才能管理学生。
所以管理者和被管理者是不需要见面的。
不见面怎么做到管理的呢?
校长只需要知道一个学生的基本信息,比如姓名,学号,电话,家庭住址等等,就可以对学生进行管理,所以见面不是必要的,只要能够获得管理信息,就可以进行管理决策。
管理的本质:通过对数据的管理,达到对人的管理。
校长和学生面都不见,怎么获得学生的数据呢?通过辅导员(执行者)。
那么对应到计算机结构中:
- 校长--管理者--操作系统
- 辅导员--执行者--驱动程序
- 学生--被管理者--各种软硬件资源
学生把自己的各种信息交给辅导员,辅导员再把数据传给校长。
此时校长手里有上万学生的数据,怎么进行高效的管理呢?通过数据结构:
上万个个体,每个人的信息都不一样,但是他们有一个共同点就是学生,他们的信息种类是相同的:每个人都有名字,学号等等。
这时就可以抽象出一个学生结构体,其中的成员就是学生的各种信息,同时包含指向下个学生结构体的指针。用每个学生的信息初始化这个结构体,就得到了上万的结构体对象,通过指针相连,此时校长只需要把这个学生链表管理好就可以了。成功的将对学生的管理工作转化成了对链表的增删查改。
对学生抽象出学生结构体的过程被称为“描述”,把一堆学生结构体连接起来的过程就是“组织”。
先描述一个,再组织一堆。在操作系统中,管理任何对象,最终都可以转化成对某种数据结构的增删查改。
其实语言的学习过程就是为了管理二字做准备的,C++先学类就是学习如何描述,后面的STL等库,容器本质就是各种数据结构,就是在学如何组织。
操作系统是怎么进行进程管理的呢?
先把进程描述起来,再把进程组织起来。
3. 理解进程的一般思路
教材中说:一个已经加载到内存中的程序就是进程,也说正在运行的程序叫做进程。
一个操作系统,不仅仅只能运行一个进程,可以同时运行多个进程。所以操作系统必须将进程管理起来,如何管理进程呢?先描述,再组织!
人类是怎么辨认一个事物或对象的:通过事物的属性认识的。当这个事物的属性足够多,这一堆属性的集合,就是那个事物。
所以任何一个进程,在加载到内存的时候,形成真正的进程时,除了要加载自己的代码和数据之外,操作系统要先创建描述进程(属性)的结构体对象--PCB--process ctrl block--进程控制块。
PCB就是进程属性的集合。(描述)
进程 = 内核PCB数据结构对象 + 磁盘中的可执行程序和数据
所以对进程做管理就是对PCB对象做管理,PCB中包含指针信息,可以让操作系统找到该进程对应的代码。内存中的操作系统中把各个进程的PCB通过某种数据结构连接起来,这就是组织。
此时对进程进行管理就变成了对数据结构进行增删查改。
那么具体的Linux是怎么做的呢?
Linux下的PCB就是 task_struct结构体,里面包含进程的所有属性,组织进程task_struct的方式是双向链表。
4. 查看进程
1.动态运行的所有进程可以通过 ls /proc 系统文件夹查看。
2.其中蓝色的目录就是PID,查看某个具体的进程:
其中cwd:current work dir:当前进程的工作目录,在创建进程时就会创建这个属性,比如这个proc.c:
fopen就会默认在这个工作目录下创建文件,而不是什么别的目录,也不需要自己指定。
3. ps axj 也可以用来查看
ps axj | head -1 可以显示各项进程属性。
最后的COMMAND是指令,也就是什么指令形成的这个进程。
可为什么我们过滤proc过滤出来两个,因为grep本身也是进程,让他过滤proc,就得把proc这个信息传给他,它就有了proc这个信息,所以把自己也滤出来了。(侧面证明指令也是进程)
想去掉也很简单:
想杀掉进程也很简单:kill -9 进程pid
ps的原理就是遍历PCB链表,拿到每个task_struct中的内容,显示出来。
接下来是我人生中第一个系统调用接口:
getpid():获得当前进程id
getppid():获得当前进程父进程的id
printf("pid:%d\n", getpid());printf("ppid:%d\n", getppid());
pid每次运行都会改变,ppid不变。
我们可以发现14970对应的进程正是bash,也就是命令行命令,所以每一条指令或者我们自己的程序都属于 bash 的子进程。bash就是那个用来解释命令行的进程。
不过如果我们重登一次ssh,bash的id就变了
5. fork初识
我们之前创建进程的方式是运行我们的可执行程序,fork是一个函数,而且它也可以创建进程。
fork很特别,fork有两个返回值,一个函数,有两个返回值,这简直闻所未闻。
int main()
{printf("begin:我是一个进程,pid:%d,ppid:%d\n",getpid(), getppid());pid_t id = fork();if(id == 0){while(1){printf("我是子进程,pid:%d,ppid:%d\n", getpid(), getppid());sleep(1);}}else if(id > 0){while(1) { printf("id:%d,我是父进程,pid:%d,ppid:%d\n",id, getpid(), getppid()); sleep(1);}}else{//error}
我们可以发现,根据fork返回值id的不同,两个分支里的while循环在同时跑,这就很抽象。
我们首先发现,这个可执行程序一执行,他自己就是一个进程,有自己的 pid=16735,ppid=14970,这个pid就是自己,ppid就是bash。
接下来,进入父进程循环的id值,也就是fork的返回值,是16736,也就是子进程的pid,这个父进程本身的pid是16735,ppid是14970,它的pid和这个程序的pid一样,ppid和bash一样,说明这个父进程就是可执行程序本身。
子进程的id是0,pid是16736,ppid是16735,也就是父进程。
接下来我们解释几个问题:
1.为什么 fork 要给子进程返回0,给父进程返回子进程的pid
生活中,一个父亲可以有多个孩子,但是一个孩子只有一个父亲;在进程中,父进程可以有多个子进程,所以为了能让父进程区分是哪个子进程,所以要给父进程返回子进程的pid,但是子进程就不用区分父亲是谁,因为有且仅有一个,所以只需要给它一个0标识一下他是子进程就可以了。
2. 一个函数是如何做到返回两次的?如何理解?
fork创建了一个子进程,既然系统中多了一个进程,那么当然要把它管理起来,就有了它的PCB,进程是由PCB和代码数据构成的,但是它自己又没有代码和数据,所以代码他会直接用父进程的。所以,fork后的代码共享。
既然代码都一样,为什么要创建这个子进程呢?直接让父进程干不就好了?创建子进程,当然是为了让父子去干不同的事情,想让他们做不同的事,就得让他们执行不同的代码块!代码虽然一样,但是fork的返回值不一样,父子根据fork返回的id执行同一份代码中不同的部分!
那这是怎么实现的呢?这就不得不谈谈fork的实现了。
我们已经知道了fork是用来创建子进程的,创建的过程如下:
- 创建子进程PCB并填充
- 让子进程和父进程指向相同的代码
- 父子进程都有自己的PCB,可以被CPU调度运行了
- ......
- return ret;
我们发现在最后返回结果之前,就已经创建好子进程了,创建好以后代码就共享了,return ret属于代码内容!所以return ret 也有两个!所以有两个返回值。子进程的代码中返回0,父进程的代码中返回子进程的id,可是这两个不同的id在我们的代码中是用一个变量id接收的。
3. 一个变量id怎么会有不同的内容?如何理解?
我们知道我们的可执行程序本身就是我们所说的父进程。那这个id是什么呢?id
只是一个存储 fork()
返回值的变量,不是父进程的代码,而是运行时的数据。我们前面提到,创建好子进程之后的代码会共享,那数据会不会也共享呢?
进程的核心设计原则是相互隔离,一个进程的崩溃不应影响另一个进程。因为数据可能被修改,不能让父进程和子进程共享同一份数据!如果共享数据,子进程的错误操作(如越界写入)可能破坏父进程的内存。
而子进程没数据是不行的,所以必须拷贝一份父进程的数据,子进程的数据不是共享的,是新拷贝出来的,又因为父进程中有大量的数据,子进程中可能都不会用到,如果全都拷贝会造成空间的浪费。
所以当子进程尝试去修改父进程中的某个数据时,OS就会拷贝给子进程那个数据,这就是数据层面的“写时拷贝”。用多少,给多少。
回归问题,fork在返回时,是往id里写入数据,父进程返回时直接写入id就行了,子进程的话因为试图修改父进程中的id,会发生写时拷贝,最后父子进程的数据id是不同的。
4.那同一个id是怎么做到让我们看到不同的值?涉及到地址空间,现在暂不做表述
5. fork应用例子
bash为了保证解释命令时如果失败了不影响自己,通过创建子进程的方式完成的命令解释。那bash如何创建子进程--->用了fork,子进程去执行命令,他自己继续解释命令。
6. 进程状态
为了弄清楚显示出来的进程都是什么状态,他们在干嘛,我们需要知道每种状态符代表什么意思。一般教材中经常讲到运行、阻塞、挂起。我们先讲一下一般的操作系统学科中提到的状态都是什么意思,再讲讲Linux是怎么具体实现的。
6.1 一般操作系统
运行态:
我们现在有一堆进程,OS要把他们管理起来,也就是管理了一堆PCB,操作系统要把他们放到CPU中去运行,那肯定是有序的,不能乱来,那么操作系统中就有了一个运行队列,运行队列的头指向PCB链表的头,运行队列的尾指向PCB链表的尾,运行队列中这些已经准备好,可以随时被CPU调度的进程的状态,就叫运行态。
所以处于运行队列中的进程就处于运行状态了,并不是真正要在CPU中运行起来才算运行态。也就是说运行状态并不意味着进程一定在运行中,可能是在运行队列里。
问题:一个进程只要把自己放到CPU上开始运行,是不是要一直执行完毕,才把自己放下来?
不是!每一个进程都有一个叫做时间片的概念,操作系统分配给每个可运行进程的一段CPU执行时间,时间片用尽后,进程会被剥夺CPU使用权,放回运行队列,等待下次调度。所以在某个时间段内,所有的进程都会被执行,这种大量把进程放到CPU上然后拿下来的操作称为进程切换。
我们感受不到这种切换是因为实在是太快了,不要用我们对时间的感受去衡量CPU。
阻塞状态:
在等待特定设备的进程,叫做该进程处于阻塞状态。
操作系统是管理软硬件资源的,当然也要把底层的硬件管理起来,描述+组织,每个设备都被不同的进程所访问,比如键盘,准备进行读取以后,我就是不输入,它就一直接收不到,此时这个进程就放在键盘的等待队列中,一直等待设备就绪。
输入后,这个进程就放到运行队列中,进入运行态。每个设备都有一个等待队列,存放那些等待设备就绪的进程。
唤醒:就是把进程放到运行队列中,从阻塞状态变成运行状态。
挂起:
操作系统中管理很多进程,内存容易不足,要保证正常情况下,省出来内存。阻塞或等待状态时,这个进程的代码也没有在运行,处于空闲状态,干占空间,所以只保留PCB就行了,把数据和代码放到磁盘中(换出),用到的时候再拿回来(换入)。
某进程的代码没被使用,放到了磁盘中,这种状态的进程称为挂起。
6.2 Linux系统是怎么维护进程状态的
1. R(running)
这个是有打印内容的进程 :
这个是没打印内容的进程:
我们发现,有打印内容时,进程的状态是S+,没打印内容是的进程状态是R+,但是这个进程确实一直在运行啊,为什么不是运行状态呢?
因为printf涉及到IO设备,显示器,CPU等待显示器准备好的时间是相当长的,对于他来说,告诉显示器打印只需要一瞬间,剩下的就一直在等,CPU运行的那一刹那难以捕捉,不要用我们的感受去感受CPU的速度!
而如果不需要打印的话,那就一直while循环,不需要其他设备参与,当然一直是R状态了。
+号代表这个进程是前台进程,在前台运行时,此时当前渠道不能输入其他指令,可以使用ctrl+c停止进程。./proc & 就是后台运行了,此时只能用kill -9杀进程。
2. S(sleeping)浅度睡眠,可以被唤醒
上面的代码会等待我们输入值,如果不输入,就一直等待,此时是S状态,所以阻塞其实就是S
大部分进程其实都是S,在等待某种资源就绪,bash同理。
3. D(disk sleeping)深度睡眠,是阻塞的一种
有这样一个场景可以说明深度睡眠是干嘛用的:
某一个进程要往磁盘里写1GB数据,机械磁盘写入时,进程需要等待写入结果,成功还是失败了?什么原因失败的?等等这些信息,若OS在内存不足的情况下,发生这个进程就在这干等着,什么都不干,就会杀掉这个进程,此时当磁盘写入失败后,返回结果,本该接收返回信息的进程消失了,那么大部分磁盘会把已经写入的数据丢掉,数据丢失。如果是很重要的数据,会造成严重的后果。
所以进程在等待过程中不能以浅度睡眠S状态等待,需要把自己设为D状态,这样就没人能杀,对于进程来说就是免死金牌,不响应操作系统的任何请求。
4. T(stopped)
kill 的19号SIGSTOP就是暂停命令,这个进程我既不想停掉,也不想删掉,就可以这样把它暂停
这时就可以执行别的命令了,kill -18 进程PID 就可以继续进程了。不过继续后这个进程会变成后台进程。常用于调试状态时,想干个别的,暂停一下调试。
5. t(tracing stop)
调试中遇到断点停住的时候,就是t,也是一种暂停状态。
6. X(dead)终止态
进程终止,回收空间,只是一个返回状态,不会在任务列表里看到这个状态
7. Z(zombie)僵尸态
进程终止时不会直接进入X,先是僵尸态,维持一段时间。
当进程退出,它的父进程没有接收子进程退出返回代码时就会产生僵尸进程。僵尸进程会以终止状态保持在进程表中,并且会一直等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行并且没有读取子进程状态,子进程就会进入Z状态。
我们来创建一个例子:
int main()
{pid_t id = fork();if(id == 0) {int i = 5;while(i) {printf("我是子进程,pid:%d,ppid:%d,n:%d\n", getpid(), getppid(), i); i--;sleep(1);}exit(0);} else if(id > 0){while(1) { printf("我是父进程,pid:%d,ppid:%d\n", getpid(), getppid()); sleep(1);}//父进程没有对子进程做任何事情}
这个例子中子进程会运行5s然后结束,父进程一直运行
我们可以看到,当子进程结束时,31663变成Z,后续子进程会一直让自己处于Z状态,该进程的相关资源尤其是PCB不能被释放,这个进程会被一直占用,一直占用内存不释放。
僵尸状态必须维持下去,因为他要告诉父进程它的状态,可父进程一直不理它,那它就一直那么僵着。造成内存泄漏!如何避免?以后再说。
另一种情况,父进程直接被回收,为什么子进程也没了?
这就要讲讲孤儿进程了。
父进程如果提前退出,子进程后退出,进入Z状态后,找不到该接收状态的父进程,该如何处理呢?之前是父进程在运行,不接受,现在是父进程都不在了,想接收也接受不了,怎么办?
int main()
{pid_t id = fork();if(id == 0) {int i = 500;while(i) {printf("我是子进程,pid:%d,ppid:%d,n:%d\n", getpid(), getppid(), i); i--; sleep(1);}exit(0);} else if(id > 0){int i = 5;while(i--){ printf("我是父进程,pid:%d,ppid:%d\n", getpid(), getppid()); sleep(1);}//父进程没有对子进程做任何事情}
当父进程结束后:
我们发现这个子进程的父进程变成了1号进程,也就是操作系统。操作系统进程成了孤儿进程的父进程。孤儿子进程未来也得去释放,被1号进程领养。bash是领养不了的,因为它没有处理孙子进程的逻辑,它只是创建了父进程。
而且我们发现ctrl+c停不掉这个进程了,只能kill。
所以父进程回收后,子进程被1领养,如果子进程处于Z状态,则马上被1号领养然后回收;如果子进程还在运行,那么会等他运行结束后才会回收。1 号进程的作用是兜底清理,确保没有进程被遗漏。
7. 进程优先级
优先级是什么?
我们之前学过的权限是能不能访问某个进程,而优先级是在能访问的基础上,决定谁先谁后。也就是CPU资源分配的先后顺序,指进程的优先权。
为什么要有优先级?
因为资源是有限的,进程是有很多的,注定了进程之间是竞争关系。操作系统要保证进程之间的良性竞争,所以要确认优先级。如果我们的进程长时间得不到CPU资源,该进程的代码长时间无法得到推进--就会导致进程的饥饿问题。表现出进程长时间无响应,是否关闭。在windows中还是很常见的。
linux中是怎么实现优先级的?
UID:执行者的身份(root是0)
PRI:代表进程可被执行的优先级,其值越小,优先级越高。(priority)
NI:这个进程的nice值,PRI的修正数据
在调整进程优先级时,调整的其实是NI的值,PRI(new) = PRI(old)+ NI
当NI值是负的,PRI就会变小,优先级变高。NI是正的,PRI就会变大,优先级变低。
所以在linux下,调整进程优先级就是调整NI值。
调整方法:
top--r--输入进程PID--输入NI值
Linux不想过多的让用户参与优先级的调整,所以只能在一定范围内调整,nice:[-20, 19],也就是说PRI:[60, 99],就算我们在输入NI时超出范围也没用。
注意:显示的 PRI
列是用户态可见的“动态优先级”,由内核动态计算后导出给用户。
内核调度器的真实设计:
-
总范围:0-139(共140级),分为两部分:
-
0-99:实时进程(
SCHED_FIFO
/SCHED_RR
),优先级绝对高于普通进程。 -
100-139:普通进程(
SCHED_NORMAL
),对应nice
值调整的范围。
-
用户态无需关心内核的完整优先级范围(0-139),只需通过 nice
值调整普通进程的相对优先级。实时进程的优先级(0-99)对普通用户不可见,我们也不关心。
为什么是PRI越小,优先级越高?
在Linux内核的O(1)调度器中,CPU的运行队列(runqueue)的设计采用了两个大小为140的优先级数组(active
和expired
),并通过优先级范围划分实时任务和普通任务。
active
数组存放当前可调度的任务
expired
数组存放时间片耗尽的任务,等待重新分配时间片后转移到active
数组
数组的每个元素是一个链表,存放同一优先级的任务(如所有PRI=20的任务在一个链表中)。每个数组附带一个5*32个比特位的位图(bitmap[5]),每一位表示对应优先级队列是否非空。
当任务的时间片用完时,它会被从active
数组移到expired
数组,并分配新的时间片。当active
数组为空时,直接交换active
和expired
的指针。怎么判空也一样用到位图,如果全为0则空。
这种结构就使得遍历数组时是从最小下标开始的,也就是PRI越小,优先级越高。