5萬字、97 張圖總結作業系統核心知識點

程式設計師cxuan發表於2020-07-14

文末領取大圖。

這不是一篇教你如何建立一個作業系統的文章,相反,這是一篇指導性文章,教你從幾個方面來理解作業系統。首先你需要知道你為什麼要看這篇文章以及為什麼要學習作業系統。

搞清楚幾個問題

首先你要搞明白你學習作業系統的目的是什麼?作業系統的重要性如何?學習作業系統會給我帶來什麼?下面我會從這幾個方面為你回答下。

作業系統也是一種軟體,但是作業系統是一種非常複雜的軟體。作業系統提供了幾種抽象模型

  • 檔案:對 I/O 裝置的抽象
  • 虛擬記憶體:對程式儲存器的抽象
  • 程式:對一個正在執行程式的抽象
  • 虛擬機器:對整個作業系統的抽象

這些抽象和我們的日常開發息息相關。搞清楚了作業系統是如何抽象的,才能培養我們的抽象性思維和開發思路。

很多問題都和作業系統相關,作業系統是解決這些問題的基礎。如果你不學習作業系統,可能會想著從框架層面來解決,那是你瞭解的還不夠深入,當你學習了作業系統後,能夠培養你的全域性性思維。

學習作業系統我們能夠有效的解決併發問題,併發幾乎是網際網路的重中之重了,這也從側面說明了學習作業系統的重要性。

學習作業系統的重點不是讓你從頭製造一個作業系統,而是告訴你作業系統是如何工作的,能夠讓你對計算機底層有所瞭解,打實你的基礎。

相信你一定清楚什麼是程式設計

Data structures + Algorithms = Programming

作業系統內部會涉及到眾多的資料結構和演算法描述,能夠讓你瞭解演算法的基礎上,讓你編寫更優秀的程式。

我認為可以把計算機比作一棟樓

計算機的底層相當於就是樓的根基,計算機應用相當於就是樓的外形,而作業系統就相當於是告訴你大樓的構造原理,編寫高質量的軟體就相當於是告訴你構建一個穩定的房子。

認識作業系統

在瞭解作業系統前,你需要先知道一下什麼是計算機系統:現代計算機系統由一個或多個處理器、主存、印表機、鍵盤、滑鼠、顯示器、網路介面以及各種輸入/輸出裝置構成的系統。這些都屬於硬體的範疇。我們程式設計師不會直接和這些硬體打交道,並且每位程式設計師不可能會掌握所有計算機系統的細節。

所以電腦科學家在硬體的基礎之上,安裝了一層軟體,這層軟體能夠根據使用者輸入的指令達到控制硬體的效果,從而滿足使用者的需求,這樣的軟體稱為 作業系統,它的任務就是為使用者程式提供一個更好、更簡單、更清晰的計算機模型。也就是說,作業系統相當於是一箇中間層,為使用者層和硬體提供各自的藉口,遮蔽了不同應用和硬體之間的差異,達到統一標準的作用。

上面一個作業系統的簡化圖,最底層是硬體,硬體包括晶片、電路板、磁碟、鍵盤、顯示器等我們上面提到的裝置,在硬體之上是軟體。大部分計算機有兩種執行模式:核心態使用者態,軟體中最基礎的部分是作業系統,它執行在 核心態 中。作業系統具有硬體的訪問權,可以執行機器能夠執行的任何指令。軟體的其餘部分執行在 使用者態 下。

在大概瞭解到作業系統之後,我們先來認識一下硬體都有哪些

計算機硬體

計算機硬體是計算機的重要組成部分,其中包含了 5 個重要的組成部分:運算器、控制器、儲存器、輸入裝置、輸出裝置

  • 運算器:運算器最主要的功能是對資料和資訊進行加工和運算。它是計算機中執行算數和各種邏輯運算的部件。運算器的基本運算包括加、減、乘、除、移位等操作,這些是由 算術邏輯單元(Arithmetic&logical Unit) 實現的。而運算器主要由算數邏輯單元和暫存器構成。
  • 控制器:指按照指定順序改變主電路或控制電路的部件,它主要起到了控制命令執行的作用,完成協調和指揮整個計算機系統的操作。控制器是由程式計數器、指令暫存器、解碼譯碼器等構成。

運算器和控制器共同組成了 CPU

  • 儲存器:儲存器就是計算機的記憶裝置,顧名思義,儲存器可以儲存資訊。儲存器分為兩種,一種是主存,也就是記憶體,它是 CPU 主要互動物件,還有一種是外存,比如硬碟軟盤等。下面是現代計算機系統的儲存架構

  • 輸入裝置:輸入裝置是給計算機獲取外部資訊的裝置,它主要包括鍵盤和滑鼠。

  • 輸出裝置:輸出裝置是給使用者呈現根據輸入裝置獲取的資訊經過一系列的計算後得到顯示的裝置,它主要包括顯示器、印表機等。

這五部分也是馮諾伊曼的體系結構,它認為計算機必須具有如下功能:

把需要的程式和資料送至計算機中。必須具有長期記憶程式、資料、中間結果及最終運算結果的能力。能夠完成各種算術、邏輯運算和資料傳送等資料加工處理的能力。能夠根據需要控制程式走向,並能根據指令控制機器的各部件協調操作。能夠按照要求將處理結果輸出給使用者。

下面是一張 intel 家族產品圖,是一個詳細的計算機硬體分類,我們在根據圖中涉及到硬體進行介紹

  • 匯流排(Buses):在整個系統中執行的是稱為匯流排的電氣管道的集合,這些匯流排在元件之間來回傳輸位元組資訊。通常匯流排被設計成傳送定長的位元組塊,也就是 字(word)。字中的位元組數(字長)是一個基本的系統引數,各個系統中都不盡相同。現在大部分的字都是 4 個位元組(32 位)或者 8 個位元組(64 位)。

  • I/O 裝置(I/O Devices):Input/Output 裝置是系統和外部世界的連線。上圖中有四類 I/O 裝置:用於使用者輸入的鍵盤和滑鼠,用於使用者輸出的顯示器,一個磁碟驅動用來長時間的儲存資料和程式。剛開始的時候,可執行程式就儲存在磁碟上。

    每個I/O 裝置連線 I/O 匯流排都被稱為控制器(controller) 或者是 介面卡(Adapter)。控制器和介面卡之間的主要區別在於封裝方式。控制器是 I/O 裝置本身或者系統的主印製板電路(通常稱作主機板)上的晶片組。而介面卡則是一塊插在主機板插槽上的卡。無論組織形式如何,它們的最終目的都是彼此交換資訊。

  • 主存(Main Memory),主存是一個臨時儲存裝置,而不是永久性儲存,磁碟是 永久性儲存 的裝置。主存既儲存程式,又儲存處理器執行流程所處理的資料。從物理組成上說,主存是由一系列 DRAM(dynamic random access memory) 動態隨機儲存構成的集合。邏輯上說,記憶體就是一個線性的位元組陣列,有它唯一的地址編號,從 0 開始。一般來說,組成程式的每條機器指令都由不同數量的位元組構成,C 程式變數相對應的資料項的大小根據型別進行變化。比如,在 Linux 的 x86-64 機器上,short 型別的資料需要 2 個位元組,int 和 float 需要 4 個位元組,而 long 和 double 需要 8 個位元組。

  • 處理器(Processor)CPU(central processing unit) 或者簡單的處理器,是解釋(並執行)儲存在主儲存器中的指令的引擎。處理器的核心大小為一個字的儲存裝置(或暫存器),稱為程式計數器(PC)。在任何時刻,PC 都指向主存中的某條機器語言指令(即含有該條指令的地址)。

    從系統通電開始,直到系統斷電,處理器一直在不斷地執行程式計數器指向的指令,再更新程式計數器,使其指向下一條指令。處理器根據其指令集體系結構定義的指令模型進行操作。在這個模型中,指令按照嚴格的順序執行,執行一條指令涉及執行一系列的步驟。處理器從程式計數器指向的記憶體中讀取指令,解釋指令中的位,執行該指令指示的一些簡單操作,然後更新程式計數器以指向下一條指令。指令與指令之間可能連續,可能不連續(比如 jmp 指令就不會順序讀取)

下面是 CPU 可能執行簡單操作的幾個步驟

  • 載入(Load):從主存中拷貝一個位元組或者一個字到記憶體中,覆蓋暫存器先前的內容
  • 儲存(Store):將暫存器中的位元組或字複製到主儲存器中的某個位置,從而覆蓋該位置的先前內容
  • 操作(Operate):把兩個暫存器的內容複製到 ALU(Arithmetic logic unit) 。把兩個字進行算術運算,並把結果儲存在暫存器中,重寫暫存器先前的內容。

算術邏輯單元(ALU)是對數字二進位制數執行算術和按位運算的組合數位電子電路。

  • 跳轉(jump):從指令中抽取一個字,把這個字複製到程式計數器(PC) 中,覆蓋原來的值

程式和執行緒

關於程式和執行緒,你需要理解下面這張腦圖中的重點

程式

作業系統中最核心的概念就是 程式,程式是對正在執行中的程式的一個抽象。作業系統的其他所有內容都是圍繞著程式展開的。

在多道程式處理的系統中,CPU 會在程式間快速切換,使每個程式執行幾十或者幾百毫秒。然而,嚴格意義來說,在某一個瞬間,CPU 只能執行一個程式,然而我們如果把時間定位為 1 秒內的話,它可能執行多個程式。這樣就會讓我們產生並行的錯覺。因為 CPU 執行速度很快,程式間的換進換出也非常迅速,因此我們很難對多個並行程式進行跟蹤。所以,作業系統的設計者開發了用於描述並行的一種概念模型(順序程式),使得並行更加容易理解和分析。

程式模型

一個程式就是一個正在執行的程式的例項,程式也包括程式計數器、暫存器和變數的當前值。從概念上來說,每個程式都有各自的虛擬 CPU,但是實際情況是 CPU 會在各個程式之間進行來回切換。

如上圖所示,這是一個具有 4 個程式的多道處理程式,在程式不斷切換的過程中,程式計數器也在不同的變化。

在上圖中,這 4 道程式被抽象為 4 個擁有各自控制流程(即每個自己的程式計數器)的程式,並且每個程式都獨立的執行。當然,實際上只有一個物理程式計數器,每個程式要執行時,其邏輯程式計數器會裝載到物理程式計數器中。當程式執行結束後,其物理程式計數器就會是真正的程式計數器,然後再把它放回程式的邏輯計數器中。

從下圖我們可以看到,在觀察足夠長的一段時間後,所有的程式都執行了,但在任何一個給定的瞬間僅有一個程式真正執行

因此,當我們說一個 CPU 只能真正一次執行一個程式的時候,即使有 2 個核(或 CPU),每一個核也只能一次執行一個執行緒

由於 CPU 會在各個程式之間來回快速切換,所以每個程式在 CPU 中的執行時間是無法確定的。並且當同一個程式再次在 CPU 中執行時,其在 CPU 內部的執行時間往往也是不固定的。

這裡的關鍵思想是認識到一個程式所需的條件,程式是某一類特定活動的總和,它有程式、輸入輸出以及狀態。

程式的建立

作業系統需要一些方式來建立程式。下面是一些建立程式的方式

  • 系統初始化(init):啟動作業系統時,通常會建立若干個程式。
  • 正在執行的程式執行了建立程式的系統呼叫(比如 fork)
  • 使用者請求建立一個新程式:在許多互動式系統中,輸入一個命令或者雙擊圖示就可以啟動程式,以上任意一種操作都可以選擇開啟一個新的程式,在基本的 UNIX 系統中執行 X,新程式將接管啟動它的視窗。
  • 初始化一個批處理工作

從技術上講,在所有這些情況下,讓現有流程執行流程是通過建立系統呼叫來建立新流程的。該程式可能是正在執行的使用者程式,是從鍵盤或滑鼠呼叫的系統程式或批處理程式。這些就是系統呼叫建立新程式的過程。該系統呼叫告訴作業系統建立一個新程式,並直接或間接指示在其中執行哪個程式。

在 UNIX 中,僅有一個系統呼叫來建立一個新的程式,這個系統呼叫就是 fork。這個呼叫會建立一個與呼叫程式相關的副本。在 fork 後,一個父程式和子程式會有相同的記憶體映像,相同的環境字串和相同的開啟檔案。

在 Windows 中,情況正相反,一個簡單的 Win32 功能呼叫 CreateProcess,會處理流程建立並將正確的程式載入到新的程式中。這個呼叫會有 10 個引數,包括了需要執行的程式、輸入給程式的命令列引數、各種安全屬性、有關開啟的檔案是否繼承控制位、優先順序資訊、程式所需要建立的視窗規格以及指向一個結構的指標,在該結構中新建立程式的資訊被返回給呼叫者。在 Windows 中,從一開始父程式的地址空間和子程式的地址空間就是不同的

程式的終止

程式在建立之後,它就開始執行並做完成任務。然而,沒有什麼事兒是永不停歇的,包括程式也一樣。程式早晚會發生終止,但是通常是由於以下情況觸發的

  • 正常退出(自願的) : 多數程式是由於完成了工作而終止。當編譯器完成了所給定程式的編譯之後,編譯器會執行一個系統呼叫告訴作業系統它完成了工作。這個呼叫在 UNIX 中是 exit ,在 Windows 中是 ExitProcess
  • 錯誤退出(自願的):比如執行一條不存在的命令,於是編譯器就會提醒並退出。
  • 嚴重錯誤(非自願的)
  • 被其他程式殺死(非自願的) : 某個程式執行系統呼叫告訴作業系統殺死某個程式。在 UNIX 中,這個系統呼叫是 kill。在 Win32 中對應的函式是 TerminateProcess(注意不是系統呼叫)。

程式的層次結構

在一些系統中,當一個程式建立了其他程式後,父程式和子程式就會以某種方式進行關聯。子程式它自己就會建立更多程式,從而形成一個程式層次結構。

UNIX 程式體系

在 UNIX 中,程式和它的所有子程式以及子程式的子程式共同組成一個程式組。當使用者從鍵盤中發出一個訊號後,該訊號被髮送給當前與鍵盤相關的程式組中的所有成員(它們通常是在當前視窗建立的所有活動程式)。每個程式可以分別捕獲該訊號、忽略該訊號或採取預設的動作,即被訊號 kill 掉。整個作業系統中所有的程式都隸屬於一個單個以 init 為根的程式樹。

Windows 程式體系

相反,Windows 中沒有程式層次的概念,Windows 中所有程式都是平等的,唯一類似於層次結構的是在建立程式的時候,父程式得到一個特別的令牌(稱為控制程式碼),該控制程式碼可以用來控制子程式。然而,這個令牌可能也會移交給別的作業系統,這樣就不存在層次結構了。而在 UNIX 中,程式不能剝奪其子程式的 程式權。(這樣看來,還是 Windows 比較)。

程式狀態

儘管每個程式是一個獨立的實體,有其自己的程式計數器和內部狀態,但是,程式之間仍然需要相互幫助。當一個程式開始執行時,它可能會經歷下面這幾種狀態

圖中會涉及三種狀態

  1. 執行態,執行態指的就是程式實際佔用 CPU 時間片執行時
  2. 就緒態,就緒態指的是可執行,但因為其他程式正在執行而處於就緒狀態
  3. 阻塞態,除非某種外部事件發生,否則程式不能執行

程式的實現

作業系統為了執行程式間的切換,會維護著一張表,這張表就是 程式表(process table)。每個程式佔用一個程式表項。該表項包含了程式狀態的重要資訊,包括程式計數器、堆疊指標、記憶體分配狀況、所開啟檔案的狀態、賬號和排程資訊,以及其他在程式由執行態轉換到就緒態或阻塞態時所必須儲存的資訊。

下面展示了一個典型系統中的關鍵欄位

第一列內容與程式管理有關,第二列內容與 儲存管理有關,第三列內容與檔案管理有關。

現在我們應該對程式表有個大致的瞭解了,就可以在對單個 CPU 上如何執行多個順序程式的錯覺做更多的解釋。與每一 I/O 類相關聯的是一個稱作 中斷向量(interrupt vector) 的位置(靠近記憶體底部的固定區域)。它包含中斷服務程式的入口地址。假設當一個磁碟中斷髮生時,使用者程式 3 正在執行,則中斷硬體將程式計數器、程式狀態字、有時還有一個或多個暫存器壓入堆疊,計算機隨即跳轉到中斷向量所指示的地址。這就是硬體所做的事情。然後軟體就隨即接管一切剩餘的工作。

當中斷結束後,作業系統會呼叫一個 C 程式來處理中斷剩下的工作。在完成剩下的工作後,會使某些程式就緒,接著呼叫排程程式,決定隨後執行哪個程式。然後將控制權轉移給一段組合語言程式碼,為當前的程式裝入暫存器值以及記憶體對映並啟動該程式執行,下面顯示了中斷處理和排程的過程。

  1. 硬體壓入堆疊程式計數器等

  2. 硬體從中斷向量裝入新的程式計數器

  3. 組合語言過程儲存暫存器的值

  4. 組合語言過程設定新的堆疊

  5. C 中斷伺服器執行(典型的讀和快取寫入)

  6. 排程器決定下面哪個程式先執行

  7. C 過程返回至彙編程式碼

  8. 組合語言過程開始執行新的當前程式

一個程式在執行過程中可能被中斷數千次,但關鍵每次中斷後,被中斷的程式都返回到與中斷髮生前完全相同的狀態。

執行緒

在傳統的作業系統中,每個程式都有一個地址空間和一個控制執行緒。事實上,這是大部分程式的定義。不過,在許多情況下,經常存在同一地址空間中執行多個控制執行緒的情形,這些執行緒就像是分離的程式。下面我們就著重探討一下什麼是執行緒

執行緒的使用

或許這個疑問也是你的疑問,為什麼要在程式的基礎上再建立一個執行緒的概念,準確的說,這其實是程式模型和執行緒模型的討論,回答這個問題,可能需要分三步來回答

  • 多執行緒之間會共享同一塊地址空間和所有可用資料的能力,這是程式所不具備的
  • 執行緒要比程式更輕量級,由於執行緒更輕,所以它比程式更容易建立,也更容易撤銷。在許多系統中,建立一個執行緒要比建立一個程式快 10 - 100 倍。
  • 第三個原因可能是效能方面的探討,如果多個執行緒都是 CPU 密集型的,那麼並不能獲得效能上的增強,但是如果存在著大量的計算和大量的 I/O 處理,擁有多個執行緒能在這些活動中彼此重疊進行,從而會加快應用程式的執行速度

經典的執行緒模型

程式中擁有一個執行的執行緒,通常簡寫為 執行緒(thread)。執行緒會有程式計數器,用來記錄接著要執行哪一條指令;執行緒實際上 CPU 上排程執行的實體。

下圖我們可以看到三個傳統的程式,每個程式有自己的地址空間和單個控制執行緒。每個執行緒都在不同的地址空間中執行

下圖中,我們可以看到有一個程式三個執行緒的情況。每個執行緒都在相同的地址空間中執行。

執行緒不像是程式那樣具備較強的獨立性。同一個程式中的所有執行緒都會有完全一樣的地址空間,這意味著它們也共享同樣的全域性變數。由於每個執行緒都可以訪問程式地址空間內每個記憶體地址,因此一個執行緒可以讀取、寫入甚至擦除另一個執行緒的堆疊。執行緒之間除了共享同一記憶體空間外,還具有如下不同的內容

上圖左邊的是同一個程式中每個執行緒共享的內容,上圖右邊是每個執行緒中的內容。也就是說左邊的列表是程式的屬性,右邊的列表是執行緒的屬性。

執行緒之間的狀態轉換和程式之間的狀態轉換是一樣的

每個執行緒都會有自己的堆疊,如下圖所示

執行緒系統呼叫

程式通常會從當前的某個單執行緒開始,然後這個執行緒通過呼叫一個庫函式(比如 thread_create )建立新的執行緒。執行緒建立的函式會要求指定新建立執行緒的名稱。建立的執行緒通常都返回一個執行緒識別符號,該識別符號就是新執行緒的名字。

當一個執行緒完成工作後,可以通過呼叫一個函式(比如 thread_exit)來退出。緊接著執行緒消失,狀態變為終止,不能再進行排程。在某些執行緒的執行過程中,可以通過呼叫函式例如 thread_join ,表示一個執行緒可以等待另一個執行緒退出。這個過程阻塞呼叫執行緒直到等待特定的執行緒退出。在這種情況下,執行緒的建立和終止非常類似於程式的建立和終止。

另一個常見的執行緒是呼叫 thread_yield,它允許執行緒自動放棄 CPU 從而讓另一個執行緒執行。這樣一個呼叫還是很重要的,因為不同於程式,執行緒是無法利用時鐘中斷強制讓執行緒讓出 CPU 的。

POSIX 執行緒

POSIX 執行緒 通常稱為 pthreads是一種獨立於語言而存在的執行模型,以及並行執行模型。

它允許程式控制時間上重疊的多個不同的工作流程。每個工作流程都稱為一個執行緒,可以通過呼叫 POSIX Threads API 來實現對這些流程的建立和控制。可以把它理解為執行緒的標準。

POSIX Threads 的實現在許多類似且符合POSIX的作業系統上可用,例如 FreeBSD、NetBSD、OpenBSD、Linux、macOS、Android、Solaris,它在現有 Windows API 之上實現了pthread

IEEE 是世界上最大的技術專業組織,致力於為人類的利益而發展技術。

執行緒呼叫 描述
pthread_create 建立一個新執行緒
pthread_exit 結束呼叫的執行緒
pthread_join 等待一個特定的執行緒退出
pthread_yield 釋放 CPU 來執行另外一個執行緒
pthread_attr_init 建立並初始化一個執行緒的屬性結構
pthread_attr_destory 刪除一個執行緒的屬性結構

所有的 Pthreads 都有特定的屬性,每一個都含有識別符號、一組暫存器(包括程式計數器)和一組儲存在結構中的屬性。這個屬性包括堆疊大小、排程引數以及其他執行緒需要的專案。

執行緒實現

主要有三種實現方式

  • 在使用者空間中實現執行緒;
  • 在核心空間中實現執行緒;
  • 在使用者和核心空間中混合實現執行緒。

下面我們分開討論一下

在使用者空間中實現執行緒

第一種方法是把整個執行緒包放在使用者空間中,核心對執行緒一無所知,它不知道執行緒的存在。所有的這類實現都有同樣的通用結構

執行緒在執行時系統之上執行,執行時系統是管理執行緒過程的集合,包括前面提到的四個過程: pthread_create, pthread_exit, pthread_join 和 pthread_yield。

在核心中實現執行緒

當某個執行緒希望建立一個新執行緒或撤銷一個已有執行緒時,它會進行一個系統呼叫,這個系統呼叫通過對執行緒表的更新來完成執行緒建立或銷燬工作。

核心中的執行緒表持有每個執行緒的暫存器、狀態和其他資訊。這些資訊和使用者空間中的執行緒資訊相同,但是位置卻被放在了核心中而不是使用者空間中。另外,核心還維護了一張程式表用來跟蹤系統狀態。

所有能夠阻塞的呼叫都會通過系統呼叫的方式來實現,當一個執行緒阻塞時,核心可以進行選擇,是執行在同一個程式中的另一個執行緒(如果有就緒執行緒的話)還是執行一個另一個程式中的執行緒。但是在使用者實現中,執行時系統始終執行自己的執行緒,直到核心剝奪它的 CPU 時間片(或者沒有可執行的執行緒存在了)為止。

混合實現

結合使用者空間和核心空間的優點,設計人員採用了一種核心級執行緒的方式,然後將使用者級執行緒與某些或者全部核心執行緒多路複用起來

在這種模型中,程式設計人員可以自由控制使用者執行緒和核心執行緒的數量,具有很大的靈活度。採用這種方法,核心只識別核心級執行緒,並對其進行排程。其中一些核心級執行緒會被多個使用者級執行緒多路複用。

程式間通訊

程式是需要頻繁的和其他程式進行交流的。下面我們會一起討論有關 程式間通訊(Inter Process Communication, IPC) 的問題。大致來說,程式間的通訊機制可以分為 6 種

下面我們分別對其進行概述

訊號 signal

訊號是 UNIX 系統最先開始使用的程式間通訊機制,因為 Linux 是繼承於 UNIX 的,所以 Linux 也支援訊號機制,通過向一個或多個程式傳送非同步事件訊號來實現,訊號可以從鍵盤或者訪問不存在的位置等地方產生;訊號通過 shell 將任務傳送給子程式。

你可以在 Linux 系統上輸入 kill -l 來列出系統使用的訊號,下面是我提供的一些訊號

程式可以選擇忽略傳送過來的訊號,但是有兩個是不能忽略的:SIGSTOPSIGKILL 訊號。SIGSTOP 訊號會通知當前正在執行的程式執行關閉操作,SIGKILL 訊號會通知當前程式應該被殺死。除此之外,程式可以選擇它想要處理的訊號,程式也可以選擇阻止訊號,如果不阻止,可以選擇自行處理,也可以選擇進行核心處理。如果選擇交給核心進行處理,那麼就執行預設處理。

作業系統會中斷目標程式的程式來向其傳送訊號、在任何非原子指令中,執行都可以中斷,如果程式已經註冊了新號處理程式,那麼就執行程式,如果沒有註冊,將採用預設處理的方式。

管道 pipe

Linux 系統中的程式可以通過建立管道 pipe 進行通訊

在兩個程式之間,可以建立一個通道,一個程式向這個通道里寫入位元組流,另一個程式從這個管道中讀取位元組流。管道是同步的,當程式嘗試從空管道讀取資料時,該程式會被阻塞,直到有可用資料為止。shell 中的管線 pipelines 就是用管道實現的,當 shell 發現輸出

sort <f | head

它會建立兩個程式,一個是 sort,一個是 head,sort,會在這兩個應用程式之間建立一個管道使得 sort 程式的標準輸出作為 head 程式的標準輸入。sort 程式產生的輸出就不用寫到檔案中了,如果管道滿了系統會停止 sort 以等待 head 讀出資料

管道實際上就是 |,兩個應用程式不知道有管道的存在,一切都是由 shell 管理和控制的。

共享記憶體 shared memory

兩個程式之間還可以通過共享記憶體進行程式間通訊,其中兩個或者多個程式可以訪問公共記憶體空間。兩個程式的共享工作是通過共享記憶體完成的,一個程式所作的修改可以對另一個程式可見(很像執行緒間的通訊)。

在使用共享記憶體前,需要經過一系列的呼叫流程,流程如下

  • 建立共享記憶體段或者使用已建立的共享記憶體段(shmget())
  • 將程式附加到已經建立的記憶體段中(shmat())
  • 從已連線的共享記憶體段分離程式(shmdt())
  • 對共享記憶體段執行控制操作(shmctl())

先入先出佇列 FIFO

先入先出佇列 FIFO 通常被稱為 命名管道(Named Pipes),命名管道的工作方式與常規管道非常相似,但是確實有一些明顯的區別。未命名的管道沒有備份檔案:作業系統負責維護記憶體中的緩衝區,用來將位元組從寫入器傳輸到讀取器。一旦寫入或者輸出終止的話,緩衝區將被回收,傳輸的資料會丟失。相比之下,命名管道具有支援檔案和獨特 API ,命名管道在檔案系統中作為裝置的專用檔案存在。當所有的程式通訊完成後,命名管道將保留在檔案系統中以備後用。命名管道具有嚴格的 FIFO 行為

寫入的第一個位元組是讀取的第一個位元組,寫入的第二個位元組是讀取的第二個位元組,依此類推。

訊息佇列 Message Queue

一聽到訊息佇列這個名詞你可能不知道是什麼意思,訊息佇列是用來描述核心定址空間內的內部連結列表。可以按幾種不同的方式將訊息按順序傳送到佇列並從佇列中檢索訊息。每個訊息佇列由 IPC 識別符號唯一標識。訊息佇列有兩種模式,一種是嚴格模式, 嚴格模式就像是 FIFO 先入先出佇列似的,訊息順序傳送,順序讀取。還有一種模式是 非嚴格模式,訊息的順序性不是非常重要。

套接字 Socket

還有一種管理兩個程式間通訊的是使用 socket,socket 提供端到端的雙相通訊。一個套接字可以與一個或多個程式關聯。就像管道有命令管道和未命名管道一樣,套接字也有兩種模式,套接字一般用於兩個程式之間的網路通訊,網路套接字需要來自諸如TCP(傳輸控制協議)或較低階別UDP(使用者資料包協議)等基礎協議的支援。

套接字有以下幾種分類

  • 順序包套接字(Sequential Packet Socket): 此類套接字為最大長度固定的資料包提供可靠的連線。此連線是雙向的並且是順序的。
  • 資料包套接字(Datagram Socket):資料包套接字支援雙向資料流。資料包套接字接受訊息的順序與傳送者可能不同。
  • 流式套接字(Stream Socket):流套接字的工作方式類似於電話對話,提供雙向可靠的資料流。
  • 原始套接字(Raw Socket): 可以使用原始套接字訪問基礎通訊協議。

排程

當一個計算機是多道程式設計系統時,會頻繁的有很多程式或者執行緒來同時競爭 CPU 時間片。當兩個或兩個以上的程式/執行緒處於就緒狀態時,就會發生這種情況。如果只有一個 CPU 可用,那麼必須選擇接下來哪個程式/執行緒可以執行。作業系統中有一個叫做 排程程式(scheduler) 的角色存在,它就是做這件事兒的,該程式使用的演算法叫做 排程演算法(scheduling algorithm)

排程演算法的分類

毫無疑問,不同的環境下需要不同的排程演算法。之所以出現這種情況,是因為不同的應用程式和不同的作業系統有不同的目標。也就是說,在不同的系統中,排程程式的優化也是不同的。這裡有必要劃分出三種環境

  • 批處理(Batch) : 商業領域
  • 互動式(Interactive) : 互動式使用者環境
  • 實時(Real time)

批處理中的排程

現在讓我們把目光從一般性的排程轉換為特定的排程演算法。下面我們會探討在批處理中的排程。

先來先服務

最簡單的非搶佔式排程演算法的設計就是 先來先服務(first-come,first-serverd)。當第一個任務從外部進入系統時,將會立即啟動並允許執行任意長的時間。它不會因為執行時間太長而中斷。當其他作業進入時,它們排到就緒佇列尾部。當正在執行的程式阻塞,處於等待佇列的第一個程式就開始執行。當一個阻塞的程式重新處於就緒態時,它會像一個新到達的任務,會排在佇列的末尾,即排在所有程式最後。

這個演算法的強大之處在於易於理解和程式設計,在這個演算法中,一個單連結串列記錄了所有就緒程式。要選取一個程式執行,只要從該佇列的頭部移走一個程式即可;要新增一個新的作業或者阻塞一個程式,只要把這個作業或程式附加在佇列的末尾即可。這是很簡單的一種實現。

最短作業優先

批處理中,第二種排程演算法是 最短作業優先(Shortest Job First),我們假設執行時間已知。例如,一家保險公司,因為每天要做類似的工作,所以人們可以相當精確地預測處理 1000 個索賠的一批作業需要多長時間。當輸入佇列中有若干個同等重要的作業被啟動時,排程程式應使用最短優先作業演算法

需要注意的是,在所有的程式都可以執行的情況下,最短作業優先的演算法才是最優的。

最短剩餘時間優先

最短作業優先的搶佔式版本被稱作為 最短剩餘時間優先(Shortest Remaining Time Next) 演算法。使用這個演算法,排程程式總是選擇剩餘執行時間最短的那個程式執行。

互動式系統中的排程

互動式系統中在個人計算機、伺服器和其他系統中都是很常用的,所以有必要來探討一下互動式排程

輪詢排程

一種最古老、最簡單、最公平並且最廣泛使用的演算法就是 輪詢演算法(round-robin)。每個程式都會被分配一個時間段,稱為時間片(quantum),在這個時間片內允許程式執行。如果時間片結束時程式還在執行的話,則搶佔一個 CPU 並將其分配給另一個程式。如果程式在時間片結束前阻塞或結束,則 CPU 立即進行切換。輪詢演算法比較容易實現。排程程式所做的就是維護一個可執行程式的列表,就像下圖中的 a,當一個程式用完時間片後就被移到佇列的末尾,就像下圖的 b。

優先順序排程

輪詢排程假設了所有的程式是同等重要的。但事實情況可能不是這樣。例如,在一所大學中的等級制度,首先是院長,然後是教授、祕書、後勤人員,最後是學生。這種將外部情況考慮在內就實現了優先順序排程(priority scheduling)

它的基本思想很明確,每個程式都被賦予一個優先順序,優先順序高的程式優先執行。

多級佇列

最早使用優先順序排程的系統是 CTSS(Compatible TimeSharing System)。CTSS 在每次切換前都需要將當前程式換出到磁碟,並從磁碟上讀入一個新程式。為 CPU 密集型程式設定較長的時間片比頻繁地分給他們很短的時間要更有效(減少交換次數)。另一方面,如前所述,長時間片的程式又會影響到響應時間,解決辦法是設定優先順序類。屬於最高優先順序的程式執行一個時間片,次高優先順序程式執行 2 個時間片,再下面一級執行 4 個時間片,以此類推。當一個程式用完分配的時間片後,它被移到下一類。

最短程式優先

最短程式優先是根據程式過去的行為進行推測,並執行估計執行時間最短的那一個。假設每個終端上每條命令的預估執行時間為 T0,現在假設測量到其下一次執行時間為 T1,可以用兩個值的加權來改進估計時間,即aT0+ (1- 1)T1。通過選擇 a 的值,可以決定是儘快忘掉老的執行時間,還是在一段長時間內始終記住它們。當 a = 1/2 時,可以得到下面這個序列

![image-20200220120452410](/Users/mr.l/Library/Application Support/typora-user-images/image-20200220120452410.png)

可以看到,在三輪過後,T0 在新的估計值中所佔比重下降至 1/8。

保證排程

一種完全不同的排程方法是對使用者做出明確的效能保證。一種實際而且容易實現的保證是:若使用者工作時有 n 個使用者登入,則每個使用者將獲得 CPU 處理能力的 1/n。類似地,在一個有 n 個程式執行的單使用者系統中,若所有的程式都等價,則每個程式將獲得 1/n 的 CPU 時間。

彩票排程

對使用者進行承諾並在隨後兌現承諾是一件好事,不過很難實現。但是存在著一種簡單的方式,有一種既可以給出預測結果而又有一種比較簡單的實現方式的演算法,就是 彩票排程(lottery scheduling)演算法。

其基本思想是為程式提供各種系統資源(例如 CPU 時間)的彩票。當做出一個排程決策的時候,就隨機抽出一張彩票,擁有彩票的程式將獲得該資源。在應用到 CPU 排程時,系統可以每秒持有 50 次抽獎,每個中獎者將獲得比如 20 毫秒的 CPU 時間作為獎勵。

公平分享排程

到目前為止,我們假設被排程的都是各個程式自身,而不用考慮該程式的擁有者是誰。結果是,如果使用者 1 啟動了 9 個程式,而使用者 2 啟動了一個程式,使用輪轉或相同優先順序排程演算法,那麼使用者 1 將得到 90 % 的 CPU 時間,而使用者 2 將之得到 10 % 的 CPU 時間。

為了阻止這種情況的出現,一些系統在排程前會把程式的擁有者考慮在內。在這種模型下,每個使用者都會分配一些CPU 時間,而排程程式會選擇程式並強制執行。因此如果兩個使用者每個都會有 50% 的 CPU 時間片保證,那麼無論一個使用者有多少個程式,都將獲得相同的 CPU 份額。

實時系統中的排程

實時系統(real-time) 是一個時間扮演了重要作用的系統。實時系統可以分為兩類,硬實時(hard real time)軟實時(soft real time) 系統,前者意味著必須要滿足絕對的截止時間;後者的含義是雖然不希望偶爾錯失截止時間,但是可以容忍。

實時系統中的事件可以按照響應方式進一步分類為週期性(以規則的時間間隔發生)事件或 非週期性(發生時間不可預知)事件。一個系統可能要響應多個週期性事件流,根據每個事件處理所需的時間,可能甚至無法處理所有事件。例如,如果有 m 個週期事件,事件 i 以週期 Pi 發生,並需要 Ci 秒 CPU 時間處理一個事件,那麼可以處理負載的條件是

只有滿足這個條件的實時系統稱為可排程的,這意味著它實際上能夠被實現。一個不滿足此檢驗標準的程式不能被排程,因為這些程式共同需要的 CPU 時間總和大於 CPU 能提供的時間。

下面我們來了解一下記憶體管理,你需要知道的知識點如下

地址空間

如果要使多個應用程式同時執行在記憶體中,必須要解決兩個問題:保護重定位。第一種解決方式是用保護金鑰標記記憶體塊,並將執行過程的金鑰與提取的每個儲存字的金鑰進行比較。這種方式只能解決第一種問題(破壞作業系統),但是不能解決多程式在記憶體中同時執行的問題。

還有一種更好的方式是創造一個儲存器抽象:地址空間(the address space)。就像程式的概念建立了一種抽象的 CPU 來執行程式,地址空間也建立了一種抽象記憶體供程式使用。

基址暫存器和變址暫存器

最簡單的辦法是使用動態重定位(dynamic relocation)技術,它就是通過一種簡單的方式將每個程式的地址空間對映到實體記憶體的不同區域。還有一種方式是使用基址暫存器和變址暫存器。

  • 基址暫存器:儲存資料記憶體的起始位置
  • 變址暫存器:儲存應用程式的長度。

每當程式引用記憶體以獲取指令或讀取、寫入資料時,CPU 都會自動將基址值新增到程式生成的地址中,然後再將其傳送到記憶體匯流排上。同時,它檢查程式提供的地址是否大於或等於變址暫存器 中的值。如果程式提供的地址要超過變址暫存器的範圍,那麼會產生錯誤並中止訪問。

交換技術

在程式執行過程中,經常會出現記憶體不足的問題。

針對上面記憶體不足的問題,提出了兩種處理方式:最簡單的一種方式就是交換(swapping)技術,即把一個程式完整的調入記憶體,然後再記憶體中執行一段時間,再把它放回磁碟。空閒程式會儲存在磁碟中,所以這些程式在沒有執行時不會佔用太多記憶體。另外一種策略叫做虛擬記憶體(virtual memory),虛擬記憶體技術能夠允許應用程式部分的執行在記憶體中。下面我們首先先探討一下交換

交換過程

下面是一個交換過程

剛開始的時候,只有程式 A 在記憶體中,然後從建立程式 B 和程式 C 或者從磁碟中把它們換入記憶體,然後在圖 d 中,A 被換出記憶體到磁碟中,最後 A 重新進來。因為圖 g 中的程式 A 現在到了不同的位置,所以在裝載過程中需要被重新定位,或者在交換程式時通過軟體來執行;或者在程式執行期間通過硬體來重定位。基址暫存器和變址暫存器就適用於這種情況。

交換在記憶體建立了多個 空閒區(hole),記憶體會把所有的空閒區儘可能向下移動合併成為一個大的空閒區。這項技術稱為記憶體緊縮(memory compaction)。但是這項技術通常不會使用,因為這項技術會消耗很多 CPU 時間。

空閒記憶體管理

在進行記憶體動態分配時,作業系統必須對其進行管理。大致上說,有兩種監控記憶體使用的方式

  • 點陣圖(bitmap)
  • 空閒列表(free lists)

使用點陣圖的儲存管理

使用點陣圖方法時,記憶體可能被劃分為小到幾個字或大到幾千位元組的分配單元。每個分配單元對應於點陣圖中的一位,0 表示空閒, 1 表示佔用(或者相反)。一塊記憶體區域和其對應的點陣圖如下

點陣圖提供了一種簡單的方法在固定大小的記憶體中跟蹤記憶體的使用情況,因為點陣圖的大小取決於記憶體和分配單元的大小。這種方法有一個問題是,當決定為把具有 k 個分配單元的程式放入記憶體時,內容管理器(memory manager) 必須搜尋點陣圖,在點陣圖中找出能夠執行 k 個連續 0 位的串。在點陣圖中找出制定長度的連續 0 串是一個很耗時的操作,這是點陣圖的缺點。(可以簡單理解為在雜亂無章的陣列中,找出具有一大長串空閒的陣列單元)

使用連結串列進行管理

另一種記錄記憶體使用情況的方法是,維護一個記錄已分配記憶體段和空閒記憶體段的連結串列,段會包含程式或者是兩個程式的空閒區域。可用上面的圖 c 來表示記憶體的使用情況。連結串列中的每一項都可以代表一個 空閒區(H) 或者是程式(P)的起始標誌,長度和下一個連結串列項的位置。

當按照地址順序在連結串列中存放程式和空閒區時,有幾種演算法可以為建立的程式(或者從磁碟中換入的程式)分配記憶體。我們先假設記憶體管理器知道應該分配多少記憶體,最簡單的演算法是使用 首次適配(first fit)。記憶體管理器會沿著段列表進行掃描,直到找個一個足夠大的空閒區為止。 除非空閒區大小和要分配的空間大小一樣,否則將空閒區分為兩部分,一部分供程式使用;一部分生成新的空閒區。首次適配演算法是一種速度很快的演算法,因為它會盡可能的搜尋連結串列。

首次適配的一個小的變體是 下次適配(next fit)。它和首次匹配的工作方式相同,只有一個不同之處那就是下次適配在每次找到合適的空閒區時就會記錄當時的位置,以便下次尋找空閒區時從上次結束的地方開始搜尋,而不是像首次匹配演算法那樣每次都會從頭開始搜尋。

另外一個著名的並且廣泛使用的演算法是 最佳適配(best fit)。最佳適配會從頭到尾尋找整個連結串列,找出能夠容納程式的最小空閒區。

虛擬記憶體

儘管基址暫存器和變址暫存器用來建立地址空間的抽象,但是這有一個其他的問題需要解決:管理軟體的不斷增大(managing bloatware)。虛擬記憶體的基本思想是,每個程式都有自己的地址空間,這個地址空間被劃分為多個稱為頁面(page)的塊。每一頁都是連續的地址範圍。這些頁被對映到實體記憶體,但並不是所有的頁都必須在記憶體中才能執行程式。當程式引用到一部分在實體記憶體中的地址空間時,硬體會立刻執行必要的對映。當程式引用到一部分不在實體記憶體中的地址空間時,由作業系統負責將缺失的部分裝入實體記憶體並重新執行失敗的指令。

分頁

大部分使用虛擬記憶體的系統中都會使用一種 分頁(paging) 技術。在任何一臺計算機上,程式會引用使用一組記憶體地址。當程式執行

MOV REG,1000

這條指令時,它會把記憶體地址為 1000 的記憶體單元的內容複製到 REG 中(或者相反,這取決於計算機)。地址可以通過索引、基址暫存器、段暫存器或其他方式產生。

這些程式生成的地址被稱為 虛擬地址(virtual addresses) 並形成虛擬地址空間(virtual address space),在沒有虛擬記憶體的計算機上,系統直接將虛擬地址送到記憶體中線上,讀寫操作都使用同樣地址的實體記憶體。在使用虛擬記憶體時,虛擬地址不會直接傳送到記憶體匯流排上。相反,會使用 MMU(Memory Management Unit) 記憶體管理單元把虛擬地址對映為實體記憶體地址,像下圖這樣

下面這幅圖展示了這種對映是如何工作的

頁表給出虛擬地址與實體記憶體地址之間的對映關係。每一頁起始於 4096 的倍數位置,結束於 4095 的位置,所以 4K 到 8K 實際為 4096 - 8191 ,8K - 12K 就是 8192 - 12287

在這個例子中,我們可能有一個 16 位地址的計算機,地址從 0 - 64 K - 1,這些是虛擬地址。然而只有 32 KB 的實體地址。所以雖然可以編寫 64 KB 的程式,但是程式無法全部調入記憶體執行,在磁碟上必須有一個最多 64 KB 的程式核心映像的完整副本,以保證程式片段在需要時被調入記憶體。

頁表

虛擬頁號可作為頁表的索引用來找到虛擬頁中的內容。由頁表項可以找到頁框號(如果有的話)。然後把頁框號拼接到偏移量的高位端,以替換掉虛擬頁號,形成實體地址。

因此,頁表的目的是把虛擬頁對映到頁框中。從數學上說,頁表是一個函式,它的引數是虛擬頁號,結果是物理頁框號。

通過這個函式可以把虛擬地址中的虛擬頁轉換為頁框,從而形成實體地址。

頁表項的結構

下面我們探討一下頁表項的具體結構,上面你知道了頁表項的大致構成,是由頁框號和在/不在位構成的,現在我們來具體探討一下頁表項的構成

頁表項的結構是與機器相關的,但是不同機器上的頁表項大致相同。上面是一個頁表項的構成,不同計算機的頁表項可能不同,但是一般來說都是 32 位的。頁表項中最重要的欄位就是頁框號(Page frame number)。畢竟,頁表到頁框最重要的一步操作就是要把此值對映過去。下一個比較重要的就是在/不在位,如果此位上的值是 1,那麼頁表項是有效的並且能夠被使用。如果此值是 0 的話,則表示該頁表項對應的虛擬頁面不在記憶體中,訪問該頁面會引起一個缺頁異常(page fault)

保護位(Protection) 告訴我們哪一種訪問是允許的,啥意思呢?最簡單的表示形式是這個域只有一位,0 表示可讀可寫,1 表示的是隻讀

修改位(Modified)訪問位(Referenced) 會跟蹤頁面的使用情況。當一個頁面被寫入時,硬體會自動的設定修改位。修改位在頁面重新分配頁框時很有用。如果一個頁面已經被修改過(即它是 的),則必須把它寫回磁碟。如果一個頁面沒有被修改過(即它是 乾淨的),那麼重新分配時這個頁框會被直接丟棄,因為磁碟上的副本仍然是有效的。這個位有時也叫做 髒位(dirty bit),因為它反映了頁面的狀態。

訪問位(Referenced) 在頁面被訪問時被設定,不管是讀還是寫。這個值能夠幫助作業系統在發生缺頁中斷時選擇要淘汰的頁。不再使用的頁要比正在使用的頁更適合被淘汰。這個位在後面要討論的頁面置換演算法中作用很大。

最後一位用於禁止該頁面被快取記憶體,這個功能對於對映到裝置暫存器還是記憶體中起到了關鍵作用。通過這一位可以禁用快取記憶體。具有獨立的 I/O 空間而不是用記憶體對映 I/O 的機器來說,並不需要這一位。

頁面置換演算法

下面我們就來探討一下有哪些頁面置換演算法。

最優頁面置換演算法

最優的頁面置換演算法的工作流程如下:在缺頁中斷髮生時,這些頁面之一將在下一條指令(包含該指令的頁面)上被引用。其他頁面則可能要到 10、100 或者 1000 條指令後才會被訪問。每個頁面都可以用在該頁首次被訪問前所要執行的指令數作為標記。

最優化的頁面演算法表明應該標記最大的頁面。如果一個頁面在 800 萬條指令內不會被使用,另外一個頁面在 600 萬條指令內不會被使用,則置換前一個頁面,從而把需要調入這個頁面而發生的缺頁中斷推遲。計算機也像人類一樣,會把不願意做的事情儘可能的往後拖。

這個演算法最大的問題時無法實現。當缺頁中斷髮生時,作業系統無法知道各個頁面的下一次將在什麼時候被訪問。這種演算法在實際過程中根本不會使用。

最近未使用頁面置換演算法

為了能夠讓作業系統收集頁面使用資訊,大部分使用虛擬地址的計算機都有兩個狀態位,R 和 M,來和每個頁面進行關聯。每當引用頁面(讀入或寫入)時都設定 R,寫入(即修改)頁面時設定 M,這些位包含在每個頁表項中,就像下面所示

因為每次訪問時都會更新這些位,因此由硬體來設定它們非常重要。一旦某個位被設定為 1,就會一直保持 1 直到作業系統下次來修改此位。

如果硬體沒有這些位,那麼可以使用作業系統的缺頁中斷時鐘中斷機制來進行模擬。當啟動一個程式時,將其所有的頁面都標記為不在記憶體;一旦訪問任何一個頁面就會引發一次缺頁中斷,此時作業系統就可以設定 R 位(在它的內部表中),修改頁表項使其指向正確的頁面,並設定為 READ ONLY 模式,然後重新啟動引起缺頁中斷的指令。如果頁面隨後被修改,就會發生另一個缺頁異常。從而允許作業系統設定 M 位並把頁面的模式設定為 READ/WRITE

可以用 R 位和 M 位來構造一個簡單的頁面置換演算法:當啟動一個程式時,作業系統將其所有頁面的兩個位都設定為 0。R 位定期的被清零(在每個時鐘中斷)。用來將最近未引用的頁面和已引用的頁面分開。

當出現缺頁中斷後,作業系統會檢查所有的頁面,並根據它們的 R 位和 M 位將當前值分為四類:

  • 第 0 類:沒有引用 R,沒有修改 M
  • 第 1 類:沒有引用 R,已修改 M
  • 第 2 類:引用 R ,沒有修改 M
  • 第 3 類:已被訪問 R,已被修改 M

儘管看起來好像無法實現第一類頁面,但是當第三類頁面的 R 位被時鐘中斷清除時,它們就會發生。時鐘中斷不會清除 M 位,因為需要這個資訊才能知道是否寫回磁碟中。清除 R 但不清除 M 會導致出現一類頁面。

NRU(Not Recently Used) 演算法從編號最小的非空類中隨機刪除一個頁面。此演算法隱含的思想是,在一個時鐘內(約 20 ms)淘汰一個已修改但是沒有被訪問的頁面要比一個大量引用的未修改頁面好,NRU 的主要優點是易於理解並且能夠有效的實現

先進先出頁面置換演算法

另一種開銷較小的方式是使用 FIFO(First-In,First-Out) 演算法,這種型別的資料結構也適用在頁面置換演算法中。由作業系統維護一個所有在當前記憶體中的頁面的連結串列,最早進入的放在表頭,最新進入的頁面放在表尾。在發生缺頁異常時,會把頭部的頁移除並且把新的頁新增到表尾。

第二次機會頁面置換演算法

我們上面學到的 FIFO 連結串列頁面有個缺陷,那就是出鏈和入鏈並不會進行 check 檢查,這樣就會容易把經常使用的頁面置換出去,為了避免這一問題,我們對該演算法做一個簡單的修改:我們檢查最老頁面的 R 位,如果是 0 ,那麼這個頁面就是最老的而且沒有被使用,那麼這個頁面就會被立刻換出。如果 R 位是 1,那麼就清除此位,此頁面會被放在連結串列的尾部,修改它的裝入時間就像剛放進來的一樣。然後繼續搜尋。

這種演算法叫做 第二次機會(second chance)演算法,就像下面這樣,我們看到頁面 A 到 H 保留在連結串列中,並按到達記憶體的時間排序。

a)按照先進先出的方法排列的頁面;b)在時刻 20 處發生缺頁異常中斷並且 A 的 R 位已經設定時的頁面連結串列。

假設缺頁異常發生在時刻 20 處,這時最老的頁面是 A ,它是在 0 時刻到達的。如果 A 的 R 位是 0,那麼它將被淘汰出記憶體,或者把它寫回磁碟(如果它已經被修改過),或者只是簡單的放棄(如果它是未被修改過)。另一方面,如果它的 R 位已經設定了,則將 A 放到連結串列的尾部並且重新設定裝入時間為當前時刻(20 處),然後清除 R 位。然後從 B 頁面開始繼續搜尋合適的頁面。

尋找第二次機會的是在最近的時鐘間隔中未被訪問過的頁面。如果所有的頁面都被訪問過,該演算法就會被簡化為單純的 FIFO 演算法。具體來說,假設圖 a 中所有頁面都設定了 R 位。作業系統將頁面依次移到連結串列末尾,每次都在新增到末尾時清除 R 位。最後,演算法又會回到頁面 A,此時的 R 位已經被清除,那麼頁面 A 就會被執行出鏈處理,因此演算法能夠正常結束。

時鐘頁面置換演算法

一種比較好的方式是把所有的頁面都儲存在一個類似鐘面的環形連結串列中,一個錶針指向最老的頁面。如下圖所示

當缺頁錯誤出現時,演算法首先檢查錶針指向的頁面,如果它的 R 位是 0 就淘汰該頁面,並把新的頁面插入到這個位置,然後把錶針向前移動一位;如果 R 位是 1 就清除 R 位並把錶針前移一個位置。重複這個過程直到找到了一個 R 位為 0 的頁面位置。瞭解這個演算法的工作方式,就明白為什麼它被稱為 時鐘(clokc)演算法了。

最近最少使用頁面置換演算法

在前面幾條指令中頻繁使用的頁面和可能在後面的幾條指令中被使用。反過來說,已經很久沒有使用的頁面有可能在未來一段時間內仍不會被使用。這個思想揭示了一個可以實現的演算法:在缺頁中斷時,置換未使用時間最長的頁面。這個策略稱為 LRU(Least Recently Used) ,最近最少使用頁面置換演算法。

雖然 LRU 在理論上是可以實現的,但是從長遠看來代價比較高。為了完全實現 LRU,會在記憶體中維護一個所有頁面的連結串列,最頻繁使用的頁位於表頭,最近最少使用的頁位於表尾。困難的是在每次記憶體引用時更新整個連結串列。在連結串列中找到一個頁面,刪除它,然後把它移動到表頭是一個非常耗時的操作,即使使用硬體來實現也是一樣的費時。

用軟體模擬 LRU

儘管上面的 LRU 演算法在原則上是可以實現的,但是很少有機器能夠擁有那些特殊的硬體。上面是硬體的實現方式,那麼現在考慮要用軟體來實現 LRU 。一種可以實現的方案是 NFU(Not Frequently Used,最不常用)演算法。它需要一個軟體計數器來和每個頁面關聯,初始化的時候是 0 。在每個時鐘中斷時,作業系統會瀏覽記憶體中的所有頁,會將每個頁面的 R 位(0 或 1)加到它的計數器上。這個計數器大體上跟蹤了各個頁面訪問的頻繁程度。當缺頁異常出現時,則置換計數器值最小的頁面。

只需要對 NFU 做一個簡單的修改就可以讓它模擬 LRU,這個修改有兩個步驟

  • 首先,在 R 位被新增進來之前先把計數器右移一位;
  • 第二步,R 位被新增到最左邊的位而不是最右邊的位。

修改以後的演算法稱為 老化(aging) 演算法,下圖解釋了老化演算法是如何工作的。

我們假設在第一個時鐘週期內頁面 0 - 5 的 R 位依次是 1,0,1,0,1,1,(也就是頁面 0 是 1,頁面 1 是 0,頁面 2 是 1 這樣類推)。也就是說,在 0 個時鐘週期到 1 個時鐘週期之間,0,2,4,5 都被引用了,從而把它們的 R 位設定為 1,剩下的設定為 0 。在相關的六個計數器被右移之後 R 位被新增到 左側 ,就像上圖中的 a。剩下的四列顯示了接下來的四個時鐘週期內的六個計數器變化。

CPU正在以某個頻率前進,該頻率的週期稱為時鐘滴答時鐘週期。一個 100Mhz 的處理器每秒將接收100,000,000個時鐘滴答。

當缺頁異常出現時,將置換(就是移除)計數器值最小的頁面。如果一個頁面在前面 4 個時鐘週期內都沒有被訪問過,那麼它的計數器應該會有四個連續的 0 ,因此它的值肯定要比前面 3 個時鐘週期內都沒有被訪問過的頁面的計數器小。

這個演算法與 LRU 演算法有兩個重要的區別:看一下上圖中的 e,第三列和第五列

工作集時鐘頁面置換演算法

當缺頁異常發生後,需要掃描整個頁表才能確定被淘汰的頁面,因此基本工作集演算法還是比較浪費時間的。一個對基本工作集演算法的提升是基於時鐘演算法但是卻使用工作集的資訊,這種演算法稱為WSClock(工作集時鐘)。由於它的實現簡單並且具有高效能,因此在實踐中被廣泛應用。

與時鐘演算法一樣,所需的資料結構是一個以頁框為元素的迴圈列表,就像下面這樣

​ 工作集時鐘頁面置換演算法的操作:a) 和 b) 給出 R = 1 時所發生的情形;c) 和 d) 給出 R = 0 的例子

最初的時候,該表是空的。當裝入第一個頁面後,把它載入到該表中。隨著更多的頁面的加入,它們形成一個環形結構。每個表項包含來自基本工作集演算法的上次使用時間,以及 R 位(已標明)和 M 位(未標明)。

與時鐘演算法一樣,在每個缺頁異常時,首先檢查指標指向的頁面。如果 R 位被是設定為 1,該頁面在當前時鐘週期內就被使用過,那麼該頁面就不適合被淘汰。然後把該頁面的 R 位置為 0,指標指向下一個頁面,並重復該演算法。該事件序列化後的狀態參見圖 b。

現在考慮指標指向的頁面 R = 0 時會發生什麼,參見圖 c,如果頁面的使用期限大於 t 並且頁面為被訪問過,那麼這個頁面就不會在工作集中,並且在磁碟上會有一個此頁面的副本。申請重新調入一個新的頁面,並把新的頁面放在其中,如圖 d 所示。另一方面,如果頁面被修改過,就不能重新申請頁面,因為這個頁面在磁碟上沒有有效的副本。為了避免由於排程寫磁碟操作引起的程式切換,指標繼續向前走,演算法繼續對下一個頁面進行操作。畢竟,有可能存在一個老的,沒有被修改過的頁面可以立即使用。

原則上來說,所有的頁面都有可能因為磁碟I/O 在某個時鐘週期內被排程。為了降低磁碟阻塞,需要設定一個限制,即最大隻允許寫回 n 個頁面。一旦達到該限制,就不允許排程新的寫操作。

那麼就有個問題,指標會繞一圈回到原點的,如果回到原點,它的起始點會發生什麼?這裡有兩種情況:

  • 至少排程了一次寫操作
  • 沒有排程過寫操作

在第一種情況中,指標僅僅是不停的移動,尋找一個未被修改過的頁面。由於已經排程了一個或者多個寫操作,最終會有某個寫操作完成,它的頁面會被標記為未修改。置換遇到的第一個未被修改過的頁面,這個頁面不一定是第一個被排程寫操作的頁面,因為硬碟驅動程式為了優化效能可能會把寫操作重排序。

對於第二種情況,所有的頁面都在工作集中,否則將至少排程了一個寫操作。由於缺乏額外的資訊,最簡單的方法就是置換一個未被修改的頁面來使用,掃描中需要記錄未被修改的頁面的位置,如果不存在未被修改的頁面,就選定當前頁面並把它寫回磁碟。

頁面置換演算法小結

我們到現在已經研究了各種頁面置換演算法,現在我們來一個簡單的總結,演算法的總結歸納如下

演算法 註釋
最優演算法 不可實現,但可以用作基準
NRU(最近未使用) 演算法 和 LRU 演算法很相似
FIFO(先進先出) 演算法 有可能會拋棄重要的頁面
第二次機會演算法 比 FIFO 有較大的改善
時鐘演算法 實際使用
LRU(最近最少)演算法 比較優秀,但是很難實現
NFU(最不經常食用)演算法 和 LRU 很類似
老化演算法 近似 LRU 的高效演算法
工作集演算法 實施起來開銷很大
工作集時鐘演算法 比較有效的演算法
  • 最優演算法在當前頁面中置換最後要訪問的頁面。不幸的是,沒有辦法來判定哪個頁面是最後一個要訪問的,因此實際上該演算法不能使用。然而,它可以作為衡量其他演算法的標準。

  • NRU 演算法根據 R 位和 M 位的狀態將頁面氛圍四類。從編號最小的類別中隨機選擇一個頁面。NRU 演算法易於實現,但是效能不是很好。存在更好的演算法。

  • FIFO 會跟蹤頁面載入進入記憶體中的順序,並把頁面放入一個連結串列中。有可能刪除存在時間最長但是還在使用的頁面,因此這個演算法也不是一個很好的選擇。

  • 第二次機會演算法是對 FIFO 的一個修改,它會在刪除頁面之前檢查這個頁面是否仍在使用。如果頁面正在使用,就會進行保留。這個改進大大提高了效能。

  • 時鐘 演算法是第二次機會演算法的另外一種實現形式,時鐘演算法和第二次演算法的效能差不多,但是會花費更少的時間來執行演算法。

  • LRU 演算法是一個非常優秀的演算法,但是沒有特殊的硬體(TLB)很難實現。如果沒有硬體,就不能使用 LRU 演算法。

  • NFU 演算法是一種近似於 LRU 的演算法,它的效能不是非常好。

  • 老化 演算法是一種更接近 LRU 演算法的實現,並且可以更好的實現,因此是一個很好的選擇

  • 最後兩種演算法都使用了工作集演算法。工作集演算法提供了合理的效能開銷,但是它的實現比較複雜。WSClock 是另外一種變體,它不僅能夠提供良好的效能,而且可以高效地實現。

總之,最好的演算法是老化演算法和WSClock演算法。他們分別是基於 LRU 和工作集演算法。他們都具有良好的效能並且能夠被有效的實現。還存在其他一些好的演算法,但實際上這兩個可能是最重要的。

下面來聊一聊檔案系統,你需要知道下面這些知識點

檔案

檔案命名

檔案是一種抽象機制,它提供了一種方式用來儲存資訊以及在後面進行讀取。可能任何一種機制最重要的特性就是管理物件的命名方式。在建立一個檔案後,它會給檔案一個命名。當程式終止時,檔案會繼續存在,並且其他程式可以使用名稱訪問該檔案

檔案命名規則對於不同的作業系統來說是不一樣的,但是所有現代作業系統都允許使用 1 - 8 個字母的字串作為合法檔名。

某些檔案區分大小寫字母,而大多數則不區分。UNIX 屬於第一類;歷史悠久的 MS-DOS 屬於第二類(順便說一句,儘管 MS-DOS 歷史悠久,但 MS-DOS 仍在嵌入式系統中非常廣泛地使用,因此它絕不是過時的);因此,UNIX 系統會有三種不同的命名檔案:mariaMariaMARIA 。在 MS-DOS ,所有這些命名都屬於相同的檔案。

許多作業系統支援兩部分的檔名,它們之間用 . 分隔開,比如檔名 prog.c。原點後面的檔案稱為 副檔名(file extension) ,副檔名通常表示檔案的一些資訊。一些常用的副檔名以及含義如下圖所示

副檔名 含義
bak 備份檔案
c c 源程式檔案
gif 符合圖形交換格式的影像檔案
hlp 幫助檔案
html WWW 超文字標記語言文件
jpg 符合 JPEG 編碼標準的靜態圖片
mp3 符合 MP3 音訊編碼格式的音樂檔案
mpg 符合 MPEG 編碼標準的電影
o 目標檔案(編譯器輸出格式,尚未連結)
pdf pdf 格式的檔案
ps PostScript 檔案
tex 為 TEX 格式化程式準備的輸入檔案
txt 文字檔案
zip 壓縮檔案

在 UNIX 系統中,副檔名只是一種約定,作業系統並不強制採用。

檔案結構

檔案的構造有多種方式。下圖列出了常用的三種構造方式

​ 三種不同的檔案。 a) 位元組序列 。b) 記錄序列。c) 樹

上圖中的 a 是一種無結構的位元組序列,作業系統不關心序列的內容是什麼,作業系統能看到的就是位元組(bytes)。其檔案內容的任何含義只在使用者程式中進行解釋。UNIX 和 Windows 都採用這種辦法。

圖 b 表示在檔案結構上的第一部改進。在這個模型中,檔案是具有固定長度記錄的序列,每個記錄都有其內部結構。 把檔案作為記錄序列的核心思想是:讀操作返回一個記錄,而寫操作重寫或者追加一個記錄。第三種檔案結構如上圖 c 所示。在這種組織結構中,檔案由一顆記錄樹構成,記錄樹的長度不一定相同,每個記錄樹都在記錄中的固定位置包含一個key 欄位。這棵樹按 key 進行排序,從而可以對特定的 key 進行快速查詢。

檔案型別

很多作業系統支援多種檔案型別。例如,UNIX(同樣包括 OS X)和 Windows 都具有常規的檔案和目錄。除此之外,UNIX 還具有字元特殊檔案(character special file)塊特殊檔案(block special file)常規檔案(Regular files) 是包含有使用者資訊的檔案。使用者一般使用的檔案大都是常規檔案,常規檔案一般包括 可執行檔案、文字檔案、影像檔案,從常規檔案讀取資料或將資料寫入時,核心會根據檔案系統的規則執行操作,是寫入可能被延遲,記錄日誌或者接受其他操作。

檔案訪問

早期的作業系統只有一種訪問方式:序列訪問(sequential access)。在這些系統中,程式可以按照順序讀取所有的位元組或檔案中的記錄,但是不能跳過並亂序執行它們。順序訪問檔案是可以返回到起點的,需要時可以多次讀取該檔案。當儲存介質是磁帶而不是磁碟時,順序訪問檔案很方便。

在使用磁碟來儲存檔案時,可以不按照順序讀取檔案中的位元組或者記錄,或者按照關鍵字而不是位置來訪問記錄。這種能夠以任意次序進行讀取的稱為隨機訪問檔案(random access file)。許多應用程式都需要這種方式。

隨機訪問檔案對許多應用程式來說都必不可少,例如,資料庫系統。如果乘客打電話預定某航班機票,訂票程式必須能夠直接訪問航班記錄,而不必先讀取其他航班的成千上萬條記錄。

有兩種方法可以指示從何處開始讀取檔案。第一種方法是直接使用 read 從頭開始讀取。另一種是用一個特殊的 seek 操作設定當前位置,在 seek 操作後,從這個當前位置順序地開始讀檔案。UNIX 和 Windows 使用的是後面一種方式。

檔案屬性

檔案包括檔名和資料。除此之外,所有的作業系統還會儲存其他與檔案相關的資訊,如檔案建立的日期和時間、檔案大小。我們可以稱這些為檔案的屬性(attributes)。有些人也喜歡把它們稱作 後設資料(metadata)。檔案的屬性在不同的系統中差別很大。檔案的屬性只有兩種狀態:設定(set)清除(clear)

檔案操作

使用檔案的目的是用來儲存資訊並方便以後的檢索。對於儲存和檢索,不同的系統提供了不同的操作。以下是與檔案有關的最常用的一些系統呼叫:

  1. Create,建立不包含任何資料的檔案。呼叫的目的是表示檔案即將建立,並對檔案設定一些屬性。
  2. Delete,當檔案不再需要,必須刪除它以釋放記憶體空間。為此總會有一個系統呼叫來刪除檔案。
  3. Open,在使用檔案之前,必須先開啟檔案。這個呼叫的目的是允許系統將屬性和磁碟地址列表儲存到主存中,用來以後的快速訪問。
  4. Close,當所有程式完成時,屬性和磁碟地址不再需要,因此應關閉檔案以釋放表空間。很多系統限制程式開啟檔案的個數,以此達到鼓勵使用者關閉不再使用的檔案。磁碟以塊為單位寫入,關閉檔案時會強制寫入最後一,即使這個塊空間內部還不滿。
  5. Read,資料從檔案中讀取。通常情況下,讀取的資料來自檔案的當前位置。呼叫者必須指定需要讀取多少資料,並且提供存放這些資料的緩衝區。
  6. Write,向檔案寫資料,寫操作一般也是從檔案的當前位置開始進行。如果當前位置是檔案的末尾,則會直接追加進行寫入。如果當前位置在檔案中,則現有資料被覆蓋,並且永遠消失。
  7. append,使用 append 只能向檔案末尾新增資料。
  8. seek,對於隨機訪問的檔案,要指定從何處開始獲取資料。通常的方法是用 seek 系統呼叫把當前位置指標指向檔案中的特定位置。seek 呼叫結束後,就可以從指定位置開始讀寫資料了。
  9. get attributes,程式執行時通常需要讀取檔案屬性。
  10. set attributes,使用者可以自己設定一些檔案屬性,甚至是在檔案建立之後,實現該功能的是 set attributes 系統呼叫。
  11. rename,使用者可以自己更改已有檔案的名字,rename 系統呼叫用於這一目的。

目錄

檔案系統通常提供目錄(directories) 或者 資料夾(folders) 用於記錄檔案的位置,在很多系統中目錄本身也是檔案,下面我們會討論關於檔案,他們的組織形式、屬性和可以對檔案進行的操作。

一級目錄系統

目錄系統最簡單的形式是有一個能夠包含所有檔案的目錄。這種目錄被稱為根目錄(root directory),由於根目錄的唯一性,所以其名稱並不重要。在最早期的個人計算機中,這種系統很常見,部分原因是因為只有一個使用者。下面是一個單層目錄系統的例子

​ 含有四個檔案的單層目錄系統

該目錄中有四個檔案。這種設計的優點在於簡單,並且能夠快速定位檔案,畢竟只有一個地方可以檢索。這種目錄組織形式現在一般用於簡單的嵌入式裝置(如數位相機和某些行動式音樂播放器)上使用。

層次目錄系統

對於簡單的應用而言,一般都用單層目錄方式,但是這種組織形式並不適合於現代計算機,因為現代計算機含有成千上萬個檔案和資料夾。如果都放在根目錄下,查詢起來會非常困難。為了解決這一問題,出現了層次目錄系統(Hierarchical Directory Systems),也稱為目錄樹。通過這種方式,可以用很多目錄把檔案進行分組。進而,如果多個使用者共享同一個檔案伺服器,比如公司的網路系統,每個使用者可以為自己的目錄樹擁有自己的私人根目錄。這種方式的組織結構如下

根目錄含有目錄 A、B 和 C ,分別屬於不同的使用者,其中兩個使用者個字建立了子目錄。使用者可以建立任意數量的子目錄,現代檔案系統都是按照這種方式組織的。

路徑名

當目錄樹組織檔案系統時,需要有某種方法指明檔名。常用的方法有兩種,第一種方式是每個檔案都會用一個絕對路徑名(absolute path name),它由根目錄到檔案的路徑組成。

另外一種指定檔名的方法是 相對路徑名(relative path name)。它常常和 工作目錄(working directory) (也稱作 當前目錄(current directory))一起使用。使用者可以指定一個目錄作為當前工作目錄。例如,如果當前目錄是 /usr/ast,那麼絕對路徑 /usr/ast/mailbox可以直接使用 mailbox 來引用。

目錄操作

不同檔案中管理目錄的系統呼叫的差別比管理檔案的系統呼叫差別大。為了瞭解這些系統呼叫有哪些以及它們怎樣工作,下面給出一個例子(取自 UNIX)。

  1. Create,建立目錄,除了目錄項 ... 外,目錄內容為空。
  2. Delete,刪除目錄,只有空目錄可以刪除。只包含 ... 的目錄被認為是空目錄,這兩個目錄項通常不能刪除
  3. opendir,目錄內容可被讀取。例如,未列出目錄中的全部檔案,程式必須先開啟該目錄,然後讀其中全部檔案的檔名。與開啟和讀檔案相同,在讀目錄前,必須先開啟檔案。
  4. closedir,讀目錄結束後,應該關閉目錄用於釋放內部表空間。
  5. readdir,系統呼叫 readdir 返回開啟目錄的下一個目錄項。以前也採用 read 系統呼叫來讀取目錄,但是這種方法有一個缺點:程式設計師必須瞭解和處理目錄的內部結構。相反,不論採用哪一種目錄結構,readdir 總是以標準格式返回一個目錄項。
  6. rename,在很多方面目錄和檔案都相似。檔案可以更換名稱,目錄也可以。
  7. link,連結技術允許在多個目錄中出現同一個檔案。這個系統呼叫指定一個存在的檔案和一個路徑名,並建立從該檔案到路徑所指名字的連結。這樣,可以在多個目錄中出現同一個檔案。有時也被稱為硬連結(hard link)
  8. unlink,刪除目錄項。如果被解除連結的檔案只出現在一個目錄中,則將它從檔案中刪除。如果它出現在多個目錄中,則只刪除指定路徑名的連結,依然保留其他路徑名的連結。在 UNIX 中,用於刪除檔案的系統呼叫就是 unlink。

檔案系統的實現

檔案系統佈局

檔案系統儲存在磁碟中。大部分的磁碟能夠劃分出一到多個分割槽,叫做磁碟分割槽(disk partitioning) 或者是磁碟分片(disk slicing)。每個分割槽都有獨立的檔案系統,每塊分割槽的檔案系統可以不同。磁碟的 0 號分割槽稱為 主開機記錄(Master Boot Record, MBR),用來引導(boot) 計算機。在 MBR 的結尾是分割槽表(partition table)。每個分割槽表給出每個分割槽由開始到結束的地址。

當計算機開始引 boot 時,BIOS 讀入並執行 MBR。

引導塊

MBR 做的第一件事就是確定活動分割槽,讀入它的第一個塊,稱為引導塊(boot block) 並執行。引導塊中的程式將載入分割槽中的作業系統。為了一致性,每個分割槽都會從引導塊開始,即使引導塊不包含作業系統。引導塊佔據檔案系統的前 4096 個位元組,從磁碟上的位元組偏移量 0 開始。引導塊可用於啟動作業系統。

除了從引導塊開始之外,磁碟分割槽的佈局是隨著檔案系統的不同而變化的。通常檔案系統會包含一些屬性,如下

​ 檔案系統佈局

超級塊

緊跟在引導塊後面的是 超級塊(Superblock),超級塊 的大小為 4096 位元組,從磁碟上的位元組偏移 4096 開始。超級塊包含檔案系統的所有關鍵引數

  • 檔案系統的大小
  • 檔案系統中的資料塊數
  • 指示檔案系統狀態的標誌
  • 分配組大小

在計算機啟動或者檔案系統首次使用時,超級塊會被讀入記憶體。

空閒空間塊

接著是檔案系統中空閒塊的資訊,例如,可以用點陣圖或者指標列表的形式給出。

BitMap 點陣圖或者 Bit vector 位向量

點陣圖或位向量是一系列位或位的集合,其中每個位對應一個磁碟塊,該位可以採用兩個值:0和1,0表示已分配該塊,而1表示一個空閒塊。下圖中的磁碟上給定的磁碟塊例項(分配了綠色塊)可以用16位的點陣圖表示為:0000111000000110。

使用連結串列進行管理

在這種方法中,空閒磁碟塊連結在一起,即一個空閒塊包含指向下一個空閒塊的指標。第一個磁碟塊的塊號儲存在磁碟上的單獨位置,也快取在記憶體中。

碎片

這裡不得不提一個叫做碎片(fragment)的概念,也稱為片段。一般零散的單個資料通常稱為片段。 磁碟塊可以進一步分為固定大小的分配單元,片段只是在驅動器上彼此不相鄰的檔案片段。

inode

然後在後面是一個 inode(index node),也稱作索引節點。它是一個陣列的結構,每個檔案有一個 inode,inode 非常重要,它說明了檔案的方方面面。每個索引節點都儲存物件資料的屬性和磁碟塊位置

有一種簡單的方法可以找到它們 ls -lai 命令。讓我們看一下根檔案系統:

inode 節點主要包括了以下資訊

  • 模式/許可權(保護)
  • 所有者 ID
  • 組 ID
  • 檔案大小
  • 檔案的硬連結數
  • 上次訪問時間
  • 最後修改時間
  • inode 上次修改時間

檔案分為兩部分,索引節點和塊。一旦建立後,每種型別的塊數是固定的。你不能增加分割槽上 inode 的數量,也不能增加磁碟塊的數量。

緊跟在 inode 後面的是根目錄,它存放的是檔案系統目錄樹的根部。最後,磁碟的其他部分存放了其他所有的目錄和檔案。

檔案的實現

最重要的問題是記錄各個檔案分別用到了哪些磁碟塊。不同的系統採用了不同的方法。下面我們會探討一下這些方式。分配背後的主要思想是有效利用檔案空間快速訪問檔案 ,主要有三種分配方案

  • 連續分配
  • 連結串列分配
  • 索引分配

連續分配

最簡單的分配方案是把每個檔案作為一連串連續資料塊儲存在磁碟上。因此,在具有 1KB 塊的磁碟上,將為 50 KB 檔案分配 50 個連續塊。

​ 使用連續空間儲存檔案

上面展示了 40 個連續的記憶體塊。從最左側的 0 塊開始。初始狀態下,還沒有裝載檔案,因此磁碟是空的。接著,從磁碟開始處(塊 0 )處開始寫入佔用 4 塊長度的記憶體 A 。然後是一個佔用 6 塊長度的記憶體 B,會直接在 A 的末尾開始寫。

注意每個檔案都會在新的檔案塊開始寫,所以如果檔案 A 只佔用了 3 又 1/2 個塊,那麼最後一個塊的部分記憶體會被浪費。在上面這幅圖中,總共展示了 7 個檔案,每個檔案都會從上個檔案的末尾塊開始寫新的檔案塊。

連續的磁碟空間分配有兩個優點。

  • 第一,連續檔案儲存實現起來比較簡單,只需要記住兩個數字就可以:一個是第一個塊的檔案地址和檔案的塊數量。給定第一個塊的編號,可以通過簡單的加法找到任何其他塊的編號。

  • 第二點是讀取效能比較強,可以通過一次操作從檔案中讀取整個檔案。只需要一次尋找第一個塊。後面就不再需要尋道時間和旋轉延遲,所以資料會以全頻寬進入磁碟。

因此,連續的空間分配具有實現簡單高效能的特點。

不幸的是,連續空間分配也有很明顯的不足。隨著時間的推移,磁碟會變得很零碎。下圖解釋了這種現象

這裡有兩個檔案 D 和 F 被刪除了。當刪除一個檔案時,此檔案所佔用的塊也隨之釋放,就會在磁碟空間中留下一些空閒塊。磁碟並不會在這個位置擠壓掉空閒塊,因為這會複製空閒塊之後的所有檔案,可能會有上百萬的塊,這個量級就太大了。

連結串列分配

第二種儲存檔案的方式是為每個檔案構造磁碟塊連結串列,每個檔案都是磁碟塊的連結列表,就像下面所示

​ 以磁碟塊的連結串列形式儲存檔案

每個塊的第一個字作為指向下一塊的指標,塊的其他部分存放資料。如果上面這張圖你看的不是很清楚的話,可以看看整個的連結串列分配方案

與連續分配方案不同,這一方法可以充分利用每個磁碟塊。除了最後一個磁碟塊外,不會因為磁碟碎片而浪費儲存空間。同樣,在目錄項中,只要儲存了第一個檔案塊,那麼其他檔案塊也能夠被找到。

另一方面,在連結串列的分配方案中,儘管順序讀取非常方便,但是隨機訪問卻很困難(這也是陣列和連結串列資料結構的一大區別)。

還有一個問題是,由於指標會佔用一些位元組,每個磁碟塊實際儲存資料的位元組數並不再是 2 的整數次冪。雖然這個問題並不會很嚴重,但是這種方式降低了程式執行效率。許多程式都是以長度為 2 的整數次冪來讀寫磁碟,由於每個塊的前幾個位元組被指標所使用,所以要讀出一個完成的塊大小資訊,就需要當前塊的資訊和下一塊的資訊拼湊而成,因此就引發了查詢和拼接的開銷。

使用記憶體表進行連結串列分配

由於連續分配和連結串列分配都有其不可忽視的缺點。所以提出了使用記憶體中的表來解決分配問題。取出每個磁碟塊的指標字,把它們放在記憶體的一個表中,就可以解決上述連結串列的兩個不足之處。下面是一個例子

上圖表示了連結串列形成的磁碟塊的內容。這兩個圖中都有兩個檔案,檔案 A 依次使用了磁碟塊地址 4、7、 2、 10、 12,檔案 B 使用了6、3、11 和 14。也就是說,檔案 A 從地址 4 處開始,順著連結串列走就能找到檔案 A 的全部磁碟塊。同樣,從第 6 塊開始,順著鏈走到最後,也能夠找到檔案 B 的全部磁碟塊。你會發現,這兩個連結串列都以不屬於有效磁碟編號的特殊標記(-1)結束。記憶體中的這種表格稱為 檔案分配表(File Application Table,FAT)

目錄的實現

檔案只有開啟後才能夠被讀取。在檔案開啟後,作業系統會使用使用者提供的路徑名來定位磁碟中的目錄。目錄項提供了查詢檔案磁碟塊所需要的資訊。根據系統的不同,提供的資訊也不同,可能提供的資訊是整個檔案的磁碟地址,或者是第一個塊的數量(兩個連結串列方案)或 inode的數量。不過不管用那種情況,目錄系統的主要功能就是 將檔案的 ASCII 碼的名稱對映到定位資料所需的資訊上

共享檔案

當多個使用者在同一個專案中工作時,他們通常需要共享檔案。如果這個共享檔案同時出現在多個使用者目錄下,那麼他們協同工作起來就很方便。下面的這張圖我們在上面提到過,但是有一個更改的地方,就是 C 的一個檔案也出現在了 B 的目錄下

如果按照如上圖的這種組織方式而言,那麼 B 的目錄與該共享檔案的聯絡稱為 連結(link)。那麼檔案系統現在就是一個 有向無環圖(Directed Acyclic Graph, 簡稱 DAG),而不是一棵樹了。

日誌結構檔案系統

技術的改變會給當前的檔案系統帶來壓力。這種情況下,CPU 會變得越來越快,磁碟會變得越來越大並且越來越便宜(但不會越來越快)。記憶體容量也是以指數級增長。但是磁碟的尋道時間(除了固態盤,因為固態盤沒有尋道時間)並沒有獲得提高。

為此,Berkeley 設計了一種全新的檔案系統,試圖緩解這個問題,這個檔案系統就是 日誌結構檔案系統(Log-structured File System, LFS)。旨在解決以下問題。

  • 不斷增長的系統記憶體

  • 順序 I/O 效能勝過隨機 I/O 效能

  • 現有低效率的檔案系統

  • 檔案系統不支援 RAID(虛擬化)

另一方面,當時的檔案系統不論是 UNIX 還是 FFS,都有大量的隨機讀寫(在 FFS 中建立一個新檔案至少需要5次隨機寫),因此成為整個系統的效能瓶頸。同時因為 Page cache 的存在,作者認為隨機讀不是主要問題:隨著越來越大的記憶體,大部分的讀操作都能被 cache,因此 LFS 主要要解決的是減少對硬碟的隨機寫操作。

在這種設計中,inode 甚至具有與 UNIX 中相同的結構,但是現在它們分散在整個日誌中,而不是位於磁碟上的固定位置。所以,inode 很定位。為了能夠找到 inode ,維護了一個由 inode 索引的 inode map(inode 對映)。表項 i 指向磁碟中的第 i 個 inode 。這個對映儲存在磁碟中,但是也儲存在快取中,因此,使用最頻繁的部分大部分時間都在記憶體中。

到目前為止,所有寫入最初都快取在記憶體中,並且追加在日誌末尾,所有快取的寫入都定期在單個段中寫入磁碟。所以,現在開啟檔案也就意味著用對映定位檔案的索引節點。一旦 inode 被定位後,磁碟塊的地址就能夠被找到。所有這些塊本身都將位於日誌中某處的分段中。

真實情況下的磁碟容量是有限的,所以最終日誌會佔滿整個磁碟空間,這種情況下就會出現沒有新的磁碟塊被寫入到日誌中。幸運的是,許多現有段可能具有不再需要的塊。例如,如果一個檔案被覆蓋了,那麼它的 inode 將被指向新的塊,但是舊的磁碟塊仍在先前寫入的段中佔據著空間。

為了處理這個問題,LFS 有一個清理(clean)執行緒,它會迴圈掃描日誌並對日誌進行壓縮。首先,通過檢視日誌中第一部分的資訊來檢視其中存在哪些索引節點和檔案。它會檢查當前 inode 的對映來檢視 inode 否在在當前塊中,是否仍在被使用。如果不是,該資訊將被丟棄。如果仍然在使用,那麼 inode 和塊就會進入記憶體等待寫回到下一個段中。然後原來的段被標記為空閒,以便日誌可以用來存放新的資料。用這種方法,清理執行緒遍歷日誌,從後面移走舊的段,然後將有效的資料放入記憶體等待寫到下一個段中。由此一來整個磁碟會形成一個大的環形緩衝區,寫執行緒將新的段寫在前面,而清理執行緒則清理後面的段。

日誌檔案系統

雖然日誌結構系統的設計很優雅,但是由於它們和現有的檔案系統不相匹配,因此還沒有廣泛使用。不過,從日誌檔案結構系統衍生出來一種新的日誌系統,叫做日誌檔案系統,它會記錄系統下一步將要做什麼的日誌。微軟的 NTFS 檔案系統、Linux 的 ext3 就使用了此日誌。 OS X 將日誌系統作為可供選項。為了看清它是如何工作的,我們下面討論一個例子,比如 移除檔案 ,這個操作在 UNIX 中需要三個步驟完成:

  • 在目錄中刪除檔案
  • 釋放 inode 到空閒 inode 池
  • 將所有磁碟塊歸還給空閒磁碟池。

虛擬檔案系統

UNIX 作業系統使用一種 虛擬檔案系統(Virtual File System, VFS) 來嘗試將多種檔案系統構成一個有序的結構。關鍵的思想是抽象出所有檔案系統都共有的部分,並將這部分程式碼放在一層,這一層再呼叫具體檔案系統來管理資料。下面是一個 VFS 的系統結構

還是那句經典的話,在計算機世界中,任何解決不了的問題都可以加個代理來解決。所有和檔案相關的系統呼叫在最初的處理上都指向虛擬檔案系統。這些來自使用者程式的呼叫,都是標準的 POSIX 系統呼叫,比如 open、read、write 和 seek 等。VFS 對使用者程式有一個 上層 介面,這個介面就是著名的 POSIX 介面。

檔案系統的管理和優化

能夠使檔案系統工作是一回事,能夠使檔案系統高效、穩定的工作是另一回事,下面我們就來探討一下檔案系統的管理和優化。

磁碟空間管理

檔案通常存在磁碟中,所以如何管理磁碟空間是一個作業系統的設計者需要考慮的問題。在檔案上進行存有兩種策略:分配 n 個位元組的連續磁碟空間;或者把檔案拆分成多個並不一定連續的塊。在儲存管理系統中,主要有分段管理分頁管理 兩種方式。

正如我們所看到的,按連續位元組序列儲存檔案有一個明顯的問題,當檔案擴大時,有可能需要在磁碟上移動檔案。記憶體中分段也有同樣的問題。不同的是,相對於把檔案從磁碟的一個位置移動到另一個位置,記憶體中段的移動操作要快很多。因此,幾乎所有的檔案系統都把檔案分割成固定大小的塊來儲存。

塊大小

一旦把檔案分為固定大小的塊來儲存,就會出現問題,塊的大小是多少?按照磁碟組織方式,扇區、磁軌和柱面顯然都可以作為分配單位。在分頁系統中,分頁大小也是主要因素。

擁有大的塊尺寸意味著每個檔案,甚至 1 位元組檔案,都要佔用一個柱面空間,也就是說小檔案浪費了大量的磁碟空間。另一方面,小塊意味著大部分檔案將會跨越多個塊,因此需要多次搜尋和旋轉延遲才能讀取它們,從而降低了效能。因此,如果分配的塊太大會浪費空間;分配的塊太小會浪費時間

記錄空閒塊

一旦指定了塊大小,下一個問題就是怎樣跟蹤空閒塊。有兩種方法被廣泛採用,如下圖所示

第一種方法是採用磁碟塊連結串列,連結串列的每個塊中包含極可能多的空閒磁碟塊號。對於 1 KB 的塊和 32 位的磁碟塊號,空閒表中每個塊包含有 255 個空閒的塊號。考慮 1 TB 的硬碟,擁有大概十億個磁碟塊。為了儲存全部地址塊號,如果每塊可以儲存 255 個塊號,則需要將近 400 萬個塊。通常,空閒塊用於儲存空閒列表,因此儲存基本上是空閒的。

另一種空閒空間管理的技術是點陣圖(bitmap),n 個塊的磁碟需要 n 位點陣圖。在點陣圖中,空閒塊用 1 表示,已分配的塊用 0 表示。對於 1 TB 硬碟的例子,需要 10 億位表示,即需要大約 130 000 個 1 KB 塊儲存。很明顯,和 32 位連結串列模型相比,點陣圖需要的空間更少,因為每個塊使用 1 位。只有當磁碟快滿的時候,連結串列需要的塊才會比點陣圖少。

磁碟配額

為了防止一些使用者佔用太多的磁碟空間,多使用者操作通常提供一種磁碟配額(enforcing disk quotas)的機制。系統管理員為每個使用者分配最大的檔案和塊分配,並且作業系統確保使用者不會超過其配額。我們下面會談到這一機制。

在使用者開啟一個檔案時,作業系統會找到檔案屬性磁碟地址,並把它們送入記憶體中的開啟檔案表。其中一個屬性告訴檔案所有者是誰。任何有關檔案的增加都會記到所有者的配額中。

​ 配額表中記錄了每個使用者的配額

第二張表包含了每個使用者當前開啟檔案的配額記錄,即使是其他人開啟該檔案也一樣。如上圖所示,該表的內容是從被開啟檔案的所有者的磁碟配額檔案中提取出來的。當所有檔案關閉時,該記錄被寫回配額檔案。

當在開啟檔案表中建立一新表項時,會產生一個指向所有者配額記錄的指標。每次向檔案中新增一個塊時,檔案所有者所用資料塊的總數也隨之增加,並會同時增加硬限制軟限制的檢查。可以超出軟限制,但硬限制不可以超出。當已達到硬限制時,再往檔案中新增內容將引發錯誤。同樣,對檔案數目也存在類似的檢查。

檔案系統備份

做檔案備份很耗費時間而且也很浪費空間,這會引起下面幾個問題。首先,是要備份整個檔案還是僅備份一部分呢?一般來說,只是備份特定目錄及其下的全部檔案,而不是備份整個檔案系統。

其次,對上次未修改過的檔案再進行備份是一種浪費,因而產生了一種增量轉儲(incremental dumps) 的思想。最簡單的增量轉儲的形式就是週期性的做全面的備份,而每天只對增量轉儲完成後發生變化的檔案做單個備份。

稍微好一點的方式是隻備份最近一次轉儲以來更改過的檔案。當然,這種做法極大的縮減了轉儲時間,但恢復起來卻更復雜,因為最近的全面轉儲先要全部恢復,隨後按逆序進行增量轉儲。為了方便恢復,人們往往使用更復雜的轉儲模式。

第三,既然待轉儲的往往是海量資料,那麼在將其寫入磁帶之前對檔案進行壓縮就很有必要。但是,如果在備份過程中出現了檔案損壞的情況,就會導致破壞壓縮演算法,從而使整個磁帶無法讀取。所以在備份前是否進行檔案壓縮需慎重考慮。

第四,對正在使用的檔案系統做備份是很難的。如果在轉儲過程中要新增,刪除和修改檔案和目錄,則轉儲結果可能不一致。因此,因為轉儲過程中需要花費數個小時的時間,所以有必要在晚上將系統離線進行備份,然而這種方式的接受程度並不高。所以,人們修改了轉儲演算法,記下檔案系統的瞬時快照,即複製關鍵的資料結構,然後需要把將來對檔案和目錄所做的修改複製到塊中,而不是到處更新他們。

磁碟轉儲到備份磁碟上有兩種方案:物理轉儲和邏輯轉儲物理轉儲(physical dump) 是從磁碟的 0 塊開始,依次將所有磁碟塊按照順序寫入到輸出磁碟,並在複製最後一個磁碟時停止。這種程式的萬無一失性是其他程式所不具備的。

第二個需要考慮的是壞塊的轉儲。製造大型磁碟而沒有瑕疵是不可能的,所以也會存在一些壞塊(bad blocks)。有時進行低階格式化後,壞塊會被檢測出來並進行標記,這種情況的解決辦法是用磁碟末尾的一些空閒塊所替換。

然而,一些塊在格式化後會變壞,在這種情況下作業系統可以檢測到它們。通常情況下,它可以通過建立一個由所有壞塊組成的檔案來解決問題,確保它們不會出現在空閒池中並且永遠不會被分配。那麼此檔案是完全不可讀的。如果磁碟控制器將所有的壞塊重新對映,物理轉儲還是能夠正常工作的。

Windows 系統有分頁檔案(paging files)休眠檔案(hibernation files) 。它們在檔案還原時不發揮作用,同時也不應該在第一時間進行備份。

檔案系統的一致性

影響可靠性的一個因素是檔案系統的一致性。許多檔案系統讀取磁碟塊、修改磁碟塊、再把它們寫回磁碟。如果系統在所有塊寫入之前崩潰,檔案系統就會處於一種不一致(inconsistent)的狀態。如果某些尚未寫回的塊是索引節點塊,目錄塊或包含空閒列表的塊,則此問題是很嚴重的。

為了處理檔案系統一致性問題,大部分計算機都會有應用程式來檢查檔案系統的一致性。例如,UNIX 有 fsck;Windows 有 sfc,每當引導系統時(尤其是在崩潰後),都可以執行該程式。

可以進行兩種一致性檢查:塊的一致性檢查和檔案的一致性檢查。為了檢查塊的一致性,應用程式會建立兩張表,每個包含一個計數器的塊,最初設定為 0 。第一個表中的計數器跟蹤該塊在檔案中出現的次數,第二張表中的計數器記錄每個塊在空閒列表、空閒點陣圖中出現的頻率。

檔案系統效能

訪問磁碟的效率要比記憶體滿的多,是時候又祭出這張圖了

從記憶體讀一個 32 位字大概是 10ns,從硬碟上讀的速率大概是 100MB/S,對每個 32 位字來說,效率會慢了四倍,另外,還要加上 5 - 10 ms 的尋道時間等其他損耗,如果只訪問一個字,記憶體要比磁碟快百萬數量級。所以磁碟優化是很有必要的,下面我們會討論幾種優化方式

快取記憶體

最常用的減少磁碟訪問次數的技術是使用 塊快取記憶體(block cache) 或者 緩衝區快取記憶體(buffer cache)。快取記憶體指的是一系列的塊,它們在邏輯上屬於磁碟,但實際上基於效能的考慮被儲存在記憶體中。

管理快取記憶體有不同的演算法,常用的演算法是:檢查全部的讀請求,檢視在快取記憶體中是否有所需要的塊。如果存在,可執行讀操作而無須訪問磁碟。如果檢查塊不再快取記憶體中,那麼首先把它讀入快取記憶體,再複製到所需的地方。之後,對同一個塊的請求都通過快取記憶體來完成。

快取記憶體的操作如下圖所示

由於在快取記憶體中有許多塊,所以需要某種方法快速確定所需的塊是否存在。常用方法是將裝置和磁碟地址進行雜湊操作,然後,在雜湊表中查詢結果。具有相同雜湊值的塊在一個連結串列中連線在一起(這個資料結構是不是很像 HashMap?),這樣就可以沿著衝突鏈查詢其他塊。

如果快取記憶體已滿,此時需要調入新的塊,則要把原來的某一塊調出快取記憶體,如果要調出的塊在上次調入後已經被修改過,則需要把它寫回磁碟。

塊提前讀

第二個明顯提高檔案系統的效能是,在需要用到塊之前,試圖提前將其寫入快取記憶體,從而提高命中率。許多檔案都是順序讀取。如果請求檔案系統在某個檔案中生成塊 k,檔案系統執行相關操作並且在完成之後,會檢查快取記憶體,以便確定塊 k + 1 是否已經在快取記憶體。如果不在,檔案系統會為 k + 1 安排一個預讀取,因為檔案希望在用到該塊的時候能夠直接從快取記憶體中讀取。

當然,塊提前讀取策略只適用於實際順序讀取的檔案。對隨機訪問的檔案,提前讀絲毫不起作用。甚至還會造成阻礙。

減少磁碟臂運動

快取記憶體和塊提前讀並不是提高檔案系統效能的唯一方法。另一種重要的技術是把有可能順序訪問的塊放在一起,當然最好是在同一個柱面上,從而減少磁碟臂的移動次數。當寫一個輸出檔案時,檔案系統就必須按照要求一次一次地分配磁碟塊。如果用點陣圖來記錄空閒塊,並且整個點陣圖在記憶體中,那麼選擇與前一塊最近的空閒塊是很容易的。如果用空閒表,並且連結串列的一部分存在磁碟上,要分配緊鄰的空閒塊就會困難很多。

磁碟碎片整理

在初始安裝作業系統後,檔案就會被不斷的建立和清除,於是磁碟會產生很多的碎片,在建立一個檔案時,它使用的塊會散佈在整個磁碟上,降低效能。刪除檔案後,回收磁碟塊,可能會造成空穴。

磁碟效能可以通過如下方式恢復:移動檔案使它們相互挨著,並把所有的至少是大部分的空閒空間放在一個或多個大的連續區域內。Windows 有一個程式 defrag 就是做這個事兒的。Windows 使用者會經常使用它,SSD 除外。

磁碟碎片整理程式會在讓檔案系統上很好地執行。Linux 檔案系統(特別是 ext2 和 ext3)由於其選擇磁碟塊的方式,在磁碟碎片整理上一般不會像 Windows 一樣困難,因此很少需要手動的磁碟碎片整理。而且,固態硬碟並不受磁碟碎片的影響,事實上,在固態硬碟上做磁碟碎片整理反倒是多此一舉,不僅沒有提高效能,反而磨損了固態硬碟。所以碎片整理只會縮短固態硬碟的壽命。

下面我們來探討一下 I/O 流程問題。

I/O 裝置

什麼是 I/O 裝置?I/O 裝置又叫做輸入/輸出裝置,它是人類用來和計算機進行通訊的外部硬體。輸入/輸出裝置能夠向計算機傳送資料(輸出)並從計算機接收資料(輸入)

I/O 裝置(I/O devices)可以分成兩種:塊裝置(block devices)字元裝置(character devices)

塊裝置

塊裝置是一個能儲存固定大小塊資訊的裝置,它支援以固定大小的塊,扇區或群集讀取和(可選)寫入資料。每個塊都有自己的實體地址。通常塊的大小在 512 - 65536 之間。所有傳輸的資訊都會以連續的塊為單位。塊裝置的基本特徵是每個塊都較為對立,能夠獨立的進行讀寫。常見的塊裝置有 硬碟、藍光光碟、USB 盤

與字元裝置相比,塊裝置通常需要較少的引腳。

塊裝置的缺點

基於給定固態儲存器的塊裝置比基於相同型別的儲存器的位元組定址要慢一些,因為必須在塊的開頭開始讀取或寫入。所以,要讀取該塊的任何部分,必須尋找到該塊的開始,讀取整個塊,如果不使用該塊,則將其丟棄。要寫入塊的一部分,必須尋找到塊的開始,將整個塊讀入記憶體,修改資料,再次尋找到塊的開頭處,然後將整個塊寫回裝置。

字元裝置

另一類 I/O 裝置是字元裝置。字元裝置以字元為單位傳送或接收一個字元流,而不考慮任何塊結構。字元裝置是不可定址的,也沒有任何尋道操作。常見的字元裝置有 印表機、網路裝置、滑鼠、以及大多數與磁碟不同的裝置

裝置控制器

裝置控制器是處理 CPU 傳入和傳出訊號的系統。裝置通過插頭和插座連線到計算機,並且插座連線到裝置控制器。裝置控制器從連線的裝置處接收資料,並將其儲存在控制器內部的一些特殊目的暫存器(special purpose registers) 也就是本地緩衝區中。

每個裝置控制器都會有一個應用程式與之對應,裝置控制器通過應用程式的介面通過中斷與作業系統進行通訊。裝置控制器是硬體,而裝置驅動程式是軟體。

記憶體對映 I/O

每個控制器都會有幾個暫存器用來和 CPU 進行通訊。通過寫入這些暫存器,作業系統可以命令裝置傳送資料,接收資料、開啟或者關閉裝置等。通過從這些暫存器中讀取資訊,作業系統能夠知道裝置的狀態,是否準備接受一個新命令等。

為了控制暫存器,許多裝置都會有資料緩衝區(data buffer),來供系統進行讀寫。

那麼問題來了,CPU 如何與裝置暫存器和裝置資料緩衝區進行通訊呢?存在兩個可選的方式。第一種方法是,每個控制暫存器都被分配一個 I/O 埠(I/O port)號,這是一個 8 位或 16 位的整數。所有 I/O 埠的集合形成了受保護的 I/O 埠空間,以便普通使用者程式無法訪問它(只有作業系統可以訪問)。使用特殊的 I/O 指令像是

IN REG,PORT

CPU 可以讀取控制暫存器 PORT 的內容並將結果放在 CPU 暫存器 REG 中。類似的,使用

OUT PORT,REG

CPU 可以將 REG 的內容寫到控制暫存器中。大多數早期計算機,包括幾乎所有大型主機,如 IBM 360 及其所有後續機型,都是以這種方式工作的。

第二個方法是 PDP-11 引入的,它將所有控制暫存器對映到記憶體空間中。

直接記憶體訪問

無論一個 CPU 是否具有記憶體對映 I/O,它都需要定址裝置控制器以便與它們交換資料。CPU 可以從 I/O 控制器每次請求一個位元組的資料,但是這麼做會浪費 CPU 時間,所以經常會用到一種稱為直接記憶體訪問(Direct Memory Access) 的方案。為了簡化,我們假設 CPU 通過單一的系統匯流排訪問所有的裝置和記憶體,該匯流排連線 CPU 、記憶體和 I/O 裝置,如下圖所示

​ DMA 傳送操作

現代作業系統實際更為複雜,但是原理是相同的。如果硬體有 DMA 控制器,那麼作業系統只能使用 DMA。有時這個控制器會整合到磁碟控制器和其他控制器中,但這種設計需要在每個裝置上都裝有一個分離的 DMA 控制器。單個的 DMA 控制器可用於向多個裝置傳輸,這種傳輸往往同時進行。

DMA 工作原理

首先 CPU 通過設定 DMA 控制器的暫存器對它進行程式設計,所以 DMA 控制器知道將什麼資料傳送到什麼地方。DMA 控制器還要向磁碟控制器發出一個命令,通知它從磁碟讀資料到其內部的緩衝區並檢驗校驗和。當有效資料位於磁碟控制器的緩衝區中時,DMA 就可以開始了。

DMA 控制器通過在匯流排上發出一個讀請求到磁碟控制器而發起 DMA 傳送,這是第二步。這個讀請求就像其他讀請求一樣,磁碟控制器並不知道或者並不關心它是來自 CPU 還是來自 DMA 控制器。通常情況下,要寫的記憶體地址在匯流排的地址線上,所以當磁碟控制器去匹配下一個字時,它知道將該字寫到什麼地方。寫到記憶體就是另外一個匯流排迴圈了,這是第三步。當寫操作完成時,磁碟控制器在匯流排上發出一個應答訊號到 DMA 控制器,這是第四步。

然後,DMA 控制器會增加記憶體地址並減少位元組數量。如果位元組數量仍然大於 0 ,就會迴圈步驟 2 - 步驟 4 ,直到位元組計數變為 0 。此時,DMA 控制器會打斷 CPU 並告訴它傳輸已經完成了。

重溫中斷

在一臺個人計算機體系結構中,中斷結構會如下所示

​ 中斷是怎樣發生的

當一個 I/O 裝置完成它的工作後,它就會產生一箇中斷(預設作業系統已經開啟中斷),它通過在匯流排上宣告已分配的訊號來實現此目的。主機板上的中斷控制器晶片會檢測到這個訊號,然後執行中斷操作。

精確中斷和不精確中斷

使機器處於良好狀態的中斷稱為精確中斷(precise interrupt)。這樣的中斷具有四個屬性:

  • PC (程式計數器)儲存在一個已知的地方
  • PC 所指向的指令之前所有的指令已經完全執行
  • PC 所指向的指令之後所有的指令都沒有執行
  • PC 所指向的指令的執行狀態是已知的

不滿足以上要求的中斷稱為 不精確中斷(imprecise interrupt),不精確中斷讓人很頭疼。上圖描述了不精確中斷的現象。指令的執行時序和完成度具有不確定性,而且恢復起來也非常麻煩。

IO 軟體原理

I/O 軟體目標

裝置獨立性

I/O 軟體設計一個很重要的目標就是裝置獨立性(device independence)。這意味著我們能夠編寫訪問任何裝置的應用程式,而不用事先指定特定的裝置

錯誤處理

除了裝置獨立性外,I/O 軟體實現的第二個重要的目標就是錯誤處理(error handling)。通常情況下來說,錯誤應該交給硬體層面去處理。如果裝置控制器發現了讀錯誤的話,它會盡可能的去修復這個錯誤。如果裝置控制器處理不了這個問題,那麼裝置驅動程式應該進行處理,裝置驅動程式會再次嘗試讀取操作,很多錯誤都是偶然性的,如果裝置驅動程式無法處理這個錯誤,才會把錯誤向上拋到硬體層面(上層)進行處理,很多時候,上層並不需要知道下層是如何解決錯誤的。

同步和非同步傳輸

I/O 軟體實現的第三個目標就是 同步(synchronous)非同步(asynchronous,即中斷驅動)傳輸。這裡先說一下同步和非同步是怎麼回事吧。

同步傳輸中資料通常以塊或幀的形式傳送。傳送方和接收方在資料傳輸之前應該具有同步時鐘。而在非同步傳輸中,資料通常以位元組或者字元的形式傳送,非同步傳輸則不需要同步時鐘,但是會在傳輸之前向資料新增奇偶校驗位。大部分物理IO(physical I/O) 是非同步的。物理 I/O 中的 CPU 是很聰明的,CPU 傳輸完成後會轉而做其他事情,它和中斷心靈相通,等到中斷髮生後,CPU 才會回到傳輸這件事情上來。

緩衝

I/O 軟體的最後一個問題是緩衝(buffering)。通常情況下,從一個裝置發出的資料不會直接到達最後的裝置。其間會經過一系列的校驗、檢查、緩衝等操作才能到達。

共享和獨佔

I/O 軟體引起的最後一個問題就是共享裝置和獨佔裝置的問題。有些 I/O 裝置能夠被許多使用者共同使用。一些裝置比如磁碟,讓多個使用者使用一般不會產生什麼問題,但是某些裝置必須具有獨佔性,即只允許單個使用者使用完成後才能讓其他使用者使用。

一共有三種控制 I/O 裝置的方法

  • 使用程式控制 I/O
  • 使用中斷驅動 I/O
  • 使用 DMA 驅動 I/O

I/O 層次結構

I/O 軟體通常組織成四個層次,它們的大致結構如下圖所示

下面我們具體的來探討一下上面的層次結構

中斷處理程式

在計算機系統中,中斷就像女人的脾氣一樣無時無刻都在產生,中斷的出現往往是讓人很不爽的。中斷處理程式又被稱為中斷服務程式 或者是 ISR(Interrupt Service Routines),它是最靠近硬體的一層。中斷處理程式由硬體中斷、軟體中斷或者是軟體異常啟動產生的中斷,用於實現裝置驅動程式或受保護的操作模式(例如系統呼叫)之間的轉換。

中斷處理程式負責處理中斷髮生時的所有操作,操作完成後阻塞,然後啟動中斷驅動程式來解決阻塞。通常會有三種通知方式,依賴於不同的具體實現

  • 訊號量實現中:在訊號量上使用 up 進行通知;
  • 管程實現:對管程中的條件變數執行 signal 操作
  • 還有一些情況是傳送一些訊息

裝置驅動程式

每個連線到計算機的 I/O 裝置都需要有某些特定裝置的程式碼對其進行控制。這些提供 I/O 裝置到裝置控制器轉換的過程的程式碼稱為 裝置驅動程式(Device driver)

裝置控制器的主要功能有下面這些

  • 接收和識別命令:裝置控制器可以接受來自 CPU 的指令,並進行識別。裝置控制器內部也會有暫存器,用來存放指令和引數

  • 進行資料交換:CPU、控制器和裝置之間會進行資料的交換,CPU 通過匯流排把指令傳送給控制器,或從控制器中並行地讀出資料;控制器將資料寫入指定裝置。

  • 地址識別:每個硬體裝置都有自己的地址,裝置控制器能夠識別這些不同的地址,來達到控制硬體的目的,此外,為使 CPU 能向暫存器中寫入或者讀取資料,這些暫存器都應具有唯一的地址。

  • 差錯檢測:裝置控制器還具有對裝置傳遞過來的資料進行檢測的功能。

在這種情況下,裝置控制器會阻塞,直到中斷來解除阻塞狀態。還有一種情況是操作是可以無延遲的完成,所以驅動程式不需要阻塞。在第一種情況下,作業系統可能被中斷喚醒;第二種情況下作業系統不會被休眠。

裝置驅動程式必須是可重入的,因為裝置驅動程式會阻塞和喚醒然後再次阻塞。驅動程式不允許進行系統呼叫,但是它們通常需要與核心的其餘部分進行互動。

與裝置無關的 I/O 軟體

I/O 軟體有兩種,一種是我們上面介紹過的基於特定裝置的,還有一種是裝置無關性的,裝置無關性也就是不需要特定的裝置。裝置驅動程式與裝置無關的軟體之間的界限取決於具體的系統。下面顯示的功能由裝置無關的軟體實現

與裝置無關的軟體的基本功能是對所有裝置執行公共的 I/O 功能,並且向使用者層軟體提供一個統一的介面。

緩衝

無論是對於塊裝置還是字元裝置來說,緩衝都是一個非常重要的考量標準。緩衝技術應用廣泛,但它也有缺點。如果資料被緩衝次數太多,會影響效能。

錯誤處理

在 I/O 中,出錯是一種再正常不過的情況了。當出錯發生時,作業系統必須儘可能處理這些錯誤。有一些錯誤是隻有特定的裝置才能處理,有一些是由框架進行處理,這些錯誤和特定的裝置無關。

I/O 錯誤的一類是程式設計師程式設計錯誤,比如還沒有開啟檔案前就讀流,或者不關閉流導致記憶體溢位等等。這類問題由程式設計師處理;另外一類是實際的 I/O 錯誤,例如向一個磁碟壞塊寫入資料,無論怎麼寫都寫入不了。這類問題由驅動程式處理,驅動程式處理不了交給硬體處理,這個我們上面也說過。

裝置驅動程式統一介面

我們在作業系統概述中說到,作業系統一個非常重要的功能就是遮蔽了硬體和軟體的差異性,為硬體和軟體提供了統一的標準,這個標準還體現在為裝置驅動程式提供統一的介面,因為不同的硬體和廠商編寫的裝置驅動程式不同,所以如果為每個驅動程式都單獨提供介面的話,這樣沒法搞,所以必須統一。

分配和釋放

一些裝置例如印表機,它只能由一個程式來使用,這就需要作業系統根據實際情況判斷是否能夠對裝置的請求進行檢查,判斷是否能夠接受其他請求,一種比較簡單直接的方式是在特殊檔案上執行 open操作。如果裝置不可用,那麼直接 open 會導致失敗。還有一種方式是不直接導致失敗,而是讓其阻塞,等到另外一個程式釋放資源後,在進行 open 開啟操作。這種方式就把選擇權交給了使用者,由使用者判斷是否應該等待。

裝置無關的塊

不同的磁碟會具有不同的扇區大小,但是軟體不會關心扇區大小,只管儲存就是了。一些字元裝置可以一次一個位元組的交付資料,而其他的裝置則以較大的單位交付資料,這些差異也可以隱藏起來。

使用者空間的 I/O 軟體

雖然大部分 I/O 軟體都在核心結構中,但是還有一些在使用者空間實現的 I/O 軟體,凡事沒有絕對。一些 I/O 軟體和庫過程在使用者空間存在,然後以提供系統呼叫的方式實現。

盤可以說是硬體裡面比較簡單的構造了,同時也是最重要的。下面我們從盤談起,聊聊它的物理構造

盤硬體

盤會有很多種型別。其中最簡單的構造就是磁碟(magnetic hard disks), 也被稱為 hard disk,HDD等。磁碟通常與安裝在磁臂上的磁頭配對,磁頭可將資料讀取或者將資料寫入磁碟,因此磁碟的讀寫速度都同樣快。在磁碟中,資料是隨機訪問的,這也就說明可以通過任意的順序來儲存檢索單個資料塊,所以你可以在任意位置放置磁碟來讓磁頭讀取,磁碟是一種非易失性的裝置,即使斷電也能永久保留。

磁碟

為了組織和檢索資料,會將磁碟組織成特定的結構,這些特定的結構就是磁軌、扇區和柱面

磁碟被組織成柱面形式,每個盤用軸相連,每一個柱麵包含若干磁軌,每個磁軌由若干扇區組成。軟盤上大約每個磁軌有 8 - 32 個扇區,硬碟上每條磁軌上扇區的數量可達幾百個,磁頭大約是 1 - 16 個。

對於磁碟驅動程式來說,一個非常重要的特性就是控制器是否能夠同時控制兩個或者多個驅動器進行磁軌定址,這就是重疊尋道(overlapped seek)。對於控制器來說,它能夠控制一個磁碟驅動程式完成尋道操作,同時讓其他驅動程式等待尋道結束。控制器也可以在一個驅動程式上進行讀寫草哦做,與此同時讓另外的驅動器進行尋道操作,但是軟盤控制器不能在兩個驅動器上進行讀寫操作。

RAID

RAID 稱為 磁碟冗餘陣列,簡稱 磁碟陣列。利用虛擬化技術把多個硬碟結合在一起,成為一個或多個磁碟陣列組,目的是提升效能或資料冗餘。

RAID 有不同的級別

  • RAID 0 - 無容錯的條帶化磁碟陣列
  • RAID 1 - 映象和雙工
  • RAID 2 - 記憶體式糾錯碼
  • RAID 3 - 位元交錯奇偶校驗
  • RAID 4 - 塊交錯奇偶校驗
  • RAID 5 - 塊交錯分散式奇偶校驗
  • RAID 6 - P + Q冗餘

磁碟格式化

磁碟由一堆鋁的、合金或玻璃的碟片組成,磁碟剛被建立出來後,沒有任何資訊。磁碟在使用前必須經過低階格式化(low-levvel format),下面是一個扇區的格式

前導碼相當於是標示扇區的開始位置,通常以位模式開始,前導碼還包括柱面號扇區號等一些其他資訊。緊隨前導碼後面的是資料區,資料部分的大小由低階格式化程式來確定。大部分磁碟使用 512 位元組的扇區。資料區後面是 ECC,ECC 的全稱是 error correction code資料糾錯碼,它與普通的錯誤檢測不同,ECC 還可以用於恢復讀錯誤。ECC 階段的大小由不同的磁碟製造商實現。ECC 大小的設計標準取決於設計者願意犧牲多少磁碟空間來提高可靠性,以及程式可以處理的 ECC 的複雜程度。通常情況下 ECC 是 16 位,除此之外,硬碟一般具有一定數量的備用扇區,用於替換製造缺陷的扇區。

磁碟臂排程演算法

下面我們來探討一下關於影響磁碟讀寫的演算法,一般情況下,影響磁碟快讀寫的時間由下面幾個因素決定

  • 尋道時間 - 尋道時間指的就是將磁碟臂移動到需要讀取磁碟塊上的時間
  • 旋轉延遲 - 等待合適的扇區旋轉到磁頭下所需的時間
  • 實際資料的讀取或者寫入時間

這三種時間引數也是磁碟尋道的過程。一般情況下,尋道時間對總時間的影響最大,所以,有效的降低尋道時間能夠提高磁碟的讀取速度。

如果磁碟驅動程式每次接收一個請求並按照接收順序完成請求,這種處理方式也就是 先來先服務(First-Come, First-served, FCFS) ,這種方式很難優化尋道時間。因為每次都會按照順序處理,不管順序如何,有可能這次讀完後需要等待一個磁碟旋轉一週才能繼續讀取,而其他柱面能夠馬上進行讀取,這種情況下每次請求也會排隊。

通常情況下,磁碟在進行尋道時,其他程式會產生其他的磁碟請求。磁碟驅動程式會維護一張表,表中會記錄著柱面號當作索引,每個柱面未完成的請求會形成連結串列,連結串列頭存放在表的相應表項中。

一種對先來先服務的演算法改良的方案是使用 最短路徑優先(SSF) 演算法,下面描述了這個演算法。

假如我們在對磁軌 6 號進行定址時,同時發生了對 11 , 2 , 4, 14, 8, 15, 3 的請求,如果採用先來先服務的原則,如下圖所示

我們可以計算一下磁碟臂所跨越的磁碟數量為 5 + 9 + 2 + 10 + 6 + 7 + 12 = 51,相當於是跨越了 51 次盤面,如果使用最短路徑優先,我們來計算一下跨越的盤面

跨越的磁碟數量為 4 + 1 + 1 + 4 + 3 + 3 + 1 = 17 ,相比 51 足足省了兩倍的時間。

但是,最短路徑優先的演算法也不是完美無缺的,這種演算法照樣存在問題,那就是優先順序 問題,

這裡有一個原型可以參考就是我們日常生活中的電梯,電梯使用一種電梯演算法(elevator algorithm) 來進行排程,從而滿足協調效率和公平性這兩個相互衝突的目標。電梯一般會保持向一個方向移動,直到在那個方向上沒有請求為止,然後改變方向。

電梯演算法需要維護一個二進位制位,也就是當前的方向位:UP(向上)或者是 DOWN(向下)。當一個請求處理完成後,磁碟或電梯的驅動程式會檢查該位,如果此位是 UP 位,磁碟臂或者電梯倉移到下一個更高跌未完成的請求。如果高位沒有未完成的請求,則取相反方向。當方向位是 DOWN 時,同時存在一個低位的請求,磁碟臂會轉向該點。如果不存在的話,那麼它只是停止並等待。

我們舉個例子來描述一下電梯演算法,比如各個柱面得到服務的順序是 4,7,10,14,9,6,3,1 ,那麼它的流程圖如下

所以電梯演算法需要跨越的盤面數量是 3 + 3 + 4 + 5 + 3 + 3 + 1 = 22

電梯演算法通常情況下不如 SSF 演算法。

錯誤處理

一般壞塊有兩種處理辦法,一種是在控制器中進行處理;一種是在作業系統層面進行處理。

這兩種方法經常替換使用,比如一個具有 30 個資料扇區和兩個備用扇區的磁碟,其中扇區 4 是有瑕疵的。

控制器能做的事情就是將備用扇區之一重新對映。

還有一種處理方式是將所有的扇區都向上移動一個扇區

上面這這兩種情況下控制器都必須知道哪個扇區,可以通過內部的表來跟蹤這一資訊,或者通過重寫前導碼來給出重新對映的扇區號。如果是重寫前導碼,那麼涉及移動的方式必須重寫後面所有的前導碼,但是最終會提供良好的效能。

穩定儲存器

磁碟經常會出現錯誤,導致好的扇區會變成壞扇區,驅動程式也有可能掛掉。RAID 可以對扇區出錯或者是驅動器崩潰提出保護,然而 RAID 卻不能對壞資料中的寫錯誤提供保護,也不能對寫操作期間的崩潰提供保護,這樣就會破壞原始資料。

我們期望磁碟能夠準確無誤的工作,但是事實情況是不可能的,但是我們能夠知道的是,一個磁碟子系統具有如下特性:當一個寫命令發給它時,磁碟要麼正確地寫資料,要麼什麼也不做,讓現有的資料完整無誤的保留。這樣的系統稱為 穩定儲存器(stable storage)。 穩定儲存器的目標就是不惜一切代價保證磁碟的一致性。

穩定儲存器使用兩個一對相同的磁碟,對應的塊一同工作形成一個無差別的塊。穩定儲存器為了實現這個目的,定義了下面三種操作:

  • 穩定寫(stable write)
  • 穩定讀(stable read)
  • 崩潰恢復(crash recovery)

時鐘

時鐘(Clocks) 也被稱為定時器(timers),時鐘/定時器對任何程式系統來說都是必不可少的。時鐘負責維護時間、防止一個程式長期佔用 CPU 時間等其他功能。時鐘軟體(clock software) 也是一種裝置驅動的方式。下面我們就來對時鐘進行介紹,一般都是先討論硬體再介紹軟體,採用由下到上的方式,也是告訴你,底層是最重要的。

時鐘硬體

在計算機中有兩種型別的時鐘,這些時鐘與現實生活中使用的時鐘完全不一樣。

  • 比較簡單的一種時鐘被連線到 110 V 或 220 V 的電源線上,這樣每個電壓週期會產生一箇中斷,大概是 50 - 60 HZ。這些時鐘過去一直佔據支配地位。
  • 另外的一種時鐘由晶體振盪器、計數器和暫存器組成,示意圖如下所示

這種時鐘稱為可程式設計時鐘 ,可程式設計時鐘有兩種模式,一種是 一鍵式(one-shot mode),當時鍾啟動時,會把儲存器中的值複製到計數器中,然後,每次晶體的振盪器的脈衝都會使計數器 -1。當計數器變為 0 時,會產生一箇中斷,並停止工作,直到軟體再一次顯示啟動。還有一種模式時 方波(square-wave mode) 模式,在這種模式下,當計數器變為 0 併產生中斷後,儲存暫存器的值會自動複製到計數器中,這種週期性的中斷稱為一個時鐘週期。

時鐘軟體

時鐘硬體所做的工作只是根據已知的時間間隔產生中斷,而其他的工作都是由時鐘軟體來完成,一般作業系統的不同,時鐘軟體的具體實現也不同,但是一般都會包括以下這幾點

  • 維護一天的時間
  • 阻止程式執行的時間超過其指定時間
  • 統計 CPU 的使用情況
  • 處理使用者程式的警告系統呼叫
  • 為系統各個部分提供看門狗定時器
  • 完成概要剖析,監視和資訊收集

軟定時器

時鐘軟體也被稱為可程式設計時鐘,可以設定它以程式需要的任何速率引發中斷。時鐘軟體觸發的中斷是一種硬中斷,但是某些應用程式對於硬中斷來說是不可接受的。

這時候就需要一種軟定時器(soft timer) 避免了中斷,無論何時當核心因為某種原因呢在執行時,它返回使用者態之前都會檢查時鐘來了解軟定時器是否到期。如果軟定時器到期,則執行被排程的事件也無需切換到核心態,因為本身已經處於核心態中。這種方式避免了頻繁的核心態和使用者態之前的切換,提高了程式執行效率。

軟定時器因為不同的原因切換進入核心態的速率不同,原因主要有

  • 系統呼叫
  • TLB 未命中
  • 缺頁異常
  • I/O 中斷
  • CPU 變得空閒

死鎖問題也是作業系統非常重要的一類問題

資源

大部分的死鎖都和資源有關,在程式對裝置、檔案具有獨佔性(排他性)時會產生死鎖。我們把這類需要排他性使用的物件稱為資源(resource)。資源主要分為 可搶佔資源和不可搶佔資源

可搶佔資源和不可搶佔資源

資源主要有可搶佔資源和不可搶佔資源。可搶佔資源(preemptable resource) 可以從擁有它的程式中搶佔而不會造成其他影響,記憶體就是一種可搶佔性資源,任何程式都能夠搶先獲得記憶體的使用權。

不可搶佔資源(nonpreemtable resource) 指的是除非引起錯誤或者異常,否則程式無法搶佔指定資源,這種不可搶佔的資源比如有光碟,在程式執行排程的過程中,其他程式是不能得到該資源的。

死鎖

如果要對死鎖進行一個定義的話,下面的定義比較貼切

如果一組程式中的每個程式都在等待一個事件,而這個事件只能由該組中的另一個程式觸發,這種情況會導致死鎖

資源死鎖的條件

針對我們上面的描述,資源死鎖可能出現的情況主要有

  • 互斥條件:每個資源都被分配給了一個程式或者資源是可用的
  • 保持和等待條件:已經獲取資源的程式被認為能夠獲取新的資源
  • 不可搶佔條件:分配給一個程式的資源不能強制的從其他程式搶佔資源,它只能由佔有它的程式顯示釋放
  • 迴圈等待:死鎖發生時,系統中一定有兩個或者兩個以上的程式組成一個迴圈,迴圈中的每個程式都在等待下一個程式釋放的資源。

發生死鎖時,上面的情況必須同時會發生。如果其中任意一個條件不會成立,死鎖就不會發生。可以通過破壞其中任意一個條件來破壞死鎖,下面這些破壞條件就是我們探討的重點

死鎖模型

Holt 在 1972 年提出對死鎖進行建模,建模的標準如下:

  • 圓形表示程式
  • 方形表示資源

從資源節點到程式節點表示資源已經被程式佔用,如下圖所示

在上圖中表示當前資源 R 正在被 A 程式所佔用

由程式節點到資源節點的有向圖表示當前程式正在請求資源,並且該程式已經被阻塞,處於等待這個資源的狀態

在上圖中,表示的含義是程式 B 正在請求資源 S 。Holt 認為,死鎖的描述應該如下

這是一個死鎖的過程,程式 C 等待資源 T 的釋放,資源 T 卻已經被程式 D 佔用,程式 D 等待請求佔用資源 U ,資源 U 卻已經被執行緒 C 佔用,從而形成環。

有四種處理死鎖的策略:

  • 忽略死鎖帶來的影響(驚呆了)
  • 檢測死鎖並回復死鎖,死鎖發生時對其進行檢測,一旦發生死鎖後,採取行動解決問題
  • 通過仔細分配資源來避免死鎖
  • 通過破壞死鎖產生的四個條件之一來避免死鎖

下面我們分別介紹一下這四種方法

鴕鳥演算法

最簡單的解決辦法就是使用鴕鳥演算法(ostrich algorithm),把頭埋在沙子裡,假裝問題根本沒有發生。每個人看待這個問題的反應都不同。數學家認為死鎖是不可接受的,必須通過有效的策略來防止死鎖的產生。工程師想要知道問題發生的頻次,系統因為其他原因崩潰的次數和死鎖帶來的嚴重後果。如果死鎖發生的頻次很低,而經常會由於硬體故障、編譯器錯誤等其他作業系統問題導致系統崩潰,那麼大多數工程師不會修復死鎖。

死鎖檢測和恢復

第二種技術是死鎖的檢測和恢復。這種解決方式不會嘗試去阻止死鎖的出現。相反,這種解決方案會希望死鎖儘可能的出現,在監測到死鎖出現後,對其進行恢復。下面我們就來探討一下死鎖的檢測和恢復的幾種方式

每種型別一個資源的死鎖檢測方式

每種資源型別都有一個資源是什麼意思?我們經常提到的印表機就是這樣的,資源只有印表機,但是裝置都不會超過一個。

可以通過構造一張資源分配表來檢測這種錯誤,比如我們上面提到的

如果這張圖包含了一個或一個以上的環,那麼死鎖就存在,處於這個環中任意一個程式都是死鎖的程式。

每種型別多個資源的死鎖檢測方式

如果有多種相同的資源存在,就需要採用另一種方法來檢測死鎖。可以通過構造一個矩陣來檢測從 P1 -> Pn 這 n 個程式中的死鎖。

現在我們提供一種基於矩陣的演算法來檢測從 P1 到 Pn 這 n 個程式中的死鎖。假設資源型別為 m,E1 代表資源型別1,E2 表示資源型別 2 ,Ei 代表資源型別 i (1 <= i <= m)。E 表示的是 現有資源向量(existing resource vector),代表每種已存在的資源總數。

現在我們就需要構造兩個陣列:C 表示的是當前分配矩陣(current allocation matrix) ,R 表示的是 請求矩陣(request matrix)。Ci 表示的是 Pi 持有每一種型別資源的資源數。所以,Cij 表示 Pi 持有資源 j 的數量。Rij 表示 Pi 所需要獲得的資源 j 的數量

一般來說,已分配資源 j 的數量加起來再和所有可供使用的資源數相加 = 該類資源的總數。

死鎖的檢測就是基於向量的比較。每個程式起初都是沒有被標記過的,演算法會開始對程式做標記,程式被標記後說明程式被執行了,不會進入死鎖,當演算法結束時,任何沒有被標記過的程式都會被判定為死鎖程式。

上面我們探討了兩種檢測死鎖的方式,那麼現在你知道怎麼檢測後,你何時去做死鎖檢測呢?一般來說,有兩個考量標準:

  • 每當有資源請求時就去檢測,這種方式會佔用昂貴的 CPU 時間。
  • 每隔 k 分鐘檢測一次,或者當 CPU 使用率降低到某個標準下去檢測。考慮到 CPU 效率的原因,如果死鎖程式達到一定數量,就沒有多少程式可以執行,所以 CPU 會經常空閒。

從死鎖中恢復

上面我們探討了如何檢測程式死鎖,我們最終的目的肯定是想讓程式能夠正常的執行下去,所以針對檢測出來的死鎖,我們要對其進行恢復,下面我們會探討幾種死鎖的恢復方式

通過搶佔進行恢復

在某些情況下,可能會臨時將某個資源從它的持有者轉移到另一個程式。比如在不通知原程式的情況下,將某個資源從程式中強制取走給其他程式使用,使用完後又送回。這種恢復方式一般比較困難而且有些簡單粗暴,並不可取。

通過回滾進行恢復

如果系統設計者和機器操作員知道有可能發生死鎖,那麼就可以定期檢查流程。程式的檢測點意味著程式的狀態可以被寫入到檔案以便後面進行恢復。檢測點不僅包含儲存映像(memory image),還包含資源狀態(resource state)。一種更有效的解決方式是不要覆蓋原有的檢測點,而是每出現一個檢測點都要把它寫入到檔案中,這樣當程式執行時,就會有一系列的檢查點檔案被累積起來。

為了進行恢復,要從上一個較早的檢查點上開始,這樣所需要資源的程式會回滾到上一個時間點,在這個時間點上,死鎖程式還沒有獲取所需要的資源,可以在此時對其進行資源分配。

殺死程式恢復

最簡單有效的解決方案是直接殺死一個死鎖程式。但是殺死一個程式可能照樣行不通,這時候就需要殺死別的資源進行恢復。

另外一種方式是選擇一個環外的程式作為犧牲品來釋放程式資源。

死鎖避免

我們上面討論的是如何檢測出現死鎖和如何恢復死鎖,下面我們探討幾種規避死鎖的方式

單個資源的銀行家演算法

銀行家演算法是 Dijkstra 在 1965 年提出的一種排程演算法,它本身是一種死鎖的排程演算法。它的模型是基於一個城鎮中的銀行家,銀行家向城鎮中的客戶承諾了一定數量的貸款額度。演算法要做的就是判斷請求是否會進入一種不安全的狀態。如果是,就拒絕請求,如果請求後系統是安全的,就接受該請求。

類似的,還有多個資源的銀行家演算法,讀者可以自行了解。

破壞死鎖

死鎖本質上是無法避免的,因為它需要獲得未知的資源和請求,但是死鎖是滿足四個條件後才出現的,它們分別是

  • 互斥
  • 保持和等待
  • 不可搶佔
  • 迴圈等待

我們分別對這四個條件進行討論,按理說破壞其中的任意一個條件就能夠破壞死鎖

破壞互斥條件

我們首先考慮的就是破壞互斥使用條件。如果資源不被一個程式獨佔,那麼死鎖肯定不會產生。如果兩個印表機同時使用一個資源會造成混亂,印表機的解決方式是使用 假離線印表機(spooling printer) ,這項技術可以允許多個程式同時產生輸出,在這種模型中,實際請求印表機的唯一程式是印表機守護程式,也稱為後臺程式。後臺程式不會請求其他資源。我們可以消除印表機的死鎖。

後臺程式通常被編寫為能夠輸出完整的檔案後才能列印,假如兩個程式都佔用了假離線空間的一半,而這兩個程式都沒有完成全部的輸出,就會導致死鎖。

因此,儘量做到儘可能少的程式可以請求資源。

破壞保持等待的條件

第二種方式是如果我們能阻止持有資源的程式請求其他資源,我們就能夠消除死鎖。一種實現方式是讓所有的程式開始執行前請求全部的資源。如果所需的資源可用,程式會完成資源的分配並執行到結束。如果有任何一個資源處於頻繁分配的情況,那麼沒有分配到資源的程式就會等待。

很多程式無法在執行完成前就知道到底需要多少資源,如果知道的話,就可以使用銀行家演算法;還有一個問題是這樣無法合理有效利用資源

還有一種方式是程式在請求其他資源時,先釋放所佔用的資源,然後再嘗試一次獲取全部的資源。

破壞不可搶佔條件

破壞不可搶佔條件也是可以的。可以通過虛擬化的方式來避免這種情況。

破壞迴圈等待條件

現在就剩最後一個條件了,迴圈等待條件可以通過多種方法來破壞。一種方式是制定一個標準,一個程式在任何時候只能使用一種資源。如果需要另外一種資源,必須釋放當前資源。對於需要將大檔案從磁帶複製到印表機的過程,此限制是不可接受的。

另一種方式是將所有的資源統一編號,如下圖所示

程式可以在任何時間提出請求,但是所有的請求都必須按照資源的順序提出。如果按照此分配規則的話,那麼資源分配之間不會出現環。

儘管通過這種方式來消除死鎖,但是編號的順序不可能讓每個程式都會接受。

其他問題

下面我們來探討一下其他問題,包括 通訊死鎖、活鎖是什麼、飢餓問題和兩階段加鎖

兩階段加鎖

雖然很多情況下死鎖的避免和預防都能處理,但是效果並不好。隨著時間的推移,提出了很多優秀的演算法用來處理死鎖。例如在資料庫系統中,一個經常發生的操作是請求鎖住一些記錄,然後更新所有鎖定的記錄。當同時有多個程式執行時,就會有死鎖的風險。

一種解決方式是使用 兩階段提交(two-phase locking)。顧名思義分為兩個階段,一階段是程式嘗試一次鎖定它需要的所有記錄。如果成功後,才會開始第二階段,第二階段是執行更新並釋放鎖。第一階段並不做真正有意義的工作。

如果在第一階段某個程式所需要的記錄已經被加鎖,那麼該程式會釋放所有鎖定的記錄並重新開始第一階段。從某種意義上來說,這種方法類似於預先請求所有必需的資源或者是在進行一些不可逆的操作之前請求所有的資源。

不過在一般的應用場景中,兩階段加鎖的策略並不通用。如果一個程式缺少資源就會半途中斷並重新開始的方式是不可接受的。

通訊死鎖

我們上面一直討論的是資源死鎖,資源死鎖是一種死鎖型別,但並不是唯一型別,還有通訊死鎖,也就是兩個或多個程式在傳送訊息時出現的死鎖。程式 A 給程式 B 發了一條訊息,然後程式 A 阻塞直到程式 B 返回響應。假設請求訊息丟失了,那麼程式 A 在一直等著回覆,程式 B 也會阻塞等待請求訊息到來,這時候就產生死鎖

儘管會產生死鎖,但是這並不是一個資源死鎖,因為 A 並沒有佔據 B 的資源。事實上,通訊死鎖並沒有完全可見的資源。根據死鎖的定義來說:每個程式因為等待其他程式引起的事件而產生阻塞,這就是一種死鎖。相較於最常見的通訊死鎖,我們把上面這種情況稱為通訊死鎖(communication deadlock)

通訊死鎖不能通過排程的方式來避免,但是可以使用通訊中一個非常重要的概念來避免:超時(timeout)。在通訊過程中,只要一個資訊被髮出後,傳送者就會啟動一個定時器,定時器會記錄訊息的超時時間,如果超時時間到了但是訊息還沒有返回,就會認為訊息已經丟失並重新傳送,通過這種方式,可以避免通訊死鎖。

但是並非所有網路通訊發生的死鎖都是通訊死鎖,也存在資源死鎖,下面就是一個典型的資源死鎖。

當一個資料包從主機進入路由器時,會被放入一個緩衝區,然後再傳輸到另外一個路由器,再到另一個,以此類推直到目的地。緩衝區都是資源並且數量有限。如下圖所示,每個路由器都有 10 個緩衝區(實際上有很多)。

假如路由器 A 的所有資料需要傳送到 B ,B 的所有資料包需要傳送到 D,然後 D 的所有資料包需要傳送到 A 。沒有資料包可以移動,因為在另一端沒有緩衝區可用,這就是一個典型的資源死鎖。

活鎖

某些情況下,當程式意識到它不能獲取所需要的下一個鎖時,就會嘗試禮貌的釋放已經獲得的鎖,然後等待非常短的時間再次嘗試獲取。可以想像一下這個場景:當兩個人在狹路相逢的時候,都想給對方讓路,相同的步調會導致雙方都無法前進。

現在假想有一對並行的程式用到了兩個資源。它們分別嘗試獲取另一個鎖失敗後,兩個程式都會釋放自己持有的鎖,再次進行嘗試,這個過程會一直進行重複。很明顯,這個過程中沒有程式阻塞,但是程式仍然不會向下執行,這種狀況我們稱之為 活鎖(livelock)

飢餓

與死鎖和活鎖的一個非常相似的問題是 飢餓(starvvation)。想象一下你什麼時候會餓?一段時間不吃東西是不是會餓?對於程式來講,最重要的就是資源,如果一段時間沒有獲得資源,那麼程式會產生飢餓,這些程式會永遠得不到服務。

我們假設印表機的分配方案是每次都會分配給最小檔案的程式,那麼要列印大檔案的程式會永遠得不到服務,導致程式飢餓,程式會無限制的推後,雖然它沒有阻塞。

關注二維碼回覆"os腦圖"即可獲取高清思維導圖

回覆 "os" 領取作業系統 PDF

相關文章