目录
[3.2.5 孤儿进程](#3.2.5 孤儿进程)
[编辑 3.5.4过期队列](#编辑 3.5.4过期队列)
一.冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
我们所认识的计算机,都是由一个个的硬件组件组成
• 输入单元:包括键盘,鼠标,扫描仪等
• 中央处理器(CPU):含有运算器和控制器等
• 输出单元:显示器,打印机等
关于冯诺依曼,必须强调几点:
• 这里的存储器指的是内存
• 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)(所以软件要想运行必须先加载到内存)
• 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
• 一句话,所有设备都只能直接和内存打交道。
注意比如printf打印的时候也不是直接打印到我们的显示屏,而是先到缓冲区,再刷新到外设输出设备上,进度条就是如此。
所以数据在数据层面,只和内存打交道。外设之和内存打交道。
二.操作系统
2.1概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
• 内核(进程管理,内存管理,文件管理,驱动管理)
• 其他程序(例如函数库,shell程序等等)
简单来说,操作系统是一款进行软硬件管理的软件
2.2设计OS的目的
• 对下,与硬件交互,管理所有的软硬件资源
• 对上,为用户程序(应用程序)提供一个良好的执行环境
2.3核心功能
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的"搞管理"的软件
2.4如何理解管理
• 管理的例子 - 学生,辅导员,校长
• 描述被管理对象
• 组织被管理对象
管理学生转化为对学生链表的增删查改
总结
计算机管理硬件
描述起来,用struct结构体
组织起来,用链表或其他高效的数据结构
2.5系统调用和库函数概念
理解系统调用
操作系统要向上提供对应的服务,但是操作系统不相信任何用户和人。操作系统要向用户提供服务,会把操作系统封装起来,然后提供系统调用(操作系统提供的系统调用)。系统调用的本质就是用户和操作系统之间进行某种数据交互。
• 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用, 这部分由操作系统提供的接口,叫做系统调用。
理解库函数
• 系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开 发。
就像是银行:
我们不可能到银行内部去,银行只会提供一个一个的窗口来服务用户。而银行的大堂经理就相当于是外壳程序,举个例子:用户使用printf函数,在函数内部就有系统调用,我们可以直接使用printf函数,就是因为printf已经封装好了系统调用。
我们可以理解为库函数就是对部分系统调用的封装。库函数和系统调用属于上下层的关系。
三.进程
3.1进程的基本概念与基本操作
• 课本概念:程序的一个执行实例,正在执行的程序等
• 内核观点:担当分配系统资源(CPU时间,内存)的实体。
我们自己写的代码在磁盘里,只有我们运行可执行程序的时候才会加载到内存。而加载到内存之后(下图代码和数据)OS会创建一个结构体,这个结构体里面包含该代码和数据的所有属性。新来的结构体节点会链入到链表里面,这个就是进程列表。
这个结构体叫做PCB(代码控制块),而在Linux上是tast_struct(任务块)
就像是在找工作时,代码和数据就是我们自己,PCB就是我们的简历, CPU是面试官,在进程列表中PCB在排队,就是简历在排队等着面试官审核。
总结一下:
3.1.1描述进程
基本概念
• 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
• 课本上称之为PCB(process control block),Linux操作系统下的PCB是:task_struct
task_struct-PCB的⼀种
• 在Linux中描述进程的结构体叫做task_struct。
• task_struct是Linux内核的一种数据结构类型,它会被装载到RAM(内存)里并且包含着进程的信息。
3.1.2tast_struct
• 标示符:描述本进程的唯一标示符用来区别其他进程。
• 状态:任务状态,退出代码,退出信号等。
• 优先级:相对于其他进程的优先级。
• 程序计数器:程序中即将被执行的下一条指令的地址。
• 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针 (简历里的电话,找到你这个人,同理这里找到代码和数据)
• 上下文数据:进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
• I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
• 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
• 其他信息
3.1.3查看进程
创建进程
我们历史上执行的所有的指令,工具,自己的程序运行起来全部都是进程。
其中的getpid就是一个系统调用可以使用man的方式查看它。
运行之后:
查看进程
在开一个终端可以查到我们正在运行的这个进程:
上面的也可以用&&符号链接:
可以找一下pid在哪里,8903对应的就是我们刚才写的那个代码进程
除了上面的ps查看进程,还可以用ls /proc
我们自己写的代码的进程也在这里里面(蓝色就是目录,里面装的是该进程的动态属性,我们杀死进程这个目录也会被清除):
小知识exe和cwd
exe:
可以进入这个目录:
这个exe就是我们对应的可执行文件,当进程在跑的时候我们甚至可以把这个文件删除也不会影响我们正在跑的进程,因为删除的这个文件已经被执行了,上面说过的一个东西程序被执行就一定要先加载到内存,我们删除的只是磁盘上的文件。
而当我们真的把这个文件删除之后,再去查看进程的话这个exe就会变成红色并闪动:
cwd:
cwd叫做current work dir,这个其实就是对应我们进程运行的路径。
还记得我们在学习C语言文件操作的时候,fopen会创建文件,fopen函数第一个参数如果不带路径的话 ,它会默认的在当前路径创建文件,而这个当前路径是怎么来的?因为进程在启动时会记录下来当前路径,而fopen就是进程中的一句代码,fopen在创建文件的时候,会默认的获取工作路径,在路径后面加上我们新建的代码。
当然也可以该cwd,chdir:
杀死进程
杀死进程的方式我们可以直接使用crtl+c的方式,还有使用kill -9 +(进程的pid)。:
-9是进信号编号
3.1.4父进程
我们可用getppid获取当前进程的父进程的id:
我们创建的进程的父进程是bash (命令行解释器),所以bash自己就是一个进程
系统每一次登录都会给每一个用户分配一个bash
3.1.5通过系统调用创建进程-fork初识
fork是一个系统调用:
来看这样代码:
先看现象:
在我们frok函数后面的printf运行了两次,因为frok进程创建了一个子进程,所以这个子进程也会打印。而且子进程没有自己的代码和数据(只会创建进程,不会加载代码和数据),所以父进程所指向的代码和数据同时也是子进程所指向的代码和数据(不会执行已经执行过的代码和数据)。
fork有两个返回值
看下面代码:
从运行结果可以看出,父进程的父进程是7416对应的是bash,子进程的父进程是14048
这就可以颠覆认知了,以前在学习C语言分支结构的时候是不是只会执行一个?而这个执行了两个。以前学习函数的时候函数的返回值是不是只有一个?id这个变量既等于0也大于0?
为什么fork给父子返回各自的不同的返回值?
父进程:子进程=1:n。父进程为了区分不同的子进程所以要把子进程的pid返回给父进程。而子进程本身就可以getppid,所以只需要确保子进程创建成功了就行。
为什么一个函数会返回两次?
为什么一个变量既==0又大于0?导致if else同时成立?
进程具有独立性
可以通过代码测试一下:
创建进程后子进程的gval一秒加10,而父进程不变:
这就是写实拷贝
3.2进程状态
3.2.0进程状态查看
ps aux / ps axj 命令
• a:显示一个终端所有的进程,包括其他用户的进程。• x:显示没有控制终端的进程,例如后台运行的守护进程。
• j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息
• u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等
3.2.1Linux内核源代码怎么说
• 为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状 态(在Linux内核里,进程有时候也叫做任务)。 下面的状态在kernel源代码里定义:
cpp
/*
*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 */
};
• R运行状态(running):并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
• S睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep))。
• D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个 状态的进程通常会等待IO的结束。
• T停止状态(stopped):可以通过发送SIGSTOP信号给进程来停止(T)进程。这个被暂停的进程可以通过发送SIGCONT信号让进程继续运行。
• X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
R运行状态
来看下面的代码:
每隔一秒就打印一下进程的状态:
大部分的STAT都是S+:
运气好我们可能会遇到R+:
为什么大部分是S?因为有printf,进程状态可能是在运行队列和阻塞队列里频繁的切换,而代码运行大部分时间都在等待,所以我们看到的进程状态大部分都是S+ 。
所以如果我们把printf给注释掉(不要让代码IO),我们在去查看的话就全是R+了。
小知识:让进程后台运行
杀死进程直接kill -9 +pid
S睡眠状态
其实就是阻塞状态:
接下来看进程状态就是S+:
t追踪状态和T暂停状态
加上-g选项使我们的可执行程序可以被调试(debug模式)
在某一个位置打上断点,之后在运行程序:
下面就可以看到进程状态,为t
另一个T暂停状态:
一个死循环的进程,我使用crtl+z来暂停程序。暂停和阻塞不一样,可能是做了一些非法操作,是操作系统用来止损的
不仅如此,查看T状态,还可以用kill的选项
当然还可以continue:
继续让程序跑起来。
D不可中断睡眠状态
同时也是阻塞状态
上面的S状态是可中断睡眠状态(浅休眠),如果一个进程正在处于可中断睡眠状态,我们可以crtl+c直接杀死进程。
D状态的进程不能被杀掉
在这个场景下,进程要往磁盘写入100MB的东西,如果这时的内存已经严重不足了,OS就会采用极端杀死进程的方式。假如磁盘写这100MB的内存失败了,磁盘就会去找进程,但是进程已经死亡了,无法找到进程,此时磁盘也不知道怎么办了,这100MB的东西就丢失了。所以这里如果是S状态的话就会导致进程被杀死,我们需要设定一个状态D来表示进程一定不能被杀死(只能等这个进程自己醒来或者断电)。
3.2.2运行&&阻塞&&挂起
运行和阻塞
运行:进程在调度队列中,进程的状态都是running
阻塞:等待某种设备或资源就绪(像是scanf等待键盘文件就绪)
OS要管理系统中的硬件资源,就必须先描述再组织。也就是说又是一个结构体跟之前的PCB差不多的思路。这里会有一个设备队列。而从运行状态到阻塞状态其实就是把调度队列里的某个要执行的进程(比如要使用scanf,需要设备输入),链入到特定设备的等待队列中(已经不在运行队列里了)(此时的状态就改为了阻塞状态)。
进程状态的变化,表现之一。就是要在不同的队列中流动,本质都是数据结构的增删查改。
阻塞挂起
当我们的系统内存资源严重不足时,操作系统要做一些内存置换的算法,把那些不会调度的进程(阻塞状态的进程)或者相关的内存块交换到磁盘上(把代码和数组拿进去,只剩下了PCB)这就是阻塞挂起
运行挂起
如果内存资源更加吃紧,操作系统会把运行队列里的末端的代码和数据也全部的交换到swap。当真正的需要调度时再交换过来。
3.2.3理解内核链表
我们之前学过的双链表都是在链表节点的内部定义一个指向下一个节点的next指针和指向上一个节点的指针prev,而内核里的链表虽然也是双向链表但是在结构上跟我们之前学过的不太一样。内核里链表是通过把next和prev指针放在一个结构体里面来实现双向链表的。
而我们的指针也不在是指向task_struct,改为了指向下一个list_head,拥有偏移量也可以访问其他的变量,如下图:
要怎么算偏移量以至于可以求出task_struct的地址?
到这里拓展一下思维,我们既然可以放一个links来管理这些PCB使它们成为双向链表。那我们当然也可以放很多个这样的links来管理这些PCB:
这样的话每一个links都可以与任意的task_struct里的links来链接起来,所以这些PCB既可以属于全局链表也可以属于运行队列,还可以属于阻塞队列,甚至有了这样的思想我们可以同时创造一个既属于二叉树又属于队列的数据结构。
3.2.4僵尸进程
• 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
• 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
• 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
看这样一组代码:
cpp
1 #include<stdio.h>
2 #include<sys/types.h>
3 #include<unistd.h>
4 int main()
5 {
6 pid_t id =fork();
7 if(id==0)
8 {
9 int count=5;
10 while(count)
11 {
12 printf("我是子进程,我正在运行%d\n",count);
13 sleep(1);
14 count--;
15 }
16 }
17 else
18 {
19 while(1)
20 {
21
22 printf("我是父进程,我正在运行\n");
23 sleep(1);
24 }
25 }
26 return 0;
27 }
5秒之后就能看到z状态(defunct就是死亡的)
如果父进程一直不管,不回收,不获取子进程的退出信息,那么z会一直存在(导致内存泄漏)
危害
• 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我 办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态?是的!
• 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中, 换句话说,Z状态一直不退出,PCB一直都要维护?是的!
• 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进开辟空间!
• 内存泄漏?是的
3.2.5 孤儿进程
• 父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
• 父进程先退出,子进程就称之为"孤儿进程"
• 孤儿进程被1号init/systemd进程领养,当然要有init/systemd进程回收喽。
来看代码,父进程在5秒后会结束进程,子进程是一个死循环:
我们知道父进程创建子进程,而现在父进程没了,则子进程的ppid是谁呢?
这里可以看到,父进程死亡之前,都是正常的,但是5秒后的子进程的ppid就变成了1.
父子进程关系中,如果父进程先退出,子进程要被1号进程领养,这个被领养的进程(子进程)叫做孤儿进程(后台进程,crtl+c杀不了)。
1号进程是什么?
以前我们说在我们登录Linux系统时,系统会给我们创建一个bash,我们就认为是这个1号进程给我们创建的,我们也可以理解为这个1号进程就是操作系统的一部分。
为什么要被1号进程领养?
如果不领养,子进程没爹了之后,进程结束就没有父进程会回收,就会变成僵尸进程一直不被回收,导致内存泄漏
3.3进程优先级
3.3.1基本概念
• cpu资源分配的先后顺序,就是指进程的优先权(priority)。
• 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性 能。
• 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大改善 系统整体性能。
我们现在的操作系统是基于时间片的操作系统,考虑公平性,优先级可能变化,但是变化幅度不能太大。
3.3.2查看系统进程
在linux或者unix系统中,用ps ‒l命令则会类似输出以下几个内容:
• UID:代表执行者的身份
• PID:代表这个进程的代号
• PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
• PRI:代表这个进程可被执行的优先级,其值越小越早被执行(默认80)
• NI:代表这个进程的nice值
UID
以前我们说,访问文件时系统是怎么知道我是拥有者,所属组还是other的? 当我们在使用指令访问文件的时候,其实就是进程在访问,而进程就代表着用户(UID),所以我们自己就能访问我们的文件了
PRI和NI
• PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此 值越小进程的优先级别越高
• 那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
• PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
• 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快 被执行
• 所以,调整进程优先级,在Linux下,就是调整进程nice值
• nice其取值范围是-20至19,一共40个级别。
⽤top命令更改已存在进程的nice:
• top
• 进入top后按"r"‒>输入进程PID‒>输入nice值
这里的NI就被我改为了10,而PRI变成了90。
使用nice和renice指令也可以经常修改优先级。还有某些的系统调用也是可以的。
优先级的极值问题
优先级设立不合理,会导致优先级低的进程,长时间得不到CPU的资源,进而导致:进程饥饿。
3.3.3补充概念-竞争、独立、并行、并发
• 竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
• 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
• 并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
• 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
3.4进程切换
一旦一个进程占有CPU,会把自己的代码跑完吗?肯定是不会的,如果到了某一个进程就一定要把这个进程跑完的话,假如这个进程非常大呢?其他的进程也是要跑的啊。所以CPU就有一了时间片的概念。时间片是指操作系统将CPU的执行时间划分为多个较小的时间段,每个时间段为一个时间片。
CPU上下文切换:其实际含义是任务切换,或者CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器,并开始下一个任务的运行,这一过程就是context switch。
进程切换,最核心的,就是保存和恢复当前进程的硬件上下文的数据,即CPU内寄存器的内容
可以看源码:
时间片:当代计算机都是分时操作系统,每个进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从CPU中剥离下来。
3.5Linux2.6内核进程O(1)调度队列
3.5.1⼀个CPU拥有⼀个runqueue
如果有多个CPU就要考虑进程个数的负载均衡问题
3.5.2优先级
• 普通优先级:100〜139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
• 实时优先级:0〜99(不关心)
3.5.3活动队列
• 时间片还没有结束的所有进程都按照优先级放在该队列
• nr_active:总共有多少个运行状态的进程
• queue[140]:一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以, 数组下标就是优先级!
• 从该结构中,选择一个最合适的进程,过程是怎么的呢?
1.从0下标开始遍历queue[140]
2.找到第一个非空队列,该队列必定为优先级最高的队列
3.拿到选中队列的第一个进程,开始运行,调度完成!
4.遍历queue[140]时间复杂度是常数!但还是太低效了!
• bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用 5*32个比特位表示队列是否为空,这样,便可以大大提高查找效率!
根据位图查找有进程的数组下标:
每个数组是根据FIFO放入的tast_struct,都是队列。但是有一个问题就会出现,当前的进程如果时间片到了的话还会链入到进程队列的末尾吗?如果继续链入到当前队列的末尾的话,其他同等优先级的进程完成之后它会继续,但如果这个进程是一个死循环的话,就会无限运行。那其他优先级的进程呢?这就导致了进程饥饿。
3.5.4过期队列
• 过期队列和活动队列结构⼀模⼀样
• 过期队列上放置的进程,都是时间片耗尽的进程
• 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
3.5.5active指针和expired指针
• active指针永远指向活动队列
• expired指针永远指向过期队列
• 可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直 都存在的。
• 没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了⼀批 新的活动进程!