操作系統導論——CPU虛擬化


CPU虛擬化

目錄

抽象:進程

進程非正式定義:進程就是運行中的程序

OS通過虛擬化CPU來提供這種假象:通過讓一個進程只允許一個時間片,然后切換到其他進程,OS提供了存在多個虛擬化CPU的假象,這也是時分共享CPU技術

抽象:進程

進程的機器狀態:

  1. 它的內存。指令存在內存中。正在進行的程序讀取和寫入的數據也在內存中。因此進程可以訪問的內存是該進程的一部分。
  2. 機器狀態的另一部分是寄存器
  3. 程序也經常訪問持久存儲設備。

進程API

OS接口包含的內容:

創建(create):OS必須包含一些創建新進程的方法。shell中輸入命令或雙擊應用程序。

銷毀(destroy):還提供了一個強制銷毀進程的接口。可以用來停止失控進程。

等待(wait):程序休眠

其他控制(miscellaneous control):例如,大多數OS提供某種方法來暫停進程,然后恢復。

狀態(status):獲得有關進程的狀態信息。例如運行了多長時間,處於什么狀態。

進程創建:更多細節

OS如何啟動並運行一個程序?

OS運行程序必須做的第一件事是將代碼和所有靜態數據(例如初始化變量)加載到內存中加載到進程的地址空間。程序最初以某種可執行格式駐留在磁盤上。

image-20210918100301411

早期OS加載過程盡早完成,即在運行程序之前全部完成。現代OS惰性執行該程序,即僅在程序執行期間需要加載的代碼或數據片段,才會加載。

加載到內存中后,OS在運行此進程之前還要執行一些其他操作。第二件事,必須為程序的運行時棧(run-time stack)分配一些內存。也可能為程序的堆分配一些內存。

OS還將執行一些其他初始化任務,特別是與輸入/輸出相關的任務

總的來說,將代碼和靜態數據加載到內存中,通過創建和初始化棧以及執行與I/O設置相關的其他工作。最后一項任務,啟動程序,在入口處運行,即main()。通過跳轉main()例程,OS將CPU的控制權轉移到新創建的進程中,從而程序開始執行。

進程狀態

進程的三種狀態:

運行(running):在運行狀態下,進程正在處理器上運行。這意味着,它正在執行指令。

就緒(ready):在就緒態下,進程已准備好運行,但由於某種原因,OS選擇不在此時運行。

阻塞(block):在阻塞狀態下,一個進程執行了某種操作,直到發生其他時間時才會准備運行。一個常見的例子是,當進程向磁盤發起I/O請求時,它會被阻塞,因此其他進程可以使用處理器。

image-20210918101903561

數據結構

為了跟蹤每個進程的狀態,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的控制?

基本技巧:受限直接執行

image-20210919094711574

使用正常的調用並返回跳轉到程序的main(),並在稍后回到內核。

問題1:受限制的操作

直接運行非常快,程序直接在硬件CPU上執行。但是進程希望執行某種受限操作怎么辦(I/O請求或獲得更多系統資源)?

采用受保護的控制權轉移:硬件通過提供不同的執行模式來協助操作系統。在用戶態,應用程序不能完全訪問硬件資源。在內核態,OS可以訪問機器的全部資源。還提供了 trap 內核和 return from trap返回到用戶態程序的特別說明,以及一些指令,讓OS告訴硬件陷阱表(trap table)在內存中的位置。

要執行系統調用,程序必須執行特殊的trap指令。返回時,必須確保存儲足夠的調用者寄存器。例如x86上,處理器會將程序計數器、標志和其他一些寄存器推送到每個進程的內核棧(kernel stack)上。

內核通過 trap table 知道在OS內運行什么代碼。

image-20210919101706511

問題2:在進程之間切換

如何重新獲得CPU控制權,以便可以在進程之間切換。

協作方式:等待系統調用

mac os像這樣的系統通常包括一個顯式的 yield 系統調用

協作調度系統中,OS通過等待系統調用,或某種非法操作發送,從而重新獲得CPU的控制權。

非協作方式:操作系統進行控制

進程不協作,如何獲得CPU控制權?

即使進程以非協作方式運行,添加時鍾中斷也讓OS能夠在CPU上重新運行。

時鍾設備可以編程為每隔幾毫米產生一次中斷。產生中斷時,當前進程停止,OS預先配置的中斷程序會運行。此外,OS重新獲得CPU控制權,可以停止當進程並啟動另外一個進程。

保存和恢復上下文

是繼續當前正在運行的進程還是切換到另一個進程,這個決定是由調度程序 scheduler 做出的,它是OS的一部分。

image-20210919105244490

有兩種類型的寄存器保存/恢復

第一次是發生時鍾中斷的時候。在這種情況下,運行進程的用戶寄存器由硬件隱式保存,使用該進程的內核棧。

第二種是當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

image-20210920104736432

如何改變優先級

  • 規則3:工作進入系統時,放在最高優先級(最上層隊列)

  • 規則4a:工作用完整個時間片后,降低其優先級(移入下一個隊列)

  • 規則4b:如果工作在其時間片內主動釋放CPU,則優先級不變

實例1:單個長工作

該工作首先進入最高級隊列,執行10ms時間片后,調度程序將其優先級減1,因此進入Q1。在Q1執行完一個時間片后,進入最低優先級隊列。

image-20210920105312194

實例2:來了短工作

A在最低優先級隊列,B在100時到達,加入最高級隊列,經過兩個時間片運行,B執行完畢,然后繼續A執行

如果不知道工作是短工作還是長工作,那么就在開始的時候假設其是短工作,並賦予最高優先級。如果確實是短工作,則很快會執行完畢,否則將被慢慢移入優先級隊列,這時該工作也被認為是長工作。

實例3:如果有I/O

A為長工作,B為交互型工作,每工作1ms執行IO。

image-20210920105921114

MLFQ的問題

1:如果有許多交互型工作,則會長時間占用CPU,而CPU密集型長工作則得不到服務,會造成飢餓

2:有的程序會使用手段欺騙調度程序,以獲得遠超公平的資源,如果在占用99%的時間片后釋放CPU保持較高優先級隊列

3:一個程序可能在不同時間表現不同。計算密集型進程可能在某段時間表現為一個交互性進程。

提升優先級

周期性地提升所有工作的優先級,簡單的:將所有工作扔到最高優先級隊列

  • 規則5:經過一段時間S,就將系統中所有工作重新加入到最高級隊列

image-20210920110959314

另外的問題是:S的值該如何設置?

更好的計時方式

如何防止調度程序被愚弄?調度程序應該記錄一個進程在某一層中消耗的總時間,而不是在調度時重新計時。只要進程用完了自己的配額,就將其降到低一優先級隊列中去。

  • 規則4:一旦工作用完了其在某一層中的事件配額(無論中間主動放棄多少次CPU),就降低其優先級(移入低一級隊列)

image-20210920123341755

MLFQ調優及其他問題

配置多少隊列?每一層隊列的時間片配置多大?應該多久提升一次進程的優先級?

Solaris默認60層隊列,時間片長度從20ms到幾百ms,每1秒左右提升一次進程的優先級

image-20210920124034773

調度:比例份額

比例份額調度程序有時也稱為公平份額調度程序。調度程序的最終目標是確保每個工作獲得一定比例的CPU時間,而不是優化周轉時間和響應時間。

基本概念:彩票數表示份額

一個進程擁有的彩票數占總彩票數的百分比,就是它占有資源的份額。

假設A75張,B有25張,定時地抽取,以決定運行A或B。

這種隨機方法相對於傳統決策有幾種優勢:

1.不需要記錄任何狀態。傳統的需要記錄每個進程已經獲得了多少CPU時間。

2.快速。

利用隨機性,進程的運行時間從概率上滿足期望的比例

步長調度

系統中的每個工作都有自己的步長,這個值與票數成反比。

eg: A、B、C的票數分別是100、50、250,接下來用一個大數分別除以票數來獲得步長,比如用10000,得到的步長分別為100、200、40.每次進程運行后,會讓計數器增加它的步長,記錄它的總體進展。

image-20210921111441996

問題:同樣需要記錄,而且記錄的是全局狀態。並且如果新進入一個程序,狀態設為0,會獨占CPU。

這兩類調度程序不經常使用。

多處理器調度(高級)

內存虛擬化

抽象:地址空間

早期系統

早期的機器並沒有提供多少抽象給用戶。OS從物理地址0開始 ,進程則是使用剩余的內存。

多道程序和時分共享

多個進程在給定時間准備運行,比如當一個進程在等待I/O操作時,OS會切換這些進程,增加CPU利用效率。人們意識到批量計算的局限性,每個人都在等待它們執行的任務及時響應。

最開始的機器共享:讓一個進程單獨占用全部內存運行一小段時間,然后停止,將它所有的狀態信息保存在磁盤上(包含所有的物理內存),加載其他進程的狀態信息,再運行一段時間。這種方法保存和恢復寄存器級的狀態信息相對較快,但將全部的內存信息保存到磁盤就太慢了。

因此,在進程切換的時候,仍然將進程信息放在內存中,這樣OS可以更有效率地實現時分共享。

隨着時分共享流行,進程的保護變得十分重要,不希望一個進程可以讀取其他進程的內存。

image-20210921123334490

地址空間

地址空間是OS提供的一個易用的物理內存抽象,是運行的程序看到的系統中的內存。

一個進程的地址空間包含運行的程序的所有內存狀態。包含代碼、棧和堆。

image-20210921122958682

當多個線程在地址空間中共存時,沒有像這樣分配的好辦法。

我們描述地址空間時,所描述的時操作系統提供給運行程序的抽象程序不在物理地址0~16kb的內存中,而是加載到任意的物理地址。如上節圖所示,A、B和C加載到內存中的不同地址。

當OS在單一的物理內存為多個運行的進程(所有進程共享內存)構建一個私有的、可能很大的地址空間的抽象,就說OS在虛擬化內存

eg:上節圖的進行A在地址0(將其稱為虛擬地址, virtual address)執行加載操作時,OS確保不是加載到物理地址0,而是物理地址320KB(A載入內存的地址)。

目標

虛擬內存(VM)系統的目標:

  1. 透明。實現虛擬化內存的方式應該讓運行的程序看不見,程序不應該感知到內存被虛擬化的事實。
  2. 效率。OS追求虛擬化盡可能高效,包括時間上(不會使程序運行得更慢)和空間上(不需要太多額外的內存來支持虛擬化)。
  3. 保護。確保進程受到保護,不會受其他進程影響,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,該進程如圖所示

image-20210922114023711

動態(基於硬件)重定位

每個CPU需要兩個硬件寄存器:基址寄存器和界限寄存器。使得能將地址空間放在物理內存的任何位置,同時又能確保進程只能訪問自己的地址空間。

OS決定程序在內存的中的實際加載地址,並將起始地址記錄在基址寄存器中。上述程序運行時,OS決定加載到32KB物理內存,因此基址寄存器的值就為32KB。該進程產生的所有內存引用,都會被處理器通過以下方式轉為物理地址:

physical address = virtual address + base

eg:上述訪問int x

物理地址 = 32KB + 15KB = 47KB,進程中使用的內存引用都是虛擬地址,硬件計算出物理地址,並將其發回給內存系統。

上述界限寄存器被設置為16KB,因為只開辟的16KB的內存空間。如果進程需要訪問超過這個界限或者為負數的虛擬地址,CPU會觸發異常,進程最終可能被終止。

有時我們將CPU的這個負責地址轉換的部分統稱為內存管理單元 MMU。

空閑列表

OS用列表記錄哪些空閑內存沒有使用,以便能夠為進程分配內存。

image-20210922120412195

重定向會造成內部碎片,所以需要更復雜的機制,以便更好地利用物理內存,接下來嘗試將基址加界限的概念稍稍泛化,得到分段的概念。

分段

如果將整個地址空間放入物理內存,那么棧和堆之間的空間並沒有被進程使用,缺依然占用了實際的物理內存。如何支持大地址空間?

分段:泛化的基址/界限

不止一個基址界限對,而是給地址空間內的每個邏輯段一對。一個段只是地址空間里的一個連續定長的區域,在典型的地址空間里有3個邏輯不同的段:代碼、棧和堆。

image-20210923093507572

引用哪個段

硬件如何知道段內的偏移量?以及地址引用了哪幾個段?

顯式的方式:如果用14位虛擬地址的前兩位來標識:00表示代碼,01表示堆,10表示棧

會引起一個段的地址空間被浪費,所以有些系統將棧和堆當作同一個段,用一位來做標識。

反向增長

上圖中棧從28KB增長到26KB,相應虛擬地址從16KB到14KB。於是除了基址和界限外,硬件還需要知道段的增長方向(用一位區分,1代表自小而大增長,0反之)

支持共享

為了節省內存,在地址空間之間共享某些內存段是有用的,因此需要硬件支持。

給每個段增加了幾個位,標識程序是否能夠讀寫該段,或執行其中的代碼。

image-20210923095426976

有了保護位,除了檢查虛擬地址是否越界,硬件還需要檢查特定訪問是否允許

細粒度與粗粒度的分段

分段少-粗粒度

分段多-細粒度,在內存中保存段表

操作系統支持

一個基址界限對和分段的區別:每個地址空間只有一個基址界限對會使得在運行程序的同時,需要把進程所需的代碼塊,棧和堆需要用的空間放到內存當中去,而這些空間是沒有使用的,這樣容易造成內部碎片,許多內存被浪費掉了。而分段則無需給進程分配一整塊的內存,通過給每個代碼段一對基址界限,這樣通過邏輯地址來尋址,可以節省很多內存。

分段帶來的新問題:

OS在上下文切換時應該做什么?每個段寄存器中的內容必須保存和恢復。每個進程都有自己獨立的虛擬地址空間,OS必須在進程運行之前,確保這些寄存器被正確地賦值。

管理物理內存的空閑空間。物理內存很快充滿了許多空閑空間的小洞,很難分配給新的段或擴大已有的段。這種問題稱外部碎片

image-20210923100930669

問題的解決方案

1.緊湊物理內存,重新安排原有的段。例如,OS先終止運行的進程,將它們的數據復制到連續的內存區域中去,改變它們的段寄存器中的值,指向新的物理地址,從而得到了足夠大的連續空間。但是,內存緊湊成本很高,會占用大量的處理器時間,而且應當在什么時候移動?

2.利用空閑列表管理算法,試圖保留大的內存塊用於分配。如最優適配、最壞匹配、首次匹配等等。

小結

分段可以更高效地虛擬內存,避免潛在的內存浪費,更好地支持稀疏地址空間,還可以代碼共享。但是還是不足以支持更一般化的稀疏地址空間。例如,一個很大但是很稀疏的堆,都在一個邏輯段中,整個堆仍然需要完全加載到內存當中去。

空閑空間管理

如何管理空閑空間?

假設

在堆上管理空閑空間的數據結構稱為空閑列表。假設不能進行緊湊,分配程序管理的是連續的一塊字節區域。

底層機制

首先,看看空間分割與合並,其次如何快速並相對輕松地追蹤已分配的空間。最后,討論如何利用空閑區域的內部空間維護一個簡單的列表,來追蹤空閑和已分配的空間。

分割和合並

image-20210923105322453

任何大於10字節的請求都會失敗(返回NULL)

如果申請小於10字節的內存,分配程序會執行分割(splitting)動作:找到一塊可以滿足的空閑空間,將其分割,第一塊返回給用戶,第二塊留在空閑列表中。

分配程序還有另一種機制,合並(coalescing)。如果內存被free,只是簡單地將空閑空間加入到空閑列表中,這樣仍為幾個10字節的空閑空閑,申請20字節的空間依然失敗。所以分配程序在釋放一塊內存時合並可用空間。

追蹤已分配空間的大小

大多數分配程序都會在頭塊(header)中保存一點額外的信息。如果用戶調用了malloc(),並將結果保存在ptr中:ptr = malloc(20)

image-20210923110545494

如果用戶請求N字節的內存,庫不是尋找大小為N的空閑塊,而是尋找N加上頭塊大小的空閑塊

讓堆增長:大多數的分配程序會從很小的堆開始,空間耗盡時,再向OS申請更大的空間.OS再執行sbrk系統調用時,會找到空閑的物理內存頁,將它們映射到請求進程的地址空間中去,並返回新的堆的末尾地址。

基本策略

最優匹配

首先遍歷整個空閑列表,找到和請求大小一樣或更大的空閑塊,然后返回這組候選者中最小的一塊。

選擇最接近用戶請求大小的塊,從而避免了浪費空間,然而,在遍歷查找正確的空閑塊時,要付出較高的代價

最差適配

嘗試找到最大的空閑塊,分割並滿足用戶需求后,將剩余的塊加入空閑列表。

表現非常差,導致過量的碎片,同時還有很高的開銷。

首次匹配

找到第一個足夠大的塊,將請求的空間返回給用戶,同樣,剩余的空閑空間留給后續請求。

有速度優勢,不需要遍歷整個列表,但有時列表開頭有很多小塊。因此,分配程序如何管理空閑列表的順序就變得很重要。一種是基於地址排序。通過保持空閑塊按內存地址有序,合並操作會很容易,從而減少內存碎片

下次匹配

這個算法多維護一個指針,指向上一次查找結束的位置。想法是將對空閑空間的查找操作擴展到整個列表中去,避免對列表開頭頻繁的分割。同樣避免了遍歷列表

分頁:介紹

如何通過頁來實現虛擬內存?

將空間分割成固定長度的分片。把物理內存看成是定長槽塊的陣列,叫做頁幀,每個這樣的頁幀包含一個虛擬內存頁。

例子

image-20210924094110192

圖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

image-20210924100735014

image-20210924113816505

  • 頁映射幀
  • 頁是連續的虛擬內存
  • 幀是非連續的物理內存
  • 不是所有的頁都有對應的幀

通過頁幀來計算物理地址

image-20210924112245998

image-20210924112800118

頁表

頁表用於將虛擬地址(虛擬頁號)映射到物理地址(物理幀號)。

頁表中的有效位(valid bit)用於指示特定地址轉換是否有效。保護位(protection bit)表明頁是否可以讀取、寫入或執行。存在位(present bit)表示該頁是在物理存儲器還是在磁盤上。臟位(dirty bit)表示頁面被帶入內存后是否被修改過。參考位(reference bit,也稱訪問位 access bit)用於追蹤頁是否被訪問。

eg:x86的頁表項

image-20210924102233878

存在位P;是否允許讀寫位(R/W);確定用戶模式進程是否可以訪問該頁面的用戶/超級用戶位(U/S);確定硬件緩存如何為這些頁面工作(PWT/PCD/PAT/G);一個訪問位A;臟位D;頁幀號PFN。

image-20210924115356277

頁表的地址轉換實例

image-20210924120755993

(4,1023)對應物理地址: 2^10*4+1023=5119

分頁:快速地址轉換(TLB)

分頁機制的性能問題:

  • 訪問一個內存單元需要2次內存訪問:一次用於獲取頁表項,一次用於訪問數據
  • 頁表可能很大

因為要使用分頁,就要將內存地址空間切分成大量固定大小的單元頁,並且需要記錄這些單元的地址映射信息。這些映射信息一半存儲在物理內存中,所以在轉換虛擬地址時,分頁邏輯上需要一次額外的內存訪問。每次指令獲取、顯式加載或保存,都要額外讀取一次內存以得到轉換信息,會非常慢。

如何加速地址轉換?

緩存或間接訪問。需要增加地址轉換旁緩沖存儲器(translation-lookaside buffer)TLB(位於CPU內部),就是頻繁發生的虛擬到物理地址轉換的硬件緩存。對每次內存訪問,硬件先檢查TLB,看其中是否有期望的轉換映射,如果有就不用訪問頁表。

image-20210924121656901

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()

img

訪問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位)

img

也可以使得不同的VPN使用相同的PFN,共享同一物理頁

分頁:較小的表

如何讓頁表更小?

分頁和分段結合

結合的方法不是為進程的整個地址空間提供單個頁表,而是為每個邏輯分段提供一個

多級頁表

基本思想:

首先,將頁表分成頁大小的單元。然后,如果整頁的頁表項無效,就完全不分配該頁的頁表。為了追蹤頁表的頁是否有效(以及如果有效,它在內存中的位置),使用了名為頁目錄的新結構。頁目錄因此可以告訴你頁表的頁在哪里,或者頁表的整個頁不包含有效頁。

image-20210925092043446

線性頁表,即使地址空間的大部分中間區域無效,我們仍然需要為這些區域分配頁表空間。

頁目錄項(Page Directory Entries)至少擁有有效位和頁幀號。但這個有效位是指PDE指向的頁表至少有一項是有效的。很好地支持稀疏的地址空間。

image-20210925095945106

例子展示

一個大小為16KB的小地址空間,包含64個字節的頁。

image-20210925101813094

因此,有一個14位的虛擬地址空間,VPN有8位,偏移量有6位,所以線性表有2^8=256個項

image-20210925102938626

假設每個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))

image-20210925104819557

從頁目錄項獲得的頁幀號必須左移

完整的頁目錄如下:

image-20210925104354680

一共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位。

image-20210925112000562

不是要把所有地址都存在表里

image-20210925100137448

地址轉換過程

展示了每個內存引用在硬件中發生的情況(假設硬件管理的TLB)

在TLB未命中時,硬件才需要執行完整的多級查找。這條路勁上,可以看到傳統的兩級頁表的成本,兩次額外的內存訪問來查找有效的轉換映射

image-20210925112523748

反向頁表

用頁幀號作為Index來查找邏輯頁號

image-20210925114115927

超越物理內存:機制

如何用硬盤透明地提供巨大虛擬地址空間的假象?

交換空間

需要在硬盤上開辟一部分空間用於物理頁的移入和移出,一般稱這樣的空間為交換空間。

image-20210925122128679

存在位

硬件判斷頁是否在內存中的方法,時通過頁表項中的存在位。如果為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不存在局部性時,置換算法差別不大

image-20210925133220348

為了實現LRU,需要做很多工作。在每次頁訪問(即每次內存訪問,不管是取指令還是加載指令還是存儲指令)時,我們都必須更新一些數據,從而將該頁移動到列表的前面。

時鍾置換算法

頁面需要進行替換時,OS檢查當前指向的頁P的使用位是0還是1。如果是0,則意味着最近被使用,不適合被替換,然后P的使用為設為0,時鍾指針遞增到下一頁(P+1)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM