前记
这本书的英文名字是《Operating Systems:Three Easy Pieces》(吐槽下中文翻译,很不搭的感觉),作者是Remzi H.Arpaci-Dusseau.英文起名还有一个梗:Remzi希望这本书可以像理查德·费曼写过一些物理学的讲义,他把一些基本内容汇集成一本书,叫做《Six Easy Pieces》。这本书探讨的又正好是操作系统的3个简单部分,所以取名很合适,因为操作系统的难度差不多是物理学的一半。这本书的英文电子版是免费的,Remzi说这本书之所以免费,是因为曾经他有一个学生因为买不起书而向他求助,这让他很受触动,从此他决定公开此书资源,Remzi人真的很好!
概述和理念
整本书围绕虚拟化、并发和持久性这三个主要概念展开,介绍了所有操作系统的主要组件。这本书并不是一股脑的把一些结论性质的东西放出来,而是从引出问题出发,一步步引导读者去思考操作系统是如何发展到现在这样的。
这本书的每一个章节都配套了习题,都是完全开源免费的,我们只需要跟着readme照葫芦画瓢就好,链接我放在这里: github.com/remzi-arpac... 代码需要在Linux环境下运行,Windows电脑的朋友可以下个虚拟机,我是用Oracle VM VirtualBox装的Ubuntu。
我在看书之前听了一遍清华的操作系统原理课程,课程的很多内容和这本书是很像的,b站就可以看到课程,这里也推荐给读者:www.bilibili.com/video/BV1uW...
书中作者写给读者的话让我深受触动:
叶芝有一句名言:"教育不是注满一桶水,而是点燃一把火。"他说的既对也错。你必须"给桶注一点水",这本书当然可以帮助你完成这部分的教育。毕竟,当你去Google面试时,他们会问你一个关于如何使用信号量的技巧问题,确切地知道信号量是什么感觉真好,对吧?
但是叶芝的主要观点显而易见:教育的真正要点是让你对某些事情感兴趣,可以独立学习更多关于这个主题的东西,而不仅仅是你需要消化什么才能在某些课程上取得好成绩。正如我们的父亲(Remzi的父亲Vedat Arpaci)曾经说过的,"在课堂以外学习。"
今天想要写一篇小结,大致总结下自己学到的知识并且分享一些常见的笔试面试题,也顺便分享这个理念。
正文小结
虚拟化
我们为什么要进行虚拟化呢?其实很简单,因为计算机的硬件资源是有限的,而通过操作系统,我们可以更加有效的利用物理资源。本书中的虚拟化过程,就是在竭尽所能的达到这个目的。前几章是在引入进程的概念,简单的介绍了几个常用API:fork()、exec()、wait(),读者可以根据自己兴趣或者课后习题,写一些简单的程序熟悉它们。然后就来到了**虚拟化部分的第一个大件:CPU**。
为了虚拟化CPU,操作系统需要以某种方式让许多任务共享物理CPU,让它们看起来像是同时运行。基本思想很简单,运行一个进程一段时间,然后运行另一个进程,如此轮换。通过这种方式时分共享CPU,就实现了虚拟化。
想象一下,运行程序最快的方式是不是直接把程序放到CPU当中运行?当然是!但问题是,如果这个程序做了一些很离谱的事情,我们该怎么让它停下来?以及出于虚拟化CPU的目的,已经运行的程序(也就是进程),操作系统如何让它停下来,切换到另一个进程?为此,便引申了出来一些底层机制,读者可根据关键词做进一步了解(用户模式和内核模式、协作方式和非协作方式、时钟中断),这部分是比较概念性的东西,逻辑难度并不大,主要是为了适应专业词汇。
了解了底层机制,我们便开始学习操作系统的上层策略了,接下来这本书介绍了一系列的调度策略,这部分也是面试当中常被问到的:先进先出(FIFO)、最短任务优先(SJF)、最短完成时间优先(STCF),多级反馈队列(MLFQ)、单队列多处理器调度和多队列多处理器调度(当然也是有单队列单处理器调度和多队列单处理器调度的哈哈) 。调度策略并非只有这几种,设计人员可以根据不同的指标去设计不同的策略。这本书还引入了比例份额调度的概念,简单讨论了两种实现:彩票调度和步长调度,简单来说,前者是随机的,后者是确定的。
其中单队列的方式(SQMS)比较容易构建,负载均衡较好,但在扩展性和缓存亲和度方面有着固有的缺陷。多队列的方式(MQMS)有很好的扩展性和缓存亲和度,但实现负载均衡却很困难,也更复杂。
这部分的话,理解它们的机理以后是可以很自然的用自己的方式表述出来的。然后因为我上学期喜欢刷力扣学习,所以这里也推荐读者结合一些具体场景的代码,问题导向的话,很多抽象的概念就显得简单了一些。比如下面这个是力扣的621题,我暂时也只写了这个,同理还有力扣的1834题、636题,还有些别的场景,也很类似,比如第10题和第993题。下面这个是我第621题的解法:
perl
class Solution {
public:
int leastInterval(vector<char>& tasks, int n) {
if(n == 0){
return tasks.size();
}
vector<int> freq(26,0);
for(char task:tasks){
freq[task - 'A']++;
}
sort(freq.begin(),freq.end());
int max_freq = freq[25];
int idle_slots = (max_freq-1)*n;
for(int i= 24; i>=0 && idle_slots>0;--i){
idle_slots -= min(max_freq - 1, freq[i]);
}
idle_slots = max(0,idle_slots);
return tasks.size() + idle_slots;
}
};
CPU虚拟化的部分到这里就结束了,接下来是另一个大件:内存虚拟化。
如上所说,我们的物理资源是有限的,物理内存自然也是有限的,我们通过虚拟内存技术,可以更加有效的利用实际物理内存,形成一种"内存"(地址空间)比物理内存大很多的现象,这个过程需要操作系统和硬件配合完成。
实际上,作为用户级的程序员,可以看到的任何地址都是虚拟地址。
本书简单介绍了一些内存操作的API,malloc1()、free()等等,而后开始介绍地址转换机制的发展历程。从简单的思想出发,基址和界限 开始,然后慢慢增加复杂性,分段、分页,TLB、混合分段和分页、多级页表、反向页表等等。这一部分的关键之处是设计合适的映射方式,多个进程或者同一进程的不同虚拟页面可以映射到同一块物理内存,从而实现了一种形式的内存共享。这种映射关系是动态的,可以根据应用程序的需要进行调整。
在了解基本机制后,这本书介绍了几种常见的缓存策略:最优替换策略、先入先出策略、随机、利用历史数据(LRU)。同样缓存策略也可以在力扣里找到练习题,第146题的LRU和第460题的LFU,LRU要简单些,我暂时只写了LRU:
ini
class LRUCache {
public:
struct Node{
int key, value;
Node * prev;
Node * next;
Node(int k, int v){
this->key = k;
this->value = v;
}
};
int cap;
Node* head = new Node(-1,-1);
Node* tail = new Node(-1,-1);
unordered_map<int,Node*> map;
LRUCache(int capacity) {
cap = capacity;
head->next = tail;
tail -> prev = head;
}
void addNode(Node* newnode){
Node* temp = head -> next;
newnode -> next = temp;
newnode -> prev = head;
head -> next = newnode;
temp -> prev = newnode;
}
void deltNode(Node* delnode){
Node* prevv = delnode -> prev;
Node* nextt = delnode -> next;
prevv -> next = nextt;
nextt -> prev = prevv;
}
int get(int key) {
if(map.find(key)!=map.end()){
Node* resultNode = map[key];
int result = resultNode->value;
deltNode(resultNode);
addNode(resultNode);
map[key] = head->next;
return result;
}
return -1;
}
void put(int key, int value) {
if(map.find(key)!= map.end()){
Node* current = map[key];
map.erase(key);
deltNode(current);
}
if(map.size()==cap){
map.erase(tail->prev->key);
deltNode(tail->prev);
}
addNode(new Node(key,value));
map[key] = head ->next;
}
};
/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache* obj = new LRUCache(capacity);
* int param_1 = obj->get(key);
* obj->put(key,value);
*/
这是我习惯用的方法,但是时间复杂度不能击败100%,欢迎看到这篇文章的朋友分享更好的方式。
最后这本书在简单介绍了VAX/VMS虚拟内存系统之后,虚拟化部分就结束了。当然,在本书的最后还做了一部分补充知识,简单介绍了虚拟机管理程序,我们可以将其理解成操作系统之下的操作系统,它是介于操作系统和硬件之间的。
并发
接下来是第二部分:并发,这部分从表面的问题来看,和数据库的进程事务管理很像。当然,虽然在表面上看起来相似,但是操作系统和数据库系统的目标和关注点不一样,处理并发问题的具体机制和策略可能会有所不同。
之前我们已经知道,进程就是一个正在运行的程序,在这里我们需要引入一个新的概念,叫做线程 。这里也有一个常见的面试题,进程和线程的区别? 这里直接引用原文:
经典观点是一个程序只有一个执行点(一个程序计数器,用来存放要执行的指令),但多线程(multi-threaded)程序会有多个执行点(多个程序计数器,每个都用于取指令和执行)。换一个角度来看,每个线 程类似于独立的进程,只有一点区别:它们共享地址空间,从而能够访问相同的数据。
因此,单个线程的状态与进程状态非常类似。线程有一个程序计数器,记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块。现在,我们需要一个或多个线程控制,保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一主要区别:地址空间保持不变(即不需要切换当前使用的页表)。线程和进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型【我们现在可以称之为单线程(single-threaded)进程】中,只有一个栈,通常位于地址空间的底部(见图 26.1 左图)。
然而,在多线程的进程中,每个线程独立运行,当然可以调用各种例程来完成正在执行的任何工作。不是地址空间中只有一个栈,而是每个线程都有一个栈。假设有一个多线程的进程,它有两个线程,结果地址空间看起来不同(见图26.1右图)。
可能在不同的地方会给这些问题不同的定义,但其实本质都类似这样的场景:有一个变量a = 1,进程T1执行a=a+1, 进程T2也执行a=a+1,进程T1执行这个操作的时候a=1,执行a+1后a等于2,这时候a=2的结果还没有返回,进程T2突然抢断了进程T1,这个时候a依然是等于1的,执行a=a+1,返回结果a=2。T1继续执行操作,返回a=2。问题就出现了,经历了T1和T2,按理说a应该等于3的,结果等于2。
这一整个章节都是在结合具体的场景,给出相应的解决方案,关键之处在于设计合理的数据结构和函数,以及在正确的地方调用相应函数,这也是这一章最难的地方。
本书一开始还是介绍了一些线程的API,然后介绍了锁、临界区、互斥、条件变量 ,然后引入信号量 的概念。我们可以看一个经典的生产者-消费者例子:
scss
sem_t empty;
sem_t full;
sem_t mutex;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
sem_wait(&mutex); // line p0 (NEW LINE)
sem_wait(&empty); // line p1
put(i); // line p2
sem_post(&full); // line p3
sem_post(&mutex); // line p4 (NEW LINE)
}
}
void *consumer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
sem_wait(&mutex); // line c0 (NEW LINE)
sem_wait(&full); // line c1
int tmp = get(); // line c2
sem_post(&empty); // line c3
sem_post(&mutex); // line c4 (NEW LINE)
printf("%d\n", tmp);
}
}
int main(int argc, char *argv[]) {
// ...
sem_init(&empty, 0, MAX); // MAX buffers are empty to begin with...
sem_init(&full, 0, 0); // ... and 0 are full
sem_init(&mutex, 0, 1); // mutex=1 because it is a lock (NEW LINE)
// ...
}
整个场景是这样的:生产者只有在某个区域"空了"(empty)的情况下才能继续执行,然后它会在这个区域"生产"(put),直到生产"满了"(full),添加这个条件(sem_post(&full)),然后不再占用锁(sem_post(&mutex))。
消费者只有在某个区域"满了"(full)的情况下才能继续执行,然后它会把这个区域"消费"(get),直到消费"空了"(empty),添加这个条件(sem_post(&empty)),然后不再占用锁(sem_post(&mutex))。
这里就出现了典型的死锁情况:
假设有两个线程,一个生产者和一个消费者。消费者首先运行,获得锁(c0 行),然后对 full 信号量执行 sem_wait() (c1 行)。因为还没有数据,所以消费者阻塞,让出 CPU。但是,重要的是,此时消费者仍然持有锁。
然后生产者运行。假如生产者能够运行,它就能生产数据并唤醒消费者线程。遗憾的是,它首先对二值互斥信号量调用 sem_wait()(p0 行)。锁已经被持有,因此生产者也被卡住。
这里出现了一个循环等待。消费者持有互斥量,等待在 full 信号量上。生产者可以发送full 信号,却在等待互斥量。因此,生产者和消费者互相等待对方------典型的死锁。
正确做法是这样的:
scss
sem_t empty;
sem_t full;
sem_t mutex;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
sem_wait(&empty); // line p1
sem_wait(&mutex); // line p1.5 (MOVED MUTEX HERE...)
put(i); // line p2
sem_post(&mutex); // line p2.5 (... AND HERE)
sem_post(&full); // line p3
}
}
void *consumer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
sem_wait(&full); // line c1
sem_wait(&mutex); // line c1.5 (MOVED MUTEX HERE...)
int tmp = get(); // line c2
sem_post(&mutex); // line c2.5 (... AND HERE)
sem_post(&empty); // line c3
printf("%d\n", tmp);
}
}
int main(int argc, char *argv[]) {
// ...
sem_init(&empty, 0, MAX); // MAX buffers are empty to begin with...
sem_init(&full, 0, 0); // ... and 0 are full
sem_init(&mutex, 0, 1); // mutex=1 because it is a lock (NEW LINE)
// ...
}
还有一个经典的哲学家就餐问题,读者可以参考力扣1226题。简单来说,好的做法是隔一个哲学家同时进餐,如何设计呢?然后本书介绍了一些常见的并发问题,这个死锁依赖图给我感觉也很nice哈哈:
接着总结了产生死锁的四个条件,这部分也是常出现的面试题。最后是进阶课程:基于事件的并发。并发性这一章节也就结束了。
其实读下来可以也可以感觉到,这一章节重要的是熟悉各种概念,然后如何设计数据结构和函数去解决相应的问题,这才是真正不断发挥创造力的地方。
持久性
最后一个章节是持久性的问题,核心问题是如何长久保存数据。这一部分我们可以学到一些硬件知识,可以了解相应过程中,操作系统是如何同硬件打交道的,再往后可以看到操作系统是如何同网络进行交互的。我个人希望在对组成和网络理解更多的时候再回过头来看,这里也只是对其有个初步认识,细节的实现才是真正棘手的地方。
这一章节和计算机组成原理是有很多重叠部分的,这里也让我初步理解了一个困扰了我很久的问题:操作系统是如何与设备进行交互的。
这个问题可以通过古老的抽象技术来解决。在最底层,操作系统的一部分软件清楚地知道设备如何工作,我们将这部分软件称为设备驱动程序。
书中这个图粗略展示了Linux软件的组织方式:
在学习这部分知识的时候,要关注一些硬件的实现,这部分可以在计算机组成原理当中补齐。
寄存器的实现,懂得利用中断减少CPU开销,当然这也是需要就具体问题而定的:
注意,使用中断并非总是最佳方案。假如有一个非常高性能的设备,它处理请求很快:通常在CPU第一次轮询时就可以返回结果。此时如果使用中断,反而会让系统变慢:切换到其它进程,处理中断,再切换回之前的进程代价不小。因此,如果设备非常快,那么最好的办法反而是轮询。如果设备比较慢,那么采用允许发生重叠的中断更好。如果设备的速度未知,或者时快时慢,可以考虑使用混合策略,先尝试轮询一段时间,如果设备没有完成操作,此时再使用中断。这种两阶段的办法可以实现两种方法的好处。
另一个最好不要使用中断的场景是网络。网络端收到大量数据包,如果每一个包都发生一次中断,那么有可能导致操作系统发生活锁,即不断处理中断而无法处理用户层 请求。例如,假设一个Web服务器因为"电杆效应"而突然承受很重的负载。这种情况下,偶尔使用轮询的方式可以更好的控制系统的行为,并允许Web服务器先服务一些用户请求,再回去检查网卡设备是否有更多数据包到达。
另一个基于中断的优化是合并。设备在抛出中断之前往往会等待一小段时间,在此期间,其它请求可能很快完成,因此多次中断可以合并为一次中断抛出,从而降低处理中断的代价。当然,等待太长会增加请求的延迟,这是系统中常见的折中。
磁盘驱动器,这需要一点数学知识,但是很简单,不超过高中难度。
I/O的成本很高,所以操作系统专门有个磁盘调度程序来检查请求并决定下一个要调度的请求。和任务调度不一样的地方在于,每个任务的长度是不知道的。书里介绍了一些方式:最短寻道时间优先(SSTF)、电梯(又称SCAN或C-SCAN)、最短定位时间优先(SPTF)。
廉价冗余磁盘阵列(RAID),为了解决下面这个问题引入的一种存储虚拟化技术:如果磁盘出现故障,而数据没有备份,那么所有有价值的数据都没了。
本书插叙了一章文件和目录,介绍了一些常用的指令。而后简单介绍了文件系统的实现思路,以及出现断电或者系统崩溃的情况时,更新持久数据结构的方式。还有网络文件系统、分布式系统、Andrew文件系统等等。
通过阅读,可以大致理解操作系统是如何与网络进行交互的,也可以引出来通信原理的知识。到这里,这本书就大致结束了。
写在最后
我读这本书主要带着这个目的:在理解操作系统大致如何运行基础上,应对学校考试和笔试面试题,以及理解操作系统作为计算机组成和计算机网络的中间件是如何实现接口的。
对待知识,我总是希望在形成初步认知后,找到自己真正感兴趣的点,让自己有足够的热情去研究细节。若是横跨整个人生时间区间,我希望余生都可以做自己喜欢的事。也希望在前进的路上,可以遇到更多志同道合的人。
感谢阅读。