——来源《操作系统导论》,感兴趣可以直接看书
进程API
进程管理是操作系统非常非常重要的一个部分。那么:
操作系统应该提供怎样的进程来创建及控制接口 ? 如何设计这些接口才能既方便又实用? |
fork()系统调用
int main(int argc, char * argv[]) { printf("hellooworld (pid:%d)\n", (int) getptd(); int rc = fork(); if(rc < 0){ fprintf(stderr, "fork faild"); }else if(rc == 0 ){ printf("我是个孩子(pid:%d)\n", (int) getptd(); }else{ printf("我是个 (pid:%d) 的爸爸(pid:%d)\n", rc, (int) getptd(); } return 0; } |
这段程序的输出是什么?
prompt> ./p1 hello world(pid:29146) hello, I am parent of 29147 (pid:29146) hello, I am child (pid:29147) prompt> |
会有其他的输出结果吗?
科普解释
进程调用了fork()系统调用,用来创建新进程。新旧进程几乎一模一样,且新就进程都会从fork()系统调用中返回。
新创建的进程称为子进程(child),原来的进程称为父进程(parent)。
子进程不会从main()函数开始执行( 因此hello world 信息只输出了一次),而是直接从fork()系统调用返回,就好像是它自己调用了fork()。
但是父子进程从fork()返回的返回值是不一样的。
父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0。这个差别非常重要,因为这样就很容易编写代码处理两种不同的情况(像上面那样)。
子进程并不是完全拷贝了父进程。我们可以观察到父子进程的pid是不一致的。子进程也有自己独立的地址空间,有内存堆栈、有寄存器、有程序计数器。
wait系统调用
int main(int argc, char * argv[]) { printf("hellooworld (pid:%d)\n", (int) getptd()); int rc = fork(); if(rc < 0){ fprintf(stderr, "fork faild"); }else if(rc == 0 ){ printf("我是个孩子(pid:%d)\n", (int) getptd()); }else{ int wc = wait(NULL); printf("我是个 (pid:%d) 的爸爸(pid:%d)\n", rc, (int) getptd()); } return 0; } |
通过wait()方法,我们的输出结果就是固定的了。尽管,仍然哟可能父进程先被调用。
exec()系统调用
我们的第三个程序p3.c
int main(int argc, char * argv[]) { printf("hellooworld (pid:%d)\n", (int) getptd(); int rc = fork(); if(rc < 0){ fprintf(stderr, "fork faild"); }else if(rc == 0 ){ printf("hello, I am child (pid:8d)\n", (int) getpid()); char *myargs[3]; myargs[0] = strdup("wc"); // program: wc是用来统计文件的字符个数的一个程序 myargs[1] = strdup("p3.c"); // p3.c是需要被统计的文件 myargs[2] = NULL; execvp (myargs[0], myargs) ; // printf ("我是个孩子(pid:%d)\n", (int) getptd()) ; }else{ int wc = wait(NULL); printf("我是个 (pid:%d) 的爸爸(pid:%d)\n", rc, (int) getptd(); } return 0; } |
这段代码的调用结果是什么?会输出“我是孩子吗”?
科普解释
fork()系统调用很奇怪,它的伙伴execp()也不一般。 给定可执行程序的名称(如wc)、需要的参数(如p3.c)后,execvp会从可执行程序中加载代码和静态数据,并用它覆写当前进程的自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。
然后操作系统就执行该程序,将参数通过argv传递给这个进程。
因此,它并没有创建新进程,而是直接将当前运行的程序 替换为新的运行程序.子进程执行execvp()之后,几乎就像p3.c 从未运行过一样。对exec()的成功调用永远不会返回
思考
为什么操作系统设计fork() , wait(), execv() 这三个接口呢?接口似乎也非常不符合常见思路。
实际上,在后续的shell的调用中,这种做法还挺好用的。
shell 是一个用户程序,它首先显示一个提示符(prompt), 然后等待用户输入。
我们可以向它输入一个命令(一个可执行程序的名称及需要的参数)。然后shell帮助我们找到对应的可执行程序,调用fork()来创建新的进程,并调用exec()的某个变体来执行这个可执行程序,然后wait命令等待进程执行完安好才能。子进程执行结束后,shell 会从wait()返回并再次输出一个提示符,等待用户输入下一条命令。
fork()和exec()的分离,让shell可以方便地实现很多有用的功能。比如:
prompt> wC p3.c > newfile. txt |
在上面的例子中,wc的输出结果被重定向到文件newfile.txt中。shell怎么实现重定向。只需要在exec()之前先关闭标准输出,然后打开文件newfile. txt即可。
Lampson 在他的著名论文《Hints for Computer SystemsDesign》中曾经说过:“做对事(Getitright)。 抽象和简化都不能替代做对事。” 有时你必须做正确的事,当你这样做时,总是好过其他方案。有许多方式来设计创建进程的API,但fork()和exec()的组合既简单又极其强大。 因此UNIX的设计师们因为Lampson经常“做对事”,所以我们就以他来命名这条定律。 |
受限执行
多任务并行执行是操作系统需要处理的一件事。在单CPU时代,利用时分共享技术来实现虚拟化CPU,来实现。
然而,在构建这样的虚拟化机制时存在一些挑战。
第一个是性能: 如何在不增加系统开销的情况下实现虚拟化?
第二个是控制权:如何有效地运行进程,同时保留对CPU的控制?
控制权对于操作系统尤为重要,因为操作系统负责资源管理。
如果没有控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息,比如访问不属于自己的地址空间的内存。因此,在保持控制权的同时获得高性能,这是构建操作系统的主要挑战之一
操作系统必须以高性能的方式虚拟化CPU,同时保持对系统的控制。为此,需要硬件和操作系统支持。操作系统通常会明智地利用硬件支持,以便高效地实现其工作。 |
直接执行
如果一个进程的任何命令的执行都需要经过一层操作系统。那么就像我们业务调用链路中增加一个链路节点一样,会增加耗时。
那么为了让CPU不被浪费,做一些不必要意义的入栈和出栈,那么让程序直接运行在CPU上即可。称受限的直接执行(limited direct execution)。
所以我们的程序运行大概是这个样子。
操作系统 | 程序 |
---|---|
在进程列表中创建进程条目(PCB) 分配内存 将程序代码加载到内存中 根据argc/argv设置程序栈 | |
清除寄存器 执行call mian()方法 | |
执行main() 从main中执行return | |
释放进程的内存 从进程列表中清除 |
如果是这个样子,可能会遇到两个问题
- 如果程序想执行某些首先的操作(比如访问磁盘、比如申请更多的CPU和内存), 那操作系统似乎没办法进行控制
- 如果程序想一直执行下去(山无棱、天地合、才会放弃CPU),那就像操作系统将自己的皇位禅让给了程序一样
问题一:受限制的操作
一个进程必须能够执行IO和其他一些受限制的操作,但又不能让进城完全控制系统。操作系统和硬件应该如何协作这一点? |
为此,我们必须先明确哪些是受限制的操作。其次,应该提供一种机制,让这些操作能够被限制到。
所以我们就可以想到:让用户进程通过操作系统执行特权操作。
可以类比:支付系统的订单支付状态。订单的支付状态就类比磁盘、CPU等资源,支付系统就类比操作系统, 更改订单支付状态的功能类比硬件提供的能力。那么订单系统就不适合随意更改订单的支付状态,需要通过支付系统对外提供的“发起付款”“发起退款”等小心暴露的接口来完成。
那就是这样子的:
操作系统 | 程序 |
---|---|
在进程列表中创建进程条目(PCB) 分配内存 将程序代码加载到内存中 根据argc/argv设置程序栈 | |
清除寄存器 执行 call mian()方法 | |
执行main() ..... 调用系统调用做一些特权操作 | |
处理系统调用 | |
从特权操作中返回 ..... 从main中返回 | |
释放进程的内存 从进程列表中清除 |
那程序是如何感知到这是一次特权操作呢?通过特殊的陷阱指令(trap)。该指令同时跳入内核并将提升到内核模式。
一旦进入内核, 系统就可以执行任何需要的特权操作(如果允许),从而完成调用进程想要所需的功能。
完成后,操作系统调用一个特殊的从陷阱返回(return-from-trap)指令,如你期望的那样,该指令返回到用户进程中,同时回到用户模式。
操作系统 | 程序 |
---|---|
在进程列表中创建进程条目(PCB) 分配内存 将程序代码加载到内存中 根据argc/argv设置程序栈 | |
清除寄存器 执行 call mian()方法 | |
执行main() ..... 调用系统调用做一些特权操作 | |
处理陷阱 处理陷阱 从陷阱中返回 | |
从特权操作中返回 ..... 从main中返回 | |
释放进程的内存 从进程列表中清除 |
这里有个小的细节问题:
操作系统在处理陷阱的时候,应该执行什么样的指令呢?
首先:不同进程程序调用的特权操作可能是一样的;
其次:特权操作可能会有一些维护和升级;
那么特权操作的指令一定不是维护在用户程序中的。
===========> 如果我们将特权操作的代码的位置公开,由用户进程指定代码位置,就可以满足上面的要求了
但是如果一个程序员很强很狡猾,他就可以指定跳过文件访问的权限代码,直接执行文件访问的操作。
===========> 启动时设置陷阱表(trap table)来实现。
当机器启动的时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。
操作系统做的第一件事, 就是告诉硬件在发生某些异常事件时要运行哪些代码。例如,当发生硬盘中断,发生键盘中断或程序进行系统调用时,应该运行哪些代码?
操作系统通常通过某种特殊的指令,通知硬件这些陷阱处理程序的位置。
一且 硬件被通知,它就会记住这些处理程序的位置,直到下一次重新启动机器 ,并且硬件知道在发生系统调用和其他异常事件时要做什么(即跳转到哪段代码)。
也就是说:特权操作的程序是由硬件和操作系统维护的。
===========> next 执行指令来告诉硬件陷阱表的位置,也是非常强大的一个指定!
操作系统 | 硬件 | 程序 |
---|---|---|
启动(内核模式) | ||
初始化陷阱表 | ||
记住系统调用处理程序的地址 | ||
运行(内核模式) | ||
在进程列表中创建进程条目(PCB) 分配内存 将程序代码加载到内存中 根据argc/argv设置程序栈 用寄存器/程序计数器填充内核栈 从陷阱中返回 | ||
| 从内核栈恢复寄存器 转向用户模式 跳转到mian | |
执行main() ..... 调用系统调用陷入操作系统 | ||
将寄存器保存到内核栈 转向内核模式 跳到陷阱处理程序 | ||
处理陷阱 处理陷阱 从陷阱中返回 | ||
从内核栈恢复寄存器 转向用户模式 跳到陷阱之后的程序计数器 | ||
从特权操作中返回 ..... 从main中返回 | ||
释放进程的内存 从进程列表中清除 |
类比:
我是一个山庄的庄主,山庄靠卖火锅底料养着。
但是我比较懒,想找人来当管事。但是又不想给这个人过多的权限。万一他把我钱卷走了咋办,万一他把火锅底料的秘方卖了咋办。
所以,我就自己负责这块。管事平时可以随意造,但是涉及到火锅底料部分,他就必须申请权限,由我来负责和火锅底料的研发团队对接。
问题二:在进程之间切换
操作系统能够中断一个进程的执行,并且开始另一个进程,想想是件非常简单的事情。
但是进程是直接运行在CPU上的,操作系统是没有运行的,那么它应该怎么控制呢?
一个进程必须能够执行IO和其他一些受限制的操作,但又不能让进城完全控制系统。操作系统和硬件应该如何协作这一点? |
协作方式:等待系统调用
过去有些系统是会假定:系统的进程会合理运行。及时一个进程的运行事件很长,也会定期放弃CPU,以便操作系统决定运行其他任务。
那么友好的进程可能会通过系统调用,将CPU的控制权转移给操作系统。如果程序执行了某些非法操作,也会将控制转移给操作系统。
但如果系统遇到的都是坏人呢?
非协作方式:操作系统控制
许多年前构建计算机系统的许多人都发现了:时钟中断(timer interrrupt)。时钟设备可以每隔几毫秒产生一次中断。产生中断时,当前运行的进程停止,操作系统中预先配置的中断处理程序会运行。
那么这个时候,操作系统就会重新获得CPU的控制权,因此可以做它想做的事:停止当前进程,并启动另 一个进程。(联想一下进程调度~)
这么说来,感觉硬件的控制权限会更大。时钟设备可以随时打断一个程序的执行,那程序会受到影响吗?
因此:硬件需要为正在运行的程序保存足够的状态(上下文切换~),以便程序继续运行时(随后从陷阱返回指令)能够正确恢复执行。
这一组操作与上一节中的硬件在显式系统调用陷入内核时的行为非常相似,其中各种寄存器因此被保存(进入内核栈),因此从陷阱返回指令可以容易地恢复。
操作系统 | 硬件 | 程序 | |
---|---|---|---|
启动(内核模式) | |||
初始化陷阱表 | |||
记住系统调用处理程序的地址 记住时钟处理程序 | |||
启动中断时钟 | |||
启动时钟 每隔X毫秒中断CPU | |||
运行(内核模式) | |||
程序A运行中... | 程序B | ||
时钟中断 将寄存器A保存到内核栈A 转向内核模式 跳到陷阱处理程序 | |||
处理陷阱 调用switch()例程 将寄存器A保存到进程结构A 将进程结构B恢复到寄存器B 从陷阱中返回(进入B) | |||
从内核栈B回恢复寄存器B 转向用户模式 跳到B的程序计数器 | |||
进程B运行中...... |
思考
操作系统虚拟化CPU的关键底层机制,就是:受限直接执行。就是:让你想运行的程序在CPU上运行,但首先确保设置好硬件,以便在没有操作系统帮助的情况下,可以限制进程执行的操作。
类比一下:
有宝宝的家庭,可能会注意锁好有危险东西的柜子、房间,并且掩盖电源插座,当这些都准备好的时候,可以让宝宝自由行动。
之前我们指出,在协作式抢占时,无限循环(以及类似行为)的唯一解决方案是重启( reboot)机器。虽然你可能会嘲笑这种粗暴的做法,但研究表明,重启(或在通常意义上说,重新开始运行一些软件)可能是构建强大系统的一个非常有用的工具。 |