文章

《Operating Systems: Three Easy Pieces》学习笔记(一) 操作系统介绍

本系列文章将按照《Operating Systems: Three Easy Pieces》一书的章节顺序编写,结合原文与自己的感悟,以作笔记之用,如有不足之处,恳请在评论区指出

虚拟化 CPU

首先看个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <assert.h>
#include "common.h"

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        fprintf(stderr, "usage: cpu <string>\n");
        exit(1);
    }
    char *str = argv[1];
    while (1)
    {
        Spin(1);
        printf("%s\n", str);
    }
    return 0;
}

该程序每秒打印一次输入参数,是个死循环,不会退出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)

当然运行不同进程时的策略,如优先级等也是需要讨论的

知识点:时分共享,上下文切换

虚拟化内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"

int main(int argc, char *argv[])
{
    int *p = malloc(sizeof(int)); // a1
    assert(p != NULL);
    printf("(%d) memory address of p: %08x\n",
           getpid(), (unsigned)p); // a2
    *p = 0;                        // a3
    while (1)
    {
        Spin(1);
        *p = *p + 1;
        printf("(%d) p: %d\n", getpid(), *p); // a4
    }
    return 0;
}

这是一个访问内存的程序(mem.c)

该程序做了几件事。首先,它分配了一些内存(a1 行)。然后,打印出内存的地址(a2 行),然后将数字 0 放入新分配的内存的第一个空位中(a3 行)。最后,程序循环,延迟一秒钟并递增 p 中保存的值。在每个打印语句中,它还会打印出所谓的正在运行程序的进程标识符(PID)(a4 行)。该 PID 对每个运行进程是唯一的。

该程序的输出如下:

1
2
3
4
5
6
7
8
prompt> ./mem
(2134) memory address of p: 00200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5
ˆC

当只运行一个程序时,p 递增,一切正常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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),操作系统以某种方式映射到机器的物理内存上。一个正在运行的程序中的内存引用不会影响其他进程(或操作系统本身)的地址空间。对于正在运行的程序,它完全拥有自己的物理内存。但实际情况是,物理内存是由操作系统管理的共享资源。

知识点:(等待补充)

并发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
#include <stdlib.h>
#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 <value>\n");
        exit(1);
    }
    loops = atoi(argv[1]);
    pthread_t p1, p2;
    printf("Initial value : %d\n", counter);

    Pthread_create(&p1, NULL, worker, NULL);
    Pthread_create(&p2, NULL, worker, NULL);
    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);
    printf("Final value : %d\n", counter);
    return 0;
}

主程序利用 Pthread_create()创建了两个线程(thread),每个线程中循环了 loops 次来递增全局变量counter

理想情况下,counter 最终的值应该为 2xloops,因为两个线程各把 counter 递增了 loops 次

1
2
3
4
5
6
prompt> ./thread 100000
Initial value : 0
Final value : 143012 // huh??
prompt> ./thread 100000
Initial value : 0
Final value : 137298 // what the??

当运行时,发现值每次各不相同,且小于 2xloops。

事实证明,这些奇怪的、不寻常的结果与指令如何执行有关,指令每一执行一条。遗憾的是,上面的程序中的关键部分是增加共享计数器的地方,它需要 3 条指令:

  • 一条将计数器的值从内存加载到寄存器
  • 一条将其递增
  • 一条将其保存回内存。

因为这 3 条指令甚不是以原子方式(atomically)执行(所有的指令一一性执行)的,所以奇怪的事情可能会发生。

知识点:原子操作,

持久性

操作系统中管理磁盘的软件通常称为文件系统(file system)。因此它负责以可靠和高效的方式,将用户创建的任何文件(file)存储在系统的磁盘上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <fcntl.h>
#include <sys/types.h>

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 world\n", 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 树

设计目标

一个最基本的目标,是建立一些抽象(abstraction),让系统方便和易于使用。抽象对我们在计算机科学中做的每件事都很有帮助。抽象使得编写一个大型程序成为可能,将其划分为小而且容易理解的部分

设计和实现操作系统的一个目标,是提供高性能(performance)。换言之,我们的目标是最小化操作系统的开销(minimize the overhead)。但是虚拟化的设计是为了易于使用,无形之中会增大开销,比如虚拟页的切换,cpu 的调度等等,所以尽可能的保持易用性与性能的平衡至关重要

另一个目标是在应用程序之间以及在 OS 和应用程序之间提供保护(protection)。因为我们希望让许多程序同时运行,所以要确保一个程序的恶意或偶然的不良行为不会损害其他程序。保护是操作系统基本原理之一的核心,这就是隔离(isolation)。让进程彼此隔离是保护的关键,因此决定了 OS 必须执行的大部分任务

操作系统也必须不间断运行。当它失效时,系统上运行的所有应用程序也会失效。由于这种依赖性,操作系统往往力求提供高度的可靠性(reliability)。

参考

本文由作者按照 CC BY 4.0 进行授权

© Kai. 保留部分权利。

浙ICP备20006745号-2,本站由 Jekyll 生成,采用 Chirpy 主题。