操作系统 | 概述
程序运行时
正在运行的程序就是一直在执行指令。处理器从内存中获取一条指令,对指令解码(弄清指令的语义),然后执行它。完成这条指令后处理器继续执行下一条指令,直到程序最终完成。操作系统负责让程序运行变得容易(可以运行多个程序),允许程序共享内存、和设备交互以及其他一些工作。
《Operating Systems: Three Easy Pieces》 一书中将操作系统的特征分为虚拟化、并发和持久性3个部分。
虚拟化&虚拟机
虚拟化技术是操作系统将物理资源(如CPU、RAM、Disk)转变为更通用强大的虚拟形式。因此有时候操作系统也称为虚拟机(VMvare Work Station、JVM亦是虚拟机)。操作系统会提供系统调用,像标准库一样提供给应用程序用以运行、访问内存和I/O设备等。由于虚拟化技术使得许多程序共享CPU、RAM、Disk,因此操作系统是计算机系统资源的管理者,在资源分配上达到高效公平或是更多目标。
接下来通过实际的代码看看相关虚拟技术的表现。
虚拟化CPU
先通过代码来模拟一下多进程的情形。
common.h:
#ifndef __common_h__
#define __common_h__
#include <sys/time.h>
#include <sys/stat.h>
#include <assert.h>
double GetTime() { // 获取当前时间
struct timeval t;
int rc = gettimeofday(&t, NULL);
assert(rc == 0);
return (double) t.tv_sec + (double) t.tv_usec/1e6;
}
void Spin(int howlong) { // 等待howlong
double t = GetTime();
while ((GetTime() - t) < (double) howlong)
;
}
#endif
cpu.c:
间隔1s钟反复打印命令行传入的参数(字符串)。
#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;
}
下面我们在Linux下运行这段程序,使用的shell为tcsh。
这里的shell命令中,&
用于创建后台进程。执行命令后,可以看到操作系统创建了PID(Process Identifier,进程标识符)为2086、2087、2088、2089的4个进程,虽然只有一个处理器,但4个进程似乎是在同时运行。这是因为操作系统提供了一种假象,即系统中拥有无限多的CPU,从而使得许多程序在宏观上看起来像是同时在运行,这就是虚拟化CPU。
1次运行4个进程会引发很多问题,这也是操作系统课程的聚焦。在这里的运行结果中我们可以看到,ABCD字符并没有按照顺序输出。程序的运行时间由操作系统的调度策略来决定。
如果要将程序停止,使用kill
命令通知操作系统即可。
虚拟化内存
下面尝试同时运行多个操作内存的程序,看看运行时内存中值的情况。
mem.c:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
int main(int argc, char *argv[]) {
int *p = malloc(sizeof(int));
assert(p != NULL);
printf("(%d) memory address of p: %o8x\n",
getpid(), (unsigned) p); // 打印分配的内存地址
*p = 0; // p指向地址存入0
while (1) {
Spin(1);
*p = *p + 1;
printf("(%d) p: %d\n", getpid(), *p);
}
return 0;
}
在这里需要关闭ASLR(空间地址随机化布局),从而使程序分配到的内存段起始地址保持一致。
setarch $(uname --machine) --addr-no-randomize /bin/bash
运行多个内存分配程序,可以观察到每个程序都在相同的地址(555592a0)处分配了内存。对于该地址内存单元存储的值,每个程序都能够独立的更新而不受其他程序影响。在程序看来,内存是其私有的,而不是与其他正在运行的程序共享物理内存。
这就是虚拟化内存技术,每个进程访问自己的私有虚拟地址空间(virtual address space),然后操作系统以某种方式将虚拟内存映射到机器的物理内存上。一个正在运行的程序中的内存引用不会影响其他进程(或操作系统本身)的地址空间。 对于正在运行的程序,它完全拥有自己的物理内存。
并发(Concurrency)
在上面关于虚拟化的实验中,可以看到操作系统同时处理很多事情,首先运行一个进程,然后再运行一个进程,以此类推。但是运行结果表明,这样做会导致一些深刻而有趣的问题。
我们看看linux c下的一个多线程程序 threads.c:
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
#include "common_threads.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 <loops>\n");
exit(1);
}
loops = atoi(argv[1]);
pthread_t p1, p2;
printf("Initial value : %d\n", counter);
// 创建两个线程,每个线程调用worker()增加共享计数器的值
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;
}
这里要注意使用gcc编译时需要将线程库引入:
这里的运行结果和预期一致。但是我们加大loops
数值,异常就会逐步显现出来:
这样不寻常的结果与指令如何执行有关。在面的程序中,关键部分是增加共享计数器的地方,它需要 3 条指令:一条将计数器的值从内存加载到寄存器,一条将其递增,操一条将其保存回内存。 但这3条指令并非原子方式执行(atomically,所有指令一次性执行),因此会导致异常。
持久性
RAM中的内存断电即丢失,因此需要硬件和软件持久的存储数据。Hard drive 和 SSD 是典型的用于存储长期保存的数据的IO设备。操作系统为CPU和内存提供了抽象,但是并不会为每个应用创建虚拟磁盘,并假设用户经常需要资源共享。
下面看一个学习C语言文件操作时常见的例子: io.c
#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;
}
在这里程序向操作系统发出了open()
、write()
、close()
3个调用。这些系统调用(system call)被转到文件系统(file system)的操作系统部分,然后该系统处理这些请求。中间操作系统为了写入数据究竟做了什么,这些情况目前是透明的。
设计理念&目标
操作系统中基本思想方法是通过建立一些抽象(abstraction),屏蔽一些底层细节,让系统方便易用。就像用C这样的高级语言编写这样的程序不用考虑汇编,用汇编写代码不用考虑逻辑门,用逻辑门来构建处理器不用太多考虑晶体管。
设计和实现操作系统的一个目标,是尽可能提供高性能(performance),也就是最小化操作系统的开销(minimize the overhead)。另一个目标是操作系统需要可信,在 OS 和应用程序之间提供保护(protection),确保某个程序的恶意行为不影响其他程序。因此进程间彼此隔离(isolation)是保护的关键。