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

文末領取大圖。

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

搞清楚幾個問題

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

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

  • 文件:對 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章