1 冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
截至目前,我们所认识的计算机,都是由一个个的硬件组件组成。
外设
- 输入设备:鼠标,键盘,摄像头,话筒,磁盘,网卡…
- 输出设备:显示器,播放器硬件,磁盘,网卡…
CPU
- 运算器:对数据进行计算任务(算术运算,逻辑运算等)
- 控制器:对计算机硬件流程进行一定的控制
其中输入设备,输出设备,存储器,运算器,控制器都是独立的个体,各个硬件单元都必须用“线”连起来。(系统总线和IO总线)
关于冯诺依曼,必须注意下面几点:
- 这⾥的存储器指的是内存
- 不考虑缓存情况,这⾥的CPU能且只能对内存进⾏读写,不能访问外设(输⼊或输出设备)
- 外设(输⼊或输出设备)要输⼊或者输出数据,也只能写⼊内存或者从内存中读取。
- ⼀句话,所有设备都只能直接和内存打交道
2 操作系统(Operator System)
2.1 概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等等)
2.2 设计OS的目的
- 通过管理好底层的软硬件资源(手段)
- 为用户提供一个良好(稳定,高效,安全)的执行环境(目的)
2.3 定位
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件
2.4 计算机管理硬件(先描述,再组织)
- 描述起来,用struct结构体
- 组织起来,用链表或其他高效的数据结构
- 在操作系统中,管理任何对象,最终都可以转化为对某种数据结构的增删查改
2.5 系统调用和库函数概念
- 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。(类比银行窗口)
- 系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发
3 进程
3.1 基本概念
- 课本概念:一个已经加载到内存中的程序,或者,正在运行的程序,叫做进程。
- 内核观点:内核PCB数据结构对象 + 自己的代码和数据。
3.2 描述进程-PCB
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
3.2.1 task_struct是PCB的一种
- 在Linux中描述进程的结构体叫做task_struct
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息
3.3 操作系统对进程的管理
操作系统可以同时运行多个进程,是如何控制的?
- 答:任何一个进程在加载到内存时,操作系统会先创建一个,描述进程的结构体对象(PCB,进程控制块),该对象中包含了进程的编号,进程的状态,优先级以及各种相关的指针信息等,因此,操作系统对进程的管理,变成了对各种结构体的管理,即:对某种数据结构的增删改查。
3.3.1 Linux具体是怎么做的
Linux内核中,最基本的组织进程是task_struct,采用的组织形式是双向链表。
3.4 task_ struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程
- 状态: 任务状态,退出代码,退出信号等
- 优先级: 相对于其他进程的优先级
- 程序计数器: 程序中即将被执行的下一条指令的地址
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等
- 其他信息
3.5 查看进程
ps ajx | head -1 && ps ajx | grep test1
查看含有test1的进程有哪些。
ls /proc/
查看当前系统的所有进程
上面的蓝色数字,就是当前正在运行进程的PID。所以,在系统中启动的所有进程,在proc目录里会,创建一个以PID命名的文件夹/目录,该目录包含了该进程的大部分属性。
补充:每一个进程在系统运行期间,它终止了再重新启动,操作系统给它分配的PID标识符大概率是不一样的。
3.6 通过系统调用获取进程标示符
- 进程id(PID)
- 父进程id(PPID)
ps ajx | head -1 && ps ajx | grep test1 | grep -v grep
kill -9 13210
从任务管理器,杀掉该进程。
查看自己的PID可以使用getpid()
,查看父进程使用getppid()
#include "stdio.h"
#include "unistd.h"
#include <sys/types.h>
int main()
{
int i = 0;
while(1)
{
printf("I am a process,my id is:%d,parent:%d\n",getpid(),getppid());
sleep(1);
i++;
}
}
补充:程序每次运行起来的PID都会发生变化,我们可以根据PID杀掉这个进程。而PPID一般在同一个终端下启动,不会发生改变,所有的父进程都是bash。
3.6 通过系统调用创建进程 — fork()
先查看手册:man fork
,/return val
查看fork的返回值
先看代码和现象:
#include "stdio.h"
#include "unistd.h"
#include <sys/types.h>
int main()
{
printf("before: only one line\n");
fork();
printf("after: only one line\n");
sleep(1);
return 0;
}
#include "stdio.h"
#include "unistd.h"
#include <sys/types.h>
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("我是父进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
// error
}
return 0;
}
begin:我是一个进程,pid:21780,ppid:15936
这是最开始的进程,其中父进程是:15936,就是bash
创建进程有两种方法:
./
运行我们的代码 — 指令级别- fork() — 代码层面创建的子进程
进程 = 内核数据结构(PCB) + 代码和数据(代码:函数或是逻辑运算,数据:全局变量,局部变量等)
- 为什么fork的返回值不同?要给子进程返回0,给父进程返回子进程的PID?
- 返回值不同值是为了区分,让不同的执行流,执行不同的代码块!(让子进程帮父进程做事情!),一般而言,fork之后的代码共享。
- 类比:一个父亲可以有多个子女,父亲找儿子做事情得喊名字,所以给父亲返回PID,一个子女只能有一个亲生父亲,具有唯一性,所以给子进程返回0(子进程找父进程可以用getppid())。
- fork函数为什么能返回两次,如何做到的?
- fork函数可以创建子进程,进程 = 内核数据结构 + 代码和数据,其中子进程的内核数据结构(PCB)是拷贝父进程的,子进程的数据和代码也是与父进程共享的,fork也是一个函数,父子进程共享,因此可以return两次。
- 创建子进程的目的是为了让父和子执行不同的事情,让父和子执行不同的代码块。因此,父和子两个进程又是可以割裂的,是独立的,父和子不能访问同一份数据,目的是让各个进程之间相互独立,所以子进程的数据是写时拷贝。
- 一个变量怎么会有不同的内容,如何理解?
- 写时拷贝
- fork函数究竟在干什么?
- 创建子进程,父子进程的后续代码是共享的,但是fork本身也是一个函数,父和子各自执行return,最终实现两次返回,在返回的时候发生了写时拷贝,让父和子的返回值不同,后续可以根据返回值的不同(if else判断),让父子进程分流,让父子执行不同的代码块,所以父子进程可以分别执行不同的代码,分别在各自的死循环中运行了。
4 进程状态
4.1 操作系统中的进程状态(运行、阻塞、挂起)
调度器需要确保CPU的资源被合理使用,所以需要维护一个运行队列,他将进程的PCB结构体连接起来,而被链接起来的进程就会按顺序被调度器调度,此时处于运行队列的这些进程就处于运行态,这说明运行态并不指的是正常运行的进程,而是处在运行队列中,并且随时可以被调度的进程!
4.1.1 并发执行和进程切换
**调度器将进程放到CPU上去运行,并不代表必须要将进程全部运行完才会被放下来!!**因为
- 进程当中可能会存在一些死循环的进程
- 调度器要尽量保证公平性,不能让一个进程占用CPU太长时间
什么时候应该把这个进程放下来,就要取决于时间片,当一个进程在超过时间片还没有结束的时候,就要把他放下去然后重新放在新的运行队列的位置。
CPU运行速度是很快的(纳秒级别),人眼很难察觉,所以在一个时间段内必然所有的进程都会被执行,称之为并发执行。 而大量地把进程从CPU上拿上来在放下去的这个过程,称之为进程切换!
4.1.2 阻塞状态
操作系统管理硬件的过程也是需要先描述再组织,因此不同的硬件设备都需要维护一个阻塞队列,当该硬件没有准备好时,该进程只能在阻塞队列中等待。
比如scanf函数从键盘获取数据,但是如果我们一直不输入的话,这个进程就会被阻塞!!
4.1.3 挂起状态
当操作系统的内部资源严重不足的时候,需要在保证正常运行的前提下想办法把一些内存里的资源置换到磁盘中来节省空间,我们将被置换的进程所处的状态叫做挂起状态。
一般来说,导致内存资源不足的原因是因为,存在大量进程都在阻塞队列 ,所以我们要办法将一些资源置换到磁盘中,但是为了不影响阻塞队列的管理,所以大多数情况下并不是直接将PCB置换过去,而是将该进程的数据和代码先置换过去,而当执行到该进程的时候,再通过某种方式将其数据和代码置换回来。
- 挂起状态就是PCB在排队,但是他对应的代码和数据被暂时移到外设中,节省内存空间 。
- 一个进程是否被挂起并不需要让你知道,就跟你把钱存银行里一样,你并不知道自己的钱是被干什么用了,银行并不会告诉你,只是你想要的时候他能及时给到你就好!!
4.2 Linux管理进程状态的方法
一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)
下面的状态是在kernel源代码里的定义
/*
* 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 */
};
4.2.1 R状态
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
4.2.2 S状态
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep))。 ——>其实就相当于是阻塞状态(因为需要跟硬件保持联系)
ps ajx | head -1 && ps ajx | grep test1 | grep -v grep
这条指令,可以查看test1的进程状态。
为了更好地观察阻塞状态,举个例子:
第一段代码是只有while循环:
#include "stdio.h"
#include "unistd.h"
#include <sys/types.h>
int main()
{
while(1)
{
}
return 0;
}
第二段代码有printf + while循环
#include "stdio.h"
#include "unistd.h"
#include <sys/types.h>
int main()
{
while(1)
{
printf("HYQ\n");
sleep(1);
}
return 0;
}
问题:为什么第一种情况是R状态,而第二种情况是S状态呢???
- 因为printf需要一直和显示器建立联系,所以CPU有很大概率一直处在等待状态,因为需要等显示器准备好(CPU太快了 所以显示器缓不过来) ,如果不跟硬件建立联系那么该进程的执行速度是非常快的!!(纳秒级别)
4.2.3 D状态
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
区分:S是浅度睡眠(可以被唤醒)、D是深度睡眠 (不相应任何需求)为了能够更好地理解他们的区别,以下会讲述一个故事:
比方说我们现在编译了一段代码,需要将1GB的文件写入磁盘中,内存需要跟磁盘建立联系,磁盘在被写入之前需要判断该行为是否可以被执行,假设现在磁盘中的空间不足1GB,那么这个请求就应该被驳回。这个过程中我们的内存需要先对磁盘说:“我打算写入1GB的内容,你看看可不可以” 磁盘回复:“那你稍等一下,我看看自己的空间是否足够” 当该信息确认后,然后磁盘回复进程:“我当前空间足够,可以执行” 。然后进程才会通过其对应的代码和数据来将1GB写入磁盘。 所以这个过程有发起也有返回,内存像磁盘申请,磁盘完成后将结果返回给内存,但是这个过程是需要等待的!!
假设当前有大量的进程处于阻塞队列,此时内存不够了,因此操作系统需要杀死一部分进程来保证运行 。当系统压力很大时,依靠内存的辗转腾挪解决不了时,操作系统只能想办法杀死他认为不太重要的进程!!
内存在向磁盘发出请求的时候,在磁盘还没回复是否可行的时候该进程就被操作系统杀死了,所以磁盘想要回复的时候发现该进程不在了,所以就懵圈了。当磁盘想要回应的时候却发现那个等待自己的进程没有了,那么现在写入失败了怎么办?我是应该继续尝试呢,还是丢掉呢??此时不同的操作系统有不同的做法。
比如是在银行,某些数据丢失导致损失了几个亿!!这个时候法官 叫来了 操作系统、进程、磁盘 三个人,来这个过程应该是谁的错,第一嫌疑人是操作系统,因为操作系统杀进程了,操作系统说:“请问我是否履行了自己的职责,我是否是在比较极端的情况下去杀进程的,我能做的最大努力就是保证操作系统不挂掉!!如果我有错,那我下次再遇到这种情况??我还做不做了?就算我不杀进程,导致操作系统挂了,他数据该丢还是会丢,还会影响其他进程,这个责任又该谁负责呢??” 法官觉得操作系统说得有道理,于是又把矛头转向了第二嫌疑人磁盘,因为磁盘在写入失败的时候擅自把数据丢失了。磁盘说:“这不怪我,我就是个跑腿的,我在写入的时候就告诉他可能会失败了,所以我让他在那里等我的结果,可是他人不见了,而是丢失是因为我还有其他工作得做,如果我有错的话,那我们是不是得把磁盘所有逻辑都改了???”法官觉得磁盘说的也有代理,于是又把矛头转向了进程,此时进程扑通一声跪了下来说:“我是被杀的,我怎么能有错呢?”所以凡是存在争议很大的地方,大部分都是因为制度设置的不合理。所以法官说,你们都回去吧,我把操作系统改一改——>让一些进行在磁盘写入完毕期间,这个进程不能被任何人杀掉,其实就是不接受任何响应,但是D状态不多见因为如果有很多说明系统已经临近崩溃了!!
4.2.4 T状态
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可 以通过发送 SIGCONT 信号让进程继续运行。
kill -l
查看kill的命令选项
T状态存在的意义:可能是需要等待某种资源,或者是我们单纯不想让该进程运行!!应用场景就是gdb
4.2.5 X状态
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
4.3 僵尸进程
讲一个小例子:
比如你正在公园跑步,突然看见一个老人走了两步就倒地上了,这时候你叫了120,120发现人已经没救了,于是走了。然后你又叫了110,但是110并不会立马清理现场,因为本质查明真相的原则,可能会需要先带着法医对尸体进行检测然后再确认结果,比如说异常死亡或者是正常死亡(因为家人需要了解情况),然后才会去清理现场。 其实这段已经死亡一直到清理现场之前的这段时间,就相当于是僵尸状态。
回到进程的角度,一个进程在退出的时候并不是立即把所有的资源全部释放,而是要把当前进程的退出信息维持一段时间!!——>因为父进程需要关心子进程!!
情况1:当子进程先退出 父进程还在
#include "stdio.h"
#include "unistd.h"
#include<stdlib.h>
int main()
{
pid_t id =fork();
if(id == 0)
{
int i = 5;
while(i)
{
printf("child,pid:%d,ppid:%d,cnt:%d\n",getpid(),getppid(),i);
i--;
sleep(1);
}
exit(0);
}
else
{
while(1)
{
printf("parent,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
所以只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程保持Z状态,相关的资源(尤其是task_struct结构体)不能被释放!资源会一直被占用!
问题1:为什么要存在僵尸状态?
- 因为他要告诉关心他的进程(父进程),你交代给我的事情我办得怎么样了,所以他一定要等到父进程读取之后才能完全死亡。
问题2:一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费(甚至是内存泄漏)?
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,也是要保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护,需要占用内存,会造成内存泄漏!!!
情况2:父进程先退出 子进程还在
#include "stdio.h"
#include "unistd.h"
#include<stdlib.h>
int main()
{
pid_t id =fork();
if(id != 0)
{
int i = 5;
while(i)
{
printf("parent,pid:%d,ppid:%d,cnt:%d\n",getpid(),getppid(),i);
i--;
sleep(1);
}
exit(0);
}
else
{
while(1)
{
printf("children,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
说明如果父进程比子进程先一步消亡,那么子进程会变成孤儿进程,他的PPID会变成1 也就是被系统进程给收养。
问题1:为什么要被领养呢?
- 因为孤儿进程未来也要消亡,也要被释放。
问题2:ctrl+c为什么无法中止异常进程,他的底层原理是什么?
- 本质上是在一瞬间父进程会被bash进程回收掉!!所以子进程也在父进程退出的一瞬间被收回掉了, 所以由于子进程的PPID不是bash进程而是系统进程,所以无法中止
问题3:子进程是bash进程的孙子进程,为什么父进程消亡后不由bash进程回收而是交由系统进程回收?
- 因为bash做不到,因为孙子进程不是他去创建的,他没有这个权限,而系统进程可以做到,因为要将孤儿进程托孤给系统进程 。 当然不同的操作系统具体的实现方法可能也不同!!
4.4 Linux是怎么维护进程的
Linux内部维护进程主要是采用双链表的形式管理,但是由于其可能有不同的应用场景需求,所以有时候我们也要把它放到队列、二叉树……中管理,所以为了方便这样的操作,我们的task_struct结构体里面必然也需要维护各种各样类型的指针。