一、上期回顾
在我们上周简单了解完冯诺伊曼体系结构和操作系统,知道了外设和CPU之间的数据交流必须要通过内存,操作系统是一个对软硬件资源做管理的软件,本质是对数据做管理,在语言层面就是对数据结构做管理,进行增删查改等操作,所以我们今天学习的进程也是一个要依赖于上面知识的结构。那话不多说,让我们正式学习一下什么是进程吧!
二、进程的概念
进程概念:可执行程序的代码段 ➕ 数据段 ➕ 内核数据结构
- 代码段:我们写的程序代码
- 数据段:运行过程中产生的各种运算数据(全局变量、局部变量、各种常量)
- 内核数据结构:PCB、进程地址空间、页表等
PCB是一个记录可执行程序信息的进程控制块,PCB的本质就是一个结构体,里面存放了关于进程的相关信息。等内存中存在很多的进程时,这些进程的PCB就会通过双向链表链接起来,统一归操作系统管理。(后面有讲)
操作系统只看PCB,后续对进程的操作实际上都是对PCB的操作
进程,顾名思义:进行的程序,也就是
"正在运行"的程序(后面会推翻这个结论)。首先,可执行程序( .exe )一定是一个二进制文件,存放在磁盘中;其次,可执行程序要运行就必须要经过CPU的处理,那就要将可执行程序的代码和数据加载到内存中;既然加载到内存中,那就会被操作系统管理,而操作系统并不会直接管理可执行程序的代码和数据,而是对PCB做管理,因为PCB是存放程序的信息,操作系统只对信息做管理。
这是根据前面学的冯诺伊曼体系和操作系统的知识得出的结论;
那我想问几个问题
1. 操作系统为什么知道它是进程?
因为我们的可执行程序加载到内存时,操作系统不会直接对可执行程序做管理,而是对存放可执行程序的信息的PCB做管理,通过PCB就知道是进程了,所以进程并不是指可执行程序。
这也同时解释一个事实,当我们将程序加载到内存的时候,内存分配的实际空间要比程序自身大,多出来的空间就是要存放这些内核数据结构的。
这里必须要说的一个事实:操作系统要时刻为了效率和资源的使用率负责,所以我们在加载一个程序的时候,并不是把一个程序的全部都加载到内存中,而是先创建自身的PCB,再部分加载程序的代码和数据,经过CPU处理过的内容就不使用了,将它们唤出,再继续唤入程序的其他部分。这也证实了一点:有的程序自身比内存大
2. 运行是什么?
前面说了PCB是记录可执行程序信息的,那操作系统对可执行程序的了解,就是通过PCB。而我们上面讲的进程,说它是正在运行的程序,其实不对,因为在后面我们会知道进程其实是分状态的,操作系统是因为拿到了这个PCB才知道这个程序是处于什么状态,所以进程并不是一定运行的,可能是处于不同的状态。
对于进程来说,运行仅仅是一个状态,进程的状态分为很多种,如运行、阻塞、僵尸、挂起等状态,状态是表示进程在哪个队列中排队,如运行队列,等待资源队列(阻塞)。
3. 什么才算正在运行呢?
只要在运行队列中排队就是运行
因为CPU的速度很快,我们不能用人对时间的观念去比对CPU的速度,CPU在极短的时间里就会完成排队和运行,所以我们就直接认为只要在运行队列排队,就算运行。
总结:操作系统对进程的管理 --> 对PCB的管理 --> 高效数据结构的管理
PCB的补充
PCB是一种内核数据结构,存在于操作系统,归属于操作系统的管理;所以我们用户是无法直接取到PCB里面的信息的,必须通过系统调用接口,而系统调用接口是属于系统软件部分,用户是直接用不了的,我们一般使用 lib 库中的函数来调用系统调用接口 。
由于我们是学习Linux系统的,PCB是操作系统学科的叫法,而在Linux系统下 PCB 被称作 task_struct ,下面我就都会用 task_struct 来替代 PCB
三、task_struct 的内容分类
- 标示符(pid): 描述本进程的唯一标示符,用来区别其他进程;
- 状态:任务状态(运行、阻塞、挂起、僵尸等),退出代码,退出信号等;
- 优先级: 相对于其他进程的优先级;
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的 I / O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息:...
接下来我们会详细去讲解这几个内容
四、进程标识符
进程标识符:pid
- task_struct里面的一个变量
- 每个进程特有的、独一无二的
总结:
- 获取 进程(pid) 的函数:getpid();
- 获取 父进程(ppid) 的函数:getppid();
- 进程的pid都是独一无二的,每次运行程序,都相当于一个新的进程,所以每启动一次程序,该进程的pid就会变化,但父进程ppid是不变的;
- 在命令行运行的命令或程序都是bash的子进程;
- 几乎所有的指令都是程序,运行起来都是进程;
4.1 查看进程标识符:方法一
我们可以使用这条指令去查看所有的进程;
ps -axj
总结1&2:
因为我们的task_struct是属于操作系统管理的,所以用户无法直接拿到操作系统内部的数据,必须要通过系统调用接口,而我们要使用系统调用接口去获取pid和ppid就要用C语言的库函数getpid,getppid;
总结3:
在经过测试会发现,每次进程的创建都是不同的pid,但是ppid都是相同的
测试代码:
#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { while(1) { printf("I am a process pid = %d ppid = %d\n", getpid(), getppid()); sleep(1); } return 0; }
命令行测试指令:(这里mytest你要跟据自己的修改)
while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; done
总结4
我们通过测试可以看到只要在命令行运行的指令或者可执行程序,其父进程都是bash
4.2 查看进程标识符:方法二
Linux系统会将进程的pid作为目录放在/proc目录下,所以我们可以在目录里查看进程的pid
进程被杀死,对应的目录也就消失了
当我们直接删除在磁盘中的可执行程序时,进程是依旧运行的,这是因为我们在将可执行程序从磁盘拷贝了一份放在内存里,删除磁盘中的不会影响内存中的。但是我们会看到exe指向的可执行程序变红了,这表示磁盘的可执行程序不存在了。
这里有一个cwd,指向的是当前进程对应的当前工作目录,这和我们C语言学习的文件指针操作有关,如fopen一个文件,用 ” w “ 的方式,如果文件不存在,就会在当前路径下创建一个,这个当前路径指的就是cwd对应的路径!我们可以做测试去改变进程当前的工作目录,使用函数chdir("路径");
4.3 fork创建子进程(会在进程控制细讲)
简单介绍一下fork,是用来创建子进程的函数
返回值return:
- 父进程:返回值为子进程的pid
- 子进程:返回值为0
- 创建失败:返回值为-1
概念规则:
- fork之后,父子进程代码共享;
- 父进程会将自己的大部分属性给子进程,所以子进程的task_struct会和父进程的大部分一致,但是子进程会有自己的pid和ppid;
- 我们使用fork函数,通常是想让父子进程去做不同的事情,所以会使用if-else语句通过return的不同来让父子进程执行不同的代码,其中代码全部由父进程提供。
- 我们需要了解一个事实:任意进程之间是具有独立性的,互不影响;如我们关闭qq,微信不会受影响;所以当父子进程共享的数据进行修改时,为了保证进程的独立性,操作系统会介入其中,发生写时拷贝,然后修改拷贝的这个数据就行。
问题1:为什么fork的返回值可以不同?
返回值不同是因为在fork内部的时候就已经产生父子进程了,而父子进程会共享后面的代码和数据,所以会分别执行一次return语句。
问题2:为什么同一个变量可以有不同的值?
首先变量名是给用户看的,而计算机看的根本就不是变量名,在形成可执行程序的时候,已经是二进制文件了,变量名这种东西早已经不存在了。计算机本质上看的是这些变量的地址空间,即使名字相同,物理地址空间一定是不同的,这样就可以区分开了。
在这里是用到了我们后面要讲解的进程地址空间,我们可以试着将这个变量的地址打印出来,会发现这个变量的地址居然也是一样的,可是我们上面不是说地址是不同的嘛?注意我们说的是物理地址不同,我们在C/C++中看到的地址都不是物理地址,而是虚拟地址。
每个进程都是有自己的地址空间的,而这个地址空间也是一种内核数据结构,存的是虚拟地址,虚拟地址会通过页表映射到物理地址,每个进程也都有自己的页表。所以变量的不同值,是通过映射管理来的,同一个虚拟地址,但是页表的映射关系不同,这样就可以找到不同值的变量。修改变量值也是一个道理,我们子进程修改一个变量,那就要发生写时拷贝,将修改的值的地址写入到子进程的页表中,这样父进程的映射关系不会改变,他们都可以找到自己对应的值。(后面会在进程地址空间细讲)
五、进程状态
终于来到了进程状态,我们上面说了进程并不是一直运行的,是拥有很多种状态的,下面我们先讲解两个小知识点:
1. 进程不是一直运行的
当我们在.c文件中写了个scanf函数,在运行的时候就卡在显示器文件上不动了,那这是运行么?答案是这不是运行,因为scanf函数是等我们的键盘进行写入操作,跟CPU没有关系。此时的进程是处于一个等待、阻塞这样的状态,等待的是某种软硬件资源,这里就是等待键盘资源的写入操作。
综上所述:进程不是一直运行的,可能等待某种软硬件资源
2. 进程即使放在CPU上,也不会一直运行
在我们的单CPU的计算机中,你的一个程序是死循环,如果一直运行这个死循环,那CPU就已经跑疯了,被占满了,但是此时你的别的进程会受到影响么?
当然不会,此时我们的社交、音乐等软件依旧可以正常使用。所以也就证明了一个死循环是不会一直运行的,这是因为CPU会对进程有时间的限制,这个限制概念称为时间片,当死循环在CPU上运行超过一定的时间片限制,就会自动离开CPU,但是这个进程没有执行结束,会放在一个等待CPU的队列里(最新版的Linux称作“过期队列”),这就涉及时间片的轮转和进程的切换了。
而在我们多个CPU的计算机,进程是平均分配给每个CPU的,几个CPU就有几个运行队列
时间片是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间,在抢占内核中是:从进程开始运行直到被抢占的时间。
现代操作系统(如:Windows、Linux、Mac OS X等)允许同时运行多个进程 —— 例如,你可以在打开音乐播放器听音乐的同时用浏览器浏览网页并下载文件。事实上,虽然一台计算机通常可能有多个CPU,但是同一个CPU永远不可能真正地同时运行多个任务。在只考虑一个CPU的情况下,这些进程“看起来像”同时运行的,实则是轮番穿插地运行,由于时间片通常很短(在Linux上为5ms-800ms),用户不会感觉到。
5.1 进程状态概念和解析
进程状态是进程的一个基本信息,在task_struct这个结构体里定义,本质上就是一个整型变量,通过宏定义来完成赋值。不同的变量值,代表不同的状态,通过改变这个变量的值,来完成不同状态的转换。
进程状态主要讲一下运行状态、阻塞状态、挂起状态
- 运行状态:在CPU的运行队列中排队
- 阻塞状态:进程在等待软硬件资源时,资源没有就绪
- 挂起状态:计算机资源不足时,将进程的代码和数据放入外设的swap分区中,腾出空间
简单思考排队的原因:资源不足
任何的排队都是因为资源不足,如学校食堂吃饭窗口少,我们就需要去排队买饭,如在银行办理业务,我们也需要排队,但是上面会写着军人优先的字样。这样的生活实例就是告诉我们,排队就是因为资源不足,但是排队可以有优先级!(进程优先级讲解)
状态决定了什么?进程的后续动作!
我们的Linux系统可能会存在多个进程,而这些进程都是要根据状态来执行后续动作的,比如运行状态就是要去运行,阻塞状态就是要等待资源,就绪状态就要去排队。
我们的运行状态其实就是在CPU的运行队列中去排队,阻塞状态就是到要等待的资源队列中排队,不同的状态就是去不同的队列中排队。所以我们会发现状态之间的切换,本质上就是操作系统把task_struct连入到不同资源的队列中。
Linux是怎么将task_struct连接到队列的?
Linux下的task_struct里有一个前后指针构成的结构体,其中的指针指向的是其他task_struct 中 的该结构体;
task_struct 默认有一个全局指向关系,方便操作系统对进程的信息做管理;
一个tast_struct是可以拥有多个这样的node结构体,所以当我们的进程都在运行队列中排队的时候,如下图,在其他资源队列中也是同样的道理,那就再来一个node结构体就行。
总结:状态的切换实质上就是操作系统将task_struct连接到不同资源的队列中
5.2 Linux下具体的进程状态
- 前台进程:有➕,进程运行的时候不能执行命令;
- 运行前台进程格式:./可执行程序
- 后台进程:无➕,可以执行命令,无法被ctrl + c,只能 kill 杀死
- 运行后台进程格式:./可执行程序 &
1. 运行状态 R
排在运行队列中就是运行状态
2. 可中断睡眠 S (阻塞状态)
又称“浅度睡眠”,是阻塞状态的一种表现形式,可以用ctrl + c中断进程
3. 不可中断睡眠 D (阻塞状态)
又称“深度睡眠”,也是阻塞状态的一种,通常发生在Linux操作系统严重内存不足的时候,会杀死进程以确保自己系统的存活。所以为了防止有些进程被操作系统杀死,就会给这个进程赋予D状态,但是一旦有D状态的进程,就说明我们的资源响应十分的慢了,操作系统也基本要挂掉了。
4. 暂停状态 T(阻塞状态)
T指的是进程在操作系统中做了非法的操作,那操作系统就会将这个进程暂停,暂停之后,该进程就变成后台进程了。
kill -19 进程pid 暂停一个进程
kill -18 进程pid 继续一个进程
5. 暂停状态 t(阻塞状态)
t 指的是被追踪的暂停状态,如gdb调试打断点
6. 死亡状态 X
进程真正死亡的状态,是操作系统中说的终止状态
7. 僵尸状态 Z
进程已经退出,但这个进程的状态需要自己维持住,供父进程读取,这个状态我们称为僵尸状态;
为什么有僵尸状态?
因为我们创建进程,是让该进程去完成任务的,所以在进程结束的时候,必须要有该进程的退出原因给用户看。并且所有的进程退出时,都会有僵尸状态。但是我们一般是看不到僵尸状态的进程,因为bash会自动读取子进程的状态。
如果父进程不读取,那么task_struct 会一直存在,会造成内存泄漏,注意只是task_struct存在,进程其他的都已经释放掉了,因为我们只需要保留退出原因,其他的信息对我们来说是没必要保留的,而退出原因是存放在task_struct里的;如果我们并没有接收进程的退出原因,该进程成为僵尸进程,此时我们没有办法杀掉他,因为进程已经退出,只剩task_struct。
避免僵尸进程的产生采用进程等待(wait/waitpid)方式完成(进程控制讲解)
8. 孤儿进程
如果一个进程的父进程先退出,那么该进程就会被1号init进程领养,这个进程就是孤儿进程,此时该进程也转为后台进程。