計算機面試重難點 之 作業系統

WINLSR發表於2021-08-19

使用者態與核心態?哪些操作會導致使用者態切換到核心態?

使用者態與核心態是指作業系統兩種執行級別。作業系統核心的功能與服務(程式)執行在核心態,例如:程式管理、記憶體管理、裝置管理、檔案管理等;使用者程式只能訪問使用者程式碼和資料,當使用者程式要訪問核心態級別的功能與服務(程式)時,需要通過系統呼叫來實現。

通常,系統呼叫異常外設中斷會導致使用者態到核心態的切換:

系統呼叫:這是使用者態程式主動要求切換到核心態的一種方式使用者態程式通過系統呼叫申請使用作業系統提供的服務程式完成工作。例如Linux系統中常見的forkopenreadwriteclose等系統呼叫。

異常:當CPU在執行執行在使用者態下的程式時,發生了某些事先不可知的異常,這時會觸發由當前執行程式切換到處理此異常的核心相關程式中,也就轉到了核心態,比如缺頁異常。

外設中斷:當外圍裝置完成使用者請求的操作後,會向CPU發出相應的中斷訊號,這時CPU會暫停執行下一條即將要執行的指令轉而去執行與中斷訊號對應的處理程式,如果先前執行的指令是使用者態下的程式,那麼這個轉換的過程自然也就發生了由使用者態到核心態的切換。比如硬碟讀寫操作完成,系統會切換到硬碟讀寫的中斷處理程式中執行後續操作等。

程式與執行緒的區別?

簡單來說,程式可以理解為程式碼在計算機上的一次完整的執行過程,一個程式通常包含多個執行緒,執行緒的出現是為了進一步提高程式併發執行的程度。

就資源分配來說: 程式是資源分配的基本單位;執行緒不擁有資源,但可以共享程式資源。
就CPU排程來說: 執行緒是CPU排程的基本單位,同一程式中的執行緒切換,不會引起程式切換;不同程式中的執行緒切換,會引起程式切換。
就係統開銷來說: 程式建立和銷燬時,系統都要單獨為它分配和回收資源,開銷遠大於執行緒的建立和銷燬;程式的上下文切換需要儲存更多的資訊,執行緒(同一程式中)的上下文切換只需要儲存執行緒的私有資料:棧、程式計數器(PC)等,系統開銷更小。
通訊方式: 程式擁有各自獨立的地址空間,程式間的通訊需要依靠IPC機制;執行緒由於共享程式資源,因此可以通過訪問共享資料進行通訊,通訊非常方便,但需要解決好共享全域性變數的同步與互斥。

程式的基本狀態?

程式有五種基本狀態:建立狀態、就緒狀態、執行狀態、阻塞狀態、終止狀態。狀態的轉換關係如下:

image-20210808162909665

建立狀態:為程式建立PCBProcess Control Block,程式控制塊。它是作業系統為了管理程式設定的一個專門的資料結構)並分配除CPU時間片以外的必要資源;

就緒狀態:當程式已分配到除CPU以外的所有必要資源後,只要再獲得CPU,便可立即執行,這種狀態稱為就緒狀態;

執行狀態:程式處於就緒狀態被排程後,程式進入執行狀態;

阻塞狀態:正在執行的程式由於某些事件(如:I/O請求)而暫時交出CPU資源,無法繼續執行,程式受到阻塞,進入阻塞狀態;

終止狀態:程式自然結束,或出現錯誤,被系統終釋放資源後進入終止狀態,無法再執行。

程式排程演算法有哪些?

先到先服務 (FCFS) 排程演算法 : 從就緒佇列中選擇⼀個最先進⼊該佇列的程式為其分配 CPU資源並使之執行。有利於長作業,但不利於短作業,短作業會因為前面的長作業長時間執行而遲遲得不到排程。同時,I/O密集型作業會因為多次被阻塞而多次重新排隊。

短作業優先 (SJF) 排程演算法: 從就緒佇列中選出⼀個估計運⾏時間最短的程式為其分配 CPU資源並使之執行。不利於長作業,如果新的短作業不斷到達就緒佇列,長作業會一直不能被排程。

優先順序排程排程演算法: 為每個程式設定優先順序,⾸先執⾏⾼優先順序的程式,相同優先順序的程式按照先來先服務的策略排程。

時間⽚輪轉排程演算法 : 按照先來先服務的策略依次排程程式,每個程式執行固定時間片後被重新放入隊尾。

多級反饋佇列排程演算法 :前⾯介紹的⼏種程式排程演算法都有⼀定的侷限性,⽽該演算法可以兼顧高優先順序以及長、短作業。該演算法由高到低設定多個優先順序不同的佇列,通常優先順序越高的佇列設定的時間片越小。該演算法按照時間片輪轉演算法先排程高優先順序佇列裡的程式,若高優先順序佇列中已沒有需要排程的程式,則依次排程次優先順序佇列中的程式。高優先順序佇列中的程式如果被排程執行一個時間片的大小後仍沒有完成,則依次放入次優先順序佇列中。如果低優先順序佇列中的程式被排程執行時高優先順序佇列中又有新的程式到達,那麼執行完當前時間片後,CPU會馬上分配給新到達高優先佇列中的程式。

作業系統的僵死程式和孤兒程式的區別?

Linux系統中,子程式由父程式建立,子程式退出後雖然會釋放部分資源,但程式描述等資源並沒有被釋放,需要父程式呼叫wait(會阻塞父程式)waitpid(可以為非阻塞)來釋放,可以方便父程式拿到子程式的終止狀態。

殭屍程式:當程式退出之後,他的父程式沒有通過呼叫waitwaitpid回收他的資源,該程式會繼續停留在系統的程式表中,佔用核心資源,這樣的程式就是殭屍程式。通過ps命令顯示的殭屍程式狀態為Z(zombie)。大量殭屍程式沒被回收會導致資源浪費,更致命的是他們會佔用大量程式號,導致系統無法給新程式分配程式號。

孤兒程式:程式結束後,它的一個或多個子程式還在執行,那麼這些子程式就是孤兒程式。孤兒程式如果沒有被自己所在的程式組收養,就會作為init(PID = 1)程式的子程式,他的的資源會由init程式回收。

殭屍程式在其父程式退出後轉為孤兒程式。

如何解決殭屍程式:

  1. 通過kill -9 殺掉其父程式,僵死程式就可以轉為孤兒程式,進而被init程式回收;
  2. 由於程式退出時會向父程式傳送SIGCHLD訊號,因此,可以在父程式捕獲該訊號並通過waitwaitpid釋放子程式資源。

孤兒程式與殭屍程式

殭屍程式與孤兒程式

什麼是死鎖?死鎖產生的必要條件?

死鎖:在許多應用中程式需要以獨佔的方式訪問資源,當多個程式併發執行時可能會出現相互等待對方所佔用的資源而都無法繼續向下執行的現象,此現象稱為死鎖

死鎖產生的四個必要條件(發生死鎖時,一定會有以下條件成立):

  • 互斥條件:一個資源只能被一個程式佔有,程式應互斥且排他的使用這些資源。

  • 請求與保持條件:程式在請求資源得不到滿足而等待時,不釋放已佔有的資源。

  • 不可剝奪條件:程式已經佔有的資源,除非程式自己釋放,其他程式不能強行剝奪 。

  • 迴圈等待條件:若干程式之間形成一種首位尾相接的環形等待資源關係。

處理死鎖的基本策略和常用方法?

預防死鎖(破壞四個必要條件):

  • 並不是所有應用場景都可以破壞互斥條件。案例:SPOOLing技術將一臺獨享印表機改造為可供多個使用者共享的印表機。(破壞互斥條件 )

  • 當程式在執行前一次申請完他所需要的全部資源,在他的資源未滿足前,不讓他投入執行。(破壞請求和保持條件)

  • 給不同程式設定優先順序,當某個高優先順序程式需要的資源被其它程式佔有的時候,可以由作業系統協助將想要的資源強行剝奪。(破壞不可剝奪條件)

  • 給資源編號,程式必須按照編號從小到大的順序申請自己所需資源。(破壞迴圈等待條件)

避免死鎖(銀行家演算法):

預防死鎖的幾種策略,會嚴重地損害系統效能。在避免死鎖時,要施加較弱的限制,從而獲得較為滿意的系統效能。具有代表性的避免死鎖演算法是銀行家演算法

銀行家演算法的實質就是要設法保證系統動態分配資源後不會進入不安全狀態,以避免可能產生的死鎖。 即每當程式提出資源請求且系統的資源能夠滿足該請求時,系統將判斷滿足此次資源請求後系統狀態是否安全,如果判斷結果為安全,則給該程式分配資源,否則不分配資源,申請資源的程式將阻塞。

銀行家演算法所需資料結構

  1. Available[j] 向量:系統中可利用的各種資源數目

  2. Max[i, j] 矩陣:每個程式對每種資源的最大需求

  3. Allocation[i, j] 矩陣:每個程式已分配的各類資源的數目

  4. Need[i, j] 矩陣:每個程式還需要的各類資源數

其中三個矩陣間存在下述關係:

Need[i, j] = Max[i, j] - allocation[i, j]

銀行家演算法流程

Request是第i個程式P的請求向量,如果Request[j] = K,表示程式P需要Kj型別的資源。當P發出資源請求後,系統按下述步驟進行檢查:

  1. Request[j] <= Need[i, j],轉向2,否則認為出錯(因為它所需的資源數目已超過它所宣佈的最大值)。

  2. Requesti[j] <= Available[j],轉向3,否則須等待(表現為程式P受阻)。

  3. 系統嘗試把資源分配給程式P,並修改下面資料結構中的數值:

Available[j] = Available[j] – Request[j]
Allocation[i, j] = Allocation[i, j] + Request[j]
Need[i, j] = Need[i, j] –Request[j]

  1. 試分配後,執行安全性演算法,檢查此次分配後系統是否處於安全狀態。若安全,才正式分配;否則,此次試分配作廢,程式P等待。

安全性演算法步驟:

檢査當前的剩餘可用資源是否能滿足某個程式的最大需求,如果可以,就把該程式加入安全序列,並把該程式持有的資源全部回收不斷重複上述過程,看最終是否能讓所有程式都加入安全序列。

注:只要能找出一個安全序列,系統處於安全狀態。當然,安全序列可能有多個。如果分配了資源之後,系統中找不出任何安全序列,系統就進入了不安全狀態。

詳解作業系統之銀行家演算法(附流程圖)

死鎖檢測和死鎖解除:

如果系統中既不採用預防死鎖也不採用避免死鎖的措施,系統就很有可能發生死鎖。這種情況下,系統應當提供兩種演算法。

  1. 死鎖檢測演算法:用於檢測系統狀態,確定系統中是否已經發生了死鎖。

    • 所需資料結構:資源分配圖,又叫資源有向圖,如下:

      image-20210811174518234

      圓圈代表一個程式,方框代表一類資源,方框內的圓圈代表該類資源的一個單位的資源。從程式到資源的有向邊為請求邊,表示該程式申請一個單位的該類資源;從資源到程式的邊為分配邊,表示該類資源已有一個資源分配給了該程式。圖中,程式P1已經分得了兩個R1資源,請求了一個R2資源;程式P2分得了一個R1資源和一個R2資源,並又請求了一個R1資源。

    • 演算法流程:嘗試將滿足執行條件的程式有向邊消去以簡化資源分配圖,如果能簡化說明系統佔時沒有出現死鎖。如果此時系統的資源分配圖是不可簡化的,那麼此時發生了系統死鎖(死鎖定理)

      資源分配圖簡化例項:

      image-20210811185827076

      按照死鎖定理中,找出的程式為P1,因為它申請的資源可以被滿足,說明(a)時刻沒有發生死鎖。

  2. 死鎖解除演算法:該演算法可將系統從死鎖中解脫出來。

    • 資源剝奪法:將一些死鎖程式暫時掛起來,並且搶佔它的資源,並將這些資源分配給其他的死鎖程式 ,要注意的是應該防止被掛起的程式長時間得不到資源而處於飢餓狀態。
    • 撤銷程式法:強制撤銷部分甚至全部死鎖並剝奪這些程式的資源。撤銷的原則可以按照程式優先順序和撤銷程式的代價高低進行。
    • 程式回退法:讓一或多個程式回退到足以迴避死鎖的地步,程式回退時自願釋放資源而非被剝奪。這個方法要求系統保持程式的歷史資訊,並設定還原點。

死鎖的檢測及解除

程式間通訊(IPC)的方式有哪些?

  1. 管道(pipe)

    管道可用於具有親緣關係的程式間的通訊,通常指父子程式之間;同時,管道是一種半雙工的通訊方式,資料只能單向流動。所謂管道,實際是核心管理的一串快取,生命週期隨程式的建立而建立,隨程式的結束而銷燬。

    Linux 系統(一種 UNIX 系統)中,可以在C程式碼中呼叫pipe系統呼叫建立管道並通訊:

    #include <unistd.h>
    int pipe(int pipedes[2]); // 建立管道
    

    成功返回0,失敗返回-1pipedes[0]指向管道的讀端,pipedes[1]指向管道的寫端。使用時,先建立管道並得到兩個分別指向管道兩端的檔案描述符,父程式通過fork函式建立子程式,然後子程式也有兩個檔案描述符分別指向同一管道的兩端;父程式通過 close(pipedes[0]) 關閉管道讀端,子程式通過close(pipedes[1])關閉管道寫端;父程式通過write(pipedes[1], ... )系統呼叫往管道里寫,子程式通過read(pipedes[0], ...)系統呼叫從管道里讀。(這裡是父寫子讀,也可以反過來)

    備註:

    標頭檔案 unistd.h 意為:unix std,其提供了訪問 POSIX 作業系統 API 的功能。類似於 Windows 系統提供的 windows.h

    POSIXPortable Operating System Interface of UNIX,可移植作業系統介面,是 IEEE 為了方便在各種 UNIX 系統之間移植軟體而定製的標準。

    示例:

    #include <stdio.h>
    #include <unistd.h>
    #include <string.h>
    
    int main()
    {
        int pipedes[2];
    
        if (pipe(pipedes))
        {
            perror("pipe() fail");
            return -1;
        }
    
        pid_t pid = fork();
        if (pid < 0)
        {
            perror("fork() fail");
            return -2;
        }
    
        // child process
        if (pid == 0)
        {
            close(pipedes[0]);
            char str[] = "Hello, parent!";
            //這裡用沒有傳送結束符'\0'
            int len = write(pipedes[1], str, strlen(str));
            printf("write len : %d \n", len);
            return 0;
    
        // parent process
        }
        else
        {
            close(pipedes[1]);
            char buf[1024];
            int len = read(pipedes[0], buf, sizeof(buf));
            //手動新增上結束符
            buf[len] = '\0';
            printf("read message from child process : %s , length %d \n", buf, len);
            return 0;
        }
    }
    
  2. 命名管道(named pipe)

    命名管道克服了管道沒有名字的限制,除具有管道所具有的功能外,它還允許無親緣關係程式間的通訊。命名管道提供一個路徑名與之關聯,以檔案形式儲存檔案系統中,只要程式可以訪問該路徑,就可以通過該命名管道相互通訊。

    Linux系統中,可以在shell中通過mkfifo命令建立命名管道;也可以在C原始碼中通過呼叫 int mkfifo(const char * pathname, mode_t mode)系統呼叫建立,第一個參表示命名管道路徑,第二個表示檔案許可權,通常為0666(可讀可寫)。要藉助該命名管道通訊的程式在原始碼通過openwritereadclose系統呼叫配合進行通訊。(命名管道也是半雙工的)

    示例:

    1. shell 中執行命令 mkfifo mypipe,會建立一個名為mypipe的命名管道。

    2. 建立 namepipe_test_write.c 作為寫端:

      #include <stdio.h>
      #include <string.h>
      #include <fcntl.h>
      #include <unistd.h>
      
      // 一次傳輸的字元個數不超過127個字元,最後一位存'\0'
      #define LEN 128
      
      int main() 
      {
          int filedes = open("./mypipe", O_WRONLY);
      
          char buf[LEN] = {'\0'}; 
      
          while (1) {
              printf("please input message: \n");
      
              int i = 0;
              while (i < LEN - 1) {
                  char ch = getchar();
      
                  if (ch == '\n') {
                      break;
                  }
      
                  buf[i++] = ch;
              }
              //手動新增字串結束符
              buf[i] = '\0';
      
              if (strcmp(buf, "quit") == 0) {
                  break;
              }
      
              int len = write(filedes, buf, strlen(buf));
              printf("write len : %d \n", len);
              printf("-----------------------------------\n");
          }
      
          close(filedes);
          return 0;
      }
      
    3. 建立namepipe_test_read.c作為讀端:

      #include <stdio.h>
      #include <string.h>
      #include <fcntl.h>
      #include <unistd.h>
      
      int main() 
      {
          int filedes = open("./mypipe", O_RDONLY); 
      
          char buf[128] = {'\0'};
          while (read(filedes, buf, sizeof(buf)) > 0) 
          {
              printf("read from pipe : \n%s\n", buf);
              memset(buf, '\0', sizeof(buf));
              printf("-----------------------------------\n");
          }
          
          close(filedes);
          return 0;
      }
      
    4. 編譯後分別在兩個終端中執行,無論誰先啟動都行。當兩個程式都啟動後,寫端才會列印出提示符,表示可以開始通訊。在寫端輸入字串並回車後,讀端會讀取並列印。當寫端輸入quit字串時,通訊結束。詳情如下圖:(編譯執行環境:w10 wsl2(ubuntu 20.04) image-20210814154302987

  3. 訊號(signal)

    訊號是在軟體層次上對中斷機制的一種模擬,是一種非同步通訊方式。訊號用於通知接收程式有某種事件發生,接收到該訊號的程式會相應的採取一些行動。

    Linux系統中,訊號在signal.h中定義,訊號的名稱都以SIG開頭,如:

    • SIGINT:終止訊號 (ctrl + c)

    • SIGQUIT:退出訊號 (ctrl + \)

    • SIGSTOP:暫停訊號 (ctrl + z )

    • SIGSCONT:繼續訊號 (ctrl + z)

    • SIGALRM:鬧鐘訊號,常用作定時器

    • SIGCHLD:子程式狀態改變,父程式收到訊號

    • SIGKILL:殺死訊號( kill -9 pid)

    訊號傳送:通常,使用者可以通過按鍵、呼叫kill命令、或在C原始碼中呼叫int kill(pid_t pid, int sig)系統呼叫來向另一個程式傳送訊號。

    訊號處理:接收程式可以通過signalsigaction函式註冊對訊號的處理方式。如:忽略(SIGKILLSIGSTOP不能被忽略)、預設處理、自定義處理。

  4. 訊息佇列(message queue)

    訊息佇列本質上是位於核心空間的連結串列,連結串列的每個節點都是一條訊息,每一條訊息都有自己的訊息型別,這個訊息型別是由傳送方和接收方約定。不同於管道,從訊息佇列讀取資料時不一定要以先進先出的次序讀取,可以按訊息的型別讀取,藉此可以實現提前檢視緊急訊息的功能。訊息佇列的生命週期是伴隨著核心的,如果沒有手動釋放訊息佇列,他會直到作業系統關閉才釋放。

    Linux系統中,利用C語言,通過呼叫int msgget(key_t key, int msgflg)系統呼叫建立(不存在時)訊息佇列並得到訊息佇列的唯一識別符號。兩個需要通訊的程式中,只要傳入msgget中的key相同,他們得到的訊息佇列識別符號就相同。key通常使用key_t ftok(const char *pathname, int proj_id)系統呼叫來獲得。程式獲得訊息佇列唯一識別符號後可以使用int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg)ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg)系統呼叫分別向訊息佇列傳送訊息和從訊息佇列接收訊息。

    示例:

    msg.h:定義訊息格式

    #ifndef _MSG_
    #define _MSG_
    
    typedef struct msg
    {
        long msgType;      //必須是long型且是結構體第一個變數
        char message[128]; //型別任意
        //...可以有更多資料
    } Msg;
    
    #endif
    

    msg_queue_test_write.c:傳送端

    #include <stdio.h>
    #include <string.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    #include <errno.h>
    #include "msg.h"
    
    int main()
    {
        key_t key = ftok("./", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
            return -1;
        }
    
        //建立(不存在時)訊息佇列並得到訊息佇列的唯一識別符號, 0666表示許可權
        int msgqid = msgget(key, IPC_CREAT | 0666);
        if (msgqid == -1)
        {
            perror("msgget() fail");
            return -2;
        }
    
        //傳送型別 1 ~ 5 共 5 種型別的訊息
        for (int i = 1; i < 6; i++)
        {
            Msg msg;
    
            msg.msgType = i;
            strcpy(msg.message, "Hello : ");
    
            //四位元組int型轉化為字串長度最多為12:一個符號 + 10個數字 + 一個'\0'結束符
            char tmp[12] = {'\0'};
            sprintf(tmp, "%d", i);
    
            //將i轉化為字串並拼接至message
            strcat(msg.message, tmp);
    
            //最後一個引數:阻塞方式傳送訊息,如果訊息佇列沒有空間接收新傳送的訊息則阻塞
            int flag = msgsnd(msgqid, &msg, sizeof(msg) - sizeof(long), 0);
            if (flag == -1)
            {
                printf("msgsnd(): send type: %d failed: %s", i, strerror(errno));
            }
        }
    
        return 0;
    }
    
    

    msg_queue_test_read.c:接收端

    #include <stdio.h>
    #include <string.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>
    #include <errno.h>
    #include "msg.h"
    
    int main()
    {
        key_t key = ftok("./", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
            return -1;
        }
    
        //建立(不存在時)訊息佇列並得到訊息佇列的唯一識別符號, 0666表示許可權
        int msgqid = msgget(key, IPC_CREAT | 0666);
        if (msgqid == -1)
        {
            perror("msgget() fail");
            return -2;
        }
    
        Msg msg;
        // 第二個引數:指明訊息長度(不包含訊息型別)
        // 倒數第二個引數: 大於0時表示獲取制定型別的訊息,如果等於0表示獲取最前面的訊息
        // 最後一個引數:如果訊息佇列為空就阻塞,直到讀取到訊息
        int len = msgrcv(msgqid, &msg, sizeof(Msg) - sizeof(long), 0, 0);
        if (len < 0)
        {
            perror("msgrcv() fail");
        }
    
        printf("receive %d length: %s \n", len, msg.message);
    
        return 0;
    }
    

    編譯後在兩個終端中分別開啟讀端和寫端(無論先後),訊息佇列開始為空,如果先啟動讀端,讀端會被阻塞,直到寫端寫了資料後,讀端讀取到資料了才會退出。該例中,寫端一次寫入五個訊息,讀端可以成功讀取五次,第六次讀取會被阻塞,直到訊息佇列又有新的訊息到達。如下:(編譯執行環境:ubuntu 20.14wsl2(ubuntu 20.14)中編譯可以通過,但執行時提示沒有相應實現

    image-20210818175832536

    以上程式碼並沒有釋放訊息佇列(msgctl(msgqid, IPC_RMID, 0)函式可以釋放),因此,程式執行結束後,通過命令ipcs -q仍然可以看到我們建立的訊息佇列(系統重啟後消失):

    image-20210818180905534

    最後,我們可以通過命令ipcrm -q msqid釋放訊息佇列。

    訊息佇列

  5. 共享記憶體(shared memory)

    不同程式擁有各自獨立的邏輯地址空間,不能相互訪問。共享記憶體通過將一塊實體記憶體對映到不同程式的邏輯地址空間,使得他們可以訪問同一塊實體記憶體,從而實現共享記憶體。訪問共享記憶體區域和訪問程式獨有的記憶體區域一樣快,讀取和寫入的過程中不用像管道和訊息佇列那在使用者態與核心態之間拷貝資訊,因此,共享記憶體是最快的程式間通訊形式。由於多個程式共享同一塊記憶體區域,為保證正確通訊,需要藉助同步機制,如:訊號量,來進行同步。

    Linux系統中,利用C語言,通過呼叫int shmget(key_t key, size_t size, int shmflg)系統呼叫建立(不存在時)共享記憶體並得到共享記憶體的唯一識別符號,其中key和訊息佇列中提到的key相同。程式獲得共享記憶體唯一識別符號後通過呼叫void *shmat(int shm_id, const void *shm_addr, int shmflg)系統呼叫建立(attach)使用者程式空間到共享記憶體的對映,得到指向共享記憶體的指標。根據該指標就可以利用系統讀寫函式向共享記憶體中寫資料或者從共享記憶體讀取資料。通訊完成後,程式通過呼叫int shmdt(const void *shmaddr)函式解除(detach)對映關係,shmaddr引數是之前呼叫shmat時的返回值(指向共享記憶體的指標)。

    示例:

    shared_memory_test_write.c:寫端

    #include <stdio.h>
    #include <sys/ipc.h>
    #include <sys/shm.h>
    
    int main()
    {
        //根據路徑和指定的id生成唯一的key
        key_t key = ftok("/", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
        }
    
        //建立(不存在時)大小為1024KB的共享記憶體,0666代表許可權
        int shmid = shmget(key, 1024, IPC_CREAT | 0666);
        if (shmid == -1)
        {
            perror("shmget() fail");
        }
    
        //attach,將共享記憶體對映到當前程式
        //第二個引數:共享記憶體連線到當前程式中的地址,通常為0,表示讓系統來選擇
        //第三個引數:shm_flg是一組標誌位,通常為0
        //返回共享記憶體地址
        void *shm = shmat(shmid, 0, 0);
        if (shm == (void *)-1)
        {
            perror("shmat() fail");
        }
    
        //將鍵盤輸入的資料寫入共享記憶體
        fgets(shm, 1024, stdin);
    
        //detach,把共享記憶體從當前程式中分離出去
        int flag = shmdt(shm);
        if (flag == -1)
        {
            perror("shmdt() fail");
        }
    
        return 0;
    }
    

    shared_memory_test_read.c:讀端

    #include <stdio.h>
    #include <sys/ipc.h>
    #include <sys/shm.h>
    
    int main()
    {
        //根據路徑和指定的id生成唯一的key
        key_t key = ftok("/", 2021);
        if (key == -1)
        {
            perror("ftok() fail");
        }
    
        //建立(不存在時)大小為1024KB的共享記憶體,0666代表許可權
        int shmid = shmget(key, 1024, IPC_CREAT | 0666);
        if (shmid == -1)
        {
            perror("shmget() fail");
        }
    
        //attach,將共享記憶體對映到當前程式
        //第二個引數:共享記憶體連線到當前程式中的地址,通常為0,表示讓系統來選擇
        //第三個引數:shm_flg是一組標誌位,通常為0
        //返回共享記憶體地址
        void *shm = shmat(shmid, 0, 0);
        if (shm == (void *)-1)
        {
            perror("shmat() fail");
        }
    
        //將鍵盤輸入的資料寫入共享記憶體
        fputs(shm, stdout);
    
        //detach,把共享記憶體從當前程式中分離出去
        int flag = shmdt(shm);
        if (flag == -1)
        {
            perror("shmdt() fail");
        }
    
        return 0;
    }
    

    編譯後先執行寫端,輸入hello world回車後程式退出;然後執行讀端,程式讀取並列印出hello world。如下:(編譯執行環境:ubuntu 20.14

    image-20210819091913414

    以上程式碼並沒有釋放共享記憶體( shmctl(shmid, IPC_RMID, 0)可以釋放),因此,程式執行結束後,通過命令ipcs -m仍然可以看到我們建立的共享記憶體(系統重啟後消失)。最後,我們可以通過命令ipcrm -m shmid釋放共享記憶體。

    程式間的7種通訊方式(含例程程式碼)

    Linux下程式間通訊方式——共享記憶體

  6. 訊號量(semaphore)

    訊號量的原理是一種資料操作鎖的概念,可用於多個程式間的同步。它本身並不具備資料交換的功能,而是通過控制其他的通訊資源來實現程式間通訊。我們可以將其理解成一個具有原子性的計數器,每當有程式申請使用訊號量,通過一個P操作來對訊號量進行-1操作,當計數器減到0的時候就說明沒有資源了,其他程式繼續訪問就會被阻塞,當該程式執行完這段工作釋放臨界資源之後,就會執行V操作來對訊號量進行+1操作,被阻塞的程式就會被喚醒。

    Linux系統中,利用C語言,通過呼叫int semget(key_t key, int nsems, int semflg)系統呼叫建立(不存在時)訊號量並得到訊號量的唯一識別符號,其中key和前述key相同。

    可以通過命令ipcs -s檢視系統當前存在的訊號量,通過命令ipcrm -s semid可以釋放訊號量。

    程式間的通訊之訊號量

    用訊號量為共享記憶體新增同步機制

  7. 套接字(socket)

    更為一般的程式間通訊機制,可用於執行在不同主機上的程式之間通訊。他通過IP地址埠號確定一臺主機上一個程式,常用於網路程式設計。

備註:以上這些通訊方式並不是所有的作業系統都提供了實現,即使作業系統提供了實現,程式語言也不一定提供了訪問的介面。以上訊息佇列、共享記憶體和訊號量都是基於System V規範的(另一種實現是基於POSIX規範的)。

常見記憶體管理方式有哪些?

記憶體管理機制分為連續分配管理方式和非連續分配管理方式。前者為程式分配一個連續的記憶體空間,是古老的記憶體管理方式,常見的有單一連續分配固定分割槽分配等。後者是充分利用離散的記憶體,將程式分散的裝入記憶體分割槽中;根據分割槽大小是否固定可以分為頁式管理(固定分割槽大小)和段式管理(不固定分割槽大小);還可以二者混用成段頁式管理

什麼是分頁儲存管理?什麼是分段儲存管理?區別是什麼?

分頁儲存管理:

分頁儲存管理極大的提高了記憶體利用率,他將實際的實體記憶體分為大小相等的塊,通常被稱為頁幀。頁式管理將使用者程式所需記憶體以離散的頁幀形式分配給他們。每個使用者程式都有自己的邏輯地址空間,邏輯空間也被劃分為與頁幀大小相同的頁面,邏輯頁面和物理頁幀是一一對應的關係。

那麼CPU定址時,是如何完成邏輯地址到實際物記憶體地址的轉換呢?首先要知道,邏輯地址被劃分為高位和低位,高位代表當前邏輯地址所在頁面對應的頁號,低位代表的是頁內偏移,以32位作業系統來說,他的邏輯地址共有32位,如果頁面(由頁幀大小決定)大小為4KB(4 * 1024 = 212注:地址單位為B,位元組。)則需要佔用低12位來表示頁內偏移。顯然,CPU僅僅藉助邏輯地址是無法完成定址的,還需要藉助程式頁表才能完成邏輯地址到實體地址的轉換,頁表中記錄的是頁面和頁幀的對應關係。開始定址時,CPU根據邏輯地址得到頁號和頁內偏移,查詢頁表可得到頁號對應頁幀在實體記憶體中的起始地址,頁幀起始地址加上頁內偏移即可得到實際的實體地址。如下圖:

image-20210817111039787

分段儲存管理

分頁儲存是從計算機的角度進行設計的,目的是為了提高記憶體的利用率,每一頁面並沒有實際意義。段式儲存管理從程式設計師和使用者角度出發,把程式分割成具有邏輯意義的段,例如:主程式段、子程式段、資料段等等,每一段的大小不定。段式管理將使用者程式所需記憶體以離散的記憶體段的形式分配給她們。藉助段式管理容易實現資料的共享與保護。

那麼分段儲存管理中,CPU又是如何完成定址的呢?分段儲存管理中,邏輯地址同樣被劃分為高位和低位,高位表示段號,低位表示段內偏移。僅僅根據段號和段內偏移尚無法完成定址,還需要藉助程式段表,段表記錄了邏輯段的大小(段長)以及邏輯段在記憶體中的起始地址(基址)。開始定址時,CPU先拿到指明的段號和段內偏移(由於段長不定,段號和段內偏移無法像分頁管理那樣根據邏輯地址和頁面大小直接計算出來頁號和頁內偏移,需要指明邏輯地址中哪部分表示段號,哪部分表示段內偏移,這也是段式管理中邏輯地址是二維的原因),繼續查詢段表可以得到邏輯段的基址(段表中的段長是用來檢查當前段的段內偏移是否超過段長而發生越界),基址加上段內偏移即可得到實際的實體地址。如下圖:

image-20210817111107322

分頁管理和分段管理區別:

  • 分頁管理是站在計算機角度進行設計,每一頁並無邏輯意義,目的是減少外部碎片,提高記憶體的利用率,對使用者不可見;段式管理站在程式設計師和使用者角度,是一種邏輯上的劃分,是為了滿足使用者的需要,對使用者是可見的,程式設計時需要指明段名和段內地址(組合語言中指明瞭段名和段內地址就指明瞭邏輯地址的段號和段內偏移)。
  • 分頁管理中,頁面大小是固定的;而分段管理中段的大小取決於具體程式程式碼段,是變化的。
  • 分頁管理中,邏輯地址是一維的;而分段管理中邏輯地址是二維的。
  • 在實現對程式和資料的共享與保護時,分段管理中,程式和資料本就按邏輯段儲存在記憶體段中,容易實現對程式段、資料段的共享控制以及保護控制;而分頁管理中,邏輯上的程式碼段或資料段是被分散的儲存在各個離散的記憶體頁幀當中,很難實現對邏輯程式段或邏輯資料段的共享與保護。

注:程式頁表和程式段表都存放於實體記憶體中,且一個頁表或段表是佔用的連續空間。以上圖中為了方便表達沒有將頁表或段表畫在實體記憶體中。

作業系統之頁式管理

段頁式儲存管理了解嗎?

在分頁儲存管理中,記憶體利用率高,不會產生外部碎片,但不方便實現資料的共享與保護;而分段儲存管理則剛好相反。段頁式儲存管理就是為了結合兩者的優點。簡單來說,段頁式儲存管理將邏輯空間劃分為邏輯段,邏輯段再劃分為邏輯頁面;而實體記憶體劃分為大小相同的頁幀,邏輯頁面和物理頁幀一一對應,並裝入物理頁幀當中。

在段頁式儲存管理中,邏輯地址被劃分為三段,由高到低依次代表段號頁號頁內偏移CPU定址時需要藉助段表和頁表,段表記錄了各個邏輯段對應頁表的實體記憶體起始地址,以及頁表長度;頁表則記錄了各個邏輯頁面對應物理頁幀的起始地址。 定址開始時,CPU首先拿到指明的段號並根據頁面(頁幀)大小計算出頁號和頁內偏移,CPU根據段號和段表可以找到該邏輯段對應頁表的起始實體記憶體地址,再根據頁號和頁表找到對應頁幀首地址,該首地址加上頁內偏移即可得到實際的實體地址。(段表中的頁表長度用來檢查頁號是否越界)。如下圖:

image-20210817171011746

作業系統之段式管理及段頁式管理

虛擬儲存器(虛擬記憶體)瞭解嗎?

基於區域性性原理,在程式裝入時,可以先裝入當前執行需要的部分,然後就可以開始啟動程式,而將其他的部分暫時留在外存。在程式執行時,如果訪問的資訊不在記憶體中,由作業系統將所需要的部分調入記憶體;如果此時記憶體已經沒有空間給新調入的部分,那麼作業系統按照某種淘汰策略將一部分舊的內容暫時換到外存上存放,然後再將新需要的部分調入記憶體,接著繼續執行程式。這樣,作業系統就可以執行比實際記憶體大的多的程式,就好像為使用者提供了一個比實際記憶體大的多的儲存器。

虛擬儲存器種類:

  • 虛擬頁式儲存管理
  • 虛擬段式儲存管理
  • 虛擬段頁式儲存管理

區域性性原理:

  • 時間區域性性原理:如果程式中的某條指令⼀旦執⾏,不久以後該指令可能再次執⾏;如果某資料被訪問過,不久以後該資料可能再次被訪問。
  • 空間區域性性原理:⼀旦程式訪問了某個儲存單元,在不久之後,其附近的儲存單元也將被訪問, 即程式在⼀段時間內所訪問的地址,可能集中在⼀定的範圍之內。

頁面淘汰(置換)演算法有哪些?

  1. 最佳(Optimal)淘汰演算法:淘汰將來最長時間內不再被訪問的頁面,該演算法會保證最低的缺頁率,但它是無法實現的可作為衡量其他演算法優劣的一個標準

  2. 先進先出(FIFO)淘汰演算法:淘汰最先進入的頁面。該演算法將那些經常被訪問的頁面也被換出,從而使缺頁率升高。

  3. 最近最久未使用淘汰演算法(LRU, Least Recently Used: 淘汰最近最久未使用的頁面。

  4. 時鐘(CLOCK)淘汰演算法:該演算法為每個頁面設定一個訪問位,再將記憶體中的頁面都通過指標連結成一個迴圈佇列。當某個頁被訪問時,其訪問位置1。當需要淘汰一個頁面時,只需檢查頁的訪問位。如果是0,就選擇該頁淘汰;如果是1,暫不淘汰,將訪問位改為0,繼續檢查下一個頁面。若第一輪查詢中所有的頁面都是1,則這些頁面的訪問位會被依次置為0,在第二輪掃描中一定可以找到訪問位為0的頁面去淘汰。

    改進時鐘淘汰演算法:

    簡單的時鐘置換演算法僅考慮到了一個頁面最近是否被訪問過,改進時鐘淘汰演算法還要考慮頁面是否被修改過,因此還需要給頁面增加一個修改位,表示當前頁面是否被修改過。在淘汰頁面時,第一輪首先查詢第一個(0, 0)的頁(最近沒被訪問,也沒被修改)用於淘汰;沒找到則進行第二輪查詢,查詢第一個(0,1)的頁(最近沒被訪問,但被修改過)用於淘汰,本輪將所有查詢過的頁的訪問位設為0。第二輪依然沒找到就進行第三輪查詢,查詢第一個(0, 0)的頁用於淘汰,本輪掃描不修改任何標誌位。還是沒找到則進行第四輪查詢,查詢第一個(0,1)的頁淘汰。( 由於第二輪已將所有的頁的訪問位都設為0,因此第三輪、第四輪查詢一定會選中一個頁,因此改進時鐘淘汰演算法最多會進行四輪掃描。)

相關文章