文章目录
- 冯诺依曼体系结构的理解
- 为什么要有内存的存在?
- 操作系统的管理
- 进程的理解
- 系统调用接口
- 进程的查看
- fork
- 进程状态
- Linux进程具体的状态
- 孤儿进程
- 总结
- 进程优先级
- 怎样修改优先级?
- 进程其他概念
- 进程抢占
- 进程地址空间
- 利用代码验证地址区域
- 验证堆区和栈区的增长方向
- mm_struct
- 怎么理解一块数据区的属性为只读
- 程序是怎么变成进程的(进程是如何创建的)?
- fork为什么能返回两个值?
- 虚拟地址空间的意义
冯诺依曼体系结构的理解
大部分计算机都遵守冯诺依曼体系,该体系将输入设备(键盘,鼠标,写板)和输出设备(显示器,打印机)统称为外设,运算器和控制器组成了cpu中央处理器,存储器则是计算机的内存。
cpu与外设的交互较少,大多数时间与存储器进行交互,所以存储器,也就是内存,在体系结构中的地位十分重要。
为什么要有内存的存在?
从技术角度:输入设备和输出设备的运行速度远远低于cpu,如果没有内存,输入设备就只能直接向cpu写入信息,但写入的速度很慢,cpu处理数据的速度却很快,cpu处理完数据向输出设备输出,输出设备接收数据后将其输出的速度也是很慢的,这就造成了cpu有大段时间闲置,输入设备向cpu写了8h的数据,但cpu只1h就将数据处理完了,cpu在剩下的7h无事可做,这样就造成了时间的浪费。
而内存作为cpu和IO设备之间的缓冲设备,内存的速度快于IO设备,慢于cpu,因此,内存就像是一块很大的缓存,它的存在是为了适配外设和cpu速度不均的问题。并且,内存可以预装载数据,当cpu需要时直接将数据给cpu(预装载,局部性原理:当你打开一份文件时,操作系统大概率的认为你要读取该文件下的数据,因此会将这个文件的所有数据预装载到内存中,当你访问文件下的数据时,因为这些数据早就存储在了内存中,现在只需要内存将数据写入cpu,这样一来整个体系结构的速度就被提高了)。
从成本的角度:cpu有存储数据的寄存器,外设也可以将数据写入到寄存器中,但寄存器的价格昂贵,能存储的数据量小,虽然内存的速度低于寄存器,但内存的价格便宜,可以以较低的成本使用大容量的内存,内存的出现很好的解决了价格这一痛点,使得计算机更好的普及。
所以冯诺依曼体系可能不是速度最快的,但却是最适合大部分人,最容易普及的。
之前总听到一句话:所有程序要运行必须先加载到内存中,为什么?因为冯诺依曼体系结构决定,一个程序被保存在磁盘中,在这种情况下,磁盘作为输入设备不能和cpu进行交互,所以磁盘要先将该程序加载到内存中,通过内存间接的与cpu交互。
操作系统的管理
硬件是死的,硬件需要被正确的使用才能完成功能,在计算机中,硬件被操作系统使用
换言之,操作系统作为一款软件,是用来管理这些硬件的。这里需要解释一下管理:管理的本质是对数据的管理,比如校长对学生的管理,校长不对学生进行直接的管理,而是通过数据(考试成绩,绩点排名…)进行管理,对于需要调整的数据,将调整的任务下派给辅导员或者其他老师,借由他们的手对学生进行管理。操作系统也是如此,操作系统不与硬件直接交互,而是对这些硬件的数据进行管理,对于需要调整的数据就借由相应的驱动进行管理。
操作系统管理数据的方式就是:先描述对象的属性,再组织这些属性,一个程序运行起来后,操作系统根据程序的属性进行描述,假设这个程序被描述成一个节点,每个程序都被这样描述后,用链表或其他高效的数据结构将它们组织起来,对这些程序的管理就变成了对该数据结构的管理(增删查改)。
进程的理解
一个程序运行起来后就叫做进程,这句话似乎难以理解,更准确的说,程序运行后,被操作系统描述成一个数据结构,并且由于体系结构决定,程序的相关代码需要被加载到内存中,我们常说的进程就等于对应的数据结构 + 内存中的相关代码。
在Linux中,用task_struct描述一个程序运行后对应的数据结构(Linux底层是用c写的,c语言用struct描述一个复杂对象),在windows中则是用PCB (process ctrl block)描述程序运行后对应的数据结构。
系统调用接口
为了防止恶意或非恶意的对代码的破坏,操作系统不对外暴露自己的代码逻辑与数据结构,只对外提供接口,而Linux系统是由c语言写的,这些接口本质上是c语言函数,所以学习系统编程,其实是对系统调用接口的学习。
站在系统的角度上,平常编写的c/c++代码也调用了系统接口,比如printf,cout,代码在编译的最后与库进行链接,库里的函数实际上调用了系统的接口(因为你需要使用显示器这个硬件设备,需要经过操作系统)。除此之外还有Linux中的指令,shell外壳,也是对系统接口的调用。
当然,不同操作系统对外提供的接口是不同的,所以代码想要具有良好的跨平台性,就需要该语言兼容了多个操作系统,即同一个语言接口可以调用不同操作系统的接口。
进程的查看
这段代码将死循环执行一条打印语句,那么编译过后的代码形成了一个exe文件,该exe文件运行起来后,成为了一个进程,复制当前会话,在另一个窗口查看exe进程
用ps axj查看当前进程,ps(process status,进程状态)。在查询结果中使管道,用grep 'a.out’过滤出包含a.out的进程,grep -v grep,过滤掉含有grep的进程。
ps axj | head -1,输出ps axj文件的第一行,&&的意思是前面的指令执行完执行后面的指令
根目录下的proc里存储了当前进程的详细信息。
通过进程的pid查看进程的详细信息,其中cwd指的是当前工作路径,也就是所谓“当前路径”,如果程序默认生成了文件,将放到这个路径下。
该进程每次被分配的pid都不同,但父进程的pid都是31686,这是因为在命令行上运行的所有程序都是bash进程的子进程。
fork
使用fork函数创建子进程
fork通过复制当前进程,创建子进程
运行上面的代码,同一条printf语句却打印出了两行不同的内容,为何?
同样的,一段代码即走了if还走了else
fork后的代码为什么会被执行两次?
fork函数为当前进程创建一个子进程,子进程与父进程共享代码,所以两个进程都会执行fork之后的代码,这就是printf为什么被打印两次。调用fork后,父子进程的返回值不同,父进程的fork返回子进程的pid,子进程的fork返回0,可以根据不同的返回值进行if,else的判断,使父子进程执行不同的代码块。
fork函数是怎样返回两个值的?
上面说了fork后的代码被执行了两次,fork本质是一个函数,函数的最后一定有个return语句,当函数要执行return语句时,说明函数的功能已经完成,准备返回了。而fork函数的主要功能是:1.创建子进程,生成对应的task_struct。2.将task_struct放入cpu的运行队列中。 父进程执行fork函数时,在fork返回之前已经生成了子进程,由于父子进程共享代码,所以fork函数的return语句一定会被子进程执行,fork函数的return被两个进程分别执行了一次。
fork为什么给子进程返回0,父进程返回非0?
子进程的父进程唯一,所以子进程不用特别标识出父进程,返回0是用来标识子进程创建成功的,但父进程的子进程不唯一,一个父进程可能有n个子进程,给父进程返回子进程的pid是为了让父进程知道自己有这个子进程。
进程状态
运行态:
系统为需要运行的进程创建了一个运行队列,运行态不是指进程被cpu运行,而是指进程被放到运行队列中,表示该进程随时准备被运行
终止态:
终止态也不是指进程被释放,而是进程执行完,可能还在运行队列中,只是永远不被运行了,随时可能被释放
进程被执行完成,为什么不立即释放,而要维护一个终止态?
操作系统可能忙于其他进程的执行,释放进程需要消耗资源与时间,所以等待其他更重要的进程执行完,再来释放。
阻塞态:
当一个进程将要被执行时,对应的task_struct被放到runqueue运行队列中。但进程除了需要访问cpu,可能还要访问一些外设,如显卡,声卡,磁盘,这些外设的速度很慢,如果要访问的外设被其他进程占用,该进程就需要等待外设资源,此时进程的task_struct从cpu的runqueue被放到对应外设的waitqueue上,进程被放到外设的等待队列后的状态被称为阻塞态
挂起态:
当内存不足时,runqueue不能再添加task_struct,这时操作系统会进行辗转腾挪的操作,将短时间内不会被执行的进程置换到磁盘中的swap分区,这时的进程状态叫做挂起态,当内存空闲时再置换回来。所以当计算机内存不足时,往往伴随着磁盘的高频访问。
Linux进程具体的状态
R,S,D
// Linux中的7种进程状态
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 */
};
这段代码死循环的执行打印,通过查询,代码的进程状态为S+状态
S状态为Linux中的浅休眠状态,即可以被唤醒也可以被杀死,S对应着阻塞态。由于sleep的缘故,该进程在显示器的等待队列中阻塞,直到sleep完成再运行,如果将代码中的sleep去掉
查询该进程状态,结果还是S状态,即阻塞态,原因 是cpu执行该进程的速度远大于显示器的打印速度,以至于该进程依然在等待显示器,等待其完成前面的打印。
去掉代码中的打印,进程会一直保持运行态,对应Linux中的R状态
如果进程等待的资源是磁盘,那么该进程就会处于D状态,D状态也是一种阻塞状态。
为什么有了S状态还要有D状态?
一个进程向磁盘读写数据,需要磁盘的反馈,即告知是否操作成功,若不告知可能导致数据丢失:当磁盘执行进程的指令时,若进程处于S状态,阻塞在等待队列中,在内存压力过大时,操作系统会终止用户进程,终止的进程可能就是这个在等待磁盘读写完成的进程(除了操作系统,该进程还有可能被手动杀死),而磁盘写入完成后,发现该进程被终止,用户也就无法知道是否读取成功。所以D状态被设计出来,用来表示深度睡眠,操作系统无法杀死或唤醒,直到磁盘读写完成,该进程自动唤醒。
Z,X
X状态:进程执行完成,随时准备被释放,即终止态
Z状态:是Linux中独有的状态。当Linux的一个进程退出时,该进程不会直接进入X状态,而是进入僵尸态Z,为何?操作系统中每一个进程都有一个父进程,子进程的完成情况是怎样的,需要告知父进程,根据情况操作系统再进行相关的调整。所以一个进程执行完,要进入Z状态,维护该进程的信息,让父进程读取
可以模拟出一个僵尸进程,使用fork函数,用if判断将子进程退出,父进程什么都不做,这时子进程的状态就是Z
如果一直没人回收僵尸进程,那么该进程会一直占用系统资源,从而导致内容泄漏,所以在编程时,需要特别注意子进程的情况,防止出现僵尸状态。
(状态后跟+,表示这是一个前台进程,可以用ctrl+c停止,后台进程只能用kill -9 xxx杀死,xxx是pid)
孤儿进程
如果子进程的父进程退出了,那么这个子进程就成了孤儿进程,孤儿进程将由操作系统也就是1号进程领养
孤儿进程不能用ctrl+c终止,只能用kill指令,kill -9 pid杀死进程
T,t
两个t都是进程的暂停状态,t是进程被调试时的状态。
总结
操作系统的抽象状态概念与linux的具体状态对比
运行态 – R
终止态 – X
挂起态 – S,D,T,t
阻塞态 – S,D
进程优先级
进程优先级是什么? 与权限对比,权限是指一个进程能不能访问某个资源,而优先级是指进程能访问某个资源,只是访问的先后顺序不同。为什么要有优先级? 计算机资源是有限的,而进程总是有很多的,系统对于资源的分配不能面面俱到,需要给重要的进程更多资源,相对不重要的进程更少资源
怎样修改优先级?
ps -al指令可以查看进程的详细信息,PRI是优先级priority的缩写,NI是nice的缩写,进程的优先级PRI = 80 + NI,NI的取值范围在[-20,19]。
1.敲sudo top
2.敲r
3.输入要设置优先级的进程pid
4.设置nice值,回车
进程其他概念
竞争性:cpu资源有限,但进程数量很多,每个进程为完成任务,需要竞争cpu资源,于是有了优先级来确定进程使用资源的先后顺序
独立性:每个进程相互独立,一个进程挂掉不会影响另一个进程,多个进程运行期间互不干扰
并发:多个进程在一个cpu下采用进程切换的方式,一段时间内,每个进程都被执行过,称为并发
并行:多个进程在多个cpu下运行,称为并行
当一台电脑有两个cpu,同一时间内可能有两个进程同时在跑,但单cpu的电脑,打开任务管理器,可以看到有多个进程在跑,这是为什么?明明cpu在同一时间内只能运行一个进程。
一个进程占用cpu资源,该进程不可能一直占用cpu资源直到运行完成,操作系统有一个时间片的概念,每一个进程的调度周期被称为时间片,调度周期结束后,cpu就去调用其他进程,所以在一段时间内多个进程都会被执行,由于时间片很短,给我们的感觉就是多个进程同时在跑。
进程抢占
如果现在有一个优先级较高的进程需要被执行,cpu在执行的进程的优先级小于该进程,这时cpu会终止正在执行的进程,执行优先级较高的进程,这样的现象叫做进程抢占。
前面说cpu执行进程的方式是切换式执行的,但cpu执行一个进程必定产生大量的临时数据保存在cpu的寄存器中(这些临时数据被称作上下文数据),当上一个进程被剥离时,这些数据要去哪?首先cpu寄存器的资源很少,前一个进程的上下文数据不可能保存在寄存器中,所以数据会被保存到进程控制块PCB中,也就是内存中,下次执行该进程时再读取恢复这些数据。
进程地址空间
一个程序跑起来后,需要一块空间存储数据,这块空间叫做程序地址空间,从下到上分别为代码区,初始化数据区,未初始化数据区,堆区,栈区,命令行参数环境变量区。
利用代码验证地址区域
验证堆区和栈区的增长方向
通过打印的地址我们可以看到堆区向高地址增长,栈区向低地址增长。所以在栈区先定义的变量地址高,在堆区先定义的变量地址低。
并且堆区和栈区有非常大的地址镂空,这也侧面说明了堆区向上增长,栈区向下增长,中间的镂空是一块存储空间。
mm_struct
用fork创建子进程,创建子进程之前已经创建了全局变量g_val,循环输出两个进程的pid,g_val的值以及地址。在5秒后将子进程的g_val值修改,通过打印结果可以看到,两个进程的g_val地址都是相同的,但是在子进程修改g_val的值后,父进程g_val的值却没有改变,而且两个进程g_val的地址却是相同的。
可以证明,这两个进程没有使用同一块内存空间,而是两块分别独立的存储空间,如果两个进程使用相同的内存空间,怎么解释相同的地址,子进程读取时是400,父进程读取时是100?所以每一个进程都有一块自己的进程地址空间。
之前说过一个可执行程序被加载到内存后创建了task_struct,形成了进程,task_struct中有一个结构——mm_struct,虚拟地址空间被存储到结构体mm_struct中,虚拟地址空间就是刚刚说的栈区,堆区,代码区那块空间。mm_struct存储了将虚拟地址映射到真实地址的页表,进程可以通过页表访问真实的物理空间。
怎么理解一块数据区的属性为只读
存储器是一种硬件,硬件没有能力规定你是否能访问它,所以你对硬件的读或写操作都是允许的,但如果一块硬件是只读的,那么硬件中已经存在的数据是怎么被写入的? 实际的情况是:操作系统在你和硬件中加了一层软件层,访问硬件必须经过这层软件层,只读数据区由这层软件层管理,当你想往只读数据区写入数据时,被这层软件层拦截,你就无法访问只读数据区。
这层软件层可以理解为进程地址空间,进程地址空间也叫作虚拟地址空间,虚拟地址空间中的只读数据区对应着物理内存中的一块区域,当你想写入只读数据区时,页表中可能没有该虚拟地址与物理地址的映射,或者页表中没有这个地址,访问该地址是非法的,系统通过页表的映射关系拦截写入只读数据区的操作,同理,野指针越界的检查也是通过这种方式。
再看最开始的问题,父进程创建子进程时,子进程与父进程共享代码与数据,所以两个进程的g_val的地址是相同的,但这个相同指的是虚拟地址相同,两个进程相同的虚拟地址实际指向物理内存的不同空间,所以修改了子进程的g_val值,只是通过页表找到虚拟地址映射的物理地址,将该物理地址上的数据修改,而父进程的g_val的物理地址上的数据并没有修改。
程序是怎么变成进程的(进程是如何创建的)?
首先明白两点:1.代码被编译为可执行程序后(此时存储在磁盘中),已经有了虚拟地址。2.并且划分好了虚拟空间的区域
当程序要运行时,操作系统将代码与数据从磁盘加载到真实的物理内存中,并且建立相应的task_struct,task_struct中保存了页表。然后cpu执行内存中的代码,当要调用函数,或者读取变量的数据时,操作系统拿到的只是程序编译后形成的虚拟地址,所以cpu要到该进程的页表中通过拿到的虚拟地址查找真实地址,再到内存中查找真实地址继续执行代码。
所以cpu执行代码并不是直接通过真实地址,而是通过虚拟地址在页表中查找真实地址执行代码。就像学生的座号,座号只是虚拟的,学生的位置会发生改变,老师并不是通过教室的位置来识别学生,而是通过座号识别学生。学生就是进程,座号就是进程的虚拟地址,而学生的位置就是进程的真实地址。
fork为什么能返回两个值?
pid_t id = fork()
子进程和父进程共享代码,以上面那段代码为例,父进程执行fork函数时生成了子进程,父子进程的页表相同,也就是说在父子进程的页表中,id变量的虚拟地址映射了同一块物理内存。当子进程执行fork函数,fork最后执行return函数,id作为接收返回值的对象,要被fork函数的返回值修改,而父子进程的id存储在同一块内存空间,根据进程的独立性,子进程数据的修改不能影响父进程,所以这时会发生一个写时拷贝,哪个进程先调用fork的return,就更新哪个进程的页表,将id的虚拟地址映射到另一块物理内存中,再接受函数的返回值。所以,通过页表,父子进程的数据实现了分离,而两个地址一样的变量,却有不同的值,本质上是虚拟地址一样,物理地址不一样。
fork生成的子进程是否继承了父进程的所有代码?还是只继承了父进程fork之后的代码?虽然fork只执行父进程fork之后的代码,但子进程却继承了父进程的所有代码,子进程拷贝父进程中的pc(程序计数器,保存下一条要执行的指令),子进程根据pc执行代码,所以子进程只会执行fork之后的代码
有了虚拟地址空间的概念,可以更好的理解fork之后操作系统做了什么,操作系统生成了一个新的进程:创建了task_struct,mm_struct以及页表,由于进程具有独立性,子进程和父进程共享同一张页表,但数据以写时拷贝的方式进行修改,即其中一个进程的数据修改,其页表也要修改,不再指向和另一进程相同的地址空间。
为什么不能在创建子进程时将空间分离?
1.子进程不一定要进行空间的修改
2.每次fork都进行空间的分配,时间和内存的消耗高
3.只拷贝要被修改的数据是最理想的情况,但实现的难度高
写时拷贝变相的提高了内存的使用率。采用延时拷贝的策略,在需要修改数据时才分配空间给进程,虽然拷贝的成本依旧存在,但在修改这些数据之前,内存可以被其他进程使用,保证了内存的高效利用。
虚拟地址空间的意义
1.保证系统的安全性,比如代码中出现了野指针访问,并且这个访问将其他进程的数据修改了,这是一种极其危险的操作。而有了虚拟地址空间,操作系统就能查找该地址映射的真实物理地址,如果该地址没有映射物理地址,操作系统可以直接将野指针访问的操作拦截。在一定程度上保护了内存
2.更好地进行进程的管理。进程的页表中有许多区域:代码区,已初始化全局数据区,未初始化全局数据区…其中的堆区是动态管理的。比如你的程序需要100字节的内存,编译完代码后,操作系统不会直接将这100字节的内存给你,因为无法确定该程序会在什么时候被调用,直接给100字节内存很有可能造成空间的浪费,mm_struct中保存了每块区域的虚拟地址,(以[start,end]这样的区间保存),申请100字节的空间时,只需要对堆区的end+=100,操作系统才不会管你是否申请了内存,只会生成页表,当进程被调用时让这100字节虚拟空间映射到真实物理地址中。所以虚拟地址空间将进程与真实物理地址解耦,通过页表的映射,使进程的内存分配变得简单。
3.简化了进程本身的设计。虚拟地址空间使得每一个进程以统一的方式看待内存,这使得编译的工作变得简单,因为虚拟地址空间的每个区域都是固定的,边界明确的,局部变量存储在一块空间,正文代码存储在一块空间…操作系统只需要根据页表的映射执行代码。如果没有虚拟地址空间,数据的存储将杂乱无章,编译的工作将会变得十分困难