
程序运行时会发生什么?
一个正在运行的程序会做一件非常简单的事情:执行指令。处理器从内存中获取(fetch)一条指令,对其进行解码(decode)(弄清楚这是哪条指令),然后执行(execute)它(做它应该做的事情,如两个数相加、访问内存、检查条件、跳转到函数等)。完成这条指令后,处理器继续执行下一条指令,依此类推,直到程序最终完成。这就是冯·诺依曼(Von Neumann)计算模型的基本概念。
有一类软件负责让程序运行变得容易(甚至允许你同时运行多个程序),允许程序共享内存,让程序能够与设备交互,以及其他类似的有趣的工作。这些软件称为操作系统(Operating System,OS),因为它们负责确保系统既易于使用又正确高效地运行。
虚拟化CPU首先看一个程序:
#include#include #include #include #include "common.h" int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "usage: cpu n"); exit(1); } char *str = argv[1]; while (1) { Spin(1); printf("%sn", str); } return 0; }
这个函数的作用就是每一秒打印输出的参数,并且是个死循环。
输出为:
prompt> gcc -o cpu cpu.c -Wall prompt> ./cpu "A" A A A A ˆC prompt>
当同时执行运行 4 个程序的命令时,打印几乎是同时运行的,而不是等待第一个程序运行结束才运行下个程序。
prompt> ./cpu A & ; ./cpu B & ; ./cpu C & ; ./cpu D & [1] 7353 [2] 7354 [3] 7355 [4] 7356 A B D C A B D C A C B D ...
尽管我们只有一个处理器,但这 4 个程序似乎在同时运行。但对于单核的处理器,同时运行 4 个进程是不可能的,所以这里就要介绍 CPU 的虚拟化。事实证明,在硬件的一些帮助下,操作系统负责提供这种假象(illusion),即系统拥有非常多的虚拟CPU的假象。将单个 CPU(或其中一小部分)转换为看似无限数量的 CPU,从而让许多程序看似同时运行,这就是所谓的虚拟CPU(virtualizing the CPU)
虚拟化内存内存就是一个字节数组。要读取(read)内存,必须指定一个地址(address),才能访问存储在那里的数据。要写入(write)或更新(update)内存,还必须指定要写入给定地址的数据。
让我们来看一个程序,它通过调用 malloc()来分配一些内存
#include#include #include #include "common.h" int main(int argc, char *argv[]) { int *p = malloc(sizeof(int)); // a1 assert(p != NULL); printf("(%d) memory address of p: %08xn", getpid(), (unsigned)p); // a2 *p = 0; // a3 while (1) { Spin(1); *p = *p + 1; printf("(%d) p: %dn", getpid(), *p); // a4 } return 0; }
该程序的输出如下:
prompt> ./mem (2134) memory address of p: 00200000 (2134) p: 1 (2134) p: 2 (2134) p: 3 (2134) p: 4 (2134) p: 5 ˆC
该程序做了几件事。首先,它分配了一些内存(a1行)。然后,打印出内存的地址(a2行),然后将数字0放入新分配的内存的第一个空位中(a3行)。最后,程序循环,延迟一秒钟并递增p中保存的值。在每个打印语句中,它还会打印出所谓的正在运行程序的进程标识符(PID)(a4行)。该PID对每个运行进程是唯一的。
现在,我们再一运行同一个程序的多个实例,看看会发生什么。
prompt> ./mem &; ./mem & [1] 24113 [2] 24114 (24113) memory address of p: 00200000 (24114) memory address of p: 00200000 (24113) p: 1 (24114) p: 1 (24114) p: 2 (24113) p: 2 (24113) p: 3 (24114) p: 3 (24113) p: 4 (24114) p: 4 ...
当同时运行多个相同的程序时,分配的内存地址竟然是相同的,先抛开虚拟化的概念,以物理内存的角度看待,这几个程序分配的内存指针指向了同一块内存空间,也就是修改其中一个程序修改内存也会导致另一个程序中的值改变。
但是从结果来看这两块内存相互独立,并不影响,就好像每个正在运行的程序都有自己的私有内存,而不是与其他正在运行的程序共享相同的物理内存。
实际上,这正是操作系统虚拟化内存(virtualizing memory)时发生的情况。每个进程访问自己的私有虚拟地址空间(virtual address space)(有时称为地址空间address space) ,操作系统以某种方式映射到机器的物理内存上。一个正在运行的程序中的内存引用不会影响其他进程(或操作系统本身)的地址空间。对于正在运行的程序,它完全拥有自己的物理内存。但实际情况是,物理内存是由操作系统管理的共享资源。
并发并发指一系列问题,这些问题在同时(并发地)处理很多事情时出现且必须解决。
我们来看一个多线程程序的例子。
#include#include #include "common.h" volatile int counter = 0; int loops; void *worker(void *arg) { int i; for (i = 0; i < loops; i++) { counter++; } return NULL; } int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "usage: threads n"); exit(1); } loops = atoi(argv[1]); pthread_t p1, p2; printf("Initial value : %dn", counter); Pthread_create(&p1, NULL, worker, NULL); Pthread_create(&p2, NULL, worker, NULL); Pthread_join(p1, NULL); Pthread_join(p2, NULL); printf("Final value : %dn", counter); return 0; }
主程序利用Pthread_create()创建了两个线程(thread),每个线程中循环了loops次来递增全局变量counter 。也就是说,当 loops 的输入值设为 N 时,我们预计程序的最终输出为 2N。
但是当N很大时,结果却与我们的预期不符。
prompt> ./thread 100000 Initial value : 0 Final value : 143012 // huh?? prompt> ./thread 100000 Initial value : 0 Final value : 137298 // what the??
当我们再一运行该程序时,不仅再一次得到了错误的值,还与上一的值不同。事实上,如果你一遍又一遍地使用较高的 loops 值运行程序,可能会发现有时甚至可以得到正确的答案。
事实证明,这些奇怪的、不寻常的结果与指令如何执行有关,指令每一执行一条。遗憾的是,上面的程序中的关键部分是增加共享计数器的地方,它需要 3 条指令:
因为这 3 条指令不是以原子方式(atomically)执行(所有的指令一次性执行)的,所以奇怪的事情可能会发生。
持久性在系统内存中,数据容易丢失,因为像DRAM 这样的设备以易失(volatile)的方式存储数值。如果断电或系统崩溃,那么内存中的所有数据都会丢失。因此,我们需要硬件和软件来持久地(persistently)存储数据。
操作系统中管理磁盘的软件通常称为文件系统(file system)。因此它负责以可靠和高效的方式,将用户创建的任何文件(file)存储在系统的磁盘上。
我们来看一些代码。
#include#include #include #include #include int main(int argc, char *argv[]) { int fd = open("/tmp/file", O_WRONLY | O_CREAT | O_TRUNC, S_IRWXU); assert(fd > -1); int rc = write(fd, "hello worldn", 13); assert(rc == 13); close(fd); return 0; }
为了完成这个任务,该程序向操作系统发出 3 个调用。第一个是对 open()的调用,它打开文件并创建它。第二个是 write(),将一些数据写入文件。第三个是 close(),只是简单地关闭文件,从而表明程序不会再向它写入更多的数据。这些系统调用(system call)被转到称为文件系统(file system)的操作系统部分,然后该系统处理这些请求,并向用户返回某种错误代码。
首先确定新数据将驻留在磁盘上的哪个位置,然后在文件系统所维护的各种结构中对其进行记录。这样做需要向底层存储设备发出 I/O 请求,以读取现有结构或更新(写入)它们。所有写过设备驱动程序(device driver)的人都知道,让设备现表你执行某项操作是一个复杂而详细的过程。它需要深入了解低级别设备接口及其确切的语义。幸运的是,操作系统提供了一种通过系统调用来访问设备的标准和简单的方法。因此,OS 有时被视为标准库(standard library)。
出于性能方面的原因,大多数文件系统首先会延迟这些写操作一段时间,希望将其批量分组为较大的组。为了处理写入期间系统崩溃的问题,大多数文件系统都包含某种复杂的写入协议,如日志(journaling)或写时复制(copy-on-write),仔细排序
写入磁盘的操作,以确保如果在写入序列期间发生故障,系统可以在之后恢复到合理的状态。为了使不同的通用操作更高效,文件系统采用了许多不同的数据结构和访问方法,从简单的列表到复杂的B树。
操作系统实际上做了什么:它取得 CPU、内存或磁盘等物理资源(resources),甚对它们进行虚拟化(virtualize)。它处理与并发(concurrency)有关的麻烦且棘手的问题。它持久地(persistently)存储文件,从而使它们长期安全。
一个最基本的目标,是建立一些抽象(abstraction),让系统方便和易于使用。抽象对我们在计算机科学中做的每件事都很有帮助。抽象使得编写一个大型程序成为可能,将其划分为小而且容易理解的部分。
设计和实现操作系统的一个目标,是提供高性能(performance)。换言之,我们的目标是最小化操作系统的开销(minimize the overhead)。但是虚拟化的设计是为了易于使用,无形之中会增大开销,比如虚拟页的切换,cpu 的调度等等,所以尽可能的保持易用性与性能的平衡至关重要。
另一个目标是在应用程序之间以及在 OS 和应用程序之间提供保护(protection)。因为我们希望让许多程序同时运行,所以要确保一个程序的恶意或偶然的不良行为不会损害其他程序。保护是操作系统基本原理之一的核心,这就是隔离(isolation)。让进程彼此隔离是保护的关键,因此决定了 OS 必须执行的大部分任务
操作系统也必须不间断运行。当它失效时,系统上运行的所有应用程序也会失效。由于这种依赖性,操作系统往往力求提供高度的可靠性(reliability)。
其他目标。在我们日益增长的绿色世界中,能源效率(energy-efficiency)非常重要;安全性(security)(实际上是保护的扩展)对于恶意应用程序至关重要,特别是在这高度联网的时现。随着操作系统在越来越小的设备上运行,移动性(mobility)变得越来越重要。
简单历史