Operating Systems: Three Easy Pieces__读书摘要

原书链接:Operating Systems: Three Easy Pieces

介绍

1.学生与老师的对话

这一段主要讲学生应该如何学习。

听过->忘了

看过->记得

做过->理解

2.介绍操作系统

本书是给知道操作系统怎么运行的人阅读的。

当程序运行起来的时候,发生了什么?

程序跑起来的时候只是做一件非常简单的事情:它执行指令。

每秒几百万次的执行,处理器从内存中取指令解码这条指令,执行这条指令。

执行完当前这条指令后,处理器移动到下一条指令,直到程序最终完成

听起来很简单,但是我们会学习当一个程序跑起来时,围绕着我们最初的目的,让系统容易用,会有很多其他狂野的事情会发生。

操作系统让你的程序跑起来很简单,甚至允许你看上去有很多程序同时在运行,允许程序分享内存,允许程序通过设备去交流,还有很多有意思的事情。

操作系统这个软件负责确保操作系统运行正确且高效,而且还要容易用。

操作系统做的第一件通用技术是虚拟化,操作系统通过虚拟化技术把物理资源转化为更通用,更猛,更容易用的虚拟状态。

因此我们有时用虚拟机作为操作系统的参考。

为了更好用,操作系统提供一些接口供我们调用。一个典型的操作系统,一般来说会提供几百个系统调用

因为系统提供这些调用去跑程序,访问内存,访问设备,以及其他相关操作,我们有时候说系统提供一个标准库给应用。

最后,因为虚拟化允许很多程序运行,很多程序并发的访问他们自己的指令和数据,以及很多程序访问设备,系统有时候被认为是资源管理器,

处理器,内存,硬盘是系统的资源。

也正因如此,操作系统的角色是去管理这些资源,要做到效率,公平。

#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;
}

虚拟化CPU

这个程序没做太多事情,自旋1s,打印一句话,这个str是用户一开始在cmdline中输入的。

系统开始跑程序,该程序重复校验时间,直到1s过去。

过去1s后,打印一个用户输入的字符串。

当我们把上面的程序跑4个,输出的结果就非常复杂了。

事情变得有趣起来,尽管我们只有一个CPU,但是我们4个程序似乎一起跑了起来,在同一时间。

这种魔法一般的事情到底是怎么发生的?

这其实是操作系统在硬件的帮助下产生了这种错觉。

系统有很多虚拟的CPU,把一个CPU变成好像是无限个CPU,因此允许很多程序看似立刻运行起来,我们称之为虚拟化CPU,这是本书首先主要关注点部分。

当然,运行程序,停止运行,另外告诉CPU哪个程序去运行,这需要一些API帮助你跟操作系统去沟通你的需求,我们也会讲这些API通过这本书。事实上,大部分人都是通过API来跟操作系统沟通的。

你也许注意到了,运行多个程序在同一时间这个能力,引发了一系列的问题。例如,如果2个程序想要运行在一个特定时间,谁应该运行?一条系统方针回答了这个问题,系统方针(policy)在很多地方去回答这种类型的问题。所以我们会慢慢学习这些知识,通过不断的学习操作系统实现的基本机制。可以把操作系统的角色看成是资源管理者。

虚拟化内存

现在,让我们考虑一下内存的问题吧。

现代机器呈现的物理内存模型非常简单,内存只是一个字节数组

想要去读内存,需要一个地址,然后要能有权限访问存在这里面的数据

想要去写内存,你还需要一个指定的数据,写到内存里。

当一个程序运行时,内存都是可访问的,一个程序保存它所有的数据结构在内存中,并且通过各种指令去访问他们,比如加载,保存,或者其他明确的指令去访问他们。

不要忘记程序的指令也在内存中,因此在每次取指令时访问内存。

通过malloc去分配内存。

当我们去跑一个程序的多个实例时,发现分配了相同的地址,但是却能正常独立运行,

这就好像每个程序都有私有内存,但是却共享相同的物理内存一样。

事实上,正是如此,这就是操作系统的虚拟内存。每一个进程访问自己的私有虚拟地址空间,系统通过某种方式映射到机器的物理内存。

一个运行程序的内存引用,不会影响其他进程的地址空间

运行程序只关心自己获得的那点物理内存。

然而,实际情况是物理内存是一种共享资源,由操作系统管理。所有这一切究竟是如何完成的也是本书第一部分的主题,关于虚拟化的主题

并发

这本书另外一个主题是并发,我们使用这个概念术语来指代在同一个程序中同时(即同时)处理许多事情时出现并且必须解决的许多问题。并发问题首先出现在操作系统本身;正如您在上述虚拟化示例中所见,操作系统同时处理许多事情,首先运行一个进程,然后运行另一个进程,依此类推。事实证明,这样做会导致一些深刻而有趣的问题。

不幸的是,并发的问题不再仅限于系统本身,现代多线程程序展示了同样的问题。

程序运行次数多,counter++算出来的值就不一样。

这问题的原因在于执行指令的顺序。

执行++这个指令,先要load,然后increase,最后store,但是这些操作都不是原子性的,在本书的第二部分,有很多细节方面的描述。

持久化

持久化的硬件常常以输入输出设备的形式出现,HDD是常用的仓库,尽管SSD也很好。

文件系统是管理硬盘的,它负责可靠,高效的存放文件。

系统不会创建虚拟的,私人的硬盘空间。

open , write , close 这些系统调用会被路由到文件系统中,该系统负责处理请求,或者返回错误码给用户。

系统有点像一个标准库。

日志和写时复制来应对crash的问题。

症结:

文件系统是操作系统的一部分,用来管理持久数据,什么技术才能让这件事做得这么准确?

什么机制,策略需要去做到高性能,怎么才能更可靠,当面对硬件和软件的错误时。

第三部分,会讲通用IO,硬盘,RAIDs 和文件系统的相关细节。

设计目标

找到好的trade-off是成功构建系统的关键。

如何抽象,是让系统方便去用的关键,在计算机科学中,抽象是所有东西的基础。

抽象可以让我们写一个大系统拆分为非常小的,方便理解的小片段。

写C不用想汇编,写汇编不用想逻辑门

虚拟化让系统变得很好用,但这并不是没有代价的。

我们必须努力的去提供虚拟化以及其他系统特性但没有额外的开销。

这些额外的开销,可能是额外的运行时间,额外的空间,

我们寻找一种办法可以最小化其中一个,或者both,但这往往不能总是达到,有时候,我们要学着去注意和忍受。

另外一个目标是 为 应用与应用, 系统与应用 之间提供保护,使得他们互相之间不会影响。

隔离是一个重要的原则,

系统必须要不停的运行,否则运行在其上面的应用就会停止。

系统通常提供高度的可靠性。

随着系统长得越来越复杂,构造一个操作系统是非常具有挑战性的事情。

很多正在研究的课题聚焦在这个问题。

我们还有其他的目标:能效,安全,可移动设备,

###

一些故事

操作系统一开始就是一个lib,用于给开发者调用,以避免去写一些底层代码。

这个阶段主要是批处理

解释系统调用和程序调用的区别:系统调用开启的是硬件许可等级,而程序调用是用户层面的许可,这就意味着程序调用是受到约束的。

一个特别的硬件指令称之为trap

硬件传递控制去一个trap handler

提升为内核模式

系统完成系统调用,会把控制权交还给用户。return-from-trap

系统会加载很多工作到内存,然后

虚拟化

3.学生与老师的对话

physical <-> virtual

4.抽象:进程

本章节,讨论系统提供给用户的最基础的抽象:进程

进程的定义:正在运行的程序

程序是没有生命的东西:它就放在硬盘中,一堆指令等待开始行动。是操作系统获得这些字节,让他们运行起来,将程序变成有用的东西。

当同时想运行多个程序时,这么做是为了让系统更容易去运行,每一个进程都不需要关系CPU是不是可以用。

问题的关键:

怎么去提供有很多个CPU的错觉?尽管只有很少的物理CPU可以用,我们怎么才能够让程序以为我们有几乎无限多的CPU提供呢?

系统通过虚拟化CPU来制造这种假象,通过运行一个程序,然后停止运行去跑另外一个程序,更进一步的,系统可以增强这种错觉有很多虚拟的CPU。

这种基本的技术,叫做time sharing 的CPU(CPU时间分片?),允许用户去跑尽可能多的并发程序,潜在的成本是性能,如果CPU要被分享,那么每个进程会跑得更慢一些。

去实现CPU的虚拟化,而且要很好的实现,系统需要一些低级的机器和一些高级的智能。我们称之为低级机器机制。

下面我们会讲述如何进行 语境切换 (context switch ,也叫做上下文切换)

提示:使用时间分片和空间分片。

时间分片是一种基本技术,在OS中用来共享资源。

通过允许某个实体( entity ) 使用资源一小段时间,然后切换给别的实体使用。

相对于分时复用的是空间复用,例如

上下文切换赋予OS停止跑一个程序然后去跑另外一个程序的能力。

这种分时服用机制被所有现代的操作系统使用。

在这些机制之上,以策略的形式存在操作系统中的一些智能

策略是在系统中做决定的算法。

给定一堆可用的程序跑在操作系统中,那个程序应该运行呢?

这是调度策略决定的,一般使用历史信息(比如上一分钟谁跑的时间长一些),工作量知识(这是什么类型的程序),性能指标(系统是不是为交互性能,吞吐量进行过优化),来做决定。

4.1抽象:进程

进程是系统跑程序的抽象。

正如我们上面所说,进程只是一个正在运行的程序;在任何时刻,我们都可以通过清点它在执行过程中访问或影响的系统的不同部分来总结一个进程

要去明白什么构成一个进程,我们需要理解状态机。

程序的什么部分是重要的?当他在运行的时候?

进程的一个重要组件是内存,指令存在内存中,运行程序的数据读写也在内存中,

进程可以寻址的内存(地址空间),是进程的一部分。

寄存器也是进程状态机的一部分

一些特殊寄存器构成状态机

比如:

(PC , program counter , 也叫做 IP,instruction pointer),这个特殊寄存器会告诉我们下一步执行的指令是什么,

SP,stack pointer and FP ,frame pointer 是用来管理栈,函数参数,本地变量,返回地址。

最后还有关于IO操作的。

也就是说,进程包括:进程运行的地址空间,进程的状态机,进程用到的IO操作。

4.2进程API

进程必须实现的API

  • create

  • destroy

  • wait

  • miscellaneous control , 各种各样的控制,比如挂起suspend

  • status , 查看进程运行状态

4.3进程创建:一点细节

程序怎么转换为进程?

  • load , 将静态数据和代码加载进内存,到进程的地址空间,

  • create and initializing a stack , 创建和初始化栈

  • do other work as related to I/O setup 做一些与输入输出相关的事情。

早期系统会把程序一次过加载到内存,现在的程序只加载当前需要的,想知道怎么一点点加载进内存需要知道分页和交换的机制。

当我们讨论内存虚拟化,时再详细讨论,现在我们只要知道在允许任何东西之前,系统一点要做一些事情去获得重要的程序比特,将其从硬盘加载到内存。

当代码和静态数据加载到内存中后,系统需要做一些事情。必须为程序运行时的栈(stack)分配一定的空间。

C语言用stack栈 ,存放本地参数,函数参数,返回地址。

系统分配这些内存给到进程。

他们会填充main 函数的argc,argv数组。

系统会分配给程序的堆(heap)

在C语言中,堆用来分配动态分配的数据,程序请求分配内存函数mallocfree来释放

堆被很多数据结构所需要:链表,哈希表,树,还有其他有趣的数据结构。

系统还会做其他初始化的任务,特别是与输入输出相关的。

4.4 进程状态

  • running , 程序在允许。

  • Ready , 程序可以跑,但还不跑

  • block , 缺乏条件无法继续跑 , IO请求时,让出CPU给其他进程。

4.5 数据结构

有一些关键的数据结构去追踪相关的信息片段。

追踪每个进程的状态,例如,追踪进程列表。

xv6系统中,有一个寄存器上下文会被记录,用来切换进程,这是后面要讨论的上下文切换。

// the registers xv6 will save and restore
// to stop and subsequently restart a process
struct context {
int eip;
int esp;
int ebx;
int ecx;
int edx;
int esi;
int edi;
int ebp;
};
// the different states a process can be in
enum proc_state { UNUSED, EMBRYO, SLEEPING,
RUNNABLE, RUNNING, ZOMBIE };
// the information xv6 tracks about each process
// including its register context and state
struct proc {
char *mem; // Start of process memory
uint sz; // Size of process memory
char *kstack; // Bottom of kernel stack
// for this process
enum proc_state state; // Process state
int pid; // Process ID
struct proc *parent; // Parent process
void *chan; // If !zero, sleeping on chan
int killed; // If !zero, has been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
struct context context; // Switch here to run process
struct trapframe *tf; // Trap frame for the
// current interrupt
};

数据结构:进程列表

进程列表通常也称为任务列表。

有时候人们会倾向于独立的数据结构去保存进程信息,进程控制块,它是一个C的数据结构包含所有进程的信息,我们有时候也称之为进程描述器。

4.6总结

我们已经了解了系统的最基本抽象:进程。有了这个抽象在意识中,我们可以实事求是:我们需要底层机制去实现进程,也需要高层策略去智能化的调度这种进程。有了这些概念,我们可以知道系统如何虚拟化CPU。

5.插曲:进程api

在这个插曲,我们讨论UNIX如何创建进程。

UNIX提供了一个非常耐人寻味的方式去创建一个新的进程,使用一对系统指令:fork,exec

5.1 fork,系统调用

fork系统调用是用来创建一个新进程,但是请预先警告:这肯定是您调用过的最奇怪的例程。

5.2 wait,系统调用

wait,可以让某个进程先阻塞,使得程序能按我们预期的顺序运行。

5.3 exec,系统调用

exec把原先的进程复制一遍,重写的当前的堆栈,包括静态数据等,就好像被复制的进程没有执行过一样。

5.4 为什么我们要造API

fork和exec的分开可以让shell做一系列复杂的事情,

5.5 进程控制和使用者

kill,可以发signals 给一个进程,信号机制给我们提供了丰富的

6. 机制:有限的直接执行

分时服用CPU已经可以解决我们先运行一个程序,再运行另外一个程序的要求。但是如何建立这么一个虚拟化的机制,是充满了挑战的。

  • 性能:我们怎么才能做到不增加额外的开销

  • 控制:我们要怎么保证进程运行高效而保留控制权

在保持控制的同时获得高性能是构建操作系统的核心挑战之一。

要做到保留控制权的前提 还能高效的完成虚拟化,需要硬件和系统的支持。

系统会使用恰如其分的硬件支持然后高效的完成这件事。

6.1 基本技术:有限的直接执行

系统跟程序的交互

Create entry for process list

Allocate memory for program

Load program into memory

Set up stack with argc/argv

Clear registers

Execute call main()

Run main()

Execute return from main

Free memory of process

Remove from process list

好像很简单,但这其实引发了其他的问题:

  • 怎么保证程序不会影响其他程序,同时还高效运行。

  • 怎么切换不同的进程。

6.2 问题1:受限的操作

受限的操作如何让程序完成在cpu中跑的要求呢?

系统调用其实就是函数调用,只是里面隐藏着著名的trap指令。

为了执行一个系统调用,程序必须执行一个特殊的trap指令,这个trap指令跟让用户进入系统模式没有太多区别。

上电时,系统初始化,系统会告诉硬件,当发生硬件中断时,应该去执行什么代码,当发生键盘输入时,应该执行什么事情,等等规定。

系统会告诉硬件一系列的trap handles(类似钩子函数?)

通常也伴随着一些特殊的指令。

尽管我们花费了很大的力气去保护操作系统,但是我们还要注意用户输入值的边界,

去识别具体的系统调用,系统调用会标记系统调用号。

OS @ boot                  Hardware
(kernel mode)
initialize trap table
                          remember address of...
                          syscall handler
                          
OS @ run                     Hardware                   Program
(kernel mode)                                          (user mode)
Create entry for process list
Allocate memory for program
Load program into memory
Setup user stack with argv
Fill kernel stack with reg/PC
return-from-trap
                            restore regs
                            (from kernel stack)
                            move to user mode
                            jump to main
                                                        Run main()
                                                        ...
                                                        Call system call
                                                        trap into OS
                            save regs
                            (to kernel stack)
                            move to kernel mode
                            jump to trap handler
Handle trap
Do work of syscall
return-from-trap
                            restore regs
                            (from kernel stack)
                            move to user mode
                            jump to PC after trap
                                                        ...
                                                        return from main
                                                        trap (via exit())
Free memory of process
Remove from process list

6.3进程间切换

这是个非常严重的问题,其本质是,我们怎么从新获得CPU的控制权。

  • 合作的方式:等系统调用

    这种方式假定每个程序都周期的让出CPU资源,但是如果有个程序一直卡在死循环中,系统就无法进行切换了。

  • 不合作的方式:由系统来控制

    通过时钟中断来实现,当时钟中断来时,当前运行的进程停止,预先配置好的时钟中断handler运行,在此时系统重获CPU控制权。

    当一个中断发生,硬件有责任为当前运行的程序 保留 足够多的状态,当从trap返回时怎么继续运行。这种中断后要执行的行动,跟执行一个明确的系统调用,其实是很像的,都需要保持寄存器的状态等,方便从trap返回时的运行。

保存和重载上下文

调度器负责决定是否切换当前进程。

如果是要切换进程,系统会执行上下文切换的底层代码。

所有的操作系统只需要保存一些当前运行程序的寄存器值,把它们存到内核的栈,重载一些寄存器的值。

保存通用寄存器,PC,SP寄存器的值。

重载寄存器的值。

内核栈切换到准备运行的进程。

通过切换栈,通过切换堆栈,内核在一个进程的上下文中进入对切换代码的调用,返回这个准备运行的进程的上下文,当系统终于真正运行return-from-trap指令,准备运行的进程就成为当前运行的进程。

reboot 很好,可以重置状态机

时钟中断让系统获得控制权,这非常棒。

OS @ boot 							Hardware
(kernel mode)
initialize trap table
                                    remember addresses of...
                                    syscall handler
                                    timer handler
start interrupt timer
                                    start timer
                                    interrupt CPU in X ms
                                    
OS @ run 							Hardware 							Program
(kernel mode) (user mode)
																		Process A
																		...
                                    timer interrupt
                                    save regs(A) → k-stack(A)
                                    move to kernel mode
                                    jump to trap handler
Handle the trap
Call switch() routine
save regs(A) → proc t(A)
restore regs(B) ← proc t(B)
switch to k-stack(B)
return-from-trap (into B)
                                    restore regs(B) ← k-stack(B)
                                    move to user mode
                                    jump to B’s PC
                                    									Process B

用户的寄存器由硬件存储

内核寄存器由系统存储,

lmbench 这个工具可以测量上下文切换要花多长时间。

内存带宽的限制也可能使得你CPU的性能无法全部发挥。

7.调度介绍

早期的调度器来自人类社会实践经验,比如流水线怎么运转等。

7.1 工作负载假设

对工作负载越了解,你就能更细致的调整自己的调度策略。

fully-operational scheduling discipline , 全面运作的调度策略。

进程的假设,有时候进程也被称为job

  • 每个工作跑相同的时间

  • 所有工作在同一时间到达

  • 一旦开始,工作就一直到到结束

  • 所有的工作只用到CPU

  • 所有工作的运行时间都是知道的。

7.2 调度指标

Scheduling Metrics

目前先采用只有周转时间这么一个指标。

周转时间是一个性能指标

另外一个指标是公平性。

性能跟公平性经常是矛盾的,

7.3 FIFO

FIFO : 有很多很好的特性,简单,容易实现,鉴于我们的假设,它运行得很好。

但是FIFO无法保证Shortest Job First , SJF ,

调度设计需要遵从最短任务优先。

7.4 SJF.最短的工作有限,shortest job first

如果说所有任务都同时到达,那当然,我们的SJF调度策略就是最佳策略。

7.5 最短时间完成优先 STCF ,shortest Time-to-completion First

释放条件3,一个程序开始就要跑完

通过时钟中断和上下文切换,当某个程序抢占CPU一定时间后,进入时钟中断,然后切换到别的进程。

Preemptive Shortest Job First (PSJF) 抢占最短工作优先

7.6 新的度量:响应时间

要解决一个终端没有响应的问题,如果只是以周转时间为度量指标,那么系统会先跑A, 跑完再跑B,再跑C

但是用户坐在终端面前,等待C的反馈,这样用户体验就不好了,互动性很差。

所以我们要建造一个对响应时间敏感的调度器。

7.7 循环 Round Robin

循环调度(RR),可以解决响应不及时的问题。

循环调度不是跑完整个任务,而是去跑一个时间片,time slice , 有时候也被叫做调度量(这个英文有点意思,叫做scheduling quantum , quantum 在粤语里面叫 kouta , 比如说计划生育,我不生,能不能把这个kouta让给别人呀?)

每过一个时间片,就切换下个进程来跑,直到跑完,所以有时候说一个循环也叫做一个时间片。

一个时间片必须是时钟中断周期的倍数

时间片越短,看似响应时间越快,但是这也会导致上下文开销的问题。

值得注意的是,上下文切换的成本,不仅仅只有保存和恢复几个寄存器的操作系统操作。

程序在跑的时候,还会建立大量的状态,在CPU缓存中,在分支预测,转译后备缓冲器等一些列的芯片硬件中。

随意的切换任务,会导致这些状态被flush

TLB : translation lookaside buffer : is a memory cache that is used to reduce the time taken to access a user memory location.[1] It is a part of the chip's memory-management unit (MMU). The TLB stores the recent translations of virtual memory to physical memory and can be called an address-translation cache.

转译后备缓冲器:用于改进虚拟地址到物理地址的转译速度,这是CPU中的一种缓存,存放虚拟地址映射到物理离职的标签页表条目。

如果访问的虚拟内存在TLB中,在硬件上,可以完成快速的转换,很快给出一个匹配结果。

如果不在TLB中,就要使用标签页表进行虚拟地址转换,但访问速度跟TLB比起来就很慢。

当我们提供响应时间的表现时,周转时间就非常的糟糕了。

其实任何公平的调度政策(对CPU资源分配按时间片来分配),在周转时间上都是表现不佳的,事实上,这是一种固有的权衡。

如果你不想公平,可以让运行时间短的程序先跑完,这样响应时间就慢了。

如果你想要公平,响应时间短,那你的周转时间就长。

在可能的情况下,重叠操作以最大限度地提高系统的利用率 , 比如在硬盘IO,发送消息给远程机器,

7.8 考虑IO

上面都没有提到IO操作,这里试图将IO操作引入讨论,如果程序还要进行IO操作呢?怎么办?

initiates 发起,

当IO请求发给了硬盘驱动,进程会阻塞几个毫秒或者更长。

当CPU使用和disk使用同时发生,叫做overlap,

因此,我们看到了调度程序如何合并 I/O。通过将每个 CPU 突发视为一项作业,调度程序可确保“交互式”进程频繁运行。当这些交互式作业执行 I/O 时,其他 CPU 密集型作业运行,从而更好地利用处理器

7.9 不再有甲骨文

7.10 总结

两个指标: 周转时间,响应时间。

multi-level feedback queue 多级反馈队列。

8.多级反馈队列,MLFQ

Multics 多信道

MLFQ 从历史中学习,然后去预测未来。

8.1MLFQ基本原则

  1. 如果优先级A>B,A跑,B不跑

  2. 如果优先级相等,则遵循RR原则。

MLFQ 有很多不同的队列,这些队列有不同的优先级。

高优先级的工作先运行

这种调度器的关键在于如何去设定调度优先级,优先级根据job被观察的行为进行调节。

relinquishes 放弃

如果一个进程经常放弃CPU资源,等待用户输入,那么他的优先级会提高

如果一个CPU密集型进程在跑了很长一段时间,那么他的优先级会降低。

8.2尝试1:怎么改变优先级?

  1. 当一个job进入系统,把优先级调到最高。

  2. 如果这个时间片都用完,那么这个job的优先级下降。如果没用完这个时间片就放弃CPU,他就继续保持当前的优先级。

调度器必须能在攻击中被安全保护。

如果一个进程在CPU时间片快用完的时候,放弃CPU,那么他就不会被降级,这样的话对其他进程就不公平。

8.3 尝试2:优先级提高

  1. 经过一定的周期,将所有jobs移动到最高优先级的队列。

怎么去设定这个周期,被称为巫毒常数,过快,过慢的提升优先级都不太好。

8.4 尝试3:更好的会计

  1. 重写规则4,当job使用完当前等级的时间配额,则降级

8.5 调整MLFQ和其他问题

一个关键问题是如何参数化:parameterize

几个队列?每个队列要多少时间片?优先级应该多久提升一次?

这就需要系统调优了。

避免巫毒常数 , Ousterhout’s Law

使用默认值来规避一些特殊参数错误导致的问题。

常见可调整参数:

  • 不同队列的时间片参数

  • 每个时间片的长度,多久优先级提升一次。

nice指令可以给系统提建议,提升或者降低某一个进程的优先级。

9.比例份额调度器

通常也被称为彩票调度器:

其实非常的简单,抽一张彩票去决定谁来运行,哪个进程更应该进行就给他更高的运行概率。

9.1 基本概念:票据代表你的份额

随机有3个好处:

  1. 避免边缘情况

  2. 轻量级

9.2 票据机制

  1. 票据汇率,所有进程在抽奖运行时,都会映射到系统给他的票据来进行。

  2. 票据交换,一个进程可以主动把自己的票据给其他进程。

  3. 票据通胀,程序互相信任的时候,可以有一个大哥,把一个小弟进程票据变得很多,让他能用更多的CPU资源。

9.5 怎么去分配

票据的分配要求使用者非常清楚自己的系统中,哪些程序应该使用更多的CPU时间。

9.6 步幅调度

这也是一种直接了当的调度方法,新票号=当前票号+步幅

特点是每个进程都按照其票据完全公平的分配了CPU运行时间。

彩票形式的调度策略比步幅的调度策略多了一个好处:没有全局状态。这样对新加入的进程比较友好。

9.7 Linux系统的完全公平调度

高效,可拓展。CFS目标是花很少的时间去做调度决策,通过其固有的设计和对数据结构的巧妙使用,非常适合该任务

尽管经过积极优化,调度器还是会占用5%的CPU时间。

该调度器遵循一个基本原则:公平的分配CPU,尽管是竞争的进程

virtual runtime (vruntime). 虚拟运行时间

如何平衡公平和效率?

CFS用多个控制参数进行:

  1. sched latency , 调度在准备切换之前要运行多长时间

    保证公平的方法,sched latency/进程数 = 每个进程可以分到的时间片,每个进程都跑这么多个时间片。

  2. min granularity, 最小粒度,典型值6ms,

如果作业的时间不是理想的时间片的倍数,那么CFS会追踪vruntime的值,随着时间的延长,最终还是会公平。

  1. 权重,根据权重去分配sched latency,也就是进程的运行时间。同时要适配vruntime要映射回标准时间。

  2. 使用红黑树,更快的查找,保存进程到一棵红黑树,时间复杂度为O(logn)

处理IO,睡眠进程。

对于睡眠进程,如果一个进程睡眠了,那么他将不会再运行的jobs的红黑树中,那么这是唤醒时,我们将最小的值给到他,让他享受CPU资源,但是又不至于占用太久。但这对于一些只睡眠一小段时间的进程来说,是不公平的,因为这个机制,他们没有分享到其应有的CPU资源。

9.8 总结

没有什么调度器是灵丹妙药,每种调度器都有其存在的缺陷。

10.多CPU调度

多线程程序才能发挥多核心处理器的性能。

10.1 背景:多处理器架构

重点关注硬件缓存,

在单CPU时,有多级缓存制度,

缓存因此基于局部性的概念,其中有两种:时间局部性空间局部性

缓存一致性问题,cache coherence

总线窥探:bus snooping

通过监控内存的访问,如果知道了有CPU拷贝了这段内存,当我们去读值的时候,就要放弃读,或者用缓存的数据先更新这个值。

10.2 不要忘记同步

当程序访问共享数据时,他们需要关心很多事。

上锁可以解决同步问题,但是如果核心数越多,上锁会导致性能下降就越大。

10.3 缓存关联Cache Affinity

尽可能保持一个进程在同一个CPU,如果可能的话。

10.4 单个队列调度

SQMS ,不用改太多代码,沿用之前的单CPU的机制。

但是有缺点,扩展性差,锁会降低性能,

内存关联问题的解决办法,尽可能让一个同一任务在同一个CPU中执行,有些任务就用负载均衡使用。

migrating 迁移

由于同步开销,这种调度方法扩展性不好。它不容易保留缓存关联

10.5 多队列调度

扩展性好,但负载不均衡。

进程迁移可以防止某些核心不工作的问题。

反复迁移可以一定程度保证负载均衡。

work stealing 盗取工作。

方法是去查看其他队列是不是有很多进程在跑,如果有就把这些进程搬到自己这边。

但是这引发一些列的问题,如果查得太频繁,那么就会增加开销,如果查得不频繁,就要忍受负载不均衡的问题,又是一个抉择问题,需要调参。

10.6 linux的多处理器调度

有3个调度器:

  • the O(1) scheduler,多队列,有点像多级反馈队列,通过优先级来决定谁跑。

  • the Completely Fair Scheduler (CFS),多队列

  • the BF Scheduler (BFS),单队列

10.7 总结

单队列 负载均衡较好,但是扩展性差,难以进行缓存关联。

多队列 扩展性好,缓存关联处理起来也相对容易,但是这比单队列要复杂。

11. CPU虚拟化总结

调度器权衡:周转时间,响应时间

12. 内存虚拟化

地址空间,隔离,保护,

13. 地址空间

打造一个计算机操作系统本来是很简单的事情,但是那些该死的用户带着他们的期望,便于使用,高性能,可靠性。这真的导致了很大头痛的问题。

13.1 早期操作系统

系统只是一个循环,从地址0开始运行,进程就跑在物理内存中。

13.2 多程序,分时共享

因为机器贵,人们想更高效的共同使用机器,多程序诞生,这提高了设备利用率,可以省钱。

因为有人会写垃圾程序,导致系统变慢,采用分时共享的办法,限制垃圾程序的运行。交互性要求的提高,让更多的人关注分时共享

多个程序,把内存划分为固定的段落,CPU在内存直接切换运行。

13.3 地址空间

地址空间,是为了让系统更容易使用而存在的。

这个抽象是运行程序视角下的系统内存。

一个进程的地址空间包含所有该进程所有的内存状态

地址空间有什么?

  • 程序代码, code

  • 栈,stack ,用来追踪函数调用链,分配本地变量,参数传递,返回值,

  • 堆,动态分配,用户管理内存,

  • 静态初始化变量

  • ...

13.4 目标

虚拟内存系统的一个主要目标是 透明度transparency , 虚拟内存不能让进程知道自己是在使用虚拟内存,进程通过虚拟内存看到的,好像是自己运行在真实的物理内存中一样,我们要制造这种幻觉。

另外一个目标是 效率 , 效率包括时间,空间,尽量不适用太多时间,不是用更多额外的空间。

对于时间:需要依赖硬件支持,TLBs, 加速虚拟地址到物理地址的映射

最后一个是 保护

隔离原则

建立可靠的系统,

系统还有一些是分成不同的块, 做这样的隔离可以让系统有更好的可靠性, 与之对比的是整体的内核设计.

cocoon 茧 (蚕茧 cánjiǎn)

13.5 总结

虚拟内存提供一种幻觉: 大 , 稀疏, 程序私有地址空间 ,

这一系列的要求,需要很多底层的机制, 关键的策略去完成.

如果我们在用户空间能打印出来的地址,都是虚拟地址

只有硬件和系统知道真正用了哪段内存.

14.插曲:内存api

主要问题:

怎么去分配和管理内存?

如何分配内存是打造鲁棒,可靠软件的关键.

什么接口会经常使用?

什么错误应该去避免

14.1 内存类型

  • stack 内存, 编译器隐性的帮你管理,什么时候分配,什么时候释放. 有时也叫自动内存, C语言中默认就是自动内存automatic memory

  • heap 内存, 由程序员显式的分配 , 需要程序员自己分配自己释放.

14.2 malloc调用

sizeof 是编译时运行的, 有点像宏定义 , 这是一个运算符操作数.

malloc是在运行时执行的,

因此sizeof 一个指向malloc的指针,并不会返回正确的大小.

14.3 free调用

free 不需要提供内存大小的数据, 这其实是因为内存分配库已经帮忙做这件事了.

14.4 常见错误

even though it ran correctly once, doesn’t mean it’s correct.

  • 忘记分配内存

  • 分配不足够的内存

  • 忘记初始化内存分配内存的值.

  • 忘记释放内存. -> 内存泄漏 -> 内存耗尽

  • 指针悬空

  • 重复释放

  • 错误调用free

内存错误问题, 有两个工具可以帮助到我们 purify [HJ92] and valgrind , valgrind 免费开源,可以使用来检测动态内存分配是否正确.

14.5 潜在的系统支持

系统调用 brk

用来改变系统break的位置.

mmap也可以获得内存,创建匿名内存区域,这个区域与特定的文件内存无关,而是与交换空间有关.

15. 机制:地址转换

关键: 如何关键,灵活的完成内存虚拟化

内存管理,追踪哪些内存使用率,哪些内存可以用.

做的所有目的都是未来提供一种幻觉: 程序有自己私有的内存.

15.1假设

我们先假设:

  • 程序的地址空间必须是连续的放在内存中.

  • 内存空间不是很大,特别是不能比物理内存大

  • 每一段内存空间大小一致.

15.2 一个例子

讲了一个程序如何在系统中完成地址映射

15.3 动态搬迁

硬件地址迁移,

base and bounds 基础和边界

dynamic relocation 动态搬迁

地址转换: 物理地址 = 基地址+ 虚拟地址

base 寄存器用来存放地址映射的首地址

bounds 寄存器用于限制内存,防止越界,用来保护进程.

有时人们把处理器中帮助完成地址映射的部分,叫做MMU, memory management unit

15.4 硬件支持总结

硬件需求 备注
特权模式 防止用户进程执行特权操作
基地址+边界地址寄存器 存放映射的地址
能够转换虚拟地址 并且 检测是否越界
特权指令去更新 2个地址寄存器
特权指令去处理寄存器异常
能够唤醒异常

15.5 系统支持

free list 可用的内存空间

OS 需要完成的事 备注
内存管理 需要为新的进程分配内存, 回收内存,
基地址,边界地址的管理 必须设置对,当进行上下文切换的时候
异常处理

process control block (PCB)

internal fragmentation 内部碎片化

segmentation 段

15.6 总结

下一章解决内存内部碎片化的问题。

16. 段

关键问题:

我们应该怎么支持超大地址空间?

16.1 段,广义的基地址和边界地址

将code ,stack , heap 分3段,放在内存中。

对非法地址的访问,触发段错误。

16.2 我们指的是哪一段?

用14个bit去存offset,段的类型:

有的用2位:00,01,11 去代表 code,stack ,heap ,

有的用1位:0,1, 代表stack,heap

16.3 stack

stack 的增长方向跟其他的不同,他是从高地址向低地址的。

因此还要记录一下增长极性

16.4 支持共享

通过在段中增加一些比特,来告诉系统这个段是只读的,这样就可以减少相同代码段重复载入内存的情况。

这些比特讲告诉系统,这个段是可以读写的,或者只读,可执行 等状况。

这样的话,就算跳转的只读可执行的段中去取指令,也不会发生段错误。

16.5 细粒度和粗粒度的段

刚才只聚焦在3大段,code stack , heap

实际上可以分得更细,但需要更多的硬件支持。

16.6 系统支持

external fragmentation 外部碎片化, 当我们反复创建,分配,回收,就会出现很多内存碎片,

通过compact 压实内存 ,把内存搬到一起,留出一大段可以用的内存。

压实内存的开销是很大的。

上面那种搬迁的方式不太好,可以用空闲内存列表管理算法,尽可能的去保留大的空间可以被申请。

  • best-fit : 每次都返回能满足请求内存的最小块

  • worst-fit :

  • first-fit :

  • buddy algorithm

  • ...

但是无论多么聪明的算法,外部碎片化还是依旧存在,只是算法尝试去把这个问题最小化。

16.7 总结

目前还没办法支持稀疏的地址空间工作。

下一章来解释这个问题。

17 可用空间管理

本文先谈一下内存管理系统。

paging 分页。

关键问题:

怎么去管理空闲空间

17.1 假设

  • 系统支持malloc,free这种接口

  • 有一个空闲列表

  • 目前先只关注外部碎片化的问题

  • 一旦内存分配给了某个客户,就不能再给其他客户

  • 连续的块是固定大小

17.2 底层机制

内存分配器的底层机制

  • 分裂,合并

  • 追踪分配内存块的大小,状态

  • 构建一个简单的list去保持追踪哪个内存是空闲的,哪个是被占用的

splitting and coalescing 分裂,合并

尽可能让分裂出来的块跟已分配的块连一起,当内存释放时,要重新合并一起,尽可能提供最大的块

追踪已分配的块大小

free 不接收大小参数,那是因为每个已分配的块,会用少量的额外空间去存这个块中的信息,这些额外信息是:块大小,magic number (用于完整性检查。)

free 块 = header + 分配的空间

当用户请求一个空闲空间块,其实系统请求的是: 这个块的大小+header

Embedding A Free List

这里举了一个例子,如何分配释放heap的空间。

growing the heap

sbrk 系统调用,可以使得堆空间扩展,新建一个块在这个地方。

通过上面的链表,我们可以看出,如果我们发现当前分配的堆并不能满足用户需求,我们只要在另外一块物理地址,开辟一段空间,把这段空间,放在当前进程内存块链表中即可。

17.3 基本策略

管理内存的基本策略,讨论其优缺点

Best Fit

找到最小的,刚好可以满足的块,然后返回

worst fit

找到最大的块,分配。不能用,

first fit

使用找到的第一个能用的块

next fit

用first的策略,但是从上次分配到的内存块开始找,而不是从头开始

coalescing 合并

其他方式

Segregated 隔离,分离

分离列表

如果特定应用程序有一个(或几个)流行大小的请求,则保留一个单独的列表来管理该大小的对象;所有其他请求都转发到更通用的内存分配器

slab分配器

一个特殊的分配器,由超级工程师 Jeff Bonwick 设计的slab分配器(它是为在Solaris内核中使用而设计的),以一种相当好的方式处理了这个问题[B94]。 具体来说,当内核启动时,它会为可能被频繁请求的内核对象(例如锁、文件系统 inode 等)分配一些对象缓存;因此,每个对象缓存都是给定大小的隔离空闲列表,并快速提供内存分配和空闲请求。当给定的缓存可用空间不足时,它会从更通用的内存分配器请求一些内存块(请求的总量是页面大小和相关对象的倍数)。相反,当给定slab中对象的引用计数都为零时,通用分配器可以从专用分配器中回收它们,这通常在VM系统需要更多内存时完成

这个分配器避免了频繁的创建销毁,较少了开销。

Buddy Allocation

友好分配,二进制友好分配。

很方便合并。每一块

other ideas

用链表来存这个内存块,会很慢。

现代系统用更复杂的结构来存储,比如平衡二叉树,等

内存分配器需要适配多处理器,多进程的问题。

18. 分页介绍

将内存按固定大小分配,也叫做分页。

page 页

page frames 页框

illustrate 阐明

18.1 总览

page table 页表

页表用来存储地址转换,

虚拟页数字+偏移。

虚拟页数字通过地址转换器转为物理地址

virtual page number - > physical frame number (physical page number )

offset 不需要转换

18.2 页表存在哪?

页表是最重要的数据结构,这个表存储了虚拟内存到物理内存的地址转换,

在32位系统,每一页为4KB,总共有 20bit存虚拟页码,12bit存偏移

18.3 什么在页表里面

线性页表,只是一个数组。

  • 有效位, 判断当前页面是否有效,如果不是堆栈,就不能操作?

  • 保护位 , 能否被读写执行

  • 当前位,判断是否跟disk交换

  • 脏位, 判断从载入内存后,是否被修改过。

  • 参考位,用来判断追踪一个页是否以及被访问过,这个有助于我们发现这个页非常多人访问,应该保留在内存中。在页替代时非常有用。

18.4 paging:也太慢了

这里给出了虚拟地址具体转换为物理地址的方法。

但是太慢了。

太慢,而且太占内存的问题必须要解决。

18.5 内存追踪

这里展示了一个内存追踪的案例,试图说明我们内存映射是一件非常复杂的问题。

18.6 总结

分页有很多好处

  • 不会引发外部空洞

  • 比较灵活,虚拟地址空间可以稀疏的使用。

但是实现分页支持不小心的话会让程序降速,内存浪费,

下两章,讲怎么更好的工作。

19 分页:更快的翻译,TLBS

translation-lookaside buffer ,翻译后备缓存

TLB 是 芯片MMU的一部分。

一个更好的名字是,地址转换缓存

19.1 TLB基本算法

有一段实现的代码。简单来说就是先去查TLB,如果TLB没找到就按原来的方法,如果找到了就命中了,那么就很快,而我们就是要提高这个命中率

19.2 访问一个数组的例子。

spatial locality 空间的局部性

因为空间的局部性,可以让TLB命中率提高。

temporal locality 时间局部性

当page size 更大时,命中率更高。

19.3 谁处理TLB命中失败?

CISC : complex-instruction set computers 复杂指令集计算机。

RISC : reduced-instruction set computers 精简指令集

以前做计算机复杂指令集的人不相信做操作系统的人,因此TLB命中失败,由硬件处理。CISC

更现代的架构,RISC ,采用软件管理的TLB。

19.4 TLB内容,里面有什么?

fully-associative 完全关联

硬件并行在TLB里找。

其他位:

  • 有效位 , 转换是否有效

  • 保护位, 决定一页如何被访问

  • 地址空间位

  • 确认位

  • 脏位

19.5 TLB事务:上下文切换

进程切换时,TLB也要切换,因为TLB是跟当前进程相关的。

如果在TLB中有两个虚拟地址一样的记录,那么应该怎么解决呢:

  • flush ,直接刷掉,进程切换一次刷一次。但这会增大开销,

  • 地址空间识别位,这就像PID一样,不过一般位数比较少。

可以两个进程共同用一段内存,而且还能同时存在TLB中。

19.6 事务:替换策略。

缓存替换,当我们加载一个实体进入到TLB,而且必须替换原来的,我们应该替换哪一个?

least-recently-used : LRU 最近最少使用

  • 最近最少使用原则

  • 随机原则

19.7 一个真实的TLB入口

随机访问并不总是随机,TLB命中时,访问要快一些,没命中时访问要慢一些。

19.8 总结

database management system 数据库管理系统,对大页面的支持,在下一章讲。

20.分页:更小的表格

现在开始处理,耗费太多内存的问题。

20.1 简单的办法:更大的页

导致内部空洞

20.2混合方法:分页和分段

分3页,再申请,去看一下哪些没用完,继续用?

20.3多层分表

这是一个不依赖分段,但是解决同样问题的方法,这个问题是:在不把这些不规则的内存块全部丢进内存的前提下,怎么摆脱不规则的区域的正确映射。

要把线性链表用树来表示了

页表 -> 页字典,

对于申请了但是没有用的内存,我们不需要真正的给他分配内存,我们只要用一个页字典,去把真正写了东西的页的位置存下来,其他没有写东西地方,就不需要占用真正的物理内存,当真正写的时候,再把我们的页字典更新一下就可以。

也就是,申请但是没有用的内存,不会真正的占用物理内存,只会占用一个小小的字典指针?这样就极大的提高了内存的利用率。

时空权衡

当我们想要更快的运行速度时,往往需要付出更高的空间代价。

我们可以看出,多层分表的方式,会带来一些cost,当TLB没有命中时,我们需要去加载两次内存,第一次跳到页字典,第二次跳到具体的页。

通常情况下,我们愿意用复杂性去交换更好的性能,减少的overheads,但是在多级页表的例子中,我们提高复杂度去节省有效空间。

访问模式: 页字典 -> 页表 -> 具体信息

虚拟页数字,由 页字典+页表 组成。

页表中存放了,具体的页有没有被使用的信息。

多于2层

我们需要多于两层的树。要一级一级去找,是否合法。

翻译过程,要记得TLB

每次去查多级列表之前,要先去看TLB是不是命中。

20.4 反转页表

一个更节省空间的页表 叫反转页表

POWERPC用反转页表的方式,

不是每个进程都有一个页表,我们保持一个页表有一个入口对应系统中的各个物理页表,这个入口告诉我们哪个进程在用这个页,这个进程的哪个虚拟页映射到了物理页。

找到一个正确的入口是需要搜索的,因此用哈希表去提高速度。

20.5 交换页表到硬盘

有一些系统把页表放到内核虚拟内存,可以用来跟硬盘交换,之后有章节会说。

20.6 总结

我们看到了真实的页表是怎么建立的,页表越大,TLB命中失败概率越高。对不同系统,我们用不同的分页方式更好。

有空间,就用空间换时间,没空间就只能用时间换空间。

21. 超过物理内存:机制

memory hierarchy 内存层次。

在内存层次中,硬盘空间在最底层。

超过的内存需求,我们可以先把不那么紧迫的内存交换到更低层次的内存空间。

在老机器,多进程应用需要将内存交互出来。

21.1 交换空间

我们要完成空间交互,首先就要在硬盘中保留这部分空间, 而这个空间我们一般叫做交换空间

简单的举例,我们怎么进行物理内存跟交换空间的交换。

21.2 当前比特位

假设有TLB

整个流程:

去TLB -> 找不到 -> 页表入口(包含多级页表的查询) 且 present(也就是在内存中)-> 找到后,更新TLB , 目前为止都ok

在页表中有一个位,叫当前比特位,这个位告诉系统,目前要访问的内存是否在内存中,如果不在内存中那么就是被交换到了硬盘中。

访问不在物理内存中的页面的行为通常称为页错误

page-fault handler 页错误处理

TERMINOLOGY 术语

一个术语的说明。

当我们试图去访问一段不在内存中的内存,这其实是一个非法的行为,而这个时候,硬件不应该再做过多的事情,而是应该发起错误,让系统来决定到底应该怎么做

这在操作系统中是合理的。

21.3页错误

页错误处理器会决定发生页错误时应该怎么做。

系统怎么找到我们需要的页?

在很多系统中,页表是一个很自然去存这些信息的地方,PTE通常使用像PFN这样的数据去找到在disk中的位置。

为什么硬件不处理页错误?

  1. 页错误到硬盘很慢

  2. 要处理页错误,硬件不知道页表,其实无法处理

当页错误处理返回后,更新页表,更新TLB,然后继续执行之前的指令(获取数据或者指令)

发生IO时,进程会进入阻塞,

overlap 重叠

21.4假如内存满了呢?

page in 将数据从硬盘搬到内存

page out 将数据从内存搬到硬盘

page-replacement policy. 页替换策略

这个交互策略后面才讲,但我们应该知道,如果我们换了使用较少频次的内存,我们得到一个运行速度更接近内存速度的系统。

21.5 页错误控制流

我们已经有一个非常粗略的草图,关于内存是如何访问的。

1 VPN = (VirtualAddress & VPN_MASK) >> SHIFT
2 (Success, TlbEntry) = TLB_Lookup(VPN)
3 if (Success == True) // TLB Hit
4 if (CanAccess(TlbEntry.ProtectBits) == True)
5 Offset = VirtualAddress & OFFSET_MASK
6 PhysAddr = (TlbEntry.PFN << SHIFT) | Offset
7 Register = AccessMemory(PhysAddr)
8 else
9 RaiseException(PROTECTION_FAULT)
10 else // TLB Miss
11 PTEAddr = PTBR + (VPN * sizeof(PTE))
12 PTE = AccessMemory(PTEAddr)
13 if (PTE.Valid == False)
14 RaiseException(SEGMENTATION_FAULT)
15 else
16 if (CanAccess(PTE.ProtectBits) == False)
17 RaiseException(PROTECTION_FAULT)
18 else if (PTE.Present == True)
19 // assuming hardware-managed TLB
20 TLB_Insert(VPN, PTE.PFN, PTE.ProtectBits)
21 RetryInstruction()
22 else if (PTE.Present == False)
23 RaiseException(PAGE_FAULT)
1 PFN = FindFreePhysicalPage()
2 if (PFN == -1) // no free page found
3 PFN = EvictPage() // run replacement algorithm , 为了提高效率这里使用交换守护线程来处理,把一部分空间搞出来。
4 DiskRead(PTE.DiskAddr, PFN) // sleep (waiting for I/O)
5 PTE.present = True // update page table with present
6 PTE.PFN = PFN // bit and translation (PFN)
7 RetryInstruction() // retry instruction

21.6 当替换真实发生

系统会主动保留一部分空闲的内存

high watermark (HW) 高水印

low watermark (LW) 低水印

evicting 驱逐

当系统发现可用的页面少于LW时,一个后台线程会释放内存

这个线程驱逐 页,直到HW页可以用。

这个线程有时被叫做交换守护,页守护

cluster 聚集

将IO请求一次写入大量的内容,这样会提供效率。

当你有一些事情要做,请在后台里面去做,这样会提高效率,而且允许操作合并。

操作系统经常在后台做事情,很多系统缓存写在内存,在它写入磁盘之前。

这么做有很多好处;

  1. 提高磁盘效率

  2. 改善写的延迟

  3. 可能的工作减少

  4. 更好的利益闲暇时间

21.7 总结

本章讲了如何访问超过物理内存的方法。

present位,判断是否在内存

页错误处理,解决不在内存的数据问题。

这些操作对进程来说是透明的,进程只需要关心他要访问的内存即可。

22 超过物理内存:策略

超过物理内存,我们应该怎么去找到应该被驱逐出去的页?这个问题是一个非常重要的问题。

我们怎么确定哪块内存应该被驱逐

这个决定使用替换策略决定的(replacement policy)

22.1缓存管理

鉴于主内存包含系统中所有页面的某个子集,它可以正确地被视为系统中虚拟内存页面的缓存。

替换策略的关键是提高缓存命中率

平均内存访问时间, average memory access time AMAT

AMAT = 内存的访问时间 + 命中失败率 * 访问硬盘的时间。

内存访问速度是硬盘访问速度的100000倍,这个命中率如果太低,系统将非常非常的卡

如果我们的处理策略不科学的话,我们的系统将会非常的卡。

22.2最佳的替换策略

为了更好的理解一个替换策略是如何工作的,让他跟最好的替换策略做比较是一个好方法

这个最佳策略被贝拉迪发明了,他把这个策略叫做MIN,这个策略是尽可能的把最近要访问的最远的内存去交换到硬盘。

和最佳策略去比较是非常有用的

尽管最佳策略跟真正的策略相比不太现实,但这在仿真和其他研究是非常有用的

可以知道还有多少提升的空间,当很接近理想空间了,你就可以停止研究了。

这是一个非常自然的想法,当你要扔掉一些页,你如果你知道你从现在开始,你知道你未来要访问哪些页,那么你去把你知道的要访问的最远的页去交换。

缓存命中失败类型

compulsory, capacity, and conflict misses , 必须的,容量, 冲突未命中

必须的命中失败: 冷启动

容量的命中失败:不够空间,要踢掉一部分东西,然后载入需要用的东西

冲突的命中失败:由于关联性的问题,

22.3 一个简单的策略。

早期的系统避免复杂,采用非常简单的替代策略。

比如,一些系统采用FIFO。

先进来的page先出去,这个策略太简单,根本就没有区分不同块的重要性。

anomaly 异常

22.4 另一个简单的策略:随机

全凭运气,没有任何策略去判定到底谁应该被剔除

22.5 使用历史记录:LRU

基本逻辑:我们认为,如果一段内存在短期内被访问,那么它大概率会被再访问在不久的将来。

可以使用:

  • 频次:如果一段内存经常被访问,那么它大概率是有用的

  • recency 新近: 最近访问的内存,在未来大概率是有用的

principle of locality 局部性原则

比如很多程序都会跑loop,会使用某个数据结构等,所以上面两个方法是比较能判断到底哪些内存是值得保留在内存中的

LFU : Least - Frequently - Used

LRU : Least - recently - used

局部性的类型

spatial locality 空间局部性,空间局部性认为,如何一个页被使用了,那么它附件的页也很有可能被使用。

temporal locality 时间局部性,时间局部性认为,如果一个页最近被使用了,那它在不久的未来大概率会被再使用

但是这种局部性原则有一个问题,不是所有的程序都遵循这种局部性原则,这种局部性并不保证任何事情,这只是程序可能是这样的,但也不一定是这样。

embracing 拥抱

exhibit 展示

与上诉两种方式相对的,有most - frequently -use , most - recently - used , 这两种方式完全放弃了大部分程序遵守的本地化原则。

22.6 工作量例子

缓存要大,才能保证命中率,是否考虑局部性,并不是一定是好的策略

22.7 实现历史算法

怎么实现LRU

我们先增加一点硬件支持,当一个内存被访问时,我们记录下当前的时间,然后更新内存访问时间,当需要交换时,我们去扫描所有页的更新时间,这样找到最近没被使用的页,然后交换出去

但是去找,需要扫描大量的页的信息,这样吃不消。我们可以搞一个大概的方式。

22.8 逼近的LRU

feasible 可行。

硬件支持: use bit, (reference bit)

如果被访问,那么这个位,就置为1

系统负责去置0.

系统怎么用这个位去实现逼近的LRU呢?

一个推荐的简单办法是时钟算法: 想象一下,所有的页被系统管理在一个循环列表里,一个时钟指针指向一个特定的页作为开始,当要发生替换时,系统去查一下当前指向的页P,我们先假设这个页P是最近被访问过的,当前读到是1,然后我们把这个1置为0,然后时钟指针指向下一页。这样一直找,直到找到一个0,我们就知道了,要把这一页内存交换到硬盘中。

性能方面,这个算法的性能相对LRU来说,命中率差一些,但是比起那些根本不考虑历史局部性的随机,或者FIFO来说,还是要好一些。

22.9 考虑脏页

数据被修改了的页叫脏页。

不剔除脏页。

需要多一个位,叫做改变位,或者叫脏位

有限考虑那些最近未使用且是clean的位,找不到再去找最近未使用且脏的位

22.10 其他虚拟内存策略

页替换不是vm的唯一策略,虽然是最重要的策略。

页选择:系统需要决定什么时候将一页载入内存。

对于大多数的页,系统简单使用请求页,这意味着系统将页载入内存,当它被访问。

当然系统可以猜这一页是快要被访问了,然后先将这页载入。这个技术叫做预取

其他策略决定系统怎么写页到硬盘,很多系统将多个写硬盘请求合并一起,然后一次写入,这样可以有更高的效率,这是因为硬盘的特性。

22.11 颠簸

在本章结束之前,一个最终的问题:当内存只是超额订阅,并且正在运行的进程集的内存需求超过可用的物理内存时,操作系统应该怎么做?这种情况系统会不断的进行页替换,而这种现象被叫做thrashing 颠簸

一些早期的操作系统有一套相当复杂的机制来检测和处理发生的颠簸。例如,给定一组进程,系统可以决定不运行进程的子集,希望减少的一组进程的工作集(它们正在使用的页面)适合内存,从而可以取得进展.这种通常被称为准入控制的方法表明,有时最好把少做的工作做好,而不是尝试一下把所有的事情都做好,这是我们在现实生活和现代计算机系统中经常遇到的情况(可悲的是)

oversubscribed 超额订阅

linux使用 out-of-memory-killer 当出现超额订阅时,这个守护进程选择内存密集型的进程杀掉,因此减少内存的使用情况。这个方法还是有问题,如果杀掉的程序是用来显示的,那就黑屏了。

22.12 总结

我们已经看到了页替换策略的介绍,

解决页交换带来的性能问题最简单的办法是,买更多的内存。

23 完整的虚拟内存系统

  • 页表设计

  • TLB

  • 决定哪些页保留在内存,哪些页剔除

然而,还有很多其他方面的特性,共同构成一个完成的虚拟内存系统,包括许多性能,功能,安全的特性。

怎么去建造一个完整的虚拟内存系统?我们怎么提升性能,提高安全性?

我们通过两个系统来对这方面进行论述。

  • VAX / VMS system

  • linux system

23.1 VAX/VMS 虚拟内存

VMS 的首席架构师开发了Windows NT 。

VMS可以广泛的适用于各种机器,包括便宜的VAXen和一些昂贵的机器。所以这个系统有一套机制和策略去让系统能跨越这么多不同的机器。

VMS 是一个很好的软件创新例子,用于 隐藏架构的一些固有缺陷 。

尽管系统经常依赖硬件去构建高效的抽象和隔离,有时候硬件设计者不一定能很好的理解,在VAX硬件中,我们可以看到一些例子。

在VAX硬件中,尽管存在这些硬件缺陷,我们可以看到 VMS 操作系统如何构建一个有效的工作系统。

内存管理硬件

VAX-11为每个进程提供了32-bit的虚拟地址空间,每一页是512byte,23bit VPN, 9bit offset.

VPN的前2位用来分段,这个系统是混合了分页和分段的。

低位的一半内存用来存放用户进程。高位的一半用来存系统进程。

VMS 设计者的主要关注点之一是 VAX 硬件中的页面尺寸非常小(512 字节)。由于历史原因而选择的这种大小具有使简单线性页表过大的基本问题。因此,VMS 设计者的首要目标之一是确保 VMS 不会因页表而淹没内存

怎么减少呢?

  • 通过把用户空间分成两份,VAX-11给每个进程的每个区域提供了一个页表,堆栈和堆之间地址空间的未使用部分不需要页表空间,基址寄存器保存该段的页表地址,边界保存其大小(即页表条目的数量)

  • 其次,操作系统通过在内核虚拟内存中放置用户页表(对于 P0 和 P1,因此每个进程两个)来进一步降低内存压力。因此,在分配或增长页表时,内核会从自己的虚拟内存中分配空间,即段 S。如果内存压力很大,内核可以将这些页表的页面交换到磁盘上,从而使物理内存可用用于其他用途

操作系统通常有一个被称为通用性诅咒的问题,它们的任务是为广泛的应用程序和系统提供通用支持。诅咒的根本结果是操作系统不太可能很好地支持任何一种安装。在 VMS 的情况下,诅咒是非常真实的,因为 VAX-11 架构是在许多不同的实现中实现的。今天同样真实,Linux 有望在您的手机、电视机顶盒、笔记本电脑、台式电脑以及在基于云的数据中心运行数千个进程的高端服务器上运行良好

一个真实的地址空间

例如,代码段永远不会从第 0 页开始。相反,该页被标记为不可访问,以便为检测空指针访问提供一些支持。因此,设计地址空间时的一个关注点是对调试的支持,不可访问的零页在此处以某种形式提供

内核的数据段和代码段映射到每一个进程,这有这么一些原因:

更容易拷贝数据,内核对应用来说就想一个库,尽管是被保护的。

在页表中,用保护位来实现对系统映射的保护。

页交换

页表入口有以下一些位:有效位,保护位4bit,修改位(脏位),为操作系统保留的5bit,物理帧序号,PFN。

没有参考位。

因此VMS替换算法必须没工作在没有硬件支持去决定哪一页可以被交换。

程序员还要关心内存猪:使用很多内存,使得其他程序难以运行。

模拟参考位

保护位可以模拟参考位。

主要思想:如果您想了解系统中正在使用哪些页面,请将页表中的所有页面标记为不可访问

为了解决这两个问题,开发人员提出了分段 FIFO 替换策略 [RL81]。这个想法很简单:每个进程在内存中都有一个最大数量的页面,称为它的常驻集大小(RSS)。这些页面中的每一个都保存在一个 FIFO 列表中;当进程超过其 RSS 时,“先进”页面将被驱逐。 FIFO 显然不需要任何硬件支持,因此很容易实现。

当然,正如我们之前看到的,纯 FIFO 的性能并不是特别好。为了提高 FIFO 的性能,VMS 引入了两个第二次机会列表,其中页面在从内存中驱逐之前放置,特别是全局 干净页面空闲列表 和 脏页面列表 。当一个进程 P 超过它的 RSS 时,一个页面从它的每个进程的 FIFO 中删除;如果是干净的(未修改),则将其放在干净页面列表的末尾;如果脏(已修改),则将其放在脏页列表的末尾。

如果另一个进程 Q 需要一个空闲页,它会从全局清理列表中取出第一个空闲页。但是,如果原始进程 P 在该页面被回收之前发生故障,P 会从空闲(或脏)列表中回收它,从而避免代价高昂的磁盘访问。这些全局第二次机会列表越大,分段 FIFO 算法的性能就越接近 LRU [RL81]。

VMS 中使用的另一个优化也有助于克服 VMS 中的小页面大小。具体来说,对于如此小的页面,交换期间的磁盘 I/O 可能非常低效,因为磁盘在大传输时表现更好。为了使交换 I/O 更有效,VMS 添加了许多优化,但最重要的是集群。通过集群,VMS 将全局脏列表中的大批量页面组合在一起,然后将它们写入磁盘。大多数现代系统都使用集群,因为可以自由地将页面放置在交换空间内的任何位置,从而让操作系统对页面进行分组,执行更少和更大的写入,从而提高性能。

RSS 常驻集大小,在linux, ps时可以看到。

其他巧妙的技巧

  • 请求清0,当你要申请空间时,系统给你一个入口,使得这个页不能被访问,如果真的要对这里进行读写,那么发送trap,系统捕捉到,去找一个物理页,清空,映射到进程的地址空间。如果进程永远不访问,那么这些工作可以避免。

  • 写时复制 copy-on-write ,当操作系统需要将一页从一个地址空间复制到另一个地址空间时,它可以将其映射到目标地址空间并在两个地址空间中将其标记为只读,而不是复制它。如果两个地址空间都只读取页面,则不采取进一步的行动,因此操作系统实现了快速复制,而无需实际移动任何数据

如果有一个地址空间想要去写页面,系统会trap,系统会知道这是一个写时复制的页面,然后分配一个页面(操作系统是非常会偷懒的)。

写时复制的好处:节省宝贵的内存空间

23.2 Linux虚拟内存系统

32位的Linux系统,

分割线在 0xC000 0000 , 或者 3/4 的地址空间。

高位是kernel,低位是用户空间

两种类型的内核虚拟地址:

  • 内核逻辑地址, 大部分内核数据结构在这个位置,不能交换到硬盘,kmalloc,分配的真实地址是连续的

    有意思的是,虚拟地址0xC000 0000 翻译到物理地址时,可以映射到0x00000000 , 0xC0000FFF , 0x00000FFF等待

    这个直接映射,有2个暗示:

    • 在物理和虚拟内存之间相互转换是非常简单的

    • 如果一个逻辑内存空间是连续的,那么在物理空间也是连续的。

    DMA : 直接内存访问。

  • 内核虚拟地址,vmalloc , 虚拟地址连续,但分配的真实物理地址是不连续的。分配一大段内存时特别有用。

页表结构

x86提供硬件管理,多级页表数据结构,每个进程都有一个页表。

操作系统只是在其内存中设置映射,在页面目录的开头指向一个特权寄存器,硬件处理其余的。正如预期的那样,操作系统参与进程创建、删除和上下文切换,确保在每种情况下硬件 MMU 使用正确的页表来执行转换。

32-bit 不够,考虑32->64bit的转换。

高16位目前没有用。

每一页是4KB,低12bit用来记录offset。

多级页表:每一层页字典都是9-bit,需要经过4级索引才能读到真正的地址。

大页支持

x86是允许多重页表大小的,不只是标准的4KB

2M,1G 也是支持的。

减少页表中的映射数量是挺重要的。大页可以让映射数量变少。

这样可以让TLB命中更多。

最近的研究表明,一些应用程序花费 10% 的周期来服务 TLB 未命中。

因此使用大页,这样可以占用更少的TLB坑位。

提示:考虑渐进主义

在生活中很多时候,你被鼓励成为一个革命者。 “想大了!”,他们说。 “改变世界!”,他们尖叫。你可以明白为什么它很吸引人;在某些情况下,需要进行大的改变,因此努力推动它们很有意义。而且,如果您以这种方式尝试,至少他们可能会停止对您大喊大叫。然而,在许多情况下,更慢、更渐进的方法可能是正确的做法。本章中的 Linux 大页面示例是工程增量主义的一个示例;开发人员没有采取原教旨主义者的立场并坚持大页面是未来的方式,而是采取谨慎的方法,首先为它引入专门的支持,更多地了解它的优缺点,并且只有在有真正的理由时,为所有应用程序添加更多通用支持。渐进主义虽然有时会受到蔑视,但通常会导致缓慢、深思熟虑和明智的进步。在构建系统时,这种方法可能正是您所需要的。事实上,生活中也可能如此。

incrementally 渐进的

大页有其缺点

  • 内部分段,

  • 硬盘内存交换也会有问题,会增加IO的执行量

  • 内存分配开销会增大

4K的分页是一个通用的解决方案,但是大页以及其他解决方案是对vm系统的一种革命,

Linux 对这种基于硬件的技术的缓慢采用证明了即将发生的变化

页缓存

为了减少访问永久内存的开销。

大多数系统使用主动缓存子系统将流行的数据项保存在内存中,Linux也不例外。

Linux 页面缓存是统一的,从三个主要来源将页面保存在内存中:内存映射文件、来自设备的文件数据和元数据(通常通过将 read() 和 write() 调用定向到文件系统来访问)以及堆和堆栈组成每个进程的页面(有时称为匿名内存,因为它下面没有命名文件,而是交换空间)。这些实体保存在页面缓存哈希表中,允许在需要所述数据时快速查找

页面缓存跟踪条目是干净的(读取但未更新)还是脏的(也称为修改)。后台线程(称为 pdflush)会定期将脏数据写入后备存储(即,为文件数据写入特定文件,或为匿名区域交换空间),从而确保修改后的数据最终会写回持久存储。此后台活动要么在特定时间段后发生,要么在过多页面被认为是脏的(两个可配置参数)时发生

页交换,不是采用LRU,而是采用2Q替换策略。

这个策略:有两个队列,一个是active , 一个是inactive , 保证页缓存中 active队列占 大约2/3。超过之后把active最后的页移动到inactive中。

安全和缓冲区溢出

缓冲区溢出攻击,由于开发者相信用户输入的信息是不会超过固定长度的,而实际上是超过的,这样就有可能覆盖掉程序后面的部分。

privilege escalation 权限提升

防止缓冲区溢出的第一个也是最简单的防御是防止执行在地址空间的某些区域内(例如,在堆栈内)发现的任何代码

可以通过覆盖栈的办法改变要执行的指令,从而调用黑客想要执行的指令。

为防止堆栈覆盖:采用address space layout randomization 地址空间布局随机化。ASLR

这样会导致被攻击的程序崩溃而不会导致程序被控制。

Other Security Problems: Meltdown And Spectre , 其他安全问题:崩溃和幽灵

speculative execution 推测执行

推测的问题在于它倾向于在系统的各个部分留下其执行的痕迹,例如处理器缓存、分支预测器等

这导致一个问题:正如攻击的作者所表明的那样,这种状态会使内存内容变得脆弱,甚至是我们认为受 MMU 保护的内存。

因此,增加内核保护的一种途径是从每个用户进程中删除尽可能多的内核地址空间,并为大多数内核数据使用单独的内核页表

只把内核的很少一部分映射到用户进程,其他的与用户进程隔离。

安全需要付出的代价是:方便和性能。

遗憾的是,这种隔离(kernel pagetable isolation, or KPTI) , 并没有解决所有的安全问题。

speculation 预测

23.3总结

介绍了两个系统的虚拟内存系统,我们对虚拟内存系统有了一个基本认识。

24 总结对话

并发concurrency

25 关于并发的对话

举了一堆人围着一起抢桃子的例子,形象的说明了并发给我们带来的问题。

提出了线程引擎的概念。

26 并发介绍

线程,

只有一个线程的进程 只有一个执行指针

而多线程的进程不止一个指针。

每个线程很像一个分离的进程,只有一个不一样:他们共享地址空间,可以访问同一段地址。

一个线程的状态机跟进程很像。

线程有PC,有自己的私人寄存器集,如果有两个线程在同一个处理器中运行,那么线程切换时,需要进行上下文切换。

With processes, we saved state to a process control block (PCB); now, we’ll need one or more thread control blocks (TCBs) to store the state of each thread of a process.

线程切换的开销比进程切换要小,因为不需要换地址空间

多线程进程的地址空间中,每个线程都有自己的stack,存在stack的东西叫做线程本地存储。

26.1 为什么使用线程

两个主要原因:

  • parallelism. 并行性

  • 避免阻塞

进程是逻辑区分得更开的,数据交换比较少时,比较适合。

26.2 一个例子:线程创建

创建线程有点像函数调用,但是这个调用的调用者和被调用者是独立的,没有执行顺序的限制,该由谁执行,是由系统调度器决定的。

26.3 为什么线程让事情变得糟糕:共享数据

两个线程共享数据时,出现每次运行结果不确定的问题

掌握并使用你的工具

在这里,我们使用了一个叫做反汇编器的简洁工具 objdump

26.4 核心问题:无法控制的调度

race condition 竞争条件

critical section 临界区 ,不应该并发执行。

我们期望这些在临界区的代码应该 mutual exclusion.互相排斥,这样才能保证一个线程在执行时,另外的线程不能执行。

Edsger Dijkstra ,是这方面的大神。

使用原子操作

all or nothing ,

transaction 合约。

synchronization primitives 同步原语

26.5 期望原子性

synchronization primitives 使用同步原语来实现原子操作。

怎么区支持同步?

要正确,高效,而且程序利用这些原语得到想要的结果。

26.6 还有一个问题:等待另一个

睡眠/唤醒机制 sleeping/waking

并发的关键条款

CRITICAL SECTION, RACE CONDITION, INDETERMINATE, MUTUAL EXCLUSION

临界区,竞争条件,不确定,相互排斥

  • 临界区: 操作共享资源的一小段代码

  • 竞争条件:同一时间有多个线程同时访问临界区

  • 不确定性:因为由竞争条件的存在,每次运行的结果是不确定的。

  • 为了避免上面的问题:我们应该用互相排斥的办法来避免。

26.7 为什么这些问题要在OS的课题

操作系统是最早的并发程序。

27. 插曲:线程API

操作系统应该为线程创建和控制提供哪些接口?这些界面应该如何设计以实现易用性和实用性

27.1 线程创建

Portable Operating System Interface (POSIX)

include <pthread.h>
int
pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void*),
void *arg);

//pthread_t 线程入口
//pthread_attr_t , 属性,可以用来定义栈的大小,调度优先级等。一般直接用NULL
//start_routine , 线程运行的函数。
//arg, 给运行函数的参数

//返回void * 可以让我们返回任意的数据结构。

27.2 线程完成

如何等待线程结束?

int pthread_join(pthread_t thread, void **value_ptr);
// pthread_t 线程号。决定你在等待哪个线程结束
// value_ptr 获取线程的返回值。

如果刚创建线程就去等待线程,这是一件很诡异的事情,因为这更函数调用没有区别。

27.3 锁

解决互斥问题,锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread mutex init()
pthread mutex destroy()
int pthread_mutex_trylock(pthread_mutex_t *mutex);//申请锁,如果拿不到就返回失败。
int pthread_mutex_timedlock(pthread_mutex_t *mutex,
struct timespec *abs_timeout); //申请锁,如果拿到就返回,或者到时间就返回

27.4 条件变量

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);

//发信号过来,再解锁。
//wait多一个参数是因为当它被唤醒时,需要重新获得锁。


pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
Pthread_mutex_lock(&lock);
while (ready == 0) //这里用while而不是if,后面会说。
Pthread_cond_wait(&cond, &lock);
Pthread_mutex_unlock(&lock);


Pthread_mutex_lock(&lock);
ready = 1;
Pthread_cond_signal(&cond);
Pthread_mutex_unlock(&lock);

//最后一个奇怪的地方:等待线程在一个 while 循环中重新检查条件,而不是一个简单的 if 语句。我们将在以后的章节中研究条件变量时详细讨论这个问题,但总的来说,使用 while 循环是简单而安全的做法。尽管它会重新检查条件(可能会增加一些开销),但仍有一些 pthread 实现可能会虚假地唤醒等待线程;在这种情况下,如果没有重新检查,等待线程将继续认为条件已经改变,即使它没有改变。因此,将醒来视为某些事情可能已经改变的暗示,而不是绝对的事实,会更安全

27.5 编译和运行

prompt> gcc -o main main.c -Wall -pthread

27.6 总结

可以去查pthread的手册

28 锁

实现原子操作

28.1 锁:基本想法

锁是一个变量,这个锁变量(或简称为“lock”)在任何时刻保持锁的状态。它要么可用(或解锁或空闲),因此没有线程持有锁,要么获得(或锁定或持有),因此正好有一个线程持有锁,并且可能处于临界区

lock和unlock的语义,

锁为程序员提供了一些最少量的调度控制。通常,我们将线程视为由程序员创建但由操作系统以操作系统选择的任何方式调度的实体。锁将部分控制权交还给程序员;通过在一段代码周围放置一个锁,程序员可以保证在该代码中不会有超过一个线程处于活动状态。因此,锁有助于将传统操作系统调度的混乱转变为更可控的活动

28.2 线程锁

接口库为锁提供的函数是mutex,为线程提供互斥mutual exclusion。

critical section 临界区

coarse-grained 粗粒度的

fine-grained 细粒度的

28.3 构建一把锁

怎么去构建一把锁?我们需要什么硬件支持

我们怎么去构建一把高效的锁?高效的锁以一种低损耗的方式 提供互斥。当然还需要提供一些其他的属性,那么我们的硬件支持和系统支持是怎么样的呢?

我们需要硬件和系统共同去实现锁。

28.4 评估锁

我们要评估锁好不好,需要一些基本的指标

  • 提供互斥

  • 公平

  • 性能

contention 竞争

对于性能,我们考虑以下情况:

  • 没有人竞争

  • 在一个CPU中,有线程竞争

  • 在多个CPU中,有线程竞争

28.5 控制中断

最简单的提供互斥的实现就是关闭中断。

这个种技术用于单处理器的系统。(单片机)

优点:简单

缺点:

  • 每个线程都有系统权限。

  • 一个贪婪的程序可以在开始获得这把锁,然后一直占用CPU。

  • 无法在多线程中使用。

  • 关闭中断会让这段时间的中断丢失。

  • 效率低下,屏蔽中断会被现代CPU缓慢执行。

由于这些原因,关闭中断仅在有限的上下文中用作互斥原语。例如,在某些情况下,操作系统本身会使用中断屏蔽来保证访问自己的数据结构时的原子性,或者至少防止出现某些混乱的中断处理情况。这种用法是有道理的,因为信任问题在操作系统内部消失了,操作系统总是相信自己无论如何都会执行特权操作

28.6 一次失败的尝试:只使用加载/存储

用一个flag 去上锁。

spin-wait ,自旋锁定

自旋很好性能,而且这种办法无法正确提供互斥性

28.7 构建能用的自旋锁通过test and set

test-and-set (原子交换)指令,是硬件支持的。

1 int TestAndSet(int *old_ptr, int new) {
2 int old = *old_ptr; // fetch old value at old_ptr
3 *old_ptr = new; // store ’new’ into old_ptr
4 return old; // return the old value
5 }

纯软件实现

想像上测试,两个人去上厕所。

这个厕所门上有个卡槽

有两个人,A,B。

A手上有一张写着B名字的卡, B手上有一张写着A名字的卡。

A想上厕所,先把写着B名字的卡插入卡槽,看一眼旁边没人,就直接上厕所。(没有竞争)

如果此时,A,B都想去上厕所,厕所变得拥挤了。

A还是一样,先把写着B名字的卡插入卡槽,

B也是一样,把写着A名字的卡插入卡槽,

你可以想象两个人在互相礼让。

如果A去开门时看到上面写着自己的名字,那么就打开门,如果看到是B的名字,那就先等等。

B也一样。

这句代码是关键 :while ((flag[1-self] == 1) && (turn == 1 - self))

flag[1-self] == 1 这是看一下B要不要上厕所的判断。

turn == 1 - self 这是看一下是不是自己名字的判断。

这两者共同决定要不要等。

我们再看看test-and-set函数,这个操作具有原子性。

preemptive scheduler 抢占式调度器,每运行一段时间会发生中断,然后去运行别的

28.8 评估自旋锁

  • 正确性:能提供,

  • 公平性:不提供

  • 性能:在单个CPU非常差,每一个不能运行的线程都跑满了整个CPU时间片,如果是CPU数量跟线程数量一致,那么性能就很好,因为这时,在临界区的代码很短,很快就释放锁,而另外一个CPU立马就可以跑,这样的性能是OK的

28.9 compare-and-swap 比较交换。

也叫做compare-and-exchange 比较替换。(x86提供)

1 int CompareAndSwap(int *ptr, int expected, int new) {
2 int original = *ptr;
3 if (original == expected)
4 *ptr = new;
5 return original;
6 }

lock-free synchronization 无锁同步?,可以实现无锁同步,有特殊的操作。

28.10加载链接和存储条件

这套机制在ARM,PowerPC中提供。

1 int LoadLinked(int *ptr) {
2 return *ptr;
3 }
4
5 int StoreConditional(int *ptr, int value) {
6 if (no update to *ptr since LoadLinked to this address) {
7 *ptr = value;
8 return 1; // success!
9 } else {
10 return 0; // failed to update
11 }
12 }

提供的关键机制是,可以判断这个值是否被修改。

28.11 Fetch-and-add

1 int FetchAndAdd(int *ptr) {
2 int old = *ptr;
3 *ptr = old + 1;
4 return old;
5 }

更少的代码是更好的

请注意此解决方案与我们之前尝试的一个重要区别:它确保所有线程的进度。

有一个执行顺序。

28.12 太多自旋:现在什么?

关键问题:怎么避免自旋

28.13 一个简单的方法:yield屈服

硬件支持让我们走得更远: 工作锁,锁请求的公平性。

然而我们还有一个问题:在临界区发生上下文切换怎么办?线程无线自旋,等待中断线程来提醒我去跑?

primitive 原始

使用yield() 函数,这句可以让线程放弃CPU资源,让其他线程跑。

屈服线程本质上会自行取消调度。

5 void lock() {
6 while (TestAndSet(&flag, 1) == 1)
7 yield(); // give up the CPU
8 }

通过放弃CPU资源的办法,每一个没有获得锁的线程,确实不用去跑完整个时间片,看上去有不错的提升,相比上面的自旋锁。

但是线程建切换会导致上下文反复切换,这也是一笔很大的开销。

tackled 处理。

28.14 使用队列:睡眠而不是自旋

我们可以通过增加下一个运行线程的队列。当一个线程结束后,唤醒另外一个线程。

explicit 明确的

1 typedef struct __lock_t {
2 int flag;
3 int guard;
4 queue_t *q;
5 } lock_t;
6
7 void lock_init(lock_t *m) {
8 m->flag = 0;
9 m->guard = 0;
10 queue_init(m->q);
11 }
12
13 void lock(lock_t *m) {
	14 while (TestAndSet(&m->guard, 1) == 1)
    15 ; //acquire guard lock by spinning
    16 if (m->flag == 0) {
        17 m->flag = 1; // lock is acquired
        18 m->guard = 0;
    19 } else {
        20 queue_add(m->q, gettid());
           setpark(); // new code
        21 m->guard = 0;
        22 park(); //先设置guard 为 0 因为park之后,其他线程可能唤醒,但是如果guard不是0,它也改不回来。
            //这里并没有改flag, 这不能改,因为只有获得guard的线程才能去修改flag。
    23 }
24 }
25
26 void unlock(lock_t *m) {
27 while (TestAndSet(&m->guard, 1) == 1)
28 ; //acquire guard lock by spinning
29 if (queue_empty(m->q))
30 m->flag = 0; // let go of lock; no one wants it
31 else
32 unpark(queue_remove(m->q)); // hold lock
33 // (for next thread!)
34 m->guard = 0;
35 }

问题: 唤醒等待竞赛问题:如果在pack的时候有人调用了unpack,那就没有人去唤醒这个pack的线程了。

通过系统调用克服这个问题:setpack()

如果它碰巧被中断并且另一个线程在实际调用 park 之前调用了 unpark,则后续的 park 会立即返回而不是休眠

28.15 不同的系统,不同的支持

futex 是linux提供 ,跟solaris 的接口很像

在libc中提供接口,lowlevellock.h

28.16 两相锁

Linux现在用两相锁,two-phase-lock

第一次,先自旋,希望可以获得锁。

如果没有获得,那么就进入第二阶段,进入等待队列。

混合方法一般都能得到更好的结果,当然这也跟其他的很多方面又关系。

28.17 总结

讲了几种锁的实现。

29. 基于锁的并发数据结构

线程安全。

怎么给数据结构添加锁

29.1并发计数器

介绍了如何实现一个线程安全的并发计数器。

benchmark 基准

scalable 计数器 , 可拓展

如果没有的话,在多核CPU无法发挥其性能。

大约计数器?

通过放弃准确的计数器,使用大约的计数器,性能更好,因为不需要反复的锁和解锁,不需要经常同步。

只有当本地值大于等于临界值时,才去更新一下全局的计数器。

29.2 并发链表

rudimentary 初级的

手拉手锁定。

性能差。

29.3 并发队列。

一头一尾,两把锁

29.4 并发哈希表

性能比较好。

29.5 总结

避免过早优化。

30. 条件变量

怎么实现join?

怎么去等待一个状态?

30.1 定义和循环

explicit 明确的

明确join的实现。

producer/consumer 生产者消费者

bounded-buffer 有界缓存

当你发信号或者等待时,总是用锁。

30.2 生产者/消费(有界缓存)问题

这个问题的产生,导致了信号量机制的发明

生产者消费者模型。

Mesa semantics Mesa语义,Mesa是一种语言

Hoare semantics 霍尔语义

一个简单的办法去解决一个问题: 当多线程时,如果一个线程在等待时,另外一个线程修改了状态,为防止出错,一般用while代替if

用两个互斥量来解决,消费者唤醒消费者的问题。

30.3 重复情况

解决唤醒哪个消费者线程问题:简单的办法是全部唤醒。

把不该唤醒的线程唤醒 ,被称之为covering condition 覆盖条件?

这种简单直接的办法在分配内存时是有用的。

30.4 总结

介绍了条件变量的使用。

31.信号量

synchronization primitive 同步原语

我们如何使用信号量而不是锁和条件变量?信号量的定义是什么?什么是二进制信号量?用锁和条件变量构建信号量是否简单?用信号量构建锁和条件变量?

31.1 信号量:一个定义

1 sem_t m;
2 sem_init(&m, 0, X); // initialize to X; what should X be?
3
4 sem_wait(&m);
5 // critical section here
6 sem_post(&m);

31.2 二进制信号量(lock)

X=1 , 只有一个信号量,这个行为就跟锁一样,我们也称之为二进制信号量。

31.3 信号量用于顺序

X=0 , 跟条件变量类似。

等待某线程先退出?

31.4 生产/消费者模型

1 void *producer(void *arg) {
2 int i;
3 for (i = 0; i < loops; i++) {
4 sem_wait(&empty); // Line P1
5 sem_wait(&mutex); // Line P1.5 (MUTEX HERE)
6 put(i); // Line P2
7 sem_post(&mutex); // Line P2.5 (AND HERE)
8 sem_post(&full); // Line P3
9 }
10 }
11
12 void *consumer(void *arg) {
13 int i;
14 for (i = 0; i < loops; i++) {
15 sem_wait(&full); // Line C1
16 sem_wait(&mutex); // Line C1.5 (MUTEX HERE)
17 int tmp = get(); // Line C2
18 sem_post(&mutex); // Line C2.5 (AND HERE)
19 sem_post(&empty); // Line C3
20 printf("%d\n", tmp);
21 }
22 }

31.5 读写锁

简单和愚蠢可以更好

读写锁听上去很美,但实现复杂。

自旋锁简单,有时反而是更好的

31.6 哲学家用餐问题

5个哲学家,5把叉子围成一圈。

31.7 线程节流

admission control 录取控制,用来节约在运行的线程数量

通过设置信号量的初始化的X值,可以将线程控制在我们想要的数量

31.8 怎么实现信号量

用一个条件变量,一把锁,就可以实现信号量。

1 typedef struct __Zem_t {
2 int value;
3 pthread_cond_t cond;
4 pthread_mutex_t lock;
5 } Zem_t;
6
7 // only one thread can call this
8 void Zem_init(Zem_t *s, int value) {
9 s->value = value;
10 Cond_init(&s->cond);
11 Mutex_init(&s->lock);
12 }
13
14 void Zem_wait(Zem_t *s) {
15 Mutex_lock(&s->lock);
16 while (s->value <= 0)
17 Cond_wait(&s->cond, &s->lock);
18 s->value--;
19 Mutex_unlock(&s->lock);
20 }
21
22 void Zem_post(Zem_t *s) {
23 Mutex_lock(&s->lock);
24 s->value++;
25 Cond_signal(&s->cond);
26 Mutex_unlock(&s->lock);
27 }

31.9 总结

信号量是强大,灵活的原语 用来 写并发程序

小心概括,概况往往是错的,我们应该注重细节。

32 通用并发程序

并发程序的通常bug:

  • 死锁

  • 非死锁

怎么处理常见的并发bug

32.1 有什么类型的bug存在?

有文章研究了MYSQL, Apache , Mozilla ,OpenOffice 这几个著名软件,并且统计了一下他们的bug。

总共74个bug,31个都是并发相关的。

32.2 非死锁bug

  • atomicity violation 原子性违反,不加锁的操作。

  • order violation 顺序违反

97%的非死锁并发bug都是这两种。

但是很多时候这种bug很难发现。

32.3 死锁bug

为什么发生死锁?

  • 在大量代码中,复杂的依赖关系,往往会导致死锁。

  • encapsulation 封装,

死锁的情况:

  • 互相排斥

  • 获得和等待:有一把锁,但等别的条件

  • 无法抢占:不能从持有它们的线程中强行删除资源

  • 循环等待 : 哲学家用餐问题。

预防

  • 部分排序,比如可以通过锁的地址大小,来确定一个顺序,这样就不会出错。

  • 通过一把大锁去锁住其他锁,这样就算内部锁的顺序有误也能正常运行。这种方法可以用来debug

  • 通过trylock防止第二把锁死锁,导致第一把锁不释放。可能导致活锁,一直获得<->释放 ,解决办法: 可以在循环返回并重新尝试整个事情之前添加一个随机延迟,从而降低竞争线程之间重复干扰的几率

  • 最终方案避免互相排斥,直接不用锁,可以lock-free,通过硬件的支持。(这里操作比较复杂)

  • 同过调度避免死锁,让需要两把锁的线程跑在同一个核,这就不会出现死锁。中兴的嵌入式系统用的就是这个方法

  • 探测和回复:允许死锁偶尔发生,通过重启解决。

32.4 总结

本章说了几种不同的并发错误,以及一些简单的预防机制。

33.基于事件的并发(高级)

有两个问题:

  • 正确的管理多线程应用的并发是非常具有挑战性的。

  • 在多线程应用,开发者对调度器的控制几乎没有,

我们如何在不使用线程的情况下构建并发服务器,从而保持对并发的控制并避免一些似乎困扰多线程应用程序的问题?

33.1 基本想法:一个事件循环

while (1) {
events = getEvents();
for (e in events)
processEvent(e);
}

33.2 一个重要的API: select(),poll()

阻塞(同步)接口

非阻塞(异步)接口,在基于事件的并发系统,nodejs,这种操作很关键。

select()

int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);
  • 帮助我们检测描述符是不是可读或者可写?

    read ->让服务器确定新数据包已经到达并需要处理

    write-> 让服务知道何时可以回复

  • 注意超时,NULL的话就阻塞了,0非阻塞。

33.3 使用select()

1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <sys/time.h>
4 #include <sys/types.h>
5 #include <unistd.h>
6
7 int main(void) {
8 // open and set up a bunch of sockets (not shown)
9 // main loop
10 while (1) {
11 // initialize the fd_set to all zero
12 fd_set readFDs;
13 FD_ZERO(&readFDs);
14
15 // now set the bits for the descriptors
16 // this server is interested in
17 // (for simplicity, all of them from min to max)
18 int fd;
19 for (fd = minFD; fd < maxFD; fd++)
20 FD_SET(fd, &readFDs);
21
22 // do the select
23 int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
24
25 // check which actually have data using FD_ISSET()
26 int fd;
27 for (fd = minFD; fd < maxFD; fd++)
28 if (FD_ISSET(fd, &readFDs))
29 processFD(fd);
30 }
31 }

33.4 为什么简单?不需要锁

单线程应用,没有并发问题

33.5 一个问题:阻塞的系统调用

如果系统调用阻塞了,那整个进程都卡住了,这显然是不好的,因此我们应该允许非阻塞调用。

33.6 一个解决办法:异步IO

  1. 填好内容,丢给系统

  2. 通过中断返回处理结果,发送信号的方式

33.7 另一个问题:状态机管理

manual stack management , 手动堆栈管理

保持当前进程的内容到一个数据结构,如果IO访问结束,再重新加载进来,接着运行。

33.8 还有什么困难

  • 无法使用多CPU,有同步问题

  • 跟系统的整合比较差,比如页

  • 事件管理的代码随着时间改变会很难管理,

  • IO与基于事件的系统之间的整合还需要进一步提高

33.9 总结

无论是基于线程,还是基于事件,都有其自身的复杂性。

34 对话

简简单单的几行并发代码,都可能让一个计算机科学家思考很久。所以尽可能的用已经被证明的,可以用的代码,尽可能避免自己去写。

持久化

35. 对话

36. IO设备

INTEGRATE 整合,统一

我们怎么整合输入输出设备?

36.1 系统架构

IO 总线: PCI , 接GPU,高速网卡,(一般需要高性能的都要接PCI)

peripheral bus 外围总线 , SATA,USB,SCSI , 接硬盘,鼠标,键盘

SCSI : Small Computer System Interface , 是一种用于计算机及其周边设备之间(硬盘、软驱、光驱、打印机、扫描仪等)系统级接口的独立处理器标准。

hierarchical 分层的

正在上传…重新上传取消

这种分层结构可以让性能要求较高的,更靠近CPU,要求低的远离CPU。

正在上传…重新上传取消正在上传…重新上传取消正在上传…重新上传取消

DMI (Direct Media Interface)

ATA -> SATA -> eSATA

PCIe (Peripheral Component Interconnect Express) , 特快外围组件互连 , NVMe用的是这个总线。

Canonical 规范

36.2 一个规范的设备

一个规范设备的描述:

  • 接口

  • 内部结构

正在上传…重新上传取消

36.3 一个规范的协议

While (STATUS == BUSY)
; // wait until device is not busy
Write data to DATA register
Write command to COMMAND register
(starts the device and executes the command)
While (STATUS == BUSY)
; // wait until device is done with your request

programmed I/O (PIO).

polling 轮询

系统要让设备去做一些事情,需要以下几步:

  • 不断去读状态寄存器,直到状态寄存器准备好去接收命令

  • 系统发一些数据给数据寄存器

  • 系统往命令寄存器写命令

  • 轮询状态寄存器,等待设备处理完毕,返回。

上面的描述似乎存在一些效率低,不方便的问题。

我们怎么才能不轮询就可以获取设备的状态呢?

36.4 用中断去降低CPU开销

不是用轮询的方式确认,系统通过发送一个请求,然后让进程去睡眠,上下文切换到另外的任务。

当设备完成了系统给的请求,发起硬件中断,

interrupt service routine (ISR) 中断服务日程 ,或者简单的中断处理程序

这个处理程序只有一小段的系统代码,结束这个请求,唤醒等待这个IO的进程

overlap 重叠 , 这个词是描述,让CPU和外围设备同时工作?

使用中断不总是最好的办法,如果硬件响应很快,那么这种方法就会让系统变慢

如果设备相应快,那就采用轮询的办法,如果设备相应慢,那么应该采用中断的办法。如果设备有时快有时慢,那么可以采用混合的办法,先轮询一小段时间,如果不响应,就采用中断的办法。

另一个不使用中断的原因出现在网络中,如果大量的网络请求都触发一个中断,很有可能导致CPU出现活锁。会出现一直在中断,用户程序根本没时间跑。

coalescing 凝聚,联合

基于中断的优化,可以是联合。发请求时,先等一下,看有没有别的请求可以一起,但是等太久就会变卡,所以这需要调参,又是一门学问。

36.5 更高效的数据移动通过DMA ,direct memory access

当要拷贝一些数据给PIO时,如果没有DMA,那么此时CPU只有等到内存拷贝完了才能干别的。有了DMA,DMA 引擎本质上是系统中一个非常特定的设备,它可以在没有太多 CPU 干预的情况下协调设备和主内存之间的传输。

系统只要告诉DMA引擎,数据在内存的哪个地方,有多大,应该发给谁,DMA引擎就可以工作了。

36.6 设备交互方法

系统怎么跟设备通讯?

有两种基本的方法:

  • 老办法,IBM大型机中使用的,用明确的IO指令

    这些指令指定了操作系统将数据发送到特定设备寄存器的方式,从而允许构建上述协议。

  • 内存映射IO,

    设备寄存器好像在内存地址一样。访问特定的寄存器,系统发出一个加载或者存储这些地址,系统路由到设备中而不是主存。

目前两种方式都还在用,第二种方式很好,因为不需要支持新的指令。

36.7 适配系统:设备驱动

我们如何才能保持大部分 OS 设备中立,从而对主要 OS 子系统隐藏设备交互的细节

通过抽象,在最底层,一小段软件代码必须知道设备运作的细节,我们把这些代码叫做设备驱动,设备交互的细节都封装在这。

linux有70%的代码都是驱动。

而内核崩溃大部分情况是由于驱动导致的,写驱动的都是业余爱好者。(相对于整天都是开发内核的人来说。)

36.8 案例学习:一个简单的 IDE 磁盘驱动程序

Control Register:
Address 0x3F6 = 0x08 (0000 1RE0): R=reset,
E=0 means "enable interrupt"
Command Block Registers:
Address 0x1F0 = Data Port
Address 0x1F1 = Error
Address 0x1F2 = Sector Count
Address 0x1F3 = LBA low byte
Address 0x1F4 = LBA mid byte
Address 0x1F5 = LBA hi byte
Address 0x1F6 = 1B1D TOP4LBA: B=LBA, D=drive
Address 0x1F7 = Command/status
Status Register (Address 0x1F7):
7 6 5 4 3 2 1 0
BUSY READY FAULT SEEK DRQ CORR IDDEX ERROR
Error Register (Address 0x1F1): (check when ERROR==1)
7 6 5 4 3 2 1 0
BBK UNC MC IDNF MCR ABRT T0NF AMNF
BBK = Bad Block
UNC = Uncorrectable data error
MC = Media Changed
IDNF = ID mark Not Found
MCR = Media Change Requested
ABRT = Command aborted
T0NF = Track 0 Not Found
AMNF = Address Mark Not Found

需要4种类型寄存器:控制,命令块,状态,错误

工作流程:

  • 等待驱动准备好

  • 写参数到命令寄存器

  • 开始IO

  • 数据传输

  • 处理中断

  • 错误处理

36.9 历史笔记

中断是很久以前就有的机制

DMA的起源有争议

36.10 总结

我们应该有一个基本了解。

37. 硬盘驱动

怎么在硬盘中存储和访问数据呢?

怎么存?

接口是什么?

数据实际存在哪,怎么访问?

怎么调度硬盘可以提升效率?

37.1 接口

驱动包含很多扇区sectors(512byte 块), 这些扇区都被标好号,0~n-1,这就是驱动的地址空间

多扇区操作是可能的,其实很多系统都会一次读写4KB,然而当更新disk的时候,驱动制造商只保证单512Byte写入是原子性的。如果写入过程中发生了掉电,那么就会可能有撕裂写入。torn write

torn 撕裂。

大多数磁盘驱动器客户端都会做出一些假设,但这些假设并未直接在接口中指定,这种情况我们称之为“unwritten contract” ,未签订的合同。

上面说的一些假设有:

  • 访问2个靠近的块,比访问两个隔得远的块速度要快。

  • 连续访问一大块比访问随机块要快。

37.2 基本几何学

platter 拼盘,盘子。

一个硬盘有一个或多个圆盘,每个圆盘有2个面,我们一般叫表面surface。

spindle 主轴。

盘片都围绕主轴捆绑在一起, 主轴它连接到固定转速的旋转盘片的电机,转速的衡量单位 rotations per minute (RPM),每分钟多少转。

现代的硬盘一般是7200~15000之间

我们很关系多少秒 一转,比如10000RPM, 对应就是6ms/r

数据以扇区的同心圆在每个表面上编码,我们称这样的同心圆为轨道,一个表面包含很多轨道,紧密的打包在一起,一百多条轨道就跟一根头发丝一样宽。

为了从表面读取和写入,我们需要一种机制,使我们能够感知(sense)(即读取)磁盘上的磁模式或诱导(induce)(即写入)它们的变化,我们通过磁头来完成这个功能,每个表面都有一个磁头。每个磁头由磁盘臂disk arm 连接着,磁盘臂可以在表面位置随意移动。

disk head 磁头

37.3 一个简单的磁盘驱动

假设我们有一个简单磁盘,而且只有一条轨道。这调轨道有12个扇区,每个扇区512Byte,被编号为0~11。主轴连接着电机,一直旋转。

单轨道延时:转速延时

当我们接到一个请求要去读block0时,那么我们要做的事情只是等到block0转到磁头下面,这个等待叫做旋转延迟

多轨道:寻找时间

相比刚才的等待,这里要多一步,就是寻找,需要移动磁盘臂去到对应的轨道。这种移动其实是最耗时的。移动到对应轨道的时间大概在0.5~2ms。找到对于的扇区后,就开始传输。

磁盘工作原理三部曲

  • 找到对的轨道

  • 等旋转到对应的扇区

  • 传输数据出去

其他细节

尽管下面的细节不会花费太多时间,但是非常有趣。

许多驱动器采用某种磁道倾斜 track skew,以确保即使在跨越磁道边界时也能正确处理顺序读取

在磁道的末尾,需要切换磁道时需要时间,编号时,把这部分时间考虑进去,当读到一个磁道末尾时,切换到下一个磁道,应该能顺着读下去。

外面的磁道比里面的磁道要有更多的扇区,根据基本的几何学。

现代磁盘有缓存,由于历史原因有人也叫轨道缓存。大约8~16Kb,

在写的时候什么时候应该通知别人已经正常写入了?是在内存的时候,还是已经写入硬盘之后?

多维度分析

可以把一个问题分为多个不同的维度,不同维度之间正交,最终综合起来,就很简单了。

37.4 I/O时间:做数学。

TI/O = Tseek + Trotation + Ttransfer

随机读取比顺序读取差很远,传输时间相比于寻找和旋转,是不值一提的。

高性能型的 和 容量型的 硬盘差别很大。

平均寻找的轨道数为1/3N

37.5 磁盘调度

贪心算法,采用sjf ,short job first 的办法来处理一堆请求。

SSTF : shortest seek time first

最早的调度办法 SSTF

主要问题:

  • 驱动器的几何结构系统是不知道的,但这个问题可以通过就近块优先的方法

  • 会导致一些请求饥饿,如果有一个稳定的请求在请求附近的块,那么访问附近块之外的块就不会得到相应。

应该怎么避免饥饿的问题呢?

电梯

在不同的轨道扫来扫去,这很像电梯。通过不断的扫描,从而避免了饥饿,这个方法忽略了旋转,因此不太可能有好的性能。

我们如何通过同时考虑搜索和旋转来实现更接近 SJF 的算法?

SPTF : Shortest Positioning Time First 最短定位时间优先

要看旋转时间久一点,还是寻找轨道的时间久一点,也就是说我们把旋转时间考虑进来了,这是之前的讨论中没有说明的。因为之前是认为找到轨道时间远大于旋转时间。

It always depends

很多时候都是要取舍,工程师的世界充满了取舍,我们总要为了得到什么而放弃什么。

这个的实现一般在硬盘,不会在系统,因为系统不可能实时的知道当前硬盘转到哪里了,这些信息。

其他调度问题

  • 磁盘调度在现代系统中的哪里发挥作用

    在现代系统,磁盘可以容纳多个未完成的请求,有复杂的内部调度程序。(比如去实现上面提的SPTF),系统选出它认为最好的一些请求,然后把所有这些请求发给硬盘,硬盘使用磁头所在位置信息和轨道分布细节信息采用最好的可能顺序 去服务这些请求。

  • IO合并

    把一些请求合并一起,从而提升性能。

  • 系统要等多久才发一次IO请求呢?

    anticipatory disk scheduling 预期磁盘调度

37.6 总结

没有讲太多物理层面上的事情,只是简单介绍了磁盘调度模型,如果要继续深入研究硬盘的问题,需要专门去学别的

38. 廉价磁盘的冗余阵列 RAIDs

如何去构建一个 容量大,访问速度快,可靠的硬盘。

这里一定会涉及权衡。

RAIDs 很复杂,很像个操作系统,也有自己的内存。它有一下一些好处:

  • 性能

  • 容量capacity

  • 可靠性,依靠一些冗余,RAIDs可以忍受一些硬盘丢失。·

在考虑如何向系统添加新功能时,应始终考虑是否可以透明地添加此类功能,而无需更改系统的其余部分。要求完全重写现有软件(或彻底的硬件更改)会减少想法产生影响的机会。 RAID 就是一个完美的例子,当然它的透明度有助于它的成功。管理员可以安装基于 SCSI 的 RAID 存储阵列而不是 SCSI 磁盘,系统的其余部分(主机、操作系统等)无需更改一点即可开始使用。通过解决这个部署问题,RAID 从一开始就更加成功

38.1 接口和RAID内部

在系统看来,RAID跟普通硬盘没有区别。

在内部:RAID会承接系统发过来的IO请求,然后自己去缓存,分发请求给个硬盘,他就像一台电脑,只不过这个电脑只是运行一个特殊应用。

38.2 故障模型

RAID是设计用来探测和恢复硬盘错误的。

目前先只考虑fail-stop失败模式,就是说,这个盘要么能用,要么不能。

38.3 怎么去评估一个RAID

比较不同RAID的强项和弱项。三个维度评估:容量,可靠性,性能。

38.4 RAID 0 :条纹

可以并行访问,

RAID的映射问题

通过除法,取余算出到底在哪个块的哪个地方。

块大小

  • 并行性

  • 定位时间

大块定位时间短,并行性差。

什么才是最好的大小,这是一个很大的学问,但是下面的讨论我们用4KB,虽然目前很多是用64KB,不过这不影响我们的讨论。

回到RAID-0分析

  • 容量:都能用上,没有冗余

  • 可靠性:非常差,只要有一块坏了,数据就丢失了

  • 性能:不错,可以并发访问。

评估RAID性能

  • 单次请求

  • steady-state throughput 稳态吞吐量

稳态吞吐量:

  • sequential 连续的 , 比如说在一个大文件中去找一个关键词

  • random 随机的,比如说数据库的读取。

38.5 RAID-1: 镜像

RAID10,RAID01,先条纹,后镜像,先镜像,后条纹。

RAID-1分析

  • 容量:只有一半能用,非常昂贵

  • 可靠性:可以忍受任意一块硬盘损坏。甚至有可能N/2的硬盘坏了都是可以的。

  • 性能:写的话,只有一半的带宽:N*S/2 ,读的话也只有一半,原因是两个相同硬盘都会收到相同的请求,但因为只响应其中一条(并发嘛)。需要跳过一条指令,在跳过时,还是会占用时间的,因此读也只有N * S/2 , 随机读取就是没有问题N * R,随机写也只有N * R / 2,因为写一条,2块盘都要写,所以并不能继续写别的。因此只有一半的带宽。

38.6 RAID-4 : 通过奇偶校验位节省空间

用一个盘去存奇偶校验位,可以在保证可靠性的同时,有更多的空间,

RAID-4 分析

  • 容量: N-1

  • 可靠性:只能忍受一块硬盘坏,不能更多

  • 性能:

    稳定吞吐

    • 读: (N-1)*S

    • 写: (N-1)*S

    随机吞吐:

    • 读:(N-1)*R

    • 写: 这是一个有趣的问题,因为如果同时更新同一个条纹的数据,那么这个条纹的奇偶校验位怎么算呢?,因为奇偶校验位的存在,写必须是串行的。无论你多少块硬盘,吞吐量都是R/2

38.7 RAID-5 : 旋转奇偶校验

跟4很像。

随机读性能要好一些,因为可以用5块硬盘。

随机写性能有显著的提升,不用纯串行,而只是降低并行的概率,通过计算发现,其吞吐量为:(N/4)*R , 这主要是因为校验位均匀分布在5块硬盘,有概率并发执行。

RAID4除了实现起来方便外,全面弱于RAID-5,因此基本已经被市场淘汰。

38.8 总结

有一个表总结了这几种RAID的表现。

正在上传…重新上传取消

39. 文件和目录

操作系统抽象:

the process, which is a virtualization of the CPU,

the address space, which is a virtualization of memory

进程和地址空间虚拟化了CPU和内存。

persistent storage 永久存储

怎么去管理永久存储?

接下来我们会去学习管理永久内存的关键技术,我们将聚焦在提升性能和可靠性上。

39.1 文件和目录

两个抽象:文件,目录

每个文件都有 节点编号,只是一些字节的数组。

目录也都有节点编号,但是他的内容有点特别,它包含一系列的对(底层名字 和 可读名字的匹配关系。)

目录树包含了所有的目录和文件。

目录树,从根目录开始,

命名是非常重要的。

arbitrary 随意的

39.2 文件系统接口

先只讨论创建,访问,删除

39.3 创建文件

如上所述,文件描述符由操作系统在每个进程的基础上管理。这意味着某种简单的结构(例如,数组)保存在 UNIX 系统上的 proc 结构中

一个简单的数组追踪着每个进程基础的文件,数组中的每个入口都只是一个指针,指向一个文件的数据结构。用来追踪文件被读和被写。

TIP: USE S T R A C E (AND SIMILAR TOOLS) ,strace 的用法

The strace tool provides an awesome way to see what programs are up to. By running it, you can trace which system calls a program makes, see the arguments and return codes, and generally get a very good idea of what is going on. The tool also takes some arguments which can be quite useful. For example,

-f follows any fork’d children too;

-t reports the time of day at each call;

-e trace=open,close,read,write only traces calls to those system calls and ignores all others.

There are many other flags; read the man pages and find out how to harness this wonderful tool.

39.4 读和写文件

stdin 0

stdout 1

stderr 2

一个进程刚打开的文件是3开始

39.5 读和写,但是不依次

打开一个文件,就返回一个句柄,这个句柄里面的东西是独立的。

两个句柄指向同一个文件,但是他们的句柄不同,那么当前偏移就没有关系,是完全独立的。

39.6 分享文件表入口:fork,dup

父子进程的文件表入口是一样的,如果子进程修改了文件入口位置,那么父进程也会修改,其实fork之前的东西都是公用的,这是很容易理解的。

dup可以创建一个文件描述符,这个文件描述符与原来的文件描述符描述的是同一个底层文件。

39.7 立刻写入fsync

当write时,其实只是告诉系统,你在未来的某个时间写入到永久存储中。

出于性能的考虑,文件系统会在内存中,缓冲这些文件写入一段时间(5~30s)

在用户看来写入很快,只有在少数情况会出现问题。(调用了write,还没写入之前,程序崩溃了。)

然而很多程序需要这个最终写入保证,比如数据库。

系统提供一些API去满足这些应用,fsync,当调用时,所有的脏数据都会写入磁盘。

有趣的是,这个顺序并不能保证您所期望的一切;在某些情况下,您还需要 fsync() 包含文件 foo.txt 的目录。添加此步骤不仅可以确保文件本身在磁盘上,而且可以确保文件(如果是新创建的)也永久地是目录的一部分。毫不奇怪,这种类型的细节经常被忽视,导致许多应用程序级别的错误

int fd = open("foo", O_CREAT|O_WRONLY|O_TRUNC,
S_IRUSR|S_IWUSR);
assert(fd > -1);
int rc = write(fd, buffer, size);
assert(rc == size);
rc = fsync(fd);
assert(rc == 0);

39.8 重命名文件

mmap,永久存储

mmap是访问文件永久存储数据的一种方式

rename是一个原子操作。

39.9 获得文件信息

文件的metadata , 元数据。

stat,fstat,可以获取文件的信息到一个数据结构中

struct stat {
dev_t st_dev; // ID of device containing file
ino_t st_ino; // inode number
mode_t st_mode; // protection
nlink_t st_nlink; // number of hard links
uid_t st_uid; // user ID of owner
gid_t st_gid; // group ID of owner
dev_t st_rdev; // device ID (if special file)
off_t st_size; // total size, in bytes
blksize_t st_blksize; // blocksize for filesystem I/O
blkcnt_t st_blocks; // number of blocks allocated
time_t st_atime; // time of last access
time_t st_mtime; // time of last modification
time_t st_ctime; // time of last status change
};

hp@hp-HP-Z2-Tower-G5-Workstation:~/mycode/test_file_system$ stat foo
  文件:foo
  大小:6               块:8          IO 块:4096   普通文件
设备:10302h/66306d     Inode:33686958    硬链接:1
权限:(0664/-rw-rw-r--)  Uid:( 1000/      hp)   Gid:( 1000/      hp)
最近访问:2021-10-25 14:48:56.911910301 +0800
最近更改:2021-10-25 14:48:54.027948282 +0800
最近改动:2021-10-25 14:48:54.027948282 +0800
创建时间:-

39.10 删除文件

为什么调用unlink?

要理解这个问题,我们不单要了解文件,还要了解目录。

39.11 制造目录

mkdir

39.12 读目录

opendir ,readdir , closedir

文件入口代码:

struct dirent {
char d_name[256]; // filename
ino_t d_ino; // inode number
off_t d_off; // offset to the next dirent
unsigned short d_reclen; // length of this record
unsigned char d_type; // type of file
};

信息比较少,主要是完成 文件名字->底层文件系统编号的映射。

39.13 删除目录

rmdir,非空不能删

39.14 硬连接

当你创建一个文件时,你其实做了2件事:

  • 创建了一个inode,这个节点会追踪相关的信息。

  • 将这个inode连接到一个我们能读的文件名。linking,然后把这个连接放到目录中。

创建一个硬连接,其实跟原理的连接没什么区别,除了名字以外,指向同一个inode

为什么调用unlink?

当文件系统unlink一个文件时,会去查参考数量,也就是连接数。

当unlink发生时,将文件名与inode的连接删除,让参考数减一,如果参考数减到0,那么文件系统会释放inode和相关的数据块,真正的“删除”了文件。

39.15 软连接

硬连接不能创建指向文件的,也不能创建在不同硬盘的连接。

软连接是单独的文件,有自己的inode,

软连接的大小跟被连接的文件名称相关。

悬空参考:连接的文件被删除。

39.16 许可位和访问控制列表

正常的文件系统控制位说明

root用户可以无视许可访问任何文件。

access control list (ACL) ,AFS文件系统才有。

39.17 创建和挂载一个文件系统

mount

39.18 总结

UNIX 系统(实际上,在任何系统中)中的文件系统接口似乎很初级,但是如果您想掌握它,则需要了解很多。当然,没有什么比简单地使用它(很多)更好的了。所以请这样做!当然,阅读更多;一如既往,史蒂文斯 [SR05] 是开始的地方。

40.文件系统实现

vsfs ,very simple file system

40.1 思考的方向

2个方面:

  • data structures

    文件系统的数据结构。

  • access methods

    访问方法

sophisticated 复杂的

40.2 整体组织

将硬盘分解为块,4KB

文件系统应该存什么?如果总共64个块

  • 数据块(后56个块存数据):大部分的块都是用来传数据的

  • 元数据(第四~第八个块):文件大小,文件所有者,访问权限,访问和修改时间,其他类似的信息,一般用inode结构去存。这里我们用一些块来存inode table。

  • bitmap(第二~第三):用来记录哪些块被分配了,哪些还没有分配。通过bitmap , 这个bitmap分两种

    • data bitmap ,看数据块是否被使用

    • inode bitmap,看文件是否被使用?

  • 超级块:包含这个文件系统的信息,比如:有多少个inode,数据块,inode 表从哪个块开始等等。而且还包含一个魔术数,去确认当前文件系统是什么文件系统,比如我们现在说的是vsfs

40.3 文件管理:inode

在文件系统中,最关键的数据结构是inode: index node 节点序号。

这些节点像数组一样排列,当访问一个特定节点时,可以通过index,去找到对应的文件。

假设每个inode 256byte ,有20K的inode,我们可以有80个inode.

当我们向文件系统发起访问某个inode时,通过偏移找到对应的inode节点,这个节点的内容包含了: 类型,大小,系统分配了多少块,保护信息,时间信息,数据存在于硬盘的哪个位置。这些信息称为元数据。

多级index

大文件: 间接指针:先指向一个都是指针的块,然后再指向真正的用户数据。

point-base : 用指针存,每个指针指向一个块,不够就搞一个块存指针,从而可以存更大的文件

extent-base: 存少量指针,还有指针指向的地方的大小,这样要求有连续的块可以分配,相对来说没那么灵活,但是可以剩下更多的空间用来存数据,而不是存指针。

linked-base : 只要指向第一个块, 在第一块中把文件信息说明白。FAT文件系统用这个。

ext2,ext3 是用point-base

ext4 , 用extent-base

很多学者研究文件系统,发现了一些事实:

  • 大多数文件是很小的。

  • 文件平均大小在增长

  • 大多数存储空间都给了少量的大文件

  • 文件系统包含很多文件

  • 文件系统一般存一半

  • 目录很小

40.4 目录管理

目录只是包含: 入口名 -- inode number 之间的映射。

目录其实在文件系统看来也是一个文件,页有自己的inode,只是存的东西不一样。

40.5 空余空间管理

通过bitmap

创建文件流程:

  • 在bitmap里面找一个空闲的节点,分配给新文件

  • 标记这个节点已经使用。

  • 更新bitmap

  • 数据块的分配也类似。

40.6 访问路径:读,写

解析路径,从根目录开始找,根目录的inode是2,

在2中,找下一级的inode,直到找到最后的文件的inode。

把这个文件载入到内存,返回给用户。

既要更新两个bitmap,又要更新目录内容,还要更新具体文件数据。

文件系统要更新这么多东东西才能创建一个文件,为什么我们感觉这个操作很快?

40.7 缓存和缓冲

读的时候可以命中缓存。

写的时候可以先缓冲,然后再写入硬盘。

静态和动态分配,各有各的考虑。

放弃一部分性能,更耐用。

40.8 总结

介绍了文件系统的最基本内容。

41 本地化,快速文件系统 FFS

最原始的文件系统:

  • 超级块

  • 节点表

  • 数据

41.1 问题:性能低下

分段的问题,极其影响性能

我们如何组织文件系统数据结构以提高性能?在这些数据结构之上,我们需要什么类型的分配策略?我们如何使文件系统“感知磁盘”?

41.2 FFS : 硬盘感知是一个办法

伯克利的一个小组决定构建一个更好、更快的文件系统,他们巧妙地将其称为快速文件系统 (FFS)。这个想法是将文件系统结构和分配策略设计为“磁盘感知”,从而提高性能,这正是他们所做的。 FFS由此开创了文件系统研究的新纪元;

通过保持与文件系统相同的接口(相同的 API,包括 open()、read()、write()、close() 和其他文件系统调用)但改变了内部实现,作者为新的文件系统铺平了道路文件系统构建,今天仍在继续的工作。几乎所有现代文件系统都遵循现有接口(从而保持与应用程序的兼容性),同时出于性能、可靠性或其他原因更改其内部结构。

41.3 组织结构:气缸组

FFS就是把两个文件放在同一个block组,减少seek的时间。(比如同一个文件夹下的两个文件)

hierarchy 阶层。

mantra 咒语

41.4 策略:如何分配文件和目录

关键是把相关的东西放一起。实现这一点,FFS使用了一些简单的位置启发

  • 对于目录

    找到分配目录较少(以平衡组间目录)和大量空闲 inode(以便随后能够分配一堆文件)的柱面组,并将目录数据和 inode 放在该组中

  • 对于文件

    • 数据块跟inode在同一个block组。

    • 把一个文件夹的所有文件都放在一个组。

41.5 衡量文件本地化

观察到底每次访问的文件是不是都是附件的

事实证明,很多访问都是附近的。

41.6 大文件异常

amortization 分摊

文件超过一定大小,就让它存放到附近的块组中

块越大,越解决峰值带宽,但是就会浪费空间。

然而,FFS 没有使用这种类型的计算来跨组传播大文件。相反,它采用了一种简单的方法,基于 inode 本身的结构。前 12 个直接块与 inode 放在同一组中;每个后续的间接块以及它指向的所有块都被放置在不同的组中。对于 4KB 的块大小和 32 位磁盘地址,此策略意味着文件的每 1024 个块 (4MB) 被放置在单独的组中,唯一的例外是直接指针指向的文件的前 48KB。请注意,磁盘驱动器的趋势是传输速率提高得相当快,因为磁盘制造商擅长将更多位塞入同一表面,但与寻道(磁盘臂速度和旋转速率)相关的驱动器的机械方面提高了慢慢地[P98]。这意味着随着时间的推移,机械成本变得相对更加昂贵,因此,为了分摊所述成本,您必须在搜索之间传输更多数据。

尽量少找,多花时间传。

41.7 FFS的其他事情

  • 小文件

    用子块解决,512byte,一个块4K,有效率问题,

  • 磁盘分布

    需要CPU来处理磁盘IO,为了防止读0时,错过了1要等一圈的问题,将0,1隔开,这样可以提高效率。

    可能要问,这样隔开之后,磁盘带宽不就只有50%?

    不会的,track buffer 帮我们解决这个问题,当磁头读过这个轨道之后,会把数据搬到缓冲区,等要读的时候就直接返回了。

  • 允许长文件名

    可以定义更长的名字

  • 符号链接

41.8 总结

FFS是文件系统的分水岭。

42. 崩溃一致性:FSCK 和日志

crash-consistency problem , 崩溃一致性问题:当系统掉电,或者程序崩溃时,怎么保证文件的一致性呢?

发生崩溃如何更新磁盘。

fsck: file system checker

write-ahead logging : 预写日志,

42.1 一个详细的例子。

要完成数据写入,要经过3次写入:

  1. 更新inode

  2. 更新bitmap

  3. 更新data block

consistency 一致性

当写到一半的时候崩溃:怎么保证一致性

如果只有一次正确写入,会有什么情况?

  • 只有数据块写入磁盘: 就好像什么事都没发生一样,这不会影响崩溃一致性

  • 只有inode更新了:再被读到时,会出现垃圾数据,崩溃不一致,要使用这个文件系统,必须解决这个问题。

  • 只更新了bitmap: 有一块被标记为已使用的块,实际上没有使用,发生空间泄漏的问题,崩溃不一致

如果只有两次正确写入,会有什么情况?

  • inode,bitmap ,ok: 有垃圾数据,崩溃不一致

  • inode,data block ,ok: 崩溃不一致

  • data block ,bitmap ,ok : 崩溃不一致

崩溃一致性问题

我们期望能够让硬盘从一个一致性状态切换到另外一个一致性状态,通过原子操作。

42.2 解决办法1:文件系统检查 fsck

fsck可以找到文件不一致的地方,并且修复他们。

但这些工具不能解决所有问题。对于指向垃圾内容的时候,fsck并不能知道里面是不是垃圾。

fsck主要做了什么?

  • 超级块: 通常是去看一下文件系统大小是不是大于已经分配了的空间大小,如果不是,就去用超级块的其他拷贝,而不是当前的超级块。

  • 空闲块:扫描inode,间接块,双重间接块等,通过这个扫描去确定到底哪些块是分配出去的,用这个信息去生成一个bitmap,如果出现inode和bitmap有不连续,就以算出来的bitmap为准

  • inode状态:fsck 确保文件类型的有效的,如果inode字段有问题不易修复,则认为inode有问题,由fsck清除; inode位图相应更新

  • inode连接数:fsck会去检查这个文件的连接数。从根目录开始扫描所有文件的连接数,如果发现inode列表中有,而文件夹中没有,就把这些文件放在lost+found目录下

  • 重复:fsck 还检查重复的指针,即两个不同的 inode 引用同一个块的情况。如果一个 inode 明显坏了,它可能会被清除。或者,可以复制指向的块,从而根据需要为每个 inode 提供自己的副本

  • 坏块: 在扫描所有指针的列表时,还会执行对坏块指针的检查。如果一个指针明显指向其有效范围之外的某些内容,则该指针被认为是“坏的”,例如,它的地址指向一个大于分区大小的块。在这种情况下,fsck 不能做任何太聪明的事情;它只是从 inode 或间接块中删除(清除)指针

  • 目录检查:fsck不明白用户文件的内容,但是目录是由系统生成的,fsck 对每个目录的内容执行额外的完整性检查,确保“.”和“..”是第一个条目,分配了目录条目中引用的每个 inode,并确保在整个层次结构中没有目录链接到多次。

fsck问题:很慢,明明没有改那么多文件,我们却要扫描整个磁盘。

42.3 解决办法2:日志

write-ahead logging, 写之前,先写日志

基本想法:在写硬盘之前,先写下一点log。

在超级块之后,group之前,我们增加一个日志区域。

日志:Txb ,i , B , Db , TxE

transaction identifier 交易标识符

Txb , TxE 都有交易表示符,中间的是本次操作的内容。

5个写入如果轮流写,会很慢,如果一起写,我们管不了这5个写入的顺序了,就会引发问题。

为了避免这个问题,可以分两次提交,第一次,提交1~4,第二次提交5,

硬盘保证512byte要么写入,要么什么都没发生,这是硬盘保证的原子性。

恢复

写入分3步:

  • 先写入本次操作内容

  • commit

  • 正式开始操作

如果错误发生在commit前,直接跳过就可以,根本没有影响

如果commit之后掉电,就按照日志去操作就可以。

有人可能担心数据丢失的问题,但是不用担心,这是非常罕见的问题,只要保证一致性就可以了。

批处理日志更新

batch 批处理。

日志会累计到一起去更新,比如5s一次。

使日志有限

循环队列,在超级块中存好日志开始和结束。

问题:每次写入都要写2次。

元数据日志

只记录元数据

那这样数据不就没办法写入了吗?

其实不是,只要把数据块先写就可以,如果数据块写了,但是inode,bitmap没写,那么无所谓,就当没发生过。

这种日志方法更流行,他可以保证崩溃一致性,而不需要太多额外的开销。

块重用的诡异案例

创建一个文件夹,然后创建一些文件,

删掉这些文件,然后新建一个文件,重用了之前的块。

此时发生崩溃,那么在重放日志的时候,由于之前重用块的系统机制,日志里面没有删除的记录,日志只记录了这个块的inode。

但是在重放日志时,就把原理的文件还原回来了。

避免上述问题:

  • 可以在保证删除块之前重用

  • 真正的linux中,采用撤销记录的办法。

42.4 解决办法3:其他方式

  • 软更新

  • copy-on-write

  • 在数据中搞一个指针指向inode

  • 乐观崩溃一致性。

42.5 总结

讲解了普通文件系统应对崩溃时,如何保持一致性。

43. 日志结构文件系统

搞这个系统的目的:

  • 系统内存越来越大:性能瓶颈慢慢的卡在了文件系统的写操作上。

  • 随机IO和顺序IO差太多:

  • 当前的文件系统表现非常差,在很多工作流都是

  • 文件系统没有意识到RAID

所有有趣的系统都由一些一般性的想法和许多细节组成。有时,当你学习这些系统时,你会想:“哦,我明白了;剩下的只是细节,”而你使用它只是了解了事情的真正运作方式。不要这样做!很多时候,细节很关键。正如我们将在 LFS 中看到的,总体思路很容易理解,但要真正构建一个有效的系统,您必须考虑所有棘手的情况。

Log-structured File System 日志结构文件系统

先把写的东西缓存到一个内存段,当内存段满了,连续的写入硬盘中没有被使用的块。

43.1 连续写入硬盘

写入元数据的时候,把写一个指针指向之前的

43.2 连续高效的写入

连续写入两个块时会出现一个问题,就是在写入第一和第二个块的时间间隙时,有可能磁盘已经转过去了,那么要连续写入,就必须等一个旋转周期。

LFS 用一个老技术解决这个问题,写缓存

43.3 有多少缓冲?

这取决于定位速率和传输速率的比较。

关键是要让缓冲尽可能接近传输速率,有一套计算方法,如果要达到最解决传输速率,那么缓存的大小将会增长很快。(曲线是y = x/(1-x), x= 0~1)

43.4 问题:找到节点

FFS中我们保留了节点表,很容易计算偏移

但是在LFS中,我们无法这么做,我们inode表是一直在移动的。

43.5 解决办法通过间接的方式:inode map

每次跟新都更新imap。

那么imap要存在硬盘哪里呢?

当你写完数据之后,imap就在数据后面存着。

43.6 完成解决方案:检查点区域

checkpoint region (CR) , 这个区域是存imap位置的,这个位置过一段时间才会更新,比如30秒。这个位置是硬盘中固定的位置。

43.7 从磁盘读取文件:回顾

我们怎么读磁盘信息?

比如,我们刚上电,我们去读CR,读到imap,读到内存。

43.8 那目录呢?

递归更新问题 ,不用怕,imap相当于全局的,所有更新,都在这里面发生。

43.9 一个新的问题:垃圾回收

LFS周期的去回收垃圾。

如果看到有没用的块就回收,那么文件系统会出现很多洞

LFS是根据段来的,这样就可以每次回收多一点,保证磁盘内有足够大的块?

2个问题:

  • LFS怎么知道哪个段可以释放?

  • 清洁者要多频繁的清理?应该选择哪个段去清理?

43.10 决定哪个块存活

segment summary block (ss块)

增加一个段总结块,这个块记录这个段是不是活着,甚至可以通过版本来区分,到底是不是跟imap一致,从而判断是否为最新。

43.11 一个策略问题:清除哪个块?

根据这个块是否经常被改写来确定是否应该释放。经常被写的称之为hot block,不经常被写的称之为cold block。

43.12 崩溃回复和日志

链表的方式存日志。

存2份CR,交替的写这两个CR

根据时戳判定哪个是最新的,然后去找到对应的imap,对日志进行前滚,尽可能回复磁盘的内容。

43.13 总结

写时复制,在SSD中都有使用,LFS的知识遗产很大程度的影响了后来的发展。

44. 基于falsh的SSDs

solid-state storage

我们应该聚焦flash,NAND-based flash

flash有两个特性:

  • 当你要写入的时候,需要先擦除,而擦除是非常耗费时间的

  • 当你频繁写入会引发磨损wear out,

44.1 存储一个bit

transistor 晶体管

single-level cell (SLC)

multi-level cell (MLC)

triple-level cell (TLC)

SLC flash 在一个晶体管中,只能存储一个bit

MLC flash 在一个晶体管中可以存2个bit,根据存储的不同电荷水平来区分00,01,10,11

TLC flash 可以存3个bit

小心术语。

术语是让表达更方便的,而不是让生活变得更难的

44.2 从bit到banks/planes

flash 组织成banks/planes。

block , 128KB~256KB

页page ,4K

bank,很多个block

写入一个block,需要先擦写

44.3 基本flash操作

3种基本操作:

  • read(a page)

    几10微秒,跟要读的这个块在哪里没有关系,跟他上一次读取的位置没有关系,是一个随机读取设备。

  • erase(a block)

    几毫秒,擦除后才可以被重新编程。把整个block全部置位1

  • program(a page)

    几100微秒,可以把部分1变为0

详细例子

想改某个值,先擦除,然后再编程。

总结

读page是容易的,flash读的时候很快,不需要旋转和寻找轨道

写很诡异,先要擦除整个块,然后才能写。写多了还会磨损。

44.4 flash性能和可靠性

性能:

正在上传…重新上传取消

flipped ,翻转

可靠性:

  • wear out ,磨损

  • disturbance,骚乱 ,如果读某些位,可能会导致附近的位发生翻转。这些翻转,一般叫读扰乱,program扰乱。

Backwards compatibility 向后兼容。

44.5 从原始闪存到基于闪存的 SSD

SSD也有内存,SRAM,用来缓存和缓冲数据和映射表。有控制逻辑,用来编排设备的操作,

正在上传…重新上传取消

flash translation layer : FTL ,提供一种服务:把客户端提出的读写请求转换为内部的flash操作。

高性能:

  • 并行操作

  • 减少写入扩大. 用户要求写入的比特数/ FTL真实写入的比特数,因为写入一个页,有可能变成写入一个块,这是查很远的。

高可靠性:

  • 通过混合多种方法:防止将所有写入一次完成,防止多次写入,对于不同的块做到 wear leveling 磨损均衡。

  • 减少扰乱:FTL 通常会按顺序对擦除块内的页面进行编程,从低页面到高页面

44.6 FTL组织:一个坏方法

最简单的办法,直接映射

这种方法,读写性能,可靠性都很差。

44.7 一个日志结构 FTL

这个世界大部分的固态硬盘都是通过这种办法的。

通过out-of-band 区域来永久存储 map信息,

44.8 垃圾回收GC

读出需要回收的块的有用内容,把这些内容拷贝到新的块,回收这个块。

trim 修剪

修剪操作在这种有逻辑->物理 的映射的系统中很有用。

在SSD中,我们可以通过修剪这个映射表,告诉垃圾回收器,哪些块可以直接回收。

这样可以更好的发挥SSD的性能。

为了减少垃圾回收,SSD过度供应容量,垃圾回收可以延迟,并且放到后台运行。

44.9 映射表的大小

我们的页太小,这样的话会导致我们的map很大。

VPN : 虚拟页数字。

如果是以块+地址偏移来存的话,那么在写小文件的时候,会有性能问题。因为块很大,大约256KB

混合映射

小映射表: log table

块映射表:data table

switch merge : 交换合并 , 最好的情况。

partial merge : 部分合并 ,会扩大写入,

full merge : 完全合并 , 读出每一个块中有用的page,把来回操作,把他们全部写到同一个block

页映射+缓存

直接用页映射的方法,但是不是映射所有的页,而是映射当前使用的页,缓存这些页。

44.10 擦写均衡

对于不怎么变的内容,FTL会定期把这些内容放到那些读写次数比较多的地方,从而负载均衡。

44.11 SSD性能和费用

  • 性能:随机读写比HDD要好很多,但是随机和顺序还是有差距,因此有足够的理由去提升随机读写性能。

  • 费用:SSD全面优于HDD,为什么没有全面取代,因为费用问题。

44.12 总结

讲了SSD的基本原理

45. 数据完整性和保护

怎么保证数据完整性?

我们需要什么技术?怎么样才能更高效,在时间和空间上。

45.1 硬盘失败模式

如果是说硬盘要么坏,要么ok,那么其实非常简单。(fail-stop模式)

但是其实还有很多失败模式:

  • latent-sector errors (LSEs) ,潜在扇区错误

  • block corruption. 块损坏(腐败)

error correcting codes ECC , 在硬盘侧的错误收集代码会帮助我们找到坏掉的扇区。

45.2 处理潜在扇区错误

事实证明,潜在扇区错误相当容易处理,因为它们(根据定义)很容易检测到。当存储系统尝试访问一个块,并且磁盘返回错误时,存储系统应该简单地使用它必须使用的任何冗余机制来返回正确的数据

45.3 探测损坏:校验码

使用校验码去判定块是否损坏。

  • 异或校验,算得很快。

  • Fletcher checksum ,

  • CRC , 很常用。

45.4 使用校验码

使用校验码去报错,或者去读这份数据的copy

45.5 一个新的问题:误写

增加一些信息在校验头。

增加冗余信息是更好的探测错误的办法。

45.6 最后一个问题:写丢失。

写入之后,立马读一下: 很慢

Zettabyte File System 这个文件系统,有inode ,间接块的校验码,如果写入失败,那么这里会出现错误。

45.7 清理

disk scrubbing 磁盘清理。

每天或者每周去探测一次,看是否有错误。

45.8 校验码的开销

空间:0.19%的磁盘空间

时间:要算校验和,需要时间。

45.9 总结

介绍了磁盘存在的其他错误,以及这些错误的修正办法,还有这些办法的开销。

46. 总结

总结本章讲的内容,

47. 对话

48. 分布式系统

客户端/服务器的分布式系统。

只介绍一小部分分布式系统。

系统的组件可以fail,但整个系统几乎不好fail

通讯是不可靠的活动。

我们必须考虑丢包的相关技术。

  • 性能

  • 安全

  • 通信,很重要。

48.1 通信基础

丢包问题常常发生。

48.2 不可靠的通信层

UDP

48.3 可靠通信层

TCP

ACK , 序号,

48.4 通信抽象

分布式共享内存

这种技术,让整个分布式系统像一个多线程应用。

不同的是,这些进程在不同的机器,而不是不同的进程

如果现在发生一个页的访问,会出现问题,如果这个页在本机,那就直接访问,如果不再,就发生一个错误,系统捕捉这个错误去其他机器找到这个页。

这种方法目前已不怎么使用了。想象一下,如果你现在要访问的内容分布在所有的机器中,这会引发很难处理的后果。

目前这种方法已经没有人用了

48.5 远程程序调用 RPC

把对远程机器的调用变得很简单,很直接,就像调用本地函数一样。

服务器简单的定义路由,剩下的交给RPC系统的两个部分完成:

  • 存根stub生成器:通过自动化来消除将函数参数和结果打包到消息中的一些痛苦。

    负责把调用转为网络消息发到目标设备。

    线程池可以让系统跑得更快,因为不用去等待上一个任务执行完毕。

  • 运行时库:运行时库处理 RPC 系统中的大部分繁重工作;大多数性能和可靠性问题都在这里处理 ,DNS转换要预先做好,不要每次都去解析。RPC是基于类似UDP这种不可靠的通信协议,为了性能。但是也会通过序号,超时重发这些去保证收到和只收到一次这些问题。

其他方面

如果出现客户端发的请求,但是服务器还没处理完,这时服务器应该去发一个通知,告诉客户端,我已经收到了,但是呢还没处理完,防止客户端不断重发。

如果出现一个包装不下的请求参数,要分段和重组。

字节序问题,网络端都是大字节序。

是不是要异步?

48.6 总结

简单介绍了2个分布式系统。

49. SUN 网络文件系统

有一个服务器链接着RAID,其他客户端通过网络来访问这个服务器,从而获取文件。

49.1 一个基本的分布式文件系统

在服务器实现文件系统的接口,在客户端把访问文件的接口路由到服务器,这样对客户端来说,访问远程的文件跟本地文件的差别只有性能。

49.2 NFS

最早且相当成功的分布式系统之一是由 Sun Microsystems 开发的,它被称为 Sun 网络文件系统(或 NFS)[S86]。在定义 NFS 时,Sun 采取了一种不同寻常的方法:Sun 没有构建专有的封闭系统,而是开发了一种开放协议,该协议简单地指定了客户端和服务器用于通信的确切消息格式。不同的团体可以开发自己的 NFS 服务器,从而在保持互操作性的同时在 NFS 市场中展开竞争。它奏效了:今天有许多公司销售 NFS 服务器(包括 Oracle/Sun、NetApp [HLM94]、EMC、IBM 等),NFS 的广泛成功很可能归功于这种“开放市场”方法

49.3 聚焦:简单快速的服务崩溃恢复

这是NFSv2版本最关键的是快速的服务器崩溃恢复。

49.4 快速奔溃恢复的关键:无状态

不维护客户端和服务器之间的状态。

49.5 NFSv2协议

讲了这个协议的大概,通过这里的讲解。

我们只需要用:卷标识符、inode 编号和生成编号,这三项共同构成了客户端希望访问的文件或目录的唯一标识符。卷标识符通知服务器请求所指的文件系统(一个NFS服务器可以导出多个文件系统); inode 编号告诉服务器请求正在访问该分区中的哪个文件。最后,重用一个inode号时需要生成代号;通过在重复使用 inode 编号时增加它,服务器确保具有旧文件句柄的客户端不会意外访问新分配的文件。

49.6 从协议到分布式文件系统

在构建可靠系统时,幂等性是一个有用的属性。当一个操作可以多次发出时,处理操作失败就容易多了;你可以重试一下。如果操作不是幂等的,生活就会变得更加困难。

49.7 处理服务器失败用幂等性操作。

幂等性 idempotent是分布式环境下常见的问题;幂等性指的是多次操作,结果是一致的。(多次操作数据库数据是一致的。)

完美是善的敌人

创建一个目录并非幂等性。

49.8 提升性能:客户端侧的缓存

缓存可以提升性能,但是也会引发一致性问题。

49.9 缓存一致性问题

  • 更新问题

  • 陈旧缓存

flush-on-close ,关闭时去刷新一下服务器的内容。

用之前,先看一下是不是最新的。

49.10 访问NFS缓存一致性

短命的文件,会降低性能,我们要想办法解决。

49.11 对服务器端写入缓冲的影响

多个客户端同时写入,可能会导致写入的缓存不一样。

去避免这件事,NFS必须要确认写入到永久存储中才能通知客户端已经成功了。

可以先写入到一个电池供电的内存中,然后就直接告诉客户端ok了。从而提升性能。

49.12 总结

无状态,幂等性等概念在整个文件系统中的作用。

也讲述了如何使用缓存提升性能,同时不受一致性的困扰。

50. 安德鲁文件系统AFS

AFS 关键是 规模,怎么实现一个分布式系统,让最多用户可以接入?

通过协议:

AFS 与 NFS 的不同之处还在于,从一开始,合理的用户可见行为就是首要考虑的问题。在 NFS 中,缓存一致性很难描述,因为它直接取决于低级实现细节,包括客户端缓存超时间隔。在 AFS 中,缓存一致性简单易懂:当文件打开时,客户端通常会从服务器收到最新的一致性副本

50.1 AFS版本1

所有 AFS 版本的基本原则之一 :是在访问文件的客户端计算机的本地磁盘上缓存整个文件

先衡量,在构建

50.2 版本1的问题

通过测量发现了一些问题:

  • 路径遍历成本太高

  • 客户端发出过多的 TestAuth 协议消息

还要其他问题:

  • 负载不均衡问题:通过volumes解决

  • 上下文切换频繁:通过版本2改成线程实现解决

50.3 改进协议

针对上面两个问题,我们需要改进这个协议。

50.4 AFS版本2

通过回调的方式,当服务器发现文件被修改了,调用一个回调函数去通知客户端,文件已经修改。

这里要注意轮询和中断的区别。

file identifier (FID) 文件定位器

通过对使用文件的编号,让系统更快速的定位到文件,而不需要去遍历路径。

50.5 缓存一致性

对于不同的机器,通过机制去保证。

对于同一台机器,AFS认为他们之间的修改互相是可见的。

最后一个修改的人胜出。

这个跟NFS不同,NFS只要对不同的块修改,那么两者都可以保存。或者发生冲突,AFS会直接保留最后保存的内容。

50.6 崩溃恢复

当崩溃时,存在服务器的缓存回调已经失效,此时服务器向所有的客户端发出信息(不要相信你的缓存内容),这就是AFS付出的代价。

50.7 AFSv2 规模和性能

性能接近于本地使用文件,规模变得更大,可以服务更多的用户。

评估工作负载是很重要的。

我们要仔细分析我们的工作负载,从而去选择更好的框架结构。

50.8 AFS: 其他提升

  • 统一命名

  • 安全

  • 灵活的权限

  • 便于管理

50.9 总结

NFS是开放的,在v4版本,他已经跟AFS很像了。

AFS更多的是在想法上,而不是在实际应用中。

51. 分布式系统总结对话

可以隐藏错误,让用户发现不了问题。

协议很重要,协议引发的状态同步会导致很多同步问题。

安全性

52.关于安全性方面的对话

计算机相关的内容已经介绍完毕,那么系统安全性怎么保证呢?

53. 操作系统安全的介绍

53.1 介绍

vital , 必不可少的

inevitable 不可避免的

为什么系统在安全方面很重要?

  • 所有东西都跑在操作系统上。程序建立在操作系统之上,如果系统不安全,程序能正常运行是不可能的。

  • 如果一个软件,一个网站不安全,你可以不用,但是每个人都要用操作系统

  • 硬件资源的控制权都在操作系统手上。

flaws 缺陷

越复杂的程序,缺陷就越多。

alter 更改?

操作系统需要哪些原语?硬件需要哪些机制?我们可以怎么用这些东西来解决我们安全性的问题。

53.2 我们保护什么?

我们需要综合的去看一下,我们试着去保护的是什么?答案是:所有东西

商业的操作系统有硬件的完全控制权,它可以控制进程,读写所有寄存器,检查所有主存位置,操作所有外设。总的来说,操作系统可以做以下一些事:

  • 检查更改任意进程内存

  • 对硬盘随意操作

  • 改变进程调度,甚至是杀死

  • 发送任何消息到任何地方

  • 使能或者不使能任何外围设备

  • 给任意进程其他进程的资源

  • 任意拿走任意进程控制的资源

  • 响应任何系统调用。

SECURITY ENCLAVES 安全飞地

关于加密相关的数据,是设备的安全飞地。

at the mercy of 受...支配

进程由操作系统支配。在一个恶意的操作系统中,不可能有进程会受到保护。

53.3 安全的目标和策略

我们说想要一个安全的系统,其实是很模糊的说法。研究者提出一些高度概念化的层级。

定义了3个大目标:

  • 保密,如果一些信息是去隐藏起来,防止被别人看的话,就不要让别人发现。

  • 正直,如果系统的某些信息或组件应该处于特定状态,请不要让对手更改它。一般通过认证的方式。

  • 可用性,保证数据可用,保证攻击者不能让我的数据不能用。

还有一个目标是,我们希望控制分享。

重要的方面,不可否认性 non-repudiation 。

这些都是宏大的目标,对于一个真正的系统,需要挖掘更多的细节,更具体的目标。

  • 保密:进程的内存地址空间不能被其他程序任意的读取。

  • 正直:当一个用户对一个文件的写记录,其他用户不能修改这个记录。

  • 可用性:不能绑架系统, 让系统不能工作。

系统对于安全只提供一些机制,但这些机制你想怎么用是你自己的事,关于让谁访问,不让谁访问这些事情,应该是管理员完成,而非操作系统完成。

53.4 设计安全的操作系统

设计原则:

  • 机制经济:尽可能保持系统小,简单。

  • Fail-safe 默认:默认安全,配置的时候,默认设置为安全,不能让不安全成为默认。比如root用户默认不能直接通过ssh登录。

  • 完全调解:你应该在每次触犯安全策略的情形时,先确认

  • 开放设计:告诉你的攻击者,你将如何防护他的攻击。

  • 权限分离:让两个人同时确认才可以通过之类的策略。

  • 最不常用机制:对不同用户和进程,用单独的数据结构或机制来处理他们

  • 可接受性:如果你的用户不使用它,那么你的系统毫无价值。不能对用户要求太多。

53.5 系统安全基础

进程监控,防系统调用等等。虚拟化进程跟内存是一个有用的机制

53.6 总结

下几个章节会讲更多策略。

54. 认证

54.1 介绍

context 语境

在计算机安全讨论中,代表委托人执行请求的进程或其他活动计算实体通常称为其代理 agent

credential 凭据

怎么去安全的识别一个进程?

54.2 给进程附加认证

开机有root权限,要求登录用户进来之后,给个接口,此后这个用户创建的进程,就复制这个用户的认证。

54.3 怎么去认证一个用户?

典型的,有3个方法:

  • 你知道什么?

  • 你有什么?

  • 你是谁?

54.4 通过你知道什么去认证

也就是密码。

通过保持密码的哈希值,这样系统不必知道密码,但是可以知道用户是否知道密码。

54.5 通过你有什么来认证

令牌,银行卡,手机验证码

54.6 通过你是什么来认证

人类的指纹,瞳孔,面部识别等生物特征。

没有完美的认证方式,但是对于不同的环境,其认证方式都是工作得很好的。

我们要做的是为我们要做的事情设计一套好的认证方式。

54.7 验证非人类

网站二维码,确认你是不是真人

54.8 总结

55. 访问控制

ACL,访问控制列表

这种控制方法错误比较少见,除非你的设计错误。

56. 加密

HTTPS那一套。

57. 分布式系统安全

SSH,HTTPS

附录

单词

elapsed 过去

halt 停止

mechanisms 机制

instruction 指令

fetch 拿

allocates 分配

exhibit 展示

crux 症结

implementing 执行,实现。

overheads 开销

malicious 恶意的

strive 努力的

brief 短暂的

batch 批处理

illusion 错觉

policies ,策略

mechanisms 机制

defer 推迟

reside 居住,驻留。

eagerly 热切的

explicitly 明确的,显式的

refine 提炼

improves resource utilization 提高资源利用率

now-extinct 现已灭绝。

Interlude 插曲

retain 保留

malfeasance 渎职

Assumptions 假设

preemptive 先发制人

preempt 抢占

interactivity 互动性

utilization 利用率

猜你喜欢

转载自blog.csdn.net/qq_38307618/article/details/123919550