当前位置: 首页 > news >正文

操作系统导论读书笔记

目录

  • 虚拟化
    • 抽象:进程
      • 抽象:进程概念
      • 进程创建:更多细节
  • 插叙:进程API
    • fork()系统调用
    • wait()系统调用
    • 最后是exec()系统调用
    • 为什么这样设计API
    • 其他API
  • 机制:受限直接执行
    • 基本技巧:受限直接执行
      • 问题1:受限制的操作

虚拟化

抽象:进程

本章讨论操作系统提供的基本的抽象—— 进程。进程的非正式定义非常简单:进程就是运行中的程序。程序本身是没有生命周期的,它只是存在磁盘上面的一些指令(也可能是一些静态数据)​。是操作系统让这些字节运行起来,让程序发挥作用。

虽然只有少量的物理CPU可用,但是操作系统如何提供几乎有无数个CPU可用的假象?操作系统通过虚拟化(virtualizing)CPU来提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统提供了存在多个虚拟CPU的假象。这就是时分共享(time sharing)CPU技术,允许用户如愿运行多个并发进程。潜在的开销就是性能损失,因为如果CPU必须共享,每个进程的运行就会慢一点。

要实现CPU的虚拟化,要实现得好,操作系统就需要一些低级机制以及一些高级智能。我们将低级机制称为机制(mechanism)​。机制是一些低级方法或协议,实现了所需的功能。例如,我们稍后将学习如何实现 上下文切换(context switch) ​,它让操作系统能够停止运行一个程序,并开始在给定的CPU上运行另一个程序。所有现代操作系统都采用了这种分时机制。

在这些机制之上,操作系统中有一些智能以策略(policy)的形式存在。策略是在操作系统内做出某种决定的算法。例如,给定一组可能的程序要在CPU上运行,操作系统应该运行哪个程序?操作系统中的调度策略(scheduling policy)会做出这样的决定,可能利用历史信息(例如,哪个程序在最后一分钟运行得更多?​)​、工作负载知识(例如,运行什么类型的程序?​)以及性能指标 (例如,系统是否针对交互式性能或吞吐量进行优化?​)来做出决定。

抽象:进程概念

操作系统为正在运行的程序提供的抽象,就是所谓的进程(process)​。正如我们上面所说的,一个进程只是一个正在运行的程序。在任何时刻,我们都可以清点它在执行过程中访问或影响的系统的不同部分,从而概括一个进程。

进程的机器状态有一个明显组成部分,就是它的内存。指令存在内存中。正在运行的程序读取和写入的数据也在内存中。因此进程可以访问的内存(称为地址空间,address space)是该进程的一部分。进程的机器状态的另一部分是寄存器。许多指令明确地读取或更新寄存器,因此显然,它们对于执行该进程很重要。

请注意,有一些非常特殊的寄存器构成了该机器状态的一部分。例如,程序计数器(Program Counter,PC)​(有时称为指令指针,Instruction Pointer或IP)告诉我们程序当前正在执行哪个指令;类似地,栈指针(stack pointer)和相关的帧指针(frame pointer)用于管理函数参数栈、局部变量和返回地址。

进程创建:更多细节

操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。程序最初以某种可执行格式驻留在磁盘上(disk,或者在某些现代系统中,在基于闪存的SSD上)​。因此,将程序和静态数据加载到内存中的过程,需要操作系统从磁盘读取这些字节,并将它们放在内存中的某处。在早期的(或简单的)操作系统中,加载过程尽早(eagerly)完成,即在运行程序之前全部完成。现代操作系统惰性(lazily)执行该过程,即仅在程序执行期间需要加载的代码或数据片段,才会加载。

操作系统也可能为程序的堆(heap)分配一些内存。在C程序中,堆用于显式请求的动态分配数据。程序通过调用malloc()来请求这样的空间,并通过调用free()来明确地释放它。数据结构(如链表、散列表、树和其他有趣的数据结构)需要堆。起初堆会很小。随着程序运行,通过malloc()库API请求更多内存,操作系统可能会参与分配更多内存给进程,以满足这些调用。

插叙:进程API

本章将讨论UNIX系统中的进程创建。UNIX系统采用了一种非常有趣的创建新进程的方式,即通过一对系统调用:fork()和exec()。进程还可以通过第三个系统调用wait(),来等待其创建的子进程执行完成。

fork()系统调用

系统调用fork()用于创建新进程[C63]​。但要小心,这可能是你使用过的最奇怪的接口:

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4
5    int
6    main(int argc, char *argv[])
7    {
8        printf("hello world (pid:%d)\n", (int) getpid());
9        int rc = fork();
10       if (rc < 0) {        // fork failed; exit
11           fprintf(stderr, "fork failed\n");
12           exit(1);
13       } else if (rc == 0) { // child (new process)
14           printf("hello, I am child (pid:%d)\n", (int) getpid());
15       } else {             // parent goes down this path (main)
16           printf("hello, I am parent of %d (pid:%d)\n",
17                   rc, (int) getpid());
18       }
19       return 0;
20   }

进程调用了fork()系统调用,这是操作系统提供的创建新进程的方法。新创建的进程几乎与调用进程完全一样,对操作系统来说,这时看起来有两个完全一样的p1程序在运行,并都从fork()系统调用中返回。新创建的进程称为子进程(child)​,原来的进程称为父进程(parent)​。子进程不会从main()函数开始执行(因此hello world信息只输出了一次)​,而是直接从fork()系统调用返回,就好像是它自己调用了fork()。

你可能已经注意到,子进程并不是完全拷贝了父进程。具体来说,虽然它拥有自己的地址空间(即拥有自己的私有内存)​、寄存器、程序计数器等,但是它从fork()返回的值是不同的。父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0。这个差别非常重要,因为这样就很容易编写代码处理两种不同的情况(像上面那样)​。

CPU调度程序(scheduler)决定了某个时刻哪个进程被执行,我们稍后将详细介绍这部分内容。由于CPU调度程序非常复杂,所以我们不能假设哪个进程会先运行。事实表明,这种不确定性(non-determinism)会导致一些很有趣的问题,特别是在多线程程序(multi-threaded program)中。

wait()系统调用

到目前为止,我们没有做太多事情:只是创建了一个子进程,打印了一些信息并退出。事实表明,有时候父进程需要等待子进程执行完毕,这很有用。这项任务由wait()系统调用(或者更完整的兄弟接口waitpid())​。

最后是exec()系统调用

最后是exec()系统调用,它也是创建进程API的一个重要部分。这个系统调用可以让子进程执行与父进程不同的程序。例如,在p2.c中调用fork(),这只是在你想运行相同程序的拷贝时有用。但是,我们常常想运行不同的程序,exec()正好做这样的事。

fork()系统调用很奇怪,它的伙伴exec()也不一般。给定可执行程序的名称(如wc)及需要的参数(如p3.c)后,exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据)​,堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过argv传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序(以前的p3)替换为不同的运行程序(wc)​。子进程执行exec()之后,几乎就像p3.c从未运行过一样。对exec()的成功调用永远不会返回。

为什么这样设计API

当然,你的心中可能有一个大大的问号:为什么设计如此奇怪的接口,来完成简单的、创建新进程的任务?好吧,事实证明,这种分离fork()及exec()的做法在构建UNIX shell的时候非常有用,因为这给了shell在fork之后exec之前运行代码的机会,这些代码可以在运行新程序前改变环境,从而让一系列有趣的功能很容易实现。

fork()和exec()的分离,让shell可以方便地实现很多有用的功能。比如:
prompt> wc p3.c > newfile.txt。在上面的例子中,wc的输出结果被重定向(redirect)到文件newfile.txt中(通过newfile.txt之前的大于号来指明重定向)​。shell实现结果重定向的方式也很简单,当完成子进程的创建后,shell在调用exec()之前先关闭了标准输出(standard output)​,打开了文件newfile.txt。这样,即将运行的程序wc的输出结果就被发送到该文件,而不是打印在屏幕上。

NIX管道也是用类似的方式实现的,但用的是pipe()系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道(pipe)上(队列)​,另一个进程的输入也被连接到了同一个管道上。因此,前一个进程的输出无缝地作为后一个进程的输入,许多命令可以用这种方式串联在一起,共同完成某项任务。比如通过将grep、wc命令用管道连接可以完成从一个文件中查找某个词,并统计其出现次数的功能:grep -o foo file | wc -l。

其他API

除了上面提到的fork()、exec()和wait()之外,在UNIX中还有其他许多与进程交互的方式。比如可以通过kill()系统调用向进程发送信号(signal)​,包括要求进程睡眠、终止或其他有用的指令。实际上,整个信号子系统提供了一套丰富的向进程传递外部事件的途径,包括接受和执行这些信号。

机制:受限直接执行

为了虚拟化CPU,操作系统需要以某种方式让许多任务共享物理CPU,让它们看起来像是同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过以这种方式时分共享(time sharing)CPU,就实现了虚拟化。

然而,在构建这样的虚拟化机制时存在一些挑战。第一个是性能:如何在不增加系统开销的情况下实现虚拟化?第二个是控制权:如何有效地运行进程,同时保留对CPU的控制?控制权对于操作系统尤为重要,因为操作系统负责资源管理。如果没有控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息。因此,在保持控制权的同时获得高性能,这是构建操作系统的主要挑战之一。

操作系统必须以高性能的方式虚拟化CPU,同时保持对系统的控制。为此,需要硬件和操作系统支持。操作系统通常会明智地利用硬件支持,以便高效地实现其工作

基本技巧:受限直接执行

为了使程序尽可能快地运行,操作系统开发人员想出了一种技术——我们称之为受限的直接执行(limited direct execution)​。这个概念的“直接执行”部分很简单:只需直接在CPU上运行程序即可。因此,当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数或类似的)​,跳转到那里,并开始运行用户的代码。

但是,这种方法在我们的虚拟化CPU时产生了一些问题。第一个问题很简单:如果我们只运行一个程序,操作系统怎么能确保程序不做任何我们不希望它做的事,同时仍然高效地运行它?第二个问题:当我们运行一个进程时,操作系统如何让它停下来并切换到另一个进程,从而实现虚拟化CPU所需的时分共享?

问题1:受限制的操作


http://www.mrgr.cn/news/81514.html

相关文章:

  • 水库大坝三维模型的开发和使用3Dmax篇
  • 基于STM32F103控制L298N驱动两相四线步进电机
  • 数据库管理-第275期 Oracle 23ai:画了两张架构图(20241225)
  • idea配置gitee仓库
  • Flink调优----资源配置调优与状态及Checkpoint调优
  • FFmpeg 的常用API
  • 学习数量关系
  • Docker 部署 plumelog 最新版本 实现日志采集
  • Petalinux使用QSPI FLASH引导启动
  • Unity 实现Canvas显示3D物体
  • 【ES6复习笔记】ES6的模块化(18)
  • 网络安全研究中的网络攻击
  • Flink调优----反压处理
  • AI Agent开源框架汇总(持续更新)
  • Qt工作总结02 <设置工具栏ToolBar>
  • 2024-12-24 NO1. XR Interaction ToolKit 环境配置
  • linux-21 目录管理(一)mkdir命令,创建空目录
  • 踏踏实实练SQLday1-1连续登录
  • 【SLAM】点线特征的VINS-Mono:PL-VINS算法测试
  • Tasmota ESP设备开源固件(esp8266,32X)