1.冯 • 诺依曼体系结构
1.1 体系结构
冯•诺依曼结构也称普林斯顿结构,是一种将程序指令存储器和数据存储器合并在一起的存储器结构。数学家冯•诺依曼提出了计算机制造的三个基本原则,即采用二进制逻辑、程序存储执行以及计算机由五个部分组成(运算器、控制器、存储器、输入设备、输出设备),这套理论被称为冯•诺依曼体系结构。我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯•诺依曼。其中:
输入设备:键盘、鼠标......。
输出设备:显示器、音响......。
存储器:如果没有特殊说明,一般是内存。
运算器:集成于CPU,用于实现数据加工处理等功能的部件。
控制器:集成于CPU,用于控制整个CPU的工作。
各个组件之间的互通是通过“线”连接实现的,计算机更精密,所以使用“主板”来把它们关联在一起。
2.2 数据流向
内存
内存有一个重要的特征:掉电易失。当计算机突然断电,内存中的数据就会消失。这与内存本身的存储结构有关,只能做临时的电信号存储,硬件结构会定期的对内存的电信号的刷新,保证数据是最新的。
外存
外存一般是指除了内存之外的其他的具有永久性存储能力的设备,常见的有磁盘。这种磁盘类的设备具有永久性存储能力,磁盘不属于计算机体系结构当中的存储器,而是属于外设的一种。输入设备、输出设备都叫外部设备。有些设备只是输入设备、有些设备只是输出设备。而磁盘和网卡既是输入设备也是输出设备。比如我们向磁盘中特定的文件中进行文本或者二进制写入,写入的本质是把数据写到对应的外设,即磁盘上。也可以从磁盘中读取数据到内存中。这个过程就是向磁盘进行读取和写入操作,既是输入也是输出。网卡既可以把数据发送到网络中,也可以把数据从网络中拿回来。这些就是输入输出设备。
外设是相对于内存+CPU来说的。
CPU
运算器+控制器+其他=CPU---特点:快。CPU是负责计算的。CPU计算时是需要数据的。CPU很死板,只能被动的接受别人提供的指令,别人提供的数据,通过执行别人的指令,达到计算别人数据的目的。CPU接受别人提供的指令的前提是,CPU必须首先要认识别人的指令。CPU是怎么认识别人提供的指令呢?CPU内部有自己的指令集。我们写代码、编译代码的本质是做什么?代码经过编译形成二进制可执行程序,CPU就可以执行了。为什么?因为二进制可执行程序中将数据变成CPU内部的指令集。此时将程序再喂给CPU,CPU就可以根据自己的指令集去识别到你的程序内部是做什么。翻译成CPU的指令集、让CPU去执行,这也就是为什么CPU能够执行你写的程序的根本原因,这也是编译器存在的根本原因。比如:你进入企业中成为一个工程师,并胜任工作的前提是,你必须在学校在完成某种教育。当你在学校中学习完专业知识后,你在进入工作岗位后才能胜任工作。即在学校的学习,让你的大脑当中被写入了很多很多的指令集。即你是一个CPU,你在学校中你就要执行学校老师曾经设定好的程序代码,比如上课学习专业知识、早操等,这是学校给你提供的程序,当你进入到公司时,你要完成工作,你为什么能顺利完成工作呢?是因为你的大脑中有对应的指令集。在世界中存在各种各样的程序,我们就是那个CPU,被动的接受着来自这个世界的程序,去执行这个世界内部不同的工种、不同的工作,这就是指令集。指令集包括:精简指令集、复杂指令集。CPU中的数据从哪来?一种是永久存储介质、一种是临时存储介质。CPU读取的数据必须是从内存中拿。CPU在读取和写入时,在数据层面上只与内存打交道。为什么?为提高整机效率。如果CPU从磁盘中拿数据,整个CPU和外设之间协作的速度是以外设为基本起点的。如果以内存为基本单位,CPU从内存中读取数据,那么速度以内存的速度为基本单位。
内存中天然没有数据,CPU需要的数据,可以在被需要之前,预先的从磁盘搬入到内存中。比如:预先将数据从磁盘加载到内存,计算机开机时,就是将操作系统从磁盘预先加载到内存,然后让操作系统快速获取。
CPU要的数据在磁盘中,CPU读取或者写入数据时,可以将数据写入到内存中,后面有时间再将数据刷新到磁盘中;所以CPU之和内存打交道、内存和外设和内存打交道。内存可以看作为一个巨大的缓存,可以适配CPU和外设之间速度不匹配的问题。CPU可以将数据预先写到内存中,定期刷新到外设中;同样外设也可以将数据预先写到内存中,让CPU去读取。这里谁帮我们预先去读数据呢?谁帮我们刷新数据呢?谁帮我们将数据预先加载到内存中?谁来加载?加载多少数据?操作系统帮我们做各种各样的策略。计算机是硬件和软件的结合。
存储器(内存) -- 特点:较快。内存是负责临时存储的。
外设 -- 特点:较慢。外设(磁盘)是负责永久存储的。
冯•诺依曼体系结构规定了硬件层面上的数据流向,所有的输入单元的数据必须先写到存储器中(这里只针对数据,不包括信号),然后CPU通过某种方式访问存储器,将数据读取到CPU内部,运算器进行运算,控制器进行控制,然后将结果写回到内存,最后将结果传输到输出设备中。可执行程序运行时,必须加载到内存,为什么?
各个组件之间的互通是通过“线”连接实现的,由于计算机比较精密,所以使用“主板”来把他们关联在一起。
在此之前先了解以下计算机的存储分级,其中寄存器离CPU最近,因为它本来就集成在CPU里面;L1、L2、L3是对应的三级缓存,它也集成于CPU;主存通常值的是内存;本地存储(硬盘)和网络存储通常指的是外设。如图所示,这样设计其实是因为造价的原因,通过这个图我们想解释的是为什么计算机非得把数据从外设(磁盘)-->三级缓存(内存)-->CPU,而非从外设(磁盘)-->CPU。
原因是因为离CPU更近的,存储容量更小、速度更快、成本更高;离CPU更远的,则相反。假设CPU直接访问磁盘,那么效率就太低了。这里有一个不太严谨的运算速度的数据,CPU是纳秒级别的、内存是微秒级别的、磁盘是毫秒级别的。当一个运算速度快的设备和一个运算速度慢的设备一起协同时,最终的运算效率是以运算速度慢的设备为主,就如“木桶原理”---要去衡量木桶能装多少水,并不是由最高的木片决定的,而是由最短的木片决定的。也就是说一般CPU去计算时,它的短板就在磁盘上。所以整个计算机体系的效率就一定会被磁盘拖累。所以我们必须在运行时把数据加载到内存中,然后CPU再去计算,而且在计算的期间可以同时让输入单元加载到内存,这样可以让加载的时间和计算的时间重合,以提升效率。
同理因为效率原因CPU也是不能直接访问输出单元的,这里以网卡为例,我刚发出一条QQ消息给好友,发现网络很卡,四五秒才发出去,而在这个过程,你不可能让CPU等你四五秒吧,这样成本就太高了,所以通常CPU也是把数据写到内存中,在合适的时间再把数据刷新到输出单元中。
所以,本质上可以把内存看作CPU和所有外设之间的缓存,所有设备也都是只能和内存打交道,也可以理解成这是内存的价值。
小结:所有数据-->外设-->内存-->CPU-->内存-->刷新到外设,其中我们现在所谈论的观点是在数据层面上CPU不直接和外设交互,外设只和内存交互,这也就是可执行程序运行时,必须加载到内存的原因,因为冯·诺伊曼体系结构规定了,而我们上面花了大量篇幅主要是阐述了冯·诺伊曼体系结构为什么这样规定。如上面举的例子,计算机在开机时就是将操作系统加载到内存。
2.3 实例
对冯·诺伊曼体系结构的理解不能只停留在概念上,要深入到对软件数据流的理解上。请解释你在QQ软件上向朋友发送了一句“你好”或者文件,该操作数据的流动过程?
本质上发消息和发文件都是没有区别的,这里的实例的意义是让我们在硬件层面上理解了它的数据流,无论是QQ、WetChat软件都离不开这样的数据流。
2.操作系统
2.1 概念
操作系统是一个进行软、硬件资源管理的软件,任何计算机系统都包含一个基本的程序集合,称为操作系统。笼统的理解,操作系统包括:
1、内核(进程管理、内存管理、文件管理、驱动管理)。
2、其他程序(例如函数库、shell程序等)。
狭义上的操作系统只是内核,广义上的额操作系统是内核+图形界面等,我们以后谈的也只是内核。
2.2 为什么要有操作系统?
最明显的原因是如果没有操作系统,我们就无法操作计算机。换句话说,操作系统的出现可以减少用户使用计算机的成本。你总不能自己拿上电信号对应的电线自己玩把,这样成本太高了。
操作系统对下管理好所有的软、硬件,对上给用户提供一个稳定高效的运行环境。其中硬件指的是CPU、网卡、显卡等;软件指的是进程管理、文件、驱动、卸载等。无论是对上还是对下,都是为了方便用户使用。
2.3 计算机体系及操作系统定位
其中,用户可以操作C/C++库、Shell命令、图形界面,底层可以通过操作系统接口完成操作系统工作。比如,用户调用C库使用printf函数在显示器上输出,printf又去调用系统接口最后输出于显示器,在后面的文章中我会介绍一些系统接口。操作系统目前主流的功能有四大类----1、进程管理;2、内存管理;3、文件管理;4、驱动管理。在后面的文章中,我会重点介绍进程管理和文件管理,内存管理主要介绍地址空间和映射关系。
操作系统是不信任任何用户的,但是也必须给上层用户提供各种服务。用户对系统软、硬件的访问都必须经过操作系统。也就是说用户想要去访问硬件,只能通过操作系统所提供的接口去完成。但是操作系统提供的接口使用成本比较高,所以就有了基于系统调用的库。比如,银行不信任任何人,你要去取钱(访问硬件),你不能直接去仓库拿钱,你也不能私底下指挥工作人员(驱动软件)帮你去仓库拿钱。银行规定你要拿钱,必须通过银行提供的ATM(操作系统提供的接口)来取钱。而对于一些老人来说,银行所提供的窗口(系统接口)使用成本也较高,所以就有了人工窗口(库函数)。
也就是说我们使用printf、scanf等函数时,都是用了系统接口,称之为系统调用。
2.4 系统调用和库函数概念
它们本质上都是一种接口,库函数是语言或者是第三方库给我们提供的接口,系统调用是操作系统提供的接口。库函数可能是C/C++,但是操作系统是C,因为操作系统是用C语言写的。
1、在开发角度,操作系统对外会表现为一个整体,它不相信任何用户,但是会暴露自己的部分接口,供上层开发者使用,这部分由操作系统提供的接口,叫做系统调用。
2、系统调用在使用上,功能比较基础,对用户的要求相对也比较高。所以,有些开发者就对部分系统调用进行适度的封装,从而形成了库,有了库,就很有利于更上层用户或者开发者进行二次开发。类似于银行取钱时,一般都会使用人工窗口(库),如王大爷不会取钱,就去人工窗口取钱(调用库)。其实对于库函数的使用,要么SystemCall,如printf函数;要么没使用SystemCall,如sqrt函数或者简单的1+1或者循环,C语言中还有很多的数学函数,像这些函数并没有访问操作系统,因为这些函数的实现是在用户层实现的。
我们学习的C/C++的范畴实际上在系统提供的接口之上,当然Java等语言还要往上点。所以我们经常说的可“跨平台性”的根本原因就是因为C语言的库对用户提供的接口是一样的。但是系统调用的接口可能不一样,Window下就用W的,Linux下就用L的。
使用linux操作系统时,使用shell,满足用户的指令需求。----指令操作。
编程时用的C/C++标准库,C/C++库中有些函数会调用系统调用。----变成操作。
如printf和cout函数都时C/C++库中提供的向显示器打印的操作,即该操作是向硬件写入,根据冯·诺依曼体系结构,显示器是输出设备,程序调用printf和cout时,将数据从内存打印到硬件上。本质是调用的系统调用,操作系统帮我们在屏幕上打印数据,我们只是将数据交给了操作系统。
计算机体系是一个层状结构,任何访问硬件或者系统软件的行为,都必须通过操作系统接口,贯穿操作系统进行访问。
2.5 管理
2.5.1 管理者是通过被管理者的数据,来对被管理者进行管理
操作系统是一个进行软、硬件管理的软件。那么为什么要进行软、硬件的管理?因为操作系统要通过合理的管理软、硬件资源(手段),为用户提供良好的(稳定的、高效的、安全的)执行环境(目的)。下面通过一个实例对管理进行阐述。
如图所示,在学校中大概有以下三种角色:1.学生(被管理者),对应计算机系统的软、硬件;2.辅导员(执行者),对应计算机系统的驱动软件;3.校长(管理者),对应操作系统。
现实生活中,校长不直接与学生打交道,也能将学生管理好。
管理者和被管理者不直接交互,那么管理者是如何管理被管理者的?实际上管理者和被管理者之间并不直接交互,管理者是通过被管理者的数据,来对被管理者进行管理。比如,评选奖学金时,校长并不直接与学生打交道,而是通过从教学管理系统中学生的综合成绩排名中选取成绩排名前3的学生来发放奖学金。然后,安排辅导员来执行奖学金的发放任务。
2.5.2 管理者和被管理者并不直接打交道,那么数据从哪来的 ?
其实是执行者在你入学时把你的个人信息档案录入系统。
如上所述,操作系统管理硬件不需要直接与硬件打交道,而是只需要管理硬件的数据。那么操作系统是怎么拿到硬件的数据呢?在操作系统和硬件之间有一种软件层,即驱动。底层硬件(磁盘)的数据在操作系统的授意之下,被磁盘的驱动程序拿到操作系统的内部,操作系统根据数据做的决策也由驱动程序去执行。操作系统通过驱动程序来对硬件做管理,就好比校长通过辅导员对学生做管理。
既然是管理数据,就一定要把学生的信息抽取出来,要多少信息取决于被管理对象,抽取信息的过程,我们称之为描述学生。Linux是用C语言写的,而学生的信息可以用一个struct来描述。将学生的信息,如姓名、性别、成绩等保存在一个结构体里面,并在结构体中维护一个指向下一个节点的指针,创建一个链表。
校长对学生的数据做管理就转化成了对链表做管理。增加学生、开除学生就可以转化为在链表中插入节点、删除节点;找到学习成绩好的那个学生,将成绩作为key值遍历链表,找到成绩最高的那个节点,该学生的所有信息都在该节点中。这就完成了对被管理者进行建模的过程!
对于校长这个管理者来讲,要做管理都必须要经过两个重要的阶段,1、先描述;2、再组织。描述就是将被管理对象抽象出来变成对应的结构体;再组织就是将所有的根据该结构定义出来的多个对象设计成特定的数据结构,将对被管理对象的管理转化成对某种数据结构的管理。这种思维方式就是操作系统对软、硬件管理的思维方式。被管理对象先描述、再组织,这种思路就叫做面向对象,面向对象用C语言中理解为struct结构体,C++语言中理解为类。面向对象先把被管理对象抽象成类,然后根据类再实例化出一个个的对象,将实例化出的对象再组织起来。(这里的组织就涉及到数据结构)。
再比如操作系统对硬件的管理,先根据硬件设备的数据,将硬件设备抽象成一个类(C语言中是结构体),并通过链表(list)或者顺序表(vector)将该类实例化出的对象组织起来。
list dev_list;//定义一个链表
struct dev disk_div;//定义一个磁盘设备
dev_list.insert(disk_div);//将磁盘设备结构体插入到链表中
struct dev keyboard_div;//定义一个键盘设备
dev_list.insert(keyboard_div);//将键盘设备结构体插入到链表中
这样就形成了底层是对应的硬件,上层是结构体构成的链表。操作系统对硬件的管理就转化成了对上层内部的数据结构的管理。假如操作系统将键盘删除,就是先遍历链表找到键盘这个节点,如果发现键盘结构体中的状态是error,就把键盘这个结构体从链表中删除。操作系统对硬件的管理就变成了对硬件描述对象的结构体做管理,也就是说对硬件的管理转化成了对数据结构的管理,这就是一个建模的过程。
总结:1、管理的本质是对数据做管理;2、要拿到的被管理者的数据是由驱动程序帮忙拿到的;3、当拿到数据之后,操作系统内部先把所有的被管理者进行描述,描述完成之后,将对应的设备用特定的数据结构组织起来。
3.进程
3.1 概念
一般课本中定义:进程是程序的一个执行实例,是正在执行的程序。(这种说法不全面)
举个生活中的例子:如果有一个社会人士想要成为某所大学的学生,那么他需要满足什么样的条件呢?只需要他本人进入到该学校,在校园内活动就可以吗?这显然是不行的。判断一个人是不是这个学校的学生,依据的是这个人有没有被学校管理,学校会不会给他排课,给他计学分、发放毕业证。
同样的,把一个可执行程序变为进程,不仅仅要把该可执行程序加载到内存中,还要让这个可执行程序被操作系统所管理。
当我们写完代码之后,编译链接就形成了一个可执行程序.exe,该可执行程序本质是二进制文件,储存在磁盘中。双击这个.exe文件或者使用./将程序运行起来就是将文件的代码和数据从磁盘加载到内存,然后CPU才能执行其代码语句。做到这一步,就相当于一个社会人士进入到了学校的校园之中,但是这样就可以叫做进程了吗?不是,这些代码和数据还需要被操作系统管理。此外,当有多个可执行程序同时在内存中加载、被CPU执行时,操作系统要对这些加载进内存的可执行程序进行管理。如:加载进内存的程序存放在内存中的哪个位置?什么时间点调度该程序?什么时间点执行该程序?
那么操作系统如何管理进程呢?遵循我们之前文章中提到的六个字原则:先描述、再组织!
1、描述
为了先描述每个进程,计算机有了PCB概念。每一个可执行程序在加载到内存中时,操作系统会在内核中创建一个数据结构对象,这个数据结构就叫做PCB(process control block)--内存控制块。在Linux操作系统下的PCB是task_struct。由于Linux是用C语言写的,所以Linux操作系统下的task_struct就是结构体。这些结构体对象中填充了对应进程的属性,并且每一个结构体对象中都有一个指向其对应代码和数据的指针,这就是先描述的过程。总之,一方面,每个进程都有加载到内存中的代码和程序;另一方面,操作系统给每一个程序匹配一个进程控制块,即一个结构体变量,指向自己对应的程序。
struct task_struct* p1=malloc(struct task_struct)
p1->... XXX进程的各种属性
p1->addr 代码和数据的地址
进程控制块结构体中包含进程所有的属性,进程的属性在可执行程序中没有。
2、组织
随着多个进程加载到内存中,操作系统内核中也就有了多个PCB结构体,以特定的数据结构将这些PCB结构体链接起来,这就是再组织的过程。比如,要找到优先级最高的进程,操作系统就会遍历所有进程的PCB,找到优先级最高的进程,然后将该进程交给CPU,CPU就可以指向该进程中的代码了。如果某个进程要退出,只需要确定该进程的PCB属性中的进程状态是否是死亡的,操作系统遍历链表,找到死亡的节点,将PCB结构体对应的代码和数据以及PCB结构体释放掉,该进程也就被释放了。
结论:进程=内核数据结构(task_struct) + 进程对应的磁盘代码。
3.2 什么是task_struct
操作系统对每一个进程进行了描述,这就有了一个一个的PCB结构体,Linux中的PCB就是task_struct(在其他操作系统中的PCB不一定叫task_struct),task_struct是一个内核结构体(即操作系统给我们提供的、用来描述进程的结构体),这个结构体中有next、prev指针,可以用双向链表将进程链接起来,task_struct结构体的部分指针也可以指向进程的代码和数据。当加载进程时,要创建对应的内核对象task_struct结构体,并将该结构体与代码和数据关联起来,从而完成先描述、再组织的工作。
3.3 为什么要有PCB(struct task_struct)
当我们启动一个程序,将磁盘中的代码加载到内存时,实际上操作系统为了管理这个进程,要为该进程创建一个对应的PCB进程控制块(即先描述、再组织),再将创建的所有结构体对象用特定的数据结构(如链表)管理起来。即所有运行在系统中的进程,都以task_struct作为链表节点的形式存储在内核中,这样就把对进程的管理变成了对链表的增删查操作。
增:当生成一个可执行程序时,将.exe文件存放到磁盘上,双击运行这个.exe可执行程序时,操作系统会将该进程的代码和数据加载到内存中;并创建一个进程,对进出描述以后形成task_struct,并插入到双向链表中。
删:进程退出就是将该进程的task_struct节点从双向链表中删除,操作系统把内存中该进程的代码和数据释放。
3.4 task_struct包含的内容
标识符:描述本进程的唯一标识符,用来与其他进程进行区分。
状态:任务状态、退出代码、退出信号等。
优先级:相对于其他进程的优先级。
程序计数器:程序中即将被执行的下一条指令的地址。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文数据:进程执行时处理器的寄存器中的数据。
I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息。
此后,所有对于进程的管理都被转换成了对数据结构PCB的增删查,这是一个对进程管理建模的过程。
如上文所述,进程的正确定义:进程是内核关于进程的相关数据结构与当前进程的代码和数据的结合。
很多教材中着重强调了当前进程的代码和数据的部分,而忽略掉了内核中相关数据结构的部分。
我们知道文件包括内容+属性,那么在操作系统中,为了管理进程所创建的PCB中的进程属性与磁盘文件的属性有关联吗?有关联,但是关联不大,有关联的部分包括文件属性中的权限、名称、大小等等,但是大部分的属性是没有关联的。
4.查看进程
4.1 ps查看
我们先编写一个程序,并把它编译形成一个可执行文件。此时,我们使用./来运行这个可执行文件就会自动创建一个进程。
1、ps ajx查看当前系统中的所有进程
如果我们只想查看某一个进程,则可以使用grep来进行过滤。
ps ajx | grep '进程名'
此时,就可以看到有一个myproc进程在运行,至于下面第二行中的gerp --color=auto myproc字样,则是因为我们在系统中查找进程时,由于grep文本过滤自己本身也是一个进程,就会导致自己把自己也给过滤出来了,并且显示在下面,如果不想看到这一行,可以通过指令grep -v grep来避免显示。如下图所示。
head -1为显示进程信息的第一行,即PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND。
PPID:父进程ID;PID:本进程ID;PGID:我的祖ID;SID:会话ID;TTY:表示对应的终端;STATA:表示我的状态;UID:我的用户ID;COMMAND:表示我是哪一个进程。
kill -9 22314 --杀掉进程22314
进程在调度运行时,进程就具有动态属性!
4.2 系统调用--getpid/getppid
通过系统调用获取进程标识符。
进程id:PID;父进程id:PPID。getpid--获取当前进程的id,getppid--获取父进程id。我们可以使用man 2 getpid/getppid命令查看该系统调用接口。
代码跑起来,同时查看当前进程的pid 和 ppid:
ps ajx查看myproc:
如下图所示,当我们第一次运行程序时,当前进程的pid是16638;第二次启动程序时,当前进程的pid变成16687;第三次启动程序时,当前进程的pid变成16725。但是父进程的ID始终都是6939,没有发生变化。
我们使用ps命令查看6939是什么进程,我们发现6939进程是当前命令行bash。一般情况下,我们所运行的进程都是都是命令行解释器的子进程。
bash在执行命令时,往往不是由bash进行解释和执行,而是由bash创建子进程,由子进程执行。所以一般情况下,我们执行的每一个命令行进程都是命令行解释器的子进程。即命令行上启动的进程,没有特殊情况的话,它的父进程一般都是bash!
如果我在代码中故意写一些bug,此时运行程序时,该程序出现异常。但是作为该进程父进程的命令行解释器依然能够正常运行,而不受到任何影响。shell在执行我们自己的程序时,是以子进程的方式去运行。
5.fork命令创建子进程
5.1 认识fork
在之前的学习中,我们熟悉的创建进程的方式有两种,第一种是在window系统下,我们双击一个.exe文件,就创建了一个进程。还有一种是在linux系统下,我们通过在命令行输入./来将程序变成进程去运行。
现在,我们再来学习一种创建进程的方式,通过系统调用函数fork。
通过man 2 fork指令来查找fork的相关手册。
手册说明:fork的返回值类型是pid_t(即有符号整数)。进程创建成功,子进程的PID会返回给父进程,0会返回给子进程。进程创建失败,-1会被返回给父进程。
5.2 使用fork创建进程
这里fork后,后面的代码一定是被上面的父子进程共享的。换言之,每次循环都会被父子进程各自执行一次。
可以看到在fork之前,当前进程的pid是32230。fork之后,有两个进程,32230的父进程是6488;32231的父进程是32230。而且6488进程是bash。换言之,父进程fork创建了子进程,谁调用fork,谁就是父进程。
5.3 理解fork创建子进程
5.3.1 从用户的角度来理解fork
通过上面的代码可知fork可以创建子进程,也就是说fork函数执行之后,这个子进程才能被创建成功,父进程和子进程都要执行一样的代码。但是fork之后,父进程和子进程谁先执行,不是由fork决定的,而是由系统的调度优先级决定的。
父、子进程共享用户代码--只读的;而用户数据各自私有一份--比如使用任务管理器,结束掉Visual Studio进程,并不会影响到XShell。一个进程出现了问题,并不会影响到其他进程。操作系统中所有进程具有独立性,这是操作系统表现出来的特性。
而将各自进程的用户数据私有一份,进程和进程之间就可以达到不互相干扰的特性。这里私有数据的过程并不是一创建进程就给你的,而是采用写时拷贝的技术,曾经在C++中的深浅拷贝中谈过,这里后面再详谈。
5.3.2 从内核的角度理解fork
fork之后,站在操作系统的角度就是多了一个进程,通过前面的学习,我们知道 进程=程序代码+内核数据结构(task_struct)。其中,操作系统需要先为子进程创建内核数据结构,在系统角度创建子进程,通常以父进程为模板,子进程中默认使用的是父进程中的代码和数据(写时拷贝)。
5.5 fork后的数据修改
此时,我们可以看到:两次打印的ret值不同,为什么一个函数会有两个返回值呢?这两个ret的地址相同,说明他们是一个变量,但是为什么打印出了两个不一样的值呢?
首先我们需要知道fork做了什么,进程=内核数据结构+进程的代码和数据,当我们创建子进程时,并不是把代码和数据又拷贝了一份。而是在内核中再创建一个子进程的PCB结构体,子进程的PCB中的大部分属性会以父进程PCB为模板,并把属性信息拷贝进来。
父进程的PCB指向自己的代码和数据,子进程的PCB也指向同样的代码和数据。所以fork就相当于在内核中创建独立的PCB结构,并让子进程与父进程共享一份代码和数据。
进程在运行时具有独立性,任何一个进程出现故障都不会影响其他进程,父子进程运行时也是一样。
代码是不可以被修改的,那么数据呢?子进程和父进程共享数据,当父进程修改数据时,子进程看到的数据也被修改了,那么父进程就会影响到子进程。那么这两个进程还具有独立性吗?
代码:代码是只读的,所以进程无法修改代码,也就无法相互影响。
数据:当有一个执行流尝试修改数据时,这时候作为进程管理者,同时也是内存管理者的操作系统就要出来干涉了。操作系统会自动给当前进程触发一个机制:写时拷贝。简单说就是在写入时,操作系统会把该数据在内存中重新开辟一块空间拷贝一份。此时,写入、修改就在这个备份上执行,而不会修改原始数据,从而在数据上也能保持无法相互影响。
写时拷贝是为了维护进程独立性,为了防止多个进程运行时互相干扰。而在创建子进程时不会让子进程把父进程的所有数据全部都拷贝一份。因为并不是所有情况下都产生数据写入,所以这就避免了fork时的效率降低和浪费更多空间的问题。因此,只有写入数据时再开辟空间才是合理的。
5.6 fork的返回值
fork出子进程后,一般会让子进程和父进程去干不同的事情,这时候如何区分父子进程呢?fork函数的返回值如下:
当一个函数准备执行return语句时,该函数的主体功能就已经完成了。return语句不影响函数的功能,仅仅起到返回结果的作用。因此,fork系统调用函数在执行return语句之前,子进程就已经创建完成并已经在运行中了。所以当执行return语句返回结果时,就要给父进程和子进程各自返回一个结果,即执行了两次。最终返回结果被赋值给变量ret时,操作系统就自动出发了写时拷贝,分别把结果存入两者的备份空间中。至于为什么打印出来的ret的地址是相同的,这与虚拟地址有关,后面会详细介绍。
给父进程返回子进程的pid的原因是,一个父进程可能有多个子进程,子进程必须得用pid进行标识区分,所以一般会给父进程返回子进程的pid来控制子进程。子进程想知道父进程pid可以通过get_ppid()来获取,这样就可以维护父子进程了。
总结:
1、fork有两个返回值;
2、父子进程代码共享,数据各自开辟空间,私有一份。(采用写时拷贝)
6.使用fork的方式
一般情况下,我们使用fork创建子进程之后通常要用if进行分流。
fork之后,执行流会变成两个执行流,谁先运行由调度器决定。父子进程通过if分流分别执行不同的代码块,进而实现不同的功能,如上图所示。
结论:1、fork()之后,会有父进程+子进程两个进程在执行后续代码!即fork后续的代码,被父子进程共享!
2、通过返回值的不同,让父子进程各自执行后续共享代码中的一部分!
7.进程状态
7.1 从操作系统层面理解进程状态
进程在CPU上运行时,并不是一直在运行,而是一个进程先在CPU上运行一段时间,再切换至另外一个进程在CPU上运行一段时间,不断的切换进程周而复始重复运行,这叫做基于进程切换的分时操作系统。由于CPU的运行速度非常块,切换速度使我们感觉不到,从而给人们有种进程一直在运行的感觉。而CPU会去调用哪一个进程,是由进程的状态决定的。
一个进程从创建而产生到因撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器、有时虽有空闲处理器但等待某个事件的发生而无法执行,这说明进程和程序不相同,它是活动的且有状态变化的,能够体现一个进程的生命状态,可以用一组状态来描述:
首先,我们搭建一个操作系统的宏观概念。
当某个进程在计算机开机之后就启动起来了,该进程会被加载到内存中(即操作系统内部)。操作系统为了管理该进程,会对该进程进行“先描述、再组织”。如果该进程要在CPU上运行起来,CPU在内核中必须维护一个运行队列,该队列就是对进程的运行进行管理。
由于CPU的个数一定小于进程的个数,那么一定会有多个进程在同一个CPU中运行,CPU的资源有限,进程就会先排队。我们有以下结论,稍后会对该结论进行解释。
1、一个CPU有一个运行队列;
2、进程进入运行队列的本质是将该进程的task_struct结构体对象放入运行队列中。即进程在运行队列中排队,不是让进程的代码和数据(可执行程序)自己去排队,而是让该进程的PCB结构体去排队;
3、进程的PCB结构体在运行队列中,就是运行状态;而不是这个进程正在运行,才是运行状态;
4、不要只认为进程指挥等待(占用)CPU资源,进程也可能随时随地要外设资源;
5、进程的不同状态,本质是进程在不同的队列中等待某种资源。我们把在CPU等待队列中等待CPU资源的进程叫做R(运行状态)进程,等待外设的进程的状态叫做阻塞状态。
7.2 运行状态
操作系统对硬件的管理方式是先描述、再组织,每一个被操作系统管理的硬件都有自己相应的硬件描述结构体。比如:磁盘中保存着各种各样的可执行程序文件,当该可执行程序文件加载到内存中时,操作系统就会对该进程进行管理,会创建一个struct task_struct结构体,该结构体中保存了该进程所有的属性。
将磁盘中的bin.exe可执行程序加载到内存中,内存中就会有对应进程的代码和数据。操作系统创建一个PCB结构体来管理这个可执行程序,该结构体中包括该进程的所有属性。CPU调度内存中的可执行程序,所谓调度就是可执行程序对应的PCB结构体的最终状态是R状态(运行状态),并放入到运行队列中。
7.3 阻塞状态
进程因为等待某种条件就绪,而导致的一种不推进的状态叫做阻塞状态,给人们最直观的感受就是程序卡住了。换句话说,一个进程阻塞,一定是在等待某种所需要的资源就绪的过程。
比如:我们在下载一些资料时,如果网断了,CPU还有必要继续调度这个下载进程吗?肯定是没有必要了,此时就会把该进程设置为阻塞状态。这个进程是如何等待网络资源就绪呢?
我们之前讲过,操作系统要管理网卡、磁盘等外设,是一个先描述、再组织的过程。操作系统创建多个结构体类型,这里命名为struct dev,并把各个外设的属性信息提取填充进来,再用对应的数据结构将这些结构体链接到一起。同样,操作系统管理大量的进程也是一个先描述、再组织的过程。
当网络断开时,需要等待网络资源的进程就会把自己的PCB结构体从CPU的某些特定队列中拿出来,链接到网卡设备结构体队列的尾部来排队等待网络资源。
此时,该进程在获取到等待的资源之前,该进程不会再被CPU调度。
PCB结构体是可以被维护在不同的队列中的,进程在等待哪种资源,就会被排列到哪种资源的队列中去。比如:当我们在C语言中使用scanf函数时,运行程序,如果我们不在键盘上输入内容,进程就会处于阻塞状态,并在键盘的等待队列中排队等待资源,只有拿到数据时,进程才会再次被CPU调度。
再举个例子,如磁盘相对CPU来说运行速度很慢,但是进程或多或少都会访问磁盘。相对于进程来说,硬件的数量很少,会出现多个进程来访问一个磁盘的情况。当磁盘正在被某一进程使用时,其他进程只能等待。
总结:阻塞就是不被CPU调度---一定是因为当前进程需要等待某种资源就绪---一定是进程test_struct结构体需要在某种被操作系统管理的资源下排队。
如果某一进程是阻塞状态,并且该进程在等待磁盘的资源。此外,如果系统中存在大量的处于阻塞状态的进程,那么一旦阻塞了该进程会不会立即被调度呢?不会。
因为该进程是在等待磁盘资源,当磁盘准备好了,该进程首先是要由阻塞状态改为运行状态,然后再把该进程的PCB结构体放入到运行队列中(操作系统做这些事)。后面CPU会自动在运行队列中选择相应的进程进行调度,总会调度到该进程。该进程一旦被调度,就可以直接访问磁盘了。
如果有若干个进程都在阻塞,即便磁盘已经准备好了,这些进程不会被直接运行,而是先把这些进程重新放入到运行队列中,阻塞状态的进程通常也会等待很长时间。
7.4 挂起
PCB结构体是要占有内存的,此外可执行程序文件也会占用内存。可执行程序的代码和数据从磁盘加载到内存中,假如该进程处于阻塞状态,这个处于阻塞状态的进程的代码和数据依然在内存中加载的话,就可能会出现内存资源紧张的情况,那么还要加载新的程序,怎么办?
如果有时出现了内存资源紧张,而且阻塞进程的PCB被接入到了所需要等待资源的结构体队列中,而不被调度。此时,操作系统就会把处于阻塞状态的进程的代码和数据不在内存中放着了,而是暂时挪走保存到磁盘中,同时释放该进程代码和数据在内存中占据的空间,从而起到节省内存空间的目的。等到进程所需要的资源就绪时,再把该进程的代码和数据加载到内存中,交由CPU调度。
把一个暂时将代码和数据换出到磁盘中的进程,称该进程处于挂起状态。这个进程的PCB结构体依然在内存中,但是该进程的代码和数据暂时还不用,并被置换到磁盘中保存。操作系统就可以把这部分节省出来的内存空间给别的进程使用。
将这些进程的代码和数据放到磁盘或者重新加载回内存(将进程的相关数据加载或者保存到磁盘)就叫做内存数据的换入换出。阻塞不一定是挂起、挂起一定是阻塞的!还有阻塞挂起状态。
8.Linux操作系统中的进程状态
后期我们主要是以Linux2.6为主来学习,因为它匹配的书籍比较多。其中task_state_arrsy[]里描述的是Linux的进程状态。
8.1 R状态(运行状态)
某个进程是R状态,那么该进程一定在CPU上运行吗?
进程在运行队列中,就叫做R状态,也就是说进程想被CPU运行,前提条件是该进程必须处于R状态,R状态:我准备好了,你可以调度我。该进程的状态是R+。
下面的代码是死循环,但是进程的状态却是S(阻塞状态)。为什么呢?
printf函数回访问显示器,而显示器是外设,外设的速度比较慢。该进程要等待显示器就绪,要花费比较长的时间。该进程99%的时间都在等待IO就绪,1%的时间在执行打印代码。当你去查询进程的状态时,大概率查询到的进程状态是S状态。
该进程可能只有万分之一的时间在运行,其他时间都在休眠,站在用户的角度该进程一直都是R状态,但是对于操作系统来说可能只有一瞬间才是R状态。
8.2 T状态(暂停状态)
T状态名为暂停状态,也是一种阻塞状态,因为此时没有代码在运行了,但是暂停状态的进程有没有被挂起,不知道,这完全是由操作系统自主决定的。我们在调试程序时,让程序在断点处停下来,本质上就是让进程暂停。
8.2.1 kill指令
查看kill指令
在这里主要使用编号9、18、19的命令选项,功能分别为杀死进程、继续进程、暂停进程。
8.2.2 暂停进程、继续进程、杀死进程
我们先运行进程,并查看进程状态,查看到进程状态是R+。
现在我们使用kill -19 [进程PID]
可以查看到此时进程状态从R+变成了T。
接着使用指令:kill -18 [进程PID]
使用kill指令恢复进程后,可以发现进程状态从原来的R+变成了R,并且使用ctrl+c已经没有办法结束进程了。进程状态的+号表示前台运行,没有+号表示后台运行,ctrl+c只能结束前台运行的进程。
此时我们需要使用指令:kill -9 [进程PID】来结束进程。
像这种正在运行的进程,无论我们输入什么命令,如ls、pwd等,该进程依然会运行,但是crtl+c能够将该进程终止,这种进程称之为前台进程。即当前台进程在运行时,shell命令行就无法再获取命令行解析了。
当某一进程正在运行,使用kill -19 [进程pid]暂停该进程,该进程的状态为T状态。
此时,我们继续使用kill -18 [进程pid]重新运行该进程,此时该进程的运行状态变为S,没有了+号。
此时,我们在命令行窗口中输入ls、pwd等指令,这些指令都能响应,而且该进程一直在运行。此外,ctrl+c也终止不了该进程。该进程已经变成了后台进程,状态后面没有+号的,叫做后台进程。后台进程不能用ctrl+c终止,可以kill -9 [平台pid]来杀掉后台进程。
8.3 D状态(深度睡眠状态)
进程A王磁盘中写入一万条数据,磁盘将数据写完后并向进程A反馈是否写入成功。
磁盘的运行速度比较慢,此时进程A在等待磁盘将数据写完。此时,如果内存不足,操作系统采用将进程挂起的策略将内存空间腾出,让其他进程使用。如果挂起也解决不了内存不足的问题,Linux操作系统会自主杀掉进程。此时操作系统将进程A杀掉了。磁盘将数据写完之后,并报告给进程A数据写入失败了,你要不要报告给上层。此时由于进程A被杀掉了,磁盘找不到进程A了,进程A对磁盘的请求没有响应了。磁盘要写入的一万条数据的流向就不确定了,丢掉了。用户发现一万条数据不见了,那么是磁盘、进程A、操作系统谁的问题造成数据丢失的?不知道。
此时,系统内设计了一个新的状态:D(disk_sleep)状态--深度睡眠状态。深度睡眠状态对应的是在该状态下的进程,无法被操作系统杀掉,只能通过断电、或者进程自己醒来,来解决。
只有在高IO的情况下,才可能出现D状态的进程!
注意:进程状态,常见的操作系统理论中有运行、挂起、阻塞等概念,其他的进程状态概念是不同的书不同的叫法,重点是将运行、挂起、阻塞搞清楚。
linux系统中的R状态就是运行状态,在进程的生命周期中,有些进程属于计算密集型的进程,比如说做加密解密或者是一大串数据的运算,计算密集型的进程非常依赖CPU资源,所以大部分情况下,计算密集型的进程是R状态。有些进程属于IO密集型的进程,更多是在和外设交互,比如cout、cin、printf等程序,访问文件的操作等,IO密集型的进程一瞬间就运行了,大部分时间在等待外设资源,这类进程的状态大部分时间都是S状态。S状态在操作系统级别就是一种阻塞状态。
8.4 t状态(tracing stop)
当你使用vs of gdb调试代码时,比如你打了一个断点,然后开始调试,此时在断点处停下来的状态就是t状态,这里小写的t是为了和上面的T状态区分。
8.5 S状态(浅度睡眠状态)
浅度睡眠状态,ctrl+c可以直接终止浅度睡眠状态的进程。
8.6 僵尸进程
僵尸进程的感性认识
进程被创建出来的目的是为了完成某项具体的任务,完成该项任务会有两个过程:1、要知道该项任务完成得如何?2、也可以不关心该项任务完成的结果。
当一个进程执行完了,我怎么知道该进程是如何完成的某项任务的?(一般是操作系统或者父进程关心进程是如何完成的)。当a进程退出时,不会立即释放该进程对应的资源,会保存一段时间,让父进程或者操作系统读取。
假如一个人意外倒地,旁边的人发现后报警,警察到达现场后会先判断该人是否已经死亡。当警察一旦判断该人死亡了,首先要做的是封锁现场,并进行医学鉴定确认该人是因为什么原因去世的,最后通知家人准备后面的事情,即从检测的状态变为后续可以回收的状态。
这里的警察就相当于操作系统或者父进程,倒下的人相当于一个进程退出了。该进程退出之后,不会被立即回收,而是要等一等,让操作系统或者父进程获取它的退出结果。退出之后,再将该进程的状态由Z状态变为X状态,然后被释放掉,供系统回收。X状态我们看不到,当某个进程为X状态,就意味着该进程的全部资源可以被释放了。
即人从倒下的那一刻,到警察检查完,这个时间段内所处的状态就叫做僵尸状态。
一个僵尸进程的存在是正常的,它属于进程正常状态的一种,它已经退出了,但是它当前对应的资源没有被全部释放,还有一部分资源一定要保存起来,这部分保存起来的资源供我们的上层去读取,得知该进程是因为什么原因退出的。
僵尸进程的演示:
此时有两个进程,父进程是19345,子进程是19346,刚开始运行时,这两个进程的状态都是S+。当运行了5秒之后,此时父进程19345依然是运行状态,子进程19346退出了,进程状态为Z+,即为僵尸进程。子进程的后面多了defunct,即失效的。即该子进程已经死掉了,只是没有被回收。
当ctrl+c终止掉该进程时,就不存在父子进程了,这是因为该父子进程被终止掉后,资源就被操作系统回收了。
僵尸状态:进程退出了,但是没有被回收(进程回收一般是父进程或者操作系统来做)。上面的例子中创建了一个子进程,让父进程不要退出,而且什么都不做,让子进程正常退出。此时,该子进程就会处于一个僵尸状态。
一个进程再运行时,进程等于内核数据结构对象+对应的代码和数据。当子进程退出时,代码和数据就不会被执行了,它可以被操作系统释放掉。操作系统为了维护和管理这个进程,就保留为这个进程创建的PCB结构体。
僵尸进程的退出的结果会写到进程的PCB结构体中,一个进程退出了代码和数据可以被释放,但是该进程的PCB是没有被释放的。当父进程或操作系统永远不去释放该退出进程,并回收其资源。即操作系统不回收僵尸进程会存在内存泄漏的问题。
僵尸进程的危害
1、进程的退出状态必须被维持下去,因为它要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。如果父进程一直不读取,那子进程就一直处于Z状态。
2、维护退出状态本身是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护。
3、如果一个进程创建了很多子进程,就是不回收,会造成内存资源的浪费。因为数据结构对象本身就要占有内存。比如,C语言中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间。
8.7 孤儿进程
有如下代码,有父子两个进程。
该运行该代码后,父子进程同时运行。
当我使用kill -9 [父进程pid]杀死父进程后,就只剩子进程了。但是这里为什么没有见到父进程的僵尸状态呢?因为这里父进程也有自己的父进程bash,这里bash将父进程的资源回收了。此外,本来子进程28162的父进程是28161,当杀死父进程18161后,子进程28162的父进程变成了1,1号进程就是操作系统。当一个子进程和父进程在运行时,当父进程先退出了,那么这个子进程被操作系统(1号进程)领养了,这个被领养的子进程称为孤儿进程。
父进程先退出
1、这种现象一定是存在的。
2、子进程会被操作系统领养。(操作系统是1号进程)
3、子进程为什么要被操作系统领养?如果该子进程不被操作系统领养,那么子进程退出时,就变成了僵尸进程,此时该僵尸进程就没有父进程能够回收了。(操作系统为什么要回收该子进程呢?因为操作系统要管理里该子进程,操作系统将该进程创建出来,为其分配资源,当该进程退出时,需要将资源还给操作系统。)
4、被领养的进程,就叫做孤儿进程。
5、如果是前台创建的子进程,该子进程如果变成孤儿进程了,该子进程会自动变成后台进程。此时,只能通过使用kill -9 [进程pid]命令行的方式来杀掉该子进程。
9.进程优先级
1、优先级和权限的区别
权限决定的是能和不能的问题;优先级首先是能,后面是先做还是后做的问题。
优先级的概念:先还是后获得某种资源的能力。
2、为什么存在优先级?
存在优先级的原因是,资源太少了。
3、Linux优先级的特点
优先级的本质就是PCB结构体中的一个整数数字(也可能是几个),这个整数就是优先级。
Linux系统中的优先级:最终优先级=老的优先级+nice。Linux支持进程运行中,进行优先级的调整,调整的策略就是更改nice的值。nice的取值范围是[-20,19]。
只要去修改Linux系统的进程优先级,该进程老的优先级都是从80开始,这样就能很好的去限制最终优先级一定是在[80-20,80+19]这个范围内,从而不会出现任何奇怪的现象。比如:某个进程A的初始优先级是80,nice调整为-100,最终的进程优先级为80-20=60,即nice值最小可以设置为-20。此时,继续修改进程A的nice值为100,最终进程A的优先级变为80+19=99,即nice值的最大值为19。继续将进程A的nice值修改为9,此时进程A的优先级变为了80+9=89。这就奇怪了,这里进程A的优先级怎么不是99+9=108?因为如果是这样设置优先级的话,就会超过优先级设置的范围了。每次设置进程优先级时,该进程老的优先级都是从80开始的。即只要你尝试去设置进程的优先级,老的优先级都是从80开始的。这样就可以限制最终优先级一定是在80-20和80+19的范围之内,不会出现任何奇怪的情况。
为什么调整优先级要设置调整范围为[-20,19],优先级的本质就是PCB文件中的一个整数数字,既然是一个数字,那么为什么不把这个数字调整的特别大或者特别小呢?
因为优先级是我们确定进程获取资源先后顺序的,如果允许用户无休止的调整优先级,可能会导致操作系统调度失衡。所以允许调整优先级,但是不允许无休止的调整优先级。因为一旦无休止的调整优先级,调度器的工作就无法做了。
10.其他概念
竞争性:因为计算机资源是有限的,所以进程之间存在竞争性,所以进程必须要有优先级。数据结构中的队列就是确认进程优先级的一种方式,进程排队是确认优先级的一种底层机制。进程排队是进程的PCB结构体在排队。
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。
并行:当计算机只有一个CPU时,在任何时刻计算机只能有一个进程正在运行。当计算机有双CPU时,那么在任何时刻计算机至少有两个进程在同时运行。我们将多个进程在多个CPU下分别、同时运行,称之为并行。
并发:多个进程在同一个CPU下运行,不是说当某个进程占有了CPU之后,该进程直到运行完成,才从CPU上拿下来。当代计算机采用时间片轮转的策略,即无论某进程未来执行完要花多少时间,一次只给该进程10毫秒的时间来只能由CPU,1毫秒的时间到了,该进程必须要从CPU上剥离下来,然后放在运行队列的尾部,再继续重新排队,操作系统重新调度其他进程进入CPU运行。多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
11.进程切换
操作系统为了控制进程的执行,必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行,这种行为被称为进程切换,任务切换或上下文切换。
在CPU的内部存在大量的寄存器,虽然寄存器很多,但是只有一套硬件寄存器。寄存器分为用户可见和用户不可见两类。(寄存器可以存数据)
当操作系统要调度某个进程时,CPU会把该进程的PCB地址加载到对应的某个寄存器中,所以寄存器是可以直接找到该进程的PCB的,找到PCB了该进程的代码和数据就能找到,然后就可以执行该进程的代码和数据。
关键寄存器:pc/eip。CPU永远都在做三件事:1.取指令;2.分析指令;3.执行指令。
CPU不会主动去执行代码,操作系统来安排CPU去做很多工作。当CPU在执行代码时,要通过pc/eip寄存器中的指针找到该指针指向的某行代码。这里的指令指的是代码中的指令。
所以eip寄存器中保存的永远都是当前正在执行指令的下一条指令的地址。即CPU中存在寄存器专门用来标识CPU下一次应该从当前进程的什么位置读取对应的代码或者指令。
当进程在运行时,一定会产生非常多的临时数据,这份数据属于当前进程。CPU内部虽然只有一套寄存器硬件,但是寄存器中保存的数据数属于当前进程的。寄存器硬件不等同于寄存器内的数据。进程在运行时,占有CPU,但是进程不是一直要占有到进程结束。进程在运行时,都有自己的时间片。
因为进程有时间片,所以进程在单CPU中运行时,有可能会出现进程没运行完,就被拿下去的情况,此时要考虑该进程下次要重新运行的事情。进程离开CPU,要保留CPU内寄存器中的运行数据。注意:不是保留寄存器,即寄存器不是这个进程的上下文数据,寄存器中的数据才是进程的上下文数据。即某个进程从CPU中被拿下时,要将CPU中的该进程的上下文数据临时保存到PCB(我们认为保存在PCB,但是不太准确)。然后再继续运行下一个进程,该进程也产生很多临时数据,当该进程的时间片到了之后,将该进程产生的临时数据也保存一下。
当没执行完的进程重新回到CPU中继续运行时,不是立即要运行该进程,而是将曾经保存的进程的临时数据重新跟新恢复起来。继续从上个时间片段代码中断的地方继续运行。
进程在切换时,要进行进程的上下文保护,当进程再恢复运行时,要进行上下文的恢复。这里的上下文指的是寄存器中的数据,而不是寄存器。
在任何时刻,CPU里面的寄存器中的数据,看起来是保存在所有人都能看到的寄存器上。但是,寄存器中的数据,只属于当前运行的进程。即寄存器被所有进程共享,但是寄存器中的数据(上下文数据),是每个进程各自私有的。
也就是说,CPU内部的寄存器是一个硬件,是被所有进程共享的。就好比宿舍是CPU,宿舍中的床、桌子是寄存器,学生是进程,学生住进宿舍,学生的电脑、书本是该进程产生的上下文数据。当学生离开宿舍,学生的电脑、书本要带走,留下空间给下一个学生使用。即宿舍是被所有人共享的,但是宿舍中的电脑、书本等是学生个人所有的。学生走的时候带走个人的东西,来的下一个学生带进来自己的东西,保证进程能够正常运行。就好比进程进入到CPU产生上下文数据存储在寄存器中,进程结束时将数据临时保存在PCB中(暂时这样理解)。
比如:某个函数内部定义了一个result变量,经过计算将result的值通过return返回,函数内部定义的变量的生命周期是当前函数。当执行return时,该函数的生命周期已经结束了。但是函数的外部是怎么拿到该函数内部定义的result变量的值呢?result变量的生命周期是跟随这个函数的,函数调用结束,变量会自动释放。实际上外部是通过寄存器来拿到result的值,一般是通过eip寄存器将result值返回。