CPU虚拟化
抽象:进程
进程的非正式定义:进程就是运行中的程序。
OS通过虚拟化CPU来提供这种假象:通过让一个进程只允许一个时间片,然后切换到其他进程,OS提供了存在多个虚拟化CPU的假象,这也是时分共享CPU技术。
抽象:进程
进程的机器状态:
- 它的内存。指令存在内存中。正在进行的程序读取和写入的数据也在内存中。因此进程可以访问的内存是该进程的一部分。
- 机器状态的另一部分是寄存器。
- 程序也经常访问持久存储设备。
进程API
OS接口包含的内容:
创建(create):OS必须包含一些创建新进程的方法。shell中输入命令或双击应用程序。
销毁(destroy):还提供了一个强制销毁进程的接口。可以用来停止失控进程。
等待(wait):程序休眠
其他控制(miscellaneous control):例如,大多数OS提供某种方法来暂停进程,然后恢复。
状态(status):获得有关进程的状态信息。例如运行了多长时间,处于什么状态。
进程创建:更多细节
OS如何启动并运行一个程序?
OS运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载到内存中,加载到进程的地址空间。程序最初以某种可执行格式驻留在磁盘上。
早期OS加载过程尽早完成,即在运行程序之前全部完成。现代OS惰性执行该程序,即仅在程序执行期间需要加载的代码或数据片段,才会加载。
加载到内存中后,OS在运行此进程之前还要执行一些其他操作。第二件事,必须为程序的运行时栈(run-time stack)分配一些内存。也可能为程序的堆分配一些内存。
OS还将执行一些其他初始化任务,特别是与输入/输出相关的任务。
总的来说,将代码和静态数据加载到内存中,通过创建和初始化栈以及执行与I/O设置相关的其他工作。最后一项任务,启动程序,在入口处运行,即main()。通过跳转main()例程,OS将CPU的控制权转移到新创建的进程中,从而程序开始执行。
进程状态
进程的三种状态:
运行(running):在运行状态下,进程正在处理器上运行。这意味着,它正在执行指令。
就绪(ready):在就绪态下,进程已准备好运行,但由于某种原因,OS选择不在此时运行。
阻塞(block):在阻塞状态下,一个进程执行了某种操作,直到发生其他时间时才会准备运行。一个常见的例子是,当进程向磁盘发起I/O请求时,它会被阻塞,因此其他进程可以使用处理器。
数据结构
为了跟踪每个进程的状态,OS可能会为所有就绪的进程保留某种进程列表,以及跟踪当前正在进行的进程的一些附加信息。OS还必须以某种方式跟踪被阻塞的进程。
对于停止的进程,寄存器上下文将保存其寄存器的内容。当一个进程停止时,它的寄存器将被保存到这个内存位置。通过恢复这些寄存器(将它们的值放回实际的物理寄存器中),OS可以恢复运行该进程。
插叙:进程API
关键问题:如何创建并控制接口
fork()系统调用
系统调用fork()用于创建新进程。新创建的进程几乎与调用进程完全一样,对OS来说,这时看起来有两个完全一样的p1程序在运行,并都从fork()系统调用中返回。
子进程不会从main()函数开始执行,而是直接从fork()系统调用返回。子进程并不是完全拷贝了父进程,它拥有自己的地址空间、寄存器、程序计数器。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int
main(int argc, char* argv[])
{
printf("Hello world(pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
printf("hello, I am child (pid:%d)\n", (int)getpid());
}
else
{
printf("hello, I am parent of %d (pid:%d)\n", rc, (int)getpid());
}
return 0;
}
有可能子进程会先运行
得到如下输出:
[root@centos OSChap5]# ./a.out
Hello world(pid:2870)
hello, I am parent of 2871 (pid:2870)
hello, I am child (pid:2871)
wait()系统调用
父进程调用wait(),延迟自己的执行,直到子进程执行完毕。当子进程结束时,wait()才返回父进程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int
main(int argc, char* argv[])
{
printf("Hello world(pid:%d)\n", (int)getpid());
int rc = fork();
printf("rc:%d\n",rc);
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
printf("hello, I am child (pid:%d)\n", (int)getpid());
}
else
{
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", wc, rc, (int)getpid());
}
return 0;
}
得到如下输出:
[root@centos OSChap5]# ./a.out
Hello world(pid:2912)
rc:2913
rc:0
hello, I am child (pid:2913)
hello, I am parent of 2913 (wc:2913) (pid:2912)
exec()系统调用
也是创建进程API的一个重要部分,这个系统调用可以让子进程执行与父进程不同的程序。
exec()给定可执行程序的名称及需要的参数后,exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int
main(int argc, char* argv[])
{
printf("Hello world(pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
printf("hello, I am child (pid:%d)\n", (int)getpid());
char *myargs[3];
myargs[0]= strdup("wc");
myargs[1]= strdup("p3.c");
myargs[2]= NULL;
execvp(myargs[0], myargs);
printf("This shouldn't print out");
}
else
{
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", wc, rc, (int)getpid());
}
return 0;
}
为什么这样设计API
这样可以在fork之后exec之前运行代码的机会。
UNIX的管道也是用类似的方式实现的,但用的是pipe()系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道(pipe)上,另一个进程的输入也被链接到了同一个管道上。因此,前一个进程的输出无缝地作为后一个进程的输入,许多命令可以通过这种方式串联在一起。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
int
main(int argc, char* argv[])
{
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
close(STDOUT_FILENO);
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC,S_IRWXU);
char *myargs[3];
myargs[0]= strdup("wc");
myargs[1]= strdup("p4.c");
myargs[2]= NULL;
execvp(myargs[0], myargs);
printf("This shouldn't print out");
}
else
{
int wc = wait(NULL);
}
return 0;
}
输出如下:
[root@centos OSChap5]# cat p4.output
34 66 778 p4.c # 34行,66个
机制:受限直接执行
通过时分共享CPU,实现了虚拟化。但是也有一些挑战。第一个是性能:如何在不增加系统开销的情况下实现虚拟化?第二个是控制权:如何有效地运行进程,同时保留对CPU的控制?
基本技巧:受限直接执行
使用正常的调用并返回跳转到程序的main(),并在稍后回到内核。
问题1:受限制的操作
直接运行非常快,程序直接在硬件CPU上执行。但是进程希望执行某种受限操作怎么办(I/O请求或获得更多系统资源)?
采用受保护的控制权转移:硬件通过提供不同的执行模式来协助操作系统。在用户态,应用程序不能完全访问硬件资源。在内核态,OS可以访问机器的全部资源。还提供了 trap 内核和 return from trap返回到用户态程序的特别说明,以及一些指令,让OS告诉硬件陷阱表(trap table)在内存中的位置。
要执行系统调用,程序必须执行特殊的trap指令。返回时,必须确保存储足够的调用者寄存器。例如x86上,处理器会将程序计数器、标志和其他一些寄存器推送到每个进程的内核栈(kernel stack)上。
内核通过 trap table 知道在OS内运行什么代码。
问题2:在进程之间切换
如何重新获得CPU控制权,以便可以在进程之间切换。
协作方式:等待系统调用
mac os像这样的系统通常包括一个显式的 yield 系统调用
协作调度系统中,OS通过等待系统调用,或某种非法操作发送,从而重新获得CPU的控制权。
非协作方式:操作系统进行控制
进程不协作,如何获得CPU控制权?
即使进程以非协作方式运行,添加时钟中断也让OS能够在CPU上重新运行。
时钟设备可以编程为每隔几毫米产生一次中断。产生中断时,当前进程停止,OS预先配置的中断程序会运行。此外,OS重新获得CPU控制权,可以停止当进程并启动另外一个进程。
保存和恢复上下文
是继续当前正在运行的进程还是切换到另一个进程,这个决定是由调度程序 scheduler 做出的,它是OS的一部分。
有两种类型的寄存器保存/恢复:
第一次是发生时钟中断的时候。在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。
第二种是当OS决定从A切换到B。在这种情况下,内核寄存器被软件(即OS)明确地保存,但这次被存储在该进程的进程结构的内存中,
进程调度:介绍
工作负载假设
确定工作负载是构建调度策略的关键部分。
对进程的假设:
1.每一个工作运行时间相同
2.所有的工作同时达到
3.一旦开始,每个工作保持运行直到完成
4.所有的工作知识用CPU(即不执行I/O操作)
5.每个工作的运行时间是已知的
调度指标
周转时间:任务的周转时间定义为任务完成时间减去任务到达系统的时间。
T周转时间 = T完成时间 - T到达时间
先进先出(FIFO)
先到先服务。
eg:A、B、C同时到达系统,A比B早一点,B比C早一点,每个工作运行时间为10s,则平均周转时间是?
A最先到达,完成时间为10s。接着B开始任务,完成时间是20s,接下来是C,完成时间为30s,平均周转时间为(10+20+30)/3=20s
如果放宽假设1,每个任务运行时间不再相同,A100s,B和C还是10s,则平均周转时间为(100s+110s+120s)/3=110s。
这个问题通常被称为护航效应,一些耗时较少的潜在资源消费者被排在重量级的资源消费者之后。
最短任务优先(SJF)Shortest Jobs First
先运行最短的任务,然后是次短的任务。(非抢占式调度程序)
eg:如果沿用上述例子,平均周转时间为(10s+20s+120s)/3=50s
如果放宽假设2,每个任务不是同时到达。B和C在A不久后到达,仍然被迫等到A完成。则平均周转时间为(100s+(110-10)+(120-10))/3=103.33s
最短完成时间优先(STCF)Shortest Time-to-Completion First
向SJF添加抢占,每当新工作进入系统时,它就会确定剩余工作和新工作中,谁的剩余时间最少,然后调度该工作。
沿用上述例子的平均周转时间为50s。
新度量指标:响应时间
响应时间:从任务到达系统到首次运行的时间
T响应时间=T首次运行 - T到达时间
eg:假设A在0时到达,B和C在10时到达,则平均响应时间为3.33
STCF在响应时间上并不好,如果3个工作同时到达,第三个工作必须等待前两个工作全部运行后才能运行。
轮转(Round-Robin)
时间片轮转算法:在一个时间片运行一个工作,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束,反复执行,直到所有任务完成。时间片长度必须是时钟中断周期的倍数。
时间片长度对轮转算法至关重要。太短导致频繁上下文切换影响整体性能。
摊销技术:通过减少成本的频度(即执行较少次的操作)系统的总成本就会降低。例如,时间片为10ms,上下文切换为1ms,这样大约10%的时间用于上下文切换,如果将时间片设为100ms,则只有1%的时间用于上下文切换,因此时间片带来的成本就被摊销了。
轮转的平均周转时间会上升。任何公平的政策,即在小规模的时间内将CPU均匀分配到活动进程之间,在周转时间这类指标上表现不佳。响应时间减少、周转时间上升。
结合I/O
调度程序必须要在工作发起I/O时做出决定,因为当前执行的作业在I/O期间不会使用CPU,它被阻塞等待I/O完成。
eg:A和B各执行50ms,但是A会每执行10ms发起I/O请求,每次I/O请求为10ms。则完成A时间为90ms,再执行B,平均周转时间为70ms。但如果把A划分为5个子工作,会在A执行I/O时,B抢占执行。这样做可以实现重叠,一个进程在等待另一个进程的I/O完成时使用CPU,系统因此得到更好的利用。
无法预知
OS没法预知每个工作的长度。
调度:多级反馈队列
多级反馈队列(Multi-level Feedback Queue)解决两方面问题:首先,它要优化周转时间。其次,给交互用户很好的交互体验,因此需要降低响应时间。
MLFQ:基本规则
MLFQ中有许多独立的队列,每个队列有不同的优先级。任何时刻,一个工作只能存在于一个队列中。MLFQ总是优先执行较高优先级的工作。
例如:如果一个工作不断放弃CPU去等待键盘输入,这是交互型进程的可能行为,MLFQ因此会让它保持高优先级。相反,如果一个任务较长时间占用CPU,则会降低其优先级。
两条基本规则:
- 规则1:如果A的优先级>B的优先级,运行A
- 规则2:如果A的优先级=B的优先级,轮转运行AB
如何改变优先级
-
规则3:工作进入系统时,放在最高优先级(最上层队列)
-
规则4a:工作用完整个时间片后,降低其优先级(移入下一个队列)
-
规则4b:如果工作在其时间片内主动释放CPU,则优先级不变
实例1:单个长工作
该工作首先进入最高级队列,执行10ms时间片后,调度程序将其优先级减1,因此进入Q1。在Q1执行完一个时间片后,进入最低优先级队列。
实例2:来了短工作
A在最低优先级队列,B在100时到达,加入最高级队列,经过两个时间片运行,B执行完毕,然后继续A执行
如果不知道工作是短工作还是长工作,那么就在开始的时候假设其是短工作,并赋予最高优先级。如果确实是短工作,则很快会执行完毕,否则将被慢慢移入优先级队列,这时该工作也被认为是长工作。
实例3:如果有I/O
A为长工作,B为交互型工作,每工作1ms执行IO。
MLFQ的问题
1:如果有许多交互型工作,则会长时间占用CPU,而CPU密集型长工作则得不到服务,会造成饥饿
2:有的程序会使用手段欺骗调度程序,以获得远超公平的资源,如果在占用99%的时间片后释放CPU以保持较高优先级队列
3:一个程序可能在不同时间表现不同。计算密集型进程可能在某段时间表现为一个交互性进程。
提升优先级
周期性地提升所有工作的优先级,简单的:将所有工作扔到最高优先级队列。
- 规则5:经过一段时间S,就将系统中所有工作重新加入到最高级队列
另外的问题是:S的值该如何设置?
更好的计时方式
如何防止调度程序被愚弄?调度程序应该记录一个进程在某一层中消耗的总时间,而不是在调度时重新计时。只要进程用完了自己的配额,就将其降到低一优先级队列中去。
- 规则4:一旦工作用完了其在某一层中的事件配额(无论中间主动放弃多少次CPU),就降低其优先级(移入低一级队列)
MLFQ调优及其他问题
配置多少队列?每一层队列的时间片配置多大?应该多久提升一次进程的优先级?
Solaris默认60层队列,时间片长度从20ms到几百ms,每1秒左右提升一次进程的优先级
调度:比例份额
比例份额调度程序有时也称为公平份额调度程序。调度程序的最终目标是确保每个工作获得一定比例的CPU时间,而不是优化周转时间和响应时间。
基本概念:彩票数表示份额
一个进程拥有的彩票数占总彩票数的百分比,就是它占有资源的份额。
假设A75张,B有25张,定时地抽取,以决定运行A或B。
这种随机方法相对于传统决策有几种优势:
1.不需要记录任何状态。传统的需要记录每个进程已经获得了多少CPU时间。
2.快速。
利用随机性,进程的运行时间从概率上满足期望的比例。
步长调度
系统中的每个工作都有自己的步长,这个值与票数成反比。
eg: A、B、C的票数分别是100、50、250,接下来用一个大数分别除以票数来获得步长,比如用10000,得到的步长分别为100、200、40.每次进程运行后,会让计数器增加它的步长,记录它的总体进展。
问题:同样需要记录,而且记录的是全局状态。并且如果新进入一个程序,状态设为0,会独占CPU。
这两类调度程序不经常使用。
多处理器调度(高级)
内存虚拟化
抽象:地址空间
早期系统
早期的机器并没有提供多少抽象给用户。OS从物理地址0开始 ,进程则是使用剩余的内存。
多道程序和时分共享
多个进程在给定时间准备运行,比如当一个进程在等待I/O操作时,OS会切换这些进程,增加CPU利用效率。人们意识到批量计算的局限性,每个人都在等待它们执行的任务及时响应。
最开始的机器共享:让一个进程单独占用全部内存运行一小段时间,然后停止,将它所有的状态信息保存在磁盘上(包含所有的物理内存),加载其他进程的状态信息,再运行一段时间。这种方法保存和恢复寄存器级的状态信息相对较快,但将全部的内存信息保存到磁盘就太慢了。
因此,在进程切换的时候,仍然将进程信息放在内存中,这样OS可以更有效率地实现时分共享。
随着时分共享流行,进程的保护变得十分重要,不希望一个进程可以读取其他进程的内存。
地址空间
地址空间是OS提供的一个易用的物理内存抽象,是运行的程序看到的系统中的内存。
一个进程的地址空间包含运行的程序的所有内存状态。包含代码、栈和堆。
当多个线程在地址空间中共存时,没有像这样分配的好办法。
我们描述地址空间时,所描述的时操作系统提供给运行程序的抽象。程序不在物理地址0~16kb的内存中,而是加载到任意的物理地址。如上节图所示,A、B和C加载到内存中的不同地址。
当OS在单一的物理内存上为多个运行的进程(所有进程共享内存)构建一个私有的、可能很大的地址空间的抽象,就说OS在虚拟化内存。
eg:上节图的进行A在地址0(将其称为虚拟地址, virtual address)执行加载操作时,OS确保不是加载到物理地址0,而是物理地址320KB(A载入内存的地址)。
目标
虚拟内存(VM)系统的目标:
- 透明。实现虚拟化内存的方式应该让运行的程序看不见,程序不应该感知到内存被虚拟化的事实。
- 效率。OS追求虚拟化尽可能高效,包括时间上(不会使程序运行得更慢)和空间上(不需要太多额外的内存来支持虚拟化)。
- 保护。确保进程受到保护,不会受其他进程影响,OS本身也不会受进程影响。
代码、堆、栈的地址
#include <stdio.h>
#include <stdlib.h>
int main (int argc, char *argv[]){
printf("location of code : %p\n", (void *) main);
printf("location of heap : %p\n", (void *) malloc(1));
int x = 3;
printf("location of stack : %p\n", (void *) &x);
return x;
}
运行如下
[root@centos OSChap13]# ./a.out
location of code : 0x40057d
location of heap : 0xf8e010
location of stack : 0x7ffda1ee1dac
如果你在一个程序中打印出一个地址,那就是一个虚拟地址。虚拟地址只是提供地址如何在内存中分布的假象(抽象),只有操作系统(和硬件)才直到物理地址。
插叙:内存操纵API
如何分配和管理内存?
内存类型
C程序在运行中,会分配两种类型的内存。第一种为栈内存,它的申请和释放操作时编译器来隐式管理的,所以有时也称为自动内存。
eg:比如在func()函数中定义一个整数x
void func(){
int x; // 在栈内存中声明一个整数
}
编译器完成剩下的事,确保进入func()函数的时候,在栈上开辟空间。函数退出时,编译器释放内存。如果须在信息存在于函数调用之外,就不要放在栈中。
对于长期内存的需求,第二种称为堆内存,其中所有的申请和释放操作都由程序员显式地完成。
eg:在堆上分配一个整数,得到指向它的指针
void func(){
int *x = (int *) malloc(sizeof(int));
}
malloc()调用
函数输入:传入要申请的堆空间大小。输出:成果就返回一个指向新申请空间的指针,失败就返回NULL。
只要包含头文件 <stdlib.h>
就可以使用malloc()
了。sizeof()
通常被认为是编译时操作符,意味着这个大小是在编译时就已知道,sizeof被认为是一个操作符,而不是一个函数调用(函数调用在运行时发生)。
#include <stdio.h>
#include <stdlib.h>
main(){
int *x = malloc(10 * sizeof(int)); // sizeof认为我们只是问一个整数的指针多大
printf("%d\n", sizeof(x));
int y[10];
printf("%d\n", sizeof(y));
}
输出:
[root@centos OSChap14]# ./a.out
8
40
注意:如果为一个字符串声明空间,使用malloc(strlen(s)+1)
,使用strlen获取字符串的长度,并加上1,以便为字符串结束符留出空间。
free()调用
释放不再使用的堆内存,使用free()
函数,接收一个由malloc()
返回的指针参数。
常见错误
对于支持自动内存管理的语言,当你调用类似malloc()的机制来分类内存时(通常用new或类似的东西来分配一个新对象),永远不需要调用其他来释放内存。垃圾收集器会运行,找出不再引用的内存,替你释放。
忘了分配内存
错误代码如下
char *src = "hello";
char *dst;
strcpy(dst, src); // 段错误
上述代码不会报错,可以正常运行
正确代码如下
char *src = "hello";
char *dst = (char *) malloc(strlen(src) + 1);
strcpy(dst, src); // 或者使用strdup()
没有分配足够的内存
有时也称缓冲区溢出(buffer overflow)
比如字符串拷贝时没有加1。在拷贝时,他会在超过分配空间的末尾处写入一个字节,有些情况下无害,可能会覆盖不再使用的变量。溢出是系统中许多安全漏洞的来源。
忘记初始化分配的内存
程序会遇到未初始化的读取。
忘记释放内存
另一个常见的错误为内存泄漏,如果忘记释放,就会发生。长时间运行的应用程序或者系统中,是一个巨大的问题,因为缓慢泄露的内存会导致内存不足,此时需要重新启动。有GC机制下,如果你仍然拥有对某块内存的引用,那么垃圾收集器就不会释放。
在用完之前释放内存
这种错误称为悬挂指针。
反复释放内存
称为重复释放,结果是未定义的
错误地调用free()
free需要传入一个指针,传入其他的会得到无效释放
为什么进程退出时没有内存泄漏?
编写一个短时间运行的程序时,使用malloc分配内存。退出前是否需要free?
真正意义上,没有任何内存会“丢失”。因为系统中实际存在两级内存管理。
第一级是操作系统执行的内存管理,OS在进程运行时将内存交给进程,退出时回收。
第二级管理在每个进程中,例如在调用malloc()和free()时,在堆内管理。即使没有调用free,OS也会在程序结束运行时,收回进程的所有内存,无论地址空间中堆的状态如何。
底层操作系统支持
malloc和free不是系统调用,而是库调用。但是malloc本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求更多内存或者将一些内容释放回系统。
一个这样的系统调用叫brk
,被用来改变程序分断(break)的位置:堆结束的位置。它需要一个参数(新分断的地址),从而根据新分断是大于还是小于当前分断,来增加或减小堆的大小。另一个调用sbrk
要求传入一个增量,但目的是类似的。
机制:地址转换
如何高效、灵活地虚拟化内存?
利用地址转换,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟地址转换为数据实际存储的物理地址。因此,在每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。
一个例子
void func(){
int x;
x = x + 3;
}
x的初始值为3000,该进程如图所示
动态(基于硬件)重定位
每个CPU需要两个硬件寄存器:基址寄存器和界限寄存器。使得能将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。
OS决定程序在内存的中的实际加载地址,并将起始地址记录在基址寄存器中。上述程序运行时,OS决定加载到32KB物理内存,因此基址寄存器的值就为32KB。该进程产生的所有内存引用,都会被处理器通过以下方式转为物理地址:
physical address = virtual address + base
eg:上述访问int x
物理地址 = 32KB + 15KB = 47KB,进程中使用的内存引用都是虚拟地址,硬件计算出物理地址,并将其发回给内存系统。
上述界限寄存器被设置为16KB,因为只开辟的16KB的内存空间。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU会触发异常,进程最终可能被终止。
有时我们将CPU的这个负责地址转换的部分统称为内存管理单元 MMU。
空闲列表
OS用列表记录哪些空闲内存没有使用,以便能够为进程分配内存。
重定向会造成内部碎片,所以需要更复杂的机制,以便更好地利用物理内存,接下来尝试将基址加界限的概念稍稍泛化,得到分段的概念。
分段
如果将整个地址空间放入物理内存,那么栈和堆之间的空间并没有被进程使用,缺依然占用了实际的物理内存。如何支持大地址空间?
分段:泛化的基址/界限
不止一个基址界限对,而是给地址空间内的每个逻辑段一对。一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有3个逻辑不同的段:代码、栈和堆。
引用哪个段
硬件如何知道段内的偏移量?以及地址引用了哪几个段?
显式的方式:如果用14位虚拟地址的前两位来标识:00表示代码,01表示堆,10表示栈
会引起一个段的地址空间被浪费,所以有些系统将栈和堆当作同一个段,用一位来做标识。
反向增长
上图中栈从28KB增长到26KB,相应虚拟地址从16KB到14KB。于是除了基址和界限外,硬件还需要知道段的增长方向(用一位区分,1代表自小而大增长,0反之)
支持共享
为了节省内存,在地址空间之间共享某些内存段是有用的,因此需要硬件支持。
给每个段增加了几个位,标识程序是否能够读写该段,或执行其中的代码。
有了保护位,除了检查虚拟地址是否越界,硬件还需要检查特定访问是否允许。
细粒度与粗粒度的分段
分段少-粗粒度
分段多-细粒度,在内存中保存段表
操作系统支持
一个基址界限对和分段的区别:每个地址空间只有一个基址界限对会使得在运行程序的同时,需要把进程所需的代码块,栈和堆需要用的空间放到内存当中去,而这些空间是没有使用的,这样容易造成内部碎片,许多内存被浪费掉了。而分段则无需给进程分配一整块的内存,通过给每个代码段一对基址界限,这样通过逻辑地址来寻址,可以节省很多内存。
分段带来的新问题:
OS在上下文切换时应该做什么?每个段寄存器中的内容必须保存和恢复。每个进程都有自己独立的虚拟地址空间,OS必须在进程运行之前,确保这些寄存器被正确地赋值。
管理物理内存的空闲空间。物理内存很快充满了许多空闲空间的小洞,很难分配给新的段或扩大已有的段。这种问题称外部碎片。
问题的解决方案:
1.紧凑物理内存,重新安排原有的段。例如,OS先终止运行的进程,将它们的数据复制到连续的内存区域中去,改变它们的段寄存器中的值,指向新的物理地址,从而得到了足够大的连续空间。但是,内存紧凑成本很高,会占用大量的处理器时间,而且应当在什么时候移动?
2.利用空闲列表管理算法,试图保留大的内存块用于分配。如最优适配、最坏匹配、首次匹配等等。
小结
分段可以更高效地虚拟内存,避免潜在的内存浪费,更好地支持稀疏地址空间,还可以代码共享。但是还是不足以支持更一般化的稀疏地址空间。例如,一个很大但是很稀疏的堆,都在一个逻辑段中,整个堆仍然需要完全加载到内存当中去。
空闲空间管理
如何管理空闲空间?
假设
在堆上管理空闲空间的数据结构称为空闲列表。假设不能进行紧凑,分配程序管理的是连续的一块字节区域。
底层机制
首先,看看空间分割与合并,其次如何快速并相对轻松地追踪已分配的空间。最后,讨论如何利用空闲区域的内部空间维护一个简单的列表,来追踪空闲和已分配的空间。
分割和合并
任何大于10字节的请求都会失败(返回NULL)
如果申请小于10字节的内存,分配程序会执行分割(splitting)动作:找到一块可以满足的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中。
分配程序还有另一种机制,合并(coalescing)。如果内存被free,只是简单地将空闲空间加入到空闲列表中,这样仍为几个10字节的空闲空闲,申请20字节的空间依然失败。所以分配程序在释放一块内存时合并可用空间。
追踪已分配空间的大小
大多数分配程序都会在头块(header)中保存一点额外的信息。如果用户调用了malloc(),并将结果保存在ptr中:ptr = malloc(20)
如果用户请求N字节的内存,库不是寻找大小为N的空闲块,而是寻找N加上头块大小的空闲块。
让堆增长:大多数的分配程序会从很小的堆开始,空间耗尽时,再向OS申请更大的空间.OS再执行sbrk
系统调用时,会找到空闲的物理内存页,将它们映射到请求进程的地址空间中去,并返回新的堆的末尾地址。
基本策略
最优匹配
首先遍历整个空闲列表,找到和请求大小一样或更大的空闲块,然后返回这组候选者中最小的一块。
选择最接近用户请求大小的块,从而避免了浪费空间,然而,在遍历查找正确的空闲块时,要付出较高的代价
最差适配
尝试找到最大的空闲块,分割并满足用户需求后,将剩余的块加入空闲列表。
表现非常差,导致过量的碎片,同时还有很高的开销。
首次匹配
找到第一个足够大的块,将请求的空间返回给用户,同样,剩余的空闲空间留给后续请求。
有速度优势,不需要遍历整个列表,但有时列表开头有很多小块。因此,分配程序如何管理空闲列表的顺序就变得很重要。一种是基于地址排序。通过保持空闲块按内存地址有序,合并操作会很容易,从而减少内存碎片
下次匹配
这个算法多维护一个指针,指向上一次查找结束的位置。想法是将对空闲空间的查找操作扩展到整个列表中去,避免对列表开头频繁的分割。同样避免了遍历列表
分页:介绍
如何通过页来实现虚拟内存?
将空间分割成固定长度的分片。把物理内存看成是定长槽块的阵列,叫做页帧,每个这样的页帧包含一个虚拟内存页。
例子
图1是64字节的地址空间,有4个16字节的页(虚拟页),左边0~64KB是物理内存地址。图2是有8个页帧,128字节物理内存,右边是OS将物理内存划分的页帧号。
虚拟页和页帧注意区别
物理地址非划分成大小相等的页帧,页帧是程序使用的虚拟地址,程序的虚拟地址空间被划分成大小相等的页。页内偏移大小=帧内偏移大小,页号大小(size)和帧号大小(size)可能不一样。
为了记录地址空间的每个虚拟页放在物理内存中的位置,OS为每个进程保存一个页表。主要作用是为地址空间的每个虚拟页面保存地址转换,从而知道每个页在物理内存中的位置。如上图:Virtual Page0 -> Page Frame 3; Virtual Page1 -> Page Frame7...
eg:将虚拟地址到寄存器eax的数据显式加载
movl 21, %eax
- 页映射帧
- 页是连续的虚拟内存
- 帧是非连续的物理内存
- 不是所有的页都有对应的帧
通过页帧来计算物理地址
页表
页表用于将虚拟地址(虚拟页号)映射到物理地址(物理帧号)。
页表中的有效位(valid bit)用于指示特定地址转换是否有效。保护位(protection bit)表明页是否可以读取、写入或执行。存在位(present bit)表示该页是在物理存储器还是在磁盘上。脏位(dirty bit)表示页面被带入内存后是否被修改过。参考位(reference bit,也称访问位 access bit)用于追踪页是否被访问。
eg:x86的页表项
存在位P;是否允许读写位(R/W);确定用户模式进程是否可以访问该页面的用户/超级用户位(U/S);确定硬件缓存如何为这些页面工作(PWT/PCD/PAT/G);一个访问位A;脏位D;页帧号PFN。
页表的地址转换实例
(4,1023)对应物理地址: 2^10*4+1023=5119
分页:快速地址转换(TLB)
分页机制的性能问题:
- 访问一个内存单元需要2次内存访问:一次用于获取页表项,一次用于访问数据
- 页表可能很大
因为要使用分页,就要将内存地址空间切分成大量固定大小的单元页,并且需要记录这些单元的地址映射信息。这些映射信息一半存储在物理内存中,所以在转换虚拟地址时,分页逻辑上需要一次额外的内存访问。每次指令获取、显式加载或保存,都要额外读取一次内存以得到转换信息,会非常慢。
如何加速地址转换?
缓存或间接访问。需要增加地址转换旁缓冲存储器(translation-lookaside buffer)TLB(位于CPU内部),就是频繁发生的虚拟到物理地址转换的硬件缓存。对每次内存访问,硬件先检查TLB,看其中是否有期望的转换映射,如果有就不用访问页表。
TLB的基本算法
1 VPN = (VirtualAddress & VPN_MASK) >> SHIFT // 虚拟地址中提取页号
2 (Success, TlbEntry) = TLB_Lookup(VPN) // 检查TLB是否有VPN的映射
3 if (Success == True)
4 if (CanAccess(TlbEntry.ProtectBits) == True)
5 Offset = VirtualAddress & OFFSET_MASK
6 PhysAddr = (TlbEntry.PFN << SHIFT) | Offset // 通过VPN取出PFN与Offset求得物理地址
7 AccessMemory(PhysAddr)
8 else
9 RaiseException(PROTECTION_FAULT) // 被保护不能访问
10 else // PTBR页表基址寄存器 // TLB Miss
11 PTEAddr = PTBR + (VPN * sizeof(PTE))
12 PTE = AccessMemory(PTEAddr)
13 if (PTE.Valid == False) // 不存在对应页帧
14 RaiseException(SEGMENTATION_FAULT)
15 else if (CanAccess(PTE.ProtectBits) == False) // 被保护不能访问
16 RaiseException(PROTECTION_FAULT)
17 else // page number, page frame number, 保护位
18 TLB_Insert(VPN, PTE.PFN, PTE.ProtectBits) // 更新TLB
19 RetryInstruction()
访问a[0]时,未命中,a[0]的VPN以键值对的形式存入TLB,访问a[1]时,VPN存在于TLB中,所以命中。共计3次Miss,7次Hit,命中率为70%
处理TLB Miss
硬件处理
硬件必须知道页表在内存中的确切位置(通过页表基址寄存器),以及页表确切格式。发生Miss时,硬件会”遍历“页表,找到正确的页表项,取出想要的转换映射,用它更新TLB,并重试该指令。如上面的代码。
x86采用固定的多级页表,当前页表由CR3寄存器指出。
软件处理
Mips(如RISC-V)由操作系统管理,有所谓的软件管理TLB。发生Miss时,硬件系统会抛出异常(11行),这回暂停当前的指令流,将特权级提升至内核模式,跳转至陷阱处理程序。如下面的代码。
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 RaiseException(TLB_MISS)
这里的陷阱返回指令不同于服务于系统调用的从陷阱返回。后者返回后继续执行陷入操作系统之后那条指令,而前者是在TLB未命中的陷阱返回后,硬件必须从导致陷阱的指令继续执行。
TLB的内容
为了实现上下文切换的TLB共享,添加了一位地址空间标识符(Address Space Indentifier , ASID)。可以把ASID看做是PID(Process Identifier),但通常比PID少(PID32位,ASID8位)
也可以使得不同的VPN使用相同的PFN,共享同一物理页
分页:较小的表
如何让页表更小?
分页和分段结合
结合的方法不是为进程的整个地址空间提供单个页表,而是为每个逻辑分段提供一个
多级页表
基本思想:
首先,将页表分成页大小的单元。然后,如果整页的页表项无效,就完全不分配该页的页表。为了追踪页表的页是否有效(以及如果有效,它在内存中的位置),使用了名为页目录的新结构。页目录因此可以告诉你页表的页在哪里,或者页表的整个页不包含有效页。
线性页表,即使地址空间的大部分中间区域无效,我们仍然需要为这些区域分配页表空间。
页目录项(Page Directory Entries)至少拥有有效位和页帧号。但这个有效位是指PDE指向的页表至少有一项是有效的。很好地支持稀疏的地址空间。
例子展示
一个大小为16KB的小地址空间,包含64个字节的页。
因此,有一个14位的虚拟地址空间,VPN有8位,偏移量有6位,所以线性表有2^8=256个项
假设每个PTE为4字节,则Page Table为 256*4 Byte=1KB,每页大小为64 Byte,因此Page Table可以分为16个64字节的页,每个页可以容纳16个PTE。
总体步骤:获取VPN,用它索引到页目录,然后再索引到页表的页中。
16个页需要4位来表示,使用VPN的前四位。称为 Page Directory Index,根据下式来计算页目录项(PDE)的地址
PDEAddr = PageDirBase + (PDIndex * sizeof(PDE))
如果页目录项标记为无效(valid = 0 )则访问无效引发异常。如果有效,则需要从页目录项指向的页表的页中获取页表项PTE,页表索引(Page-Table Index)使用VPN剩下四位,公式如下
PTEAddr = (PDE.PFN << SHIFT) + (PDIndex * sizeof(PDE))
从页目录项获得的页帧号必须左移
完整的页目录如下:
一共256个PTE,实际只用存储16个页表项目录加上2个页(128 Bytes),远小于线性表的1 KB
不止两级
假设30位地址空间,每个页512字节,所以需要9位(2^9=512)offset,和21位 Virtual Page Number
假设每个PTE 4字节,所以每页有128个PTE(512/4=128),因此页表索引PTI 需要7位(2^7=128)
剩下14位(21-7=14)的页目录索引PDI,不是128。所以分成2个7位。
不是要把所有地址都存在表里
地址转换过程
展示了每个内存引用在硬件中发生的情况(假设硬件管理的TLB)
在TLB未命中时,硬件才需要执行完整的多级查找。这条路劲上,可以看到传统的两级页表的成本,两次额外的内存访问来查找有效的转换映射
反向页表
用页帧号作为Index来查找逻辑页号
超越物理内存:机制
如何用硬盘透明地提供巨大虚拟地址空间的假象?
交换空间
需要在硬盘上开辟一部分空间用于物理页的移入和移出,一般称这样的空间为交换空间。
存在位
硬件判断页是否在内存中的方法,时通过页表项中的存在位。如果为1,则表示该页存在于物理内存中,并且所有内容都如上述进行。如果存在位位0,则页不在内存中,而是硬盘上。访问不在物理内存中的页,这种行为称为页错误(page fault)
页错误
OS如何知道所需要的页在哪?
许多系统中,页表是存储这些信息最好的地方。因此,OS可以用PTE中的某些位来存储硬盘地址,这些位通常用来存储PFN。当操作系统接收到页错误时,会在PTE中查找地址,并将请求发送到硬盘,将页读取到内存中。
硬盘I/O完成时,操作系统会更新页表,将此页标记为内存,更新页表项(PTE)的PFN字段已记录获取页的内存位置,并重试指令。
I/O时,进程处于阻塞状态。
交换什么时候发生
为保证有少量的空闲内存,大多数OS会设置高水位线(High Watermark,HW)和低水位线(Low Watermark,LW)来帮助决定何时从内存中清除页。
原理:当OS发现有少于LW个页可用时,后台负责释放内存的线程会开始运行,知道有HW个可用的物理页。这个后台线程称为守护进程(swap daemon),然后进入休眠状态。
通过同时执行多个交换进程,可以进行一些性能优化。交换算法需要先检查是否有空闲也,而不是直接执行替换。如果没有空闲页,会通知后台分页线程按需要释放页。当线程释放一定数目的页时,它会重新唤醒原来的线程,然后就可以把需要的页交换进内存,继续工作。
页面置换算法
如何决定换出哪个页?
缓存管理
内存只包含系统中所有页的自己,可以将其视为系统中虚拟内存页的缓存。进行页面置换时,希望缓存命中多(内存中找到待访问页面),缓存未命中少(磁盘获取页的次数)。
平均内存访问时间 AMAT=(PHit * TM)+(PMiss * TD)
TM访问内存的成本,TD访问磁盘的成本,各自的概率相加为1
例子
10次命中9次,未命中1次。访问内存为100ns,访问磁盘为10ms,则
AMAT = (0.9*100ns) + (0.1 *10ms)=1ms
在线代OS中,访问磁盘的成本很高,即使很小的概率未命中页会拉低正在运行程序的总体AMAT
最优替换策略
替换内存中在最远将来才会被访问到的页。
前几次未命中,因为缓存开始是空的,这种称为冷启动未命中,或强制未命中。
FIFO
Belady线性
随机
10000次尝试后大约40%的概率命中
LRU
使用的一个历史信息是频率,如果一个页被访问了很多次,那么不应该被置换。
工作负载
workload不存在局部性时,置换算法差别不大
为了实现LRU,需要做很多工作。在每次页访问(即每次内存访问,不管是取指令还是加载指令还是存储指令)时,我们都必须更新一些数据,从而将该页移动到列表的前面。
时钟置换算法
页面需要进行替换时,OS检查当前指向的页P的使用位是0还是1。如果是0,则意味着最近被使用,不适合被替换,然后P的使用为设为0,时钟指针递增到下一页(P+1)