迄今為止,博主在部落格中闡述的內容包含渲染技術、效能優化、圖形API、Shader、GPU、遊戲引擎架構、圖形驅動等等技術範疇的內容,這些內容都僅僅侷限於單個應用程式之中,常常讓人有」只緣身在此山中「的感嘆。現在是時候更進一步了——進入作業系統(Operating System,OS)的範疇,以更高的層次去看待渲染體系,以便我們能夠高屋建瓴,練就深厚的技術內功。
本篇將站在應用層開發者的視角,去闡述Windows、Linux等作業系統的相關技術內幕(如果是作業系統開發者,則博主不認為是很貼切的目標讀者),主要包含但不限於以下內容:
作業系統經歷了數十年的發展歷程,從最初的形態,到當前的琳琅滿目,無論是技術、體驗、應用等方面都有了質的飛躍。
UNIX作業系統最早於20世紀70年代開發,是一個支援多個使用者同時使用的多工作業系統。它的特點是基於命令列,支援同時執行數千個小程式,易於從單個程式建立管道,內建多使用者支援和分割區。面臨的挑戰是基於命令列,尋找幫助和檔案可能很繁瑣,許多不同的變體(下圖)。
Unix系統和它的衍生體。
同樣流行且知名的還有Windows、MacOS、Android、Linux等作業系統。
作業系統位於應用程式和硬體之間,調解存取並抽象出介面,程式通過陷阱或例外請求服務,裝置通過中斷請求關注等。作業系統執行許多功能,具體而言,有:
作業系統是一組程式,用於控制應用程式的執行,並充當計算機使用者和計算機硬體之間的中介。作業系統是一種管理計算機硬體併為應用程式執行提供環境的軟體,範例有Windows、Windows/NT、Linux、OS/2和MacOS。OS涉及的主題常常是以下幾方面:
作業系統提供以下幾大類功能:
常見的模組或子系統有記憶體管理、IO系統、檔案系統、程序、執行緒、中斷、安全、資訊服務等等,它們之間有著錯綜複雜的關聯:
作業系統的目標是:1、使計算機系統方便使用者使用;2、有效地使用計算機硬體;3、執行使用者程式並使解決使用者問題更容易。
可以分別從使用者、系統視角來看待作業系統。
計算機的使用者檢視取決於使用的介面。一些使用者可能使用PC,在這種情況下,系統設計為只有一個使用者可以利用資源,並且主要是為了便於使用,其中主要關注的是效能而不是資源利用率。一些使用者可能使用連線到大型電腦或小型計算機的終端,其他使用者可以通過其他終端存取同一臺計算機,這些使用者可以共用資源和交換資訊。在這種情況下,作業系統被設計為最大限度地利用資源,以便有效地利用所有可用的CPU時間、記憶體和I/O。其他使用者可以坐在工作站上,連線到其他工作站和伺服器的網路,在這種情況下,作業系統被設計為在個人可見性和資源利用率之間進行折衷。
而從系統角度來看,可以將系統視為資源分配器,也就是說,一個計算機系統有許多可用於解決問題的資源。作業系統充當這些資源的管理器,必須決定如何將這些資源分配給程式和使用者,以便能夠高效、公平地操作計算機系統。作業系統的不同視角是,它需要控制各種I/O裝置和使用者程式,即作業系統是用於管理使用者程式執行的控制程式,以防止錯誤和不當使用計算機。資源可以是CPU時間、記憶體空間、檔案儲存空間、I/O裝置等。
作業系統服務是作業系統為程式的執行提供了環境,也為程式提供一些服務,每個作業系統提供的服務可能與其他作業系統不同,使得程式設計任務更容易。
作業系統服務概覽。
作業系統提供的各種服務如下:
程式執行:系統必須能夠將程式載入到主記憶體分割區並執行該程式,程式必須能夠正常終止此執行以成功執行,或異常終止以顯示錯誤。
I/O操作:完成裝置分配和I/O裝置控制任務,提供通知裝置錯誤、裝置狀態等。
檔案系統操作:完成開啟檔案、關閉檔案的任務,程式需要按名稱建立和刪除檔案,允許檔案操作,如讀取檔案、寫入檔案、附加檔案。
通訊:在同一計算機系統上或在計算機網路上的不同計算機系統之間完成程序間通訊的任務,在安全模式下提供訊息傳遞和共用記憶體存取。
錯誤檢測:作業系統應針對任何型別的溢位(如算術溢位)採取適當的操作;除以零錯誤、存取非法記憶體位置和使用者CPU時間過大。完成錯誤檢測和恢復任務(如果有),例如印表機卡紙或缺紙。跟蹤CPU、記憶體、I/O裝置、儲存裝置、檔案系統、網路等的狀態。如果出現致命錯誤,如RAM奇偶校驗錯誤、功率波動,則中止執行。
資源分配:完成資源分配到多個作業的任務,在資源使用後或作業終止時回收分配的資源,當多個使用者登入到系統時,必須將資源分配給每個使用者。對於資源在各個程序之間的當前分配,作業系統使用CPU排程執行時間,以確定將為哪個程序分配資源。
賬戶:作業系統跟蹤哪些使用者使用了多少和哪種計算機資源,維護系統活動紀錄檔以進行效能分析和錯誤恢復。
保護:完成保護系統資源免受惡意使用的任務,採用安全方案防止未經授權的存取/使用者進行安全計算,使用登入密碼和註冊對合法使用者進行身份驗證。作業系統負責硬體和軟體保護,作業系統保護儲存在多使用者計算機系統中的資訊。
系統呼叫:系統呼叫提供程序和作業系統之間的介面,它們通常以組合語言指令的形式提供。有些系統允許直接從高階語言程式(如C、BCPL和PERL等)進行系統呼叫,根據使用的計算機,系統呼叫以不同的方式進行。系統呼叫可以大致分為5大類:
程序管理和控制:
檔案管理/檔案操作/檔案處理:
裝置管理:
資訊維護/管理:
通訊管理:
通訊管理有兩種模式:
作業系統可分為以下幾類。
在批次處理系統(Batch System)型別的作業系統中,使用者定期(如每天、每週、每月)將作業提交到一箇中心位置,該位置的系統使用者不直接與計算機系統互動。為了加快處理速度,具有類似需求的作業被分批次處理,並作為一個組在計算機中執行。因此,程式設計師將把程式留給操作員,每個作業的輸出將傳送給適當的程式設計師。這種型別的主要任務是自動將控制權從一個作業轉移到下一個作業。
此種作業系統的優勢是簡單、連續的作業排程,儘量減少人為干預,由於作業成批次處理,提高了效能和系統吞吐量。缺點是從使用者的角度來看,由於批次處理,週轉時間可能會很長,程式偵錯困難,作業可以進入無限迴圈,可能會損壞顯示器,由於缺乏保護方案,一項作業可能會影響待定作業。
應用案例是工資系統、銀行對賬單等。注意,週轉時間是指使用者提交流程或作業與完成該流程或作業之間所用的時間。
聯機的全稱是線上同步外圍裝置操作(Simultaneous Peripheral Operation On-Line,SPOOL),是暫時儲存資料以供裝置、程式或系統使用和執行的過程,資料被傳送到其他易失性(臨時記憶體)記憶體並儲存在其中,直到程式或計算機請求執行。
時間共用系統(Time-Sharing)也被稱為分時系統、多使用者系統,提供使用者與系統之間的線上通訊,使用者直接發出指令並接收中間響應,因此稱為互動式系統。它允許多個使用者同時共用計算機系統,CPU在幾個程式之間快速多路複用,這些程式儲存在記憶體和磁碟上,一個程式在磁碟上交換記憶體和記憶體。
CPU通過在多個作業之間切換來執行多個作業,但切換頻繁,使用者可以在每個程式執行時與之互動。互動式計算機系統提供使用者和系統之間的直接通訊,使用者直接使用鍵盤或滑鼠向作業系統或程式發出指令,並等待即時結果。因此,響應時間將很短。分時系統允許許多使用者同時共用計算機,由於此係統中的每個操作都很短,因此每個使用者只需要很少的CPU時間。系統可以快速地從一個使用者切換到另一個使用者,因此每個使用者都會感覺整個計算機系統都是專門用於自己的使用的,即使它是由許多使用者共用的。
在上圖中,使用者5處於活動狀態,但使用者1、使用者2、使用者3和使用者4處於等待狀態,而使用者6處於就緒狀態。一旦使用者5的時間片完成,時間片就移到下一個就緒使用者,即使用者6。在此狀態下,使用者2、使用者3、使用者4和使用者5處於等待狀態,使用者1處於就緒狀態。這個過程以同樣的方式繼續,以此類推。
分時系統的優點是有效共用和利用計算機資源,對許多使用者的快速響應時間,CPU空閒時間完全消除,適合線上資料處理和使用者對話。
分時系統的缺點是比多道程式作業系統更復雜,系統必須具有記憶體管理和保護,因為多個作業同時儲存在記憶體中,分時系統還必須提供檔案系統,因此需要磁碟管理,它為需要複雜CPU排程方案的並行執行提供了機制。範例:Multics、UNIX等。
實時系統(Real-Time System)的特點是提供即時響應,保證關鍵任務按時完成。對於要在計算機上執行的每個功能,此型別必須具有已知的最大時間限制。當處理器的操作或資料流有嚴格的時間要求時,使用實時系統,實時系統可用作專用應用中的控制裝置。
當處理器的操作或資料流有嚴格的時間要求時,使用實時系統,控制科學實驗、醫學成像系統和一些顯示系統的系統是實時系統。感測器將資料傳送到計算機,計算機分析資料並調整控制元件以修改感測器輸入。
實時系統的缺點是:只有當實時系統在時間限制內返回正確的結果時,才認為它能夠正確執行,輔助儲存有限或丟失,而資料通常儲存在短期記憶體或ROM中,缺少高階作業系統功能。
實時系統有兩種型別:
範例:QNX、VX工作,數位音訊或多媒體包含在這一類別中,是一種特殊用途的作業系統,其中對處理器的操作有嚴格的時間要求。實時作業系統有明確定義的固定時間限制,處理必須在時間限制內完成,否則系統將失敗。只有在時間限制內返回正確的結果,實時系統才能正常執行。這些系統的特點是將時間作為關鍵引數。
實時作業系統的優點:
實時作業系統的缺點:
實時作業系統的例子有:科學實驗、醫學成像系統、工業控制系統、武器系統、機器人、空中交通控制系統等。
多程式設計(Multiprogramming)概念通過組織作業增加CPU利用率,CPU總有一個作業要執行。作業系統同時在記憶體中保留多個作業,如下圖所示。
這組作業是作業池中保留的作業的子集,作業系統拾取並開始執行記憶體中的一個作業,當一個作業需要等待時,CPU只需切換到另一個作業,依此類推。
多道程式作業系統很複雜,因為作業系統為使用者做出決定,稱為作業排程。如果多個作業準備同時執行,系統將從中選擇一個,稱為CPU排程。其他功能包括記憶體管理、裝置和檔案管理。
多道程式系統的優點是有效的資源利用率(CPU、記憶體、外圍裝置),消除或最小化浪費的CPU空閒時間,增加的吞吐量(在給定的時間間隔內,相對於提交執行的作業數量,執行的作業數)。
多道程式系統的缺點是在程式執行期間,不提供使用者與計算機系統的互動,磁碟技術的引入解決了這些問題,而不是將卡片從讀卡器讀入磁碟,這種處理形式稱為聯機,複雜且相當昂貴。
這些系統有多個處理器進行緊密通訊,共用計算機匯流排、時鐘、記憶體和外圍裝置,例如UNIX、LINUX。多處理器系統有三個主要優點:
多處理系統的型別包括:
與緊耦合系統相比,處理器不共用記憶體或時鐘,相反,每個處理器都有自己的本地記憶體。處理器通過各種通訊線路(如高速匯流排或電話線)相互通訊,分散式系統的功能依賴於網路。通過能夠通訊,分散式系統能夠共用計算任務併為使用者提供豐富的功能集,網路因所使用的協定、節點和傳輸媒介之間的距離而異。
TCP/IP是最常見的網路協定,分散式系統中的處理器大小和功能各不相同,可以是微處理器、工作站、小型計算機和大型通用計算機,網路型別基於節點之間的距離,例如LAN(在房間、樓層或建築物內)和WAN(在建築物、城市或國家之間)。
分散式作業系統的優點:資源共用,計算速度加快,負載共用/負載平衡,可靠性,通訊。
分散式作業系統的缺點:主網路故障將停止整個通訊,為了建立分散式系統,所使用的語言還沒有很好的定義,這些型別的系統並不容易獲得,因為它們非常昂貴。不僅底層軟體非常複雜,而且還沒有被很好地理解。範例是LOCUS等。
這些系統在伺服器上執行,並提供管理資料、使用者、組、安全、應用程式和其他網路功能的能力。此類作業系統允許通過小型專用網路共用存取檔案、印表機、安全、應用程式和其他網路功能。網路作業系統的另一個重要方面是,所有使用者都清楚底層設定、網路中所有其他使用者的設定、他們的個人連線等,這就是為什麼這些計算機通常被稱為緊耦合系統的原因。
網路作業系統的優點:高度穩定的集中式伺服器,安全問題通過伺服器處理,新技術和硬體升級很容易整合到系統中,可以從不同位置和型別的系統遠端存取伺服器。網路作業系統的缺點:伺服器成本高昂,使用者必須依賴中央位置進行大多數操作,需要定期維護和更新。
網路作業系統的範例有Microsoft Windows Server 2003、Microsoft Windows Server 2008、UNIX、Linux、Mac OS X、Novell NetWare和BSD等。
作業系統體系結構的設計傳統上遵循關注點分離原則,這一原則建議將作業系統結構化為相對獨立的部分,這些部分提供簡單的單個功能,從而使設計的複雜性保持可控。
有幾個商業系統沒有定義良好的結構,例如作業系統開始時是小的、簡單的和有限的系統,然後擴充套件到超出其原始範圍。MS-DOS就是這種系統的一個例子,沒有仔細地劃分成模組。另一個有限結構的例子是UNIX作業系統,沒有CPU執行模式(使用者和核心),因此應用程式中的錯誤可能會導致整個系統崩潰。
g)
在這個模型中,對於每個系統呼叫,都有一個服務過程來處理和執行它。實用程式執行多個服務程式所需的操作,例如從使用者程式中獲取資料。程式分為三層,如下圖所示。
作業系統體系結構的單片設計不適合作業系統的特殊性質。儘管設計遵循關注點分離,但沒有嘗試限制授予作業系統各個部分的許可權,整個作業系統以最大許可權執行。
單片作業系統內的通訊開銷與任何其他軟體內的通訊開支相同,被認為相對較低。CP/M和DOS是單片作業系統的簡單範例,CP/M和DOS都是與應用程式共用單個地址空間的作業系統。在CP/M中,16位元地址空間以系統變數和應用程式區域開始,以作業系統的三個部分結束,即CCP(控制檯命令處理器)、BDOS(基本磁碟作業系統)和BIOS(基本輸入/輸出系統)。在DOS中,20位地址空間從中斷向量陣列和系統變數開始,然後是DOS的常駐部分和應用程式區域,最後是視訊卡和BIOS使用的記憶體塊。
在分層方法中,作業系統被劃分為多個層(級別),每個層都構建在較低層之上。底層(第0層)是硬體,最頂層(第N層)是使用者介面。
作業系統的分層通用架構。
分層方法的主要優點是模組化,層的選擇使得每個使用者只具有較低層的功能(或操作)和服務。這種方法簡化了偵錯和系統驗證,即可以偵錯第一層,而不必考慮系統的其餘部分。一旦偵錯了第一層,就假定它在偵錯第二層時正常工作,依此類推。如果在偵錯特定層的過程中發現錯誤,則該錯誤必須位於該層上,因為它下面的層已被偵錯。
這樣,當系統被分解為多個層次時,系統的設計和實現就簡化了。每個層僅使用較低層提供的操作來實現,層不需要知道這些操作是如何實現的;它只需要知道這些操作是做什麼的。分層方法首先在作業系統中使用。它被定義為六層。
層 | 功能 |
---|---|
5 | 使用者程式 |
4 | I/O管理 |
3 | 操作程序通訊 |
2 | 記憶體管理 |
1 | CPU排程 |
0 | 硬體 |
分層方法的主要缺點:
1、主要困難在於對層的仔細定義,因為一個層只能使用它下面的那些層。例如,虛擬記憶體演演算法使用的磁碟空間的裝置驅動程式必須低於記憶體管理例程的級別,因為記憶體管理需要使用磁碟空間的能力。
2、效率低於非分層系統(每一層都會增加系統呼叫的開銷,最終的結果是系統呼叫比非分層系統花費的時間更長)。
其中,Unix系統的架構如下圖所示:
Unix可分為核心和系統程式。Unix核心包括系統資源管理、介面和裝置驅動程式,如CPU排程、檔案系統、記憶體管理和I/O管理。
Linux是針對Intel 386/486/Pentium機器的完整Unix克隆,充當計算機系統的硬體和軟體之間的通訊服務,其核心包含了任何作業系統中所期望的所有特性,部分功能包括:
Windows XP是一個基於增強技術的多工作業系統,整合了Windows 2000的優點,如基於標準的安全性、可管理性和可靠性,以及Windows 98和Windows Me的最佳功能,如隨插即用和易於使用的使用者介面。Windows XP的體系結構如下圖所示,採用分層結構,由硬體抽象層、核心層、執行層、使用者模式層和應用程式組成。
Windows XP的每個核心實體都被視為一個物件,由執行程式中的物件管理器管理。使用者模式應用程式可以通過程序中的物件控制程式碼呼叫核心物件。使用核心物件來提供基本服務,以及對使用者端-伺服器計算的支援,使Windows XP能夠支援多種應用程式。Windows XP還提供虛擬記憶體、整合快取、搶佔式排程、更強的安全模式和國際化功能。
Windows和Windows Vista架構圖。
通過刪除核心的所有不重要部分並將其作為系統級和使用者級程式實現來構建作業系統,通常提供最少的程序和記憶體管理以及通訊設施, 作業系統元件之間的通訊通過訊息傳遞提供。
微核心的優點是擴充套件作業系統變得容易得多,對核心的任何更改都會減少,因為核心更小,微核心還提供了更高的安全性和可靠性。主要缺點是由於訊息傳遞增加了系統開銷,效能較差。
MINIX 3微核心只有大約12000行C語言和1400行組合語言,用於捕捉中斷和切換程序等非常低階的功能。C程式碼管理和排程程序,處理程序間通訊(通過在程序之間傳遞訊息),並提供一組大約40個核心呼叫,以允許作業系統的其餘部分完成其工作。這些呼叫執行諸如將處理程式掛接到中斷、在地址空間之間行動資料以及為新程序安裝記憶體對映等功能。MINIX 3的程序結構如下圖所示,核心呼叫處理程式標記為Sys。時鐘的裝置驅動程式也在核心中,因為排程程式與它緊密互動。其他裝置驅動程式作為單獨的使用者程序執行。
Solaris結構圖如下:
微核心思想的一個微小變化是區分兩類程序,即伺服器(每個程序都提供一些服務)和使用者端(使用這些服務)。此模型稱為客戶機-伺服器模型,通常最底層是微核心(但非必需),其本質是使用者端程序和伺服器程序的存在。
使用者端和伺服器之間的通訊通常是通過訊息傳遞進行的。為了獲得服務,使用者端程序構造一條訊息,說明它想要什麼,並將其傳送到適當的服務。然後,該服務完成工作並返回答案。如果客戶機和伺服器碰巧在同一臺機器上執行,則可以進行某些優化,如訊息傳遞。
這種想法的一個明顯的概括是讓使用者端和伺服器執行在不同的計算機上,通過區域網或廣域網連線,如下圖所示。由於使用者端通過傳送訊息與伺服器通訊,使用者端不需要知道訊息是在自己的機器上本地處理的,還是通過網路傳送到遠端機器上的伺服器。就客戶而言,在這兩種情況下都會發生同樣的事情:傳送請求,然後回覆。因此,客戶機-伺服器模型是一種抽象,可以用於單個機器或機器網路。
虛擬機器器涉及的概念。
虛擬機器器採用分層方法得出其邏輯結論,將硬體和作業系統核心視為硬體,提供與底層裸硬體相同的介面。作業系統產生了多個程序的錯覺,每個程序都使用自己的(虛擬)記憶體在自己的處理器上執行,共用物理計算機的資源以建立虛擬機器器。CPU排程可以建立使用者擁有自己處理器的外觀。聯機和檔案系統可以提供虛擬讀卡器和虛擬行印表機。普通使用者分時終端充當虛擬機器器操作員的控制檯。
虛擬機器器概念提供了對系統資源的完全保護,因為每個虛擬機器器都與所有其他虛擬機器器隔離。然而,這種隔離不允許直接共用資源。虛擬機器器系統是作業系統研究和開發的完美工具。系統開發是在虛擬機器器上進行的,而不是在物理機上進行,因此不會中斷正常的系統操作。虛擬機器器概念很難實現,因為需要為底層機器提供精確的副本。它的缺點是虛擬機器器包括由於大量模擬虛擬機器器操作而增加的系統開銷,VM OS的效率取決於VM監視器必須模擬的運算元。虛擬機器器可分為程序和系統兩個級別:
VM/370是於1979由Seawright和MacKinnon推出的虛擬機器器,它基於一個敏銳的觀察:分時系統提供(1)多道程式設計和(2)擴充套件機器,與裸硬體相比,具有更方便的介面。VM/370的本質是將這兩個功能完全分離。
系統的核心,即虛擬機器器監視器,在裸硬體上執行並執行多道程式設計,向上一層提供的不是一個,而是幾個虛擬機器器,如圖1-28所示。然而,與所有其他作業系統不同,這些虛擬機器器不是擴充套件機,具有檔案和其他漂亮的功能。相反,它們是裸硬體的精確副本,包括核心/使用者模式、I/O、中斷以及真實機器所擁有的所有其他內容。
虛擬化在Web託管領域也很流行。如果沒有虛擬化,Web託管客戶就不得不在共用託管和專用託管之間進行選擇。當一家網路託管公司提供虛擬機器器出租時,一臺物理機可以執行許多虛擬機器器,每個虛擬機器器看起來都是一臺完整的機器。租用虛擬機器器的客戶可以執行他們想要的任何作業系統和軟體,但成本僅為專用伺服器的一小部分(因為同一物理機同時支援多個虛擬機器器)。
虛擬化的另一個用途是為那些希望能夠同時執行兩個或更多作業系統(例如Windows和Linux)的終端使用者提供的,因為他們喜歡的一些應用程式包在一個上執行,而另一些在另一個上執行。這種情況如圖下圖(a)所示,其中術語「虛擬機器器監控器」已重新命名為型別1虛擬機器器監控程式,現在常用的是,因為「虛擬機器器監控」需要的擊鍵次數比人們現在準備好的要多。
(a) 1類虛擬機器器監控程式。(b) 純型別2管理程式。(c) 一個實用的2型管理程式。
虛擬計算機的一種架構範例。
使用虛擬機器器的另一個領域是執行Java程式,但方式有所不同。當Sun Microsystems發明Java程式語言時,它還發明瞭一種稱為JVM(Java Virtual Machine)的虛擬機器器(即電腦架構)。Java編譯器為JVM生成程式碼,然後通常由軟體JVM直譯器執行。這種方法的優點是,JVM程式碼可以通過Internet傳送到任何具有JVM直譯器並在其中執行的計算機,例如,如果編譯器生成了SPARC或x86二進位制程式,那麼它們就不可能如此容易地釋出和執行。使用JVM的另一個優點是,如果直譯器實現正確(並不是小事),可以檢查傳入的JVM程式的安全性,然後在受保護的環境中執行,這樣它們就不會竊取資料或造成任何損壞。
遊戲開發是一個非常複雜的過程和專案,如果沒有模組化架構和高效開發環境的幫助,幾乎是不可行的。當前市面上的各種產品共用由以下抽象層次組成的結構,從上到下分別是:遊戲應用、遊戲引擎、圖形API、作業系統、裝置驅動、硬體裝置。
下圖是更加詳細的層級模組,其中作業系統(OS)處於圖形API等第三方SDK和驅動之間,充當著承上啟下的重要作用和通訊橋樑,是整個計算機層級架構極其重要的組成部分。
計算機系統可以分為四個部分:硬體、作業系統、應用程式和使用者。系統元件的抽象檢視如圖1所示。
1、硬體:如CPU、記憶體和I/O裝置。
2、作業系統:在計算機系統的操作中提供正確使用硬體的方法,類似於政府。
3、應用程式:解決使用者的計算問題,如:編譯器、資料庫系統和web瀏覽器。
4、使用者:人、機器或其他計算機。
在頂層,計算機由處理器、記憶體和I/O元件組成,每種型別有一個或多個模組。這些元件以某種方式互連,以實現計算機的主要功能,即執行程式。有四個主要結構要素:
作業系統所處的層級如下圖所示:
通用系統架構如下圖,展示了Windows的總體架構,包含了使用者模式和核心模式元件。
上圖中出現的概念的簡要說明如下:
使用者模式:
使用者程序。是基於映象檔案(image file)的普通程序,在系統上執行,例如Notepad.exe、cmd.exe、explorer.exe等。
子系統DLL。子系統DLL是實現子系統API的動態連結庫(DLL),子系統是核心公開的功能的特定檢視。從技術上講,從Windows 8.1開始,只有一個子系統——Windows子系統。子系統dll包括眾所周知的檔案,如kernel32.dll、user32.dll、gdi32.dll,advapi32.dll和combase.dll和許多其他dll。它們主要實現了Windows的官方API。
NTDLL.DLL。實現Windows本機API的系統範圍DLL,是程式碼的最底層,仍處於使用者模式,最重要的作用是將系統呼叫轉換到核心模式,還實現了堆管理器、映像載入器和使用者模式執行緒池的某些部分。
服務程序。服務程序是正常的Windows程序,與服務控制管理器(SCM,在services.exe中實現)通訊,並允許對其生命週期進行一些控制。SCM可以啟動、停止、暫停、恢復並向服務傳送其他訊息。
系統程序。系統程序是一個概括術語,用於描述通常「就在那裡」的程序,在通常情況下,這些程序不會直接通訊。儘管如此,它們仍然很重要,其中一些對系統的功能至關重要,終止其中一些會導致致命的系統崩潰。一些系統程序是原生程序,意味著它們只使用原生API(由NTDLL實現的API)。系統程序的範例包括Smss.exe、Lsass.exe、Winlogon.exe、Services.exe等。
子系統程序。Windows子系統程序,執行映象Csrss.exe,可視為核心助手,用於管理在Windows系統下執行的程序。它是一個關鍵的程序,如果被殺死,系統將崩潰。通常有一個CSRS.exe範例,因此在標準系統中存在兩個範例——一個用於對談(通常為0),一個用於登入使用者對談(通常為1)。儘管CSRS.exe是Windows子系統的「管理器」(目前僅剩的一個),其重要性不僅僅是此角色。
核心模式:
核心。核心層實現核心模式作業系統程式碼的最基本和時間敏感部分,包括執行緒排程、中斷和異常排程以及各種核心原語(如互斥和號誌)的實現。一些核心程式碼是用CPU特定的機器語言編寫的,以提高效率並直接存取CPU特定的細節。
Win32k.sys。Windows子系統的核心模式元件,本質上是一個核心模組(驅動程式),用於處理Windows的使用者介面部分和經典的圖形裝置介面(GDI)API。意味著所有視窗操作都由該元件處理,系統的其餘部分對UI幾乎一無所知。
Hyper-V管理程式。如果支援基於虛擬化的安全性(VBS),則Hyper-V管理程式存在於Windows 10和server 2016(及更高版本)系統上。VBS提供了額外的安全層,其中實際的機器實際上是由Hyper-V控制的虛擬機器器。
下圖是微核心結構示意圖:
OS的元件通常包含控制程式、系統服務程式、工具類程式等。其中,控制程式建立環境以執行其它程式,通過圖形介面,控制和維護計算機操作,例如windows環境的GUI。系統服務程式無需使用者干預即可執行特定功能,可以手動啟動,也可以設定為在啟動作業系統時自動啟動,在執行作業系統時在後臺執行,可能會影響系統效能、響應能力、能效和安全性,例如任務排程程式、windows更新、信使服務、隨插即用、索引服務等。工具類程式執行與管理系統資源相關的非常具體的任務,關注各種計算機元件的操作方式,常用實用程式是磁碟格式化實用程式、防病毒實用程式、備份實用程式、檔案管理器、磁碟清理等。
作業系統提供由程序通過涉及環轉換的機制存取的服務,以將控制轉移到執行所需功能的核心。這有一個顯著的缺點,即每個服務呼叫都涉及上下文切換的開銷,其中儲存處理器狀態並執行保護域傳輸。然而,正如A High Performance Kernel-Less Operating System Architecture所發現的,在支援分段的處理器體系結構上,通過不執行環轉換,可以在存取作業系統提供的服務時獲得顯著的效能提升。KLOS是基於這種設計構建的無核心作業系統,它的服務呼叫機制比當前廣泛實施的服務或系統呼叫機制快了一個數量級,比傳統陷阱/中斷提高了4倍,比Intel SYSENTER/SYSEXIT快速系統呼叫模型提高了2倍。
KLOS的架構圖。
常見的OS高效能開發技術包含:
線上和離線操作。為每個I/O裝置編寫了一個稱為裝置控制器的特殊子程式,一些I/O裝置已配備用於線上操作(它們連線到處理器)或離線操作(它們由控制單元執行)。
緩衝。緩衝區是一個主記憶體儲器區域,用於在I/O傳輸期間儲存資料。輸入時,資料通過I/O通道放入緩衝區,當傳輸完成時,處理器可以存取資料。可以是單緩衝或雙緩衝。
聯機(同時進行外圍裝置線上操作)。聯機將磁碟用作非常大的緩衝區,它很有用,因為裝置存取不同速率的資料。緩衝區提供了一個等待站,當較慢的裝置趕上時,資料可以在這裡暫存。聯機允許一個作業的計算和另一個作業I/O之間的重疊。
多道程式處理。在多道程式設計中,幾個程式同時儲存在主記憶體中,CPU在它們之間切換,因此CPU總是有一個要執行的程式。作業系統開始從記憶體執行一個程式,如果該程式需要等待,例如I/O操作,作業系統將切換到另一個程式。多程式設計提高了CPU利用率。多道程式設計系統提供了一種環境,在這種環境中,各種系統資源得到了有效利用,但它們不提供使用者與計算機系統的互動。優勢是CPU利用率高,似乎許多程式幾乎同時分配CPU。缺點是需要CPU排程,要在記憶體中容納許多作業,需要記憶體管理。
並行系統。系統中的處理器上有2個及以上,這些處理器共用計算機匯流排、時鐘、記憶體和I/O裝置。優點是提高吞吐量(以時間單位完成的程式數)。
分散式系統。在幾個物理處理器之間分配計算,涉及通過通訊鏈路連線2個或多個獨立的計算機系統。因此,每個處理器都有自己的OS和本地記憶體,處理器通過各種通訊線路(如高速匯流排或電話線)相互通訊。
分散式系統的優點:
個人計算機。專用於單個使用者的計算機系統,PC作業系統既不是多使用者系統,也不是多工系統。PC作業系統的目標是最大限度地提高使用者的便利性和響應能力,而不是最大限度地利用CPU和I/O。比如Microsoft Windows和Apple Macintosh。
A caching model of operating system kernel functionality描述了作業系統功能的快取模型,可以快取核心快取執行緒和地址空間等作業系統物件,就像傳統硬體快取記憶體資料一樣。使用者模式應用程式核心處理這些物件的載入和寫回,實現特定於應用程式的管理策略和機制。在多處理器上實現快取核心及其效能測量的經驗表明,該快取模型可以提供與傳統單片作業系統相比具有競爭力的效能,同時還可以提供系統資源的應用程式級控制、更好的模組化、更好的可伸縮性、更小的大小以及故障控制的基礎。
如下圖所示,各種應用程式、伺服器核心和作業系統模擬器可以在同一硬體上同時執行。一個稱為系統資源管理器(SRM)的特殊應用程式核心,每個快取核心/MPM複製一個,管理其他應用程式核心之間的資源共用,以便它們可以同時共用相同的硬體,而不會產生不合理的干擾。例如,它可以防止執行大型模擬的惡意應用程式核心中斷提供在同一ParaDiGM設定上執行的分時服務的UNIX模擬器的執行。
軟體架構一覽。
下圖顯示了Cache Kernel物件之間的依賴關係。圖中的箭頭表示從箭頭尾部的物件到頭部的物件的參照,因此是快取依賴項。例如,實體記憶體對映中的訊號對映參照一個執行緒,該執行緒參照一個參照其所屬核心物件的地址空間。因此,當執行緒、地址空間或核心被解除安裝時,必須解除安裝訊號對映。
快取資料架構。
作業系統與執行它的計算機的硬體緊密相連,它擴充套件了計算機的指令集並管理其資源。一臺簡單的個人計算機可以抽象為類似於下圖的模型,CPU、記憶體和I/O裝置都通過系統匯流排連線,並通過它彼此通訊。現代個人電腦的結構更復雜,涉及多條匯流排。
計算機硬體架構抽象圖。
計算機硬體組成圖。
Intel Core i7結構圖。
計算機的「大腦」是CPU,它從記憶體中獲取指令並執行它們。每個CPU的基本週期是從記憶體中獲取第一條指令,對其進行解碼以確定其型別和運算元,然後執行,然後獲取、解碼和執行後續指令。迴圈重複,直到程式結束。以這種方式執行程式。
每個CPU都有一組特定的指令可以執行。因此,x86處理器無法執行ARM程式,而ARM處理器無法執行x86程式。因為存取記憶體以獲取指令或資料字比執行指令需要更長的時間,所以所有CPU都包含一些暫存器來儲存關鍵變數和臨時結果。因此,指令集通常包含將字從記憶體載入到暫存器,並將字從暫存器儲存到記憶體的指令。其他指令將來自暫存器、記憶體或兩者的兩個運算元組合成一個結果,例如新增兩個字並將結果儲存在暫存器或記憶體中。
除了用於儲存變數和臨時結果的通用暫存器外,大多數計算機還具有程式設計師可見的幾個專用暫存器。其中之一是程式計數器,它包含要提取的下一條指令的記憶體地址。獲取該指令後,程式計數器將更新為指向其後續指令。另一個暫存器是堆疊指標,它指向記憶體中當前堆疊的頂部。堆疊包含每個已進入但尚未退出的過程的一個幀。過程的堆疊框架儲存那些不儲存在暫存器中的輸入引數、區域性變數和臨時變數。另一個暫存器是PSW(程式狀態字)。該暫存器包含由比較指令、CPU優先順序、模式(使用者或核心)和各種其他控制位設定的條件程式碼位。使用者程式通常可以讀取整個PSW,但通常只能寫入其部分欄位。PSW在系統呼叫和I/O中起著重要作用。
作業系統必須完全瞭解所有暫存器。當對CPU進行多路複用時,作業系統通常會停止正在執行的程式以(重新)啟動另一個程式。每次停止執行的程式時,作業系統必須儲存所有暫存器,以便在程式稍後執行時恢復。
為了提高效能,CPU設計者早已放棄了一次獲取、解碼和執行一條指令的簡單模型。許多現代CPU都具有同時執行多條指令的功能。例如,CPU可能有單獨的提取、解碼和執行單元,因此在執行指令n時,它也可能是解碼指令n+1和提取指令n+2。這種組織稱為管道,如下圖(a)所示,用於三級管道。較長的管道是常見的。在大多數管道設計中,一旦指令被提取到管道中,就必須執行它,即使前面的指令是執行的條件分支。管道給編譯器編寫者和作業系統編寫者帶來了極大的麻煩,因為它們向他們暴露了底層機器的複雜性,並且他們必須處理它們。
比管道設計更先進的是超標量(superscalar)CPU,如上圖(b)所示。在這種設計中,有多個執行單元,例如一個用於整數運算,一個用於浮點運算,另一個用於布林運算。兩個或多個指令被同時獲取、解碼並轉儲到一個保持緩衝區,直到它們可以執行為止。一旦執行單元可用,它就會檢視保持緩衝區中是否有它可以處理的指令,如果有,就從緩衝區中刪除該指令並執行它。這種設計的一個含義是,程式指令經常亂序執行。在大多數情況下,要由硬體來確保產生的結果與順序實現所產生的結果相同,但正如我們將看到的那樣,作業系統上強加了令人討厭的複雜性。
如前所述,除嵌入式系統中使用的非常簡單的CPU外,大多數CPU都有兩種模式,核心模式和使用者模式。通常,PSW中的一個位控制模式。在核心模式下執行時,CPU可以執行其指令集中的每一條指令,並使用硬體的每一項功能。在桌上型電腦和伺服器上,作業系統通常以核心模式執行,從而可以存取整個硬體。在大多數嵌入式系統上,一小部分以核心模式執行,其餘作業系統以使用者模式執行。
使用者程式總是在使用者模式下執行,該模式只允許執行指令的子集和存取功能的子集。通常,在使用者模式下,所有涉及I/O和記憶體保護的指令都是不允許的。當然,也禁止將PSW模式位設定為進入核心模式。
要從作業系統獲取服務,使用者程式必須進行系統呼叫,該呼叫會進入核心並呼叫作業系統。TRAP指令從使用者模式切換到核心模式,並啟動作業系統。工作完成後,根據系統呼叫後的指令將控制權返回給使用者程式。可以將其視為一種特殊的程序呼叫,它具有從使用者模式切換到核心模式的附加屬性。
值得注意的是,除了執行系統呼叫的指令之外,計算機還有陷阱(trap),大多數其它陷阱是由硬體警告異常情況(如試圖除以0或浮點下溢)引起的。在所有情況下,作業系統都會得到控制,並且必須決定要做什麼。有時程式必須因錯誤而終止,其他時候可以忽略錯誤(下溢數位可以設定為0)。最後,當程式事先宣佈要處理某些型別的條件時,可以將控制權傳遞迴程式,讓它處理問題。
除了多執行緒之外,現在許多CPU晶片上都有四個、八個或更多完整的處理器或核心。下圖中的多核晶片有效地攜帶了四個微型晶片,每個晶片都有自己的獨立CPU,一些處理器(如Intel Xeon Phi和Tilera TilePro)已經在單個晶片上執行了60多核。使用這種多核晶片肯定需要多處理器作業系統。
順便說一句,就絕對數量而言,沒有什麼能比得上現代GPU(圖形處理單元),GPU是一個擁有數千個微核心的處理器,它們對於許多並行完成的小計算非常有用,比如在圖形應用程式中渲染多邊形。他們不太擅長連續任務,也很難程式設計。雖然GPU對作業系統很有用(例如,加密或處理網路流量),但作業系統本身不太可能在GPU上執行。
(a) 具有共用二級快取的四核晶片。(b) 具有獨立二級快取的四核晶片。
任何計算機的第二個主要部件是記憶體。理想情況下,記憶體應該非常快(比執行指令快,這樣CPU就不會被記憶體佔用),非常大且非常便宜。目前沒有任何技術能夠滿足所有這些目標,因此採取了不同的方法。記憶體系統被構造為一個層次結構,如下圖所示。與低層相比,頂層具有更高的速度、更小的容量和更高的每位元成本,通常是10億或更多倍。
記憶體層級架構和速度示意圖。
頂層由CPU內部的暫存器組成,它們由與CPU相同的材料製成,因此與CPU一樣快,因此,存取它們沒有任何延誤。它們的可用儲存容量通常是32位元CPU上的32×32位元,64位元CPU上為64×64位元,兩種情況下都小於1 KB。程式必須在軟體中自行管理暫存器(即決定在其中儲存什麼)。
快取記憶體主要由硬體控制。主記憶體被劃分為快取行,通常為64位元組,快取線0中的地址為0到63,快取線1中的地址是64到127,依此類推。最常用的快取行儲存在CPU內部或非常靠近CPU的快取記憶體中,當程式需要讀取記憶體字時,快取硬體會檢查所需的行是否在快取中。如果是,稱為快取命中,則請求從快取中得到滿足,並且沒有記憶體請求通過匯流排傳送到主記憶體。快取命中通常需要大約兩個時鐘週期,快取未命中必須轉移到記憶體中,會導致大量時間損失。由於快取記憶體的成本較高,其大小受到限制,有些機器有兩級甚至三級快取,每級快取都比前一級快取更慢、更大。
快取和記憶體結構。
快取在電腦科學的許多領域都扮演著重要角色,不僅僅是快取RAM行。每當一個資源可以劃分為多個部分時,其中一些部分的使用量比其他部分大得多,快取通常用於提高效能。作業系統一直在使用它。例如,大多數作業系統將頻繁使用的檔案(片段)儲存在主記憶體中,以避免重複從磁碟獲取它們。類似地,轉換長路徑名的結果如下:
/home/ast/projects/minix3/src/kernel/clock.c
可以快取到檔案所在的磁碟地址,以避免重複查詢。最後,當網頁(URL)的地址轉換為網路地址(IP地址)時,可以快取結果以供將來使用。還有許多其他用途。在任何快取系統中,很快就會出現幾個問題,包括:
1、何時將新專案放入快取。
2、將新專案放入哪個快取行。
3、需要插槽時要從快取中刪除的項。
4、將新收回的專案放在較大記憶體中的何處。
並非每個問題都與每個快取情況相關。對於CPU快取中的主記憶體快取行,通常在每次快取未命中時都會輸入一個新項。要使用的快取行通常是通過使用參照的記憶體地址的一些高位來計算的。例如,對於4096條64位元組和32位元地址的快取行,可以使用位6到17來指定快取行,其中位0到5是快取行內的位元組。在這種情況下,要刪除的項與新資料進入的項相同,但在其他系統中可能不是。最後,當快取行被重寫到主記憶體時(如果快取行在被快取後已被修改),記憶體中要重寫它的位置是由所討論的地址唯一確定的。
快取是一個好主意,現代CPU有兩個快取。第一級快取或L1快取始終位於CPU內部,通常將解碼的指令送入CPU的執行引擎。大多數晶片都有第二個L1快取,用於儲存大量使用的資料字,一級快取通常每個為16 KB。此外,通常還有一個稱為L2快取的第二個快取,它儲存了幾兆位元組的最近使用的記憶體字。一級快取和二級快取的區別在於定時,對一級快取的存取不會有任何延遲,而對二級快取的存取會延遲一個或兩個時鐘週期。
在多核晶片上,設計者必須決定快取的位置。在下圖(a),所有核心共用一個二級快取。這種方法用於Intel多核晶片。相反,在下圖(b)中,每個核心都有自己的二級快取,AMD採用了這種方法。每種策略都有其優缺點,例如,Intel共用的二級快取需要更復雜的快取控制器,但AMD方法使保持二級快取一致性更加困難。
在上上圖的層次結構中,主記憶體緊隨其後,是記憶體系統的主力,通常稱為RAM(隨機存取記憶體),更早之前稱之為磁芯記憶體,因為20世紀50年代和60年代的計算機使用微小的可磁化鐵氧體磁芯作為主記憶體儲器,所有無法從快取中滿足的CPU請求都會轉到主記憶體。
除了主記憶體儲器之外,許多計算機還有少量非易失性隨機存取記憶體。與RAM不同,非易失性記憶體在電源關閉時不會丟失其內容。ROM(唯讀記憶體)是在工廠程式設計的,以後不能更改。它既快又便宜,在一些計算機上,用於啟動計算機的引導載入程式包含在ROM中。此外,一些I/O卡附帶ROM,用於處理低階裝置控制。
EEPROM(電可擦除PROM)和快閃記憶體也是非易失性的,但與ROM相反,它們可以擦除和重寫。然而,寫它們比寫RAM需要更多數量級的時間,因此它們的使用方式與ROM相同,只是有了一個額外的功能,即現在可以通過在現場重寫它們來糾正程式中的錯誤。
快閃記憶體也常用作行動式電子裝置的儲存媒介,在數碼相機中用作膠片,在行動式音樂播放器中用作磁碟,僅舉兩個用途。快閃記憶體的速度介於RAM和磁碟之間。此外,與磁碟記憶體不同,如果擦除次數太多,它就會磨損。
另一種記憶體是CMOS,它是易失性的。許多計算機使用CMOS記憶體來儲存當前時間和日期,CMOS記憶體和增加時間的時鐘電路由一個小電池供電,因此即使拔下電腦插頭,時間也能正確更新。CMOS記憶體還可以儲存設定引數,例如從哪個磁碟啟動。之所以使用CMOS,是因為它耗電很少,以至於原廠安裝的電池通常可以使用幾年。然而,當它開始出現故障時,計算機可能會出現阿爾茨海默病,忘記它多年來知道的事情,比如從哪個硬碟啟動。
磁碟儲存每位元比RAM便宜兩個數量級,通常也要大兩個數量級別,唯一的問題是隨機存取資料的時間慢了近三個數量級,原因是磁碟是一種機械裝置,如下圖所示。
磁碟由一個或多個旋轉速度為5400、7200、10800 RPM或以上的金屬盤組成。一個機械臂從角落繞著碟片旋轉,類似於老式33-RPM留聲機上播放乙烯基唱片的拾取臂,資訊以一系列同心圓寫入磁碟。在任何給定的手臂位置,每個頭部都可以讀取一個稱為軌道的環形區域,給定手臂位置的所有軌道一起構成一個圓柱體。
每個磁軌被劃分為若干磁區,通常每個磁區512位元組。在現代磁碟上,外圓柱體包含的磁區比內圓柱體多。將臂從一個圓柱體移動到另一個圓柱體大約需要1毫秒。根據驅動器的不同,將其移動到隨機圓柱體通常需要5到10毫秒。一旦臂位於正確的軌道上,驅動器必須等待所需磁區在磁頭下旋轉,根據驅動器的RPM,額外延遲5毫秒到10毫秒。一旦磁區位於磁頭之下,低端磁碟的讀取或寫入速度為50 MB/秒,而高速磁碟的讀取和寫入速度為160 MB/秒。
有時,我們會聽到人們談論實際上根本不是磁碟的磁碟,如SSD(固態磁碟)。SSD沒有移動部件,不包含磁碟形狀的碟片,並將資料儲存在(快閃記憶體)記憶體中。它們與磁碟相似的唯一方式是,儲存了大量資料,這些資料在斷電時不會丟失。
許多計算機支援一種稱為虛擬記憶體的方案,該方案通過將程式放在磁碟上並將主記憶體用作執行最頻繁的部分的快取,使執行大於實體記憶體的程式成為可能。此方案需要動態重新對映記憶體地址,以將程式生成的地址轉換為字所在的RAM中的實體地址。此對映由CPU的一部分MMU(記憶體管理單元)完成。
快取和MMU的存在會對效能產生重大影響。在多道程式設計系統中,當從一個程式切換到另一個程式(有時稱為上下文切換)時,可能需要從快取中清除所有修改過的塊,並更改MMU中的對映暫存器。這兩種操作昂貴很大,應該被開發者注意並規避。
IO儲存裝置根據速度和媒介,有著以下的層級關係:
Linux中的網路分層如下:
CPU和記憶體不是作業系統必須管理的唯一資源,I/O裝置還與作業系統進行大量互動,通常由兩部分組成:控制器和裝置本身。
控制器是物理控制裝置的一個或一組晶片,接受來自作業系統的命令,例如從裝置讀取資料,並執行這些命令。在許多情況下,裝置的實際控制是複雜和詳細的,因此控制器的工作是為作業系統提供一個更簡單(但仍然非常複雜)的介面。例如,磁碟控制器可能會接受從磁碟2讀取磁區11206的命令。然後,控制器必須將此線性磁區編號轉換為圓柱體、磁區和磁頭。由於外部圓柱體的磁區比內部圓柱體的多,並且一些壞磁區已重新對映到其他磁區,因此這種轉換可能會變得複雜。然後,控制器必須確定磁碟臂在哪個圓柱體上,並向其發出命令,以移入或移出所需數量的圓柱體。它必須等到正確的磁區在磁頭下旋轉,然後開始讀取和儲存從驅動器上下來的位,刪除前導碼並計算校驗和。最後,它必須將輸入的位組合成單詞並儲存在記憶體中。為了完成所有這些工作,控制器通常包含小型嵌入式計算機,這些計算機被程式設計來完成它們的工作。
另一部分是實際裝置本身。裝置有相當簡單的介面,既是因為它們不能做很多事情,也是為了使它們成為標準。例如,需要後者,以便任何SATA磁碟控制器都可以處理任何SATA盤。SATA代表序列ATA,ATA代表AT附件,是圍繞當時功能極為強大的6-MHz 80286處理器而構建的。SATA目前是許多計算機上的標準磁碟型別。由於實際的裝置介面隱藏在控制器後面,作業系統看到的只是控制器的介面,可能與裝置的介面有很大的不同。
因為每種型別的控制器都不同,所以需要不同的軟體來控制每種控制器,與控制器對話、發出命令並接受響應的軟體稱為裝置驅動程式,每個控制器製造商必須為其支援的每個作業系統提供一個驅動程式。例如掃描器可能附帶OS X、Windows 7、Windows 8和Linux的驅動程式。
要使用該驅動程式,必須將其放入作業系統中,以便它可以在核心模式下執行。驅動程式實際上可以在核心外執行,現在像Linux和Windows這樣的作業系統確實提供了一些支援,絕大多數驅動程式仍在核心邊界以下執行。目前只有極少數系統(如MINIX 3)在使用者空間中執行所有驅動程式,必須允許使用者空間中的驅動程式以受控方式存取裝置,但並不簡單。
有三種方法可以將驅動程式放入核心。第一種方法是用新的驅動程式重新連結核心,然後重新啟動系統,例如許多較舊的UNIX系統。第二種方法是在作業系統檔案中輸入一個條目,告訴它需要驅動程式,然後重新啟動系統。在引導時,作業系統會找到所需的驅動程式並載入它們,如Windows。第三種方法是讓作業系統能夠在執行時接受新的驅動程式,並動態安裝它們,而無需重新啟動。這種方式過去很少見,但現在越來越普遍了。熱插拔裝置,如USB和IEEE 1394裝置,始終需要動態載入的驅動程式。
每個控制器都有少量用於與其通訊的暫存器。例如,最小磁碟控制器可能具有指定磁碟地址、記憶體地址、磁區計數和方向(讀或寫)的暫存器。為了啟用控制器,驅動程式從作業系統獲取命令,然後將其轉換為適當的值,寫入裝置暫存器。所有裝置暫存器的集合構成I/O埠空間。
在某些計算機上,裝置暫存器被對映到作業系統的地址空間(它可以使用的地址),因此它們可以像普通記憶體字一樣讀取和寫入。在這類計算機上,不需要特殊的I/O指令,使用者程式可以通過不將這些記憶體地址放在其可及範圍內而遠離硬體(例如,通過使用基址和限制暫存器)。在其他計算機上,裝置暫存器放在一個特殊的I/O埠空間中,每個暫存器都有一個埠地址。在這些機器上,核心模式下有特殊的IN和OUT指令,允許驅動程式讀取和寫入暫存器。前一種方案不需要特殊的I/O指令,但會佔用一些地址空間。後者不使用地址空間,但需要特殊說明。這兩種系統都被廣泛使用。
輸入和輸出可以用三種不同的方式完成。在最簡單的方法中,使用者程式發出一個系統呼叫,然後核心將其轉換為對相應驅動程式的過程呼叫。然後,驅動程式啟動I/O並在一個緊密的迴圈中持續輪詢裝置,以檢視是否完成了操作(通常有一些位表示裝置仍在忙)。當I/O完成時,驅動程式將資料(如果有)放在需要的位置並返回。然後,作業系統將控制權返回給呼叫者。這種方法稱為繁忙等待,其缺點是佔用CPU輪詢裝置直到完成。
第二種方法是驅動程式啟動裝置,並在完成時要求其中斷。此時,驅動返回。然後,如果需要,作業系統會阻止呼叫者,並尋找其他工作。當控制器檢測到傳輸結束時,它會生成一箇中斷以完成訊號。
中斷在作業系統中非常重要,所以讓我們更仔細地研究一下這個想法。在下圖(a)中,我們看到I/O的三步過程。在步驟1中,驅動程式通過寫入其裝置暫存器來告訴控制器要做什麼。然後,控制器啟動裝置。當控制器完成讀取或寫入它被要求傳輸的位元組數時,它在步驟2中使用某些匯流排向中斷控制器晶片傳送訊號。如果中斷控制器準備接受中斷(如果它忙於處理更高優先順序的中斷,則可能不會接受),它在CPU晶片上斷言一個管腳,在步驟3中告訴它。在步驟4中,中斷控制器將裝置的編號放在匯流排上,這樣CPU就可以讀取它,並知道哪個裝置剛剛完成(許多裝置可能同時執行)。
一旦CPU決定接受中斷,程式計數器和PSW通常被推到當前堆疊上,CPU切換到核心模式。裝置編號可用作記憶體部分的索引,以查詢該裝置的中斷處理程式的地址。這部分記憶體稱為中斷向量。一旦中斷處理程式(中斷裝置驅動程式的一部分)啟動,它將刪除堆疊程式計數器和PSW並儲存它們,然後查詢裝置以瞭解其狀態。當處理程式全部完成時,它返回到以前執行的使用者程式,返回到尚未執行的第一條指令。這些步驟如下圖(b)所示。
(a)啟動I/O裝置並獲得中斷的步驟。(b) 中斷處理包括接受中斷、執行中斷處理程式和返回使用者程式。
執行I/O的第三種方法使用特殊的硬體:DMA(直接記憶體存取)晶片,可以控制記憶體和某些控制器之間的位元流,而無需持續的CPU干預。CPU設定DMA晶片,告訴它要傳輸多少位元組、涉及的裝置和記憶體地址以及方向,然後讓它執行。當DMA晶片完成時,它會觸發中斷,如上文所述進行處理。
例如,當另一箇中斷處理程式正在執行時,中斷可能(而且經常)發生在非常不方便的時刻。因此,CPU有一種方法可以禁用中斷,然後稍後重新啟用它們。當中斷被禁用時,任何完成的裝置都會繼續斷言其中斷訊號,但CPU不會中斷,直到再次啟用中斷。如果多個裝置在中斷被禁用時完成,中斷控制器通常根據分配給每個裝置的靜態優先順序決定首先讓哪個裝置通過。優先順序最高的裝置獲勝並首先得到服務。其他必須等待。
隨著處理器和記憶體的速度越來越快,單個匯流排(當然還有IBM PC匯流排)處理所有流量的能力已經到了極限。為了更快的I/O裝置和CPU到記憶體的通訊量,新增了額外的匯流排。由於這一演變,一個大型x86系統目前看起來類似於下圖。
大型x86系統的結構。
該系統有許多匯流排(例如,快取、記憶體、PCIe、PCI、USB、SATA和DMI),每個匯流排具有不同的傳輸速率和功能。作業系統必須瞭解所有這些資訊,以便進行設定和管理。
主匯流排是PCIe(外圍元件互連高速)匯流排,PCIe匯流排是Intel作為舊PCI匯流排的繼承者而發明的,而舊PCI匯流排又是原始ISA(行業標準體系結構)匯流排的替代品。PCIe能夠每秒傳輸數十Gb的資料,比其前代產品快得多,它的性質也非常不同。直到2004年建立,大多數匯流排都是並行共用的,共用匯流排架構意味著多個裝置使用相同的線路傳輸資料。因此,當多個裝置有資料要傳送時,需要一個仲裁器(arbiter)來確定誰可以使用匯流排。相反,PCIe使用專用的點到點連線,傳統PCI中使用的並行匯流排架構意味著可以通過多條導線傳送每個字的資料。例如,在常規PCI匯流排中,單個32位元數位通過32條並行線傳送。與此相反,PCIe使用序列匯流排體系結構,並通過單個連線(稱為通道)傳送訊息中的所有位,就像網路封包一樣。這種方式簡單得多,因為不必確保所有32位元都在同一時間到達目的地。仍然使用平行性,因為可以有多條平行通道(lane),例如可以使用32條通道並行傳輸32條訊息。隨著網路卡和圖形介面卡等外圍裝置速度的快速增長,PCIe標準每3-5年升級一次。例如,16通道PCIe 2.0提供每秒64 Gb的速率,升級到PCIe 3.0將使速度提高一倍,而PCIe 4.0將使速度再次提高一倍。
與此同時,仍有許多適用於舊PCI標準的傳統裝置,如上圖所示,這些裝置連線到單獨的集線器處理器。未來,當我們認為PCI不再僅僅是老式的,而是老式的時,所有PCI裝置都有可能連線到另一個集線器,而該集線器又將它們連線到主集線器上,從而形成匯流排樹。
在此設定中,CPU通過快速DDR3匯流排與記憶體通訊,通過PCIe與外部圖形裝置通訊,並通過DMI(直接媒體介面)匯流排上的集線器與所有其他裝置通訊。集線器依次連線所有其他裝置,使用通用序列匯流排與USB裝置通訊,使用SATA匯流排與硬碟和DVD驅動器互動,使用PCIe傳輸乙太網幀。
此外,每個核心都有一個專用快取和一個更大的快取,在它們之間共用,每個快取都引入另一條匯流排。
發明USB(通用序列匯流排)是為了將所有低速I/O裝置(如鍵盤和滑鼠)連線到計算機上。然而,對於以8-Mbps ISA作為第一臺IBM PC的主匯流排而成長起來的一代人來說,將以5 Gbps「低速」執行的現代USB 3.0裝置稱為「低速」可能不是自然而然的。USB使用一個帶有四到十一根線(取決於版本)的小聯結器,其中一些線為USB裝置供電或接地。USB是一種集中式匯流排,其中根裝置每1毫秒輪詢一次所有I/O裝置,以檢視它們是否有流量。USB 1.0可以處理12 Mbps的總負載,USB 2.0將速度提高到480 Mbps,USB 3.0最高不低於5 Gbps。任何USB裝置都可以連線到計算機上,將立即執行,而無需重新啟動計算機,這是USB裝置之前所需的,讓一代受挫的使用者非常震驚。
SCSI(小型計算機系統介面)匯流排是一種高效能匯流排,用於快速磁碟、掃描器和其他需要大量頻寬的裝置。如今,它們大多在伺服器和工作站上,可以以高達640 MB/秒的速度執行。
要在上圖所示的環境中工作,作業系統必須知道哪些外圍裝置連線到計算機並對其進行設定。這一要求促使英特爾和微軟基於蘋果Macintosh首次實現的類似概念,設計了一種稱為隨插即用的PC系統。在隨插即用之前,每個I/O卡都有一個固定的中斷請求級別和其I/O暫存器的固定地址。例如,鍵盤為中斷1並使用I/O地址0x60至0x64,軟碟控制器為中斷6並使用I/O位置0x3F0至0x3F7,印表機為中斷7並使用I/O位址0x378至0x37A,依此類推。
到目前為止,一切都很好。當用戶購買了一張音效卡和一張資料機卡,而這兩張卡都碰巧使用了同一中斷(如中斷4)時,問題就出現了——會發生衝突,不能一起工作。解決方案是在每個I/O卡上包括DIP開關或跳線,並指示使用者請將其設定為選擇一箇中斷級別和I/O裝置地址,該中斷級別和輸入/輸出裝置地址不會與使用者系統中的任何其他地址衝突。不幸的是,極少人能做到,導致混亂。
隨插即用所做的是讓系統自動收集有關I/O裝置的資訊,集中分配中斷級別和I/O地址,然後告訴每個卡的編號。這項工作與啟動計算機密切相關,且不是簡單的小事。
下面兩圖顯示了主流的核心總體架構。
核心架構1。
核心架構2。
圖的底部顯示了核心的一部分,該部分(以及從那裡呼叫的函數)在監督者模式下執行。在監督器模式下執行的所有程式碼都是用組合程式編寫的,幷包含在檔案crt0.S中。crt0.S中的程式碼分為啟動程式碼、存取硬體的函數、中斷服務例程、任務開關(排程程式)和出於效能原因而用組合程式寫的號誌函數。
圖的中間部分顯示了在使用者模式下執行的核心的其餘部分。對crt0.S中程式碼的任何呼叫都需要更改為監控模式,即從中間到下部的每個箭頭都與一個或多個TRAP指令相關,這些指令會導致監控模式的更改。類os包含一組帶有TRAP指令的包裝函數,使應用程式能夠存取某些硬體部件。SerialIn和SerialOut類稱為序列I/O,需要硬體存取,也可以從中斷服務例程存取。Class Task包含與任務管理相關的任何內容,並使用核心的supervisor部分進行(顯式)工作切換。工作切換也由中斷服務例程引起。Semaphore類提供包裝函數,使其成員函數的實現在使用者模式下可用。核心內部使用了幾個Queue類,並且應用程式也可以使用這些類;他們中的大多數使用Semaphore類。
通常,應用程式與內部核心介面無關,與核心相關的介面是在類os、SerialIn、SerialOut、Task、Queue和Semaphore中定義的介面。
下圖給出了核心的主要元件的簡單概述。可以看到底部的硬體,硬體由晶片、電路板、磁碟、鍵盤、顯示器和類似的物理物件組成。硬體之上是軟體,大多數計算機有兩種操作模式:核心模式和使用者模式。作業系統是軟體中最基本的部分,它以核心模式(也稱為管理器模式)執行。在這種模式下,它可以完全存取所有硬體,並可以執行機器能夠執行的任何指令。軟體的其餘部分以使用者模式執行,在該模式下,只有機器指令的一個子集可用。特別是,那些影響機器控制或進行I/O輸入/輸出的指令「被禁止用於使用者模式程式。本文反覆討論核心模式和使用者模式之間的區別,它在作業系統的工作方式中起著至關重要的作用。
更詳細的結構圖如下:
傳統的Unix核心架構圖如下:
現代Unix核心已經進化成如下架構:
Linux核心模組列表樣例如下:
Linux核心元件如下所示:
Windows核心公開各種型別的物件,供使用者模式程序、核心本身和核心模式驅動程式使用。這些型別的範例是系統(核心)空間中的資料結構,當用戶或核心模式程式碼請求時,由物件管理器(執行程式的一部分)建立和管理。核心物件使用了參照計數,因此只有當物件的最後一個參照被釋放時,物件才會被銷燬並從記憶體中釋放。
Windows核心支援很多物件型別,可從Sysinternals執行WinObj工具,並找到ObjectTypes目錄(下圖)。可以根據其可見性和用途進行分類:
核心物件的主要屬性如下圖所示:
某些型別的物件可以具有基於字串的名稱,這些名稱可用於使用適當的開啟函數按名稱開啟物件。注意,並非所有物件都有名稱,例如程序和執行緒沒有名稱——它們有ID。這就是為什麼OpenProcess和OpenThread函數需要程序/執行緒識別符號(數位)而不是基於string的名稱。
在使用者模式程式碼中,如果不存在具有名稱的物件,則呼叫具有名稱的建立函數將建立具有該名稱的物件;如果存在,則只開啟現有物件。
提供給建立函數的名稱不是物件的最終名稱。在經典(桌面)程序中,它前面有\Sessions\x\BaseNamedObjects\其中x是呼叫方的對談ID。如果對談為零,則名稱前面只加上\BaseNamedObjects\。如果呼叫方碰巧在AppContainer(通常是通用Windows平臺程序)中執行,則字首字串更復雜,由唯一的AppContainerSID: \Sessions\x\AppContaineerNameObjects\組成。
核心物件的控制程式碼是程序私有的,但在某些情況下,程序可能希望與另一個程序共用核心物件。這樣的程序不能簡單地將控制程式碼的值傳遞給其他程序,因為在其他程序的控制程式碼表中,控制程式碼值可能指向其他物件或為空。顯然,必須有某種機制來允許這種分享。事實上,有三種分享機制:
按名稱共用。如果可用,是最簡單的方法。「可用」表示所討論的物件可以有名稱,並且確實有名稱。典型的場景是,共同作業程序(2個或更多)將使用相同的物件名呼叫相應的Create函數。進行呼叫的第一個程序將建立物件,其他程序的後續呼叫將為同一物件開啟其他控制程式碼。
通過控制程式碼繼承共用。通常是父程序建立子程序時,傳入繼承屬性和資料而達成。
通過複製控制程式碼共用。控制程式碼複製沒有固有的限制(除了安全性),幾乎可以在任何核心物件上工作,無論是有命字的還是沒有名字的,並且可以在任何時間點工作。然而,有一個缺陷,是實踐中最困難的分享方式(後面會提及)。Windows通過呼叫DuplicateHandle
複製控制程式碼。
Windows複製控制程式碼應用案例圖示。
由於核心物件駐留在系統空間中,因此無法直接從使用者模式存取它們。應用程式必須使用間接機制來存取核心物件,稱為控制程式碼(Handle)。控制程式碼至少具有以下優點:
核心物件是參照計數的。物件管理器維護控制程式碼計數和指標計數,其和是物件的總參照計數(直接指標可以從核心模式獲得)。一旦不再需要使用者模式使用者端使用的物件,使用者端程式碼應通過呼叫CloseHandle關閉用於存取該物件的控制程式碼。之後控制程式碼將無效,嘗試通過關閉控制程式碼存取物件將失敗。在一般情況下,使用者端不知道物件是否已被銷燬。如果物件的參照降至零,則物件管理器將刪除該物件。
控制程式碼值是4的倍數,其中第一個有效控制程式碼是4;零永遠不是有效的控制程式碼值,在64位元系統上亦是如此。控制程式碼間接指向核心空間中的一個小資料結構,該結構包含控制程式碼的一些資訊。下圖描述了32位元和64位元系統的資料結構。
在32位元系統上,該控制程式碼條目的大小為8位元組,在64位元系統上為16位元組(從技術上而言,12位元組已足夠,但為了對齊目的,會擴充套件為16位元組)。每個條目包含以下成分:
存取掩碼是位掩碼,其中每個「1」位表示可以使用該控制程式碼執行的特定操作。當通過建立物件或開啟現有物件建立控制程式碼時,將設定存取掩碼。如果建立了物件,則呼叫者通常具有對該物件的完全存取權。但是,如果物件被開啟,呼叫方需要指定所需的存取掩碼,它可能會得到,也可能不會得到。
某些控制程式碼具有特殊值,不可關閉,被稱為偽控制程式碼(Pseudo Handles),儘管它們在需要時與任何其他控制程式碼一樣使用。在偽控制程式碼上呼叫CloseHandle總是失敗。
當不再需要控制程式碼時,關閉控制程式碼非常重要。如果應用程式未能正確執行此操作,則可能會出現「控制程式碼洩漏」,即如果應用程式開啟控制程式碼但「忘記」關閉它們,則控制程式碼的數量將無法控制地增長。幫助程式碼管理控制程式碼而不忘記關閉它們的一種方法是使用C++實現一個眾所周知的習慣用法,稱為資源獲取即初始化(Resource Acquisition is Initialization,RAII)。其思想是對包裝在型別中的控制程式碼使用解構函式,以確保在包裝物件被銷燬時關閉控制程式碼。下面是一個簡單的控制程式碼RAII封裝器:
struct Handle
{
explicit Handle(HANDLE h = nullptr)
:_h(h) // 初始化
{}
// 解構函式關閉控制程式碼。
~Handle() { Close(); }
// 刪除拷貝構造和拷貝賦值
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
// 允許移動(所有權轉移)
Handle(Handle&& other) : _h(other._h)
{
other._h = nullptr;
}
Handle& operator=(Handle&& other)
{
if (this != &other)
{
Close();
_h = other._h;
other._h = nullptr;
}
return *this;
}
operator bool() const
{
return _h != nullptr && _h != INVALID_HANDLE_VALUE;
}
HANDLE Get() const
{
return _h;
}
void Close()
{
if (_h)
{
::CloseHandle(_h);
_h = nullptr;
}
}
private:
HANDLE _h;
};
Windows中還有其他常用核心物件,即使用者物件和GDI物件。以下是這些物件的簡要描述以及這些物件的控制程式碼。
任何作業系統核心的核心職責是管理連線到機器硬碟機和藍光光碟、鍵盤和滑鼠、3D處理器和無線收音機的硬體。為了履行這一職責,核心需要與機器的各個裝置進行通訊。考慮到處理器的速度可能比它們與之對話的硬體快幾個數量級,核心發出請求並等待明顯較慢的硬體的響應是不理想的。相反,由於硬體的響應速度相對較慢,核心必須能夠自由地去處理其他工作,只有在硬體實際完成工作之後才處理硬體。
處理器如何在不影響機器整體效能的情況下與硬體一起工作?這個問題的一個答案是輪詢(polling),核心可以定期檢查系統中硬體的狀態並做出相應的響應。然而,輪詢會產生開銷,因為無論硬體是活動的還是就緒的,輪詢都必須重複進行。更好的解決方案是提供一種機制,讓硬體在需要注意時向核心發出訊號,這種機制稱為中斷(interrupt)。在本節中,我們將討論中斷以及核心如何響應它們,並使用稱為中斷處理程式(interrupt handler)的特殊函數。
無中斷和有中斷的程式控制流程。
中斷使硬體能夠向處理器傳送訊號,例如,當用鍵盤鍵入時,鍵盤控制器(管理鍵盤的硬體裝置)向處理器發出電訊號,以提醒作業系統新可用的按鍵,這些電訊號就是中斷。處理器接收中斷並向作業系統傳送訊號以使作業系統能夠響應新資料,硬體裝置相對於處理器時鐘非同步地生成中斷,它們可以在任何時間發生。因此,核心可以隨時中斷以處理中斷。
中斷是由來自硬體裝置的電子訊號物理產生的,並被引導到中斷控制器的輸入引腳,中斷控制器是一個簡單的多執行緒晶片,將多條中斷線組合成一條到處理器的單線。在接收到中斷時,中斷控制器向處理器傳送訊號,處理器檢測到該訊號並中斷其當前執行以處理該中斷。然後,處理器可以通知作業系統發生了中斷,作業系統可以適當地處理中斷。
不同的裝置可以通過與每個中斷相關聯的唯一值與不同的中斷相關聯,來自鍵盤的中斷與來自硬碟的中斷是不同的,使得作業系統能夠區分中斷並知道哪個硬體裝置導致了哪個中斷。反過來,作業系統可以使用相應的處理程式為每個中斷提供服務。
這些中斷值通常稱為中斷請求(interrupt request,IRQ)線(line)。每個IRQ行都分配了一個數值,例如,在經典PC上,IRQ 0是計時器中斷,IRQ 1是鍵盤中斷。然而,並非所有的中斷號都是如此嚴格地定義的,例如,與PCI匯流排上的裝置相關的中斷通常是動態分配的。其他非PC架構對中斷值具有類似的動態分配,重要的是,一個特定的中斷與一個特定裝置相關聯,核心知道這一點。然後硬體發出中斷以引起核心的注意:「嘿,我有新的按鍵在等待!讀取並處理這些壞孩子!」
帶中斷的指令週期。
每個硬體中斷都與一個優先順序相關聯,稱為中斷請求級別(Interrupt Request Level,IRQL)(注意,不要與稱為IRQ的中斷物理線混淆),由HAL確定。每個處理器的上下文都有自己的IRQL,就像任何暫存器一樣。IRQL可以由CPU硬體實現,也可以不由CPU硬體來實現,但本質上並不重要,IRQL應該像其他CPU暫存器一樣對待。
基本規則是處理器執行具有最高IRQL的程式碼。例如,如果某個CPU的IRQL在某個時刻為零,並且出現了一個IRQL為5的中斷,它將在當前執行緒的核心堆疊中儲存其狀態(上下文),將其IRQL提升到5,然後執行與該中斷相關聯的ISR。一旦ISR完成,IRQL將下降到其先前的級別,繼續執行先前執行的程式碼,就像中斷不存在一樣。當ISR執行時,IRQL為5或更低的其他中斷無法中斷此處理器。另一方面,如果新中斷的IRQL高於5,CPU將再次儲存其狀態,將IRQL提升到新級別,執行與第二個中斷相關聯的第二個ISR,完成後,將返回到IRQL 5,恢復其狀態並繼續執行原始ISR。本質上,提升IRQL會暫時阻止IRQL等於或低於IRQL的程式碼。中斷髮生時的基本事件序列如下圖所示,下下圖顯示了中斷巢狀的樣子。
基本中斷排程流程。
巢狀中斷流程。
上兩圖所示場景的一個重要事實是,所有ISR的執行都是由最初被中斷的同一執行緒完成的。Windows沒有處理中斷的特殊執行緒,它們由當時在中斷的處理器上執行的任何執行緒處理。我們很快就會發現,當處理器的IRQL為2或更高時,上下文切換是不可能的,因此在這些ISR執行時,不可能有其他執行緒潛入。
由於這些「中斷」,被中斷的執行緒不會減少其數量。可以說,這不是它的錯。
當執行使用者模式程式碼時,IRQL始終為0,這就是為什麼在任何使用者模式檔案中都沒有提到IRQL這個術語的原因之一——它總是為0,不能更改。大多數核心模式程式碼也使用IRQL 0執行,在核心模式下,可以在當前處理器上提升IRQL。
Windows使用API提升或降低IRQL的範例:
// assuming current IRQL <= DISPATCH_LEVEL
KIRQL oldIrql; // typedefed as UCHAR
// 提升IRQL
KeRaiseIrql(DISPATCH_LEVEL, &oldIrql);
NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);
// 在IRQL的DISPATCH_LEVEL執行工作。
// 降低IRQ
KeLowerIrql(oldIrql);
IRQL是處理器的一個屬性,優先順序是執行緒的屬性,執行緒優先順序僅在IRQL<2時才有意義。一旦執行執行緒將IRQL提升到2或更高,它的優先順序就不再有任何意義了,理論上它擁有無限量程——會一直執行,直到IRQL降低到2以下。
當然,在IRQL>=2上花費大量時間不是一件好事,使用者模式程式碼肯定沒有執行,這只是在這些級別上執行程式碼的能力受到嚴格限制的原因之一。
Windows工作管理員顯示使用稱為系統中斷的偽程序在IRQL2或更高版本中花費的CPU時間,Process Explorer將其稱為「中斷」。下圖上半部分顯示了Task Manager的螢幕截圖,下半部分顯示了Process Explorer中的相同資訊。
核心響應特定中斷而執行的函數稱為中斷處理程式或中斷服務例程(interrupt service routine,ISR),每個產生中斷的裝置都有一個相關的中斷處理程式。例如,一個函數處理來自系統計時器的中斷,而另一個函數則處理鍵盤產生的中斷。裝置的中斷處理程式是裝置驅動程式(管理裝置的核心程式碼)的一部分。
在Linux中,中斷處理程式是正常的C函數。它們匹配一個特定的原型,這使得核心能夠以標準方式傳遞處理程式資訊,但除此之外,它們都是普通函數。中斷處理程式與其他核心函數的區別在於,核心在響應中斷時呼叫它們,並且它們在一個稱為中斷上下文(interrupt context)的特殊上下文中執行,這個特殊的上下文有時被稱為原子上下文(atomic context),在此上下文中執行的程式碼不能被阻塞。
因為中斷可以在任何時候發生,所以中斷處理程式可以在任何時間執行,處理程式必須快速執行,以便儘快恢復被中斷程式碼的執行。因此,雖然作業系統無延遲地為中斷提供服務對硬體很重要,但對系統的其餘部分來說,中斷處理程式在儘可能短的時間內執行也是很重要的。
至少,中斷處理程式的工作是向硬體確認中斷的接收:「嘿,硬體,我聽到了;現在回去工作吧!」然而,中斷處理程式通常要執行大量的工作,例如,考慮網路裝置的中斷處理程式。除了響應硬體之外,中斷處理器還需要將網路封包從硬體複製到記憶體中,對其進行處理,並將封包向下推播到適當的協定棧或應用程式。顯然,可能需要大量工作,尤其是今天的千兆和10千兆乙太網卡。
通過中斷傳輸控制權。
在Linux的驅動程式中,請求中斷行並安裝處理程式是通過request_irq()
完成的:
if(request_irq(irqn, my_interrupt, IRQF_SHARED, "my_device", my_dev))
{
printk(KERN_ERR "my_device: cannot register IRQ %d\n", irqn);
return -EIO;
}
執行中斷處理程式時,核心處於中斷上下文中,程序上下文是核心代表程序執行時的操作模式,例如,執行系統呼叫或執行核心執行緒。在程序上下文中,當前宏指向關聯的任務。此外,由於程序在程序上下文中耦合到核心,程序上下文可以休眠或以其他方式呼叫排程器。
另一方面,中斷上下文與程序無關,當前宏不相關(儘管它指向被中斷的程序)。如果沒有後備程序,中斷上下文將無法休眠,它將如何重新排程?因此,不能從中斷上下文中呼叫某些函數,如果函數處於休眠狀態,則不能從中斷處理程式中使用它,這限制了從中斷處理常式中呼叫的函數。
中斷上下文是時間關鍵的,因為中斷處理程式會中斷其他程式碼。程式碼應該快速簡單。繁忙的迴圈是可能的,但不鼓勵。請記住,中斷處理程式中斷了其他程式碼(甚至可能是另一行上的另一箇中斷處理程式!)。由於這種非同步特性,所有中斷處理程式都必須儘可能快和簡單。儘可能地,工作應該從中斷處理程式中推出,並在下半部分中執行,在更方便的時間執行。
中斷處理程式堆疊的設定是一個設定選項,從歷史上看,中斷處理程式沒有收到自己的堆疊,相反,他們將共用中斷的程序堆疊。核心堆疊大小為兩頁,通常在32位元體系結構上為8KB,在64位元體系結構中為16KB。因為在這種設定中,中斷處理程式共用堆疊,所以它們在分配資料時必須格外節約。當然,核心堆疊一開始是有限的,因此所有核心程式碼都應該謹慎。
在核心程序的早期,可以將堆疊大小從兩頁減少到一頁,在32位元系統上只提供4KB的堆疊,此舉減少了記憶體壓力,因為系統上的每個程序以前都需要兩頁連續的、不可延伸的核心記憶體。為了處理減少的堆疊大小,中斷處理程式被賦予了自己的堆疊,每個處理器一個堆疊,一個頁面大小,此堆疊稱為中斷堆疊(interrupt stack)。儘管中斷堆疊的總大小是原始共用堆疊的一半,但可用的平均堆疊空間更大,因為中斷處理程式可以自己獲取整個記憶體頁。
中斷處理程式不應該關心正在使用的堆疊設定或核心堆疊的大小,應該始終使用絕對最小的堆疊空間。
Linux中中斷處理系統的實現依賴於體系結構,實現取決於處理器、使用的中斷控制器型別以及體系結構和機器的設計。下圖是中斷通過硬體和核心的路徑圖。
裝置通過其匯流排向中斷控制器傳送電訊號來發出中斷,如果中斷線被啟用,中斷控制器將中斷傳送到處理器。在大多數架構中,通過特殊引腳傳送到處理器的電訊號來實現。除非中斷在處理器中被禁用,否則處理器會立即停止它正在做的事情,禁用中斷系統,並跳轉到記憶體中的一個預定義位置並執行位於該位置的程式碼。這個預定義點由核心設定,是中斷處理程式的入口點。
中斷在核心中的過程從這個預定義的入口點開始,就像系統呼叫通過預定義的例外處理程式進入核心一樣。對於每個中斷行,處理器跳轉到記憶體中的一個唯一位置並執行位於該位置的程式碼。通過這種方式,核心知道傳入中斷的IRQ號,初始入口點簡單地儲存該值並將當前暫存器值(屬於中斷的任務)儲存在堆疊上,則核心呼叫do_IRQ()
。從這裡開始,大多數中斷處理程式碼都是用C編寫的,然而,它仍然依賴於體系結構。下面是處理IRQ的程式碼:
/**
* handle_IRQ_event - irq action chain handler
* @irq: the interrupt number
* @action: the interrupt action chain for this irq
*
* Handles the action chain of an irq event
*/
irqreturn_t handle_IRQ_event(unsigned int irq, struct irqaction* action)
{
irqreturn_t ret, retval = IRQ_NONE;
unsigned int status = 0;
if (!(action->flags & IRQF_DISABLED))
local_irq_enable_in_hardirq();
do {
trace_irq_handler_entry(irq, action);
ret = action->handler(irq, action->dev_id);
trace_irq_handler_exit(irq, action, ret);
switch (ret) {
case IRQ_WAKE_THREAD:
/*
* Set result to handled so the spurious check
* does not trigger.
*/
ret = IRQ_HANDLED;
/*
* Catch drivers which return WAKE_THREAD but
* did not set up a thread function
*/
if (unlikely(!action->thread_fn)) {
www.it - ebooks.info
warn_no_thread(irq, action);
break;
}
/*
* Wake up the handler thread for this
* action. In case the thread crashed and was
* killed we just pretend that we handled the
* interrupt. The hardirq handler above has
* disabled the device interrupt, so no irq
* storm is lurking.
*/
if (likely(!test_bit(IRQTF_DIED,
&action->thread_flags))) {
set_bit(IRQTF_RUNTHREAD, &action->thread_flags);
wake_up_process(action->thread);
}
/* Fall through to add to randomness */
case IRQ_HANDLED:
status |= action->flags;
break;
default:
break;
}
retval |= ret;
action = action->next;
} while (action);
if (status & IRQF_SAMPLE_RANDOM)
add_interrupt_randomness(irq);
local_irq_disable();
return retval;
}
下圖顯示了使用者端呼叫某些I/O操作時的典型事件序列,在圖中,使用者模式執行緒開啟檔案的控制程式碼,並使用ReadFile函數發出讀取操作。由於執行緒可以進行非同步呼叫,因此它幾乎立即重新獲得控制權,並可以執行其他工作。接收到此請求的驅動程式將呼叫檔案系統驅動程式(例如NTFS),該驅動程式可能會呼叫其下面的其他驅動程式,直到請求到達磁碟驅動程式,該磁碟驅動程式將在實際磁碟硬體上啟動操作。在這一點上,沒有程式碼需要執行,因為硬體「做它的事情」。
當硬體完成讀取操作時,它發出一箇中斷,導致與中斷相關聯的中斷服務例程在裝置IRQL上執行(請注意,處理請求的執行緒是任意的,因為中斷是非同步到達的)。典型的ISR存取裝置的硬體以獲得操作結果,它的最終行動應該是完成最初的請求。
簡單的中斷處理過程。
在Windows中,延遲過程呼叫(DPC)是封裝在IRQLDISPATCH_LEVEL呼叫的函數的物件。就DPC而言,呼叫執行緒並不重要。非同步過程呼叫(APC)也是封裝要呼叫的函數的資料結構。但與DPC相反,APC的目標是特定執行緒,因此只有該執行緒才能執行該函數,意味著每個執行緒都有一個與其關聯的APC佇列。APC有三種型別:
本章將闡述各種作業系統下的程序的概念、特點和技術內幕。
在這種模型中,計算機上所有可執行的軟體,有時包括作業系統,都被組織成若干順序程序,或者簡稱為程序。程序只是執行程式的一個範例,包括程式計數器、暫存器和變數的當前值。從概念上講,每個程序都有自己的虛擬CPU。當然,在現實中,真正的CPU在程序之間來回切換,但為了理解系統,考慮以(偽)並行方式執行的程序集合要比跟蹤CPU如何在程式之間切換容易得多。這種快速的來回切換稱為多道程式設計(multiprogramming)。
在下圖(a)中,我們看到一臺計算機在記憶體中多道程式設計四個程式。在下圖(b)中,我們看到四個程序,每個程序都有自己的控制流(即自己的邏輯程式計數器),每個程序獨立於其他程序執行。當然,只有一個物理程式計數器,因此當每個程序執行時,其邏輯程式計數器被載入到實際程式計數器中。當它完成時(暫時),物理程式計數器儲存在記憶體中程序的儲存邏輯程式計數器中。在下圖(c)中,我們可以看到,從足夠長的時間間隔來看,所有程序都取得了進展,但在任何給定的時刻,只有一個程序實際在執行。
(a) 多道程式設計四個程式。(b) 四個獨立、連續程序的概念模型。(c) 一次只有一個程式處於活動狀態。
程序或任務是正在執行的程式的範例,程序的執行必須按程式設計順序。任何時候最多執行一條指令,包括由程式計數器的值和處理器暫存器的內容表示的當前活動,還包括包含臨時資料(如方法引數返回地址和區域性變數)的程序堆疊和包含全域性變數的資料段。程序還可能包括堆,堆是在程序執行時動態分配的記憶體。
常規的程序實現和記憶體結構。
程序和程式之間的差異:程式本身不是一個程序,正在執行的程式稱為程序。程式是一個被動實體,例如儲存在磁碟上的檔案的內容,而程序是一個活動實體,有一個程式計數器指定要執行的下一條指令,一組相關的資源可以在多個程序之間共用,使用一些排程演演算法來確定何時停止一個程序併為另一個程序服務。
當程序執行時,它會改變狀態,其狀態由該程序的正確活動定義。每個程序可能處於以下狀態之一:
許多程序可能同時處於就緒和等待狀態,但在任何一個處理器上,任何時候都只能執行一個程序。下圖是程序不同狀態之間的切換:
RTOS+的程序狀態切換如下:
Linux程序狀態切換如下:
兩狀態的程序模型:
五狀態的程序模型:
程序佇列模型樣例圖:
帶一個或兩個暫停狀態的程序狀態轉換圖:
Unix程序狀態轉換表:
每個程序在作業系統中由過程控制塊(Process Control Block,PCB)表示,也由過程控制塊控制。過程控制塊也稱為任務控制塊,包含與特定過程相關聯的許多資訊,包括以下資訊:
CPU利用PCB來切換程序的執行。
當CPU切換到另一個程序時,系統必須儲存舊程序的狀態,並載入新程序的儲存狀態,這個行為稱為上下文切換。上下文切換時間開銷大,系統在切換時沒有做任何有用的工作。切換速度因機器而異,具體取決於記憶體速度、必須複製的暫存器數量以及特殊指令的存在,典型的速度是幾毫秒。上下文切換時間高度依賴於硬體支援。
程序是一個包含和管理物件,表示程式的執行範例。以往經常使用的「程序執行」是不準確的,程序實際上不執行——而是程序管理。執行緒才是執行程式碼並在技術上執行的載體。從高層次的角度來看,一個程序具有以下特點:
一個程序的重要組成部分。
程序的定址需求。
作業系統控制表的常規結構。
Windows程序和它的資源構成。
程序由其程序ID唯一標識,只要核心程序物件存在,程序ID就保持唯一。一旦它被銷燬,相同的ID就可以重新用於新的程序。可執行檔案本身不是程序的唯一識別符號,例如,Windows的記事本(notepad.exe)可能有5個範例的exe同時執行,每個程序都有自己的地址空間、執行緒、控制程式碼表、程序ID等。這5個程序都使用相同的映象檔案(notepad.exe)作為其初始程式碼和資料,但每個範例都有自己的屬性。
動態連結庫(DLL)是可執行檔案,可以包含程式碼、資料和資源(至少其中之一)。DLL在程序初始化時(稱為靜態連結)或在顯式請求時(動態連結)動態載入到程序中。DLL由於不包含可執行檔案等標準主函數,因此無法直接執行。DLL允許在使用同一DLL的多個程序之間共用實體記憶體中的程式碼,下圖顯示使用對映到相同物理(和虛擬)地址的共用DLL的兩個程序。
儘管自Windows NT第一次釋出以來,程序的基本結構和屬性沒有改變,但新的程序型別已經引入到具有特殊行為或結構的系統中。以下是當前支援的所有程序型別的快速概述:
幾乎所有程序都會交替使用(磁碟或網路)I/O請求進行計算,如下圖所示。通常,CPU會執行一段時間而不停止,然後進行系統呼叫以讀取檔案或寫入檔案。當系統呼叫完成時,CPU會再次計算,直到需要更多資料或必須寫入更多資料,依此類推。請注意,一些I/O活動算作計算。例如,當CPU將位複製到視訊RAM以更新螢幕時,它是在計算,而不是執行I/O,因為CPU正在使用中。在這種意義上,I/O是指程序進入阻塞狀態,等待外部裝置完成其工作。
CPU使用的突發與等待I/O的時間交替發生。(a) CPU受限的程序。(b) I/O受限的程序。
關於上圖,需要注意的重要一點是,一些程序,如(a)中的程序,花費了大部分時間進行計算,而其他程序,如(b)所示,花費了大量時間等待I/O。
前者稱為計算受限或CPU受限;後者稱為I/O受限。計算受限的程序通常有較長的CPU突發,因此很少有I/O等待,而I/O受限程序有較短的CPU突發時間,因此頻繁的I/O等待。注意,關鍵因素是CPU突發的長度,而不是I/O突發的長度。I/O受限程序是I/O受限的,因為它們不會在I/O請求之間進行大量計算,而不是因為它們有特別長的I/O請求。發出讀取磁碟塊的硬體請求需要同樣的時間,無論資料到達後處理資料需要多少時間。
值得注意的是,隨著CPU的速度越來越快,程序往往會獲得更多的I/O受限。出現這種效果是因為CPU的改進速度比磁碟快得多。因此,I/O受限程序的排程在未來可能會成為一個更重要的主題。這裡的基本思想是,如果一個I/O受限的程序想要執行,它應該能夠很快獲得機會,以便發出磁碟請求並保持磁碟繁忙。當程序受到I/O限制時,需要相當多的程序來保持CPU的完全佔用。
與程序排程相關的一個關鍵問題是何時做出程序排程策略。事實證明,在各種情況下都需要排程。首先,建立新程序時,需要決定是執行父程序還是子程序。由於這兩個程序都處於就緒狀態,這是一個正常的排程決策,可以選擇任何一種方式,也就是說,排程程式可以合法地選擇下一個執行父程序或子程序。
其次,當程序退出時,必須做出排程決策。該程序無法再執行(因為它不再存在),因此必須從就緒程序集中選擇其他程序。如果沒有程序就緒,則系統提供的空閒程序通常會執行。
第三,當一個程序在I/O、號誌或其他原因上阻塞時,必須選擇另一個程序來執行。有時,阻塞的原因可能會影響選擇。例如,如果A是一個重要的程序,它正在等待B退出其關鍵區域,那麼讓B接下來執行將允許它退出其關鍵區,從而讓A繼續。然而,問題是排程程式通常沒有必要的資訊來考慮這種依賴關係。
第四,當發生I/O中斷時,可以做出排程決策。如果中斷來自現已完成其工作的I/O裝置,則等待I/O的某些程序可能已準備好執行。由排程程式決定是執行新準備好的程序、中斷時正在執行的程序還是第三個程序。
如果硬體時鐘以50或60 Hz或其他頻率提供週期性中斷,則可以在每個時鐘中斷或每個第k個時鐘中斷時做出排程決策。排程演演算法可以根據如何處理時鐘中斷分為兩類。非臨時排程演演算法選擇要執行的程序,然後讓它執行,直到它阻塞(在I/O上或等待另一個程序)或自動釋放CPU。即使它執行了許多小時,也不會被強制暫停。實際上,在時鐘中斷期間不會做出排程決策。時鐘中斷處理完成後,中斷前執行的程序將恢復,除非高優先順序程序正在等待現已滿足的超時。
相比之下,搶佔式排程演演算法選擇一個程序,並讓它最多執行一段固定時間。如果它在時間間隔結束時仍在執行,那麼它將被掛起,並且排程程式會選擇另一個要執行的程序(如果有的話)。執行搶佔式排程需要在時間間隔結束時發生時鐘中斷,以便將CPU控制權交還給排程程式。如果沒有可用的時鐘,則非臨時排程是唯一的選擇。
在不同的環境中,需要不同的排程演演算法。排程程式應該優化的內容在所有系統中並不相同,值得區別的三種環境是:批次、互動、實時。
為了設計排程演演算法,有必要了解好的演演算法應該做什麼。有些目標取決於環境(批次處理、互動式或實時),但有些目標在所有情況下都是可取的。下面列出了一些目標:
在任何情況下,公平都很重要。可比程序應獲得可比服務,給一個程序比同等程序多很多CPU時間是不公平的。當然,不同類別的程序可能會有不同的處理方式。與公平相關的是執行系統的策略,如果本地策略是安全控制程序可以在任何時候執行,即使意味著工資單延遲30秒,排程程式也必須確保執行此策略。
另一個總體目標是儘可能使系統的所有部分保持繁忙。如果CPU和所有I/O裝置都可以一直執行,那麼與某些元件處於空閒狀態相比,每秒完成的工作量會更多。例如,在批次處理系統中,排程程式可以控制哪些作業進入記憶體以執行。
在記憶體中同時使用一些CPU受限程序和一些I/O受限程序比首先載入和執行所有CPU受限作業,然後在它們完成時載入和執行全部I/O受限作業要好。如果使用後一種策略,當CPU受限的程序正在執行時,它們將爭奪CPU,磁碟將處於空閒狀態。稍後,當I/O受限作業進入時,它們將爭奪磁碟,CPU將處於空閒狀態。最好通過仔細混合程序來保持整個系統同時執行。
系統在不同排程級別下的狀態轉換圖如下:
排程佇列圖如下:
程序排程是作業系統的基本功能。當一臺計算機進行多程式設計時,它有多個程序同時競爭CPU。如果只有一個CPU可用,那麼必須選擇下一個執行哪個程序。這一決策過程稱為排程(scheduling),做出此選擇的作業系統部分稱為排程程式(scheduler),用於進行此選擇的演演算法稱為排程演演算法(scheduling algorithm)。
排程佇列(Scheduling queue)是程序進入系統時被放入的作業佇列,此佇列由系統中的所有行程群組成,駐留在主記憶體中並已準備好等待執行或儲存在名為就緒佇列的列表中的程序。
此佇列通常儲存為連結列表,就緒佇列標題包含指向列表中第一個和最後一個PCB的指標,PCB包含一個指向就緒佇列中下一個PCB的指標欄位。等待特定I/O裝置的程序列表儲存在名為裝置佇列的列表中,每個裝置都有自己的裝置佇列。新程序最初被放入就緒佇列。它在就緒佇列中等待,直到它被選中執行並被賦予CPU。
0.png)
排程程式的描述如下:
排程程式的型別有以下幾種:
長期排程程式從磁碟中選擇程序並將其載入到記憶體中以便執行。它控制多重程式設計的程度,即記憶體中程序的數量,執行頻率低於其他排程程式。如果多道程式設計的程度是穩定的,那麼程序建立的平均速度等於程序離開系統的平均離開速度。因此,僅當程序離開系統時才需要呼叫長期排程程式。由於執行之間的間隔較長,它可以花費更多的時間來決定應該選擇哪個程序來執行。
CPU中的大多數程序要麼是I/O密集的,要麼是CPU密集的。I/O密集的程序(互動式「C」程式)是一個將大部分時間花在I/O操作上的程序,而不是花在執行I/O操作上,CPU密集的程序在計算上花費的時間比I/O操作(複雜的排序程式)要多。長期排程程式應選擇I/O繫結和CPU繫結程序的良好組合,這一點很重要。
短期排程程式在準備執行的程序中進行選擇,並將CPU分配給其中一個程序,這兩個排程程式之間的主要區別是它們的執行頻率。短期排程程式必須經常為CPU選擇新程序,它必須在100毫秒內至少執行一次。由於兩次執行之間的時間間隔很短,因此必須非常快。
一些作業系統引入了一種稱為中期排程程式的額外中間級別的排程,這個排程器背後的主要思想是,有時從記憶體中刪除程序是有利的,從而降低多道程式的程度。然後,該程序可以重新引入記憶體,並且可以從中斷的地方繼續執行,稱為交換。程序稍後由中期排程程式調出和調入。交換對於改善程序未命中是必要的,或者由於記憶體需求的某些變化,超出了可用記憶體限制,這需要釋放一些記憶體。
排程的目標有:
排程的總體目標:
稍加思考就會發現其中一些目標是相互矛盾的。可以看出,任何支援某類作業的排程演演算法都會損害另一類作業。畢竟,可用的CPU時間是有限的。
就如何處理時鐘中斷而言,排程演演算法可以分為兩類:
非搶佔式排程。如果一個程序一旦被賦予CPU,CPU就不能從該程序中取出,那麼排程程式是非搶佔性的。以下是非搶佔式排程的一些特徵:
搶佔式排程。如果一個程序一旦被給予,CPU就可以被拿走,那麼排程規程是優先的。允許邏輯上可執行的程序暫時掛起的策略稱為搶佔式排程,它與「執行到完成」方法相反。
CPU排程處理決定就緒佇列中的哪些程序將分配給CPU的問題。下面是我們將要研究的一些排程演演算法。
FCFS是最簡單的CPU排程演演算法。首先請求CPU的程序,即首先分配給CPU的程序。可以通過FIFO佇列輕鬆管理,當程序進入就緒佇列時,其PCB連結到佇列的後部。但是,FCFS的平均等待時間很長。考慮以下情況:
程序 | CPU時間 |
---|---|
P1 | 3 |
P2 | 4 |
P3 | 2 |
P4 | 4 |
如果順序為P1、P2、P3、P4,則使用FCFS演演算法計算平均等待時間和平均週轉時間。解決方案:如果程序以P1、P2、P3、P4的順序到達,則根據FCFS,甘特圖將為:
不同的時間描述如下:
程序等待時間:P1 = 0,P2 = 3,P3 = 8,P4 = 10。
程序週轉(Turnaround)時間:P1 = 0 + 3 = 3,P2 = 3 + 5 = 8,P3 = 8 + 2 = 10,P4 = 10 + 4 =14。
平均等待時間:(0 + 3 + 8 + 10) / 4 = 21 / 4 = 5.25。
平均週轉時間:(3 + 8 + 10 + 14)/4 = 35 / 4 = 8.75。
FCFS演演算法是非搶佔式的,即一旦CPU分配給程序,該程序就會通過終止或請求I/O來保持CPU,直到釋放CPU為止。
此演演算法的另一個名稱是下一個最短程序(SPN),如果CPU可用,此演演算法將與每個程序關聯。這種排程也稱為最短的下一次CPU迸發(burst),因為排程是通過檢查程序的下一個CPU迸發的長度而不是其總長度來完成的。考慮以下情況:
程序 | CPU時間 |
---|---|
P1 | 3 |
P2 | 5 |
P3 | 2 |
P4 | 4 |
解決方案:根據SJF,甘特圖將是:
不同的時間描述如下:
SJF演演算法可以是搶佔式或非搶佔式演演算法,搶佔式的SJF也稱為最短剩餘時間優先。考慮以下範例:
程序 | 到達時間 | CPU時間 |
---|---|---|
P1 | 0 | 8 |
P2 | 1 | 4 |
P3 | 2 | 9 |
P4 | 3 | 5 |
此情況的甘特圖如下:
程序等待時間:P1 = 10 - 1 = 9,P2 = 1 – 1 = 0,P3 = 17 – 2 = 15,P4 = 5 – 3 = 2。
平均等待時間:(9 + 0 + 15 + 2) / 4 = 26 / 4 = 6.5。
在這個排程中,每個程序都有一個優先順序編號(整數),CPU被分配給優先順序最高的程序(最小的整數,最高的優先順序),可分為搶佔式和非搶佔式。同等優先順序的流程以FCFS方式安排。SJF是一種優先順序排程,其中優先順序是預測的下一個CPU突發時間。
存在飢餓問題——低優先順序程序可能永遠不會執行,解決方案是老化——隨著時間的推移,程序的優先順序增加。
優先順序可以在內部或外部定義。內部優先事項的例子有:時間限制、記憶體要求、檔案要求(如開啟檔案的數量)、CPU與I/O要求。外部定義的優先順序由作業系統外部的標準設定,例如程序的重要性、為使用計算機而支付的資金型別或金額、贊助工作的部門、政策。
優先順序佇列。
考慮以下範例:
程序 | 到達時間 | CPU時間 |
---|---|---|
P1 | 10 | 3 |
P2 | 1 | 1 |
P3 | 2 | 3 |
P4 | 1 | 4 |
P5 | 5 | 2 |
根據優先順序排程,甘特圖將為:
程序等待時間:P1 = 6,P2 = 0,P3 = 16,P4 = 18,P5 = 1。
平均等待時間:(0 + 1 + 6 + 16 + 18) / 5 = 41 / 5 = 8.2。
這種演演算法僅用於分時系統設計,類似於具有搶佔條件的FCFS排程,可以在程序之間切換。一個稱為量程時間或時間片的小時間單位用於在程序之間切換,輪詢制下的平均等待時間很長。考慮以下範例:
程序 | CPU時間 |
---|---|
P1 | 3 |
P2 | 5 |
P3 | 2 |
P4 | 4 |
時間片 = 1ms,則甘特圖為:
程序等待時間:
平均等待時間:(6 + 9 + 5 + 9) / 4 = 7.2
SRT是SJF的搶佔式對等物,在分時環境中很有用。在SRT排程中,下一步執行估計執行時間最短的程序,包括新到達的程序。在SJF方案中,一旦作業開始執行,它就會一直執行到完成,一個正在執行的程序可以被一個估計執行時間最短的新到達程序搶佔。SRT的開銷高於對應的SJF,必須跟蹤執行程序的執行時間,並且必須處理偶爾的搶佔。在這個方案中,小程序的到達幾乎會立即執行,然而,更長的工作意味著更長的等待時間。
因為最短的作業首先總是為批次處理系統產生最小的平均響應時間,所以如果它也可以用於互動式流程,那就更好了。在一定程度上是可以的。互動程序通常遵循等待命令、執行命令、等待命令、執行命令等模式。如果我們將每個命令的執行視為單獨的「作業」,那麼我們可以通過先執行最短的一個來最小化總體響應時間,前提是找出當前可執行的程序中最短的程序。
排程的一種完全不同的方法是向用戶作出關於效能的真正承諾,然後兌現這些承諾。一個切實可行且易於實現的承諾是:如果在你工作時有n個使用者登入,你將獲得大約1/n的CPU電量。類似地,在一個執行n個程序的單使用者系統上,在所有條件都相同的情況下,每個程序應該獲得1/n的CPU週期,這似乎很公平。
為了兌現這一承諾,系統必須跟蹤每個程序自建立以來有多少CPU。然後,它計算每個程序有權使用的CPU數量,即自建立以來的時間除以n。由於每個程序實際擁有的CPU時間量也是已知的,因此計算實際消耗的CPU時間與有權使用CPU時間的比率非常簡單。比率為0.5意味著一個程序只擁有它應該擁有的一半,比率為2.0意味著程序擁有的是它應有的兩倍。然後,演演算法以最低比率執行該程序,直到其比率超過其最接近的競爭對手的比率。然後選擇下一個執行。
雖然向用戶作出承諾,然後兌現承諾是一個好主意,但很難實現。然而,可以使用另一種演演算法以更簡單的實現給出類似的可預測結果。這被稱為彩票排程(Lottery Scheduling)。
基本思想是為各種系統資源(如CPU時間)提供程序彩票。每當必須做出排程決策時,都會隨機選擇彩票,持有該彩票的程序將獲得資源。當應用於CPU排程時,系統可能每秒舉行50次抽獎,每個優勝者都會獲得20毫秒的CPU時間作為獎品。
到目前為止,我們假設每個程序都是自己排程的,而不管其所有者是誰。因此,如果使用者1啟動九個程序,而使用者2啟動一個程序,並且具有迴圈或同等優先順序,則使用者1將獲得90%的CPU,使用者2僅獲得10%的CPU。
為了防止這種情況,一些系統在排程程序之前會考慮哪個使用者擁有程序。在這個模型中,每個使用者都被分配了CPU的一部分,排程程式以強制執行的方式選擇程序。因此,如果向兩個使用者承諾每人50%的CPU,那麼無論他們有多少程序,他們都會得到50%的CPU。
舉個例子,考慮一個有兩個使用者的系統,每個使用者承諾佔用50%的CPU。使用者1有四個程序(A、B、C和D),使用者2只有一個程序(E)。如果使用迴圈排程,則滿足所有約束的可能排程式列如下:
A E B E C E D E A E B E C E E D E ...
另一方面,如果使用者1有權獲得使用者2兩倍的CPU時間,我們可能會得到:
A B E C D E A B E C D E ...
當然,還有許多其他的可能性,可以利用,取決於公平的概念是什麼。
多級佇列排程演演算法將就緒佇列劃分為幾個單獨的佇列,例如,在多級佇列排程中,程序被永久分配給一個佇列。根據程序的某些屬性,如記憶體大小、程序優先順序、程序型別,這些程序被永久分配給另一個程序。演演算法從具有最高優先順序的已佔用佇列中選擇程序,然後執行該程序。
多級反饋佇列排程演演算法允許程序在佇列之間移動,使用許多就緒佇列,並將不同的優先順序與每個佇列相關聯。演演算法從佔用的佇列中選擇優先順序最高的程序,並以搶佔或非搶佔方式執行該程序,如果程序使用了太多CPU時間,它將移動到低優先順序佇列。類似地,在較低優先順序佇列中等待時間過長的程序可能會被移動到較高優先順序佇列,也可能被移動到最高優先順序佇列。請注意,這種形式的老化可以防止飢餓。例子:
三個佇列:Q0–RR,時間量為8毫秒;Q1–RR時間量16毫秒;Q2–FCFS。
通常,多級反饋佇列排程程式定義的引數有:佇列數,每個佇列的排程演演算法,用於確定何時將程序升級到更高優先順序佇列的方法,用於確定何時將程序降級到較低優先順序佇列的方法,用於確定程序需要服務時將進入哪個佇列的方法。
程序排程在實際執行環境中,需要考量CPU、核心數量、IO等因素的影響,然後通過不同的排程演演算法來統計其資料,從而得出相對客觀且有參考價值的排程資料。評估示意圖如下:
各種排程策略的特點:
排程策略的時序對比圖:
排程策略的綜合對比:
除了上述出現的排程演演算法,實際上還有很多其它排程策略,如適用於實時作業系統的時限排程(Deadline Scheduling)、比率單調排程(Rate Monotonic Scheduling)等。
另外,還存在優先順序反轉(Priority Inversion),它是一種可能發生在任何基於優先順序的搶佔式排程方案中的現象,但在實時排程環境中尤其相關。優先順序反轉的最著名例子涉及火星探路者(Pathfinder)任務,這個漫遊機器人於1997年7月4日登陸火星,開始收集大量資料並將其傳送回地球。但在任務開始幾天之後,著陸器軟體開始經歷整個系統重置,每次都會導致資料丟失。在建造「探路者」號的噴氣推進實驗室團隊進行了大量努力之後,問題被追溯到優先順序反轉。
在任何優先順序排程方案中,系統應始終以最高優先順序執行任務。當系統內的情況迫使較高優先順序的任務等待較低優先順序的任務時,會發生優先順序反轉。如果較低優先順序的任務鎖定了資源(如裝置或二進位制號誌),而較高優先順序的任務試圖鎖定同一資源,則會發生優先順序反轉的簡單範例。在資源可用之前,優先順序較高的任務將處於阻塞狀態。如果較低優先順序的任務很快完成並釋放資源,則較高優先順序的任務可能會很快恢復,並且可能不會違反實時約束。
一種更嚴重的情況稱為無限優先順序反轉(Unbound Priority Inversion),其中優先順序反轉的持續時間不僅取決於處理共用資源所需的時間,還取決於其他不相關任務的不可預測的操作。Pathfinder軟體中經歷的優先順序反轉是無限的,是個很好的例子。
Windows程序的常見屬性在工作管理員中可以檢視,它們的詳情如下所述:
名字。通常是程序所基於的可執行檔名,但不是程序的唯一識別符號。有些程序似乎根本沒有可執行名稱,例如包括系統、安全系統、登入檔、記憶體壓縮、系統空閒程序和系統中斷。
PID。程序的唯一ID,是4的倍數,其中最低有效PID值為4(屬於系統程序)。一旦程序終止,程序ID將被重用,因此可以看到一個新程序。如果程序需要唯一識別符號,則PID和流程啟動時間的組合在特定系統上確實是唯一的。
狀態(Status)。狀態可以有三個值之一:執行(Running)、掛起(Suspended)和不響應(Not Responding),根據程序型別總結了它們的含義。
程序型別 | 執行時的情況 | 掛起的情況 | 不響應的情況 |
---|---|---|---|
GUI程序(非UWP) | GUI執行緒可響應時 | 程序中的所有執行緒都掛起 | GUI執行緒至少5秒未檢查訊息佇列 |
CUI程序(非UWP) | 至少有一個執行緒未掛起 | 程序中的所有執行緒都掛起 | 用不 |
UWP程序 | 在後臺 | 在後臺 | GUI執行緒至少5秒未檢查訊息佇列 |
常見的狀態轉換如下圖所示:
使用者名稱。使用者名稱指示程序正在哪個使用者下執行。令牌物件附加到程序(稱為主令牌),該程序基於使用者儲存程序的安全上下文。該安全上下文包含使用者所屬的組、許可權等資訊。程序可以在特定的內建使用者下執行,例如本地系統(在工作管理員中顯示為系統)、網路服務和本地服務。這些使用者帳戶通常用於執行服務。
對談ID。程序在其下執行對談的對談號,對談0用於系統程序和服務,對談1及以上用於互動式登入。
CPU。顯示該程序的CPU消耗百分比,注意它僅顯示整數。要獲得更好的精度,請使用Process Explorer。
記憶體。與記憶體相關的列有些棘手,工作管理員顯示的預設列是記憶體(活動專用工作集)或記憶體(專用工作集,早期版本)。術語工作集是指RAM(物理記憶體),私有工作集是程序使用的RAM,不與其他程序共用。共用記憶體最常見的例子是DLL程式碼。活動專用工作集與專用工作集相同,但對於當前掛起的UWP程序設定為零。以上兩個計數器是否能很好地指示程序使用的記憶體量?不幸的是,不是。這些指示使用的是專用RAM,但是當前被調出的記憶體呢?還有另一列——提交大小(Commit Size),用於瞭解程序記憶體使用情況的最佳列。工作管理員預設情況下不顯示此列。
基本優先順序。基本優先順序列(正式稱為優先順序類)顯示了六個值中的一個,為該程序中執行的執行緒提供基本排程優先順序。與優先順序相關聯的可能值如下:
控制程式碼。顯示在特定程序中開啟的核心物件的控制程式碼數量。
執行緒。「執行緒」列顯示每個程序中的執行緒數量。通常至少應該是一個,因為沒有執行緒的程序是無用的。但是,一些程序顯示為沒有執行緒。具體來說,安全系統顯示為沒有執行緒,因為安全核心實際上使用普通核心進行排程。系統中斷偽程序根本不是程序,因此不能有任何執行緒。最後,系統空閒程序也不擁有執行緒。此程序顯示的執行緒數是系統上的邏輯處理器數。
程序更詳細的屬性表如下:
在虛擬記憶體的使用者程序:
程序建立中涉及的主要部分如下圖所示。
1、Open Image File
核心開啟映象(可執行檔案)檔案,並驗證其是否為可移植可執行檔案(PE)的正確格式。副檔名並不重要,實際內容才重要。假設各種標頭檔案有效,核心將建立一個新的程序核心物件和一個執行緒核心物件,因為一個正常程序是由一個執行緒建立的,最終應該執行主入口點。
2、Create & Initialize Kernel Process Object
此時,核心將映像對映到新程序的地址空間以及NtDll.Dll。NtDll對映到每個程序(最小和微程序除外),因為它在程序建立的最後階段具有非常重要的職責,並且是呼叫系統呼叫的最終階段。建立程序仍在執行的最後一個主要步驟是通知Windows子系統程序(Csrss.exe)已建立新程序和執行緒。(Csrss可以被認為是核心管理Windows子系統程序某些方面的助手)。
3、Create & Initialize Kernel Thread Object
此時,從核心的角度來看,程序已經成功建立,因此呼叫方呼叫的程序建立函數(通常是CreateProcess)返回成功。然而,新程序尚未準備好執行其初始程式碼。程序初始化的第二部分必須在新程序的上下文中由新建立的執行緒執行。
一些開發人員認為,在新流程中執行的第一件事是可執行檔案的主要功能。然而,在實際的主函數開始執行之前,還有很多事情要做,最明顯的是NtDll,因為目前程序中沒有其他作業系統級程式碼。此時,NtDll有幾個職責:
實際上,開發人員可以編寫四個主要函數,每個函數都有相應的C/C++執行時函數。下表總結了這些名稱及其使用時間。
開發人員的main | C/C++執行時起點 | 場景 |
---|---|---|
main | mainCRTStartup | 使用ASCII字元的控制檯應用程式 |
wmain | wmainCRTStartup | 使用Unicode字元的控制檯應用程式 |
WinMain | WinMainCRTStartup | 使用ASCII字元的GUI應用程式 |
wWinMain | wWinMainCRTStartup | 使用Unicode字元的GUI應用程式 |
大多數程序將在系統關閉之前的某個時間點終止,有幾種方法可以退出或終止程序。需要記住的一點是,無論程序如何終止,核心都會確保程序沒有私有的內容:釋放所有私有(非共用)記憶體,並關閉程序控制程式碼表中的所有控制程式碼。如果滿足以下任一條件,則過程終止:
1、程序中的所有執行緒退出或終止。
2、程序中的任何執行緒呼叫了ExitProcess。
3、使用TerminateProcess終止程序(通常在外部,但可能是由於未處理的異常)。
編寫Windows應用程式的開發者通常會在某個時刻發現執行主函數的執行緒是「特殊的」,通常稱為主執行緒。可以觀察到,無論何時主函數返回,程序都會退出——似乎是上述流程退出原因中未列出的場景。然而,它確實如此,實際是上述的情形2。C/C++執行時庫呼叫main/WinMain,然後執行所需的清理,如呼叫全域性C++解構函式、C執行時清理等,最後呼叫ExitProcess,導致程序退出。
從核心的角度來看,程序中的所有執行緒都是相等的,並且沒有主執行緒。當核心中的所有執行緒退出/終止時,核心會銷燬程序,因為沒有執行緒的程序幾乎是無用的。實際上,這種情況只能在原生程序(僅依賴於NtDll.dll且沒有C/C++執行時的可執行檔案)中實現。換句話說,在正常的Windows程式設計中不太可能發生。
當使用多道程式設計時,CPU利用率可以提高。粗略地說,如果平均程序只計算了它在記憶體中的20%的時間,那麼當五個程序同時在記憶體中時,CPU應該一直處於繁忙狀態。然而,這個模型是不切實際的樂觀,因為它預設所有五個程序永遠不會同時等待I/O。
一個更好的模型是從概率的角度來看CPU的使用情況。假設一個程序花了一小部分時間等待I/O完成,當記憶體中同時有\(n\)個程序時,所有\(n\)個程序等待I/O的概率為\(p^n\)(在這種情況下,CPU將處於空閒狀態)。CPU利用率由以下公式給出:
下圖顯示了CPU利用率作為\(n\)的函數——稱為多道程式設計的程度。
CPU利用率是記憶體中程序數的函數。
從圖中可以明顯看出,如果程序花費80%的時間等待I/O,那麼必須同時在記憶體中至少有10個程序才能使CPU浪費低於10%。當你意識到等待使用者在終端鍵入內容(或單擊圖示)的互動式程序處於I/O等待狀態時,應該很清楚,80%以上的I/O等待時間並不罕見。但即使在伺服器上,執行大量磁碟I/O的程序通常也會有這個百分比或更多。
為了準確起見,應該指出,剛才描述的概率模型只是一個近似值。它隱式假設所有n個程序都是獨立的,意味著記憶體中有5個程序的系統可以有三個執行,兩個等待。但是,對於單個CPU,我們不能同時執行三個程序,因此在CPU繁忙時準備就緒的程序將不得不等待,因此,這些程序不是獨立的。使用排隊論可以構建更精確的模型,但我們所做的多道程式設計讓程序在CPU空閒時使用它,當然,即使上圖中的真實曲線與圖中所示的曲線略有不同,它仍然有效。
儘管上圖中的模型思想簡單,但它仍然可以用於對CPU效能進行具體的、儘管是近似的預測。例如,假設一臺計算機有8GB記憶體,作業系統及其表佔2GB,每個使用者程式也佔2GB。這些大小允許三個使用者程式同時在記憶體中,平均I/O等待時間為80%時,CPU利用率(忽略作業系統開銷)為\(1− 0.8^3\)或約49%。再增加8GB記憶體,系統就可以從三路多道程式設計過渡到七路多道程式設計,從而將CPU利用率提高到79%。換句話說,額外的8GB將提高30%的吞吐量。
再增加8GB只會將CPU利用率從79%提高到91%,因此吞吐量只會再提高12%。使用這個模型,計算機的所有者可能會認為第一次增加記憶體是一項不錯的投資,但第二次不是。
建立活動程序的第一步是將程式載入到主記憶體中並建立程序映像(下圖),下下圖描述了大多數系統的典型場景。應用程式由多個編譯或組裝的目的碼形式的模組組成,這些模組被連結以解析模組之間的任何參照,同時,解析對庫例程的參照。庫例程本身可以合併到程式中或作為共用程式碼參照,這些程式碼必須在執行時由作業系統提供。
載入函數。
一個載入和連結的場景。
在上圖中,載入器從位置開始將載入模組放置在主記憶體儲器中。在載入程式時,必須滿足定址要求。一般來說,可以採取三種方法:
1、絕對載入。
絕對載入絕對載入程式要求將給定的載入模組始終載入到主記憶體中的同一位置。因此,在呈現給載入器的載入模組中,所有地址參照都必須指向特定的或絕對的主記憶體地址。例如,如果上圖中的x是位置1024,那麼載入模組中指定給該記憶體區域的第一個字具有地址1024。
將特定地址值分配給程式內的記憶體參照可以由程式設計師完成,也可以在編譯或組合時完成。前一種方法有幾個缺點,首先,每個程式設計師都必須知道將模組放入主記憶體的預期分配策略,其次,如果對程式進行了任何修改,涉及到模組主體中的插入或刪除,那麼所有地址都必須更改。因此,最好允許程式中的記憶體參照以符號方式表示,然後在編譯或組合時解析這些符號參照,如下圖所示,對指令或資料項的每個參照最初都用符號表示,在準備模組輸入到絕對載入器時,組合程式或編譯器將把所有這些參照轉換為特定地址(在本例中,對於從1024位元置開始載入的模組),如下下圖b所示。
2、可重定位載入。
可重定位載入在載入之前將記憶體參照系結到特定地址的缺點是,生成的載入模組只能放在主記憶體的一個區域中。然而,當許多程式共用主記憶體時,可能不希望提前決定將特定模組載入到記憶體的哪個區域。最好在載入時做出該決定,因此,我們需要一個可以位於主記憶體中任何位置的載入模組。
為了滿足這個新的要求,組合程式或編譯器生成的不是實際的主記憶體儲器地址(絕對地址),而是與某個已知點(例如程式的開始)相關的地址。這種技術如下圖c所示。載入模組的開頭被分配了相對地址0,並且模組內的所有其他記憶體參照都是相對於模組的開始來表示的。
由於所有記憶體參照都以相對格式表示,所以載入器將模組放置在所需的位置就成為一項簡單的任務。如果要從位置開始載入模組,則載入程式必須在將模組載入到記憶體中時簡單地新增到每個記憶體參照中。為了協助該任務,載入模組必須包含告訴載入器地址參照在哪裡以及如何解釋地址參照的資訊(通常相對於程式源,但也可能相對於程式中的其他點,例如當前位置)。這組資訊由編譯器或組合程式準備,通常稱為重定位字典(relocation dictionary)。
3、動態執行時載入。
重定位載入比較常見,相對於絕對載入,它具有明顯的優勢。然而,在多道程式設計環境中,即使是不依賴虛擬記憶體的環境,可重定位的載入方案依然不夠。需要在主記憶體中交換程序映像,以最大限度地提高處理器的利用率。為了最大限度地提高主記憶體利用率,我們希望能夠在不同的時間將程序映像交換回不同的位置。因此,一個程式一旦載入,就可以交換到磁碟上,然後在不同的位置重新交換。如果在初始載入時將記憶體參照系結到絕對地址,是不可能的。
另一種方法是推遲絕對地址的計算,直到在執行時實際需要它。為此,載入模組被載入到主記憶體中,所有記憶體參照都以相對形式(上圖c)。直到實際執行了一條指令,才計算出絕對地址。為了確保此功能不會降低效能,必須通過特殊的程式或硬體而不是軟體來完成。
動態地址計算提供了完全的靈活性。程式可以載入到主記憶體儲器的任何區域,隨後,程式的執行可以被中斷,程式可以從主記憶體儲器中調出,稍後再調回另一個位置。
連結器的功能是將一組目標模組作為輸入,並生成一個載入模組,該載入模組由一組整合的程式和資料模組組成,並傳遞給載入器。在每個物件模組中,可能存在對其他模組中位置的地址參照。每個這樣的參照只能在未連結的物件模組中用符號表示。連結器建立一個載入模組,它是所有物件模組的連續連線。每個模組內參照必須從符號地址更改為對整個載入模組內某個位置的參照。例如,圖7中的模組A包含模組B的過程呼叫。當這些模組在載入模組中組合時,對模組B的符號參照將更改為對載入模組中B入口點位置的特定參照。
連結編輯器此地址連結的性質將取決於要建立的載入模組的型別以及連結髮生的時間(上表b)。通常情況下,如果需要可重新定位的負載模組,則通常按以下方式進行連線。每個編譯或組裝的物件模組都是用相對於物件模組開頭的參照建立的,所有這些模組都被放在一個單獨的可重定位載入模組中,其中包含相對於載入模組原點的所有參照,該模組可以用作可重定位載入或動態執行時載入的輸入。
產生可重定位載入模組的連結器通常被稱為連結編輯器。下圖顯示了連結編輯器功能。
與載入一樣,可以延遲某些連結功能。術語動態連結用於指將某些外部模組的連結延遲到載入模組建立之後的做法,因此載入模組包含對其他程式的未解析參照,這些參照可以在載入時或執行時解析。
對於載入時動態連結,需要執行以下步驟。將要載入的載入模組(應用程式模組)讀入記憶體,對外部模組(目標模組)的任何參照都會導致載入器找到目標模組,載入它,並從應用程式模組的開頭將參照更改為記憶體中的相對地址。與所謂的靜態連結相比,動態連結有幾個優點:
1、合併目標模組的更改或升級版本變得更容易,目標模組可以是作業系統實用程式或其他通用例程。對於靜態連結,對這種支援模組的更改將需要重新連結整個應用程式模組,這不僅效率低下,而且在某些情況下可能是不可能的。例如,在個人計算機領域,大多數商業軟體都是以載入模組的形式釋出的:原始碼和物件版本不釋出。
2、在動態連結檔案中包含目的碼為自動程式碼共用鋪平了道路。作業系統可以識別多個應用程式正在使用相同的目的碼,因為它載入並連結了該程式碼。它可以使用該資訊載入目的碼的單個副本並將其連結到兩個應用程式,而不必為每個應用程式載入一個副本。
3、獨立軟體開發人員更容易擴充套件廣泛使用的作業系統(如Linux)的功能。開發人員可以提出一個對各種應用程式有用的新函數,並將其打包為動態連結模組。
對於執行時動態連結,一些連結被推遲到執行時。目標模組的外部參照保留在載入的程式中。當呼叫不存在的模組時,作業系統定位該模組,載入該模組,並將其連結到呼叫模組。這些模組通常是可共用的,在Windows環境中,這些稱為動態連結庫(DLL)。因此,如果一個程序已經在使用動態連結的共用模組,則該模組位於主記憶體中,新程序可以簡單地連結到已載入的模組。
如果兩個或多個程序共用一個DLL模組,但期望該模組的不同版本,則使用DLL可能會導致通常稱為DLL地獄(DLL hell)的問題。例如,可能會重新安裝應用程式或系統功能,並將較舊版本的DLL檔案帶入其中。
我們已經看到,動態載入允許整個載入模組到處移動,但是,模組的結構是靜態的,在整個程序的執行過程中以及從一個執行到下一個執行都保持不變。但是,在某些情況下,無法在執行之前確定需要哪些物件模組,例如事務處理應用程式(如航空公司預訂系統或銀行應用程式),事務的性質決定了需要哪些程式模組,它們被適當地載入並與主程式連結。使用這種動態連結器的優點是,除非參照了程式單元,否則不必為程式單元分配記憶體。此功能用於支援分段系統。
一個額外的細化是可行的:應用程式不需要知道可能被呼叫的所有模組或入口點的名稱。例如,可以編寫繪圖程式以與各種繪圖儀配合使用,每個繪圖儀都由不同的驅動程式包驅動。應用程式可以從另一個程序或在組態檔中查詢當前安裝在系統上的繪圖儀的名稱,這允許應用程式的使用者安裝在編寫應用程式時不存在的新繪圖儀。
執行緒是通過程序程式碼的執行流,有自己的程式計數器、系統暫存器和堆疊,是通過並行提高應用程式效能的一種流行方法。執行緒有時稱為輕量級程序,代表了一種通過減少超執行緒來提高作業系統效能的軟體方法,相當於一個經典的程序。每個執行緒只屬於一個程序,程序外不能存在任何執行緒,每個執行緒代表一個單獨的控制流。
一些作業系統提供了使用者級執行緒和核心級執行緒的組合功能,Solaris就是這種組合方法的一個很好的例子。在組合系統中,同一應用程式中的多個執行緒可以在多個處理器上並行執行,阻塞系統呼叫不需要阻塞整個程序。
在下圖(a)中,我們看到了三個傳統程序,每個程序都有自己的地址空間和單個控制執行緒。相反,在下圖(b)中,我們看到一個具有三個控制執行緒的單個程序。儘管在這兩種情況下,我們都有三個執行緒,但在下圖(a)中,每個執行緒都在不同的地址空間中執行,而在下圖(b)中,三個執行緒共用相同的地址空間。當多執行緒程序在單個CPU系統上執行時,執行緒輪流執行。通過在多個程序之間來回切換,系統提供了並行執行的獨立順序程序的錯覺。多執行緒的工作方式相同,CPU線上程之間快速來回切換,提供了執行緒並行執行的假象,儘管在比實際CPU慢的CPU上執行。在一個程序中有三個計算繫結執行緒,這些執行緒看起來是並行執行的,每個執行緒在一個CPU上的速度是實際CPU的三分之一。
(a) 三個程序,每個程序有一個執行緒。(b) 一個程序有三個執行緒。
程序中的不同執行緒不像不同程序那樣獨立。所有執行緒都有完全相同的地址空間,意味著它們也共用相同的全域性變數。由於每個執行緒都可以存取程序地址空間內的每個記憶體地址,因此一個執行緒可以讀取、寫入甚至清除另一個執行緒的堆疊。執行緒之間沒有保護,原因有二:其一,這是不可能的;其二,不應該有保護。不同的程序可能來自不同的使用者,並且可能相互競爭,不同的是,一個程序總是由一個使用者擁有,該使用者可能建立了多個執行緒,以便他們能夠合作,而不是競爭。除了共用地址空間外,所有執行緒還可以共用同一組開啟的檔案、子程序、警報和訊號等,如下表所示。因此,當三個程序基本無關時,將使用上圖(a)的組織,然而,當三個執行緒實際上是同一工作的一部分並且相互積極密切合作時,上圖(b)是合適的。
逐程序資料項 | 逐執行緒資料項 |
---|---|
地址空間 全域性變數 開啟檔案 子程序 待定報警 訊號和訊號處理 賬號資訊 |
程式計數器 暫存器 堆疊 狀態 |
第一列中的專案是程序屬性,而不是執行緒屬性。例如,如果一個執行緒開啟了一個檔案,則該檔案對程序中的其他執行緒可見,並且它們可以讀取和寫入該檔案。這是合乎邏輯的,因為程序是資源管理的單元,而不是執行緒。如果每個執行緒都有自己的地址空間、開啟的檔案、掛起的警報等,那麼它將是一個單獨的程序。我們試圖通過執行緒概念實現的是多個執行執行緒共用一組資源的能力,以便它們能夠緊密共同作業來執行某些任務。
Windows程序和執行緒對比圖。
與傳統程序(即只有一個執行緒的程序)一樣,執行緒可以處於以下幾種狀態之一:執行、阻塞、就緒或終止。正在執行的執行緒當前具有CPU並且處於活動狀態。相反,阻塞的執行緒正在等待某個事件解除阻塞。例如,當執行緒執行從鍵盤讀取的系統呼叫時,它會被阻塞,直到輸入被鍵入為止。執行緒可以阻止等待某個外部事件發生或其他執行緒解除阻止。就緒執行緒計劃執行,並在輪到它時立即執行,執行緒狀態之間的轉換與程序狀態之間的過渡相同。
重要的是要認識到每個執行緒都有自己的堆疊,如下圖所示。每個執行緒的堆疊包含一個幀,用於每個呼叫但尚未返回的過程。此幀(frame)包含過程的區域性變數和過程呼叫完成時要使用的返回地址。例如,如果過程X呼叫過程Y,而Y呼叫過程Z,那麼在執行Z時,X、Y和Z的幀都將位於堆疊上。每個執行緒通常會呼叫不同的過程,因此具有不同的執行歷史。這就是為什麼每個執行緒都需要自己的堆疊。
每個執行緒都有自己的堆疊。
當存在多執行緒時,程序通常以單個執行緒開始。此執行緒能夠通過呼叫庫過程(如執行緒建立)來建立新執行緒。執行緒建立的引數指定要執行新執行緒的過程的名稱。沒有必要(甚至不可能)指定任何關於新執行緒地址空間的內容,因為它會自動在建立執行緒的地址空間中執行。有時執行緒是分層的,具有父子關係,但通常不存在這種關係,所有執行緒都是相等的。無論是否具有層次關係,建立執行緒通常都會返回一個執行緒識別符號,用於命名新執行緒。
當一個執行緒完成它的工作時,它可以通過呼叫庫過程來退出,比如執行緒退出。然後它會消失,不再可排程。在某些執行緒系統中,一個執行緒可以通過呼叫過程等待(特定)執行緒退出,例如執行緒聯接。此過程將阻塞呼叫執行緒,直到(特定)執行緒退出。在這方面,執行緒建立和終止與程序建立和終止非常相似,也有大致相同的選項。
另一個常見的執行緒呼叫是執行緒放棄(thread yield),它允許一個執行緒自願放棄CPU時間片,讓另一個執行緒執行。這種機制很重要,因為沒有時鐘中斷來實際執行多道程式,就像程序一樣。因此,執行緒要有禮貌,並不時主動放棄CPU,以給其他執行緒一個執行的機會。其他呼叫允許一個執行緒等待另一個執行緒完成一些工作,等待一個執行緒宣佈它已經完成了一些工作,依此類推。
雖然執行緒通常很有用,但它們也給程式設計模型帶來了許多複雜性。首先,考慮UNIX fork系統呼叫的效果。如果父程序有多個執行緒,那麼子程序是否也應該有它們?如果沒有,程序可能無法正常執行,因為所有這些可能都是必需的。
但是,如果子程序獲得的執行緒數與父程序相同,那麼如果父程序中的執行緒在讀呼叫(例如從鍵盤進行的讀呼叫)中被阻塞,會發生什麼情況?現在鍵盤上有兩個執行緒被阻塞了嗎?一個在父執行緒,一個在子執行緒?當一行被鍵入時,兩個執行緒都會得到它的副本嗎?只有父母?只有孩子?開啟的網路連線也存在同樣的問題。
另一類問題與執行緒共用許多資料結構有關。如果一個執行緒關閉一個檔案,而另一個執行緒仍在讀取該檔案,會發生什麼情況?假設一個執行緒注意到記憶體太少,並開始分配更多記憶體。中途,執行緒切換髮生,新執行緒還注意到記憶體太少,並開始分配更多記憶體,結果記憶體可能會分配兩次。這些問題可以通過一些努力來解決,但要使多執行緒程式正確工作,需要仔細考慮和設計。
多執行緒模型有三種型別:
多對多關係。在這個模型中,許多使用者級執行緒多路複用到數量較小或相等的核心執行緒,核心執行緒的數量可能特定於特定的應用程式或特定的計算機。在這個模型中,開發人員可以根據需要建立任意多個使用者執行緒,相應的核心執行緒可以在多處理器上並行執行。
多對一關係。多對一模型將多個使用者級執行緒對映到一個核心級執行緒,執行緒管理是在使用者空間中完成的。當執行緒進行阻塞系統呼叫時,整個程序將被阻塞。一次只有一個執行緒可以存取核心,因此多個執行緒無法在多處理器上並行執行。如果使用者級執行緒庫是在作業系統中實現的,則該系統不支援核心執行緒使用多對一關係模式。
一對一關係。使用者級執行緒與核心級執行緒之間存在一對一的關係,此模型比多對一模型提供更多並行性。當一個執行緒進行阻塞的系統呼叫時,它允許另一個執行緒執行,支援在微處理器上並行執行多個執行緒。
此模型的缺點是建立使用者執行緒需要相應的核心執行緒。OS/2、Windows NT和Windows 2000使用一對一關係模型。
多執行緒程式設計的好處可以分為四大類:
總之,執行緒的優點/優點是最小化上下文切換時間,提供了程序內的並行性,高效溝通,建立和上下文切換執行緒更經濟。利用多處理器體系結構–多執行緒的好處可以在多處理器體系架構中大大增加。
執行緒可分為使用者級執行緒和核心級執行緒。
在使用者執行緒中,執行緒管理的所有工作都由應用程式完成,核心不知道執行緒的存在。執行緒庫包含用於建立和銷燬執行緒、線上程之間傳遞訊息和資料、排程執行緒執行以及儲存和恢復執行緒上下文的程式碼。應用程式從單個執行緒開始,並開始在該執行緒中執行,使用者級執行緒的建立和管理通常很快。
使用者級執行緒相對於核心級執行緒的優勢:執行緒切換不需要核心模式特權,使用者級執行緒可以在任何作業系統上執行,計劃可以是特定於應用程式的,使用者級執行緒的建立和管理速度很快。
使用者級執行緒的缺點:在典型的作業系統中,大多數系統呼叫都是阻塞的,多執行緒應用程式無法利用多處理。
在核心級執行緒中,由核心完成的執行緒管理。應用程式區域中沒有執行緒管理程式碼,作業系統直接支援核心執行緒。任何應用程式都可以程式設計為多執行緒,單個程序支援應用程式中的所有執行緒。
核心維護整個程序以及程序中各個執行緒的上下文資訊,核心的排程是線上程的基礎上完成的,核心在核心空間中執行執行緒建立、排程和管理,核心執行緒的建立和管理速度通常比使用者執行緒慢。
核心級執行緒的優點:核心可以在多個程序上同時排程來自同一程序的多個執行緒,如果程序中的一個執行緒被阻塞,核心可以排程同一程序的另一個執行緒。核心例程本身可以多執行緒。
核心級執行緒的缺點:核心執行緒的建立和管理速度通常比使用者執行緒慢,在同一程序中將控制權從一個執行緒轉移到另一個執行緒需要將模式切換到核心。
使用者和核心執行緒對比圖。
執行緒涉及了複雜的狀態轉換,以下是作業系統常見的轉換圖:
使用者級執行緒狀態和程序狀態之間的關係範例。
Windows執行緒狀態轉換圖。
Linux程序、執行緒模型。
Solaris使用者執行緒和LWP狀態。
使用者級執行緒和核心級執行緒之間的差異如下表:
使用者執行緒 | 核心執行緒 | |
---|---|---|
建立和管理速度 | 更快 | 較慢 |
實現方式 | 由使用者級的執行緒庫實現 | 作業系統直接支援 |
作業系統依賴性 | 可在任何作業系統上執行 | 特定於作業系統 |
命名方式 | 在使用者級別提供的支援稱為使用者級別執行緒 | 核心可能提供的支援稱為核心級執行緒 |
多核利用 | 無法利用多處理的優勢 | 核心例程本身可以是多執行緒的 |
程序和執行緒的區別如下表:
程序 | 執行緒 | |
---|---|---|
量級 | 重量級程序 | 輕量級程序(僅對類Linux) |
切換 | 程序切換需要與作業系統互動 | 執行緒切換不需要呼叫作業系統並導致核心中斷 |
共用 | 在多程序中,每個程序執行相同的程式碼,但有自己的記憶體和檔案資源 | 所有執行緒共用同一組開啟的檔案、子程序 |
阻塞 | 如果一個服務程序被阻塞,則在它被阻塞之前,無法執行其他服務程序 | 當一個服務執行緒被阻塞並等待時,同一任務中的第二個執行緒可以執行 |
冗餘 | 多冗餘程序比多執行緒程序使用更多資源 | 多執行緒程序比多冗餘程序使用更少的資源 |
獨立性 | 在多程序中,每個程序都獨立於其他程序執行 | 一個執行緒可以讀取、寫入甚至完全清除另一個執行緒堆疊 |
實現執行緒有兩個主要位置:使用者空間和核心空間,以及它們的混合實現。下面將描述這些方法及其優缺點。
這種方法將執行緒包完全放在使用者空間中,核心對它們一無所知。就核心而言,它管理的是普通的單執行緒程序。第一個也是最明顯的優點是,使用者級執行緒包可以在不支援執行緒的作業系統上實現。過去所有的作業系統都屬於這一類,甚至現在仍有一些。使用這種方法,執行緒由庫實現。所有這些實現都具有相同的總體結構,如下圖所示。執行緒執行在執行時系統之上,執行時系統是管理執行緒的程序的集合。
左:使用者級執行緒包。右:由核心管理的執行緒包。
當在使用者空間中管理執行緒時,每個程序都需要自己的私有執行緒表來跟蹤該程序中的執行緒。這個表類似於核心的程序表,只是它只跟蹤每個執行緒的屬性,例如每個執行緒的程式計數器、堆疊指標、暫存器、狀態等等。執行緒表由執行時系統管理,當執行緒移動到就緒狀態或阻塞狀態時,重新啟動執行緒所需的資訊儲存線上程表中,與核心在程序表中儲存程序資訊的方式完全相同。
當一個執行緒做了一些可能導致其在本地被阻塞的事情時,例如,等待其程序中的另一個執行緒完成某些工作,它將呼叫一個執行時系統過程。此過程檢查執行緒是否必須置於阻塞狀態。如果是這樣,它將執行緒的暫存器(即它自己的)儲存線上程表中,在表中查詢準備執行的執行緒,並用新執行緒儲存的值重新載入機器暫存器。一旦堆疊指標和程式計數器被切換,新執行緒就會自動重新啟動。如果機器恰巧有一條指令儲存所有暫存器,另一條指令載入所有暫存器,那麼整個執行緒切換隻需幾個指令即可完成。這樣做執行緒切換至少比捕獲到核心快一個數量級,這是支援使用者級執行緒包的有力論據。
然而,與程序有一個關鍵區別。當執行緒暫時完成執行時,例如,當它呼叫執行緒放棄時,執行緒放棄程式碼可以將執行緒的資訊儲存線上程表本身中。此外,它還可以呼叫執行緒排程程式來選擇要執行的另一個執行緒。儲存執行緒狀態和排程程式的過程只是區域性過程,因此呼叫它們比進行核心呼叫要高效得多。在其他問題中,不需要陷阱、不需要上下文切換、不需要重新整理記憶體快取等等。這些特點使得執行緒排程非常快速。
使用者級執行緒還有其他優點。它們允許每個程序都有自己的客製化排程演演算法。對於某些應用程式,例如,那些具有垃圾收集器執行緒的應用程式,不必擔心執行緒在不方便的時候被停止。它們的伸縮性也更好,因為核心執行緒總是需要核心中的一些表空間和堆疊空間,如果有大量執行緒,可能是一個問題。
儘管它們的效能更好,但使用者級執行緒包仍存在一些主要問題。首先是如何實現阻塞系統呼叫的問題,假設一個執行緒在按下任何鍵之前讀取鍵盤,讓執行緒實際執行系統呼叫是不可接受的,因為會停止所有執行緒。首先擁有執行緒的主要目標之一是允許每個執行緒使用阻塞呼叫,但要防止一個阻塞的執行緒影響其他執行緒。由於有阻塞系統呼叫,難以輕鬆實現這個目標。
系統呼叫可以全部更改為非阻塞(例如,如果沒有緩衝字元,鍵盤上的讀取只會返回0位元組),但要求更改作業系統是不可取的。此外,使用者級執行緒的一個論點是,它們可以在現有作業系統上執行。此外,更改read的語意將需要更改許多使用者程式。
如果可以提前告知呼叫是否會阻塞,則可以使用另一種方法。在UNIX的大多數版本中,存在一個系統呼叫select,它允許呼叫者告訴預期的讀取是否會阻塞。當存在此呼叫時,庫過程read可以替換為一個新的過程,該過程首先執行select呼叫,然後僅在安全時執行read呼叫(即不會阻塞)。如果讀取呼叫將阻塞,則不進行呼叫,而是執行另一個執行緒。下次執行時系統獲得控制時,它可以再次檢查讀取是否現在是安全的。這種方法需要重寫系統呼叫庫的部分內容,效率低且不雅觀,但別無選擇。放置在系統呼叫周圍進行檢查的程式碼稱為封套(jacket)或包裝器(wrapper)。
與阻塞系統呼叫的問題類似的是頁面錯誤問題。如果程式呼叫或跳轉到不在記憶體中的指令,則會發生頁錯誤,作業系統將從磁碟獲取丟失的指令(及其鄰居),稱為頁面錯誤(page fault)。當找到並讀入必要的指令時,程序被阻塞。如果執行緒導致頁面錯誤,核心甚至不知道執行緒的存在,自然會阻塞整個程序,直到磁碟I/O完成,即使其他執行緒可能可以執行。
使用者級執行緒包的另一個問題是,如果一個執行緒開始執行,那麼該程序中的其他執行緒將永遠不會執行,除非第一個執行緒自願放棄CPU。在單個程序中,沒有時鐘中斷,因此無法以迴圈方式(輪流)排程程序。除非執行緒自願進入執行時系統,否則排程程式永遠不會有機會。
執行緒永遠執行的問題的一個可能的解決方案是讓執行時系統每秒請求一次時鐘訊號(中斷)來給它控制權,但對程式來說也是粗糙和混亂的。頻率較高的週期性時鐘中斷並不總是可行,即使是這樣,總開銷也可能很大。此外,執行緒還可能需要時鐘中斷,從而干擾執行時系統對時鐘的使用。
另一個,也是最具破壞性的,反對使用者級執行緒的論點是,程式設計師通常希望執行緒恰好位於執行緒經常阻塞的應用程式中,例如,在多執行緒Web伺服器中,這些執行緒不斷地進行系統呼叫。一旦核心出現執行系統呼叫的陷阱,如果舊的執行緒被阻塞,核心就幾乎不需要再做任何工作來切換執行緒,並且讓核心這樣做可以消除不斷進行選擇系統呼叫以檢查讀取系統呼叫是否安全的需要。對於本質上完全受CPU限制且很少阻塞的應用程式,使用執行緒有什麼意義?沒有人會認真建議計算前n個質數或使用執行緒下棋,因為這樣做毫無益處。
現在讓我們考慮讓核心瞭解並管理執行緒。如上圖右所示,每個系統都不需要執行時系統。此外,每個程序中都沒有執行緒表。相反,核心有一個執行緒表來跟蹤系統中的所有執行緒。當執行緒想要建立新執行緒或銷燬現有執行緒時,它會進行核心呼叫,然後通過更新核心執行緒表來建立或銷燬執行緒。
核心的執行緒表儲存每個執行緒的暫存器、狀態和其他資訊。這些資訊與使用者級執行緒的資訊相同,但現在儲存在核心中,而不是使用者空間中(在執行時系統中),這些資訊是傳統核心維護的關於其單執行緒程序的資訊的子集,即程序狀態。此外,核心還維護傳統的程序表以跟蹤程序。
所有可能阻塞執行緒的呼叫都被實現為系統呼叫,其成本遠遠高於對執行時系統過程的呼叫。當一個執行緒阻塞時,核心可以選擇執行同一程序中的另一個執行緒(如果一個執行緒已就緒)或不同程序中的一個執行緒。使用使用者級執行緒,執行時系統會從自己的程序中保持執行緒的執行,直到核心佔用CPU(或者沒有準備好的執行緒可以執行)。
由於在核心中建立和銷燬執行緒的成本相對較高,一些系統採用了一種環境正確的方法並回收其執行緒。當執行緒被銷燬時,它被標記為不可執行,但其核心資料結構不會受到其他方面的影響。稍後,當必須建立新執行緒時,將重新啟用舊執行緒,從而節省一些開銷。使用者級執行緒也可以進行執行緒回收,但由於執行緒管理開銷要小得多,因此這樣做的動機較小。
核心執行緒不需要任何新的非阻塞系統呼叫。此外,如果程序中的一個執行緒導致頁面錯誤,核心可以輕鬆檢查程序是否有其他可執行的執行緒,如果有,則在等待從磁碟引入所需頁面的同時執行其中一個執行緒。它們的主要缺點是系統呼叫的成本很高,因此如果執行緒操作(建立、終止等)很常見,則會產生更多的開銷。
雖然核心執行緒可以解決一些問題,但它們並不能解決所有問題。例如,當多執行緒程序分叉時會發生什麼?新程序是否與舊程序具有相同數量的執行緒,還是隻有一個執行緒?在許多情況下,最佳選擇取決於程序下一步計劃做什麼。如果它要呼叫exec來啟動一個新程式,可能選擇一個執行緒是正確的,但如果它繼續執行,則最好重新生成所有執行緒。
另一個問題是訊號。請記住,至少在經典模型中,訊號被傳送到程序,而不是執行緒。當訊號傳入時,哪個執行緒應該處理它?執行緒可能會註冊他們對某些訊號的興趣,因此當訊號傳入時,它會被傳送給表示想要它的執行緒。但是,如果兩個或多個執行緒註冊同一訊號,會發生什麼情況?這些只是執行緒引入的兩個問題,還有更多。
為了將使用者級執行緒與核心級執行緒的優點結合起來,已經研究了多種方法。一種方法是使用核心級執行緒,然後將使用者級執行緒複用到部分或全部執行緒上,如下圖所示。當使用這種方法時,開發人員可以確定要使用多少核心執行緒,以及每個執行緒要複用多少使用者級執行緒。該模型提供了最大的靈活性。
將使用者級執行緒多路傳輸到核心級執行緒。
使用這種方法,核心只知道核心級別的執行緒並對其進行排程。其中一些執行緒可能有多個使用者級執行緒在其上進行多路複用,這些使用者級執行緒的建立、銷燬和排程就像在沒有多執行緒功能的作業系統上執行的程序中的使用者級執行緒一樣。在這個模型中,每個核心級執行緒都有一些使用者級執行緒,這些執行緒輪流使用它。
許多現有程式都是為單執行緒程序編寫的,將這些轉換為多執行緒比最初看起來要複雜得多。下面,我們將研究幾個陷阱。
首先,執行緒的程式碼通常由多個過程組成,就像一個程序一樣,這些變數可能有區域性變數、全域性變數和引數。區域性變數和引數不會引起任何問題,但對於執行緒是全域性的但對於整個程式不是全域性的變數是一個問題。這些變數是全域性變數,因為執行緒中的許多過程都使用它們(因為它們可能使用任何全域性變數),但其他執行緒在邏輯上應該不使用它們。
例如,考慮UNIX維護的errno變數。當程序(或執行緒)進行失敗的系統呼叫時,錯誤程式碼被放入errno。在下圖中,執行緒1執行系統呼叫存取,以確定它是否有權存取某個檔案。作業系統在全域性變數errno中返回答案,控制權返回執行緒1後,但在有機會讀取errno之前,排程程式決定執行緒1目前有足夠的CPU時間,並決定切換到執行緒2。執行緒2執行一個失敗的開啟呼叫,會導致errno被覆蓋,執行緒1的存取程式碼永遠丟失。執行緒1稍後啟動後,將讀取錯誤的值,並且行為不正確。
執行緒之間因使用全域性變數而發生衝突。
這個問題有多種解決方案。一是完全禁止全域性變數,無論這個理想多麼值得,它都與許多現有的軟體相沖突。另一種方法是為每個執行緒分配自己的私有全域性變數,如下圖所示。這樣,每個執行緒都有自己的errno和其他全域性變數的私有副本,從而避免了衝突。實際上,這個決定建立了一個新的作用域級別,變數對執行緒的所有過程都可見(但對其他執行緒不可見),此外,變數的現有作用域級別只對一個過程可見,變數在程式中到處可見。
執行緒可以擁有私有的全域性變數。
然而,存取私有全域性變數有點棘手,因為大多數程式語言都有表示區域性變數和全域性變數的方法,但沒有中間形式。可以為全域性變數分配一塊記憶體,並將其作為額外引數傳遞給執行緒中的每個過程。雖然不是一個優雅的方法,但確實有效。或者,可以引入新的庫過程來建立、設定和讀取這些執行緒範圍的全域性變數。第一個呼叫可能如下所示:
create_global("bufptr");
它在堆上或為呼叫執行緒保留的特殊儲存區域中為名為bufptr的指標分配儲存。無論儲存分配到哪裡,只有呼叫執行緒可以存取全域性變數。如果另一個執行緒建立了一個同名的全域性變數,它將獲得一個與現有儲存位置不衝突的不同儲存位置。存取全域性變數需要兩個呼叫:一個用於寫入,另一個用於讀取。寫法類似以下程式碼:
set_global("bufptr", &buf);
它將指標的值儲存在之前由建立全域性呼叫建立的儲存位置。要讀取全域性變數,呼叫可能如下所示:
bufptr = read_global("bufptr");
它返回儲存在全域性變數中的地址,因此可以存取其資料。
將單執行緒程式轉換為多執行緒程式的下一個問題是,許多庫過程都是不可重入的。也就是說,在前一個呼叫尚未完成的情況下,它們不會對任何給定過程進行第二次呼叫。例如,通過網路傳送訊息很可能被程式設計為在庫內的固定緩衝區中組裝訊息,然後陷阱到核心傳送訊息。如果一個執行緒在緩衝區中組裝了它的訊息,然後時鐘中斷強制切換到第二個執行緒,該執行緒立即用自己的訊息覆蓋緩衝區,會發生什麼情況?
類似地,記憶體分配過程(如UNIX中的malloc)維護有關記憶體使用的關鍵表,例如可用記憶體塊的連結列表。當malloc忙於更新這些列表時,它們可能暫時處於不一致的狀態,指標沒有指向任何地方。如果在表不一致時發生執行緒切換,並且來自不同執行緒的新呼叫,則可能會使用無效指標,從而導致程式崩潰。要有效地解決所有這些問題意味著要重寫整個庫,是一項非常重要的工作,很可能會引入細微的錯誤。
另一種解決方案是為每個過程提供一個封套(jacket),該封套設定一個位,將庫標記為正在使用。當上一個呼叫尚未完成時,另一個執行緒使用庫過程的任何嘗試都將被阻止。雖然這種方法可以工作,但它大大消除了潛在的並行性。
接下來,考慮訊號。有些訊號在邏輯上是特定於執行緒的,而其他訊號則不是。例如,如果一個執行緒呼叫警報,那麼產生的訊號應該傳送給進行呼叫的執行緒。然而,當執行緒完全在使用者空間中實現時,核心甚至不知道執行緒,很難將訊號指向正確的執行緒。如果一個程序一次只能有一個警報掛起,並且多個執行緒獨立呼叫警報,則會出現額外的複雜性。
其他訊號,如鍵盤中斷,不是特定於執行緒的。誰應該抓住他們?一個指定執行緒?所有執行緒?新建立的彈出執行緒?此外,如果一個執行緒在不通知其他執行緒的情況下更改訊號處理程式,會發生什麼情況?如果一個執行緒想要捕捉一個特定的訊號(例如,使用者點選CTRL-C),而另一個執行緒希望這個訊號終止程序,會發生什麼?如果一個或多個執行緒執行標準庫過程,而其他執行緒是使用者編寫的,則可能會出現這種情況。顯然,這些期望是不相容的。通常,訊號很難在單執行緒環境中管理,進入多執行緒環境並不會使它們更容易處理。
執行緒引入的最後一個問題是堆疊管理。在許多系統中,當程序的堆疊溢位時,核心只會自動為該程序提供更多堆疊。當一個程序有多個執行緒時,它也必須有多個堆疊。核心如果不知道所有這些堆疊,就無法在出現堆疊錯誤時自動增長它們,事實上,它甚至可能沒有意識到記憶體故障與某些執行緒堆疊的增長有關。
這些問題當然不是不可克服的,但它們確實表明,僅僅將執行緒引入現有系統而不進行相當實質性的系統重新設計是行不通的。至少,系統呼叫的語意可能需要重新定義,庫需要重寫。所有這些事情都必須以這樣一種方式進行,即在程序只有一個執行緒的情況下,保持與現有程式的向後相容。
程序是管理物件,不直接執行程式碼。要在Windows上完成任何操作,必須建立執行緒。使用者模式程序是由一個執行緒建立的,該執行緒最終執行可執行檔案的主入口點。在許多情況下,應用程式可能不需要更多執行緒。然而,一些應用程式可能會受益於使用程序內執行的多個執行緒。每個執行緒都是獨立的執行路徑,因此可以使用不同的處理器,從而實現真正的並行。
執行緒是執行程式碼的實際載體,包含在程序中,使用程序公開的資源進行工作(如虛擬記憶體和核心物件的控制程式碼)。執行緒擁有的最重要屬性如下:
執行緒最常見的狀態是:
執行緒抽象了一個獨立的執行路徑,從執行的角度來看,它與可能同時處於活動狀態的其他執行緒無關。一旦執行緒開始執行,它可以執行以下任何操作,直到退出:
在進一步研究執行緒之前,我們必須認識到執行緒是處理器的抽象。但處理器的定義究竟是什麼?在多核構成一個典型CPU的時代,這些術語可能會變得混亂。下圖顯示了典型CPU的邏輯組成。
在上圖中,有一個插槽(Socket),它是卡在計算機主機板上的物理晶片。筆記型電腦和家用電腦通常只有一種,大型伺服器計算機可能包含多個插槽。每個插槽都有多個核心,它們是獨立的處理器(上圖是4個)。
在英特爾處理器上,每個核心可能被分成兩個邏輯處理器,由於一種稱為超執行緒(Hyper Threading)的技術,也稱為硬體執行緒。從Windows的角度來看,處理器的數量是邏輯處理器的數量。下圖顯示博主的筆電有16個邏輯處理器,意味著在任何給定時刻,最多有16個執行緒正在執行。工作管理員還顯示了插槽、核心和邏輯處理器的數量。
AMD也有類似的技術,稱為並行多執行緒(Simultaneous Multi Threading,SMT)。
可以在BIOS設定中禁用超執行緒。超執行緒的潛在缺點是,共用一個核心的每兩個邏輯處理器也共用二級快取,因此可能會相互「干擾」。
下面的範例演示了多執行緒的複雜用法。PrimeSconter應用程式使用指定數量的執行緒對一系列數位中的質數進行計數,想法是將工作分成幾個執行緒,每個執行緒都計算其數位範圍內的素數。然後,主執行緒等待所有工作執行緒退出,允許它簡單地對所有執行緒的計數求和。如下圖所示。
這種建立多個執行某些工作的執行緒,並等待它們在聚合結果之前退出的想法有時被稱為分叉-合併(Fork-Join),因為執行緒從某個初始執行緒「分叉」,然後在完成後「連線回」初始執行緒。
這種模式的另一個名稱是結構化並行(Structured Parallelism)。
此應用程式中使用的執行緒數是演演算法的引數之一——有趣的問題是,最快完成計算的最佳執行緒數是多少?
下面程式碼是上圖所示的應用程式PrimeSconter的實現程式碼:
struct PrimesData
{
int From, To;
int Count;
};
bool IsPrime(int n)
{
if (n < 2)
return false;
if(n == 2)
return true;
int limit = (int)::sqrt(n);
for (int i = 2; i <= limit; i++)
if (n % i == 0)
return false;
return true;
}
DWORD WINAPI CalcPrimes(PVOID param)
{
auto data = static_cast<PrimesData*>(param);
int from = data->From, to = data->To;
int count = 0;
for (int i = from; i <= to; i++)
if (IsPrime(i))
count++;
data->Count = count;
return count;
}
int CalcAllPrimes(int from, int to, int threads, DWORD& elapsed)
{
auto start = ::GetTickCount64();
// allocate data for each thread
auto data = std::make_unique<PrimesData[]>(threads);
// allocate an array of handles
auto handles = std::make_unique<HANDLE[]>(threads);
int chunk = (to - from + 1) / threads;
for (int i = 0; i < threads; i++)
{
auto& d = data[i];
d.From = i * chunk;
d.To = i == threads - 1 ? to : (i + 1) * chunk - 1;
DWORD tid;
handles[i] = ::CreateThread(nullptr, 0, CalcPrimes, &d, 0, &tid);
assert(handles[i]);
printf("Thread %d created. TID=%u\n", i + 1, tid);
}
elapsed = static_cast<DWORD>(::GetTickCount64() - start);
FILETIME dummy, kernel, user;
int total = 0;
for (int i = 0; i < threads; i++)
{
::GetThreadTimes(handles[i], &dummy, &dummy, &kernel, &user);
int count = data[i].Count;
printf("Thread %2d Count: %7d. Execution time: %4u msec\n", i + 1, count, (user.dwLowDateTime + kernel.dwLowDateTime) / 10000);
total += count;
::CloseHandle(handles[i]);
}
return total;
}
int main(int argc, const char* argv[])
{
if (argc < 4)
{
printf("Usage: PrimesCounter <from> <to> <threads>\n");
return 0;
}
int from = atoi(argv[1]);
int to = atoi(argv[2]);
int threads = atoi(argv[3]);
if (from < 1 || to < 1 || threads < 1 || threads > 64)
{
printf("Invalid input.\n");
return 1;
}
DWORD elapsed;
int count = CalcAllPrimes(from, to, threads, elapsed);
printf("Total primes: %d. Elapsed: %d msec\n", count, elapsed);
return 1;
}
以下是相同值範圍的一些執行,從使用一個執行緒的基線開始:
C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 1
Thread 1 created (3 to 20000000). TID=29760
Thread 1 Count: 1270606. Execution time: 9218 msec
Total primes: 1270606. Elapsed: 9218 msec
C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 2
Thread 1 created (3 to 10000001). TID=22824
Thread 2 created (10000002 to 20000000). TID=41816
Thread 1 Count: 664578. Execution time: 3625 msec
Thread 2 Count: 606028. Execution time: 5968 msec
Total primes: 1270606. Elapsed: 5984 msec
C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 4
Thread 1 created (3 to 5000001). TID=52384
Thread 2 created (5000002 to 10000000). TID=47756
Thread 3 created (10000001 to 14999999). TID=42296
Thread 4 created (15000000 to 20000000). TID=34972
Thread 1 Count: 348512. Execution time: 1312 msec
Thread 2 Count: 316066. Execution time: 2218 msec
Thread 3 Count: 306125. Execution time: 2734 msec
Thread 4 Count: 299903. Execution time: 3140 msec
Total primes: 1270606. Elapsed: 3141 msec
C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 8
Thread 1 created (3 to 2500001). TID=25200
Thread 2 created (2500002 to 5000000). TID=48588
Thread 3 created (5000001 to 7499999). TID=52904
Thread 4 created (7500000 to 9999998). TID=18040
Thread 5 created (9999999 to 12499997). TID=50340
Thread 6 created (12499998 to 14999996). TID=43408
Thread 7 created (14999997 to 17499995). TID=53376
Thread 8 created (17499996 to 20000000). TID=33848
Thread 1 Count: 183071. Execution time: 578 msec
Thread 2 Count: 165441. Execution time: 921 msec
Thread 3 Count: 159748. Execution time: 1171 msec
Thread 4 Count: 156318. Execution time: 1343 msec
Thread 5 Count: 154123. Execution time: 1531 msec
Thread 6 Count: 152002. Execution time: 1531 msec
Thread 7 Count: 150684. Execution time: 1718 msec
Thread 8 Count: 149219. Execution time: 1765 msec
Total primes: 1270606. Elapsed: 1766 msec
C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 16
Thread 1 created (3 to 1250001). TID=50844
Thread 2 created (1250002 to 2500000). TID=9792
Thread 3 created (2500001 to 3749999). TID=12600
Thread 4 created (3750000 to 4999998). TID=52804
Thread 5 created (4999999 to 6249997). TID=5408
Thread 6 created (6249998 to 7499996). TID=42488
Thread 7 created (7499997 to 8749995). TID=49336
Thread 8 created (8749996 to 9999994). TID=13384
Thread 9 created (9999995 to 11249993). TID=41508
Thread 10 created (11249994 to 12499992). TID=12900
Thread 11 created (12499993 to 13749991). TID=39512
Thread 12 created (13749992 to 14999990). TID=3084
Thread 13 created (14999991 to 16249989). TID=52760
Thread 14 created (16249990 to 17499988). TID=17496
Thread 15 created (17499989 to 18749987). TID=39956
Thread 16 created (18749988 to 20000000). TID=31672
Thread 1 Count: 96468. Execution time: 281 msec
Thread 2 Count: 86603. Execution time: 484 msec
Thread 3 Count: 83645. Execution time: 562 msec
Thread 4 Count: 81795. Execution time: 671 msec
Thread 5 Count: 80304. Execution time: 781 msec
Thread 6 Count: 79445. Execution time: 812 msec
Thread 7 Count: 78589. Execution time: 859 msec
Thread 8 Count: 77729. Execution time: 828 msec
Thread 9 Count: 77362. Execution time: 906 msec
Thread 10 Count: 76761. Execution time: 1000 msec
Thread 11 Count: 76174. Execution time: 984 msec
Thread 12 Count: 75828. Execution time: 1046 msec
Thread 13 Count: 75448. Execution time: 1078 msec
Thread 14 Count: 75235. Execution time: 1062 msec
Thread 15 Count: 74745. Execution time: 1062 msec
Thread 16 Count: 74475. Execution time: 1109 msec
Total primes: 1270606. Elapsed: 1188 msec
C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 20
Thread 1 created (3 to 1000001). TID=30496
Thread 2 created (1000002 to 2000000). TID=7300
Thread 3 created (2000001 to 2999999). TID=50580
Thread 4 created (3000000 to 3999998). TID=21536
Thread 5 created (3999999 to 4999997). TID=24664
Thread 6 created (4999998 to 5999996). TID=34464
Thread 7 created (5999997 to 6999995). TID=51124
Thread 8 created (6999996 to 7999994). TID=29972
Thread 9 created (7999995 to 8999993). TID=50092
Thread 10 created (8999994 to 9999992). TID=49396
Thread 11 created (9999993 to 10999991). TID=18264
Thread 12 created (10999992 to 11999990). TID=33496
Thread 13 created (11999991 to 12999989). TID=16924
Thread 14 created (12999990 to 13999988). TID=44692
Thread 15 created (13999989 to 14999987). TID=53132
Thread 16 created (14999988 to 15999986). TID=53692
Thread 17 created (15999987 to 16999985). TID=5848
Thread 18 created (16999986 to 17999984). TID=12760
Thread 19 created (17999985 to 18999983). TID=13180
Thread 20 created (18999984 to 20000000). TID=49980
Thread 1 Count: 78497. Execution time: 218 msec
Thread 2 Count: 70435. Execution time: 343 msec
Thread 3 Count: 67883. Execution time: 421 msec
Thread 4 Count: 66330. Execution time: 484 msec
Thread 5 Count: 65366. Execution time: 578 msec
Thread 6 Count: 64337. Execution time: 640 msec
Thread 7 Count: 63798. Execution time: 640 msec
Thread 8 Count: 63130. Execution time: 703 msec
Thread 9 Count: 62712. Execution time: 718 msec
Thread 10 Count: 62090. Execution time: 703 msec
Thread 11 Count: 61937. Execution time: 781 msec
Thread 12 Count: 61544. Execution time: 812 msec
Thread 13 Count: 61191. Execution time: 796 msec
Thread 14 Count: 60826. Execution time: 843 msec
Thread 15 Count: 60627. Execution time: 875 msec
Thread 16 Count: 60425. Execution time: 875 msec
Thread 17 Count: 60184. Execution time: 875 msec
Thread 18 Count: 60053. Execution time: 890 msec
Thread 19 Count: 59681. Execution time: 875 msec
Thread 20 Count: 59560. Execution time: 906 msec
Total primes: 1270606. Elapsed: 1109 msec
執行這些執行的系統具有16個邏輯處理器。以下是來自上述輸出的幾個有趣的觀察結果:
為什麼我們會得到這些結果?Fork-Join演演算法中的最佳執行緒數是多少?答案似乎應該是「邏輯處理器的數量」,因為越來越多的執行緒會導致上下文切換,因為不是所有執行緒都可以同時執行,而使用更少的執行緒肯定會使一些處理器無法使用。
然而,實際並不是那麼簡單。得到這兩個觀察結果的唯一原因是:執行緒之間的工作分配不均等(就執行時間而言)。僅僅是因為所使用的演演算法:數位越大,需要做的工作越多,因為sqrt函數是單調函數,其輸出與其輸入成正比。通常是Fork-Join演演算法的挑戰:工作的公平分割。下圖演示了四個執行緒的範例情況。
請注意,在上面的輸出中,後面的執行緒執行時間更長,只是因為它們有更多的工作要做。很明顯,即便系統只有16個邏輯處理器,我們也可以使用20個執行緒獲得更好的執行時間。完成的早期執行緒使處理器空閒,允許那些「額外」執行緒(16之後)獲得處理器,從而推動工作向前。有限制嗎?當然,在某種程度上,上下文切換開銷,加上執行緒堆疊記憶體分配過多可能導致的頁面錯誤,將使情況變得更糟。顯然,確定應用程式的最佳處理器數量不是一個容易的問題。更難以回答的是,如果執行緒需要頻繁地進行I/O,這個問題就變得更加困難。
每個好的(或壞的)執行緒都必須在某個時刻結束。Windows執行緒終止有三種方式:
1、執行緒函數返回(最佳選項)。
2、執行緒呼叫ExitThread(最好避免)。
3、執行緒以TerminateThread終止(通常是個壞主意)。
函數的區域性變數和返回地址駐留線上程堆疊上。執行緒堆疊的大小可以通過CreateThread的第二個引數指定,但實際上有兩個值影響執行緒堆疊:作為堆疊最大大小的保留記憶體大小和準備使用的初始提交記憶體大小。保留記憶體只是將一個連續地址空間範圍標記為用於某些目的,因此程序地址空間中的新分配不會從該範圍中進行。對於堆疊,此舉是必要的,因為堆疊始終是連續的。提交記憶體意味著實際分配的記憶體,因此可以使用。
可以立即分配最大堆疊大小,預先提交整個堆疊,但這種做法是一種浪費,因為執行緒可能不需要整個範圍來執行與堆疊相關的工作。記憶體管理器有一個優化方案:提交較小的記憶體量,如果堆疊增長超過該量,則觸發堆疊擴充套件,直至達到保留限制。觸發是由一個帶有特殊標誌PAGE_GUARD的頁面完成的,如果讀寫該頁面,將導致異常。記憶體管理器捕獲此異常,然後提交一個附加頁,將PAGE_GUARD頁向下移動一頁(請記住,堆疊會增長到較低的地址)。下圖顯示了這種佈置。
保護頁的實際最小值為12KB,即3頁,保證了堆疊擴充套件將允許至少12KB的提交記憶體可用於堆疊。
Visual Studio允許使用連結器/系統節點下的專案屬性更改預設堆疊大小(下圖),只是在PE頭中設定請求的值。
每個執行緒都有一個相關的優先順序,線上程數量比處理器多的情況下十分重要。
執行緒優先順序從0到31,其中31是最高的。執行緒0是為稱為零頁執行緒的特殊執行緒保留的,該執行緒是核心記憶體管理器的一部分,是唯一允許優先順序為零的執行緒。在使用者模式下,優先順序不能設定為任意值。相反,執行緒的優先順序是程序優先順序類(在工作管理員中稱為基本優先順序)和該基本優先順序周圍的偏移量的組合。
Windows執行緒可以使用以下API設定執行緒優先順序:
BOOL SetThreadPriority(_In_ HANDLE hThread, _In_ int nPriority);
nPriority並非絕對優先順序,而是相對優先順序。其說明如下表:
優先順序值 | 效果 |
---|---|
THREAD_PRIORITY_IDLE (-15) | 優先順序降至1(實時優先順序類除外),執行緒優先順序降至16 |
THREAD_PRIORITY_LOWSET (-2) | 優先順序相對於優先順序類下降2 |
THREAD_PRIORITY_BELOW_NORMAL (-1) | 優先順序相對於優先順序類下降1 |
THREAD_PRIORITY_NORMAL (0) | 優先順序設定為程序優先順序類值 |
THREAD_PRIORITY_ABOVE_NORMAL (1) | 優先順序相對於優先順序類別增加1 |
THREAD_PRIORITY_HIGHEST (2) | 優先順序相對於優先順序類別增加2 |
THREAD_PRIORITY_TIME_CRITICAL (15) | 優先順序增加到15,實時優先順序類除外,其中執行緒優先順序增加到31 |
在下圖中,每個矩形表示基於SetPriorityClass / SetThreadPriority的可能執行緒優先順序值。
下表是按優先順序分類的執行緒優先順序:
)
Windows優先順序關係範例:
排程通常非常複雜,考慮到幾個因素,其中一些因素相互衝突:多處理器、電源管理(一方面希望節省電源,另一方面利用所有處理器)、NUMA(非統一記憶體架構)、超執行緒、快取等。確切的排程演演算法沒有檔案記錄是有原因的:微軟可以在後續的Windows版本和更新中進行修改和調整,而開發人員不需要依賴確切的演演算法。話雖如此,通過實驗可以體驗許多排程演演算法。我們將從最簡單的排程開始——當系統上只有一個處理器時,因為它是排程工作的基礎。稍後將研究這些演演算法在多處理系統上的一些變化方式。
排程程式維護一個就緒佇列,其中管理要執行(處於就緒狀態)的執行緒。此時不想執行的所有其他執行緒(處於等待狀態)都不會被檢視,因為它們不想執行。下圖顯示了一個範例系統,其中七個執行緒處於就緒狀態,它們根據它們所處的優先順序排列在多個佇列中。
一個系統上可能有數千個執行緒,但大多數都處於等待狀態,因此排程程式不會考慮這些等待狀態的執行緒。
單CPU的排程演演算法描述如下。
最高優先順序執行緒首先執行。上圖種的執行緒1和執行緒2具有最高(且相同)優先順序(31),因此優先順序為31的佇列中的第一個執行緒執行;假設它是執行緒1(下圖)。
執行緒1執行一段時間,稱為量程(Quantum)。假設執行緒1有很多事情要做,當它的時間量到期時,排程程式搶佔執行緒1,將其狀態儲存在核心堆疊中,然後返回到就緒狀態(因為它仍然有事情要做)。執行緒2現在成為正在執行的執行緒,因為它具有相同的優先順序(下圖)。
因此,優先順序是決定因素。只要執行緒1和執行緒2需要執行,它們就會在CPU上回圈執行,每個執行緒執行一段時間。幸運的是,執行緒通常不會永遠執行。相反,它們在某個點進入等待狀態。以下是導致執行緒進入等待狀態的幾個範例:
一旦執行緒進入等待狀態,它將從排程程式的就緒佇列中刪除。假設執行緒1和執行緒2進入等待狀態。現在最高優先順序的執行緒是執行緒3,它成為執行執行緒(下圖)。
執行緒3執行一個量程。如果它還有工作要做,它會得到另一個量程,因為它是其優先順序中唯一的量程。然而,如果執行緒1接收到它正在等待的任何內容,它將進入就緒狀態並搶佔執行緒3(因為執行緒1具有更高的優先順序),併成為正在執行的執行緒。執行緒3返回到就緒狀態(下圖)。此開關不線上程3的量程末尾,而是在更改時(執行緒1完成等待)。如果執行緒3的優先順序高於15,則會補充執行緒3的量程。
考慮到該演演算法,如果在就緒狀態中沒有更高優先順序的執行緒,執行緒4、5和6將各自具有自己的量執行。
以上是排程的基礎。事實上,在實際的單CPU場景中,正是所使用的演演算法。然而,即使在這種情況下,Windows也試圖在某種程度上「公平」。例如,如果較高優先順序的執行緒處於就緒狀態,則上述幾圖中的執行緒7(優先順序為4)可能不會執行,因此它會受到CPU不足的影響。在這樣一個系統中,這條執行緒註定會失敗嗎?當然不是;系統會將此執行緒的優先順序提高到大約每4秒15次,使其有更好的機會向前推進。此提升持續執行緒實際執行的一個時間段,然後優先順序下降到其初始值。
量程(Quantum)在上面被提到了幾次,但量程有多長?排程程式以兩種正交的方式工作:第一種是使用計時器,預設情況下每15.625毫秒觸發一次,可以通過呼叫GetSystemTimeAdjustment並檢視第二個引數來獲得。另一種方法是使用SysInternals中的clockres工具:
C:\Users\pavel>clockres
使用者端機器(家庭、專業、企業、XBOX等)的預設時間量為2個時鐘tick,伺服器機器為12個時鐘滴答(tick)。換句話說,使用者端的時間量為31.25毫秒,伺服器為187.5毫秒。
伺服器版本獲得更長時間量的原因是增加了在單個時間量中完全處理使用者端請求的機會。這在使用者端機器上不太重要,因為它可能有許多程序,每個程序所做的工作相對較少,其中一些程序的使用者介面應該是響應性的,因此短的數量更適合。
排程類值(介於0和9之間)以以下方式設定作為作業一部分的程序中執行緒的量程:
Quantum = 2 * (TimerInterval) * (SchedulingClass + 1);
最初的Windows NT設計最多支援32個處理器,其中一個機器字(32位元)用於表示系統上的實際處理器,每個位代表一個處理器。當64位元視窗出現時,處理器的最大數量自然擴充套件到64。
從Windows 7(僅限64位元系統)開始,微軟希望支援64個以上的處理器,因此有一個額外的引數出現:處理器組(Processor Group)。例如,Windows 7和Server 2008 R2最多支援256個處理器,意味著在具有256個處理器的系統上有4個處理器組。
執行緒可以是一個處理器組的成員,意味著執行緒可以在屬於其當前組的(最多)64個處理器中的一個上排程。建立程序時,會以迴圈方式為其分配一個處理器組,這樣程序就可以跨組「負載平衡」。程序中的執行緒被分配給行程群組,父程序可以通過以下方式之一影響子程序的初始處理器組:
1、父程序可以使用INHERIT_PARENT_AFFINITY標誌作為建立程序的標誌之一,以指示子程序應該繼承其父處理器組,而不是根據系統管理的迴圈來獲取它。如果父程序的執行緒使用多個關聯組,則任意選擇其中一個組作為用於子行程群組的組。
2、父程序可以使用PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY程序屬性來指定期望的預設處理器組。
多處理器排程增加了排程演演算法的複雜性。Windows唯一能保證的是,要執行的最高優先順序執行緒(如果有多個執行緒,至少一個)當前正在執行。
通常,可以在任何處理器上排程執行緒。然而,執行緒的親緣性(Affinity),即允許在其上執行的處理器,可以通過多種方式進行控制。
理想的處理器是執行緒的屬性,有時也稱為軟親緣性(Soft Affinity)。理想的處理器作為排程程式的提示,在所有其他條件相同的情況下,它是執行該執行緒程式碼的首選處理器。預設的理想處理器以迴圈方式選擇,從建立程序時生成的亂數開始。在超執行緒系統上,下一個理想處理器是從下一個核心中選擇的,而不是從下一邏輯處理器中選擇的。理想的處理器可以使用Process Explorer工具檢視,作為執行緒索引標籤中顯示的屬性之一(下圖)。
雖然理想的處理器可以作為提示和建議執行緒應該在哪個處理器上執行,但硬親緣性(Hard Affinity),有時就稱為親緣性,允許為特定執行緒或程序指定允許執行的處理器。硬親緣性在兩個級別上工作:程序和執行緒,其中基本規則是執行緒不能避開其程序設定的親緣性。
一般來說,設定硬親緣性約束通常是一個壞主意,限制了排程程式分配處理器的自由度,並可能導致執行緒獲得的CPU時間比沒有硬親緣性約束時少。不過,在一些罕見的情況下,可能會很有用,因為在同一組處理器上執行的執行緒更有可能獲得更好的CPU快取利用率。對於執行特定已知程序的系統很有用,而不是執行任何東西的隨機機器。硬親緣性的另一個用途是壓力測試,例如在某些執行中使用較少的處理器,以檢視有限數量處理器的系統在執行相同程序時的行為。
執行緒的關聯不能「逃逸」其程序親緣性,然而,在某些情況下,讓一個執行緒(或多個執行緒)使用程序中其他執行緒禁止使用的處理器是有益的。Windows 10和Server 2016新增了此功能,稱為CPU集(CPU Set)。
CPU集表示處理器的抽象檢視,其中每個CPU集可能對映到一個或多個邏輯處理器。然而,目前,每個CPU集都精確地對映到單個邏輯處理器。系統有自己的CPU集,預設情況下包括系統上的所有處理器。
CPU集和硬親緣性可能相互衝突。在這種情況下,硬親緣性總是勝出,即忽略CPU集。
多處理器(MP)排程很複雜,涉及硬親緣性、理想處理器、CPU集、電源考慮、遊戲模式和其他方面。在MP系統上,每個處理器都有自己的就緒佇列。此外,在Windows 8及更高版本上,處理器組有共用就緒佇列(目前每組最多4個),允許排程程式在需要為連線到共用就緒佇列的就緒執行緒定位處理器時擁有更多選項(每個CPU就緒佇列仍用於具有硬親緣性的執行緒)。下圖給出了一種改進的、簡化的MP排程演演算法。它假設沒有親和性或CPU集約束,沒有功率或其他特殊考慮。
如上圖所示,理想的處理器是首選處理器,其次是它執行的最後一個處理器(處理器的快取可能仍然包含該執行緒使用的資料)。如果所有處理器都忙,排程器不搶佔執行低優先順序執行緒的第一個處理器;這是低效的,因為可能需要搜尋許多處理器。相反,執行緒被放入其理想處理器的(共用)就緒佇列中。
在Windows系統,可以使用Windows Performance Recorder (wprui.exe)和Windows Performance Analyzer (WPA)來分析、追蹤和監視每個執行緒的排程情況(下圖)。
有些程序自然比其他程序更重要。例如,如果使用者使用Microsoft Word,可能希望自己的互動和Word的使用非常好。另一方面,諸如備份應用程式、反病毒掃描程式、搜尋索引器等程序並不重要,不應干擾使用者的主要應用程式。
這些後臺應用程式限制其影響的一種方法是降低其CPU優先順序。此舉雖然可行,但CPU只是程序使用的一種資源,其他資源還包括記憶體和I/O,意味著降低執行緒的CPU優先順序或程序的優先順序等級可能不足以降低此類程序的影響。
Windows提供了後臺模式(Background Mode)的概念,其中執行緒的CPU優先順序下降到4,記憶體優先順序和I/O優先順序也下降。例如,線上程檢視的程序資源管理器中檢視Windows資源管理器,顯示記憶體和I/O優先順序以及CPU優先順序(下圖)。I/O優先順序的預設值為「正常」,記憶體優先順序的預設數值為5(可能值為0到7)。
優先順序是排程的決定因素。然而,Windows對優先順序進行了一些調整,稱為優先順序提升(priority boosts)。這些優先順序的臨時增加是為了在某種意義上使排程更加「公平」,或者為使用者提供更好的體驗。
當執行緒發出同步I/O操作時,它將進入等待狀態,直到操作完成。一旦完成,負責I/O操作的裝置驅動程式就有機會提高請求執行緒的優先順序,從而提高其執行速度,因為操作最終完成。優先順序提升(如果應用)將執行緒的優先順序增加一個由驅動程式決定的量,並且執行緒管理執行的每個時間段的優先順序都會下降一個級別,直到優先順序下降到其基本級別。下圖顯示了該過程的概念檢視。
此外,前臺程序、GUI執行緒喚醒、飢餓避免法則等情況也會觸發優先順序提升。但是,應儘量避免使用優先順序提升,因為未來可能被微軟拋棄。程序或執行緒還可以被掛起、繼續或睡眠等操作,以執行不同粒度的排程行為。
本章涉及執行緒和程序間的通訊、競爭條件、關鍵部分、互斥、硬體解決方案、嚴格交替、彼得森解決方案、生產者-消費者問題、號誌、事件計數器、監視器、訊息傳遞和經典IPC問題,以及死鎖的定義、特徵、預防、避免等。
利用多執行緒並行提高效能的方式有兩種:
上面闡述了多執行緒並行的益處,接下來說說它的副作用。總結起來,副作用如下:
並行(Parallelism)是至少兩個執行緒同時執行任務的機制。一般有多核多物理執行緒的CPU同時執行的行為,才可以叫並行,單核的多執行緒不能稱之為並行。
並行(Concurrency)至少兩個執行緒利用時間片(Timeslice)執行任務的機制,是並行的更普遍形式。即便單核CPU同時執行的多執行緒,也可稱為並行。
並行的兩種形式——上:雙物理核心的同時執行(並行);下:單核的多工切換(並行)。
事實上,並行和並行在多核處理器中是可以同時存在的,比如下圖所示,存在雙核,每個核心又同時切換著多個任務:
部分參考文獻嚴格區分了並行和並行,但部分文獻並不明確指出其中的區別。虛幻引擎的多執行緒渲染架構和API中,常出現並行和並行的概念,所以虛幻是明顯區分兩者之間的含義。
經典的同步是為了避免資料競爭。當兩個或多個執行緒存取同一記憶體位置,並且其中至少一個執行緒正在寫入該位置時,就會發生資料爭用。從同一位置同時讀取從來都不是問題。但一旦寫入進入圖片,所有的賭注都將被關閉。資料可能會損壞,讀取可能會被撕毀(一些資料在更改之前讀取,一些資料在改變之後讀取)。這就是需要同步的地方。
同步會降低效能,因為某些操作必須順序執行,而不是並行執行。事實上,通過向問題中新增更多執行緒/CPU可以獲得的加速取決於可以並行化的程式碼的百分比。Amdahl's law(阿姆達爾定律)很好地描述了這一點:
公式的各個分量含義如下:
舉個具體的栗子,假設有8核16執行緒的CPU用於處理某個任務,這個任務有70%的部分是可以並行處理的,那麼它的理論加速比為:
由此可見,多執行緒程式設計帶來的效益並非跟核心數呈直線正比。實際上它的曲線如下所示:
阿姆達爾定律揭示的核心數和加速比圖例。由此可見,可並行的任務佔比越低,加速比獲得的效果越差:當可並行任務佔比為50%時,16核已經基本達到加速比天花板,無論後面增加多少核心數量,都無濟於事;如果可並行任務佔比為95%時,到2048個核心才會達到加速比天花板。
雖然阿姆達爾定律給我們帶來了殘酷的現實,但是,如果我們能夠提升任務並行佔比到接近100%,則加速比天花板可以得到極大提升:
如上公式所示,當\(p=1\)(即可並行的任務佔比100%)時,理論上的加速比和核心數量成線性正比!舉個具體的例子,在編譯Unreal Engine工程原始碼或Shader時,由於它們基本是100%的並行佔比,理論上可以獲得接近線性關係的加速比,在多核系統中將極大地縮短編譯時間。
同個程序允許有多個執行緒,這些執行緒可以共用程序的地址空間、資料結構和上下文。程序內的同一資料塊,可能存在多個執行緒在某個很小的時間片段內同時讀寫,這就會造成資料異常,從而導致了不可預料的結果。這種不可預期性便造就了競爭條件(Race Condition)。
在某些作業系統中,協同工作的程序可能共用一些共同的儲存,每個程序都可以讀寫。共用儲存可能在主記憶體中(可能在核心資料結構中),也可能是共用檔案;共用記憶體的位置不會改變通訊的性質或出現的問題。為了瞭解程序間通訊在實踐中是如何工作的,現在讓我們考慮一個簡單但常見的範例:列印假離執行緒式。當一個程序想要列印一個檔案時,它會在一個特殊的後臺處理程式目錄中輸入檔名。另一個程序,印表機守護行程,定期檢查是否有要列印的檔案,如果有,它會列印這些檔案,然後從目錄中刪除它們的名稱。
假設我們的後臺處理程式目錄有大量的插槽,編號為0、1、2…,每個插槽都可以儲存一個檔名。還可以想象有兩個共用變數,out指向下一個要列印的檔案,in指向目錄中的下一個空閒插槽。這兩個變數很可能儲存在所有程序都可以使用的兩個字的檔案中。在某一時刻,插槽0至3為空(檔案已列印),插槽4至6已滿(檔名列佇列印)。程序A和B或多或少同時決定要將檔案排佇列印。這種情況如下圖所示。
適用於墨菲定律(Murphy’s law),可能會發生以下情況。程序A讀入並將值7儲存在稱為next_free_slot的區域性變數中。就在這時,一個時鐘中斷髮生了,CPU決定程序A已經執行了足夠長的時間,因此它切換到程序B。程序B也讀入並得到一個7,也將其儲存在下一個空閒插槽的本地變數中。此時,兩個程序都認為下一個可用插槽是7。
程序B現在繼續執行,將其檔名儲存在插槽7中,並在中更新為8,然後關閉並執行其他操作。
最後,程序A再次執行,從它停止的地方開始,檢視下一個空閒插槽,在那裡找到一個7,並將其檔名寫入插槽7,擦除程序B剛才放在那裡的名稱。然後計算下一個空閒插槽+1,即8,並設定為8。後臺處理程式目錄現在在內部是一致的,因此印表機守護程式不會發現任何錯誤,但程序B永遠不會收到任何輸出。使用者B將在印表機周圍徘徊數年,渴望永遠不會輸出。像這樣的情況,兩個或多個程序正在讀取或寫入一些共用資料,最終結果取決於誰在何時執行,稱為競爭條件。偵錯包含競爭條件的程式一點也不有趣。大多數測試執行的結果都很好,但偶爾會發生一些奇怪和無法解釋的事情。不幸的是,隨著核心數量的增加,並行度也在增加,爭用情況變得越來越普遍。
常見的簡單的互斥實現機制包含禁用中斷(Disabling Interrupt)、鎖變數(Lock Variable)、嚴格輪換法(Strict Alternation)等。
在單處理器系統上,最簡單的同步解決方案是讓每個程序在進入其關鍵區域後立即禁用所有中斷,並在離開之前重新啟用它們。禁用中斷時,不會發生時鐘中斷。畢竟,由於時鐘或其他中斷,CPU只能從一個程序切換到另一個程序,中斷關閉後,CPU將不會切換到其他程序。因此,一旦程序禁用了中斷,它就可以檢查和更新共用記憶體,而不用擔心任何其他程序會干預。
這種方法通常沒有吸引力,因為給使用者程序關閉中斷的能力是不明智的。如果其中一個這樣做了,再也沒有開啟過呢?可能導致系統崩潰。此外,如果系統是多處理器(具有兩個或更多CPU),禁用中斷隻影響執行禁用指令的CPU。其他的將繼續執行,並可以存取共用記憶體。
另一方面,在更新變數或特別是列表時,核心本身常常可以方便地禁用一些指令的中斷。例如,如果在就緒程序列表處於不一致狀態時發生中斷,則可能會出現爭用條件。總之,禁用中斷通常是作業系統本身的一項有用技術,但不適合作為使用者程序的一般互斥機制。
由於多核晶片的數量不斷增加,即使在低端PC中,通過禁用核心內的中斷來實現互斥的可能性也越來越小。雙核已經很常見,許多機器中有四個,8個、16個或32個也不遠。在多核(即多處理器系統)中,禁用一個CPU的中斷不會阻止其他CPU干擾第一個CPU正在執行的操作。因此,需要更復雜的方案。
考慮使用一個共用(鎖)變數,初始值為0。當程序想要進入其關鍵區域時,它首先測試鎖。如果鎖為0,程序將其設定為1並進入臨界區域。如果鎖已經是1,程序只會等待,直到它變為0。因此,0表示沒有程序處於其關鍵區域,1表示某些程序處於其臨界區域。
不幸的是,這個想法包含了與我們在後臺處理程式目錄中看到的完全相同的致命缺陷。假設一個程序讀取鎖並看到它是0,在它可以將鎖設定為1之前,另一個程序被排程、執行並將鎖設定成1。當第一個程序再次執行時,它也將把鎖設定成了1,並且兩個程序將同時處於其關鍵區域。
現在,可能認為我們可以通過先讀取鎖值,然後在儲存到其中之前再次檢查它來解決這個問題,但並無實質作用。如果第二個程序在第一個程序完成第二次檢查後修改了鎖,則會發生爭用。
下面程式碼顯示了互斥問題的第三種方法。整數變數輪數(最初為0)跟蹤進入關鍵區域的輪數,並檢查或更新共用記憶體。最初,程序0檢查回合,發現它為0,並進入其關鍵區域。程序1也發現它為0,因此處於一個嚴密的迴圈中,不斷地測試它何時變為1。不斷地測試變數,直到出現某個值,此行為稱為忙等待(busy waiting),通常應該避免,因為會浪費CPU時間。只有當有合理的預期等待時間很短時,才會使用繁忙等待,使用繁忙等待的鎖稱為自旋鎖(spin lock)。
// 關鍵區域問題的建議解決方案。
// 程序0
while (TRUE)
{
while (turn != 0) /* loop */ ;
critical_region();
turn = 1;
noncritical_region();
}
// 程序1
while (TRUE)
{
while (turn != 1) /* loop */ ;
critical_region();
turn = 0;
noncritical_region();
}
通過將輪流思想與鎖定變數和警告變數的思想相結合,荷蘭數學家T.Dekker是第一個為互斥問題設計不需要嚴格修改的軟體解決方案的人。1981年,G.L.Peterson發現了一種更簡單的實現互斥的方法,從而使Dekker的解決方案過時,其演演算法如下程式碼所示(忽略原型)。
#define FALSE 0
#define TRUE 1
#define N 2 /* number of processes */
int turn; /* whose turn is it? */
int interested[N]; /* all values initially 0 (FALSE) */
void enter_region(int process); /* process is 0 or 1 */
{
int other; /* number of the other process */
other = 1 − process; /* the opposite of process */
interested[process] = TRUE; /* show that you are interested */
turn = process; /* set flag */
while (turn == process && interested[other] == TRUE) /* null statement */ ;
}
void leave_region(int process) /* process: who is leaving */
{
interested[process] = FALSE; /* indicate departure from critical region */
}
現在讓我們來看一個需要硬體幫助的提案。有些計算機,特別是那些設計有多處理器的計算機,有如下指令:
TSL RX, LOCK
(測試並設定鎖定),其工作原理如下。它將記憶體字鎖的內容讀入暫存器RX,然後在記憶體地址鎖處儲存一個非零值。讀字和儲存字的操作保證是不可分割的,在指令完成之前,任何其他處理器都不能存取記憶體字。執行TSL指令的CPU鎖定記憶體匯流排,以禁止其他CPU存取記憶體,直到完成。
需要注意的是,鎖定記憶體匯流排與禁用中斷非常不同。禁用中斷,然後對記憶體字執行讀操作,然後再執行寫操作,不會阻止匯流排上的第二個處理器存取讀操作和寫操作之間的字。事實上,禁用處理器1上的中斷對處理器2沒有任何影響。在處理器1完成之前,保持處理器2不在記憶體中的唯一方法是鎖定匯流排,需要特殊的硬體設施(基本上是一條匯流排,宣告匯流排已鎖定,除鎖定它的處理器外,其他處理器無法使用它)。
要使用TSL指令,我們將使用一個共用變數lock來協調對共用記憶體的存取。當鎖為0時,任何程序都可以使用TSL指令將其設定為1,然後讀取或寫入共用記憶體。完成後,程序使用普通的移動指令將鎖設定回0。
如何使用此指令來防止兩個程序同時進入其關鍵區域?下圖給出瞭解決方案,圖中顯示了虛擬(但典型)組合語言中的四指令子程式。第一條指令將舊的鎖值複製到暫存器,然後將鎖設定為1。然後將舊的值與0進行比較。如果它不為零,則表示鎖已設定,因此程式只需返回到開始處並再次測試它。它遲早會變為0(噹噹前處於其關鍵區域的程序完成其關鍵區域時),子例程返回並設定了鎖。清除鎖非常簡單,程式只將0儲存在鎖中,不需要特殊的同步指令。
關鍵區域問題的一個解決方案現在很容易。在進入其關鍵區域之前,程序呼叫enter region,它會忙於等待直到鎖空閒;然後它獲取鎖並返回。離開關鍵區域後,程序將呼叫leave region,該區域將0儲存在鎖中。與所有基於關鍵區域的解決方案一樣,程序必須在正確的時間呼叫進入區域和離開區域,以便方法工作。如果一個程序作弊,互斥將失敗。換言之,關鍵區域只有在程序合作的情況下才能發揮作用。
TSL的另一條指令是XCHG,自動交換兩個位置的內容,例如暫存器和記憶體字。程式碼如下所示,可以看出,它與TSL的解決方案基本相同。所有Intel x86 CPU都使用XCHG指令進行低階同步。
一些看似簡單快捷的操作實際上並不是執行緒安全的。即使是簡單的C變數增量(x++)也不是執行緒或多處理器安全的。例如,考慮在兩個處理器上並行執行的兩個執行緒,它們對同一記憶體位置執行增量(下圖)。
即使是簡單的增量也需要讀寫。在上圖中,每個執行緒可以將初始值(0)讀入CPU暫存器,每個執行緒遞增其處理器的暫存器,然後寫回結果,寫入X的最終結果是1而不是2。該圖是一個粗略的簡化,因為還有其他因素在起作用,如CPU快取。但即使忽略這一點,也明顯是一場資料爭用。事實上,其中一個執行緒(比如T2)可能被搶佔(例如在R遞增之後),並且當T1繼續遞增X時,一旦T2接收到CPU時間,它將1寫回X,明顯地終止執行緒T1所做的所有遞增。Windows常用的原子操作API如下:
LONG InterlockedIncrement([in, out] LONG volatile *Addend);
SHORT InterlockedIncrement16([in, out] SHORT volatile *Addend);
LONG64 InterlockedIncrement64([in, out] LONG64 volatile *Addend);
LONG InterlockedIncrementNoFence(_Inout_ LONG volatile *Addend);
LONG InterlockedDecrement([in, out] LONG volatile *Addend);
LONG InterlockedDecrement16([in, out] LONG volatile *Addend);
LONG InterlockedDecrement64([in, out] LONG volatile *Addend);
LONG InterlockedOr([in, out] LONG volatile *Destination, [in] LONG Value);
LONG InterlockedExchange([in, out] LONG volatile *Target, [in] LONG Value);
// 將指定的32位元變數的值作為原子操作遞增(加1), 使用獲取記憶體順序語意執行。
LONG InterlockedIncrementAcquire(_Inout_ volatile *Addend);
LONG InterlockedIncrementRelease(_Inout_ LONG volatile *Addend);
(...)
對於簡單的情況,例如整數增量,互鎖函數族非常適用。然而,對於其他操作,需要更通用的機制。臨界區(Critical Section)是基於最多一個執行緒獲取鎖的經典同步機制。我們需要具備四個條件才能找到一個好的解決方案:
1、臨界區域內不可能同時存在兩個程序。
2、不能對速度或CPU數量進行假設。
3、在其臨界區域之外執行的任何程序都不會阻塞任何程序。
4、任何程序都不應該永遠等待進入其關鍵區域。
抽象地說,我們想要的行為如下圖所示。過程A在時間T1進入其臨界區,稍後,時間T2,過程B試圖進入其臨界區域,但失敗了,因為另一個過程已經在其臨界區,我們一次只允許一個程序。因此,當A離開其臨界區域時,B暫時暫停,直到時間T3,允許B立即進入。最終B離開(在T4),我們回到了最初的情況,在其關鍵區域沒有過程。
一旦一個執行緒獲得了一個特定的鎖,其他執行緒就無法獲得同一個鎖,直到首先獲得它的執行緒釋放它。只有這樣,等待執行緒中的一個(並且只有一個)才能獲得鎖。意味著在任何給定時刻,只有一個執行緒獲得了鎖。(下圖)
獲取鎖的執行緒也是其所有者,意味著兩件事:
1、所有者執行緒是唯一可以釋放關鍵部分的執行緒。
2、如果所有者執行緒第二次(遞迴地)嘗試獲取臨界區,則它會自動成功,並遞增內部計數器。意味著所有者執行緒現在必須釋放臨界區相同的次數才能真正釋放它。
獲取鎖和釋放鎖之間的程式碼稱為臨界區域(critical region)。
Windows涉及臨界區的常用API如下所示:
// 初始化臨界區
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
BOOL InitializeCriticalSectionAndSpinCount(LPCRITICAL_SECTION lpCriticalSection, DWORD dwSpinCount);
BOOL InitializeCriticalSectionEx(LPCRITICAL_SECTION lpCriticalSection, DWORD dwSpinCount, DWORD Flags);
// 刪除臨界區
void DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
// 進入臨界區
void EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
// 離開臨界區
void LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
由於進入和離開臨界區必須成對出現,我們可以使用RAII機制來確保這一點,範例程式碼:
struct AutoCriticalSection
{
AutoCriticalSection(CRITICAL_SECTION& cs)
: _cs(cs)
{
::EnterCriticalSection(&_cs);
}
~AutoCriticalSection()
{
::LeaveCriticalSection(&_cs);
}
// delete copy ctor, move ctor, assignment operators
AutoCriticalSection(const AutoCriticalSection&) = delete;
AutoCriticalSection& operator=(const AutoCriticalSection&) = delete;
AutoCriticalSection(AutoCriticalSection&&) = delete;
AutoCriticalSection& operator=(AutoCriticalSection&&) = delete;
private:
CRITICAL_SECTION& _cs;
};
當然,也可以將臨界區封裝成一個C++物件,以便提供更友好、安全的存取介面:
class CriticalSection : public CRITICAL_SECTION
{
public:
CriticalSection(DWORD spinCount = 0, DWORD flags = 0)
{
::InitializeCriticalSectionEx(this, (DWORD)spinCount, flags);
}
~CriticalSection()
{
::DeleteCriticalSection(this);
}
void Lock()
{
::EnterCriticalSection(this);
}
void Unlock()
{
::LeaveCriticalSection(this);
}
bool TryLock()
{
return ::TryEnterCriticalSection(this);
}
};
臨界區的問題:考慮一個由n個程序(P0,P1,……,Pn-1)組成的系統,每個程序都有一段程式碼,這段程式碼稱為臨界區,其中程序可能會更改公共變數、更新表格、寫入檔案等。系統的重要特徵是,當程序在其臨界區執行時,不允許其他程序在其重要部分執行,程序對臨界區的執行是相互排斥的。
臨界區問題是設計一個協定,程序可以使用該協定來合作,每個程序必須請求許可才能進入其臨界區。實現此請求的程式碼部分是入口區,出口區遵循臨界段,剩餘的程式碼是剩餘區。
臨界區問題的解必須滿足以下三個條件:
硬體互斥方法如下:
在單處理器機器中,並行程序不能重疊,只能交錯。此外,程序將繼續執行,直到它呼叫作業系統服務或被中斷。因此,為了保證互斥,只要防止程序被中斷就足夠了。此功能可以以系統核心定義的原語形式提供,用於禁用和啟用中斷。使用鎖解決臨界區問題:
do
{
acquire lock
critical section;
release lock
remainder section;
} while (TRUE);
因為臨界區不能被中斷,所以可以保證互斥。
缺點是它只能在單處理器環境中工作,如果不及時維護,中斷可能會丟失,等待進入臨界區的程序可能會捱餓。
它是用於避免相互排斥的特殊機器指令,測試和設定指令可定義如下:
boolean TestAndSet (boolean *target)
{
boolean rv = *target;
*target = TRUE;
return rv:
}
上述功能自動執行。使用TestAndSet的解決方案,共用布林變數鎖已初始化為false:
do
{
while ( TestAndSet (&lock ))
; // do nothing
// critical section
lock = FALSE;
// remainder section
} while (TRUE);
優勢:簡單易驗證,適用於任何數量的程序,可用於支援多個臨界區。缺點:可能會出現繁忙等待,可能出現飢餓,可能出現死鎖。
void Swap (boolean *a, boolean *b)
{
boolean temp = *a;
*a = *b;
*b = temp:
}
共用布林變數鎖初始化為FALSE,每個程序都有一個區域性布林變數鍵:
do
{
key = TRUE;
while( key == TRUE)
Swap(&lock, &key);
// critical section
lock = FALSE;
// remainder section
} while (TRUE);
帶TestandSet()的有界等待互斥:
do
{
waiting[i] = TRUE;
key = TRUE;
while (waiting[i] && key)
key = TestAndSet(&lock);
waiting[i] = FALSE;
// critical section
j = (i + 1) % n;
while ((j != i) && !waiting[j])
j = (j + 1) % n;
if (j == i)
lock = FALSE;
else
waiting[j] = FALSE;
// remainder section
} while (TRUE);
彼得森(Peterson)的解決方案:互斥問題是設計一個預協定(或入口協定)和一個後協定(或現有協定),以防止兩個或多個執行緒同時處於其臨界區。Tanenbaum研究了臨界區問題或互斥問題的建議。問題是當一個程序更新其臨界區中的共用可修改資料時,不應允許其他程序進入其臨界區。臨界區的建議如下:
禁用中斷(硬體解決方案)。
每個程序在進入其臨界區後禁用所有中斷,並在離開臨界區前重新啟用所有中斷。中斷關閉後,CPU無法切換到其他程序。因此,沒有任何其他程序會進入臨界區的互斥狀態。
禁用中斷有時是一種有用的中斷,有時是作業系統核心中的一種有用技術,但它不適合作為使用者程序的一般互斥機制。原因是,給使用者程序關閉中斷的權力是不明智的。
鎖定變數(軟體解決方案)。
在這個解決方案中,我們考慮一個單一的共用(鎖)變數,初始值為0。當一個程序想要進入其臨界區時,它首先測試鎖。如果lock為0,程序首先將其設定為1,然後進入臨界區。如果鎖已經是1,則程序只會等待(lock)變數變為0。因此,0表示其臨界區沒有程序,1表示某些程序在其臨界區。
這個建議中的缺陷可以用例子來解釋。假設程序A看到鎖為0,在它可以將鎖設定為1之前,另一個程序B被排程、執行並將鎖設定成1。當程序A再次執行時,它也會將鎖設定到1,並且兩個程序將同時處於其臨界區。
嚴格的替代方案。
在這個建議的解決方案中,整數變數「turn」跟蹤誰將進入臨界區。最初,程序A檢查回合,發現它為0,並進入其臨界區。程序B還發現它為0,並處於迴圈中,不斷測試「turn」以檢視它何時變為1,不斷測試等待某個值出現的變數稱為忙碌等待(Busy-Waiting)。
當其中一個程序比另一個慢得多時,輪流進行並不是一個好主意。假設程序0很快完成了它的臨界區,所以這兩個程序現在都處於非臨界區,這種情況違反了上述條件3(限制等待)。
使用臨界區保護共用資料不受並行存取的影響效果很好,但這是一種悲觀的機制——最多允許一個執行緒存取共用資料。在某些情況下,一些執行緒讀取資料,而其他執行緒寫入資料,可以進行優化:如果一個執行緒讀取該資料,則沒有理由阻止僅讀取資料的其他執行緒同時執行,亦即「單寫入多讀取」機制。Windows API提供了表示這種鎖的SRWLOCK結構(S表示「Slim」),其定義和相關API如下:
typedef struct _RTL_SRWLOCK
{
PVOID Ptr;
} RTL_SRWLOCK, *PRTL_SRWLOCK;
typedef RTL_SRWLOCK SRWLOCK, *PSRWLOCK;
void InitializeSRWLock(_Out_ PSRWLOCK SRWLock);
void AcquireSRWLockShared(_InOut_ PSRWLOCK SRWLock);
void AcquireSRWLockExclusive(_InOut_ PSRWLOCK SRWLock);
void ReleaseSRWLockShared(_Inout_ PSRWLOCK SRWLock);
void ReleaseSRWLockExclusive(_Inout_ PSRWLOCK SRWLock);
讀寫鎖也可以用RAII封裝起來:
class ReaderWriterLock : public SRWLOCK
{
public:
ReaderWriterLock();
ReaderWriterLock(const ReaderWriterLock&) = delete;
ReaderWriterLock& operator=(const ReaderWriterLock&) = delete;
void LockShared();
void UnlockShared();
void LockExclusive();
void UnlockExclusive();
};
struct AutoReaderWriterLockExclusive
{
AutoReaderWriterLockExclusive(SRWLOCK& lock);
~AutoReaderWriterLockExclusive();
private:
SRWLOCK& _lock;
};
struct AutoReaderWriterLockShared
{
AutoReaderWriterLockShared(SRWLOCK& lock);
~AutoReaderWriterLockShared();
private:
SRWLOCK& _lock;
};
// 相關介面的實現
ReaderWriterLock::ReaderWriterLock()
{
::InitializeSRWLock(this);
}
void ReaderWriterLock::LockShared()
{
::AcquireSRWLockShared(this);
}
void ReaderWriterLock::UnlockShared()
{
::ReleaseSRWLockShared(this);
}
void ReaderWriterLock::LockExclusive()
{
::AcquireSRWLockExclusive(this);
}
void ReaderWriterLock::UnlockExclusive()
{
::ReleaseSRWLockExclusive(this);
}
AutoReaderWriterLockExclusive::AutoReaderWriterLockExclusive(SRWLOCK& lock)
: _lock(lock)
{
::AcquireSRWLockExclusive(&_lock);
}
AutoReaderWriterLockExclusive::~AutoReaderWriterLockExclusive()
{
::ReleaseSRWLockExclusive(&_lock);
}
AutoReaderWriterLockShared::AutoReaderWriterLockShared(SRWLOCK& lock)
: _lock(lock)
{
::AcquireSRWLockShared(&_lock);
}
AutoReaderWriterLockShared::~AutoReaderWriterLockShared()
{
::ReleaseSRWLockShared(&_lock);
}
條件變數(Condition Variable)是另一種同步機制,提供了等待臨界區或SRW鎖的能力,直到出現某種條件。條件變數的一個經典應用範例是生產者/消費者場景。假設一些執行緒生成資料項並將它們放置在佇列中,每個執行緒都執行生成專案(item)所需的任何工作,同時,其他執行緒充當消費者——每個執行緒從佇列中刪除一個專案,並以某種方式處理它(下圖)。
如果專案的生產速度快於消費者的處理速度,則佇列不為空,消費者繼續工作。另一方面,如果消費者執行緒處理所有專案,它們應該進入等待狀態,直到生成新的專案,在這種情況下,它們應該被喚醒——正是條件變數提供的行為。與之無關的使用者執行緒(佇列為空)不應自旋(spin),定期檢查佇列是否變為非空,因為會毫無理由地消耗CPU週期。條件變數允許高效等待(不消耗CPU),直到執行緒被喚醒(通常由生產者執行緒喚醒)。
Windows的條件變數由CONDITION_VARIABLE不透明結構表示,使用類似於SRWLOCK,相關API如下:
void InitializeConditionVariable(PCONDITION_VARIABLE ConditionVariable);
BOOL SleepConditionVariableCS(PCONDITION_VARIABLE ConditionVariable, PCRITICAL_SECTION CriticalSection, DWORD dwMilliseconds);
BOOL SleepConditionVariableSRW(PCONDITION_VARIABLE ConditionVariable, PSRWLOCK SRWLock, DWORD dwMilliseconds, ULONG Flags);
VOID WakeConditionVariable (PCONDITION_VARIABLE ConditionVariable);
VOID WakeAllConditionVariable (PCONDITION_VARIABLE ConditionVariable);
執行緒一旦被喚醒,將重新獲取同步物件並繼續執行。此時,執行緒應該重新檢查它等待的條件,如果不滿足,則再次呼叫Sleep*函數。如下圖所示(使用臨界區)。
上圖涉及的步驟如下:
1、使用者執行緒獲取臨界區。
2、執行緒檢查是否可以繼續,例如可以檢查應該處理的佇列是否為空。
3、如果它為空,則執行緒呼叫SleepConditionVariableCS,將釋放臨界區(以便另一個執行緒可以獲取它)並進入睡眠(等待狀態)。
4、在某些時候,生產者執行緒將通過呼叫WakeConditionVariable來喚醒消費者執行緒,例如向佇列中新增了一個新的項。
5、SleepConditionVariableCS返回,獲取臨界區並返回檢查是否可以繼續。如果沒有,它將繼續等待。
6、現在可以繼續了,執行緒可以執行它的工作(例如從佇列中刪除項)。臨界區仍然保留。
7、最後,工作完成,臨界區分必須釋放。
Windows 8和Server 2012新增了另一種同步機制,允許執行緒高效地等待,直到某個地址的值更改為所需值,然後它可以醒來繼續工作。當然可以使用其他同步機制來實現類似的效果,例如使用條件變數,但等待地址(Waiting on Address)更有效,並且不容易死鎖,因為沒有直接使用臨界區(或其他軟體同步原語)。執行緒可以通過呼叫WaitOnAddress進入等待狀態,直到某個值出現在「受監視」資料上,相關API:
BOOL WaitOnAddress(volatile VOID* Address, PVOID CompareAddress, SIZE_T AddressSize, DWORD dwMilliseconds);
VOID WakeByAddressSingle(_In_ PVOID Address);
VOID WakeByAddressAll(_In_ PVOID Address);
Windows 8中引入的另一個同步原語是同步屏障(Synchronization Barrier),它允許同步執行緒,這些執行緒需要到達工作中的某個點才能繼續。例如,假設系統有幾個部分,在主應用程式程式碼可以繼續之前,每個部分都需要分兩個階段初始化。實現這一點的一種簡單方法是順序呼叫每個初始化函數:
void RunApp()
{
// phase 1
InitSubsystem1();
InitSubsystem2();
InitSubsystem3();
InitSubsystem4();
// phase 2
InitSubsystem1Phase2();
InitSubsystem2Phase2();
InitSubsystem3Phase2();
InitSubsystem4Phase2();
// go ahead and run main application code...
}
雖然以上可行,但是如果每個初始化都可以同時進行,那麼每個初始化都由不同的執行緒執行。在所有其他執行緒完成phase 1之前,每個執行緒不得繼續進行phase 2初始化。當然,可以通過使用其他同步原語的組合來實現這種方案,但已經存在用於這種目的的同步屏障。Windows使用SYNCHRONIZATION_BARRIER不透明結構表示,且使用InitializeSynchronizationBarrier進行初始化,相關API:
BOOL InitializeSynchronizationBarrier(LPSYNCHRONIZATION_BARRIER lpBarrier, LONG lTotalThreads, LONG lSpinCount);
BOOL EnterSynchronizationBarrier(LPSYNCHRONIZATION_BARRIER lpBarrier, DWORD dwFlags);
該函數僅在釋放屏障後,對單個執行緒返回TRUE,對所有其他執行緒返回FALSE。在前面描述的場景中,以下是在單獨執行緒中執行的初始化函數之一:
DWORD WINAPI InitSubSystem1(PVOID p)
{
auto barrier = (PSYNCHRONIZATION_BARRIER)p;
// phase 1
printf("Subsystem 1: Starting phase 1 initialization (TID: %u)...\n", ::GetCurrentThreadId());
// do work...
printf("Subsystem 1: Ended phase 1 initialization...\n");
// 進入屏障
::EnterSynchronizationBarrier(barrier, 0);
printf("Subsystem 1: Starting phase 2 initialization...\n");
// do work
printf("Subsystem 1: Ended phase 2 initialization...\n");
return 0;
}
phase 1初始化完成後,呼叫EnterSynchronizationBarrier,等待所有其他執行緒完成phase 1初始化。主函數可以這樣編寫:
SYNCHRONIZATION_BARRIER sb;
// 初始化屏障
InitializeSynchronizationBarrier(&sb, 4, -1);
LPTHREAD_START_ROUTINE functions[] = {InitSubSystem1, InitSubSystem2, InitSubSystem3, InitSubSystem4};
printf("System initialization started\n");
HANDLE hThread[4];
int i = 0;
for (auto f : functions)
{
hThread[i++] = ::CreateThread(nullptr, 0, f, &sb, 0, nullptr);
}
// 等待所有屏障
::WaitForMultipleObjects(_countof(hThread), hThread, TRUE, INFINITE);
printf("System initialization complete\n");
// close thread handles...
程序間通訊(Inter process communication,IPC)是一種允許程序相互通訊並同步其操作的機制。這些程序之間的通訊可以看作是它們之間合作的一種方法,作業系統中並行執行的程序可以是獨立程序,也可以是共同作業程序。如果一個程序不能影響或不受系統中執行的其他程序的影響,則該程序是獨立的,任何不與任何其他程序共用資料的程序都是獨立的。如果一個程序可以影響或受到系統中執行的其他程序的影響,那麼它就是在合作。顯然,任何與其他程序共用資料的程序都是一個共同作業程序。程序合作的原因:
程序間通訊機制。
程序間通訊有兩種基本模型:
訊息傳遞範例。
Solaris同步資料結構。
Windows同步物件。
非直接程序通訊。
在共用記憶體系統中,使用共用記憶體的程序間通訊要求通訊程序建立共用記憶體區域。通常,共用記憶體區域駐留在建立共用記憶體段的程序的地址空間中。其他希望使用此共用記憶體段進行通訊的程序必須將其連線到其地址空間,通常作業系統會嘗試阻止一個程序存取另一個程序的記憶體。共用記憶體要求兩個或更多程序同意刪除此限制。然後,他們可以通過讀取和寫入共用區域中的資料來交換資訊,資料的形式和位置由這些程序決定,不受作業系統的控制。這些程序還負責確保它們不會同時寫入同一位置。
競爭條件(Race Condition)的情況是多個程序同時存取和操作相同的資料,執行結果取決於存取發生的特定順序。假設兩個程序P1和P2共用全域性變數a,在執行過程中,P1會將a更新為值1,而在執行過程的某個時刻,P2會將a更新為值2,因此,這兩個任務正在競爭地寫入變數a。在本例中,比賽的「失敗者」(最後更新的程序)決定a的最終值。
因此,作業系統關注以下事項:
程序互動可以定義為相互之間的不感知、間接感知和直接感知。並行程序在以下情形之一會發生衝突:
程序經常需要與其他程序進行通訊,最好是以結構良好的方式進行,而不是使用中斷。簡單地說,有三個問題:
1、一個程序如何向另一個程序傳遞資訊。
2、與確保兩個或多個程序不會相互妨礙有關,例如,航空公司預訂系統中的兩個程序,每個程序都試圖為不同的客戶搶佔飛機上的最後一個座位。
3、涉及存在依賴項時的正確排序:如果程序A生成資料,程序B列印它們,B必須等到A生成了一些資料後才能開始列印。
同樣重要的是,其中兩個問題同樣適用於執行緒。。
Windows核心物件相關的最重要的幾點描述如下:
一些核心物件更為特殊,稱為排程物件(dispatcher object)或可等待物件(waitable object)。此類物件可以處於兩種狀態之一:有訊號(signaled)或無訊號(non-signaled)。有訊號和非訊號的含義取決於物件的型別,下表總結了常見排程物件的這些狀態的含義。
物件型別 | 有訊號 | 無訊號 |
---|---|---|
程序(Process) | Exited/Terminated | Running |
執行緒(Thread) | Exited/Terminated | Running |
作業(Job) | 已達到作業結束時間 | 未達到或未設定限制 |
互斥體(Mutex) | 免費(無擁有者) | 被擁有 |
號誌(Semaphore) | 計數大於0 | 計數等於0 |
事件(Event) | 事件被設定 | 事件未被設定 |
檔案(File) | I/O操作完成 | I/O操作正在進行或未開始 |
可等待計時器(Waitable Timer) | 計時器計數已過期 | 計時器計數未過期 |
I/O完成 | 非同步I/O操作已完成 | 非同步I/O操作未完成 |
等待物件發出訊號通常由以下兩個功能之一完成(I/O完成埠除外,該埠具有自己的等待功能):
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);
DWORD WaitForMultipleObjects(DWORD nCount, CONST HANDLE* lpHandles, BOOL bWaitAll, DWORD dwMilliseconds);
WaitForSingleObject可能有四個返回值:
如果等待函數成功,因為一個或多個物件發出訊號,執行緒將被喚醒並可以繼續執行。剛剛發出訊號的物件是否仍處於訊號狀態?取決於物件的型別。某些物件仍保持其訊號狀態,例如程序和執行緒。一個程序一旦退出或終止,就會發出訊號,並在其剩餘生命週期內保持這種狀態(當該程序有開啟的控制程式碼時)。
某些型別的物件可能在成功等待後改變其訊號狀態。例如,對互斥體的成功等待會將其返回到無訊號狀態。另一個在發出訊號時表現出特殊行為的物件是自動重置事件。當發出訊號時,它會釋放一個執行緒(並且只釋放一個),當這種情況發生時,它的狀態會自動切換到無訊號狀態。
如果多個執行緒等待同一個互斥體,並且它發出訊號,會發生什麼?只有一個執行緒可以在互斥體翻轉回無訊號狀態之前獲取互斥體。在背後,物件的等待執行緒儲存在先進先出(FIFO)佇列中,因此佇列中的第一個執行緒是被喚醒的執行緒(無論其優先順序如何)。但是,不應依賴這種行為。一些內部機制可能會使執行緒不再等待(例如,如果執行緒被掛起,例如使用偵錯程式),然後當執行緒恢復時,它將被推到佇列的後面。所以這裡的簡單規則是,無法確定哪個執行緒將首先喚醒。該演演算法可能在未來的Windows版本中隨時更改。
互斥體(mutex,mutual exclusion的縮寫)也常被稱為互斥鎖、互斥量、互斥物件等,提供了與臨界區類似的功能,保護共用資料免受並行存取。一次只有一個執行緒可以成功獲取互斥體,並繼續存取共用資料。等待互斥體的所有其他執行緒必須繼續等待,直到獲取執行緒釋放互斥體。Windows的相關API:
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName);
HANDLE CreateMutexEx(LPSECURITY_ATTRIBUTES lpMutexAttributes, LPCTSTR lpName, DWORD dwFlags, DWORD dwDesiredAccess);
HANDLE OpenMutexW(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCWSTR lpName);
BOOL ReleaseMutex(_In_ HANDLE hMutex);
如果擁有互斥的執行緒退出或終止(無論出於什麼原因),會發生什麼?由於互斥體的所有者是唯一可以釋放互斥體,可能會導致死鎖,其他等待互斥的執行緒永遠不會獲取它。這種互斥體稱為廢棄互斥體(abandoned mutex),是由其所有者執行緒廢棄的。
幸運的是,核心知道互斥體的所有權,因此如果看到一個執行緒在持有互斥體時終止(如果是這樣的話,則會有多個),核心會顯式釋放被放棄的互斥體。這會導致成功獲取互斥鎖的下一個執行緒從其WaitForSingleObject呼叫中取回WAIT_ABANDONED,而不是WAIT_OBJECT_0。意味著執行緒正常獲取互斥體,但特殊返回值用作提示,以指示前一個所有者在終止前沒有釋放互斥體。
號誌(Semaphore)是一個整數變數,除了初始化之外,只能通過兩個標準原子操作存取:wait()和signal()。wait()操作最初稱為P,signal()最初稱為V。
P和V操作示意圖。圖中顯示了一個任務T0執行的P()函數呼叫序列,以及另一個任務或ISR對同一號誌執行的V()函數。
號誌作為通用同步工具:
// initialized
do
{
wait (mutex);
// Critical Section
signal (mutex);
} while (TRUE);
號誌實現:必須保證沒有兩個程序可以同時對同一號誌執行wait()和signal()。因此,實現成為臨界區問題,其中等待和訊號程式碼放置在臨界區。現在可以使用忙碌等待臨界區的實現,實現程式碼很短,如果臨界區很少被佔用,則很少忙碌等待。請注意,應用程式可能會在臨界區花費大量時間,因此不是一個好的解決方案。
無忙碌等待的號誌實現:每個號誌都有一個相關的等待佇列,等待佇列中的每個條目都有兩個資料項:值(整數型別)和指向列表中下一條記錄的指標。有兩種操作:阻塞——將呼叫操作的程序放在適當的等待佇列中;喚醒——刪除等待佇列中的一個程序,並將其放入就緒佇列。
// 等待實現
wait(semaphore *S)
{
S->value--;
if (S->value < 0)
{
add this process to S->list;
block();
}
}
// 訊號實現
signal(semaphore *S)
{
S->value++;
if (S->value <= 0)
{
remove a process P from S->list;
wakeup(P);
}
}
號誌不被硬體支援,但有幾個吸引人的特性:
號誌的不足:
雖然號誌為程序同步提供了一種方便而有效的機制,但錯誤地使用它們會導致難以檢測的定時錯誤,因為這些錯誤只有在發生特定的執行序列時才會發生,而這些序列並不總是發生。
號誌機制示意圖。
程序存取受號誌保護的共用資料。
監視器是一種程式語言結構,提供與號誌等效的功能,並且更易於控制。監視器結構已經在許多程式語言中實現,包括Concurrent Pascal、Pascal Plus、Modula-2、Modula-3和Java。抽象資料型別或ADT用一組函數封裝資料,以對該資料進行操作,這些函數獨立於ADT的任何特定實現。監視器型別是一種ADT,包含一組程式設計師定義的操作,這些操作在監視器中互斥。監視器型別還宣告其值定義該型別範例狀態的變數,以及操作這些變數的函數體。它也被實現為一個程式庫,允許程式設計師在任何物件上放置監視器鎖。監視器語法:
monitor monitor_name
{
/* shared variable declarations */
function P1(...)
{
...
}
function P2 (...)
{
...
}
...
function Pn (...)
{
...
}
initialization code (...)
{
...
}
}
因此,在監控器中定義的函數只能存取監控器中區域性宣告的變數及其形式引數。類似地,監視器的區域性變數只能由區域性函數存取。
在Windows中,號誌的作用是以執行緒安全的方式限制某些東西。號誌用當前和最大計數初始化。只要其當前計數高於零,它就處於訊號狀態。每當一個執行緒呼叫號誌上的WaitForSingleObject並且它處於訊號狀態時,號誌的計數就會減少,並且允許執行緒繼續。一旦號誌計數達到零,它就變成無訊號的,任何試圖等待它的執行緒都將阻塞。相反,想要「釋放」一個號誌計數(或更多)的執行緒呼叫ReleaseSemaphore
,導致號誌計數增加並再次將其設定為有訊號狀態。
HANDLE CreateSemaphore( LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, ...);
HANDLE CreateSemaphoreEx( LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, ...);
BOOL ReleaseSemaphore( HANDLE hSemaphore, ...);
在某種意義上,事件(Event)是最簡單的同步原語——它只是一個可以設定(訊號狀態)或重置(非訊號狀態)的標誌。作為(可能命名的)核心物件,它具有在單個程序內或跨程序工作的靈活性。與事件相關聯的複雜性在於有兩種型別的事件:手動重置和自動重置。下表總結了它們的特性。
事件型別 | 核心名稱 | SetEvent效果 |
---|---|---|
手工重置 | 通知 | 將事件置於訊號狀態,並釋放等待它的所有執行緒,事件保持在訊號狀態。 |
自動重置 | 同步 | 單個執行緒從等待中釋放,然後事件自動返回到無訊號狀態。 |
Windows相關的API:
HANDLE CreateEvent( LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset, BOOL bInitialState, LPCTSTR lpName);
HANDLE CreateEventEx( LPSECURITY_ATTRIBUTES lpEventAttributes, LPCTSTR lpName, DWORD dwFlags, DWORD dwDesiredAccess);
HANDLE OpenEvent( DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);
BOOL SetEvent(_In_ HANDLE hEvent); // signaled
BOOL ResetEvent(_In_ HANDLE hEvent); // non-signaled
BOOL PulseEvent(_In_ HANDLE hEvent);
Windows API提供了對具有不同語意和程式設計模型的多個計時器的存取。主要有:
相關API:
HANDLE CreateWaitableTimer( LPSECURITY_ATTRIBUTES lpTimerAttributes, BOOL bManualReset, LPCTSTR lpTimerName);
HANDLE CreateWaitableTimerEx( LPSECURITY_ATTRIBUTES lpTimerAttributes, LPCTSTR lpTimerName, DWORD dwFlags, DWORD dwDesiredAccess);
HANDLE OpenWaitableTimer( DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpTimerName);
BOOL SetWaitableTimer( HANDLE hTimer, const LARGE_INTEGER* lpDueTime, LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine, LPVOID lpArgToCompletionRoutine, BOOL fResume);
BOOL SetWaitableTimerEx( HANDLE hTimer, const LARGE_INTEGER* lpDueTime, LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine, LPVOID lpArgToCompletionRoutine, PREASON_CONTEXT WakeContext, ULONG TolerableDelay);
訊息傳遞允許程序進行通訊並同步其操作,而無需共用相同的地址空間。在分散式環境中特別有用,其中通訊程序可能位於通過網路連線的不同計算機上。訊息傳遞設施至少提供兩種操作:傳送(資訊)和接收(訊息)。如果P和Q希望通訊,他們需要在他們之間建立通訊鏈路,通過傳送/接收交換訊息。實現通訊鏈路物理(如共用記憶體、硬體匯流排)邏輯(如邏輯屬性)。
如果是直接通訊,程序必須明確命名:
通訊鏈路的屬性,自動建立連結,連結只與一對通訊程序相關聯,每對之間只有一條鏈路,連結可能是單向的,但通常是雙向的。
如果是間接通訊,郵件從郵箱(也稱為埠)定向和接收,每個郵箱都有唯一的id,程序只有在共用郵箱時才能通訊。通訊鏈路的屬性,僅當程序共用公共郵箱時才建立連結,連結可能與許多程序關聯。每對程序可以共用多個通訊鏈路,連結可以是單向的或雙向的。
對應訊息的同步,訊息傳遞可以是阻塞的或非阻塞的,阻塞被認為是同步的,阻止傳送會阻止發件人,直到收到訊息。阻止接收會阻止接收器,直到訊息可用,非阻塞被認為是非同步的,非阻塞傳送讓傳送方傳送訊息並繼續,非阻塞接收使接收器接收到有效訊息或空。
連線到連結的訊息佇列以三種方式之一實現:
儘管號誌為搶佔式多工提供了最強大的資料結構,但它們只是偶爾顯式使用。更常見的是,它們被另一種稱為佇列的資料結構隱藏。佇列也稱為FIFO(先進先出),是至少提供兩個函數的緩衝區:Put()和Get()。儲存在佇列中的專案的大小可能會有所不同,因此queue最好作為模板類實現。專案的數量也可能不同,因此類別建構函式將使用所需的長度作為引數。
佇列的最簡單形式是環形緩衝區(Ring Buffer)。記憶體的連續部分(稱為Buffer)被分配,兩個變數GetIndex和PutIndex被初始化為0,從而指向記憶體空間的開始。對GetIndex和PutIndex執行的唯一操作是遞增它們。如果它們碰巧超過了記憶體的末尾,它們將被重置為開始。這種在結尾處的纏繞將筆直的記憶變成了一個環。當且僅當GetIndex=PutIndex時,緩衝區為空。
否則,PutIndex總是領先於GetIndex(儘管如果PutIndex末尾已經環繞,而GetIndex尚未環繞,則PutIndexe可能小於GetIndex)。在下圖中,環形緩衝區顯示為直記憶體和邏輯環。
可以使用環形緩衝區來放入或獲取號誌,以實現無鎖同步。
死鎖的定義:在多道程式設計環境中,幾個程序可能會競爭有限數量的資源。程序請求資源時如果資源不可用,程序將進入等待狀態。有時,等待程序再也無法更改狀態,因為它請求的資源由其他等待程序持有。這種情況稱為死鎖(Deadlock)。
死鎖場景示意圖。
系統可能由有限數量的資源組成,並分佈在多個程序中,這些資源被劃分為多個範例,每個範例都具有相同的範例。程序必須在使用資源之前請求資源,並且必須在使用後釋放資源,可以請求任意數量的資源來執行指定的任務,請求的資源量不得超過可用資源的總數。程序只能按以下順序使用資源:
死鎖可能涉及不同型別的資源。範例:考慮一個有一臺印表機和一個磁帶驅動器的系統。如果程序Pi當前持有印表機,程序Pj持有磁帶驅動器。如果程序Pi請求磁帶驅動器,而程序Pj請求印表機,則會發生死鎖。多執行緒程式很可能會出現死鎖,因為它們會爭奪共用資源。
死鎖的具體範例。
處理死鎖的方法有:
為了確保死鎖永遠不會發生,系統可以使用死鎖避免或死鎖預防。死鎖預防是一套確保至少一種必要條件不會發生的方法。死鎖避免要求作業系統提前獲得有關程序在其生存期內將請求和使用哪些資源的資訊。如果系統不使用死鎖避免或死鎖預防,則可能會出現死鎖情況。在此期間,它可以提供一個演演算法來檢查系統狀態,以確定是否發生了死鎖,以及從死鎖中恢復的演演算法。未檢測到的死鎖將導致系統效能下降。
沒有死鎖的具體範例。
要發生死鎖,必須滿足以下所有4個必要條件。只要有一個條件不成立,那麼我們就可以防止死鎖的發生。
互斥。適用於不可共用的資源,如一臺印表機一次只能由一個程序使用。可共用資源中不可能存在互斥,因此它們不會陷入死鎖。唯讀檔案是共用資源的好例子,程序從不等待存取可共用資源。因此,我們不能通過否認不可共用資源中的互斥條件來防止死鎖。
保留並等待。當程序請求資源(即不可用)時,可以通過強制程序釋放其持有的所有資源來消除這種情況。可以使用的一種協定是,每個程序在開始執行之前都會分配其所有資源。例如:考慮一個將資料從磁帶機複製到磁碟,對檔案進行排序,然後將結果列印到印表機的過程。如果在開始時分配了所有資源,則會將磁帶驅動器、磁碟檔案和印表機分配給程序。主要問題是它導致資源利用率低,因為最終需要印表機,並且從一開始就分配給它,這樣其他程序就無法使用它。
另一個可使用的協定是,當程序沒有資源時,允許程序請求資源。例如,程序分配有磁帶驅動器和磁碟檔案,它執行所需的操作並釋放兩者,然後,該過程再次請求磁碟檔案和印表機,但是可能導致飢餓問題。
非搶佔式。為了確保這種情況永遠不會發生,必須搶佔資源。可以使用以下協定:如果一個程序持有某些資源並請求另一個無法立即分配給它的資源,那麼請求程序當前持有的所有資源都會被搶佔,並新增到其他程序可能正在等待的資源列表中。只有當程序重新獲得其請求的舊資源和新資源時,才會重新啟動該程序。
當流程請求資源時,我們檢查它們是否可用。如果它們可用,我們就分配它們,否則我們會檢查它們是否分配給其他等待程序。如果是這樣,我們會搶佔等待程序中的資源,並將其分配給請求程序。請求程序必須等待。
迴圈等待。死鎖的第四個也是最後一個條件是迴圈等待條件,確保永遠不會出現這種情況的一種方法是對所有資源型別強制排序,並且每個程序以遞增的順序請求資源。
避免死鎖(僅概念):死鎖預防演演算法可能導致裝置利用率低,並降低系統吞吐量。避免死鎖需要關於如何請求資源的附加資訊。瞭解請求和釋出的完整序列後,我們可以決定每個請求的程序是否應該等待。對於每個請求,它都需要檢查當前可用的資源,當前分配給每個程序的資源將處理每個程序的未來請求和釋放,以決定是否可以滿足當前請求,或者必須等待以避免將來可能出現的死鎖。死鎖避免演演算法動態檢查資源分配狀態,以確保迴圈等待條件永遠不存在。資源分配狀態由可用資源和已分配資源的數量以及每個程序的最大需求定義。
安全狀態:狀態是一種安全狀態,其中至少存在一個順序,所有程序都將完全執行,而不會導致死鎖。如果存在安全序列,則系統處於安全狀態。如果對於每個Pi,Pi可以請求的資源可以由當前可用的資源滿足,則程序式列<P1、P2、…………..Pn>是當前分配狀態的安全序列。如果Pi請求的資源當前不可用,則Pi可以獲得完成其指定任務所需的所有資源。
安全狀態不是死鎖狀態。每當程序請求資源(即當前可用的資源)時,系統必須決定是否可以立即分配資源,或者程序是否必須等待。只有當分配使系統處於安全狀態時,才會批准請求。在這種情況下,如果程序請求資源,即當前可用的資源,則必須等待。因此,資源利用率可能低於沒有死鎖避免演演算法的情況。
資源分配圖演演算法:只有當我們有一個資源型別的範例時,才使用此演演算法。除了請求邊緣和賦值邊緣外,還使用了一個稱為索賠邊緣的新邊緣。例如,結合下圖,其中索賠邊緣由虛線表示,索賠邊緣Pi->Rj表示流程Pi將來可能會請求Rj。當程序Pi請求資源Rj時,宣告邊緣將轉換為請求邊緣。當資源Rj由程序Pi釋放時,分配邊Rj->Pi被宣告邊Pi->Rj替換。
當程序Pi請求Rj時,僅當將請求邊緣Pi->Rj轉換為分配邊緣Rj->Pi不會導致迴圈時,才會批准請求。迴圈檢測演演算法用於檢測迴圈。如果沒有周期,那麼要處理的資源分配將使系統處於安全狀態。
銀行家演演算法適用於具有每個資源型別的多個範例的系統,但其效率低於資源分配圖演演算法。當一個新程序進入系統時,它必須宣告它可能需要的最大資源數,此數位不能超過系統中的資源總數。系統必須確定資源分配是否會使系統處於安全狀態,如果是這樣的話,那麼應該等待程序釋放足夠的資源。使用了幾種資料結構來實現銀行家演演算法。設「n」為系統中的程序數,「m」為資源型別數。我們需要以下資料結構:
安全演演算法用於確定系統是否處於安全狀態,虛擬碼:
// Step 1: 設Work和Finish分別為長度m和n的向量,初始化
Work = Available
For i = 1,2, …, n,
if Allocationi 0, then
Finish[i] = false;
otherwise,
Finish[i] = true
// Step 2: 查詢索引i,以便
Finish[i] == false
Requesti <= Work
If no such i exists then
go to step 4
// Step 3:
Work = Work + Allocation
Finish [i] = true
Go to step 2
//Step 4:
If Finish [i] = false
for some i, 1<=i<= n, then
the system is in a deadlock state
Moreover
if Finish[i] = false then
process Pi is deadlocked
安全狀態的計算過程。
非安全狀態的計算。
資源分配演演算法:請求=流程Pi的請求向量,如果Requesti[j]=k,則程序Pi需要k個資源型別Rj的範例。
步驟1:若Requesti<=Needi,則轉到步驟2。否則,引發錯誤條件,因為程序已超過其最大宣告。
步驟2:如果Requesti<=可用,轉至步驟3。否則,由於資源不可用,Pi必須等待。
步驟3:通過如下修改狀態,假裝將請求的資源分配給Pi:
Available = Available – Request;
Allocationi = Allocationi + Requesti;
Needi = Needi – Requesti;
如果安全的話,資源分配給Pi;如果不安全,Pi必須等待,並恢復舊的資源分配狀態。
死鎖的檢測範例。
從死鎖中恢復:中止所有死鎖程序,一次中止一個程序,直到消除死鎖迴圈。我們應該選擇什麼順序中止?答案是:
資源搶佔:選擇受害者——將成本降至最低;回滾——返回到某個安全狀態,重新啟動該狀態的程序;飢餓——同一程序可能總是被選為受害者,包括成本因素中的回滾次數。
處理臨界區似乎很簡單。然而,即使我們使用各種RAII封裝器,仍然存在死鎖的危險。當擁有鎖1的執行緒A(例如臨界區)等待執行緒B擁有的鎖2,而執行緒B正在等待鎖1時,就會發生典型的死鎖。
避免死鎖的方法另一種簡單的方法:總是以相同的順序獲取鎖,意味著每個需要多個鎖的執行緒應該總是以相同的順序獲取鎖,保證了死鎖不會發生(至少不會因為這些鎖)。實際問題是如何強制順序,無需編寫任何程式碼,只需記錄順序,以便將來的程式碼繼續遵守規則。另一種選擇是編寫一個多鎖封裝器(multi-lock wrapper),該封裝器總是以相同的順序獲取鎖,一種簡單的實現方法是通過鎖在記憶體中的地址來命令獲取。
生產者程序生成消費者程序使用的資訊,例如,編譯器可以生成組合程式使用的組合程式碼。反過來,組合程式可能會生成載入程式使用的目標模組。生產者-消費者問題也為客戶機-伺服器模式提供了一個有用的隱喻。
生產者-消費者問題的一個解決方案是使用共用記憶體,為了允許生產者和消費者程序同時執行,我們必須有一個專案緩衝區,可以由生產者填充,也可以由消費者清空。該緩衝區將駐留在由生產者和消費者程序共用的記憶體區域中。生產者可以生產一種商品,而消費者可以消費另一種商品。
生產者和消費者必須同步,以便消費者不會嘗試消費尚未生產的資料項。可以使用兩種型別的緩衝器:無限緩衝區、有限緩衝區。無限緩衝區對緩衝區的大小沒有實際限制,消費者可能不得不等待新產品,但生產者總是可以生產新產品。有限緩衝區假定緩衝區大小固定,在這種情況下,如果緩衝區為空,消費者必須等待。如果緩衝區已滿,生產者必須等待。
讓我們更仔細地看一下有限緩衝區如何使用共用記憶體演示程序間通訊,以下變數位於生產者和消費者程序共用的記憶體區域中:
#define BUFFER SIZE 10
typedef struct
{
. . .
} item;
item buffer[BUFFER SIZE];
int in = 0;
int out = 0;
共用緩衝區實現為具有兩個邏輯指標的迴圈陣列:in和out,變數in指向緩衝區中的下一個空閒位置,變數out指向緩衝區中的第一個完整位置。
生產者程序具有下一個生成的區域性變數,其中儲存了要生產的新專案。消費者程序有一個區域性變數,該變數下一次被使用,儲存了要使用的項。
// 使用共用記憶體的生產者程序。
item next produced;
while (true)
{
/* produce an item in next produced */
while (((in + 1) % BUFFER SIZE) == out)
; /* do nothing */
buffer[in] = next produced;
in = (in + 1) % BUFFER SIZE;
}
// 使用共用記憶體的消費者程序.
item next consumed;
while (true)
{
while (in == out)
; /* do nothing */
next consumed = buffer[out];
out = (out + 1) % BUFFER SIZE;
/* consume the item in next consumed */
}
編寫C程式以實現生產者和消費者問題(號誌):
對應的範例程式碼:
#include<stdio.h>
int mutex=1, full=0, empty=3, x=0;
int n;
void producer();
void consumer();
int wait(int);
int signal(int);
void main()
{
printf("\n 1.producer\n2.consumer\n3.exit\n");
while(1)
{
printf(" \nenter ur choice");
scanf("%d",&n);
switch(n)
{
case 1:
if((mutex==1)&&(empty!=0))
producer();
else
printf("buffer is full\n");
break;
case 2:
if((mutex==1)&&(full!=0))
consumer();
else
printf("buffer is empty");
break;
case 3:
exit(0);
break;
}
}
}
int wait(int s)
{
return(--s);
}
int signal(int s)
{
return (++s);
}
void producer()
{
mutex = wait(mutex);
full = signal(full);
empty = wait(empty);
x++;
printf("\n producer produces the items %d", x);
mutex = signal(mutex);
}
void consumer()
{
mutex = wait(mutex);
full = wait(full);
empty = signal(empty);
printf("\n consumer consumes the item %d", x);
x--;
mutex = signal(mutex);
}
經典IPC問題幾乎用於測試每個新提出的同步方案。在解決這些問題的方法中,我們使用號誌進行同步,因為是呈現此類解決方案的傳統方式。然而,這些解決方案的實際實現可以使用互斥鎖代替二進位制號誌。
它通常用於說明同步原語的威力。在我們的問題中,生產者和消費者流程共用以下資料結構:
int n;
semaphore mutex = 1;
semaphore empty = n;
semaphore full = 0
假設池由n個緩衝區組成,每個緩衝區可以容納一個專案。互斥號誌為存取緩衝池提供互斥,並初始化為值1。空號誌和滿號誌統計空緩衝區和滿緩衝區的數量。號誌empty被初始化為值n,號誌full被初始化為值0。
// 生產者程序的結構
do
{
(...)
/* produce an item in next produced */
(...)
wait(empty);
wait(mutex);
(...)
/* add next produced to the buffer */
(...)
signal(mutex);
signal(full);
} while (true);
// 消費者程序的結果
do {
wait(full);
wait(mutex);
(...)
/* remove an item from buffer to next consumed */
(...)
signal(mutex);
signal(empty);
(...)
/* consume the item in next consumed */
(...)
} while (true);
假設一個資料庫將在多個並行程序之間共用。其中一些程序可能只想讀取資料庫,而其他程序可能想更新(即讀寫)資料庫。如果兩個閱讀器同時存取共用資料,則不會產生不利影響。如果寫入程式和其他程序(讀卡器或寫入程式)同時存取資料庫,則可能會出現問題,沒有讀取者應該僅僅因為寫入者在等待而等待其他讀取者完成。
一旦寫入者準備就緒,該寫入者將盡快執行其寫入。任何一個問題的解決方案都可能導致飢餓。在第一種情況下,寫入者可能會捱餓;在第二種情況下,讀取者可能會捱餓。在解決第一個寫入者問題時,寫入者程序共用以下資料結構:
semaphore rw mutex = 1;
semaphore mutex = 1;
int read count = 0;
號誌rw_mutex對於讀寫器程序都是通用的,互斥號誌用於確保更新變數讀取計數時互斥,read_count變數跟蹤當前有多少程序正在讀取物件,號誌rw_mutex用作編寫器的互斥號誌。
// 寫入者程序程式碼
do
{
wait(rw mutex);
(...)
/* writing is performed */
(...)
signal(rw mutex);
} while (true);
// 讀取者程序程式碼
do
{
wait(mutex);
read count++;
if (read count == 1)
wait(rw mutex);
signal(mutex);
(...)
/* reading is performed */
(...)
wait(mutex);
read count--;
if (read count == 0)
signal(rw mutex);
signal(mutex);
} while (true);
如果一個寫入者在臨界區,n個讀取者在等待,則rw_mutex上會有一個讀取者排隊,n−1個讀取者在互斥體上排隊。當一個寫入者執行signal(rw_mutex)時,我們可以繼續執行等待的讀取者或單個等待的寫入者,由排程程式進行選擇。
用餐哲學家(dining-philosophers)問題被認為是一個經典的同步問題。
想想五位哲學家,他們一生都在思考和吃飯。哲學家們共用一張圓桌,周圍有五把椅子,每把椅子都屬於一位哲學家,桌子中央是一碗飯,桌子上放著五根筷子。
當哲學家思考時,他不會與同事互動。有時,一位哲學家餓了,試圖拿起離他最近的兩根筷子(她和左右鄰居之間的筷子)。 哲學家一次只能拿起一根筷子,且無法拿起鄰居手中的筷子。當一個飢餓的哲學家同時擁有兩隻筷子時,他吃飯時不會鬆開筷子。 當吃完飯,他放下兩隻筷子,開始重新思考。
哲學家試圖通過對號誌執行wait()操作來抓住筷子,通過對適當的號誌執行signal()操作來釋放筷子。因此,共用資料是筷子的所有元素初始化為1的地方。
semaphore chopstick[5];
do
{
wait(chopstick[i]);
wait(chopstick[(i+1) % 5]);
(...)
/* eat for awhile */
(...)
signal(chopstick[i]);
signal(chopstick[(i+1) % 5]);
(...)
/* think for awhile */
(...)
} while (true);
雖然上述這種解決方案可以保證沒有兩個鄰居同時吃飯,但必須予以拒絕,因為可能會造成死鎖。假設五位哲學家同時都餓了,每個人都抓著左邊的筷子。此時,筷子的所有元素現在都等於0。當每個哲學家都試圖抓住右邊的筷子時,他將永遠被耽擱。以下是解決死鎖問題的幾種可能方法:
除了以上所述的同步方式,Windows還提供了其它諸多方式,完整列表:
此外,C++標準庫也提供了同步原語,可以用作Windows API的替代品,特別是對於跨平臺程式碼。通常,這些物件的自定義非常有限,例如:
顯然,C++中可能缺少一些東西,例如等待地址和同步屏障,但它們可以在未來新增到標準中。在任何情況下,所有C++標準庫型別僅在同一程序中工作,無法跨程序使用它們。關於C++的更多同步技術,可以參閱2.1.3.3 C++多執行緒同步。
執行緒可以存取其堆疊資料,並處理廣泛的全域性變數。然而,有時線上程基礎上擁有一些儲存是很方便的,可以以統一的方式存取。一個經典的例子是我們熟悉的GetLastError函數,儘管任何執行緒都可以呼叫GetLastError,但存取的每個執行緒的結果都不同。處理這種情況的一種方法是儲存由執行緒ID鍵控的雜湊表,然後根據該鍵查詢值。雖然可行,但它有一些缺點:第一,雜湊表需要同步,因為多個執行緒可能同時存取它;第二,搜尋正確的執行緒可能沒有預期的那麼快。
執行緒本地儲存(Thread Local Storage,TLS)是一種使用者模式機制,允許在多執行緒的基礎上儲存資料,程序中的每個執行緒都可以存取,但只能存取自己的資料。但是存取方法是統一的。
TLS使用的另一個經典範例是C/C++標準庫。早在20世紀70年代初,C標準庫就被構想出來了,當時還沒有多執行緒的概念。因此,C執行時維護一組全域性變數作為某些操作的狀態。例如,以下經典C程式碼嘗試開啟檔案並處理可能的錯誤:
FILE* fp = fopen("somefile.txt", "r");
if(fp == NULL)
{
// something went wrong
switch(errno)
{
case ENOENT: // no such file
break;
case EFBIG:
break;
}
}
任何I/O錯誤都反映在全域性errno變數中。但在多執行緒應用程式中,是一個問題。假設執行緒1進行I/O函數呼叫,導致errno更改。在檢查其值之前,執行緒2也進行I/O呼叫,再次更改errno,導致執行緒1檢查了由於執行緒2活動而產生的值。
解決方案是errno不能是全域性變數。如今的errno不是一個變數,而是一個宏,它呼叫一個函數errno(),該函數使用執行緒本地儲存來檢索當前執行緒的值。類似地,I/O函數的實現(如fopen)使用TLS將錯誤結果儲存到當前執行緒。
Windows API為TLS使用提供以下介面:
DWORD TlsAlloc();
BOOL TlsFree(DWORD dwTlsIndex);
BOOL TlsSetValue(DWORD dwTlsIndex, PVOID pTlsValue);
PVOID TlsGetValue(DWORD dwTlsIndex);
TlsAlloc函數返回一個可用的槽索引,並將所有現有執行緒的所有對應單元格歸零。TLS對於DLL非常有用,因為DLL可能希望在每個執行緒的基礎上儲存一些資訊,因此在載入時會分配大量資訊,並在需要時使用。
TLS中的每個單元格都是指標大小的值,因此這裡的最佳實踐是使用單個插槽,並動態分配所需的任何結構,以儲存TLS中需要儲存的所有資訊,並僅將指向資料的指標儲存在插槽本身中。索引可用後,將使用TlsSetValue、TlsGetValue來儲存或從槽中檢索值。
呼叫這些函數的執行緒只能存取特定槽索引中自己的值,沒有直接存取另一個執行緒的TLS插槽的方法——因為會破壞TLS的本意。也意味著存取TLS時不需要同步,因為只有一個執行緒可以存取記憶體中的相同地址。TLS陣列如下圖所示。
執行緒本地儲存也以更簡單的形式提供,在全域性或靜態變數上使用Microsoft擴充套件關鍵字,或使用C++11或更高版本的編譯器。Windows有兩種定義方式,如下所示:
// 方式1:Microsoft特定說明符
__declspec(thread) int counter;
// 方式2:C++標準定義
thread_local int counter;
此TLS是「靜態」的——不需要任何分配,並且不能被銷燬。在內部,編譯器將所有執行緒區域性變數捆綁到一個塊(chunk)中,並將資訊儲存在PE中名為.tls的部分中。程序啟動時讀取此資訊的載入器(NTDLL)呼叫TlsAlloc來分配一個插槽,併為啟動包含所有執行緒區域性變數的記憶體塊的每個執行緒動態分配。在呼叫傳遞給CreateThread的「實」函數之前,每個使用者模式執行緒都在NTDLLProved函數中啟動。
Windows下可以使用CreateThread函數在當前程序中建立一個執行緒。然而,在某些情況下,一個程序可能希望在另一個程序中建立執行緒。典型範例是偵錯程式,當需要強制斷點時,例如當用戶按下「中斷」按鈕時,偵錯程式會在目標程序中建立一個執行緒,並將其指向DebugBreak函數(或發出中斷指令的CPU內部函數),從而導致程序中斷,並通知偵錯程式。以下API可以建立遠端執行緒:
HANDLE WINAPI CreateRemoteThread(HANDLE hProcess, ...);
HANDLE CreateRemoteThreadEx(HANDLE hProcess, ...);
使用執行緒來實現遠端過程呼叫(RPC)的示意圖如下:
在微處理器的早期,CPU的速度和記憶體(RAM)的速度是相當的。然後CPU速度上升,記憶體速度滯後,導致CPU大量停頓(stall),等待記憶體讀取或寫入值。為了補償,CPU和記憶體之間引入了快取,如下圖所示。
快取和記憶體結構圖例。
與主記憶體相比,快取記憶體是一種快速記憶體,允許CPU減少停頓。快取記憶體雖然沒有主記憶體那麼大,但它的存在在今天的系統中是必不可少的,其重要性不言而喻。舉個具體的例子。下面是兩種不同的計算矩陣之和的方式:
long long SumMatrix1(const Matrix<int>& m)
{
long long sum = 0;
// 行優先(Row major)
for (int r = 0; r < m.Rows(); ++r)
for (int c = 0; c < m.Columns(); ++c)
sum += m[r][c];
return sum;
}
long long SumMatrix2(const Matrix<int>& m)
{
long long sum = 0;
// 列優先(Col Major)
for (int c = 0; c < m.Columns(); ++c)
for (int r = 0; r < m.Rows(); ++r)
sum += m[r][c];
return sum;
}
Matrix<>類是一維陣列的簡單包裝器。從演演算法角度來看,兩個函數中矩陣元素求和所需的時間應該相同。畢竟,程式碼一次遍歷所有矩陣元素。但實際結果可能令人驚訝。下面是執行一個不同矩陣大小和元素求和所需的時間(都是單執行緒):
Type Size Sum Time (nsec)
-----------------------------------------------------------------
Row major 256 X 256 2147516416 34 usec
Col Major 256 X 256 2147516416 81 usec
Row major 512 X 512 34359869440 130 usec
Col Major 512 X 512 34359869440 796 usec
Row major 1024 X 1024 549756338176 624 usec
Col Major 1024 X 1024 549756338176 3080 usec
Row major 2048 X 2048 8796095119360 2369 usec
Col Major 2048 X 2048 8796095119360 43230 usec
Row major 4096 X 4096 140737496743936 8953 usec
Col Major 4096 X 4096 140737496743936 190985 usec
Row major 8192 X 8192 2251799847239680 35258 usec
Col Major 8192 X 8192 2251799847239680 1035334 usec
Row major 16384 X 16384 36028797153181696 142603 usec
Col Major 16384 X 16384 36028797153181696 4562040 usec
差異相當大,因為有快取的存在。當CPU讀取資料時,它不會讀取單個整數或任何被指示讀取的資料,而是讀取整個快取行(通常為64位元組),並將其放入內部快取。然後,當讀取記憶體中的下一個整數時,不需要記憶體存取,因為該整數已經存在於快取中。這是最佳的,並且是SumMatrix1的工作方式——它線性遍歷記憶體。另一方面,SumMatrix2讀取一個整數(以及快取行的其餘部分),而下一個整數位於更遠的另一個快取行(對於除最小矩陣之外的所有矩陣),需要讀取另一快取行,可能會丟棄可能很快需要的資料,使情況變得更糟。
從技術上講,在大多數CPU中實現了3個快取級別。快取離處理器越近,速度越快,但容量越小。下圖顯示了4核CPU(採用超執行緒技術)的典型快取設定。
1級快取由資料快取(D-cache)和指令快取(I-cache)組成,每個邏輯處理器有一個。然後是2級快取,由屬於同一核心的邏輯處理器共用。最後,3級快取是全系統的。這些快取的大小相當小,大約比主記憶體小3個數量級。系統上的快取大小在工作管理員的效能/CPU索引標籤中很容易看到,如下圖所示。
在上圖中,3級快取大小為16 MB(系統範圍),2級快取大小為4MB,但包括所有核心。由於該系統有8個核心,每個2級快取實際上是4MB/8=512KB。類似地,1級快取大小為640KB,分佈在16個邏輯處理器上,使每個快取640KB/16=40KB。與主記憶體大小(以千兆位元組為單位)相比,快取大小較小。
快取、主記憶體結構。
快取讀取操作過程。
讓我們看另一個例子,其中快取和快取行起著重要(甚至至關重要)的作用。下面的範例演示瞭如何遍歷一個大陣列,計算陣列中的偶數,它是通過多個執行緒完成的——每個執行緒都被分配了陣列的一個連續部分。計數本身被放置在另一個陣列中,每個單元格由相應的執行緒修改。下圖顯示了具有4個執行緒的這種佈置。
下面是計算偶數的第一個版本:
using namespace std;
struct ThreadData
{
long long start, end;
const int* data;
long long* counters;
};
long long CountEvenNumbers1(const int* data, long long size, int nthreads)
{
auto counters_buffer = make_unique<long long[]>(nthreads);
auto counters = counters_buffer.get();
auto tdata = make_unique<ThreadData[]>(nthreads);
long long chunk = size / nthreads;
vector<wil::unique_handle> threads;
vector<HANDLE> handles;
for (int i = 0; i < nthreads; i++)
{
long long start = i * chunk;
long long end = i == nthreads - 1 ? size : ((long long)i + 1) * chunk;
auto& d = tdata[i];
d.start = start;
d.end = end;
d.counters = counters + i;
d.data = data;
wil::unique_handle hThread(::CreateThread(nullptr, 0, [](auto param) -> DWORD
{
auto d = (ThreadData*)param;
auto start = d->start, end = d->end;
auto counters = d->counters;
auto data = d->data;
for (; start < end; ++start)
if (data[start] % 2 == 0)
++(*counters);
return 0;
}, tdata.get() + i, 0, nullptr));
handles.push_back(hThread.get());
threads.push_back(move(hThread));
}
::WaitForMultipleObjects(nthreads, handles.data(), TRUE, INFINITE);
long long sum = 0;
for (int i = 0; i < nthreads; i++)
sum += counters[i];
return sum;
}
啟動為每個執行緒準備資料的迴圈,並呼叫CreateThread啟動執行緒迴圈,看看執行緒的迴圈:
for (; start < end; ++start)
if (data[start] % 2 == 0)
++(*counters);
對於每個偶數,計數器指標的內容遞增1。注意,這裡沒有資料競爭——每個執行緒都有自己的單元,因此最終結果應該是正確的。這段程式碼的問題在於,當某個執行緒寫入單個計數時,會寫入一個完整的快取行,使其他處理器上檢視該記憶體的任何快取失效,迫使它們通過再次從主記憶體讀取來重新整理快取——導致效能很慢。這種情況被稱為偽共用(false sharing)。
另一種方法是不寫與其他執行緒共用快取線的單元,至少不是經常寫:
// 將統計的中間資料放在區域性變數count
size_t count = 0;
for (; start < end; ++start)
if (data[start] % 2 == 0)
count++;
*(d->counters) = count;
主要的區別是將計數保持在區域性變數count中,並且僅在迴圈完成時向結果陣列中的單元格寫入一次。由於count位於執行緒的堆疊上,並且堆疊大小至少為4KB,因此它們不可能與其他執行緒中的其他count變數位於同一快取行上,大大提高了效能。當然,通常使用區域性變數可能比間接存取記憶體快,因為編譯器更容易將該變數快取在暫存器中。但真正的影響是避免執行緒之間共用快取行。主函數使用不同數量的執行緒測試這兩種實現,如下所示:
Initializing data...
Option 1
1 threads count: 2147483647 time: 4843 msec
2 threads count: 2147483647 time: 3391 msec
3 threads count: 2147483647 time: 2468 msec
4 threads count: 2147483647 time: 2125 msec
5 threads count: 2147483647 time: 2453 msec // 執行緒多了反而降低效能!!
6 threads count: 2147483647 time: 1906 msec
7 threads count: 2147483647 time: 2109 msec // 執行緒多了反而降低效能!!
8 threads count: 2147483647 time: 2532 msec // 執行緒多了反而降低效能!!
Option 2
1 threads count: 2147483647 time: 4046 msec
2 threads count: 2147483647 time: 2313 msec
3 threads count: 2147483647 time: 1625 msec
4 threads count: 2147483647 time: 1328 msec
5 threads count: 2147483647 time: 1062 msec
6 threads count: 2147483647 time: 953 msec
7 threads count: 2147483647 time: 859 msec
8 threads count: 2147483647 time: 855 msec
請注意,在選項1中,在某些情況下,更多執行緒會降低效能。在選項2中,隨著執行緒數量的增加而持續改進效能。
主記憶體快取機制是計算機架構的一部分,在硬體中實現,通常對作業系統不可見。然而,還有兩個兩級記憶體方法的範例,它們也利用了區域性性的特性,並且至少部分地在作業系統中實現:虛擬記憶體和磁碟快取(下表)。我們將研究所有三種方法中常見的兩級記憶體的一些效能特徵。
主記憶體快取 | 虛擬記憶體(分頁) | 磁碟快取 | |
---|---|---|---|
常規存取時間比 | 5 : 1 | \(10^6\) : 1 | \(10^6\) : 1 |
記憶體管理系統 | 由特殊硬體實現 | 硬體和系統軟體的組合 | 系統軟體 |
常規塊尺寸 | 4 - 128 位元組 | 64 - 4096 位元組 | 64 - 4096 位元組 |
處理器存取二級方式 | 直接存取 | 間接存取 | 間接存取 |
下表測量了若干研究者在執行不同語言過程中各種語句型別的外觀。
關於斷言,PATT85中報告的研究提供了證實(下圖),它顯示了呼叫返回行為。每一個呼叫都由向下和向右移動的行表示,每一個返回都由向上和向右移動行表示。在圖中,定義了一個深度等於5的視窗。只有一系列呼叫和返回,在任意方向上淨移動6,才會導致視窗移動。如圖所示,正在執行的程式可以長時間保持在一個固定視窗內。
程式的呼叫返回行為樣例。
文獻中對空間區域性性和時間區域性性進行了區分。空間區域性性指的是執行過程中涉及多個聚集的記憶體位置的趨勢,反映了處理器按順序存取指令的趨勢,空間位置還反映了程式順序存取資料位置的趨勢,例如在處理資料表時。時間區域性性是指處理器存取最近使用的記憶體位置的趨勢,例如,當執行迭代迴圈時,處理器會重複執行同一組指令。
傳統上,通過將最近使用的指令和資料值儲存在快取記憶體中並利用快取層次結構來利用時間區域性性。空間區域性性通常通過使用更大的快取塊和將預取機制(獲取預期使用的項)引入快取控制邏輯來利用。最近,有相當多的研究在改進這些技術以實現更高的效能,但基本策略保持不變。
區域性性可以在形成兩級記憶體時加以利用,上層記憶體(M1)比下層記憶體(M2)更小、更快、更昂貴(每位)。MI用作較大M2的部分內容的臨時儲存,當進行記憶體參照時,將嘗試存取MI中的專案,如果成功,則進行快速存取,如果沒有,則將一塊記憶體位置從M2複製到MI,然後通過MI進行存取。由於區域性性,一旦塊被引入,應該會對該塊中的位置進行多次存取,從而實現快速的整體服務。為了表示存取一個專案的平均時間,我們不僅要考慮兩個級別記憶體的速度,還要考慮在中找到給定參照的概率:
其中:
下圖顯示了作為命中率函數的平均存取時間。可以看出,對於高命中率,平均總存取時間比M2更接近。
讓我們評估兩級記憶體機制相關的一些引數,首先考慮效能,有以下公式:
其中:
我們希望\(C_s \approx C_2\),考慮到\(C_1 >> C_2\),需要\(S_1 << S_2\)。下圖顯示了它們之間的關聯。
為此,考慮\(T_1 / T_s\)的值,它被稱為存取效率,是平均存取時間(\(T_s\))與MI存取時間(\(T_1\))的接近程度的度量:
下圖描繪了\(T_1 / T_s\)作為命中率H的函數,其中\(T_1 / T_s\)作為引數。似乎需要0.8至0.9範圍內的命中率來滿足效能要求。
存取效率作為命中率的函數(r = \(T_1 / T_2\))。
下圖顯示了區域性性對命中率的影響。顯然,如果MI的大小與M2相同,那麼命中率將為1.0——M2中的所有專案都始終儲存在中。現在假設沒有區域性性:也就是說,參照是完全隨機的,在這種情況下,命中率應該是相對記憶體大小的嚴格線性函數。例如,如果MI的大小是M2的一半,那麼在任何時候,M2中的一半專案也在其中,命中率將為0.5。然而,實際上,參照中存在一定程度的區域性性。中等和強區域性性的影響如圖所示。
命中率作為相對記憶體大小的函數。
兩個記憶體的相對大小是否滿足成本要求?答案顯然是肯定的。如果我們只需要一個相對較小的上層記憶體來實現良好的效能,那麼這兩層記憶體的平均每位元成本將接近較便宜的下層記憶體。
Windows提供執行緒池,是一種允許某些執行緒從執行緒池傳送操作的機制。與手動建立和管理執行緒相比,使用執行緒池的優勢如下:
Windows 2000提供了第一個版本的執行緒池,為每個程序提供了一個執行緒池。從Windows Vista開始,執行緒池API得到了顯著增強,包括新增了專用執行緒池,意味著一個程序中可以存在多個執行緒池。相關API:
PTP_WORK CreateThreadpoolWork(PTP_WORK_CALLBACK pfnwk, PVOID pv, PTP_CALLBACK_ENVIRON pcbe);
void CloseThreadpoolWork(PTP_WORK pwk);
VOID CloseThreadpoolWait(PTP_WAIT pwa);
VOID SubmitThreadpoolWork(PTP_WORK Work);
BOOL TrySubmitThreadpoolCallback(PTP_SIMPLE_CALLBACK pfns, PVOID pv, PTP_CALLBACK_ENVIRON pcbe);
void WaitForThreadpoolWorkCallbacks(PTP_WORK pwk, BOOL fCancelPendingCallbacks);
VOID SetThreadpoolWait(PTP_WAIT pwa, HANDLE h, PFILETIME pftTimeout);
使用者模式和記憶體模式之間的切換存在大量的基礎消耗,所以,減少兩者之間的切換可以提升效能。
早期的Windows版本提供纖程,但其存在諸多問題(如TLS未正確傳遞,執行緒環境塊不符),已被廢棄。從Windows 7和Windows 2008 R2開始,Windows支援一種稱為使用者模式排程(UMS)的替代機制,其中使用者模式執行緒成為各種排程程式,可以從使用者模式排程執行緒,而無需進行使用者模式/核心模式轉換。該機制是核心已知的,因此UMS不存在纖程的缺點,因為使用的是真正的執行緒,而不是共用執行緒的纖程。
不幸的是,但是構建一個使用UMS的真實系統並非易事。微軟(自2010年起)提供了一個名為並行執行時的庫,縮寫為Concrt,發音為「concert」,它在需要並行執行時,在後臺使用UMS提供執行緒的高效使用。
許多應用程式中的一個常見模式是使用單例物件。在某些觀點中,這是一種反模式。事實上,單例是有用的,有時是必要的。單例的一個常見要求是隻初始化一次。在多執行緒應用程式中,多個執行緒最初可能同時存取單範例,但單範例必須初始化一次。如何實現這一目標?有幾種眾所周知的演演算法,如果實施得當,可以完成任務。Windows API提供了一種內建的方法來呼叫函數,並保證只呼叫一次:
INIT_ONCE init = INIT_ONCE_STATIC_INIT;
VOID InitOnceInitialize(_Out_ PINIT_ONCE InitOnce);
BOOL InitOnceExecuteOnce(PINIT_ONCE InitOnce, __callback PINIT_ONCE_FN InitFn, PVOID Parameter, LPVOID* Context);
如果程序處於作業之下,則作業(Job)物件在Process Explorer中間接可見。在這種情況下,作業索引標籤出現在程序的屬性中(如果程序處於無作業狀態,則該索引標籤不存在)。另一種收集作業的方法是在選項/設定顏色中啟用作業顏色(預設為棕色)…。下圖顯示了Process Explorer,其中作業顏色可見,所有其他顏色均已移除。
Windows 8引入了將程序與多個作業關聯的功能,使得作業比以前有用得多,因為如果希望通過作業控制的程序已經是作業的一部分,則無法將其與其他作業關聯。分配了第二個作業的程序會導致建立作業層次結構(如果可能),第二份作業成為第一份工作的子項。基本規則如下:
下圖顯示了通過呼叫以下操作(按順序)建立的作業的層次結構:
1、將程序P1分配給作業J1。
2、將程序P1分配給作業J2,形成層次結構。
3、將程序P2分配給作業J2,程序P2現在受作業J1和J2的影響。
4、將程序P3分配給作業J1。
檢視作業層次結構並不容易。例如,Process Explorer顯示作業的詳細資訊,包括顯示作業和所有子作業(如果有)的資訊。例如,從圖上圖中檢視作業J1的資訊,將列出三個程序:P1、P2和P3。此外,由於作業存取是間接的——如果一個程序在作業下,則作業索引標籤可用——顯示的作業是該程序所屬的直接作業,未顯示任何父作業。以下程式碼建立了上圖所示的層次結構:
#include <windows.h>
#include <stdio.h>
#include <assert.h>
#include <string>
HANDLE CreateSimpleProcess(PCWSTR name)
{
std::wstring sname(name);
PROCESS_INFORMATION pi;
STARTUPINFO si = { sizeof(si) };
if (!::CreateProcess(nullptr, const_cast<PWSTR>(sname.data()), nullptr, nullptr, FALSE, CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_CONSOLE, nullptr, nullptr, &si, &pi))
{
return nullptr;
}
::CloseHandle(pi.hThread);
return pi.hProcess;
}
HANDLE CreateJobHierarchy()
{
auto hJob1 = ::CreateJobObject(nullptr, L"Job1");
assert(hJob1);
auto hProcess1 = CreateSimpleProcess(L"mspaint");
auto success = ::AssignProcessToJobObject(hJob1, hProcess1);
assert(success);
auto hJob2 = ::CreateJobObject(nullptr, L"Job2");
assert(hJob2);
success = ::AssignProcessToJobObject(hJob2, hProcess1);
assert(success);
auto hProcess2 = CreateSimpleProcess(L"mstsc");
success = ::AssignProcessToJobObject(hJob2, hProcess2);
assert(success);
auto hProcess3 = CreateSimpleProcess(L"cmd");
success = ::AssignProcessToJobObject(hJob1, hProcess3);
assert(success);
// not bothering to close process and job 2 handles
return hJob1;
}
int main()
{
auto hJob = CreateJobHierarchy();
printf("Press any key to terminate parent job...\n");
::getchar();
::TerminateJobObject(hJob, 0);
return 0;
}
當違反作業限制或發生某些事件時,作業可以通過與作業關聯的I/O完成埠通知相關方。I/O完成埠通常用於處理非同步I/O操作的完成,但在這種特殊情況下,它們被用作通知某些作業事件發生的機制。
作業是一個排程程式(可等待)物件,當發生CPU時間衝突時發出訊號。對於這個簡單的情況,執行緒可以等待WaitForSingleObject(作為一個常見範例),然後處理CPU時間衝突。設定新的CPU時間限制將作業重置為無訊號狀態。
Windows 10版本1607和Windows Server 2016引入了稱為Silos的作業的增強版本。Silos有兩種變體:
總之,作業提供了許多控制和限制程序的機會,都由核心本身實現。Windows 8中巢狀作業的引入使作業更有用,限制更少。
記憶體管理包含的內容有:邏輯和實體地址對映、記憶體分配、內部和外部碎片和壓縮、分頁,以及虛擬記憶體:請求分頁、頁面替換策略。本章將詳細闡述記憶體相關的概念、技術、原理和機制。
今天的現代Intel/AMD處理器在記憶體方面開始非常有限,最初的8086/8088處理器僅支援1 MB記憶體(實體記憶體,因為當時沒有其他記憶體),對記憶體的每次存取都是段地址和偏移量的組合,在處理器內部使用16位元值,但記憶體存取需要20位(1 MB)。段暫存器的值(16位元)乘以16(0x10),然後新增偏移量以達到1MB範圍內的地址。這種工作模式現在被稱為真真實模式(Real Mode),並且仍然是當今Intel/AMD處理器的喚醒模式。
隨著80386處理器的引入,虛擬記憶體誕生了,因為它基本上是今天使用的,包括通過只使用偏移量線性存取記憶體的能力(段暫存器剛剛設定為零),使得記憶體存取更加方便。虛擬記憶體意味著每個記憶體存取都需要轉換到實體地址所在的位置,此模式稱為保護模式(Protected Mode)。在保護模式下,無法直接存取實體記憶體,只能通過從虛擬地址到實體地址的對映,此對映必須由作業系統的記憶體管理器預先準備,因為CPU需要此對映。
在64位元系統上,保護模式稱為長模式(Long Mode),但本質上是相同的機制,擴充套件到64位元地址。
虛擬地址和實體地址之間的對映,以及作業系統級記憶體塊的管理,都是在稱為頁面的塊中執行的。頁面是必要的,因為不可能管理每個位元組——管理結構將比該位元組大得多。下表列出了Windows支援的所有架構的頁面大小。
架構 | 小(正常)頁 | 大頁 | 超大頁 |
---|---|---|---|
x86 | 4KB | 2MB | N/A |
x64 | 4KB | 2MB | 1GB |
ARM | 4KB | 2MB | 2MB |
ARM64 | 4KB | 2MB | 2MB |
小(正常)頁面是預設頁面,頁面通常表示小頁面或正常頁面,在所有架構中都是4KB。如果提到不同的頁面大小,通常伴隨一個字首:大或巨大。不同的頁面大小和頁面幀數量,對應的頁面錯誤比率如下:
記憶體管理與管理主記憶體有關,記憶體由位元組或單詞陣列組成,每個位元組或單詞都有自己的地址。CPU根據值程式計數器從記憶體中提取指令,這些指令可能會導致從特定記憶體地址進行額外載入和儲存。記憶體單元只看到記憶體地址流,它不知道它們是如何生成的。程式必須被放入記憶體並放置在程序中才能執行。輸入佇列是磁碟上等待進入記憶體執行的程序的集合。使用者程式在執行之前要經過幾個步驟。
記憶體管理功能:跟蹤每個記憶體位置的狀態,確定分配策略——記憶體分配技術和取消分配技術。
記憶體管理:邏輯和實體地址對映、記憶體分配、內部和外部碎片和壓縮、分頁;虛擬記憶體:請求分頁、頁面替換策略。
程式作為二進位制可執行檔案儲存在輔助儲存磁碟上。當程式要執行時,它們被放入主記憶體並放在一個程序中,磁碟上等待進入主記憶體的程序集合形成輸入佇列,將要執行的程序之一從佇列中取出並放入主記憶體中。在執行期間,它從主記憶體中獲取指令和資料,程序終止後,它返回記憶體空間。在執行過程中,該程序將經歷不同的步驟,在每個步驟中,地址以不同的方式表示。在源程式中,地址是符號,編譯器將符號地址轉換為可重新定位的地址,載入程式將把這個可重新定位的地址轉換為絕對地址。
指令和資料的繫結可以在程序中的任何步驟完成:
CPU生成的地址稱為邏輯地址或虛擬地址。記憶體單元看到的地址,即載入到記憶體暫存器的地址,稱為實體地址。編譯時和載入時地址繫結方法生成一些邏輯地址和實體地址,執行時地址繫結生成不同的邏輯地址和實體地址。
程式生成的邏輯地址空間集是邏輯地址空間,與這些邏輯地址對應的實體地址集是實體地址空間。在執行時,虛擬地址到實體地址的對映是由稱為記憶體管理單元(MMU)的硬體裝置完成的。
基址暫存器也稱為重定位暫存器,重新定位暫存器的值將新增到使用者程序在傳送到記憶體時生成的每個地址。使用者程式永遠看不到真實的實體地址。下圖顯示了動態關係,動態關係意味著從虛擬地址空間到實體地址空間的對映,通常在執行時通過一些硬體協助執行。
使用重新定位暫存器的動態重新定位上圖顯示了動態重新定位,意味著從虛擬地址空間對映到實體地址空間,並由硬體在執行時執行。重新定位由硬體執行,使用者無法看到動態重新定位,因此可以將部分執行的程序從記憶體的一個區域移動到另一個區域,而不會產生影響。具體的例子:
動態載入:對於要執行的程序,應該將其載入到實體記憶體中,程序的大小僅限於實體記憶體的大小,動態載入用於獲得更好的記憶體利用率。在動態載入中,除非呼叫例程或過程,否則不會載入它。每當呼叫例程時,呼叫例程首先檢查被呼叫例程是否已載入。如果沒有載入,它會導致載入程式將所需的程式載入到記憶體中,並更新程式地址表,以指示更改和控制傳遞給新呼叫的例程。
優勢:提供更好的記憶體利用率,從不載入未使用的例程,不需要特殊的作業系統支援。當在頻繁發生的情況下需要處理大量程式碼時,此方法非常有用。
交換是一種暫時從系統記憶體中刪除非活動程式的技術。程序可以暫時從記憶體中交換到後備儲存,然後再回到記憶體中繼續執行。此過程稱為交換。例如:
範例:
交換的決定因素:要交換程序,它應該完全空閒;程序可能正在等待I/O操作;如果I/O非同步存取I/O緩衝區的使用者記憶體,則無法交換程序。邏輯地址和實體地址對比如下表:
邏輯地址 | 實體地址 |
---|---|
CPU生成的地址。 | 儲存單元看到的地址。 |
程式生成的所有邏輯地址集是一個邏輯地址空間。 | 與這些邏輯地址對應的所有實體地址集是一個實體地址空間。 |
邏輯地址(範圍從0到最大)。 | 實體地址(對於基本值R,在R+0到R+max的範圍內)。 |
使用者可以檢視程式的邏輯地址。 | 使用者永遠無法檢視程式的實體地址。 |
使用者可以使用邏輯地址存取實體地址。 | 使用者可以間接存取實體地址,但不能直接存取。 |
邏輯地址是可變的,因此將隨系統而變化。 | 該物件的實體地址始終保持不變。 |
連續記憶體分配:記憶體分配的最簡單方法之一是將記憶體劃分為幾個固定分割區,主記憶體通常分為兩個分割區:常駐作業系統,通常用中斷向量儲存在低記憶體中;使用者程序,儲存在高記憶體中。每個分割區正好包含一個程序,多程式設計的程度取決於分割區的數量。
記憶體對映和保護:記憶體保護意味著保護作業系統不受使用者程序的影響,保護程序不受其他程序的影響。通過使用帶有限制暫存器的重新定位暫存器提供記憶體保護,重新定位暫存器包含最小實體地址的值,限制暫存器包含邏輯地址的範圍。(重新定位=100040,限制=74600)。
邏輯地址必須小於限制暫存器,MMU通過在重新定位暫存器中新增值來動態對映邏輯地址。當CPU排程程式選擇要執行的程序時,作為上下文切換的一部分,排程程式載入具有正確值的重新定位和限制暫存器。由於CPU生成的每個地址都會根據這些暫存器進行檢查,因此我們可以保護作業系統和其他使用者的程式和資料不被修改。
多分割區分配:
孔洞(hole)是可用記憶體塊,不同大小的孔洞分散在記憶體中。當程序到達時,會從一個足夠容納它的洞中分配記憶體。作業系統維護的資訊有分配的分割區、自由隔板(孔)。一組大小不同的孔,在任何給定的時間分散在記憶體中。
當一個程序到達並需要記憶體時,系統會搜尋這個集合以查詢一個足夠大的洞來容納這個程序。如果孔洞太大,則將其分為兩部分:一部分分配給到達程序,另一個返回到孔洞組。
當程序終止時,它會釋放記憶體塊,然後將其放回孔洞集。如果新孔洞與其他孔洞相鄰,則這些相鄰的孔洞將合併為一個較大的孔洞。此過程是一般動態儲存分配問題的一個特殊範例,即如何滿足空閒孔洞列表中大小為n的請求。
這個問題有很多解決方案。搜尋孔洞集以確定最佳分配的孔,首個匹配、最佳匹配和最差匹配策略是用於從可用孔集中選擇自由孔的最常用策略。選擇自由孔洞的三種策略說明:
First-fit和best-fit是最流行的動態記憶體分配演演算法,First-fit通常更快,最佳匹配搜尋整個列表以查詢最小的孔,即足夠大的孔。最差匹配會降低最小孔洞的生成速率。所有這些演演算法都存在碎片化問題。
當程序被載入並從記憶體中刪除時,空閒記憶體空間被分割成小塊。一段時間後,我們從記憶體中刪除的程序無法分配,因為記憶體中有小塊記憶體,記憶體塊仍然未使用。這個問題稱為碎片化。
或者,當記憶體在系統中按順序/連續分配時,會出現碎片,不同的檔案以塊的形式儲存在記憶體中,當頻繁修改或刪除這些檔案時,中間會留下間隙/可用空間,稱為碎片。
記憶體碎片可以有兩種型別:
內部碎片。由於載入的資料塊小於分割區,因此部分內部存在浪費的空間。這種情況下,可用空間太小,無法容納任何檔案。跟蹤可用空間的開銷對於作業系統來說太大了,這種便是內部碎片。範例:如果有一個50kb的塊,並且程序請求40kb,如果該塊被分配給程序,則剩餘10kb記憶體。
外部碎片。當存在足夠的記憶體空間來滿足請求,但不連續時即存在,即儲存被分割成大量的小孔洞。外部碎片可能是小問題,也可能是大問題。如圖所示,如果我們想將一個等於釋放空間總和的檔案放入記憶體,則不能,因為空間不是連續的。因此,儘管有足夠的記憶體來儲存檔案,但由於記憶體分散在不同的位置,我們無法容納它。這就是外部碎片。
克服外部碎片的一個解決方案是壓縮(compaction)。目標是將所有空閒記憶體移動到一起,形成一個大的塊。壓縮並非總是可行的,如果重新定位是靜態的,並且是在裝載時完成的,則不可能進行壓實。如果重新定位是動態的,並且在執行時完成,則可以進行壓縮。
外部碎片問題的另一個可能的解決方案是允許程序的邏輯地址空間是非連續的,從而允許在實體記憶體可用時為程序分配實體記憶體。
固定和動態分割區方案都有缺點。固定分割區方案限制了活動程序的數量,如果可用分割區大小和程序大小不匹配,則可能會低效率地使用空間。動態分割區方案維護起來更復雜,並且包括壓縮的開銷。一個有趣的折衷方案是夥伴系統(Buddy System)。
在請求分配大小為k(即\(2^{i-1}<k\le2^i\))的情況下,使用以下遞迴演演算法查詢大小為\(2^i\)的孔:
void get_hole(int i)
{
if(i==(U+1)) <failure>;
if(<i_list_empty>)
{
get_hole(i+1);
<split hole into buddies>;
<put buddies on i_list>;
}
<take first hole on i_list>;
}
下圖給出了使用1M位元組初始塊的範例。第一個請求A是100K位元組,需要128K塊,最初的區塊分為兩個512K夥伴,第一個被分成兩個256K的夥伴,其中第一個被分成兩個128K夥伴,其中一個分配給A。下一個請求B需要256K塊,這樣的塊已經可用並已分配。該過程繼續,根據需要進行拆分和合並。請注意,當釋放E時。兩個128K好友合併成256K區塊,其立即與其夥伴合併。
下圖顯示了釋放B請求後立即的好友分配的二元樹表示,葉節點表示記憶體的當前分割區。如果兩個夥伴是葉節點,則必須至少分配一個,否則它們將合併為一個更大的塊。
夥伴系統的樹表達。
將程序分配給自由幀。
大多數虛擬記憶體系統都使用一種稱為分頁的技術。在任何計算機上,程式都參照一組記憶體地址,當程式執行如下指令時:
MOV REG, 1000
這樣做是為了將記憶體地址1000的內容複製到REG(或反之,具體取決於計算機)。地址可以使用索引、基址暫存器、段暫存器和其他方式生成。
MMU的位置和功能。這裡顯示的MMU是CPU晶片的一部分,因為它現在很常見。然而,從邏輯上講,它可能是一個單獨的晶片,而且是多年前的事了。
MMU的內部操作。
硬體支援的重分配。
這些程式生成的地址稱為虛擬地址,構成虛擬地址空間。在沒有虛擬記憶體的計算機上,虛擬地址直接放在記憶體匯流排上,並導致讀取或寫入具有相同地址的實體記憶體字。當使用虛擬記憶體時,虛擬地址不會直接到達記憶體匯流排。相反,它們進入MMU(記憶體管理單元),將虛擬地址對映到實體記憶體地址,如上圖所示。
下圖顯示了該對映工作原理的一個非常簡單的範例。在這個範例中,我們有一臺計算機可以生成16位元地址,從0到64K− 1,這些是虛擬地址。然而,這臺計算機只有32 KB的實體記憶體。因此,儘管可以編寫64KB的程式,但它們不能全部載入到記憶體中並執行。然而,一個程式核心映像的完整副本(最大為64KB)必須存在於磁碟上,這樣才能根據需要放入各個部分。
虛擬地址空間由稱為頁面的固定大小單元組成。實體記憶體中的相應單元稱為頁幀(page frame)。頁面和頁幀通常大小相同。在本例中,它們是4KB,但實際系統中使用的頁面大小從512位元組到1GB不等。通過64KB的虛擬地址空間和32KB的實體記憶體,我們得到了16個虛擬頁面和8個頁幀。RAM和磁碟之間的傳輸始終是整頁的,許多處理器支援多種頁面大小,可以根據作業系統的需要進行混合和匹配。例如,x86-64體系結構支援4-KB、2-MB和1-GB頁面,因此我們可以為使用者應用程式使用4-KB頁面,為核心使用單個1-GB頁面。
虛擬地址和實體記憶體地址之間的關係由頁表給出。每個頁面以4096的倍數開始,以4095個地址結尾,因此4K–8K實際上意味著4096–8191,8K到12K意味著8192–12287。
分頁是一種允許程序的實體地址空間不連續的記憶體管理方案。分頁支援由硬體處理,用於避免外部碎片。分頁可以避免將不同大小的記憶體塊安裝到後備儲存上的相當大的問題。當主記憶體儲器中的某些程式碼或日期需要交換時,必須在後備記憶體中找到空間。實體記憶體分為固定大小的塊,稱為幀(f)。邏輯記憶體被分成大小相同的塊,稱為頁(p)。當一個程序要執行時,它的頁面會從後備記憶體載入到可用的幀中。塊儲存也被劃分為與記憶體幀大小相同的固定大小的塊。
例如,如果作業系統決定退出第1頁幀,它將在實體地址4096載入虛擬第8頁,並對MMU對映進行兩次更改。首先,它將虛擬頁1的條目標記為未對映,以捕獲將來對4096和8191之間虛擬地址的任何存取。然後,它將用1替換虛擬頁8條目中的叉,以便在重新執行捕獲的指令時,它將把虛擬地址32780對映到實體地址4108(4096+12)。
現在讓我們看看MMU內部,看看它是如何工作的,以及為什麼我們選擇使用2的冪次方的頁面大小。在下圖中,我們看到了一個虛擬地址8196(二進位制00100000000000100)的範例,它使用MMU對映進行對映。傳入的16位元虛擬地址被拆分為4位元頁碼和12位元偏移量。用4位元表示頁碼,我們可以有16頁,用12位元表示偏移量,我們可以定址一頁中的所有4096位元組。
16個4-KB頁面的MMU內部操作。
典型的頁表條目。
頁碼用作頁表的索引,生成對應於該虛擬頁的頁框架的編號。如果當前/缺失位為0,則會導致作業系統陷阱。如果位為1,則頁表中找到的頁幀編號將與12位元偏移量一起復制到輸出暫存器的高位3位,12位元偏移是從傳入虛擬地址原樣複製的。它們一起構成了一個15位的實體地址。然後將輸出暫存器作為實體記憶體地址放在記憶體匯流排上。
兩級層級頁表。
兩級分頁系統的地址轉換。
CPU生成的邏輯地址分為兩部分:頁碼(p)和頁面偏移量(d)。頁碼(p)用作頁表的索引,頁表包含實體記憶體中每個頁的基址。此基址與頁偏移量相結合,以定義實體記憶體,即傳送到記憶體單元。下圖顯示了分頁硬體:
頁面大小由硬體定義。2的冪的大小,每頁在512位元組和10Mb之間變化。如果邏輯地址空間的大小為2m地址單元,頁面大小為2n,則高位m-n表示頁碼,低位n表示頁面偏移量。
反向頁表結構。
範例:結合下圖,要顯示如何將中的邏輯記憶體對映到實體記憶體,請考慮4位元組的頁面大小和32位元組(8頁)的實體記憶體。
轉換後備緩衝區的使用。
當一個程序到達要執行的系統時,將檢查其大小(以頁表示)。程序的每一頁都需要一個幀。因此,如果程序需要n個頁面,那麼記憶體中必須至少有n個幀可用。如果n個幀可用,則將它們分配給此到達程序。程序的第一頁被載入到一個分配的幀中,幀編號被放入該程序的頁表中。下一頁被載入到另一個幀中,其幀編號被放入頁面表中,依此類推。
由於作業系統正在管理實體記憶體,因此它必須知道實體記憶體的分配細節,即分配了哪些幀,哪些幀可用,總共有多少幀,等等。這些資訊通常儲存在稱為幀表的資料結構中。幀表為每個物理頁幀都有一個條目,指示後者是空閒的還是已分配的,如果已分配,則指示哪個程序或程序的哪個頁面。
分頁替換演演算法有:
頁面替換演演算法總結如下表所示:
演演算法 | 描述 |
---|---|
最佳 | 不可實現,但可用作基準 |
NRU(最近未使用) | LRU的粗略近似值 |
FIFO(先進先出) | 可能會丟擲重要頁面 |
第二次機會 | 比FIFO有很大改進 |
時鐘 | 實時 |
LRU(最近最少使用) | 非常好,但難以準確實施 |
NFU(不常用) | 與LRU相當接近 |
老化(Aging) | 效率接近LRU |
工作環境 | 實施起來有些昂貴 |
WSClock | 高效演演算法 |
最佳演演算法將逐出將來參照最遠的頁面。不幸的是,無法確定這是哪個頁面,因此在實踐中無法使用此演演算法。然而,它可以作為衡量其他演演算法的基準。
NRU演演算法根據R和M位的狀態將頁面分為四類。從編號最低的類中隨機選擇一頁。此演演算法很容易實現,但很粗糙,有更好的存在。
FIFO通過將頁面儲存在連結列表中來跟蹤頁面載入到記憶體中的順序。然後刪除最舊的頁面就變得很簡單,但該頁面可能仍在使用中,因此FIFO是一個錯誤的選擇。
第二次機會是修改FIFO,在刪除頁面之前檢查頁面是否正在使用。如果是,則該頁面是空閒的,此修改大大提高了效能。時鐘只是第二次機會的不同實現,具有相同的效能屬性,但執行演演算法所需的時間稍短。
LRU是一種很好的演演算法,但如果沒有特殊的硬體就無法實現。如果此硬體不可用,則無法使用。NFU是近似LRU的粗略嘗試,不是很好。然而,老化是LRU的一個更好的近似值,可以有效實施,是一個很好的選擇。
最後兩個演演算法使用工作集。工作集演演算法提供了合理的效能,但實現起來有些昂貴。WSClock是一種變體,它不僅提供了良好的效能,而且實現效率也很高。
總之,最好的兩種演演算法是老化和WSClock。它們分別基於LRU和工作集,兩者都具有良好的分頁效能,並且可以有效地實現。還有其他一些好的演演算法,但這兩個演演算法在實踐中可能是最重要的。
頁面地址稱為邏輯地址,由頁碼和偏移量表示:
Logical Address = Page number + page offset
幀地址稱為實體地址,由幀編號和偏移量表示:
Physical Address = Frame number + page offset
稱為頁對映表的資料結構用於跟蹤程序的頁與實體記憶體中的幀之間的關係。
當系統為任何頁面分配一個幀時,它會將該邏輯地址轉換為實體地址,並在頁面表中建立條目,以便在整個程式執行過程中使用。
當一個程序被執行時,它對應的頁面被載入到任何可用的記憶體幀中。假設有一個8Kb的程式,但在給定的時間點,記憶體只能容納5Kb,那麼分頁概念就會出現。當計算機的RAM用完時,作業系統(OS)會將空閒或不需要的記憶體頁移到輔助記憶體中,以便為其他程序釋放RAM,並在程式需要時將其恢復。
在程式的整個執行過程中,作業系統會不斷從主記憶體中刪除空閒頁面,並將其寫入輔助記憶體,然後在程式需要時將其恢復。
分頁的優缺點:
每個程序都有自己的線性、虛擬和私有地址空間,地址空間開始於地址零,結束於某個最大值,基於作業系統位(32或64)和程序位。下圖展示了不同程序在私有記憶體地址空間下指向的實際實體地址,實體地址包含RAM和磁碟。
程序可以直接存取自己地址空間中的記憶體,意味著一個程序不能僅僅通過操縱指標來意外或惡意地讀取或寫入另一個程序的地址空間。可以存取另一個程序的記憶體,但這需要呼叫一個函數(ReadProcessMemory或WriteProcessMemority),該函數對目標程序具有足夠強的控制程式碼。
程序的地址空間稱為虛擬,指的是地址空間只是一個潛在記憶體對映的空間。每個程序開始時都會非常適度地使用其虛擬地址空間——可執行檔案與ntdll.Dll一起對映。然後,載入器(NtDll的一部分)在程序地址空間內分配一些基本結構,例如預設程序堆、程序環境塊(PEB)、程序中第一個執行緒的執行緒環境塊(TEB)。大部分地址空間為空。
程序的定址需求。
虛擬記憶體中的每個頁面可以處於三種狀態之一:空閒(free)、提交(committed)和保留(reserved)。
空閒頁面:未對映,因此嘗試存取該頁會導致存取衝突異常。程序的大部分地址空間開始時是空閒的。
提交頁面:與空閒頁面相反——是一個對映頁面,對映到RAM或檔案,存取該頁面應成功。如果頁面在RAM中,CPU直接存取資料並繼續。如果頁面不在RAM中(至少CPU查詢的表告訴它),CPU會引發一個稱為頁面錯誤的異常,由記憶體管理器捕獲。如果頁面確實駐留在磁碟上,記憶體管理器會將其帶回RAM,修復轉換表以指向RAM中的新地址,並指示CPU重試。從呼叫執行緒的角度來看,最終結果是存取成功。如果確實涉及I/O,存取速度會變慢,但呼叫執行緒不需要知道這一點,也不需要對它做任何特殊的操作——它是透明的。
提交記憶體通常稱為分配記憶體。呼叫C/C++記憶體分配函數,如malloc、calloc、operator new等,總是提交記憶體。
保留頁面:介於空閒和提交之間,類似於空閒頁面,因為存取該頁面會導致存取衝突——那裡沒有任何內容。保留頁可能稍後提交,保留頁範圍確保正常記憶體分配不會使用該範圍,因為它是為其他目的保留的,例如管理執行緒堆疊的。由於執行緒的堆疊可以增長,並且在虛擬記憶體中必須是連續的,因此保留了一個頁面範圍,以便程序中發生的其他分配不使用保留的地址範圍。
下表總結了頁面的三種狀態及特點:
頁面狀態 | 意義 | 如果存取 |
---|---|---|
空閒(free) | 未分配頁 | 存取衝突異常 |
提交(committed) | 已分配頁 | 成功(假設沒有頁面保護限制) |
保留(reserved) | 未分配頁,保留供將來使用 | 存取衝突異常 |
下表總結了不同系統的地址空間大小。
OS型別 | 程序型別 | LARGEADDRESSAWARE清理 | LARGEADDRESSAWARE設定 |
---|---|---|---|
32位元啟動,不增加UVA | 32位元 | 2GB | 2GB |
32位元啟動,增加UVA | 32位元 | 2GB | 2GB ~ 3GB |
64-bit (Windows 8.1+) | 32位元 | 2GB | 4GB |
64-bit (Windows 8.1+) | 64位元 | 2GB | 128TB |
64-bit (up to Windows 8) | 32位元 | 2GB | 4GB |
64-bit (up to Windows 8) | 64位元 | 2GB | 8TB |
在32位元系統上,存在兩種變體,結合上表,如下圖所示。
32位元意味著4GB,但為什麼程序只能獲得2GB?因為高2GB是系統空間(也稱為核心空間),是作業系統核心本身所在的位置,包括所有核心裝置驅動程式,以及它們在程式碼和資料方面消耗的記憶體。
64位元系統提供了幾個優點,第一個優點是大大增加了地址空間。64位元的理論極限是2到64次方,或16EB。大多數現代處理器只支援48位元虛擬和實體地址,意味著可以獲得的最大地址範圍是2到48次方或256TB。也就是說,64位元系統的每個程序可以有128TB的地址空間範圍,而其他128TB用於系統空間。
開發人員通常希望瞭解他們的程序在記憶體使用方面的情況。
下表是Windows任務管理(上圖)可以看到的記憶體使用資訊:
名字 | 描述 |
---|---|
記憶體使用圖 | 顯示過去60秒的實體記憶體(RAM)消耗 |
正在使用(壓縮) | 當前使用的實體記憶體(壓縮),壓縮記憶體的數量 |
提交/提交限制 | 頁面檔案擴充套件前的總提交記憶體/提交記憶體限制 |
記憶體組成 - 修改 | 尚未寫入磁碟的記憶體 |
記憶體組成 - 空閒 | 空閒頁面(大多數是零頁面) |
快取 | 如果需要,可以重新利用的記憶體(備用+修改) |
可用 | 可用實體記憶體(待機+空閒) |
分頁池/非分頁池 | 核心池記憶體使用 |
關於記憶體壓縮
記憶體壓縮是在Windows 10中新增的,作為一種通過壓縮當前不需要的記憶體來節省記憶體的方法,特別適用於UWP程序,因為它們不消耗CPU,因此這些程序使用的任何私有實體記憶體都可以被放棄。相反,記憶體被壓縮,仍然為其他程序留下空閒頁。當程序喚醒時,記憶體將快速解壓縮並準備使用,避免了對頁面檔案的I/O。
在Windows 10的前兩個版本中,壓縮記憶體儲存在系統程序的使用者模式地址空間中,但過於明顯且礙眼,所以從Windows 10版本1607開始,一個特殊的程序,記憶體壓縮(一個最小化程序),是保持壓縮記憶體的程序。此外,工作管理員完全沒有顯示此程序,但其他工具(如Process Explorer)正常顯示此程序。
上圖中的記憶體組成條以寬泛的筆劃表示物理頁面如何在內部管理,「正在使用」部分是當前被視為流程和系統工作集的一部分的頁面,備用頁是將其備份儲存在磁碟上的記憶體,但與所屬程序的關係仍然保留。如果流程現在觸及其中一個頁面,它們將立即返回其工作集(變為「正在使用中」)。如果這些頁面立即被丟擲到「空閒」頁面堆中,則需要I/O將頁面返回RAM。
修改部分表示內容尚未寫入備份儲存(通常為頁面檔案)的頁面,因此不能丟棄這些頁面。如果修改的頁面數量過多,或者待機和空閒頁面計數過小,則修改的頁面將寫入其備份檔案,並將移動到待機狀態。
所有這些轉換和管理都旨在減少I/O,這些物理頁面列表管理的更精確檢視可在Process Explorer的系統資訊檢視中的記憶體索引標籤中獲得,如下圖所示。
上圖的分頁列表部分詳細說明了執行人員記憶體管理用於管理物理頁面的各種列表。零頁面是隻包含零的頁面,與包含垃圾的空閒頁面相比,這些頁面佔大多數。一個特殊的執行執行緒稱為零頁執行緒,它以優先順序0(唯一具有此優先順序的執行緒)執行,是將空閒頁歸零的執行緒。零頁之所以重要,是為了滿足安全要求,即分配的記憶體永遠不能包含屬於另一個程序的資料,即使該程序不再存在。上上圖中記憶體組成中的空閒部分包括空閒頁和零頁的組合。
上圖另一個有趣的部分是,根據優先順序,沒有單一的備用頁面列表,但有八個,稱為記憶體優先順序,可以在Process Explorer中逐個執行緒地檢視,儘管這也是一個程序屬性,預設情況下由每個執行緒繼承。
當由於程序或系統需要實體記憶體,需要將待機列表中的頁移動為空閒頁時,使用記憶體優先順序。問題是,哪些頁面應該首先釋放?一種簡單的方法是使用FIFO佇列,其中從程序工作集中刪除的第一個頁面是第一個空閒的頁面。然而此法過於簡單,假設一個程序在後臺工作很多,例如反惡意軟體或備份應用程式。這些程序顯然使用記憶體,但它們不像使用者直接使用的應用程式那麼重要。因此,如果需要實體記憶體,它們的備用頁應該是第一個使用的,即使它們是最近使用的。這就是記憶體優先順序的作用。
預設記憶體優先順序為5。程序和執行緒的後臺模式,其CPU優先順序降低到4,記憶體優先順序降低到1,使得該程序使用的備用頁更有可能在記憶體優先順序較高的程序之前重用。如果想在不進入後臺模式的情況下更改記憶體優先順序,可以使用以下介面:
BOOL SetProcessInformation(HANDLE hProcess, PROCESS_INFORMATION_CLASS ProcessInformationClass, LPVOID ProcessInformation, DWORD ProcessInformationSize);
BOOL SetThreadInformation(HANDLE hThread, THREAD_INFORMATION_CLASS ThreadInformationClass, LPVOID ThreadInformation, DWORD ThreadInformationSize);
工作管理員中與程序相關的記憶體計數器有些混亂,「詳細資訊」索引標籤中顯示的預設記憶體計數器:記憶體(專用工作集)或記憶體(活動專用工作集)。讓我們分析這些術語:
這些計數器的問題在於工作集部分,表示當前在RAM中的專用記憶體,然而是一個不穩定的計數器,可能會根據流程活動而上下浮動。如果試圖確定程序提交(分配)了多少記憶體,或者程序洩漏了記憶體,那麼這些不是要檢視的計數器。
這些計數器僅顯示私有記憶體這一事實通常是一件好事,因為共用記憶體(如DLL程式碼使用的記憶體)是常數,因此任何人都無法對此做任何事情。私有記憶體是由程序控制的記憶體。
那麼,正確的計數器是什麼呢?是提交大小。為了使事情更加混亂,Process Explorer和效能監視器將此計數器稱為私有位元組。下圖顯示了工作管理員,它將提交大小和活動私有工作集並排排列,並按提交大小排序。
提交大小也與私有記憶體有關,所以它與私有工作集處於同等地位,區別在於不在工作集中的記憶體。如果兩個計數器都接近,則表示程序相當活躍,並使用其大部分記憶體,或者Windows的可用記憶體不低,因此記憶體管理器無法快速從工作集中刪除頁面。
在某些情況下,兩個計數器之間的差異可能非常大。在上圖中,流程程式碼(PID 34316)的大部分提交記憶體不是其工作集的一部分。這就是為什麼檢視專用工作集計數器會產生誤導,看起來這個程序消耗了大約97MB的記憶體,但實際上它消耗了大約368MB的記憶體。誠然,目前在RAM中,它僅使用97MB,但提交的記憶體確實消耗了頁表(用於對映提交的記憶體),並且該記憶體根據系統的提交限制計數。
使用工作管理員中的提交大小列確定程序的記憶體消耗,不包括共用記憶體,但並不重要(在大多數情況下)。在Process Explorer中,提交大小的等效值是私有位元組。工作管理員和Process Explorer都包含更多與記憶體相關的列(Procss Explorer包含多個工作管理員)。特別是一個列沒有類似的,即虛擬大小列,如下圖所示。
「虛擬大小」列統計所有不處於空閒狀態(即已提交和保留)的頁面,本質上是程序消耗的地址空間量。對於潛在地址空間為128 TB的64位元程序,無關緊要,對於32位元程序,可能是個問題。即使提交的記憶體不太高,但擁有大的保留記憶體區域會限制新分配的可用地址空間,可能會導致分配失敗,即使整個系統可能有足夠的空閒記憶體。
前面描述的計數器不包括保留記憶體,是有原因的。保留記憶體成本非常低,因為從CPU的角度來看,它與空閒記憶體是一樣的——不需要頁表來描述保留記憶體。事實上,從Windows 8.1開始,保留記憶體的成本甚至更低。
上圖虛擬大小欄中的一些數位似乎有些令人擔憂,一些程序的虛擬大小似乎約為2TB,私有位元組列顯示的數位要小得多,意味著虛擬大小描述的大部分記憶體大小都是保留的。一些程序擁有如此巨大的保留塊的真正原因是因為Windows 10的安全特性,稱為控制流保護(Control Flow Guard,CFG)。可以在Process Explorer中新增CFG列,將看到支援CFG的程序與巨大的2 TB保留區域之間的緊密關聯。
程序的地址空間必須包含程序在記憶體方面使用的所有內容:可執行程式碼和全域性資料、DLL程式碼和全域性資訊、執行緒堆疊、堆,以及程序提交和/或保留的任何其他記憶體。下圖顯示了程序虛擬地址空間的典型範例。
一個典型的程序載入幾十個DLL並可能使用許多執行緒,.NET等框架有自己的DLL和堆,但所有這些看似不同的東西都是由相同的「東西」組成的。
要檢視程序的實際記憶體對映,可以使用系統內部中的VMMap工具。啟動VMMap時,會立即顯示一個程序選擇對話方塊,可以在其中選擇感興趣的流程(下圖)。但是,VMMap仍然限於使用者模式存取,無法開啟受保護的程序。
一旦選擇了一個程序,VMMap的主檢視將由三個不同的水平部分填充(下圖顯示了Explorer.exe的範例)。
頂部顯示三個計數器:
每個計數器都有一個排序直方圖,顯示該計數器中包含的記憶體區域的型別,區域型別如第二部分所示。下表總結了VMMap顯示的區域型別。
型別 | 描述 |
---|---|
映象(Image) | 對映影象(EXE和DLL) |
對映檔案(Mapped File) | 對映檔案(Image除外) |
可共用(Shareable) | 頁檔案備份的記憶體對映檔案 |
堆 | 被堆使用的記憶體 |
託管堆 | 被.NET執行時(CLR或CoreLCR)管理的記憶體 |
棧 | 執行緒棧使用的記憶體 |
私有資料 | 用VirtualAlloc分配的通用記憶體 |
不可用(Unusable) | 無法使用的記憶體塊(小於64KB分配粒度) |
空閒 | 空閒頁面 |
如果需要獲取程序記憶體使用情況的摘要檢視,PSAPI函數GetProcessMemoryInfo可以提供幫助:
BOOL GetProcessMemoryInfo(HANDLE Process, PPROCESS_MEMORY_COUNTERS ppsmemCounters, DWORD cb);
該函數接受程序控制程式碼,該程序控制程式碼必須具有PROCESS_VM_READ存取掩碼,且帶有PROCESS_QUERY_LIMITED_INFORMATION或PROCESS_QUERY_INFORMATION。當前程序控制程式碼(GetCurrentProcess)是一個自然的候選,因為它具有完全存取掩碼。
程序虛擬地址空間中的每個提交頁都有保護標誌,可以使用VirtualAlloc或VirtualProtect函數設定。下表顯示了頁面保護屬性,可以從中為提交的頁面指定一個屬性,任何違反頁面保護的存取都會導致存取違反異常。
保護標記 | 描述 |
---|---|
PAGE_NOACCESS | 頁面不可存取 |
PAGE_READONLY | 僅讀存取 |
PAGE_READWRITE | 可讀寫存取 |
PAGE_WRITECOPY | 寫存取拷貝 |
PAGE_EXECUTE | 執行存取 |
PAGE_EXECUTE_READ | 執行和讀存取 |
PAGE_EXECUTE_READWRITE | 所有可能的存取 |
PAGE_EXECUTE_WRITECOPY | 執行存取和讀拷貝 |
除上述值外,還可以新增一些保護常數,如下表所示。
保護標記 | 描述 |
---|---|
PAGE_GUARD | 保護頁。任何存取都會導致頁面保護異常 |
PAGE_NOCACHE | 不可快取頁面。僅當核心驅動程式存取記憶體並且驅動程式需要時才應使用 |
PAGE_WRITECOMBINE | 一些核心驅動程式能夠使用的優化。不應普遍使用 |
PAGE_TARGETS_INVALID | 頁面是CFG的無效目標 |
PAGE_TARGETS_NO_UPDATE | 在使用VirtualProtect更改保護時不更新CFG資訊 |
通常程序具有不混合的單獨地址空間,然而在程序之間共用記憶體有時是有益的。典型的例子是DLL,所有使用者模式程序都需要NtDll,最需要的是Kernel32.dll,KernelBase.dll、AdvApi32.dll和許多其他。如果每個程序在實體記憶體中都有自己的DLL副本,將很快耗盡。事實上,擁有DLL的首要動機之一是共用(至少是程式碼)的能力。按照慣例,程式碼是唯讀的,因此可以安全地共用,來自EXE檔案的可執行程式碼也是如此。如果多個程序基於同一影象檔案執行,則沒有理由不共用。下圖的核心32.dll在兩個程序之間共用。
上圖共用DLL的所有程序中DLL的虛擬地址相同是必要的,因為並非所有程式碼都是可重定位的。如果我們在全域性範圍內宣告一個變數,如下所示:
int x;
void main()
{
x++;
//...
}
如果我們執行這個可執行檔案的兩個範例——第二個範例中的x值是多少?答案是1。x是程序的全域性資料,而不是系統的全域性資料,與DLL的工作原理相同。如果DLL宣告了一個全域性變數,則它僅對載入DLL的每個程序是全域性的。
在大多數情況下,這正是我們想要的,通過使用名為寫時複製(PAGE_WRITECOPY)的頁面保護來實現的。其思想是,所有使用相同變數的程序(在可執行檔案或這些程序使用的DLL中宣告)將該變數所在的頁面對映到相同的物理頁面(上圖)。如果程序更改了該變數的值(下圖的程序A),則會引發異常,導致記憶體管理器建立頁面的副本,並將其作為私有頁面移交給呼叫程序,從而刪除寫時的副本保護(下圖中的第3頁)。
將任何全域性資料複製到每個使用它的程序會更簡單,但會浪費實體記憶體。如果資料未更改,則無需進行復制。在某些情況下,需要在程序之間共用資料。一個相對簡單的機制是使用全域性變數,但是指定頁面應該由普通的PAGE_READWRITE而不是PAGE_WRITECOPY保護。可以通過在可執行檔案或DLL中構建新的資料段,並指定其所需的屬性來實現。下面程式碼顯示瞭如何實現這一點:
#pragma data_seg("shared")
int x = 0;
#pragma data_seg()
#pragma comment(linker, "/section:shared,RWS")
data_seg的pragma(編譯指示)在PE中建立一個新段,它的名稱可以是任何名稱(最多8個字元),為了清晰起見,在上面的程式碼中稱為「shared」。然後,應該共用的所有變數都放在該節中,並且必須顯式初始化它們,否則它們將不會儲存在該節。從技術上講,如果有幾個變數,則只需要明確初始化第一個變數。不過,最好將它們全部初始化。第二個#pragma是指向連結器的指令,用於建立具有屬性RWS(讀、寫、共用)的節。那個小「S」是關鍵,映像對映後,它將不具有PAGE_WRITECOPY保護,因此在使用相同PE的所有程序之間共用。
處理器只能存取實體記憶體(RAM)中的程式碼和資料。如果啟動了某些可執行檔案,Windows會將可執行檔案的程式碼和資料(以及NTdll.dll)對映到程序的地址空間,然後,程序的第一個執行緒開始執行,會導致它執行的程式碼(首先在NtDll.dll中,然後是可執行檔案)對映到實體記憶體並從磁碟載入,以便CPU可以執行它。
假設程序的執行緒都處於等待狀態,可能程序有一個使用者介面,使用者最小化了應用程式的視窗,有一段時間沒有使用應用程式。Windows可以將可執行檔案使用的RAM重新用於其他需要它的程序。現在假設使用者恢復應用程式的視窗——Windows現在必須將應用程式的程式碼帶回RAM。從哪裡讀取程式碼?可執行檔案本身。
意味著可執行檔案和DLL是它們自己的備份。事實上,Windows為可執行檔案和DLL建立了一個記憶體對映檔案(這也解釋了為什麼不能刪除這些檔案,因為這些檔案至少有一個開啟的控制程式碼)。
資料呢?如果長時間沒有存取某些資料(或者Windows的可用記憶體不足),記憶體管理器可以將資料寫入磁碟——頁面檔案(Page File)。頁面檔案用作專用提交記憶體的備份,不需要使用頁面檔案——沒有頁面檔案,Windows也可以正常執行,但減少了一次可以提交的記憶體量。
此外,Windows最多支援16頁檔案,它們必須位於不同的磁碟分割區中,並命名為pagefile.sys,位於根分割區(預設情況下隱藏檔案)。如果一個分割區太滿,或者另一個分割區是單獨的物理磁碟,那麼擁有多個頁面檔案可能會有好處,會增加I/O吞吐量。
工作管理員中顯示的提交限制本質上是RAM的數量加上所有頁面檔案的當前大小。每個頁面檔案可以具有初始大小和最大大小。當系統達到其提交限制時,頁面檔案將增加到其設定的最大值,因此提交限制現在會增加(由於更多I/O,效能可能會降低)。如果提交的記憶體低於原始提交限制,則頁面檔案大小將減少回其初始大小。
可以通過進入系統屬性,然後選擇高階系統設定,然後在效能部分選擇設定,然後選擇「高階」索引標籤,最後在虛擬記憶體部分選擇「更改…」來設定頁面檔案的大小,結果顯示如下圖所示的對話方塊。在單擊最後一個按鈕之前,請注意,頁面檔案的當前大小顯示在按鈕附近。
傳統的虛擬記憶體包含了虛擬地址、頁表入口、段表入口、以及它們的組合等概念和方式:
每個程序都有自己的虛擬、私有、線性地址空間,此地址空間開始時為空(或接近空,因為可執行映像和NtDll.Dll通常是第一個對映的)。一旦主(第一個)執行緒開始執行,可能會分配記憶體、載入更多DLL等。該地址空間是私有的,意味著其它程序無法直接存取它。地址空間範圍從零開始(技術上不能分配第一個64KB的地址),一直到最大值,取決於程序「位」(32或64位元)、作業系統「位」和連結器標誌,如下所示:
記憶體本身稱為虛擬的,意味著地址範圍和它在實體記憶體(RAM)中的確切位置之間存在間接關係。程序中的緩衝區可以對映到實體記憶體,也可以臨時駐留在檔案(如頁面檔案)中。術語「虛擬」是指從執行角度來看,不需要知道要存取的記憶體是否在RAM中;如果記憶體確實對映到RAM,CPU將直接存取資料。否則,CPU將引發頁面錯誤異常,導致記憶體管理器的頁面錯誤處理程式從適當的檔案中提取資料,將其複製到RAM,在對映緩衝區的頁面表條目中進行所需的更改,並指示CPU重試。
虛擬記憶體是一種允許執行可能無法在主記憶體中編譯的程序的技術,它將使用者邏輯記憶體與實體記憶體分開,這種分離允許在只有小實體記憶體可用時為程式提供超大記憶體。虛擬記憶體使程式設計任務變得更容易,因為程式設計師不再需要計算可用或不可用的實體記憶體量,允許不同程序通過頁面共用共用檔案和記憶體,通常由請求分頁實現。
程序的虛擬地址空間是指程序如何儲存在記憶體中的邏輯(或虛擬)檢視。通常,此檢視表示程序開始於某個邏輯地址,例如地址0,並且存在於連續記憶體中。
請求分頁系統類似於具有交換功能的分頁系統,當我們想執行一個程序時,我們把它交換到記憶體中。交換程式操作整個程序,其中分頁器(Pager)涉及程序的各個頁面,請求分頁的概念是使用分頁器而不是交換程式。當一個程序要換入時,分頁器會猜測在再次換出該程序之前將使用哪些頁面,而不是在整個過程中交換,分頁器只將那些必要的頁面放入記憶體。分頁記憶體到連續磁碟空間的傳輸如下圖所示。
因此,它避免了讀取無法使用的記憶體頁,從而減少了交換時間和所需的實體記憶體量。在這種技術中,我們需要一些硬體支援來區分記憶體中的頁面和磁碟上的頁面。為此,硬體使用了有效位和無效位——當該位設定為有效時,表示關聯頁面在記憶體中;如果該位設定為無效,則表示頁面無效或有效,但當前不在磁碟中。
如果程序從未嘗試存取頁面,則將頁面標記為無效將無效。因此,當程序執行並存取駐留在記憶體中的頁面時,執行將正常進行。存取標記為無效的頁面會導致頁面錯誤捕捉器(trap),是作業系統無法將所需頁面放入記憶體的結果。
結合下圖,如果程序參照的頁面不在實體記憶體中:
1、檢查此程序的內部表(頁表),以確定參照是否有效。
2、如果參照無效,則終止程序;如果參照有效但尚未引入,則必須從主記憶體引入。
3、現在在記憶體中找到一個自由幀。
4、然後,將所需的頁面讀入新分配的幀。
5、當磁碟讀取完成時,修改內部表以指示頁面現在在記憶體中。
6、重新啟動被非法地址捕捉器中斷的指令。現在,該程序可以像存取記憶體中的頁面一樣存取該頁面。
對於請求分頁,需要與分頁和交換相同的硬體。
請求分頁會對計算機系統的效能產生重大影響。設P(0<=P<=1)為頁面錯誤的概率,有效存取時間是(1-P) * ma + P * page fault
,其中:P=頁面故障、ma=記憶體存取時間。說明有效存取時間與頁面故障率成正比。在按需分頁中,保持頁面錯誤率低十分重要。
頁面錯誤會導致以下順序的行為發生:
1、作業系統陷阱。
2、儲存使用者註冊和程序狀態。
3、確定中斷是頁面錯誤。
4、檢查頁面參照是否合法,並確定頁面在磁碟上的位置。
5、從磁碟向空閒幀發出讀取。
6、如果等待,請將CPU分配給其他使用者。
7、從磁碟中斷。
8、儲存其他使用者的暫存器和程序狀態。
9、確定中斷來自磁碟。
10、更正頁面表和其他表,以顯示所需頁面現在在記憶體中。
11、等待CPU再次分配給該程序。
12、恢復使用者暫存器程序狀態和新頁表,然後恢復中斷的指令。
頁面替換策略:
結合下圖,頁面替換的描述如下:
結合下圖,頁面替換演演算法的過程如下:
1、查詢磁碟上派生頁的位置。
2、查詢自由幀。如果有空閒幀,就使用它。 否則,使用替換演演算法來選擇候選頁面。將候選頁面寫入磁碟,相應地更改頁面和幀表。
3、將所需頁面讀入自由幀,更改頁面和幀表。
4、重新啟動使用者程序。
候選頁面(Victim Page)是實體記憶體不足時支援的頁面。如果沒有空閒幀,則讀取兩個頁面轉換(輸出和一個輸入),將看到有效存取時間。每個頁面或幀可能有一個與硬體相關聯的髒(修改)位,每當寫入頁面中的任何單詞或位元組時,硬體都會設定頁面的修改位,表示頁面已被修改。當選擇要替換的頁面時,檢查其修改位,如果設定了位,那麼頁面會因為從磁碟讀取而被修改。如果未設定位,則頁面自讀入記憶體後未被修改。因此,如果頁的副本沒有被修改,可以避免將記憶體頁寫入磁碟,如果它已經存在的話。但有些頁面無法修改。
要實現請求頁面,必須解決兩個主要問題:
頁面替換演演算法決定當需要分配記憶體頁時,將交換哪個記憶體頁到頁。有3種頁面替換演演算法:
頁面錯誤:記憶體中不存在CPU要求的頁面。
頁面命中:記憶體中存在CPU要求的頁面。
1、FIFO(先進先出)演演算法
FIFO是最簡單的頁面替換演演算法,將每個頁面放入記憶體的時間關聯起來。當要替換頁面時,將選擇最舊的頁面,把佇列放在佇列的最前面,當一個頁面進入記憶體時,我們將它插入佇列的尾部。範例:考慮以下參照字串,其幀最初為空。
前三個參照(7,0,1)會出現頁面錯誤,並被放入空幀中。下一個參考頁面2取代了第7頁,因為第7頁是最先引入的。由於0是下一個參照,並且0已經在記憶體中,因此e沒有頁面錯誤。下一個參照3會導致頁面0被替換,因此下一個對0的參照會導致頁面錯誤,這將一直持續到字串結束。總共有15個頁面錯誤。
分析:參考頁數 = 20,頁面錯誤數 = 15,頁面命中次數 = 5,頁面命中率 = 頁面命中數/頁面參照總數 = (5 / 20)x100 = 25%
,頁面錯誤率 = 頁面錯誤數 / 頁面參照總數 = (15 / 20)x100= 75%。
Belady’的Anamoly問題:對於某些頁面替換演演算法,頁面錯誤可能會隨著分配的幀數的增加而增加。FIFO替換演演算法可能會面臨此問題。
最佳演演算法:最優頁面替換演演算法主要是解決Belady的Anamoly問題。理想情況下,我們希望選擇一個頁面錯誤率最低的演演算法,這種演演算法存在,並被稱為最優演演算法。其過程:替換最長時間(或根本不會)不會使用的頁面,即替換參考字串中向前距離最大的頁面。範例:考慮以下參照字串,其幀最初為空。
前三個參照會導致填充三個空幀的錯誤,第2頁的參考文獻取代了第7頁,因為只有參考文獻18才會使用第7頁。第0頁將在第5頁使用,第1頁將在14頁使用。只有9個頁面錯誤,最佳替換比有15個錯誤的FIFO好得多。該演演算法很難實現,因為它需要知道將來的參照字串。
分析:參考頁數=20,頁面錯誤數=9,頁面命中數=11,頁面命中率=頁面命中數/頁面參照總數 = (11 / 20)x100 = 55%,頁面錯誤率=頁面錯誤數/頁面參照總數= (9 / 20)x100=45%。
2、LRU(最近最少使用)演演算法
如果最優演演算法不可行,則可以近似最優演演算法。帶OPTS和FIFO的主要區別是:FIFO演演算法使用頁面內建的時間,OPT使用頁面使用的時間。LRU演演算法將替換最長時間未使用的頁面,將其頁面與上次使用該頁面的時間相關聯。這種策略是向後而不是向前看的最佳頁面替換演演算法。範例:考慮以下參照字串,其幀最初為空。
前5個錯誤類似於最佳更換。當參照第4頁時,LRU會看到三個幀中的第2頁,即最近最少使用的幀。最近使用的頁面是第0頁,剛好在使用第3頁之前。LRU策略通常用作頁面替換演演算法,被認為是不錯的選擇。
分析:參考頁數=20,頁面錯誤數=12,頁面命中數=8,頁面命中率=頁面命中數/頁面參照總數= (8 / 20)x100=40%,頁面錯誤率=頁面錯誤數/頁面參照總數= (12 / 20)x100=60%。
為了支援超大地址的記憶體空間,可以使用多級頁表,下圖a是具有兩個頁表欄位的32位元地址,b是兩級頁表:
4種常見的頁面替換演演算法的行為對比圖:
不同演演算法在固定分配、區域性頁面替換演演算法的比較:
前面討論的虛擬記憶體是一維的,因為虛擬地址從0到某個最大地址,一個接一個。對於許多問題,擁有兩個或多個獨立的虛擬地址空間可能比只有一個要好得多。例如,編譯器有許多在編譯過程中構建的表,可能包括:
隨著編譯的進行,前四個表中的每個表都在不斷增長。最後一個在編譯過程中以不可預知的方式增長和收縮。在一維記憶體中,這些五表必須分配連續的虛擬地址空間塊,如下圖所示。
考慮一下,如果一個程式的變數數量比平時大得多,但其他所有變數的數量都正常,會發生什麼。為符號表分配的地址空間塊可能已滿,但其他表中可能有很多空間。所需要的是一種方法,讓程式設計師不必管理擴充套件和收縮表,就像虛擬記憶體消除了將程式組織成覆蓋層的擔憂一樣。
一個簡單而通用的解決方案是為機器提供許多完全獨立的地址空間,這些地址空間稱為段(Segment)。每個段由一個線性地址序列組成,從0開始,一直到某個最大值。每個段的長度可以是從0到允許的最大地址之間的任意值。不同的段可能(通常)有不同的長度。此外,段長度可能在執行期間發生變化。每當有東西被推到堆疊上時,堆疊段的長度可能會增加,而當有東西從堆疊中彈出時,則會減少。
因為每個段構成一個單獨的地址空間,所以不同的段可以獨立增長或收縮,而不會相互影響。如果某個段中的堆疊需要更多的地址空間來增長,它的地址空間中沒有其他東西可以插入。當然,一個段可能會填滿,但段通常非常大,因此這種情況很少發生。要在這個分段或二維記憶體中指定地址,程式必須提供一個由兩部分組成的地址、段號和段內的地址。下圖說明了用於前面討論的編譯器表的分段記憶體,這裡顯示了五個獨立的段。
分段記憶體允許每個表獨立地進行增長或收縮。
段是一個邏輯實體,程式設計師知道並將其用作邏輯實體。一個段可能包含一個過程、一個陣列、一個堆疊或一組標量變數,但通常它不包含不同型別的混合。
分段記憶體除了簡化對增長或收縮的資料結構的處理之外,還有其他優點。如果每個過程佔用一個單獨的段,以地址0作為起始地址,那麼單獨編譯的過程的連結將大大簡化。在編譯並連線了構成程式的所有過程之後,對段n中的過程的過程呼叫將使用由兩部分組成的地址(n, 0)來定址字0(入口點)。
如果隨後修改並重新編譯了段n中的過程,則不需要更改其他過程(因為沒有修改起始地址),即使新版本比舊版本大。在一維記憶體中,過程被緊緊地擠在一起,彼此之間沒有地址空間。因此,更改一個過程的大小可能會影響段中所有其他(不相關)過程的起始地址。反過來,需要修改呼叫任何移動過程的所有過程,以便合併它們的新起始地址。如果一個程式包含數百個過程,那麼此過程可能代價高昂。
分段也有助於在多個程序之間共用程式或資料。一個常見的例子是共用庫。執行高階視窗系統的現代工作站通常在幾乎每個程式中都有超大的圖形庫。在分段系統中,圖形庫可以放在一個段中,由多個程序共用,這樣就不需要在每個程序的地址空間中都有圖形庫。雖然在純分頁系統中也可以有共用庫,但它更為複雜。實際上,這些系統是通過模擬分割來實現的。
由於每個段形成程式設計師所知道的邏輯實體,例如過程或陣列,因此不同的段可以有不同的保護型別。過程段可以指定為僅執行,禁止嘗試讀取或儲存過程段。浮點陣列可以指定為讀/寫,但不能執行,跳轉到該陣列的嘗試將被捕獲,這種保護有助於捕捉錯誤。下表比較了分頁和分段。
問題 | 分頁 | 分段 |
---|---|---|
程式設計師需要知道這項技術正在被使用嗎? | 否 | 是 |
有多少線性地址空間? | 1 | 很多 |
總地址空間能否超過實體記憶體的大小? | 是 | 是 |
程式和資料是否可以區分並單獨保護? | 否 | 是 |
大小不定的表能很容易地容納嗎? | 否 | 是 |
是否促進了使用者之間的程式共用? | 否 | 是 |
為什麼發明這種技術? | 無需購買更多實體記憶體即可獲得較大的線性地址空間 | 允許將程式和資料分解為邏輯獨立的地址空間,並幫助共用和保護 |
分段的實現與分頁在本質上有所不同:頁面大小固定,而分段則不是。下圖(a)顯示了最初包含五個段的實體記憶體的範例。現在考慮一下,如果段1被逐出,而較小的段7被放回原處,會發生什麼情況。我們得出(b)的記憶體設定。段7和段2之間是一個未使用的區域,即一個孔。然後段4替換為段5,如(c)所示,段3替換為段6,如(d)所示。在系統執行一段時間後,記憶體將被劃分為多個塊,一些包含段,一些包含孔。這種現象稱為棋盤格或外部碎片,會在洞中浪費記憶體。可以通過壓實處理,如(e)所示。
(a)-(d)棋盤的形成。(e) 通過壓實移除棋盤格。
動態分割區的效果。
下面闡述Intel x86的分頁分段技術。直到x86-64,x86的虛擬記憶體系統在許多方面都與MULTICS相似,包括分段和分頁。MULTICS有256K個獨立的段,每個段最多64K個36位元字,而x86有16K個獨立段,每個獨立段最多可容納10億個32位元字。雖然段數較少,但較大的段大小更為重要,因為很少程式需要1000個以上的段,但許多程式需要較大的段。從x86-64開始,分段被認為是過時的,不再受支援,除非是在傳統模式下。儘管在x86-64的原生模式中仍然可以使用一些舊的分割機制的殘留物,主要是為了相容性,但它們不再扮演相同的角色,也不再提供真正的分段。
虛擬地址轉換為實體地址的轉換本身是自動的,因此當CPU看到如下指令時:
mov eax, [100000H]
它知道地址0x100000是虛擬的而不是物理的(因為CPU設定為在保護模式/長模式下執行)。CPU現在必須檢視記憶體管理器預先準備的表,這些表描述了頁面在RAM中的位置(如果有的話)。如果它不在RAM中(由CPU在轉換表中檢查的有效位中的零標記),則會引發頁面錯誤異常,由記憶體管理器適當處理。地址轉換涉及的基本元件如下圖所示。
CPU被提供虛擬地址作為輸入,並且應該輸出(和使用)實體地址。由於所有工作都是按照頁面工作的,所以地址的低12位元(頁面內的偏移量)永遠不會被轉換,並按原樣傳遞到最終地址。
CPU需要上下文進行轉換。每個程序都有一個初始結構,它總是駐留在RAM中。對於32位元系統,它稱為頁面目錄指標表,對於64位元系統,則稱為頁面對映級別4(Intel術語)。從這個初始結構開始,使用其他結構,包括頁面目錄和頁面表,頁表條目是指向物理頁地址的條目(如果設定了有效位)。當頁面移動到頁面檔案時,記憶體管理器將相應的頁面表條目標記為無效,以便CPU下次遇到該頁面時,將引發頁面錯誤異常。
最後,轉換查詢緩衝區(Translation Lookaside Buffer,TLB)是最近轉換的頁面的快取,因此存取這些頁面不需要為了轉換目的而通過多層結構。該快取相對較小,從實用角度來看非常重要。在鄰近的時間使用相同範圍的記憶體地址對利用TLB快取非常有用。
分頁和轉換後備緩衝器(TLB)的操作。
Windows提供了多組API來處理記憶體,下圖顯示了可用集及其依賴關係。
最底層是虛擬API最接近記憶體管理器,有幾個含義:
相關虛擬API如下:
LPVOID VirtualAlloc(LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
LPVOID VirtualAllocEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
PVOID VirtualAllocFromApp(PVOID BaseAddress, SIZE_T Size, ULONG AllocationType, ULONG Protection);
BOOL VirtualFree(LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType);
BOOL VirtualFreeEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType);
工作集(Working Set)表示在不發生頁面錯誤的情況下可存取的記憶體。當然,一個程序希望其所有提交的記憶體都在其工作集中,記憶體管理器必須平衡一個程序和所有其他程序的需求,長時間未存取的記憶體可能會從程序的工作集中刪除,並不意味著它會被自動丟棄——記憶體管理器有精心設計的演演算法,可以將曾經是程序工作集一部分的物理頁面保留在RAM中的時間超過可能需要的時間,因此,如果有問題的程序決定存取該記憶體,它可能會立即發現錯誤進入工作集(稱為軟頁面故障)。
通過GetProcessMemoryInfo,可以獲得程序的當前和峰值工作集:
BOOL GetProcessMemoryInfo(HANDLE Process, PPROCESS_MEMORY_COUNTERS ppsmemCounters, DWORD cb);
程序具有最小和最大工作集。預設情況下,這些限制是軟的,因此如果記憶體充足,程序可以消耗比其最大工作集更多的RAM,如果記憶體不足,則可以使用比其最小工作集更少的RAM。使用GetProcessWorkingSetSize查詢這些限制:
BOOL GetProcessWorkingSetSize(HANDLE hProcess, PSIZE_T lpMinimumWorkingSetSize, PSIZE_T lpMaximumWorkingSetSize);
其它工作集相關的API:
BOOL SetProcessWorkingSetSize(HANDLE hProcess, SIZE_T dwMinimumWorkingSetSize, SIZE_T dwMaximumWorkingSetSize);
BOOL WINAPI EmptyWorkingSet(HANDLE hProcess);
BOOL SetProcessWorkingSetSizeEx(HANDLE hProcess, SIZE_T dwMinimumWorkingSetSize, SIZE_T dwMaximumWorkingSetSize, DWORD Flags);
BOOL GetProcessWorkingSetSizeEx(HANDLE hProcess, PSIZE_T lpMinimumWorkingSetSize, PSIZE_T lpMaximumWorkingSetSize, PDWORD Flags);
VirtualAlloc函數集非常強大,因為它們非常接近記憶體管理器。然而,也有一個缺點。這些函數只在頁面塊中工作:如果分配10個位元組,則返回一個頁面。如果再分配10個位元組,則會得到不同的頁面,對於管理在應用程式中非常常見的小型分配來說太浪費了。這正是堆起作用的地方。
堆管理器是一個在虛擬API之上分層的元件,它知道如何有效地管理小型分配。在此上下文中,堆是由堆管理器管理的記憶體塊,每個程序都從單個堆開始,稱為預設程序堆。使用GetProcessHeap獲得該堆的控制程式碼:
HANDLE GetProcessHeap();
可以建立更多堆,有了堆,使用HeapAlloc分配(提交)記憶體:
LPVOID HeapAlloc(HANDLE hHeap, DWORD dwFlags, SIZE_T dwBytes);
其它堆相關的API:
BOOL HeapFree(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem);
HANDLE HeapCreate(DWORD flOptions, SIZE_T dwInitialSize, SIZE_T dwMaximumSize);
BOOL HeapDestroy(HANDLE hHeap);
C/C++記憶體管理函數(如malloc、calloc、free、C++的new和delete操作符等)的實現取決於編譯器提供的庫。C/C++執行時使用堆函數來管理它們的分配。以下是malloc的實現,為了清晰起見,刪除了一些宏和指令(在malloc.cpp中):
extern "C" void* __cdecl malloc(size_t const size)
{
#ifdef _DEBUG
return _malloc_dbg(size, _NORMAL_BLOCK, nullptr, 0);
#else
return _malloc_base(size);
#endif
}
malloc有兩個實現——一個用於偵錯構建,另一個用於釋出構建,以下是釋出構建的摘錄(在檔案malloc_base.cpp中):
extern "C" __declspec(noinline) void* __cdecl _malloc_base(size_t const size)
{
// Ensure that the requested size is not too large:
_VALIDATE_RETURN_NOEXC(_HEAP_MAXREQ >= size, ENOMEM, nullptr);
// Ensure we request an allocation of at least one byte:
size_t const actual_size = size == 0 ? 1 : size;
for (;;)
{
void* const block = HeapAlloc(__acrt_heap, 0, actual_size);
if (block)
return block;
//...code omitted...
}
extern "C" bool __cdecl __acrt_initialize_heap()
{
__acrt_heap = GetProcessHeap();
if (__acrt_heap == nullptr)
return false;
return true;
}
實際上,以上API只是VirtualAlloc的冰山一角,Windows還提供了其它諸多功能的API。
非統一記憶體體系架構(NUMA)系統涉及一組節點,每個節點持有一組處理器和記憶體。下圖顯示了此類系統的拓撲範例。
上圖顯示了具有兩個NUMA節點的系統範例,每個節點擁有一個具有4個核心和8個邏輯處理器的通訊端。NUMA系統仍然是對稱的,因為任何CPU都可以執行任何程式碼並存取任何節點中的任何記憶體。然而,從本地節點存取記憶體要比存取另一個節點中的記憶體快得多。
Windows知道NUMA系統的拓撲結構。之前討論的執行緒排程,排程程式充分利用了這些資訊,並嘗試在CPU上排程執行緒,其中執行緒堆疊位於該節點的實體記憶體中。NUMA系統通常用於伺服器機器,其中通常存在多個通訊端。
檔案對映物件在Windows中無處不在。載入影象檔案(EXE或DLL)時,將使用記憶體對映檔案將其對映到記憶體。通過這種對映,通過標準指標存取記憶體,間接存取底層檔案。當程式碼需要在映像內執行時,初始存取會導致頁面錯誤異常,記憶體管理器在修復用於對映此記憶體的適當頁面表之前,通過從檔案讀取資料並將其放入實體記憶體來處理該異常,此時呼叫執行緒可以存取程式碼/資料。這些對應用程式來說是透明的。
一些程式碼需要在檔案中搜尋一些資料,而搜尋需要在檔案內來回跳轉。對於I/O API,充其量是不方便的,涉及對ReadFile(預先分配了緩衝區)和SetFilePointer(Ex)的多次呼叫。另一方面,如果檔案的「指標」可用,那麼移動和執行檔案操作就容易得多:無需分配緩衝區,無需讀取檔案呼叫,任何檔案指標更改只需轉換為指標算術。所有其他常見記憶體函數,如memcpy、memset等,在記憶體對映檔案中也同樣有效。
涉及記憶體對映檔案的常見API有:
HANDLE CreateFileMapping(HANDLE hFile, LPSECURITY_ATTRIBUTES lpFileMappingAttributes, DWORD flProtect, DWORD dwMaximumSizeHigh, DWORD dwMaximumSizeLow, LPCTSTR lpName);
LPVOID MapViewOfFile(HANDLE hFileMappingObject, DWORD dwDesiredAccess, DWORD dwFileOffsetHigh, DWORD dwFileOffsetLow, SIZE_T dwNumberOfBytesToMap);
BOOL UnmapViewOfFile(_In_ LPCVOID lpBaseAddress);
程序是相互隔離的,因此每個都有自己的地址空間、自己的控制程式碼表等。大多數時候,正是我們想要的。然而,在某些情況下,資料需要在程序之間以某種方式共用。Windows為程序間通訊(IPC)提供了許多機制,包括元件物件模型(COM)、Windows訊息、通訊端、管道、郵件槽、遠端過程呼叫(RPC)、剪貼簿、動態資料交換(DDE)等。每種方法都有其優點和缺點,但上述所有方法的共同主題是記憶體必須從一個程序複製到另一個程序。
記憶體對映檔案是IPC的另一種機制,是所有機制中最快的,因為沒有複製(事實上,其他一些IPC機制在同一臺機器上的程序之間通訊時使用記憶體對映檔案)。一個程序將資料寫入共用記憶體,所有其他具有同一檔案對映物件控制程式碼的程序都可以立即看到記憶體,因為每個程序都將同一記憶體對映到自己的地址空間,所以不會進行復制。
共用記憶體基於存取同一檔案對映物件的多個程序,物件可以通過三種方式中的任何一種共用,最簡單的方法是使用檔案對映物件的名稱。共用記憶體本身可以由特定檔案(CreateFileMapping的有效檔案控制程式碼)備份,在這種情況下,即使在檔案對映物件被銷燬後,資料仍然可用,或者由分頁檔案備份,在該情況下,一旦檔案對映物件銷燬,資料將被丟棄。這兩個選項的工作方式基本相同。
檔案對映物件在資料一致性方面提供了若干保證:
動態連結庫(DLL)是Windows NT的基本組成部分。DLL存在背後的主要動機是,它們可以在程序之間輕鬆共用,因此DLL的單個副本位於RAM中,所有需要它的程序都可以共用DLL的程式碼。在早期,RAM比現在小得多,使得記憶體節省非常重要。即使在今天,記憶體節省也非常重要,因為一個典型的程序使用了幾十個DLL。
DLL是可移植可執行(PE)檔案,可以包含以下一個或多個:程式碼、資料和資源。每個使用者模式程序都使用子系統dll,如kernel32.dll、user32.dll、gdi32.dll和advapi32.dll,實現檔案化的Windows API。當然,Ntdll.Dll在每個使用者模式程序中都是必需的,包括原生應用程式。
DLL是可以包含函數、全域性變數和資源(如選單、點陣圖和圖示)的庫。某些函數(和型別)可以通過DLL匯出,以便載入DLL的其他DLL或可執行檔案可以直接使用它們。DLL可以在程序啟動時隱式載入到程序中,也可以在應用程式呼叫LoadLibrary或LoadLibraryEx函數時顯式載入。
顯式連結到DLL可以更好地控制何時載入和解除安裝DLL。此外,如果DLL載入失敗,程序不會崩潰,因此應用程式可以處理錯誤並繼續。顯式DLL連結的一個常見用途是載入語言相關資源。例如,應用程式可能嘗試載入帶有當前系統區域設定中資源的DLL,如果未找到,則可以載入預設資源DLL,該DLL始終作為應用程式安裝的一部分提供。對於顯式連結,不使用匯入庫,因此載入程式不會嘗試載入DLL(可能不存在)。這也意味著不能使用#include來獲取匯出的符號宣告,因為連結器將因「未解決的外部」錯誤而失敗。我們如何使用這樣的DLL?
第一步是在執行時載入它,通常在需要的地方載入。這是LoadLibrary的工作:
HMODULE LoadLibrary(LPCTSTR lpLibFileName);
LoadLibrary只接受檔名或完整路徑。如果只指定了檔名,則搜尋DLL的順序與隱式載入DLL的順序相同。如果指定了完整路徑,則只嘗試載入該檔案。在實際搜尋開始之前,載入器檢查是否有一個具有相同名稱的模組已載入到程序地址空間中。如果是,則不執行搜尋,並返回現有DLL的控制程式碼。例如,如果SimpleDell.Dll已載入(無論從哪個路徑),並呼叫LoadLibrary載入名為SimpleDll.Dll的檔案(在任何路徑或不帶路徑的情況下),不會載入其他Dll。
成功載入DLL後,可以使用GetProcAddress從DLL存取匯出的函數:
FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);
函數返回DLL中匯出符號的地址。第二個引數是符號的名稱,注意,名稱必須是ASCII。返回值是一個通用的FARPROC,其中「遠」和「近」表示不同的東西。如果符號不存在(或未匯出,這是相同的),則GetProcAddress返回NULL。舉個例子:
// dll的其中一個函數宣告
__declspec(dllexport) bool IsPrime(int n);
// 載入dll,匯出上面的函數地址,並呼叫。
auto hPrimesLib = ::LoadLibrary(L"SimpleDll.dll");
if (hPrimesLib)
{
// DLL found
using PIsPrime = bool (*)(int);
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");
if (IsPrime)
{
bool test = IsPrime(17);
printf("%d\n", (int)test);
}
}
這段程式碼看起來相對簡單——DLL已載入。不幸的是,對GetProcAddress的呼叫失敗,GetLastError返回127(找不到指定的程序)。顯然,GetProcAddress無法定位匯出的函數,即使它已匯出。為什麼?
原因與函數的名稱有關,如果我們使用Dumpbin查探關於SimpleDll.Dll的資訊:
0 000111F9 ?IsPrime@@YA_NH@Z = @ILT+500(?IsPrime@@YA_NH@Z)
原因找到了——連結器「弄亂」了要使用的函數的名稱:IsPrime@@YA_NH@Z。原因與IsPrime
在C++中不夠唯一有關。iPrime函數可以是A類、B類以及全域性函數,也可能是某個名稱空間C的一部分。此外,由於C++函數過載,同一作用域中可能有多個名為IsPrime的函數。因此,連結器為函數提供了一個奇怪的名稱,其中包含這些獨特的屬性。我們可以嘗試在前面的程式碼範例中替換這個損壞的名稱,如下所示:
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "?IsPrime@@YA_NH@Z");
你會發現它是有效的!然而,這非常無趣且不太實用,我們必須用一種方法來查詢損壞的名稱,以使其正確。通常的做法是將所有匯出的函數轉換為C風格的函數。因為C不支援函數過載或類,所以連結器不必進行復雜的修改。下面是一種將函數匯出為C的方法:
extern "C" __declspec(dllexport) bool IsPrime(int n);
如果編譯C檔案,以上將是預設值。
通過此更改,可以簡化獲取指向IsPrime函數的指標:
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");
但是,這種將函數轉換為C風格的方案不能用於類中的成員函數——正是使用GetProcAddress存取C++函數不實際的原因,也是大多數用於LoadLibrary/GetProcAddress的DLL僅公開C風格函數的原因。
如果DLL不再需要,可以使用以下API釋放之:
BOOL FreeLibrary(HMODULE hLibModule);
系統為每個載入的DLL維護每個程序計數器。如果對同一DLL多次呼叫LoadLibrary,則需要相同數量的FreeLibrary呼叫才能從程序地址空間真正解除安裝DLL。如果需要載入DLL的控制程式碼,則可以使用GetModuleHandle檢索它:
HMODULE GetModuleHandle(LPCTSTR lpModuleName);
呼叫約定(Calling Convention)表明函數引數如何傳遞給函數,以及如果在堆疊上傳遞,誰負責清理引數。對於x64,只有一個呼叫約定。對於x86,有幾個,最常見的是標準呼叫約定stdcall和C呼叫約定cdecl。stdcall和cdecl都使用堆疊傳遞引數,從右向左推播。它們之間的主要區別在於,對於stdcall,被呼叫方(函數體本身)負責清理堆疊,而對於cdecl,呼叫方負責清理堆疊。
stdcall的優點是更小,因為堆疊清理程式碼只顯示一個(作為函數體的一部分)。使用cdecl,對函數的每次呼叫都必須跟隨一條指令,以清除堆疊中的引數。cdecl函數的優點是它們可以接受可變數量的引數(在C/C++中由…指定),因為只有呼叫方知道傳入了多少引數。
Visual C++中使用者模式專案中使用的預設呼叫約定是cdecl,通過在返回型別和函數名之間放置適當的關鍵字來指定呼叫約定。為此,Microsoft編譯器重新識別__cdecl
和__stdcall
關鍵字,使用的關鍵字也必須在實現中指定,以下是將IsPrime設定為使用stdcall的範例:
extern "C" __declspec(dllexport) bool __stdcall IsPrime(int n);
這也意味著,在定義函數指標以與GetProcAddress一起使用時,還必須指定正確的呼叫約定,否則我們將得到執行時錯誤或堆疊損壞:
using PIsPrime = bool (__stdcall *)(int);
// or
typedef bool(__stdcall* PIsPrime)(int);
__stdcall
是大多數Windows API使用的呼叫約定,通常是使用WINAPI、APIENTRY、PASCAL、CALLBACK之一的宏,它們的含義完全相同。
DLL可以有一個入口點(但不是必須,也可以沒有),傳統上稱為DllMain,必須具有以下原型:
BOOL WINAPI DllMain(HINSTANCE hInsdDll, DWROD reason, PVOID reserved);
hInstance引數是DLL載入到程序中的虛擬地址,如果顯式載入DLL,則它與從LoadLibrary返回的值相同。reason引數指示呼叫DllMain的原因,其值如下表所述:
Reason值 | 描述 |
---|---|
DLL_PROCESS_ATTACH | 當DLL附加到程序時呼叫 |
DLL_PROCESS_DETACH | 在從程序解除安裝DLL之前呼叫 |
DLL_THREAD_ATTACH | 在程序中建立新執行緒時呼叫 |
DLL_THREAD_DETACH | 線上程退出程序之前呼叫 |
在某些情況下,需要將DLL注入另一個程序。注入DLL是指以某種方式強制另一個程序載入特定的DLL,允許DLL在目標程序的上下文中執行程式碼。這種能力有很多用途,但它們本質上都歸結為某種形式的客製化或目標程序內操作的攔截。以下是一些具體的例子:
通過在載入所需DLL的目標程序中建立執行緒來注入DLL可能是最廣為人知和最直接的技術。其思想是在目標程序中建立一個執行緒,該執行緒使用要注入的DLL路徑呼叫LoadLibrary函數。將程式碼執行到目標程序中的範例程式碼如下:
int main(int argc, const char* argv[])
{
// 檢查命令列引數
if (argc < 3)
{
printf("Usage: injector <pid> <dllpath>\n");
return 0;
}
// 注入器需要目標程序ID和要注入的DLL, 故而開啟目標程序的控制程式碼
HANDLE hProcess = ::OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD, FALSE, atoi(argv[1]));
if (!hProcess)
return Error("Failed to open process");
// 準備要載入的DLL路徑, 路徑字串本身必須放在目標程序中,因為是執行LoadLibrary的地方. 可以使用VirtualAllocEx函數.
void* buffer = ::VirtualAllocEx(hProcess, nullptr, 1 << 12, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!buffer)
return Error("Failed to allocate buffer in target process");
// 使用WriteProcessMemory將DLL路徑複製到分配的緩衝區
if (!::WriteProcessMemory(hProcess, buffer, argv[2], ::strlen(argv[2]) + 1, nullptr))
return Error("Failed to write to target process");
// 建立遠端執行緒
DWORD tid;
HANDLE hThread = ::CreateRemoteThread(hProcess, nullptr, 0,
(LPTHREAD_START_ROUTINE)::GetProcAddress(::GetModuleHandle(L"kernel32"), "LoadLibraryA"),
buffer, 0, &tid);
if (!hThread)
return Error("Failed to create remote thread");
// 等待遠端執行緒退出
printf("Thread %u created successfully!\n", tid);
if (WAIT_OBJECT_0 == ::WaitForSingleObject(hThread, 5000))
printf("Thread exited.\n");
else
printf("Thread still hanging around...\n");
// 釋放和清理
::VirtualFreeEx(hProcess, buffer, 0, MEM_RELEASE);
::CloseHandle(hThread);
::CloseHandle(hProcess);
}
以上程式碼中,必須指定DLL的完整路徑,因為載入規則是從目標程序的角度,而不是呼叫方的角度。注入的DLL的DllMain顯示了一個簡單的訊息方塊:
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID lpReserved)
{
switch (reason)
{
case DLL_PROCESS_ATTACH:
wchar_t text[128];
::StringCchPrintf(text, _countof(text), L"Injected into process %u", ::GetCurrentProcessId());
::MessageBox(nullptr, text, L"Injected.Dll", MB_OK);
break;
}
return TRUE;
}
Windows掛鉤指的是一組與使用者介面相關的掛鉤,可通過SetWindowsHookEx等API使用:
HHOOK SetWindowsHookEx(int idHook, HOOKPROC lpfn, HINSTANCE hmod, DWORD dwThreadId);
lpfn提供的勾點函數具有以下原型:
typedef LRESULT (CALLBACK* HOOKPROC)(int code, WPARAM wParam, LPARAM lParam);
範例程式碼:
int main()
{
DWORD tid = FindMainNotepadThread();
if (tid == 0)
return Error("Failed to locate Notepad");
auto hDll = ::LoadLibrary(L"HookDll");
if (!hDll)
return Error("Failed to locate Dll\n");
using PSetNotify = void (WINAPI*)(DWORD, HHOOK);
auto setNotify = (PSetNotify)::GetProcAddress(hDll, "SetNotificationThread");
if (!setNotify)
return Error("Failed to locate SetNotificationThread function in DLL");
auto hookFunc = (HOOKPROC)::GetProcAddress(hDll, "HookFunction");
if (!hookFunc)
return Error("Failed to locate HookFunction function in DLL");
// 設定掛鉤。
auto hHook = ::SetWindowsHookEx(WH_GETMESSAGE, hookFunc, hDll, tid);
if (!hHook)
return Error("Failed to install hook");
(...)
}
在DLL(或EXE)中共用變數的技術。注入應用程式在其自身程序的上下文中呼叫SetNotificationThread,但函數將資訊寫入共用變數,因此這些變數可用於使用相同DLL的任何程序:
API掛鉤是指攔截Windows API(或更一般地說,任何外部函數)的行為,以便可以檢查其引數並可能改變其行為,是一種非常強大的技術,首先是反惡意軟體解決方案所採用的技術,通常將自己的DLL注入每個程序(或大多數程序),並掛接他們關心的某些函數,如VirtualAllocEx和CreateRemoteThread,將它們重定向到DLL提供的備用實現。在該實現中,他們可以檢查引數並在向呼叫方返回錯誤程式碼或將呼叫轉發到原始函數之前執行任何需要的操作。
匯入地址表(Import Address Table,IAT)掛鉤可能是函數掛鉤的最簡單方法,設定相對簡單,不需要任何特定於平臺的程式碼。每個PE映像都有一個匯入表,其中列出了它所依賴的DLL以及它從DLL中使用的函數。使用dumpbin或圖形工具檢查PE檔案來檢視這些匯入,以下是notepad.exe的模組概覽:
D:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\bin\Hostx64\arm>dumpbin /imports c:\Windows\System32\notepad.exe
Microsoft (R) COFF/PE Dumper Version 14.29.30133.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file c:\Windows\System32\notepad.exe
File Type: EXECUTABLE IMAGE
Section contains the following imports:
KERNEL32.dll
1400268B8 Import Address Table
14002D3D8 Import Name Table
0 time date stamp
0 Index of first forwarder reference
2B8 GetProcAddress
DC CreateMutexExW
1 AcquireSRWLockShared
114 DeleteCriticalSection
221 GetCurrentProcessId
2BE GetProcessHeap
281 GetModuleHandleW
10A DebugBreak
387 IsDebuggerPresent
342 GlobalFree
(...)
GDI32.dll
140026800 Import Address Table
14002D320 Import Name Table
0 time date stamp
0 Index of first forwarder reference
34 CreateDCW
39F StartPage
39D StartDocW
366 SetAbortProc
180 DeleteDC
18E EndDoc
(...)
USER32.dll
140026B50 Import Address Table
14002D670 Import Name Table
0 time date stamp
0 Index of first forwarder reference
2AF PostMessageW
28C MessageBoxW
177 GetMenu
43 CheckMenuItem
1C2 GetSubMenu
E9 EnableMenuItem
38D ShowWindow
142 GetDC
2FC ReleaseDC
(...)
Summary
3000 .data
1000 .didat
2000 .pdata
A000 .rdata
1000 .reloc
1000 .rsrc
25000 .text
呼叫這些匯入函數的方式是通過匯入地址表,該表包含載入程式(NtDll.Dll)在執行時對映這些函數後這些函數的最終地址。這些地址事先不知道,因為DLL可能不會在其首選地址載入。
IAT掛鉤利用了所有呼叫都是間接呼叫的事實,在執行時只替換表中的函數地址以指向替代函數,同時儲存原始地址,以便在需要時呼叫實現。這種掛鉤可以在當前程序上完成,也可以在另一個程序的上下文中與DLL注入相結合。
必須在所有程序模組中搜尋要掛鉤的函數,因為每個模組都有自己的IAT。例如,記事本可以呼叫函數CreateFileW.exe模組本身,但當呼叫「開啟檔案」對話方塊時,ComCtl32.dll也可以呼叫它。如果只對記事本的呼叫感興趣,那麼它的IAT是唯一需要連線的。否則,必須搜尋所有載入的模組,並且必須替換CreateFileW的IAT條目。
下面的範例程式碼從User32.Dll中掛接GetSysColor API,並在應用程式中更改一些顏色,而無需接觸應用程式的UI程式碼:
void HookFunctions()
{
auto hUser32 = ::GetModuleHandle(L"user32");
// save original functions
GetSysColorOrg = (decltype(GetSysColorOrg))::GetProcAddress(hUser32, "GetSysColor");
// IAT輔助函數使得掛鉤使用變得很簡單
auto count = IATHelper::HookAllModules("user32.dll", GetSysColorOrg, GetSysColorHooked);
ATLTRACE(L"Hooked %d calls to GetSysColor\n");
}
掛接函數的另一種常見方法是執行以下步驟:
該方案比IAT掛鉤更強大,因為無論是否通過IAT呼叫,實際函數程式碼都會被修改。但這種方法有兩個缺點:
實現這種掛鉤很困難,需要複雜的CPU指令和呼叫約定知識,更不用說上面的同步問題了。有幾個開源和免費的庫提供此功能,其中一個叫做「Detours」(迂迴),來自微軟,但也有其他如MinHook和EasyHook。如果需要這種掛鉤,優先考慮使用現有的庫。以下是Detours掛鉤使用範例:
#include <detours.h>
bool HookFunctions()
{
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach((PVOID*)&GetWindowTextOrg, GetWindowTextHooked);
DetourAttach((PVOID*)&GetWindowTextLengthOrg, GetWindowTextLengthHooked);
auto error = DetourTransactionCommit();
return error == ERROR_SUCCESS;
}
Detours與事務的概念一起工作,事務是一組提交以原子方式執行的操作。我們需要儲存原始函數,可以在掛接之前使用GetProcAddress完成,也可以使用指標定義完成:
decltype(::GetWindowTextW)* GetWindowTextOrg = ::GetWindowTextW;
decltype(::GetWindowTextLengthW)* GetWindowTextLengthOrg = ::GetWindowTextLengthW;
每個DLL都有一個首選載入(基)地址,即PE頭的一部分,甚至可以使用Visual Studio中的專案屬性來指定它(下圖)。
預設情況下沒有任何內容,使得VisualStudio使用一些預設值。對於32位元DLL,這些值為0x10000000;對於64位元DLL,它們為0x180000000。可以通過dumping從PE檔頭資訊來驗證:
dumpbin /headers HookDll_x64.dll
...
OPTIONAL HEADER VALUES
20B magic # (PE32+)
...
112FD entry point (00000001800112FD) @ILT+760(_DllMainCRTStartup)
1000 base of code
180000000 image base (0000000180000000 to 0000000180025FFF)
...
dumpbin /headers HookDll_x86.dll
...
OPTIONAL HEADER VALUES
10B magic # (PE32)
...
111B8 entry point (100111B8) @ILT+435(__DllMainCRTStartup@12)
1000 base of code
1000 base of data
10000000 image base (10000000 to 1001FFFF)
...
前面研究了連結到DLL的兩種主要方式:使用LIB檔案的隱式連結(最簡單、最方便)和動態連結(顯式載入DLL並查詢要使用的函數)。事實證明,還有第三種方法,在靜態連結和動態連結之間有一種「中間地帶」——延遲載入DLL(Delay-Load DLL)。
通過延遲載入,有兩個好處:靜態連結的便利性和僅在需要時動態載入DLL。要使用延遲載入DLL,需要對使用這些DLL的模組進行一些更改,無論是可執行DLL還是其他DLL。應延遲載入的DLL將新增到輸入索引標籤中的連結器選項中(下圖)。
如果要支援動態解除安裝延遲載入DLL,在「高階連結器」索引標籤中新增該選項(「解除安裝延遲載入的DLL」)。剩下的就是連結DLL的匯入庫(LIB)檔案,並使用匯出的功能,就像使用隱式連結的DLL一樣。以下是延遲載入DLL範例:
#include "..\SimpleDll\Simple.h"
#include <delayimp.h>
bool IsLoaded()
{
auto hModule = ::GetModuleHandle(L"simpledll");
printf("SimpleDll loaded: %s\n", hModule ? "Yes" : "No");
return hModule != nullptr;
}
int main()
{
IsLoaded();
bool prime = IsPrime(17);
IsLoaded();
printf("17 is prime? %s\n", prime ? "Yes" : "No");
// 解除安裝dll
__FUnloadDelayLoadedDLL2("SimpleDll.dll");
IsLoaded();
prime = IsPrime(1234567);
IsLoaded();
return 0;
}
輸出結果:
SimpleDll loaded: No
SimpleDll loaded: Yes
17 is prime? Yes
SimpleDll loaded: No
SimpleDll loaded: Yes
計算機可以操作多種裝置,一般型別包括儲存裝置(磁碟、磁帶)、傳輸裝置(網路卡、資料機)和人機介面裝置(螢幕、鍵盤、滑鼠)。裝置通過電纜甚至通過空氣傳送訊號來與計算機系統通訊,通過一個稱為埠(如串列埠)的連線點與機器通訊。如果一個或多個裝置使用一組公共電線,則該連線稱為匯流排(bus)。
當裝置A有一根電纜插入裝置B,裝置B有一條電纜插入裝置C,裝置C插入計算機上的埠時,這種安排稱為菊花鏈,通常用作匯流排。典型的PC匯流排結構如圖所示,PCI匯流排(通用PC系統匯流排)將處理器-記憶體子系統連線到快速裝置,擴充套件匯流排連線相對較慢的裝置,如鍵盤、序列和USB埠。
在下圖的右上部分,四個磁碟在插入SCSI控制器的小型計算機系統介面(SCSI)匯流排上連線在一起。用於互連計算機主要部件的其他常見匯流排包括PCI Express(PCIe),其吞吐量高達每秒16 GB,以及Hyper Transport,其吞吐量達每秒25 GB。
計算機系統包含多個I/O裝置及其各自的控制器:網路卡、圖形介面卡、磁碟控制器、DVD-ROM控制器、串列埠、通用串列埠匯流排、音效卡等。控制器是可以操作埠、匯流排或裝置的電子裝置的集合。串列埠控制器是簡單裝置控制器的一個範例,是計算機中的一個微控制器,用於控制串列埠導線上的訊號。SCSI匯流排控制器通常作為一個單獨的電路板(主機介面卡)插入計算機。它通常包含一個處理器、微碼和一些專用記憶體,以使其能夠處理SCSI協定訊息。一些裝置有自己的內建控制器。
I/O埠通常由四個暫存器組成:狀態暫存器、控制暫存器、暫存器中的資料、資料輸出暫存器。狀態暫存器包含主機可以讀取的位,這些位表示的狀態:當前命令是否已完成、是否可以從暫存器中的資料讀取位元組、是否存在裝置錯誤。
主機可以寫入控制暫存器來啟動命令或更改裝置的模式,例如,串列埠控制暫存器中的某個位在全雙工和半雙工通訊之間進行選擇,另一個啟用奇偶校驗,第三位將單詞長度設定為7或8位元,其他位選擇串列埠支援的速度之一。主機讀取暫存器中的資料以獲取輸入,資料輸出暫存器由主機寫入以傳送輸出,資料暫存器通常為1至4位元組。一些控制器具有FIFO晶片,可以儲存幾個位元組的輸入或輸出資料,以將控制器的容量擴充套件到資料暫存器的大小之外。FIFO晶片可以儲存少量資料,直到裝置或主機能夠接收這些資料。
輪詢:主機和控制器之間互動的不完整協定可能很複雜,但基本握手概念很簡單。控制器通過狀態暫存器中的忙位指示其狀態(記住,設定位意味著向位中寫入1,清除位意味著將0寫入位),在忙於工作時設定忙位,並在準備接受下一個命令時清除忙位。主機通過命令暫存器中的命令就緒位發出其願望。當控制器可以執行命令時,主機設定命令就緒位。在本例中,主機通過埠寫入輸出,通過如下握手與控制器協調:
1、主機反覆讀取忙位,直到該位變為清零。
2、主機在命令暫存器中設定寫入位,並將一個位元組寫入資料輸出暫存器。
3、主機設定命令就緒位。
4、當控制器注意到命令就緒位已設定時,它將設定為「忙碌」。
5、控制器讀取命令暫存器並看到寫入命令。
6、它讀取資料輸出暫存器以獲取位元組,並對裝置進行I/O操作。
7、控制器清除命令就緒位,清除狀態暫存器中的錯誤位以指示裝置I/O成功,並清除忙位以指示完成。
主機正忙於等待或輪詢:它處於一個迴圈中,反覆讀取狀態暫存器,直到忙位被清除。如果控制器和裝置速度快,則此方法是合理的。但如果等待時間可能很長,主機可能會切換到另一個任務。
I/O裝置的類別有:
I/O裝置之間的差異:
直接記憶體存取(Direct Memory Access,DMA)的描述如下:
I/O系統的主要目的是抽象對物理和邏輯裝置的存取,存取任意檔案系統中的檔案應與存取串列埠、USB攝像頭或印表機不同。I/O系統由多個元件組成,一些元件處於使用者模式,大多陣列件處於核心模式。最重要的部分如下圖所示。
使用者模式程序使用各種Windows API呼叫I/O系統,核心端的所有檔案和裝置操作都由I/O管理器啟動。通過建立一個稱為I/O請求包(IRP)的核心結構來處理請求(如讀或寫),填充請求的詳細資訊,然後將其傳遞給適當的裝置驅動程式。對於實際檔案,將轉到檔案系統驅動程式,如NTFS。如下圖所示,該過程與正常的系統呼叫沒有本質區別。
就核心而言,I/O操作總是非同步的,意味著驅動程式應該啟動操作並儘快返回,以便呼叫執行緒可以重新獲得控制。但是,原始呼叫者可以選擇同步呼叫,在這種情況下,I/O管理器代表呼叫者等待,直到操作完成。從客戶的角度來看,這種靈活性非常方便。
下面是不同儲存媒介的速率對比:
磁碟驅動器被定址為邏輯塊(logical block)的大型一維陣列,其中邏輯塊是最小的傳輸單元,邏輯塊的大小通常為512位元組,邏輯塊的一維陣列按順序對映到磁碟的磁區,磁區0是最外層圓柱上第一條軌跡的第一磁區。對映依次通過該軌道、該圓柱體中的其餘軌道,然後從最外層到最內層通過其餘圓柱體,邏輯到實體地址應該很容易,壞磁區除外。通過恆定角速度,每條軌道的磁區數不恆定。
磁碟提供計算機系統的大量輔助儲存,可以被視為每臺計算機共用的一個I/O裝置,有多種尺寸和速度,資訊可以用光學或磁性儲存。磁帶曾被用作早期的輔助儲存媒介,但存取時間比磁碟慢得多,目前正在使用磁帶進行備份。
現代磁碟驅動器被稱為邏輯塊的大型一維陣列,其中邏輯塊是最小的傳輸單元。磁碟I/O操作的實際細節取決於計算機系統、作業系統以及I/O通道和磁碟控制器硬體的性質。資訊儲存的基本單位是磁區,磁區儲存在扁平、圓形的媒體磁碟上,此媒介旋轉接近一個或多個讀/寫磁頭,磁頭可以從磁碟的內部移動到外部。當磁碟驅動器執行時,磁碟以恆定速度旋轉,要讀或寫,磁頭必須位於所需磁軌和該磁軌上所需磁區的開頭,軌道選擇包括在移動頭部系統中移動頭部或在固定頭部系統中電子選擇一個頭部。這些特徵是軟碟、硬碟、CD-ROM和DVD的共同特徵。
檢視現代硬碟的規格時需要注意的一點是,驅動程式軟體所指定和使用的幾何圖形幾乎總是與物理格式不同。在舊磁碟上,每個磁軌的磁區數對於所有柱面都是相同的。現代磁碟被劃分為多個分割區,外部分割區上的磁區比內部分割區上的多。下圖(a)顯示了一個有兩個區域的小圓盤。外區每條軌道有32個磁區;內部的一條每條軌道有16個磁區。一個真正的磁碟,如WD 3000 HLFS,通常有16個或更多分割區,隨著從最內層分割區到最外層分割區的擴充套件,每個分割區的磁區數量增加了大約4%。
(a) 具有兩個分割區的磁碟的物理幾何形狀。(b) 此磁碟可能的虛擬幾何體。
為了隱藏每個磁軌有多少磁區的詳細資訊,大多數現代磁碟都有一個呈現給作業系統的虛擬幾何體。該軟體被指示按照每個磁軌有x個柱面、y個磁頭和z個磁區的方式執行。然後,控制器將(x,y,z)的請求重新對映到實際的圓柱體、封頭和磁區。上圖(a)中物理磁碟的可能虛擬幾何結構如圖(b)中所示。在這兩種情況下,磁碟都有192個磁區,只有釋出的排列與實際的不同。
對於PC,這三個引數的最大值通常為(65535、16和63),因為需要向後相容原始IBM PC的限制。在這臺機器上,16位元、4位元和6位欄位用於指定這些數位,柱面和磁區編號從1開始,磁頭編號從0開始。使用這些引數,每個磁區512位元組,最大可能磁碟為31.5 GB。為了克服這個限制,所有現代磁碟現在都支援一種稱為邏輯塊定址的系統,在這種系統中,磁碟磁區從0開始連續編號,而不考慮磁碟的幾何形狀。
CPU效能在過去十年中呈指數級增長,大約每18個月翻一番。磁碟效能則不然。隨著時間的推移,CPU效能和(硬碟)效能之間的差距變得越來越大,並行處理越來越多地被用於加快CPU效能。多年來,許多人都意識到並行I/O可能也是一個好主意,Patterson等人在其1988年的論文中提出了六種可用於提高磁碟效能和/或可靠性的特定磁碟組織。這些想法很快被業界採納,併產生了一種新的I/O裝置,稱為RAID(Redundant Array of Inexpensive Disks,廉價冗餘磁碟陣列)。還存在它的反面,就是SLED(Single Large Expensive Disk,單個大型昂貴磁碟)。
RAID級別0到6。備份和奇偶校驗驅動器以陰影顯示。
磁碟有時會出錯,好的磁區可能突然變成壞的磁區,整個驅動器可能會意外損壞。RAID可防止少數磁區出現故障,甚至驅動器出現故障。然而,它們並不能防止寫錯誤,因為首先會留下壞資料,也不能防止在寫入損壞原始資料而不替換為新資料時發生崩潰。
對於某些應用程式,即使在磁碟和CPU出現錯誤的情況下,資料也決不能丟失或損壞。理想情況下,磁碟應該一直工作,沒有錯誤。不幸的是,這是無法實現的。可以實現的是具有以下屬性的磁碟子系統:當向其發出寫操作時,磁碟要麼正確寫入資料,要麼什麼也不做,從而保持現有資料的完整性。這種系統稱為穩定儲存(stable storage),並用軟體實現(Lampon and Sturgis,1979)。目標是不惜一切代價保持磁碟的一致性。
穩定儲存使用一對相同的磁碟和相應的塊一起工作,形成一個無錯誤的塊。在沒有錯誤的情況下,兩個驅動器上的相應塊是相同的。任何一個都可以讀取以獲得相同的結果。為了實現這一目標,定義了以下三種操作:
當磁碟驅動器執行時,磁碟以恆定速度旋轉。要讀或寫,磁頭必須位於所需磁軌和該磁軌上所需磁區的開頭。軌道選擇包括在移動頭部系統中移動頭部或在固定頭部系統中電子選擇一個頭部,在可移動磁頭系統上,磁頭在軌道上定位所需的時間稱為尋道時間。選擇磁軌後,磁碟控制器將等待,直到相應的磁區旋轉以與磁頭對齊,磁區開始到達頭部所需的時間稱為旋轉延遲或旋轉延遲。尋道時間(如果有的話)和旋轉延遲的總和等於存取時間,即進入讀取或寫入位置所需的時間。磁頭就位後,隨著磁區在磁頭下方移動,執行讀或寫操作,這是操作的資料傳輸部分,轉移所需的時間就是轉移時間。
尋道時間是將磁碟臂移動到所需軌道所需的時間。事實證明,是一個難以確定的數量。尋道時間由兩個關鍵部分組成:初始啟動時間,一旦檢修臂達到速度就必須穿過軌道所需的時間,計算公式:
其中:\(T_s\)是尋道時間,\(n\)是軌道遍歷時間,\(m\)是取決於磁碟驅動器的常數,\(s\)啟動時間。
旋轉延時(Rotational Latency)是等待磁碟旋轉到磁碟頭所需磁區的額外時間。
旋轉延遲(Rotational Delay):磁碟(軟碟除外)的轉速從3600 rpm到15000 rpm不等;在後一速度下,每4毫秒旋轉一圈。因此,平均旋轉延遲為2毫秒。軟碟通常以300至600 rpm的轉速旋轉。因此,平均延遲將在100到50毫秒之間。
磁碟頻寬是傳輸的總位元組數除以第一次請求服務和完成最後一次傳輸之間的總時間。
傳輸時間:往返磁碟的傳輸時間取決於磁碟的以下旋轉速度:
其中:\(T\)是傳輸時間,\(b\)是要傳輸的位元組數,\(r\)是磁軌上的位元組數,\(N\)是轉速,單位為轉/秒。因此,總平均存取時間可表示為:
滿足一系列I/O請求所需的頭數量會影響效能,如果所需的磁碟驅動器和控制器可用,則可以立即處理請求。如果裝置或控制器繁忙,任何新的服務請求都將被放入該驅動器的待定請求佇列中。當一個請求完成時,作業系統會選擇下一個要服務的掛起請求。不同型別的排程演演算法如下。
最簡單的排程形式是先進先出(FIFO)排程,它按順序處理佇列中的專案,將按照收到請求的順序為請求提供服務。此演演算法雖然是公平的,但不能提供最快的服務,不需要特別的時間來最小化總尋道時間。
這種策略的優點是公平,因為每一個請求都會得到滿足,並且請求會按照收到的順序得到滿足。使用FIFO,如果只有少數幾個程序需要存取,並且許多請求都是針對叢集檔案磁區的,那麼可以獲得良好的效能。
範例:考慮一個磁碟佇列,請求對柱面上的塊進行I/O。98, 183, 37, 122, 14, 124, 65, 67。
磁頭如果最初位於53,將首先從53移動到98,然後再移動到183,然後移動到37、122、14、124、65、67,從而使磁頭移動640個圓柱(cylinder)。從122到14,然後再回到124,這說明了這個排程演演算法的問題。如果cylinder 37和14的請求可以在122和124之前或之後一起處理,則總移動量可以大幅減少,效能可以得到改善。
SSTF首先從當前頭部位置選擇尋道時間最短的請求,是SJF排程的一種形式,可能會導致某些請求不足。由於尋道時間隨頭部所經過的cylinder數增加而增加,因此SSTF選擇最接近當前cylinder位置的掛起請求,下圖顯示了236個氣缸的缸蓋總移動量。範例:考慮一個磁碟佇列,請求對柱面上的塊進行I/O:98、183、37、122、14、124、65、67。
如果磁頭最初位於53,最接近的是cylinder 65,然後是67,然後是37,比98接近67。因此,它服務37,繼續服務14、98、122、124,最後服務183,總移動量僅為236個cylinder。
SSTF本質上是SJF的一種形式,可能會導致某些請求的匱乏,是對FCFS的實質性改進,但它不是最優的。
SCAN演演算法有時稱為電梯演演算法,掃描演演算法在磁軌0處開始掃描,並向編號最高的磁軌移動,在磁軌通過時為磁軌的所有請求提供服務。磁碟臂從磁碟的一端開始,向另一端移動,為請求提供服務,直到它到達磁碟的另一端,此時磁頭移動方向相反,服務繼續。
下圖顯示208個cylinder的頭部總移動。但請注意,如果請求是均勻密集的,則磁碟另一端的密度最大,等待時間最長。範例:考慮一個磁碟佇列,請求對柱面上的塊進行I/O:98、183、37、122、14、124、65、67。
如果磁碟磁頭最初位於53,並且磁頭向0移動,則服務於37,然後服務於14。在cylinder 0處,臂將倒轉,並朝著維修65、67、98、122、124和183的磁碟的另一端移動。如果一個請求剛好從頭部到達,它將立即得到服務,頭部後面的請求將不得不等待,直到手臂到達另一端並反轉方向。
它可以始終處理下一個最近的請求,以最小化尋道時間。根據下圖的要求,順序為12、9、16、1、34和36,如圖底部的鋸齒線所示。按照此順序,臂運動為1、3、7、15、33和2,總共61個氣缸。該演演算法稱為SSF(最短尋道優先),與FCFS相比,它幾乎將手臂的總運動量減少了一半。
C-SCAN是SCAN的變體,旨在提供更均勻的等待時間,其策略將掃描限制在一個方向。與SCAN類似,C-SCAN將磁頭從磁碟的末端移動到另一個為請求提供服務的位置,當磁頭到達另一端時,它會立即返回到磁碟的開頭,而不會在返回時處理任何請求。
C-SCAN將cylinder視為從最終cylinder到第一個cylinder的迴圈列表,減少了新請求所經歷的最大延遲。範例:考慮一個磁碟佇列,請求對柱面上的塊進行I/O:98、183、37、122、14、124、65、67。
SCAN和C-SCAN都會在磁碟的整個寬度上移動磁碟臂,開始向一個方向移動頭部。當在該方向上沒有更多請求時,滿足該方向上最近軌跡的請求,磁頭正在行駛,反轉方向並重復。此演演算法類似於每條電路上最內側和最外側的軌道。實際上,這兩種演演算法都不是以這種方式實現的。手臂在每個方向上只能到達最後的請求。然後它會反轉,不會一直到磁碟的末尾。
這些版本的SCAN和CSCAN稱為Look和C-Look排程,因為它們在繼續向給定方向移動之前會查詢請求。範例:考慮一個磁碟佇列,請求對柱面上的塊進行I/O:98、183、37、122、14、124、65、67。
我們如何在上述幾種磁碟排程演演算法中選擇合適的?通用建議如下:
檔案是相似記錄的集合,除非資料位於檔案中,否則無法將其寫入輔助儲存。檔案表示程式和資料,資料可以是數位、字母數位、字母或二進位制。許多不同型別的資訊可以儲存在一個檔案中——源程式、目標程式、可執行程式、數位資料、工資記錄器、圖形影象、錄音等。
為了提供存放檔案的地方,大多數PC作業系統都有目錄的概念,作為將檔案分組在一起的一種方式。例如,一個學生可能有一個目錄,用於他正在學習的每門課程(用於該課程所需的程式),另一個目錄用於他的電子郵件,還有另一個用於他的全球資訊網主頁的目錄。然後需要系統呼叫來建立和刪除目錄。還提供了將現有檔案放入目錄和從目錄中刪除檔案的呼叫。目錄條目可以是檔案或其他目錄。該模型還產生了檔案系統的層次結構,如下圖所示。
檔案屬性因作業系統而異,常見的檔案屬性包括:
屬性 | 解析 |
---|---|
保護 | 誰可以存取檔案以及以何種方式存取 |
密碼 | 存取檔案所需的密碼 |
建立者 | 檔案的人員的建立者ID |
所有者 | 當前所有者 |
唯讀標記 | 0表示讀/寫,1表示唯讀 |
隱藏標記 | 0表示正常,1表示不顯示在列表中 |
系統標記 | 0表示普通檔案,1代表系統檔案 |
檔案標記 | 0已備份,1需要備份 |
ASCII、二進位制標記 | 0表示ASCII檔案,1表示二進位制檔案 |
隨機存取標記 | 0僅用於順序存取,1個用於隨機存取 |
臨時標記 | 0表示正常,1用於在程序退出時刪除檔案 |
鎖定標記 | 0表示已解鎖,非零表示鎖定 |
記錄長度 | 記錄中的位元組數 |
鍵位置 | 每個記錄內鍵的偏移量 |
鍵長度 | 鍵欄位中的位元組數 |
建立時間 | 建立檔案的日期和時間 |
上次存取時間 | 上次存取檔案的日期和時間 |
上次修改時間 | 上次更改檔案的日期和時間 |
當前尺寸 | 檔案中的位元組數 |
最大尺寸 | 檔案可能增長到的位元組數 |
檔案根據其型別具有特定的定義結構:
(a) 可執行檔案;(b) 檔案。
檔案是一種抽象資料型別。要定義檔案,我們需要考慮可以對檔案執行的操作。檔案的基本操作是:
除了這6個操作之外,其他兩個操作包括在檔案末尾附加新資訊和重新命名現有檔案,這些原語可以組合起來執行其他兩個操作。大多數檔案操作都涉及在整個目錄中搜尋與檔案關聯的條目。為了避免這種情況,作業系統會保留一個小表,其中包含有關開啟檔案的資訊(開啟表)。當請求檔案操作時,將通過該表中的索引指定該檔案。因此,不需要搜尋。
開啟的檔案相關聯的資訊有:
可以通過多種方式存取檔案中的資訊。不同的檔案存取方法是:
順序存取。它是最簡單的存取方法。檔案中的資訊是按順序處理的,一條記錄接著一條記錄。編輯器和編譯器以這種方式存取檔案,通常對檔案執行讀寫操作。讀取操作讀取檔案的下一部分,並自動前進檔案指標,該指標跟蹤下一個i/i軌跡。寫入操作附加到檔案的末尾,這樣的檔案可以緊鄰開頭。順序存取取決於檔案的磁帶型號。
直接存取或相對存取。允許隨機存取任何檔案塊,基於檔案的磁碟模型,檔案由固定長度的邏輯記錄組成。它允許程式以任何順序快速讀取和寫入記錄,允許讀取或寫入任意塊。範例:使用者可能需要塊13,然後讀取塊99,然後寫入塊12。對於搜尋具有即時結果的大量資訊的記錄,直接存取方法是合適的。並非所有作業系統都支援順序和直接存取,很少有作業系統使用順序存取,有些作業系統使用直接存取。在直接存取上模擬順序存取很容易,但反過來效率極低。
索引方法:索引就像一本書末尾的索引,其中包含指向各個塊的指標。要在檔案中查詢記錄,我們搜尋索引,然後使用指標直接存取檔案並查詢所需的記錄。對於大型檔案,索引檔案本身可以非常大,以便儲存在記憶體中。一種為索引檔案本身建立索引的解決方案。主索引檔案將包含指向輔助索引檔案的指標,該檔案將指向實際資料項。可以使用兩種型別的索引:
為了跟蹤檔案,檔案系統通常有目錄或資料夾,它們本身就是檔案。
目錄系統的最簡單形式是擁有一個包含所有檔案的目錄。有時它被稱為根目錄,但因為它是唯一的目錄,所以名稱並不重要。在早期的個人電腦上,這種系統很常見,部分原因是隻有一個使用者。有趣的是,世界上第一臺超級計算機CDC 6600也只有一個目錄存放所有檔案,儘管它同時被許多使用者使用,此舉是為了保持軟體設計簡單。
下圖給出了一個具有一個目錄的系統範例,目錄包含四個檔案,此方案的優點是簡單,並且能夠快速定位檔案,畢竟只有一個地方可以檢視。它有時仍用於簡單的嵌入式裝置,如數碼相機和一些行動式音樂播放器。
單級適用於非常簡單的專用應用程式(甚至在第一臺個人計算機上使用過),但對於擁有數千個檔案的現代使用者來說,如果所有檔案都在一個目錄中,則不可能找到任何內容。因此,需要一種方法將相關檔案組合在一起——層次結構(即目錄樹)。使用這種方法,可以有任意多的目錄以自然方式對檔案進行分組。此外,如果多個使用者共用一個公共檔案伺服器,就像許多公司網路上的情況一樣,每個使用者都可以為自己的層次結構擁有一個專用根目錄。這種方法如下圖所示。在這裡,根目錄中包含的目錄A、B和C都屬於不同的使用者,其中兩個使用者為他們正在處理的專案建立了子目錄。
使用者可以建立任意數量的子目錄,為使用者組織工作提供了強大的結構化工具。因此,幾乎所有現代檔案系統都是以這種方式組織的。
當檔案系統組織為目錄樹時,需要某種方法來指定檔名。通常使用兩種不同的方法。在第一種方法中,每個檔案都有一個絕對路徑名,由根目錄到檔案的路徑組成。例如,路徑/usr/ast/mailbox意味著根目錄包含子目錄usr,而該子目錄又包含子目錄ast,其中包含檔案mailbox。絕對路徑名總是從根目錄開始,並且是唯一的。在UNIX中,路徑的元件用/分隔,在Windows中,分隔符是\,在MULTICS中,它是>。因此,在這三個系統中,相同的路徑名將寫入如下:
Windows \usr\ast\mailbox
UNIX /usr/ast/mailbox
MULTICS >usr>ast>mailbox
其中Unix的目錄樹範例如下:
現在是時候從使用者的檔案系統視角轉向實現者的視角了。使用者關心檔案的命名方式、允許對其進行哪些操作、目錄樹的外觀以及類似的介面問題。實現者感興趣的是如何儲存檔案和目錄,如何管理磁碟空間,以及如何使一切高效可靠地工作。下面我們將研究其中的一些領域,以瞭解問題和權衡。
檔案系統軟體架構。
檔案系統儲存在磁碟上。大多數磁碟可以劃分為一個或多個分割區,每個分割區上都有獨立的檔案系統。磁碟的磁區0稱為MBR(主開機記錄),用於引導計算機。MBR的末尾包含分割區表,此表給出了每個分割區的起始地址和結束地址,表中的一個分割區被標記為活動分割區。當計算機啟動時,BIOS讀取並執行MBR,MBR程式所做的第一件事是定位活動分割區,讀取其第一個塊(稱為引導塊),然後執行它。啟動塊中的程式載入該分割區中包含的作業系統。為了一致性,每個分割區都從一個引導塊開始,即使它不包含可引導的作業系統。此外,將來可能會包含一個。
除了從啟動塊開始,磁碟分割區的佈局因檔案系統而異。檔案系統通常包含下圖所示的一些項,第一個是超級塊,它包含有關檔案系統的所有關鍵引數,並在計算機啟動或首次觸控檔案系統時被讀入記憶體,超級塊中的典型資訊包括用於標識檔案系統型別的幻數、檔案系統中的塊數以及其他關鍵管理資訊。
接下來可能會出現有關檔案系統中可用塊的資訊,例如以點陣圖或指標列表的形式。接下來可能是i節點,一組資料結構,每個檔案一個,說明檔案的所有資訊。之後可能是根目錄,其中包含檔案系統樹的頂部。最後,磁碟的其餘部分包含所有其他目錄和檔案。
檔案管理元素。
通用檔案組織。
Windows支援多種檔案系統,包括在Windows 95、MS-DOS和OS/2上執行的檔案分配表(FAT)。但Windows的開發人員也設計了一種新的檔案系統,即Windows檔案系統(NTFS),旨在滿足工作站和伺服器的高階需求。高階應用範例:
NTFS是一個靈活而強大的檔案系統,它建立在一個優雅而簡單的檔案系統模型上。NTFS最值得注意的功能包括:
NTFS使用以下磁碟儲存概念:
卷的佈局。
NTFS可以在系統崩潰或磁碟故障後將檔案系統恢復到一致狀態。結合下圖,支援可恢復性的關鍵要素是:
Windows NTFS元件。
在實現檔案儲存時,最重要的問題可能是跟蹤哪個磁碟塊與哪個檔案對應,不同的作業系統使用不同的方法。下圖是記錄塊的方法:
最簡單的分配方案是將每個檔案儲存為連續執行的磁碟塊。因此,在一個具有1-KB塊的磁碟上,一個50-KB的檔案將被分配50個連續的塊。對於2-KB的塊,它將被分配25個連續的塊。
我們在下圖(a)中看到了連續儲存分配的範例,顯示了前40個磁碟塊,從左側的塊0開始。最初,磁碟是空的,然後,從開頭(塊0)開始,將一個長度為四個塊的檔案a寫入磁碟,之後,一個六塊檔案B被寫入檔案a的末尾之後。
請注意,每個檔案都從新塊的開始處開始,因此如果檔案a實際上是3½個塊,那麼在最後一個塊的末尾會浪費一些空間。在圖中,總共顯示了七個檔案,每個檔案都從前一個檔案的末尾之後的塊開始。著色只是為了更容易區分檔案,就儲存而言,它沒有實際意義。
(a) 連續分配七個檔案的磁碟空間。(b) 刪除檔案D和F後磁碟的狀態。
連續檔案分配的案例。
連續檔案分配的案例(壓縮後)。
連續磁碟空間分配有兩個顯著的優點。首先,它很容易實現,因為跟蹤檔案塊的位置可以簡化為記住兩個數位:第一個塊的磁碟地址和檔案中的塊數。給定第一個塊的數量,任何其他塊的數量都可以通過簡單的加法得到。
其次,讀取效能非常好,因為整個檔案可以在一次操作中從磁碟讀取,只需要一個尋道(到第一個塊)。此後,不再需要尋道或旋轉延遲,因此資料以磁碟的全部頻寬進入。因此,連續分配易於實現且具有高效能。
不幸的是,連續分配也有一個非常嚴重的缺點:隨著時間的推移,磁碟會變得支離破碎。要了解這是如何發生的,參看上圖(b)。這裡刪除了兩個檔案D和F。當一個檔案被刪除時,它的塊會被自然釋放,從而在磁碟上留下一段空閒塊。磁碟不是當場壓實以擠出孔洞,因為這將涉及複製孔後的所有塊,可能有數百萬塊,如果磁碟較大,這將需要數小時甚至數天的時間。因此,磁碟最終由檔案和孔洞組成。
最初,這個碎片不是問題,因為每個新檔案都可以在磁碟末尾寫入,緊跟前一個檔案。然而,最終磁碟將被填滿,因此有必要壓縮磁碟(成本高昂),或者重新使用孔中的可用空間。重複使用空間需要維護孔列表,這是可行的。但是,建立新檔案時,需要知道其最終大小,以便選擇正確大小的孔來放置檔案。
想象一下這種設計的後果。使用者啟動文書處理器以建立檔案,程式首先要問的是最終檔案的位元組數,必須回答此問題,否則程式將無法繼續。如果最終證明給出的數位太小,程式必須提前終止,因為磁碟孔已滿,沒有地方放置檔案的其餘部分。如果使用者試圖通過給出一個不切實際的大數位作為最終大小來避免這個問題,例如1GB,那麼編輯器可能無法找到如此大的洞,並宣佈無法建立檔案。當然,使用者可以自由地再次啟動程式,並說這次是500MB,以此類推,直到找到合適的漏洞為止。不過,這個方案不太可行。
然而,有一種情況下,連續分配是可行的,而且事實上仍在使用:在CD-ROM上。在這裡,所有檔案大小都是預先知道的,並且在隨後使用CD-ROM檔案系統時永遠不會改變。而DVD的情況有點複雜。原則上,一部90分鐘的電影可以編碼為一個長度約為4.5 GB的檔案,但使用的檔案系統UDF(Universal Disk Format,通用磁碟格式)使用30位數位表示檔案長度,將檔案限制在1 GB以內。因此,DVD影片通常儲存為三個或四個1-GB檔案,每個檔案都是連續的,單個邏輯檔案(電影)的這些物理片段稱為擴充套件資料塊。
儲存檔案的第二種方法是將每個檔案儲存為磁碟塊的連結列表,如下圖所示。每個塊的第一個字用作指向下一個塊的指標。塊的其餘部分用於資料。
與連續分配不同,此方法可以使用每個磁碟塊,磁碟碎片不會丟失空間(最後一個塊中的內部碎片除外)。此外,目錄條目只儲存第一個塊的磁碟地址就足夠了。其餘的可以從那裡開始找到。
另一方面,雖然順序讀取檔案很簡單,但隨機存取速度非常慢。要到達塊n,作業系統必須從開始處啟動並讀取n− 之前1個街區,一次一個。顯然,讀取大量內容時會非常緩慢。
此外,塊中的資料儲存量不再是2的冪,因為指標占用了幾個位元組。雖然不是致命的,但具有特殊大小的程式效率較低,因為許多程式讀寫塊的大小是2的冪次方。由於每個塊的前幾個位元組被指向下一個塊的指標占用,讀取完整塊大小需要從兩個磁碟塊獲取並連線資訊,這會因複製而產生額外的開銷。
通過從每個磁碟塊獲取指標字並將其放入記憶體中的表中,可以消除連結串列分配的兩個缺點。下圖顯示了上圖範例的表格。在兩個圖中,我們都有兩個檔案。檔案A按順序使用磁碟塊4、7、2、10和12,檔案B按順序使用盤塊6、3、11和14。使用下圖的表格,我們可以從區塊4開始,沿著鏈條一直走到底,從塊6開始也可以這樣做。兩條鏈條都用一個特殊標記(例如−1), 但不是有效的塊編號。主記憶體儲器中的這樣一個表稱為FAT(File Allocation Table,檔案分配表)。
使用主記憶體中的檔案分配表分配連結列表。
使用此組織,整個塊都可用於資料,隨機存取要容易得多。儘管仍必須遵循鏈來查詢檔案中的給定偏移量,但鏈完全在記憶體中,因此可以在不進行任何磁碟參照的情況下遵循它。與前面的方法一樣,目錄條目只需保留一個整數(起始塊編號)就足夠了,而且無論檔案有多大,仍然能夠定位所有塊。
這種方法的主要缺點是,整個表必須始終在記憶體中才能工作。對於1-TB磁碟和1-KB塊大小,該表需要10億個條目,每個條目對應10億個磁碟塊,每個條目必須至少為3個位元組。為了加快查詢速度,它們應該是4個位元組。因此,該表將始終佔用3 GB或2.4 GB的主記憶體,取決於系統是針對空間還是時間進行了優化,因此不太實用。顯然,FAT的想法不能很好地擴充套件到大型磁碟。它是最初的MS-DOS檔案系統,但所有版本的Windows仍然完全支援它。
跟蹤哪些塊屬於哪個檔案的最後一種方法是將稱為I節點(索引節點)的資料結構與每個檔案相關聯,它列出了檔案塊的屬性和磁碟地址,給定i節點,就可以找到檔案的所有塊。一個簡單的例子如下圖所示。
與使用記憶體中表的連結檔案相比,此方案的最大優點是,僅當相應檔案開啟時,i節點才需要在記憶體中。如果每個i節點佔用n個位元組,並且一次最多可以開啟k個檔案,則儲存開啟檔案的i節點的陣列所佔用的總記憶體僅為k*n個位元組,只需提前預留這麼多空間。
此陣列通常遠小於上一節中描述的檔案表所佔用的空間,原因很簡單,用於儲存所有磁碟塊連結列表的表的大小與磁碟本身成比例。如果磁碟有n個塊,則表需要n個條目,隨著磁碟的增大,此表也會隨之線性增長。相反,i-node方案需要記憶體中的陣列,其大小與一次可以開啟的最大檔案數成正比。磁碟是100 GB、1000 GB還是10000 GB並不重要。
i節點的一個問題是,如果每個節點都有固定數量磁碟地址的空間,那麼當檔案增長超過此限制時會發生什麼情況?一種解決方案是不為資料塊保留最後一個磁碟地址,而是為包含更多磁碟塊地址的塊的地址保留,如上圖所示。更高階的方法是兩個或多個包含磁碟地址的此類塊,甚至是指向其他滿有地址的磁碟塊的磁碟塊。類似地,Windows NTFS檔案系統使用了類似的思想,只有更大的i節點也可以包含小檔案。
在讀取檔案之前,必須先將其開啟,開啟檔案時,作業系統使用使用者提供的路徑名來查詢磁碟上的目錄項,目錄條目提供查詢磁碟塊所需的資訊。根據系統的不同,此資訊可能是整個檔案的磁碟地址(具有連續分配)、第一個塊的編號(兩個連結串列方案)或i節點的編號。在所有情況下,目錄系統的主要功能是將檔案的ASCII名稱對映到查詢資料所需的資訊上。
一個密切相關的問題是屬性應該儲存在哪裡。每個檔案系統都維護各種檔案屬性,例如每個檔案的所有者和建立時間,它們必須儲存在某個地方。一種明顯的可能性是將它們直接儲存在目錄條目中,有些系統正是這樣做的,該選項如下圖(a)所示。在這種簡單的設計中,目錄由一個固定大小的條目列表組成,每個檔案一個,其中包含一個(固定長度)檔名、檔案屬性的結構,以及一個或多個磁碟地址(最大值),說明磁碟塊的位置。
(a) 一個簡單的目錄,包含固定大小的條目,在目錄條目中有磁碟地址和屬性。(b) 一種目錄,其中的每個條目僅指一個i節點。
樹形結構資料夾的案例。
對於使用i-node的系統,儲存屬性的另一種可能性是在i-node中,而不是在目錄條目中。在這種情況下,目錄條目可以更短:只需一個檔名和一個i-node編號,如上圖(b)所示。
到目前為止,我們假設檔案的名稱很短,長度固定。在MS-DOS檔案中,基本名稱為1-8個字元,擴充套件名可選為1-3個字元。在UNIX版本7中,檔名為1-14個字元,包括任何擴充套件名。然而,幾乎所有現代作業系統都支援更長、可變長度的檔名。如何實現這些目標?
最簡單的方法是設定檔名長度限制,通常為255個字元,然後使用下圖的一種設計,為每個檔名保留255個字。這種方法很簡單,但浪費了大量目錄空間,因為很少有檔案具有如此長的名稱。出於效率原因,最好採用不同的結構。
一種替代方法是放棄所有目錄條目大小相同的想法。使用此方法,每個目錄條目都包含一個固定部分,通常從條目的長度開始,然後是固定格式的資料,通常包括所有者、建立時間、保護資訊和其他屬性。這個固定長度的頭後面跟著實際的檔名,不管檔名有多長,如圖下圖(a)所示,格式為大端格式(例如SPARC)。在這個例子中,我們有三個檔案,project-budget、personnel和foo。每個檔名都以一個特殊字元(通常為0)結尾,該字元在圖中由一個帶叉的框表示。為了允許每個目錄條目都從單詞邊界開始,每個檔名都被填入整數個單詞,如圖中陰影框所示。
處理目錄中長檔名的兩種方法。(a) 排成一行。(b) 堆成一堆。
當多個使用者一起處理一個專案時,他們通常需要共用檔案。因此,共用檔案通常很方便同時出現在屬於不同使用者的不同目錄中。下圖顯示了包含一個共用檔案的檔案系統,只有C的一個檔案現在也存在於B的一個目錄中。B的目錄和共用檔案之間的連線稱為連結。檔案系統本身現在是一個有向非迴圈圖(DAG),而不是一棵樹。將檔案系統作為DAG會使維護變得複雜,但生活正是如此。
包含了一個共用檔案的檔案系統。
共用檔案很方便,但也帶來了一些問題。首先,如果目錄確實包含磁碟地址,那麼在連結檔案時,必須在B的目錄中建立磁碟地址的副本。如果隨後B或C追加到檔案中,則新塊將僅列在執行追加操作的使用者的目錄中。其他使用者將看不到這些更改,從而破壞了共用的目的。這個問題可以通過兩種方式解決:
這些方法都有其缺點。在第一種方法中,當B連結到共用檔案時,i-node將檔案的所有者記錄為C。建立連結不會更改所有權(見下圖),但會增加i-node中的連結數,因此係統知道當前有多少目錄條目指向該檔案。
(a) 連線前的情況。(b) 建立連結後。(c) 在原始所有者刪除檔案後。
如果C隨後嘗試刪除該檔案,則系統將面臨問題。如果刪除檔案並清除i-node,B將有一個指向無效i-node的目錄條目。如果稍後將i節點重新指定給其他檔案,B的連結將指向錯誤的檔案。系統可以從i-node中的計數看出檔案仍在使用中,但沒有簡單的方法可以找到檔案的所有目錄條目,以便將其刪除。指向目錄的指標不能儲存在索引節點中,因為目錄的數量可能不受限制。
唯一要做的是刪除C的目錄條目,但保留i節點不變,計數設定為1,如上圖(C)所示。我們現在有一種情況,B是唯一一個擁有C所擁有檔案的目錄條目的使用者。如果系統進行記帳或有配額,C將繼續為該檔案計數,直到B決定刪除它,如果有,此時計數變為0,檔案被刪除。
使用符號連結時,不會出現此問題,因為只有真正的所有者才有指向i節點的指標。連結到檔案的使用者只有路徑名,而沒有i節點指標。當所有者刪除檔案時,它將被銷燬。當系統無法找到該檔案時,後續通過符號連結使用該檔案的嘗試將失敗。刪除符號連結根本不會影響檔案。
技術的變化給當前的檔案系統帶來了壓力。特別是,CPU的速度越來越快,磁碟越來越大,越來越便宜(但速度並不快),記憶體的大小呈指數級增長。磁碟尋道時間(固態磁碟除外,固態磁碟沒有尋道時間)是一個沒有明顯改善的引數。
這些因素的組合意味著在許多檔案系統中出現了效能瓶頸。伯克利大學的研究試圖通過設計一種全新的檔案系統LFS(Log-structured
File System,紀錄檔結構檔案系統)來緩解這個問題。
推動LFS設計的想法是,隨著CPU速度的加快和RAM記憶體的增大,磁碟快取也在迅速增加。因此,現在可以直接從檔案系統快取滿足大部分讀取請求,而不需要磁碟存取。從這個觀察結果可以看出,在未來,大多數磁碟存取都將是寫操作,因此在某些檔案系統中用於在需要塊之前獲取塊的預讀機制不再能獲得太多效能。
更糟糕的是,在大多數檔案系統中,寫入都是在非常小的塊中完成的。小型寫入效率很低,因為50微秒的磁碟寫入之前通常會有10毫秒的尋道和4毫秒的旋轉延遲。使用這些引數,磁碟效率會下降到1%。
要檢視所有小寫操作的來源,請考慮在UNIX系統上建立一個新檔案。要寫入此檔案,必須寫入目錄的i節點、目錄塊、檔案的i節點以及檔案本身。雖然這些寫入可能會延遲,但如果在寫入之前發生崩潰,那麼這樣做會使檔案系統面臨嚴重的一致性問題。因此,通常會立即執行i節點寫入。
根據這一推理,LFS設計者決定重新實現UNIX檔案系統,以實現磁碟的全部頻寬,即使面對由大部分小的隨機寫入組成的工作負載。基本思想是將整個磁碟結構為一個大紀錄檔。
定期地,當有特殊需要時,記憶體中緩衝的所有掛起的寫操作都被收集到一個段中,並在紀錄檔末尾作為一個連續的段寫入磁碟。因此,單個段可能包含混合在一起的i節點、目錄塊和資料塊。每段開頭都有一個段摘要,說明段中可以找到什麼。如果可以將平均段設定為大約1 MB,則幾乎可以利用磁碟的全部頻寬。
在這種設計中,i節點仍然存在,甚至具有與UNIX中相同的結構,但它們現在分散在紀錄檔中,而不是位於磁碟上的固定位置。然而,在定位i節點時,通常會按常規方式定位塊。當然,現在查詢i節點要困難得多,因為它的地址不能像在UNIX中那樣簡單地從i編號計算出來。為了能夠找到i節點,將維護一個按i編號索引的i節點對映表。此對映中的條目i指向磁碟上的i節點i。對映表儲存在磁碟上,但也會被快取,因此最常用的部分大部分時間都在記憶體中。
總結一下我們到目前為止所說的內容,所有寫操作最初都在記憶體中進行緩衝,並且定期將所有緩衝的寫操作寫入紀錄檔末尾的單個段中的磁碟。現在,開啟檔案包括使用對映表定位檔案的i節點。一旦找到i節點,就可以從中找到塊的地址。所有塊本身都是分段的,位於紀錄檔中的某個位置。
如果磁碟無限大,上面的描述就是全部。然而,實際磁碟是有限的,因此紀錄檔最終將佔據整個磁碟,此時無法向紀錄檔寫入新的段。幸運的是,許多現有段可能有不再需要的塊。例如,如果檔案被覆蓋,其i節點現在將指向新塊,但舊塊仍將在以前寫入的段中佔用空間。
為了解決這個問題,LFS有一個更乾淨的執行緒,它花時間迴圈掃描紀錄檔以壓縮它。它首先讀取紀錄檔中第一段的摘要,以檢視其中有哪些i節點和檔案。然後檢查當前的i節點對映表,以檢視i節點是否仍然是當前的,檔案塊是否仍在使用中。否則,該資訊將被丟棄。仍在使用的i節點和塊進入記憶體,在下一個段中寫出。然後將原始段標記為空閒,以便紀錄檔可以將其用於新資料。以這種方式,清潔器沿著紀錄檔移動,從後面刪除舊段,並將任何實時資料放入記憶體,以便在下一段中重寫。因此,磁碟是一個大的圓形緩衝區,寫入執行緒在前面新增新的段,而清理執行緒從後面刪除舊的段。
這裡的記賬(bookkeeping)很重要,因為當一個檔案塊被寫回一個新的段時,檔案的i節點(在紀錄檔中的某個位置)必須被定位、更新並放入記憶體中,以便在下一段中寫出。然後必須更新i節點貼圖以指向新副本。儘管如此,仍然可以進行管理,效能結果表明,所有這些複雜性都是值得的。上述論文中給出的測量結果表明,LFS在小寫操作方面比UNIX好幾個數量級,而在讀操作和大寫操作方面的效能與UNIX相當或更好。
雖然紀錄檔結構檔案系統是一個有趣的想法,但它們並沒有被廣泛使用,部分原因是它們與現有檔案系統高度不相容。然而,它們所固有的一個思想,即面對故障時的健壯性,可以很容易地應用於更傳統的檔案系統。這裡的基本思想是在檔案系統執行操作之前儲存一個紀錄檔,以便如果系統在執行計劃的工作之前崩潰,在重新啟動系統時,可以檢視紀錄檔,檢視崩潰時發生的情況並完成作業。這種檔案系統稱為紀錄檔檔案系統(Journaling File Systems),實際上正在使用中。Microsoft的NTFS檔案系統以及Linux ext3和ReiserFS檔案系統都使用紀錄檔記錄,OSX提供紀錄檔檔案系統作為一個選項。
要了解問題的本質,請考慮一個經常發生的普通操作:刪除檔案。此操作(在UNIX中)需要三個步驟:
1、從目錄中刪除檔案。
2、將i-node釋放到空閒i-node池中。
3、將所有磁碟塊返回到可用磁碟塊池。
在Windows中,需要類似的步驟。在沒有系統崩潰的情況下,採取這些步驟的順序無關緊要;在發生崩潰的情況下,情況確實如此。假設第一步完成,然後系統崩潰。i節點和檔案塊將無法從任何檔案存取,但也不能用於重新分配,它們只是處於不確定的狀態,減少了可用的資源。如果崩潰發生在第二步之後,則僅丟失塊。
如果操作順序發生更改,並且首先釋放了i-node,那麼在重新啟動後,i-node可能會被重新分配,但舊的目錄條目將繼續指向它,從而指向錯誤的檔案。如果先釋放塊,則在清除i-node之前發生崩潰意味著有效的目錄條目將指向一個i-node,其中列出了當前在空閒儲存池中的塊,並且很可能很快會被重用,從而導致兩個或多個檔案隨機共用同一塊。這些結果都不好。
紀錄檔檔案系統所做的是首先寫入一個紀錄檔條目,列出要完成的三個操作。然後將紀錄檔條目寫入磁碟(為了更好地測量,可能會從磁碟讀取,以驗證它實際上是否正確寫入)。只有在寫入紀錄檔條目後,才能開始各種操作。操作成功完成後,紀錄檔條目將被擦除。如果系統現在崩潰,在恢復時,檔案系統可以檢查紀錄檔以檢視是否有任何操作掛起。如果是這樣,則可以重新執行所有這些檔案(在重複崩潰的情況下多次執行),直到檔案被正確刪除。
為了使紀錄檔記錄有效,紀錄檔記錄的操作必須是冪等的,意味著可以根據需要重複這些操作,而不會造成損害。可以重複執行「更新點陣圖以將i節點k或塊n標記為空閒」等操作,直到列迴歸時沒有危險。類似地,搜尋目錄並刪除任何名為foobar的條目也是冪等的。另一方面,將i節點K中新釋放的塊新增到空閒列表的末尾不是冪等的,因為它們可能已經存在。更昂貴的操作「搜尋可用塊列表並將塊n新增到其中(如果尚未存在)」是冪等的。紀錄檔檔案系統必須安排其資料結構和可記錄操作,以便它們都是冪等的。在這些情況下,可以快速安全地進行崩潰恢復。
為了增加可靠性,檔案系統可以引入原子事務的資料庫概念。當使用這個概念時,一組操作可以被開始事務和結束事務操作括起來。然後,檔案系統知道它必須完成所有括號內的操作,或者不完成任何操作,但不能完成任何其他組合。
NTFS有一個廣泛的紀錄檔系統,其結構很少因系統崩潰而損壞。自1993年Windows NT首次釋出以來,它就一直在開發中。第一個做紀錄檔記錄的Linux檔案系統是ReiserFS,但它的普及受到了阻礙,因為它與當時的標準ext2檔案系統不相容。相反,與ReiserFS相比,ext3是一個不那麼雄心勃勃的專案,它在保持與以前的ext2系統相容的同時也做紀錄檔記錄。
即使對於同一作業系統,在同一臺計算機上也經常使用許多不同的檔案系統。Windows系統可能有一個主NTFS檔案系統,但也有一箇舊的FAT-32或FAT-16驅動器或分割區,其中包含舊的但仍然需要的資料,有時還需要一個快閃記憶體驅動器、舊的CD-ROM或DVD(每個都有自己獨特的檔案系統)。Windows通過使用不同的驅動器號(如C:、D:等)標識每個檔案系統來處理這些不同的檔案系統。當程序開啟檔案時,驅動器號是顯式或隱式顯示的,因此Windows知道要將請求傳遞給哪個檔案系統。沒有嘗試將異構檔案系統整合到一個統一的整體中。
相比之下,所有現代UNIX系統都在認真嘗試將多個檔案系統整合到一個結構中。Linux系統可以將ext2作為根檔案系統,在/usr上安裝ext3分割區,在/home上安裝ReiserFS檔案系統的第二個硬碟,以及在/mnt上臨時安裝ISO 9660 CD-ROM。從使用者的角度來看,存在單個檔案系統層次結構。它碰巧包含多個(不相容)檔案系統,這對使用者或程序來說是不可見的。
然而,多檔案系統的存在對於實現來說是非常明顯的,並且自從Sun Microsystems的開創性工作以來,大多數UNIX系統都使用VFS(virtual file system,虛擬檔案系統)的概念來嘗試將多個檔案系統整合到一個有序的結構中。其關鍵思想是抽象出所有檔案系統通用的檔案系統部分,並將該程式碼放在一個單獨的層中,該層呼叫底層的具體檔案系統來實際管理資料。總體結構如下圖所示。下面的討論不是針對Linux或FreeBSD或任何其他版本的UNIX,而是介紹了虛擬檔案系統在UNIX系統中的工作方式。
虛擬檔案系統的位置。
所有與檔案相關的系統呼叫都被定向到虛擬檔案系統進行初始處理。這些來自使用者程序的呼叫是標準的POSIX呼叫,例如open、read、write、lseek等。因此,VFS具有使用者程序的「上部」介面,它是眾所周知的POSIX介面。
VFS還有一個到具體檔案系統的「較低」介面,在上圖中標記為VFS介面。該介面由幾十個函數呼叫組成,VFS可以對每個檔案系統進行函數呼叫以完成工作。因此,要建立與VFS一起工作的新檔案系統,新檔案系統的設計者必須確保它提供了VFS所需的函數呼叫。此類函數的一個明顯範例是從磁碟讀取特定塊,將其放入檔案系統的緩衝區快取,並返回指向該塊的指標。因此,VFS有兩個不同的介面:上層介面用於使用者程序,下層介面用於具體檔案系統。
雖然VFS下的大多數檔案系統表示本地磁碟上的分割區,但情況並非總是如此。事實上,Sun構建VFS的最初動機是使用NFS(網路檔案系統)協定支援遠端檔案系統。VFS的設計是這樣的,只要具體的檔案系統提供了VFS所需的功能,VFS就不知道或不關心資料儲存在哪裡或底層檔案系統是什麼樣的。
在內部,大多數VFS實現本質上是物件導向的,即使它們是用C而不是C++編寫的。通常支援幾種關鍵物件型別,包括超級塊(描述檔案系統)、v節點(描述檔案)和目錄(描述檔案體系目錄),每個都有具體檔案系統必須支援的關聯操作(方法)。此外,VFS有一些內部資料結構供自己使用,包括掛載表和一組檔案描述符,用於跟蹤使用者程序中所有開啟的檔案。
為了理解VFS是如何工作的,讓我們按時間順序執行一個範例。當系統啟動時,根檔案系統向VFS註冊。此外,當其他檔案系統在引導時或操作期間裝載時,它們也必須向VFS註冊。當一個檔案系統註冊時,它基本上是提供一個VFS所需函數的地址列表,可以是一個長呼叫向量(表),也可以是其中的幾個,每個VFS物件一個,這是VFS所要求的。因此,一旦檔案系統向VFS註冊,VFS就知道如何從中讀取塊,它只需呼叫檔案系統提供的向量中的第四個(或其他)函數。類似地,VFS知道如何執行具體檔案系統必須提供的所有其他功能:它只呼叫檔案系統註冊時提供地址的函數。
安裝檔案系統後,可以使用它。例如,如果在/usr上裝載了一個檔案系統,並且某個程序在解析路徑時開啟了呼叫:
open("/usr/include/unistd.h", O_RDONLY)
則VFS會看到一個新的檔案系統已裝載在/usr上,並通過搜尋已裝載檔案系統的超級塊列表來定位其超級塊。完成此操作後,它可以找到裝載的檔案系統的根目錄,並查詢路徑include/unistd.h在那裡。然後,VFS建立一個v節點,並呼叫具體的檔案系統來返回檔案inode中的所有資訊。此資訊與其他資訊(最重要的是指向函數表的指標)一起復制到v節點(在RAM中)中,以呼叫v節點上的操作,例如讀取、寫入、關閉等。
建立v-node後,VFS在檔案描述符表中為呼叫程序建立一個條目,並將其設定為指向新的v-node。最後,VFS將檔案描述符返回給呼叫者,以便它可以使用它來讀取、寫入和關閉檔案。
稍後,當程序使用檔案描述符進行讀取時,VFS從程序和檔案描述符表中找到v節點,並跟隨指向函數表的指標,所有這些都是請求檔案所在的具體檔案系統中的地址。現在將呼叫處理讀取的函數,具體檔案系統中的程式碼將進入並獲取請求的塊。VFS不知道資料是來自本地磁碟、網路上的遠端檔案系統、U盤還是其他東西。涉及的資料結構如下圖所示。從呼叫者的程序號和檔案描述符開始,依次定位具體檔案系統中的v節點、讀取函數指標和存取函數。
VFS和具體檔案系統用於讀取的資料結構和程式碼的簡化檢視。
通過這種方式,新增新的檔案系統變得相對簡單。設計人員首先獲得VFS期望的函數呼叫列表,然後編寫檔案系統來提供所有函數呼叫。或者,如果檔案系統已經存在,那麼它們必須提供包裝器函數來完成VFS所需的工作,通常是通過對具體檔案系統進行一個或多個本地呼叫。
使檔案系統工作是一回事;讓它在現實生活中高效、穩健地工作是完全不同的。在以下各節中,我們將討論管理磁碟所涉及的一些問題。
檔案通常儲存在磁碟上,因此磁碟空間的管理是檔案系統設計者的主要關注點。儲存一個n位元組檔案有兩種通用策略:分配n個連續位元組的磁碟空間,或者將檔案分割成多個(不一定)連續的塊。在記憶體管理系統中,純分段和分頁之間也存在相同的折衷。
正如我們所看到的,將檔案儲存為連續的位元組序列有一個明顯的問題,即如果檔案增長,可能必須將其移動到磁碟上。記憶體中的段也存在同樣的問題,不同的是,與將檔案從一個磁碟位置移動到另一個磁碟的位置相比,在記憶體中移動段是一個相對較快的操作。因此,幾乎所有的檔案系統都會將檔案切成固定大小的塊,這些塊不需要相鄰。
首先考慮的塊大小(Block Size)。
一旦決定將檔案儲存在固定大小的塊中,問題就出現了,塊應該有多大。考慮到磁碟的組織方式,磁區、磁軌和柱面顯然是分配單元的候選物件(儘管它們都依賴於裝置,這是負數)。在分頁系統中,頁面大小也是一個主要的競爭者。
擁有較大的塊大小意味著每個檔案,甚至是一個1位元組的檔案,都會佔用整個柱面,也意味著小檔案會浪費大量磁碟空間。另一方面,較小的塊大小意味著大多數檔案將跨越多個塊,因此需要多次尋道和旋轉延遲來讀取它們,從而降低效能。因此,如果分配單元太大,我們就會浪費空間;如果太小,會浪費時間。
要做出正確的選擇,需要掌握一些有關檔案大小分佈的資訊。Tanenbaum等人(2006年)於1984年和2005年分別在一所大型研究型大學(VU)的電腦科學系和一個託管政治網站(www.electroral-vote.com)的商業Web伺服器上研究了檔案大小分佈。結果下圖所示,其中,對於兩個檔案大小的每一次冪,列出了三個資料集中每個資料集小於或等於它的所有檔案的百分比。例如,2005年,VU中59.13%的檔案小於等於4 KB,90.84%的檔案小於或等於64 KB。中間檔案大小為2475位元組。有些人可能會覺得這種小尺寸令人驚訝。
小於給定大小(以位元組為單位)的檔案的百分比。
我們可以從這些資料中得出什麼結論?首先,對於塊大小為1 KB的檔案,只有大約30-50%的檔案可以放在一個塊中,而對於4-KB的檔案塊,放在一塊中的檔案百分比可以達到60-70%。本文中的其他資料顯示,對於4-KB的塊,93%的磁碟塊被10%的最大檔案使用。這意味著在每個小檔案的末尾浪費一些空間幾乎無關緊要,因為磁碟被少量大檔案(視訊)填滿,小檔案佔用的空間總量幾乎無關痛癢。即使將最小的90%檔案佔用的空間加倍,也幾乎看不到。
另一方面,使用小塊意味著每個檔案將由多個塊組成。讀取每個塊通常需要尋道和旋轉延遲(固態磁碟除外),因此讀取由許多小塊組成的檔案會很慢。
例如,考慮一個每個磁軌有1 MB的磁碟,旋轉時間為8.33毫秒,平均尋道時間為5毫秒。讀取k位元組塊的時間(毫秒)就是尋道時間、旋轉延遲時間和傳輸時間的總和:
下圖的虛線曲線顯示了此類磁碟的資料速率與塊大小的函數關係。為了計算空間效率,我們需要假設平均檔案大小,為了簡單起見,我們假設所有檔案都是4KB。雖然這個數位略大於VU測量的資料,但學生可能擁有比企業資料中心中更多的小檔案,因此總體上來說,這可能是一個更好的猜測。下圖的實心曲線顯示了作為塊大小函數的空間效率。
這兩條曲線可以理解如下。一個塊的存取時間完全由尋道時間和旋轉延遲決定,因此如果存取一個塊要花費9毫秒,那麼獲取的資料越多越好。因此,資料速率幾乎與塊大小成線性增長(直到傳輸時間過長,傳輸時間開始變得重要)。
現在考慮空間效率。對於4-KB檔案和1-KB、2-KB或4-KB塊,檔案分別使用4、2和1個塊,沒有浪費。對於8-KB的塊和4-KB的檔案,空間效率下降到50%,而對於16KB的塊,空間效率降低到25%。實際上,很少檔案是磁碟塊大小的精確倍數,因此檔案的最後一個塊總是浪費一些空間。
然而,曲線表明,效能和空間利用率之間存在固有的衝突。小資料塊對效能不利,但對磁碟空間利用率有利。對於這些資料,沒有合理的折衷方案。最接近兩條曲線交叉處的大小為64KB,但資料速率僅為6.6MB/秒,空間效率約為7%,兩者都不是很好。過去,檔案系統選擇的大小在1-KB到4-KB之間,但現在磁碟超過1TB,最好將塊大小增加到64KB,並接受浪費的磁碟空間。磁碟空間幾乎不再短缺。
Vogels在康奈爾大學(Cornell University)對檔案進行了測量,以確定Windows NT檔案的使用情況是否與UNIX檔案的使用有明顯不同(Vogels,1999)。他注意到NT檔案的使用比在UNIX上更復雜。他寫道:當我們在記事本文字編輯器中鍵入幾個字元時,將其儲存到檔案將觸發26個系統呼叫,包括3次失敗的開啟嘗試、1次檔案覆蓋和4次額外的開啟和關閉序列。
然而,Vogels觀察到檔案的中值大小(按使用情況加權)為1KB,寫檔案為2.3KB,讀寫檔案為4.2KB。考慮到不同的資料集測量技術和年份,這些結果與VU結果肯定是相容的。
接下來闡述跟蹤空閒塊(Keeping Track of Free Blocks)。
一旦選擇了塊大小,下一個問題是如何跟蹤空閒塊。有兩種方法被廣泛使用,如下圖所示。第一種方法包括使用磁碟塊的連結列表,每個塊都包含儘可能多的可用磁碟塊編號。對於1-KB塊和32位元磁碟塊編號,可用列表中的每個塊都包含255個可用塊。(指向下一個塊的指標需要一個插槽。)考慮一個1-TB磁碟,它有大約10億個磁碟塊。要將所有這些地址儲存為每個塊255個,需要大約400萬個塊。通常,空閒塊用於儲存空閒列表,因此儲存基本上是空閒的。
(a) 將空閒列表儲存在連結列表中。(b) 點陣圖。
另一種可用空間管理技術是點陣圖。具有n個塊的磁碟需要具有n位的點陣圖。在圖中,可用塊用1表示,分配塊用0表示(反之亦然)。對於我們的範例1-TB磁碟,對映需要10億位,這需要大約130000個1-KB塊來儲存。點陣圖需要更少的空間並不奇怪,因為它每個塊使用1位,而連結列表模型中使用32位元。只有當磁碟接近滿時(即只有很少的空閒塊),連結列表方案所需的塊才會少於點陣圖。
如果空閒塊傾向於以長時間連續塊的形式出現,則可以修改空閒列表系統,以跟蹤塊的執行而不是單個塊的執行。可以將8、16或32位元計數與每個塊相關聯,給出連續可用塊的數量。在最好的情況下,一個基本上是空的磁碟可以用兩個數位表示:第一個空閒塊的地址後面是空閒塊的數量。另一方面,如果磁碟嚴重碎片化,則跟蹤執行比跟蹤單個塊效率低,因為不僅必須儲存地址,還必須儲存計數。
這個問題說明了作業系統設計者經常遇到的一個問題。有多種資料結構和演演算法可用於解決問題,但選擇最佳資料結構和方法需要設計者沒有並且在系統部署和大量使用之前不會擁有的資料。即使如此,資料也可能不可用。例如,在1984年和1995年測量的VU檔案大小、網站資料和康奈爾大學資料只是四個樣本。雖然比什麼都沒有要好得多,但不知道它們是否也能代表家用電腦、公司電腦、政府電腦和其他電腦。通過一些努力,我們可能已經能夠從其他型別的計算機上獲得一些樣本,但即使如此,將這些樣本外推到所有被測量的計算機上也是愚蠢的。
回到空閒列表方法,只需要在主記憶體中保留一塊指標。建立檔案時,所需的塊從指標塊中獲取。當它用完時,會從磁碟中讀入一個新的指標塊。類似地,當一個檔案被刪除時,它的塊被釋放並新增到主記憶體中的指標塊中。當這個塊被填滿時,它被寫入磁碟。
在某些情況下,此方法會導致不必要的磁碟I/O。考慮下圖(a)中的情況,記憶體中的指標塊只能再容納兩個條目。如果釋放了一個三塊檔案,指標塊溢位,必須寫入磁碟,導致(b)所示的情況。如果現在寫入了一個三塊檔案,則必須再次讀取完整的指標塊,將我們帶回(a)。如果剛剛寫入的三塊檔案是一個臨時檔案,則在釋放該檔案時,需要另一次磁碟寫入才能將整個指標塊寫回磁碟。簡而言之,當指標塊幾乎為空時,一系列短期臨時檔案可能會導致大量磁碟I/O。
避免大多數磁碟I/O的另一種方法是分割整個指標塊。因此,當釋放三個塊時,我們不再從下圖(a)轉到下圖。現在,系統可以處理一系列臨時檔案,而無需執行任何磁碟I/O。如果記憶體中的塊已滿,則會將其寫入磁碟,並讀入磁碟中的半滿塊。這裡的想法是保持磁碟上的大多數指標塊已滿(以最小化磁碟使用),但保持記憶體中的指標塊約半滿,這樣它就可以在空閒列表中沒有磁碟I/O的情況下處理檔案建立和檔案刪除。
(a) 指向記憶體中空閒磁碟塊的幾乎完整的指標塊和磁碟上的三個指標塊。(b) 釋放三個塊檔案的結果。(c)處理三個空閒塊的替代策略。帶陰影的條目表示指向可用磁碟塊的指標。
使用點陣圖,也可以只保留記憶體中的一個塊,只有當它完全滿或空時才將另一個塊放入磁碟。這種方法的另一個好處是,通過從點陣圖的單個塊進行所有分配,磁碟塊將緊密相連,從而最小化磁碟臂運動。由於點陣圖是固定大小的資料結構,如果對核心進行(部分)分頁,則可以將點陣圖放在虛擬記憶體中,並根據需要分頁。
接下來闡述磁碟配額(Disk Quotas)。
為了防止人們佔用過多的磁碟空間,多使用者作業系統通常提供一種強制執行磁碟配額的機制。其思想是,系統管理員為每個使用者分配檔案和塊的最大分配,作業系統確保使用者不會超過其配額。下面描述了一個典型的機制。
當用戶開啟一個檔案時,屬性和磁碟地址被定位並放入主記憶體中開啟的檔案表中。屬性中有一個條目,告訴誰是所有者,檔案大小的任何增加都將計入所有者的配額。
第二個表包含當前開啟檔案的每個使用者的配額記錄,即使該檔案是由其他人開啟的,此表如下圖所示。它是從磁碟上的配額檔案中為當前開啟檔案的使用者提取的,關閉所有檔案後,記錄將被寫回配額檔案。
當在開啟的檔案表中建立新條目時,會在其中輸入一個指向所有者配額記錄的指標,以便於查詢各種限制。每次向檔案中新增塊時,向所有者收取的塊總數都會增加,並對硬限制和軟限制進行檢查。可以超過軟限制,但不能超過硬限制。當達到硬塊限制時,嘗試附加到檔案將導致錯誤。還存在類似的檔案數量檢查,以防止使用者佔用所有i節點。
當用戶嘗試登入時,系統會檢查配額檔案,以檢視使用者是否已超過檔案數或磁碟塊數的軟限制。如果違反了任一限制,將顯示警告,剩餘警告數將減少一。如果計數為零,則使用者多次忽略警告,不允許登入。要獲得再次登入的許可權,需要與系統管理員進行一些討論。
此方法具有這樣的屬性,即使用者在登入對談期間可能會超出其軟限制,前提是他們在登出之前移除超出的限制。不得超過硬限制。
檔案系統的破壞通常比計算機的破壞更大。如果一臺電腦被火災、閃電或一杯咖啡潑到鍵盤上燒燬,會很煩人,也會花很多錢,但通常情況下,可以用最少的麻煩購買一臺替代品。便宜的個人電腦甚至可以在一個小時內通過去電腦商店來更換。
如果計算機的檔案系統由於硬體或軟體而無法挽回地丟失,恢復所有資訊將是困難的、耗時的,而且在許多情況下是不可能的。對於那些程式、檔案、稅務記錄、客戶檔案、資料庫、行銷計劃或其他資料永遠消失的人來說,後果可能是災難性的。雖然檔案系統不能提供任何保護,防止裝置和媒介的物理破壞,但它可以幫助保護資訊,非常簡單——進行備份,但這並不像聽起來那麼簡單。
大多數人認為備份檔案是不值得花時間和精力的,直到有一天他們的磁碟突然損壞,這時他們中的大多數人都經歷了一次致命的轉換。然而,公司(通常)非常瞭解其資料的價值,通常每天至少備份一次,通常備份到磁帶。現代磁帶可容納數百GB的容量,每GB成本為幾美分。然而,備份並不像聽起來那麼簡單,所以我們將在下面研究一些相關問題。磁帶備份通常用於處理以下兩個潛在問題之一:
1、從災難中恢復。
2、從愚蠢中恢復過來。
第一種是在磁碟崩潰、火災、洪水或其他自然災害後讓計算機重新執行。實際上,這些事情並不經常發生,這就是為什麼許多人不願意為備份而煩惱。
第二個原因是使用者經常不小心刪除了以後再次需要的檔案。這個問題經常發生,當一個檔案在Windows中被「刪除」時,它根本不會被刪除,只是被移動到一個特殊的目錄,即回收站,這樣它就可以很容易地被提取出來並在以後恢復。備份進一步遵循了這一原則,允許從舊備份磁帶恢復幾天甚至幾周前刪除的檔案。
備份需要很長時間,並且佔用大量空間,因此高效、方便地進行備份非常重要。這些考慮提出了以下問題。
首先,應該備份整個檔案系統還是隻備份其中的一部分?在許多安裝中,可執行(二進位制)程式儲存在檔案系統樹的有限部分中。如果可以從製造商網站或安裝DVD重新安裝這些檔案,則無需備份這些檔案。此外,大多數系統都有一個臨時檔案目錄,通常也沒有理由支援它。在UNIX中,所有特殊檔案(I/O裝置)都儲存在目錄/dev中。不僅不需要備份這個目錄,而且非常危險,因為如果備份程式試圖讀取每個目錄直到完成,它將永遠掛起。簡而言之,通常只備份特定目錄和其中的所有內容,而不是備份整個檔案系統。
第二,備份自上次備份以來未更改的檔案是浪費的,於是有了增量轉儲(incremental dumps)的想法。增量轉儲的最簡單形式是定期進行完整轉儲(備份),例如每週或每月進行一次,並且每天只轉儲自上次完全轉儲以來修改過的檔案。更好的方法是隻轉儲自上次轉儲以來發生更改的檔案。雖然此方案將轉儲時間減至最少,但它使恢復更加複雜,因為首先必須恢復最近的完整轉儲,然後是按相反順序的所有增量轉儲。為了便於恢復,通常使用更復雜的增量轉儲方案。
第三,由於通常會轉儲大量資料,因此最好在將資料寫入磁帶之前對其進行壓縮。然而,對於許多壓縮演演算法,備份磁帶上的一個壞點可能會破壞解壓縮演演算法,使整個檔案甚至整個磁帶都無法讀取。因此,必須仔細考慮壓縮備份流的決定。
第四,很難在活動檔案系統上執行備份。如果在轉儲過程中新增、刪除和修改檔案和目錄,則產生的轉儲可能不一致。然而,由於進行轉儲可能需要數小時,因此可能需要讓系統在晚上的大部分時間離線以進行備份,這並不總是可以接受的。為此,設計了一些演演算法,通過複製關鍵資料結構來快速快照檔案系統狀態,然後要求將來更改檔案和目錄以複製塊,而不是就地更新塊(Hutchinson等人,1999)。通過這種方式,檔案系統在快照時被有效地凍結,因此可以在以後空閒時進行備份。
第五,也是最後一點,備份會給組織帶來許多非技術性問題。如果系統管理員把所有的備份磁碟或磁帶放在辦公室裡,並且在他走下大廳去喝咖啡的時候讓它敞開著,沒有人看守,那麼世界上最好的線上安全系統可能是無用的。間諜所要做的就是闖進來一秒鐘,把一張小小的磁碟或磁帶放在口袋裡,然後興高采烈地溜走。此外,如果燒燬計算機的火也燒燬了所有備份磁碟,那麼每天備份也沒有什麼用處。因此,備份磁碟應該放在異地,但這會帶來更多的安全風險(因為現在必須保護兩個站點)。下面我們將討論只有檔案系統備份涉及的技術問題。
可以使用兩種策略將磁碟轉儲到備份磁碟:物理轉儲或邏輯轉儲。
物理轉儲從磁碟的塊0開始,按順序將所有磁碟塊寫入輸出磁碟,並在複製完最後一個磁碟塊後停止。這樣一個程式非常簡單,它可能100%沒有bug,可能是任何其他有用的程式都無法做到的。
儘管如此,還是值得對物理轉儲發表幾點意見。首先,備份未使用的磁碟塊沒有任何價值。如果轉儲程式可以存取空閒塊資料結構,則可以避免轉儲未使用的塊。但是,跳過未使用的塊需要在塊(或等效塊)前面寫入每個塊的編號,因為備份中的塊k不再是磁碟上的塊k。
第二個擔憂是轉儲壞塊。幾乎不可能製造出沒有任何缺陷的大型磁碟,總是存在一些壞塊。有時,當完成低階格式化時,會檢測到壞塊,並將其標記為壞塊,然後由每個磁軌末端為此類緊急情況保留的備用塊替換。在許多情況下,磁碟控制器在作業系統甚至不知道的情況下透明地處理壞塊替換。
然而,有時塊在格式化後會變差,在這種情況下,作業系統最終會檢測到它們。通常,它通過建立一個包含所有壞塊的「檔案」來解決這個問題,只是為了確保它們不會出現在空閒塊池中,也不會被分配。不用說,這個檔案完全不可讀。
如果所有壞塊都被磁碟控制器重新對映,並像剛才描述的那樣從作業系統中隱藏,那麼物理轉儲可以正常工作。另一方面,如果它們對作業系統可見,並且儲存在一個或多個壞塊檔案或點陣圖中,那麼物理轉儲程式必須能夠存取這些資訊,並避免轉儲這些資訊,以防止在嘗試備份壞塊檔案時出現無休止的磁碟讀取錯誤。
Windows系統具有在還原時不需要的分頁和休眠檔案,因此不應首先備份這些檔案。特定系統還可能有其他不應備份的內部檔案,因此轉儲程式需要知道這些檔案。
物理轉儲的主要優點是簡單和速度快(基本上可以以磁碟的速度執行)。主要缺點是無法跳過選定的目錄,進行增量轉儲,以及根據請求恢復單個檔案。由於這些原因,大多數安裝都會進行邏輯轉儲。
邏輯轉儲從一個或多個指定目錄開始,並遞迴轉儲在其中找到的自給定基準日期以來發生更改的所有檔案和目錄(例如,增量轉儲的上次備份或完整轉儲的系統安裝)。因此,在邏輯轉儲中,轉儲磁碟會獲得一系列經過仔細識別的目錄和檔案,這使得根據請求恢復特定檔案或目錄變得很容易。
由於邏輯轉儲是最常見的形式,讓我們使用下圖中的範例來詳細檢查一種常見演演算法。大多數UNIX系統都使用此演演算法。在圖中,我們看到一個包含目錄(正方形)和檔案(圓形)的檔案樹。陰影專案自基準日期以來已被修改,因此需要轉儲。無陰影的不需要轉儲。
要轉儲的檔案系統。正方形是目錄,圓形是檔案。自上次轉儲以來,陰影專案已被修改。每個目錄和檔案都按其i節點編號進行標記。
由於兩個原因,此演演算法還將位於修改檔案或目錄路徑上的所有目錄(即使是未修改的目錄)轉儲到修改後的檔案或目錄。第一個原因是可以將轉儲的檔案和目錄恢復到另一臺計算機上的新檔案系統。這樣,轉儲和恢復程式可以用於在計算機之間傳輸整個檔案系統。
將未修改的目錄轉儲到修改過的檔案上的第二個原因是,可以增量恢復單個檔案(可能是為了處理愚蠢的恢復)。假設週日晚上進行了完整檔案系統轉儲,週一晚上進行了增量轉儲。星期二,目錄/usr/jhs/proj/nr3及其下的所有目錄和檔案將被刪除。星期三早上,假設使用者希望恢復檔案/usr/jhs/proj/nr3/plans/summary。但是,不可能只恢復檔案摘要,因為沒有放置它的位置。必須首先恢復目錄nr3和計劃。要獲得其所有者、模式、時間等資訊,即使這些目錄自上次完全轉儲後未被修改,也必須存在於轉儲磁碟上。
轉儲演演算法維護由i節點編號索引的點陣圖,每個i節點有幾個位。隨著演演算法的進行,位將在此對映中設定和清除。該演演算法分四個階段執行。階段1從起始目錄(本例中的根目錄)開始,並檢查其中的所有條目。對於每個修改過的檔案,其i節點都標記在點陣圖中。每個目錄也被標記(無論是否被修改),然後遞迴檢查。
在第1階段結束時,所有修改的檔案和所有目錄都已標記在點陣圖中,如下圖(a)所示(通過陰影)。階段2在概念上再次遞迴遍歷樹,取消標記任何目錄中或目錄下沒有修改過的檔案或目錄。此階段將留下點陣圖,如(b)所示。請注意,目錄10、11、14、27、29和30現在沒有標記,因為它們下面沒有任何修改過的內容。他們不會被拋棄。相比之下,目錄5和6將被轉儲,即使它們本身沒有被修改,因為需要它們來將今天的更改恢復到新機器。為了提高效率,階段1和階段2可以合併在一個樹行走中。
此時,我們知道必須轉儲哪些目錄和檔案,如(b)中標記的。階段3包括按數位順序掃描i節點並轉儲所有標記為轉儲的目錄。如(c)所示。每個目錄都以目錄的屬性(所有者、時間等)為字首,以便可以恢復它們。最後,在第4階段,(d)中標記的檔案也被轉儲,再次以其屬性作為字首。這便完成了轉儲。
邏輯轉儲演演算法使用的點陣圖。
從轉儲磁碟恢復檔案系統非常簡單。首先,在磁碟上建立一個空檔案系統,然後恢復最近的完整轉儲。由於目錄首先出現在轉儲磁碟上,因此它們都會首先被還原,從而提供檔案系統的框架。然後恢復檔案本身,然後重複此過程,在完全轉儲之後進行第一次增量轉儲,然後進行下一次,依此類推。
雖然邏輯轉儲很簡單,但有一些棘手的問題。首先,由於空閒塊列表不是一個檔案,因此它不會被轉儲,因此在恢復所有轉儲之後,必須從頭重新構建它。這樣做始終是可能的,因為空閒塊集只是包含在所有合併檔案中的塊集的補充。
另一個問題是連結。如果一個檔案連結到兩個或多個目錄,那麼只恢復一次該檔案,並且所有指向該檔案的目錄都會恢復,這一點很重要。
另一個問題是UNIX檔案可能包含漏洞。合法的做法是開啟一個檔案,寫入幾個位元組,然後查詢到遠處的檔案偏移量,再寫入幾個位元組。中間的塊不是檔案的一部分,不應轉儲,也不得還原。核心檔案在資料段和堆疊之間通常有數百兆位元組的空間。如果處理不當,每個恢復的核心檔案將用零填充該區域,因此大小與虛擬地址空間相同(例如,\(2^{32}\)位元組,或者更糟的是,\(2^{64}\)位元組)。
最後,特殊檔案、命名管道等(任何不是真實檔案的檔案)都不應該轉儲,無論它們可能出現在哪個目錄中(它們不需要侷限於/dev)。
另一個可靠性問題是檔案系統一致性。許多檔案系統讀取塊,修改它們,然後將它們寫出。如果在寫出所有修改的塊之前系統崩潰,檔案系統可能會處於不一致的狀態。如果某些尚未寫出的塊是i-node塊、目錄塊或包含空閒列表的塊,則此問題尤其重要。
為了處理不一致的檔案系統,大多數計算機都有一個實用程式來檢查檔案系統的一致性。例如,UNIX具有fsck,Windows有sfc(和其他)。此實用程式可以在系統啟動時執行,特別是在崩潰後。
下面的描述說明了fsck的工作原理。Sfc有些不同,因為它在不同的檔案系統上工作,但使用檔案系統固有冗餘修復它的一般原則仍然有效。所有檔案系統檢查器都獨立於其他檔案系統(磁碟分割區)來驗證每個檔案系統。可以進行兩種一致性檢查:塊和檔案。為了檢查塊一致性,程式構建了兩個表,每個表包含每個塊的計數器,最初設定為0。第一個表中的計數器跟蹤每個塊在檔案中出現的次數;第二個表中的計數器記錄每個塊出現在空閒列表(或空閒塊的點陣圖)中的頻率。
然後,程式使用原始裝置讀取所有i節點,該裝置忽略檔案結構,只返回從0開始的所有磁碟塊。從索引節點開始,可以構建相應檔案中使用的所有塊編號的列表。讀取每個塊編號時,第一個表中的計數器遞增。然後,程式檢查空閒列表或點陣圖以查詢所有未使用的塊。自由列表中每個塊的出現都會導致其在第二個表中的計數器遞增。
如果檔案系統是一致的,那麼每個塊在第一個表或第二個表中都會有一個1,如系統(a)所示。然而,由於碰撞,表格可能類似於圖(b),其中兩個表格中都沒有出現方框2。它將被報告為丟失的塊。雖然丟失的塊不會造成真正的危害,但它們會浪費空間,從而降低磁碟的容量。丟失塊的解決方案很簡單:檔案系統檢查器只是將它們新增到空閒列表中。
另一種可能發生的情況如圖(c)所示,有一個編號為4的塊,在空閒列表中出現了兩次。(只有當空閒列表確實是一個列表時,才會出現重複;使用點陣圖是不可能的。)解決方案也很簡單:重建空閒列表。
可能發生的最壞情況是,同一資料塊存在於兩個或多個檔案中,如圖(d)和塊5所示。如果刪除其中任何一個檔案,塊5將被放在空閒列表中,導致同一塊同時處於使用和空閒狀態。如果兩個檔案都被刪除,則塊將被放入空閒列表兩次。
檔案系統狀態。(a) 一致性。(b) 丟失塊。(c) 空閒列表中存在重複塊。(d) 重複的資料塊。
檔案系統檢查器要採取的適當操作是分配一個空閒塊,將塊5的內容複製到其中,然後將副本插入其中一個檔案。通過這種方式,檔案的資訊內容保持不變(儘管幾乎可以肯定是亂碼),但檔案系統結構至少保持一致。
應報告錯誤,以便使用者檢查損壞情況。除了檢查每個塊是否都得到了正確的解釋之外,檔案系統檢查器還檢查目錄系統。它也使用計數器表,但這些計數器是按檔案而不是按塊計算的。它從根目錄開始,遞迴地下降樹,檢查檔案系統中的每個目錄。對於每個目錄中的每個i節點,它會為該檔案的使用計數增加一個計數器。請記住,由於硬連結,檔案可能會出現在兩個或多個目錄中。符號連結不計數,也不會導致目標檔案的計數器遞增。
當檢查程式全部完成後,它會有一個列表,由i-node編號索引,告訴每個檔案包含多少個目錄。然後,它將這些數位與儲存在i節點本身中的連結計數進行比較。建立檔案時,這些計數從1開始,並在每次(硬)連結到檔案時遞增。在一致的檔案系統中,這兩種計數將一致。但是,可能會出現兩種錯誤:i節點中的連結計數可能過高,也可能過低。
如果連結計數大於目錄條目的數量,那麼即使從目錄中刪除了所有檔案,該計數仍將不為零,i-node也不會被刪除。此錯誤並不嚴重,但如果檔案不在任何目錄中,則會浪費磁碟空間。應該通過將i節點中的連結計數設定為正確的值來修復此問題。
另一個錯誤可能是災難性的。如果兩個目錄條目連結到一個檔案,但i-node表示只有一個,則刪除任一目錄條目時,i-node計數將變為零。當i節點計數為零時,檔案系統會將其標記為未使用,並釋放其所有塊。此操作將導致其中一個目錄現在指向未使用的i-node,其塊可能很快會分配給其他檔案。同樣,解決方案只是將i-node中的連結計數強制為目錄條目的實際數量。
出於效率原因,這兩種操作(檢查塊和檢查目錄)通常是整合在一起的(即,只需要在i節點上進行一次傳遞)。也可以進行其他檢查。例如,目錄具有明確的格式,其中包含i節點編號和ASCII名稱。如果i節點數大於磁碟上的i節點數,則說明目錄已損壞。
此外,每個i-node都有一個模式,其中一些是合法的,但很奇怪,例如0007,它允許所有者及其組根本沒有存取許可權,但允許外部人員讀取、寫入和執行檔案。至少報告給外部人比所有者更多權利的檔案可能會有用。例如,條目超過1000條的目錄也是可疑的。位於使用者目錄中但由超級使用者擁有並啟用SETUID位的檔案是潛在的安全問題,因為此類檔案在任何使用者執行時都會獲得超級使用者的許可權。只要稍加努力,人們就可以列出一份相當長的技術上合法但仍有可能值得報道的特殊情況的清單。
存取磁碟比存取記憶體慢得多,讀取32位元記憶體字可能需要10納秒,從硬碟讀取可能會以100 MB/秒的速度進行(是每32位元字讀取速度的四倍),但除此之外,還必須增加5–10毫秒以查詢磁軌,然後等待所需的磁區到達讀取頭下方。如果只需要一個字,那麼記憶體存取的速度大約是磁碟存取的一百萬倍。由於存取時間的差異,許多檔案系統都設計了各種優化以提高效能。下面介紹三種。
第一種提升檔案系統效能的方法是快取。
用於減少磁碟存取的最常見技術是塊快取或緩衝區快取。在這種情況下,快取是邏輯上屬於磁碟但出於效能原因保留在記憶體中的塊的集合。
可以使用各種演演算法來管理快取,但常見的演演算法是檢查所有讀取請求,以檢視所需的塊是否在快取中。如果是,則無需磁碟存取即可滿足讀取請求。如果塊不在快取中,則首先將其讀入快取,然後將其複製到需要的位置。快取可以滿足對同一塊的後續請求。
快取記憶體的操作如下圖所示。由於快取記憶體中有許多(通常是數千)塊,因此需要某種方法來快速確定給定塊是否存在。通常的方法是雜湊裝置和磁碟地址,並在雜湊表中查詢結果。具有相同雜湊值的所有塊都連結在一個連結串列上,以便可以跟蹤衝突鏈。
緩衝區快取資料結構。
IO緩衝方案(輸入)。
當一個塊必須載入到一個完全快取中時,必須刪除一些塊(如果它在引入後被修改,則必須重寫到磁碟)。這種情況非常類似於分頁,所有常用頁面替換演演算法,如FIFO、二次機會和LRU都適用。分頁和快取之間的一個令人愉快的區別是快取參照相對較少,因此可以使用連結列表將所有塊保持在精確的LRU順序。
在上圖中,我們可以看到,除了從雜湊表開始的衝突鏈之外,還有一個按使用順序遍歷所有塊的雙向列表,最近最少使用的塊位於列表的前面,最近使用的塊在末尾。當一個塊被參照時,可以將其從雙向列表中的位置刪除並放在末尾。通過這種方式,可以維持精確的LRU順序。
不幸的是,這裡有一個陷阱。既然我們有可能實現精確LRU的情況,事實證明LRU是不可取的。這個問題與崩潰和檔案系統一致性有關。如果將關鍵塊(如i節點塊)讀入快取並進行修改,但未重寫到磁碟,則崩潰將使檔案系統處於不一致狀態。如果將i-node塊放在LRU鏈的末端,它可能需要很長時間才能到達前端並被重寫到磁碟。
此外,某些塊(例如i節點塊)很少在短間隔內被參照兩次。這些考慮導致修改LRU方案,考慮了兩個因素:
1、是否很快會再次需要該區塊?
2、塊對檔案系統的一致性至關重要嗎?
對於這兩個問題,塊可以分為類別,例如i節點塊、間接塊、目錄塊、完整資料塊和部分完整資料塊。很快,可能不再需要的塊將放在LRU列表的前面,而不是後面,因此它們的緩衝區將被快速重用。可能很快會再次需要的塊,例如正在寫入的部分已滿的塊,位於列表的末尾,因此它們將保留很長時間。
第二個問題獨立於第一個問題。如果塊對檔案系統一致性至關重要(基本上,除了資料塊以外的所有內容),並且它已經被修改,則應立即將其寫入磁碟,而不必考慮它放在LRU列表的哪一端。通過快速寫入關鍵塊,我們大大降低了崩潰破壞檔案系統的可能性。
即使使用此措施來保持檔案系統完整性不變,也不希望在將資料塊寫出來之前將其儲存在快取中的時間過長。想想使用個人電腦寫書的人的困境。即使我們的編寫器定期告訴編輯器將正在編輯的檔案寫入磁碟,也很有可能所有內容都仍在快取中,而磁碟上什麼也沒有。如果系統崩潰,檔案系統結構將不會損壞,但一整天的工作將丟失。
系統採用兩種方法來處理它。UNIX的方法是使用系統呼叫sync,它將所有修改的塊立即強制放到磁碟上。當系統啟動時,一個程式(通常稱為update)會在後臺啟動,在一個無休止的迴圈中發出同步呼叫,在呼叫之間休眠30秒。因此,由於崩潰,損失的工作時間不超過30秒。
雖然Windows現在有一個相當於同步的系統呼叫,稱為Flush File Buffers,但過去它沒有。相反,它有一種不同的策略,在某些方面比UNIX方法更好(在某些方面更糟)。它所做的是在每個修改過的塊寫入快取後立即將其寫入磁碟,所有修改過的塊立即寫回磁碟的快取稱為直寫快取,與非寫快取相比,它們需要更多的磁碟I/O。
當一個程式一次寫入一個1-KB的塊時,可以看出這兩種方法之間的差異。UNIX將收集快取中的所有字元,並每隔30秒或每當從快取中刪除塊時將其寫出一次。對於直寫快取,每個寫入的字元都有一個磁碟存取許可權。當然,大多數程式都進行內部緩衝,因此它們通常不會寫入字元,而是在每個寫入系統呼叫上寫入一行或更大的單元。
快取策略的這種差異導致的結果是,僅從UNIX系統中刪除磁碟而不進行同步幾乎總是會導致資料丟失,並且通常還會導致檔案系統損壞。使用直寫快取不會出現問題。之所以選擇這些不同的策略,是因為UNIX是在一個所有磁碟都是硬碟且不可移動的環境中開發的,而第一個Windows檔案系統是從軟碟世界開始的MS-DOS繼承而來的。隨著硬碟成為標準,UNIX方法以其更好的效率(但更差的可靠性)成為標準,現在在Windows上也用於硬碟。然而,如前所述,NTFS採取了其他措施(例如紀錄檔記錄)來提高可靠性。
一些作業系統將緩衝區快取與頁面快取整合在一起。當支援記憶體對映檔案時,尤其有吸引力。如果一個檔案對映到記憶體,那麼它的一些頁面可能在記憶體中,因為它們是按需分頁的,這樣的頁面與緩衝區快取中的檔案塊幾乎沒有區別。在這種情況下,可以以相同的方式處理它們,對檔案塊和頁面都使用一個快取。
第二種提升檔案系統效能的方法是塊預讀取(Block Read Ahead)。
提高感知檔案系統效能的第二種技術是,在需要塊來提高命中率之前,嘗試將塊放入快取。特別是,許多檔案是按順序讀取的。當要求檔案系統在檔案中生成塊k時,它會這樣做,但當它完成後,它會在快取中偷偷檢查塊k+1是否已經存在。如果不是,它會安排塊k+1的讀取,希望在需要時,它已經到達快取。至少,它會在路上。
當然,這種預讀策略只適用於實際按順序讀取的檔案。如果一個檔案正在被隨機存取,那麼預讀取並沒有幫助。事實上,它會將磁碟頻寬讀取捆綁在無用的塊中,並從快取中刪除可能有用的塊(如果這些塊髒了,則可能會捆綁更多的磁碟頻寬將其寫回磁碟),會造成傷害。為了檢視預讀是否值得,檔案系統可以跟蹤每個開啟的檔案的存取模式。例如,與每個檔案關聯的位可以跟蹤檔案是處於「順序存取模式」還是「隨機存取模式」。最初,檔案被賦予了懷疑的優勢,並被置於順序存取模式。然而,無論何時完成尋道,位都會被清除。如果再次開始順序讀取,則再次設定位。這樣,檔案系統就可以合理地猜測是否應該提前讀取。如果偶爾出錯,這不是災難,只是浪費了一點點磁碟頻寬。
第三種提升檔案系統效能的方法是減少圓盤臂運動(Reducing Disk-Arm Motion)。
快取和預讀並不是提高檔案系統效能的唯一方法。另一項重要的技術是通過將可能被依次接近的塊放置在同一個圓柱體中,來減少磁碟臂的運動量。寫入輸出檔案時,檔案系統必須按需一次分配一個塊。如果自由塊記錄在點陣圖中,並且整個點陣圖都在主記憶體中,那麼選擇一個儘可能接近前一個塊的自由塊就足夠容易了。有了一個空閒列表(其中一部分位於磁碟上),很難將塊緊密地分配在一起。
然而,即使有一個空閒列表,也可以進行一些塊聚類。訣竅是不按塊跟蹤磁碟儲存,而是按連續塊的組跟蹤。如果所有磁區都由512位元組組成,則系統可以使用1-KB的塊(2個磁區),但以2個塊(4個磁區)為單位分配磁碟儲存。與擁有2 KB的磁碟塊,因為快取仍將使用1 KB的塊,磁碟傳輸仍將為1 KB,但在空閒的系統上順序讀取檔案將減少兩倍的尋道數,從而大大提高效能。同一主題的變體是考慮旋轉定位,分配塊時,系統會嘗試將連續塊放置在同一圓柱體中的檔案中。
在使用i節點或類似節點的系統中,另一個效能瓶頸是,即使讀取一個短檔案也需要兩次磁碟存取:一次用於i節點,另一次用於塊。通常的i節點佈置如下圖(a)所示。這裡所有的i節點都靠近磁碟的起點,因此inode和它的塊之間的平均距離將是柱面數的一半,需要長時間查詢。
一個簡單的效能改進是將i節點放在磁碟的中間,而不是開始,從而將i節點和第一個塊之間的平均尋道減少了兩倍。下圖(b)所示的另一個想法是將磁碟劃分為圓柱體組,每個圓柱體組都有自己的i節點、塊和空閒列表。建立新檔案時,可以選擇任何i-node,但會嘗試在與i-node相同的圓柱體組中查詢塊。如果沒有可用的圓柱體組,則使用附近圓柱體組中的圓柱體組。
(a) 位於磁碟開頭的I節點。(b) 磁碟分為圓柱體組,每個圓柱體組都有自己的塊和i節點。
當然,只有當磁碟具有盤臂運動和旋轉時間時,它們才相關。越來越多的計算機配備了固態磁碟(SSD),這些固態磁碟沒有任何移動部件。對於這些建立在與快閃記憶體卡相同技術上的磁碟,隨機存取與順序存取一樣快,傳統磁碟的許多問題都消失了。不幸的是,出現了新的問題。例如,SSD在讀取、寫入和刪除時具有特殊的屬性。特別是,每個塊只能寫入有限的次數,因此要非常小心地將磨損均勻地分佈在磁碟上。
當作業系統最初安裝時,它需要的程式和檔案從磁碟的開頭開始連續安裝,每個程式和檔案都直接跟在前一個程式和檔案之後。所有可用磁碟空間都位於安裝檔案之後的單個連續單元中。然而,隨著時間的推移,檔案會被建立和刪除,通常磁碟會嚴重碎片化,到處都是檔案和漏洞。因此,當建立新檔案時,用於該檔案的塊可能會分散在整個磁碟上,從而導致效能低下。
通過移動檔案使其連續,並將所有(或至少大部分)可用空間放在磁碟上的一個或多個大的連續區域中,可以恢復效能。Windows有一個程式,即碎片整理,它正是這樣做的。Windows使用者應該定期執行它,但SSD除外。
碎片整理在分割區末尾的相鄰區域中有大量可用空間的檔案系統上效果更好。此空間允許碎片整理程式選擇分割區開始處附近的碎片檔案,並將其所有塊複製到可用空間。這樣做可以在分割區開始處附近釋放一個連續的空間塊,原始檔案或其他檔案可以連續放置在其中。然後可以使用下一塊磁碟空間等重複該過程。
無法移動某些檔案,包括分頁檔案、休眠檔案和紀錄檔記錄,因為執行此操作所需的管理工作帶來的麻煩比實際需要的多。在某些系統中,這些區域是固定大小的連續區域,因此不必進行碎片整理。他們缺乏行動性的一個問題是,他們碰巧在分割區的末尾,使用者希望減小分割區大小。解決此問題的唯一方法是完全刪除它們,調整分割區大小,然後在以後重新建立它們。
由於磁碟塊的選擇方式,Linux檔案系統(尤其是ext2和ext3)通常比Windows系統受到的碎片整理更少,因此很少需要手動碎片整理。此外,SSD實際上根本不會受到碎片的影響。事實上,對SSD進行碎片整理會適得其反。不僅效能沒有提高,SSD也會磨損,因此對它們進行碎片整理只會縮短它們的壽命。
常見的檔案系統案例有MS-DOS檔案系統、UNIX V7檔案系統、CD-ROM檔案系統。
甚至UNIX的早期版本也有一個相當複雜的多使用者檔案系統,因為它是從MULTICS派生而來的。下面我們將討論V7檔案系統,它是使UNIX出名的PDP-11的檔案系統。
檔案系統的形式是從根目錄開始的樹,新增了連結,形成了一個有向非迴圈圖(DAG)。檔名最多可以包含14個字元,並且可以包含除/(因為它是路徑中元件之間的分隔符)和NUL(因為它用於填充小於14個字元的名稱)之外的任何ASCII字元,NUL的數值為0。
UNIX目錄條目包含該目錄中每個檔案的一個條目。每個條目都非常簡單,因為UNIX使用i-node方案。目錄條目僅包含兩個欄位:檔名(14位元組)和該檔案的i-noder數(2位元組),如下圖所示。這些引數將每個檔案系統的檔案數限制為64K。
與i節點類似,UNIX i節點包含一些屬性。這些屬性包含檔案大小、三次(建立、上次存取和上次修改)、所有者、組、保護資訊以及指向i節點的目錄條目數。由於連結,需要後一個欄位。每當建立到i節點的新連結時,i節點中的計數就會增加。刪除連結時,計數將遞減。當它達到0時,將回收i節點,並將磁碟塊放回可用列表中。
為了處理非常大的檔案,可以跟蹤磁碟塊。前10個磁碟地址儲存在i節點本身,因此對於小檔案,所有必要的資訊都在i節點中,當檔案開啟時,這些資訊會從磁碟提取到主記憶體中。對於較大的檔案,i節點中的地址之一是稱為單個間接塊的磁碟塊的地址。此塊包含其他磁碟地址。如果仍然不夠,則i節點中的另一個地址(稱為雙間接塊)包含包含單個間接塊列表的塊的地址。每個間接塊都指向幾百個資料塊。如果還不夠,也可以使用三重間接塊。全圖如下所示。
開啟檔案時,檔案系統必須使用提供的檔名並定位其磁碟塊。讓我們考慮如何查詢路徑名/usr/ast/mbox。我們將以UNIX為例,但演演算法對於所有分層目錄系統基本相同。首先,檔案系統定位根目錄。在UNIX中,其i節點位於磁碟上的固定位置。從這個i-node中,它可以找到根目錄,可以位於磁碟上的任何位置,也可以是塊1。
之後,它讀取根目錄並在根目錄中查詢路徑的第一個元件usr,以查詢檔案/usr的i-node編號。根據i節點的編號定位i節點很簡單,因為每個節點在磁碟上都有一個固定的位置。從這個i-node,系統找到/usr的目錄,並在其中查詢下一個元件ast。當它找到ast的條目時,它擁有目錄/usr/ast的i-node。從這個i節點,它可以找到目錄本身並查詢mbox。然後將此檔案的i節點讀入記憶體並儲存在記憶體中,直到檔案關閉。查詢過程如下圖所示。
相對路徑名的查詢方式與絕對路徑名相同,只從工作目錄開始,而不是從根目錄開始。每個目錄都有.和..的條目,它們在建立目錄時放在那裡。條目.具有當前目錄的i-node編號,條目..具有父目錄的i-node編號。因此,查詢../dick/prog的過程。c只需在工作目錄中查詢..,找到父目錄的i-node編號,然後在該目錄中搜尋dick。處理這些名稱不需要特殊的機制,就目錄系統而言,它們只是普通的ASCII字串,與其他名稱一樣,唯一的技巧是根目錄中的..指向自身。
下圖是Linux虛擬檔案系統上下文:
下圖是Linux虛擬檔案系統概念:
除了提供諸如程序、地址空間和檔案等抽象概念外,作業系統還控制計算機的所有I/O(輸入/輸出)裝置。它必須向裝置發出命令、捕獲中斷和處理錯誤,還應該在裝置和系統其餘部分之間提供一個簡單易用的介面。在可能的情況下,所有裝置的介面應相同(裝置獨立性)。I/O程式碼佔整個作業系統的很大一部分。
不同的人以不同的方式看待I/O硬體。電氣工程師從晶片、電線、電源、電機以及構成硬體的所有其他物理元件的角度來看待它,程式設計師檢視呈現給軟體的介面—硬體接受的命令、執行的功能以及可以報告的錯誤。我們應該關注的是I/O裝置的程式設計,而不是設計、構建或維護它們,所以我們的興趣在於硬體是如何程式設計的,而不是它內部的工作方式。然而,許多I/O裝置的程式設計通常與其內部操作密切相關。在接下來的內容中,我們將提供與程式設計相關的I/O硬體的一般背景知識。
I/O裝置可以大致分為兩類:塊裝置(block device)和字元裝置(character device)。塊裝置是將資訊儲存在固定大小的塊中的裝置,每個塊都有自己的地址,公共塊大小從512位元組到65536位元組不等,所有傳輸都以一個或多個完整(連續)塊為單位。塊裝置的基本特性是可以獨立於所有其他塊讀取或寫入每個塊,硬碟、藍光光碟和USB磁碟是常見的塊裝置。
如果仔細觀察,可以塊定址的裝置和不可以塊定址裝置之間的邊界沒有很好地定義。每個人都同意磁碟是一個塊定址裝置,因為無論臂當前在哪裡,總是可以找到另一個圓柱體,然後等待所需的塊在頭部下方旋轉。現在,考慮一下仍在使用的老式磁帶機,有時用於進行磁碟備份(因為磁帶很便宜)。磁帶包含一系列塊,如果磁帶驅動器收到讀取塊N的命令,它總是可以倒帶並向前走,直到到達塊N為止。此操作類似於磁碟執行查詢,只是需要更長的時間。此外,在磁帶中間重寫一個塊也許可能,也許不可能。即使有可能將磁帶用作隨機存取塊裝置,也在一定程度上拓展了這一點,但它們通常不是這樣使用的。
另一種型別的I/O裝置是字元裝置。字元裝置傳送或接受字元流,而不考慮任何塊結構。它不可定址,並且沒有任何尋道操作。印表機、網路介面、滑鼠(用於指向)、滑鼠(用於心理實驗室實驗)以及大多數其他非磁碟裝置都可以被視為字元裝置。
這個分類方案並不完美,有些裝置不適合。例如,時鐘不可塊定址,也不生成或接受字元流,所做的只是以明確的間隔引起中斷。記憶體對映螢幕也不適合該模型,觸控式螢幕也不例外。儘管如此,塊和字元裝置的模型足夠通用,可以用作使某些處理I/O裝置的作業系統軟體獨立的基礎。例如,檔案系統只處理抽象塊裝置,而將依賴裝置的部分留給較低階別的軟體。
I/O裝置的速度範圍很廣,給軟體帶來了相當大的壓力,使其在資料速率上的效能超過許多數量級。下表顯示了一些常見裝置的資料速率。隨著時間的推移,這些裝置大多會變得更快。
裝置 | 資料速率(單位:每秒) |
---|---|
鍵盤 | 10 B |
滑鼠 | 100 B |
56K資料機 | 7.0 KB |
300dpi掃描器 | 1.0 MB |
數碼攝像機 | 3.5 MB |
4倍藍光光碟 | 18.0 MB |
802.11n無線 | 37.5 MB |
USB 2.0 | 60.0 MB |
FireWire 800 | 100 MB |
千兆乙太網 | 125 MB |
SATA 3磁碟驅動器 | 600 MB |
USB 3.0 | 625 MB |
SCSI Ultra 5匯流排 | 640 MB |
單通道PCIe 3.0匯流排 | 985 MB |
Thunderbolt 2匯流排 | 2.5 GB |
SONET OC-768網路 | 5.0 GB |
以下是常見的幾種IO組織模型:
I/O單元通常由機械部件和電子部件組成。可以將這兩部分分開,以提供更模組化和通用的設計。電子元件稱為裝置控制器或介面卡,在個人計算機上,它通常採用主機板上的晶片或可插入(PCIe)擴充套件插槽的印刷電路卡的形式。機械部件是裝置本身。
控制器卡上通常有一個聯結器,可以插入通向裝置本身的電纜。許多控制器可以處理兩個、四個甚至八個相同的裝置。如果控制器和裝置之間的介面是標準介面,可以是ANSI、IEEE或ISO官方標準,也可以是事實標準,那麼公司可以製造適合該介面的控制器或裝置。例如,許多公司都生產與SATA、SCSI、USB、Thunderbolt或FireWire(IEEE 1394)介面匹配的磁碟驅動器。
控制器和裝置之間的介面通常是非常低階別的介面。例如,一個磁碟可以格式化為2000000個磁區,每個磁軌512位元組。然而,從驅動器中實際出來的是一個序列位流,從前導碼開始,然後是磁區中的4096位,最後是校驗和,即ECC(糾錯碼)。在格式化磁碟時寫入前導碼,前導碼包含柱面和磁區編號、磁區大小、類似資料以及同步資訊。
控制器的工作是將序列位流轉換為位元組塊,並執行任何必要的錯誤糾正,位元組塊通常首先在控制器內的緩衝區中逐位組裝,在校驗和經過驗證並且塊被宣告為無錯誤後,可以將其複製到主記憶體。
LCD顯示器的控制器也可以作為一個同樣低電平的位序列裝置工作。它從記憶體中讀取包含要顯示字元的位元組,並生成訊號來修改相應畫素的背光偏振,以便將其寫入螢幕。如果沒有顯示控制器,作業系統程式設計師就必須對所有畫素的電場進行顯式程式設計。使用控制器,作業系統用一些引數初始化控制器,例如每行的字元或畫素數以及每屏的行數,並讓控制器負責實際驅動電場。
在很短的時間內,LCD螢幕已經完全取代了舊的CRT(陰極射線管)顯示器。CRT顯示器將電子束髮射到熒光屏上,利用磁場,該系統能夠彎曲光束並在螢幕上繪製畫素。與LCD螢幕相比,CRT顯示器體積龐大、耗電量大且易碎。此外,今天(視網膜)LCD螢幕的解析度非常好,人眼無法分辨單個畫素。今天很難想象,過去的筆記型電腦配備了一個小型CRT螢幕,使其深度超過20釐米,重量約為12公斤。
每個控制器都有幾個暫存器,用於與CPU通訊。通過寫入這些暫存器,作業系統可以命令裝置傳送資料、接收資料、開啟或關閉自身,或者執行某些操作。通過讀取這些暫存器,作業系統可以瞭解裝置的狀態,是否準備接受新命令,等等。
除了控制暫存器外,許多裝置還具有作業系統可以讀取和寫入的資料緩衝區。例如,計算機在螢幕上顯示畫素的一種常見方式是有一個視訊RAM,它基本上只是一個資料緩衝區,可供程式或作業系統寫入。
因此,出現了CPU如何與控制暫存器以及裝置資料緩衝區通訊的問題。有兩種選擇。在第一種方法中,每個控制暫存器被分配一個I/O埠號,一個8位元或16位元整數。所有I/O埠的集合構成I/O埠空間,該空間受到保護,因此普通使用者程式無法存取它(只有作業系統才能存取)。使用特殊的I/O指令,例如:
IN REG, PORT,
CPU可以讀取控制暫存器PORT並將結果儲存在CPU暫存器REG中。類似地,使用:
OUT PORT, REG
CPU可以將REG的內容寫入控制暫存器。大多數早期的計算機,包括幾乎所有的大型電腦,如IBM360及其所有後續產品,都是這樣工作的。在此方案中,記憶體和I/O的地址空間不同,如下圖(a)所示。指令IN R0, 4
和MOV R0, 4
在這個設計中完全不同。前者讀取I/O埠4的內容並將其放入R0,而後者讀取記憶體字4的內容,並將其置於R0。這些範例中的4表示不同且不相關的地址空間。
(a) 分開I/O和記憶體空間。(b) 記憶體對映I/O。(c) 混合。
PDP-11引入的第二種方法是將所有控制暫存器對映到記憶體空間,如上圖(b)所示。每個控制暫存器都分配了一個唯一的記憶體地址,但沒有分配記憶體。該系統稱為記憶體對映I/O(Memory-mapped I/O)。在大多數系統中,分配的地址位於或接近地址空間的頂部。上圖(c)顯示了一種混合方案,該方案具有記憶體對映I/O資料緩衝區和用於控制暫存器的單獨I/O埠。x86使用此體系結構,地址為640K到1M− 除了I/O埠0到64K之外,1是為IBM PC相容機中的裝置資料緩衝區保留的− 1.
這些計劃實際上是如何運作的?在所有情況下,當CPU想要從記憶體或I/O埠讀取一個字時,它將所需的地址放在匯流排的地址線上,然後在匯流排的控制線上斷言一個read訊號,第二條訊號線用於判斷是否需要I/O空間或記憶體空間。如果是記憶體空間,記憶體會響應請求。如果是I/O空間,I/O裝置將響應請求。如果只有記憶體空間(上圖(b)),每個記憶體模組和每個I/O裝置都會將地址線與其服務的地址範圍進行比較。如果地址在其範圍內,它將響應請求。由於從未向記憶體和I/O裝置分配地址,因此沒有歧義和衝突。
這兩種控制器定址方案有不同的優缺點。先描述記憶體對映I/O的優點:
首先,如果讀寫裝置控制暫存器需要特殊的I/O指令,那麼存取它們需要使用組合程式碼,因為無法在C或C++中執行IN或OUT指令,呼叫這樣的過程會增加控制I/O的開銷。與此相反,對於記憶體對映I/O,裝置控制暫存器只是記憶體中的變數,可以用與任何其他變數相同的方式在C中定址。因此,使用記憶體對映I/O,I/O裝置驅動程式可以完全用C編寫。如果沒有記憶體對映I/O的話,就需要一些組合程式碼。
其次,對於記憶體對映I/O,不需要特殊的保護機制來阻止使用者程序執行I/O。作業系統所要做的就是避免將包含控制暫存器的那部分地址空間放在任何使用者的虛擬地址空間中。更好的是,如果每個裝置的控制暫存器都位於地址空間的不同頁面上,作業系統可以通過簡單地將所需頁面包含在其頁面表中,讓使用者控制特定裝置,而不是其他裝置。這樣的方案可以將不同的裝置驅動程式放置在不同的地址空間中,不僅可以減小核心大小,還可以防止一個驅動程式干擾其他驅動程式。
第三,使用記憶體對映I/O,可以參照記憶體的每條指令也可以參照控制暫存器。例如,如果有一條指令TEST測試0的記憶體字,它也可以用於測試0的控制暫存器,可能是裝置空閒並可以接受新命令的訊號。組合語言程式碼可能如下所示:
LOOP: TEST PORT 4 // check if por t 4 is 0
BEQ READY // if it is 0, go to ready
BRANCH LOOP // otherwise, continue testing
READY:
如果不存在記憶體對映I/O,則必須首先將控制暫存器讀入CPU,然後進行測試,便需要兩條指令,而不是一條。在上述迴圈的情況下,必須新增第四條指令,略微降低檢測空閒裝置的響應速度。
在計算機設計中,實際上一切都涉及權衡,這裡也是如此。記憶體對映I/O也有其缺點:
首先,現在大多數計算機都有某種形式的記憶體字快取。快取裝置控制暫存器將是災難性的。考慮上面給出的存在快取的組合程式碼迴圈。對PORT 4的第一個參照將導致它被快取。後續參照只會從快取中獲取值,甚至不會詢問裝置。然後當裝置最終準備就緒時,軟體將無法發現。相反,迴圈將永遠持續下去。
為了防止記憶體對映I/O出現這種情況,硬體必須能夠選擇性地禁用快取,例如,以每頁為基礎。此功能增加了硬體和作業系統的額外複雜性,後者必須管理選擇性快取。
其次,如果只有一個地址空間,那麼所有記憶體模組和所有I/O裝置都必須檢查所有記憶體參照,以檢視要響應的記憶體參照。如果計算機只有一條匯流排,如下圖(a)所示,讓每個人都檢視每個地址是很簡單的。
然而,現代個人計算機的趨勢是擁有專用的高速記憶體匯流排,如上圖(b)所示。該匯流排是為優化記憶體效能而客製化的,不會因為I/O裝置速度慢而有所妥協。x86系統可以有多條匯流排(記憶體、PCIe、SCSI和USB)。
在記憶體對映機器上使用單獨的記憶體匯流排的問題是,I/O裝置在記憶體匯流排上經過時無法看到記憶體地址,因此無法對其作出響應。同樣,必須採取特殊措施使記憶體對映I/O在具有多條匯流排的系統上工作。一種可能是首先將所有記憶體參照傳送到記憶體。如果記憶體沒有響應,則CPU嘗試其他匯流排。這種設計可以工作,但需要額外的硬體複雜性。
第二種可能的設計是在記憶體匯流排上放置一個監聽裝置,將所有呈現的地址傳遞給潛在感興趣的I/O裝置。這裡的問題是I/O裝置可能無法以記憶體所能達到的速度處理請求。
第三種可能的設計,是在記憶體控制器中過濾地址。在這種情況下,記憶體控制器晶片包含在引導時預載入的範圍暫存器。例如,640K到1M− 1可以標記為非記憶體範圍。屬於標記為非記憶體範圍之一的地址被轉發到裝置而不是記憶體。此方案的缺點是需要在引導時確定哪些記憶體地址不是真正的記憶體地址。因此,每個方案都有支援和反對的理由,所以妥協和權衡是不可避免的。
無論CPU是否具有記憶體對映I/O,它都需要定址裝置控制器以與它們交換資料。CPU可以一次從I/O控制器請求一個位元組的資料,但這樣做會浪費CPU的時間,因此通常使用一種不同的方案,稱為DMA(Direct Memory Access,直接記憶體存取)。為了簡化解釋,我們假設CPU通過連線CPU、記憶體和I/O裝置的單個系統匯流排存取所有裝置和記憶體,如下圖所示。我們已經知道,現代系統中的實際組織更加複雜,但所有原理都是相同的。如果硬體有DMA控制器,作業系統只能使用DMA,而大多數系統都有。有時,該控制器整合到磁碟控制器和其他控制器中,但這種設計要求每個裝置都有一個單獨的DMA控制器。更常見的情況是,可以使用單個DMA控制器(例如,在主機板上)來調節到多個裝置的傳輸,通常是同時進行的。
無論DMA控制器位於何處,它都可以獨立於CPU存取系統匯流排,如下圖所示。它包含幾個可由CPU寫入和讀取的暫存器,這些暫存器包括記憶體地址暫存器、位元組計數暫存器和一個或多個控制暫存器,控制暫存器指定要使用的I/O埠、傳輸方向(從I/O裝置讀取或寫入I/O裝置)、傳輸單元(每次位元組或每次字)以及一次突發傳輸的位元組數。
為了解釋DMA的工作原理,讓我們先看看不使用DMA時磁碟讀取是如何發生的。首先,磁碟控制器從驅動器逐位序列讀取塊(一個或多個磁區),直到整個塊位於控制器的內部緩衝區中。接下來,它計算校驗和以驗證沒有發生讀取錯誤。然後控制器導致中斷。當作業系統開始執行時,它可以通過執行迴圈,一次從控制器的緩衝區讀取一個位元組或一個字的磁碟塊,每次迭代從控制器裝置暫存器讀取一個字元或字,並將其儲存在主記憶體中。
DMA傳輸的操作。
使用DMA時,過程不同。首先,CPU通過設定其暫存器來程式設計DMA控制器,以便它知道要將什麼傳輸到哪裡(上圖中的步驟1)。它還向磁碟控制器發出命令,告訴它將資料從磁碟讀取到其內部緩衝區,並驗證校驗和。當有效資料在磁碟控制器的緩衝區中時,DMA可以開始。
DMA控制器通過匯流排向磁碟控制器發出讀取請求來啟動傳輸(步驟2)。這個讀取請求看起來像任何其他讀取請求,磁碟控制器不知道(或不關心)它是來自CPU還是來自DMA控制器。通常,要寫入的記憶體地址位於匯流排的地址行上,因此當磁碟控制器從其內部緩衝區獲取下一個字時,它知道將其寫入何處。寫入記憶體是另一個標準匯流排週期(步驟3)。當寫入完成時,磁碟控制器也通過匯流排向DMA控制器傳送確認訊號(步驟4)。然後,DMA控制器增加要使用的記憶體地址,並減少位元組計數。如果位元組計數仍然大於0,則重複步驟2至4,直到計數達到0。此時,DMA控制器中斷CPU,讓它知道傳輸現在已完成。當作業系統啟動時,不必將磁碟塊複製到記憶體中,因為磁碟塊已經在那裡了。
DMA控制器的複雜程度差異很大。如上所述,最簡單的方法一次處理一個傳輸。可以對更復雜的程式進行程式設計,以同時處理多個傳輸。此類控制器內部有多組暫存器,每個通道一組。CPU首先載入每組暫存器及其傳輸的相關引數。每次傳輸必須使用不同的裝置控制器。在上圖中的每個字被傳輸後(步驟2到4),DMA控制器決定下一個要服務的裝置。它可能被設定為使用迴圈演演算法,或者它可能具有優先方案設計,以支援某些裝置而不是其他裝置。對不同裝置控制器的多個請求可能會同時掛起,前提是有明確的方法區分確認。因此,匯流排上的不同確認線通常用於每個DMA通道。
許多匯流排可以在兩種模式下執行:逐字模式和塊模式。一些DMA控制器也可以在這兩種模式中執行。在前一種模式中,DMA控制器請求傳輸一個字並獲得它,如果CPU也需要匯流排,它必須等待。這種機制稱為週期竊取(cycle stealing),因為裝置控制器會潛入CPU,偶爾從CPU竊取匯流排週期,稍微延遲一點。在塊模式下,DMA控制器告訴裝置獲取匯流排,發出一系列傳輸,然後釋放匯流排。這種操作形式稱為突發模式(burst mode)。它比周期竊取更有效,因為獲取匯流排需要時間,並且可以以一條匯流排的價格傳輸多個單詞。突發模式的缺點是,如果傳輸長突發,它會在相當長的一段時間內阻塞CPU和其他裝置。
在我們討論的模型中,有時稱為飛航模式(fly-by mode),DMA控制器告訴裝置控制器將資料直接傳輸到主記憶體儲器。一些DMA控制器使用的另一種模式是讓裝置控制器將Word傳送到DMA控制器,然後DMA控制器發出第二個匯流排請求,將Word寫入應該寫入的位置。該方案要求每傳輸一個字都有額外的匯流排週期,但更靈活,因為它還可以執行裝置到裝置的複製,甚至記憶體到記憶體的複製(首先對記憶體進行讀取,然後在不同地址對記憶體進行寫入)。
大多數DMA控制器使用實體記憶體地址進行傳輸。使用實體地址需要作業系統將預期記憶體緩衝區的虛擬地址轉換為實體地址,並將此實體地址寫入DMA控制器的地址暫存器。少數DMA控制器中使用的另一種方案是將虛擬地址寫入DMA控制器。
然後DMA控制器必須使用MMU完成虛擬到物理的轉換。只有在MMU是記憶體的一部分(可能,但很少),而不是CPU的一部分的情況下,虛擬地址才能放在匯流排上。我們前面提到過,在DMA啟動之前,磁碟首先將資料讀入其內部緩衝區。
為什麼控制器在從磁碟獲取位元組後不直接將其儲存在主記憶體中。換句話說,它為什麼需要內部緩衝區?有兩個原因。
首先,通過進行內部緩衝,磁碟控制器可以在開始傳輸之前驗證校驗和。如果校驗和不正確,則發出錯誤訊號,不進行傳輸。
第二個原因是,一旦磁碟傳輸開始,無論控制器是否準備就緒,位都會以恆定的速率從磁碟到達。如果控制器試圖將資料直接寫入記憶體,則必須通過系統匯流排傳輸每個字。如果匯流排由於其他裝置使用而繁忙(例如,在突發模式下),控制器將不得不等待。如果下一個磁碟字在儲存前一個之前到達,則控制器必須將其儲存在某個地方。如果匯流排很忙,控制器可能會儲存相當多的字,並有很多管理工作要做。當塊被內部緩衝時,直到DMA開始時才需要匯流排,因此控制器的設計要簡單得多,因為DMA傳輸到記憶體不是時間關鍵的。(事實上,一些較舊的控制器確實只需要少量內部緩衝就可以直接進入記憶體,但當匯流排非常繁忙時,傳輸可能會因溢位錯誤而終止。)
並非所有計算機都使用DMA。反對它的理由是,主CPU通常比DMA控制器快得多,並且可以更快地完成工作(當限制因素不是I/O裝置的速度時)。如果沒有其他工作要做,讓(快速)CPU等待(慢速)DMA控制器完成是毫無意義的。此外,去掉DMA控制器並讓CPU完成軟體中的所有工作可以節省資源,在低端(嵌入式)計算機上很重要。
在典型的個人計算機系統中,中斷結構如下圖所示。在硬體級別,中斷的工作方式是:當I/O裝置完成給它的工作時,它會導致中斷(假設作業系統已啟用中斷)。它通過在分配給它的匯流排上斷言訊號來實現這一點,這個訊號由主機板上的中斷控制器晶片檢測到,然後由它決定要做什麼。
中斷是如何發生的。裝置和控制器之間的連線實際上使用匯流排上的中斷線,而不是專用線。
如果沒有其他中斷掛起,中斷控制器會立即處理該中斷。然而,如果另一箇中斷正在進行中,或者另一個裝置在匯流排上的高優先順序中斷請求行上同時發出了請求,則暫時忽略該裝置。在這種情況下,它繼續在匯流排上斷言中斷訊號,直到CPU為其提供服務為止。為了處理中斷,控制器將一個數位放在地址線上,指定哪個裝置需要關注,並斷言一個訊號來中斷CPU。
中斷訊號使CPU停止正在做的事情,並開始做其他事情。地址行上的數位用作名為中斷向量的表的索引,以獲取新的程式計數器。該程式計數器指向相應中斷服務程式的開始。通常,陷阱和中斷從此時起使用相同的機制,通常共用相同的中斷向量。中斷向量的位置可以硬連線到機器中,也可以在記憶體中的任何位置,CPU暫存器(由作業系統載入)指向其原點。
在它開始執行後不久,中斷服務程式通過向中斷控制器的一個I/O埠寫入某個值來確認中斷。該確認通知控制器可以自由發出另一箇中斷。通過讓CPU延遲此確認,直到它準備好處理下一個中斷,可以避免涉及多個(幾乎同時)中斷的競爭條件。另外,一些(較舊的)計算機沒有集中式中斷控制器,因此每個裝置控制器都請求自己的中斷。
硬體總是在開始維修程式之前儲存某些資訊。儲存的資訊和儲存位置因CPU而異。至少,必須儲存程式計數器,以便重新啟動中斷的程序。在另一個極端,所有可見暫存器和大量內部暫存器也可以儲存。
一個問題是在哪裡儲存這些資訊。一種選擇是將其放入作業系統可以根據需要讀取的內部暫存器中。這種方法的一個問題是,在讀取所有潛在相關資訊之前,無法確認中斷控制器,以免第二個中斷覆蓋儲存狀態的內部暫存器。當中斷被禁用時,這種策略會導致長時間的死區,並可能導致中斷丟失和資料丟失。
因此,大多數CPU將資訊儲存在堆疊上。然而,這種方法也有問題。首先:誰的堆疊?如果使用當前堆疊,它很可能是使用者程序堆疊。堆疊指標甚至可能不是合法的,當硬體試圖在指向的地址寫入某些字時,會導致致命錯誤。此外,它可能指向頁面的末尾。在多次記憶體寫入之後,可能會超出頁面邊界並生成頁面錯誤。在硬體中斷處理期間發生頁面錯誤會產生一個更大的問題:在哪裡儲存狀態以處理頁面錯誤?
如果使用核心堆疊,則堆疊指標合法並指向固定頁面的可能性要大得多。然而,切換到核心模式可能需要更改MMU上下文,並且可能會使大部分或全部快取和TLB無效。靜態或動態重新載入所有這些內容將增加處理中斷的時間,從而浪費CPU時間。
另一個問題是,大多數現代CPU都是高度流水線的,而且常常是超標量的(內部並行)。在較舊的系統中,每條指令執行完畢後,微程式或硬體會檢查是否有中斷掛起。如果是這樣,程式計數器和PSW被推到堆疊上,中斷序列開始。在中斷處理程式執行後,發生了相反的過程,舊的PSW和程式計數器從堆疊中彈出,前一個過程繼續。
該模型隱式假設,如果中斷髮生在某條指令之後,則該指令之前(包括該指令)的所有指令都已完全執行,並且在執行之後根本沒有指令。在較舊的機器上,此假設始終有效。在現代裝置上可能不是這樣。
如果在管道已滿時發生中斷,通常情況下會發生什麼情況?許多指令處於不同的執行階段。當中斷髮生時,程式計數器的值可能無法反映已執行指令和未執行指令之間的正確邊界。事實上,許多指令可能已部分執行,不同的指令或多或少都已完成。在這種情況下,程式計數器很可能反映要提取並推入管道的下一條指令的地址,而不是執行單元剛剛處理的指令的地址。
在超標量機器上,情況更糟。指令可以分解為微操作,微操作可能會無序執行,取決於內部資源(如功能單元和暫存器)的可用性。在中斷時,一些早就開始的指令可能還沒有開始,而另一些最近開始的指令幾乎已經完成。在發出中斷訊號時,可能有許多處於不同完整狀態的指令,它們與程式計數器之間的關係較小。
使機器處於定義良好狀態的中斷稱為精確中斷(precise interrupt),它有四個屬性:
1、PC(程式計數器)儲存在已知位置。
2、PC所指的指令之前的所有指令均已完成。
3、除PC指示的指令外,沒有其他指令完成。
4、PC指向的指令的執行狀態是已知的。
請注意,除電腦指示的指令外,沒有禁止啟動的指令。只是它們對暫存器或記憶體所做的任何更改都必須在中斷髮生之前撤消。允許已執行指向的指令。還允許尚未執行。
必須明確哪種情況適用,通常,如果中斷是I/O中斷,則指令尚未啟動。然而,如果中斷真的是一個陷阱或頁面錯誤,那麼PC通常會指向導致錯誤的指令,以便稍後重新啟動,下圖(a)中的情況說明了一個精確的中斷。程式計數器(316)之前的所有指令都已完成,而超出它的指令都沒有啟動(或回滾以撤消其效果)。
不滿足這些要求的中斷稱為不精確中斷(imprecise interrupt),它使作業系統編寫者的生活最不愉快,他們現在必須弄清楚發生了什麼,還有什麼事情要發生。下圖(b)顯示了一個不精確的中斷,其中程式計數器附近的不同指令處於不同的完成階段,舊指令不一定比新指令更完整。具有不精確中斷的機器通常會向堆疊中吐出大量內部狀態,以使作業系統能夠判斷出發生了什麼。重啟機器所需的程式碼通常非常複雜。此外,在每次中斷時都將大量資訊儲存到記憶體中,使中斷速度變慢,恢復情況更糟。這導致了一種具有諷刺意味的情況,即由於中斷速度較慢,速度非常快的超標量CPU有時不適合實時工作。
一些計算機的設計使得某些中斷和陷阱是精確的,而另一些則不是。例如,I/O中斷是精確的,但由於致命程式設計錯誤導致的陷阱是不精確的,這並不是很糟糕,因為在程序被零除後,不需要嘗試重新啟動正在執行的程序。有些機器有一個位,可以設定為強制所有中斷精確。設定這個位的缺點是,它迫使CPU仔細記錄正在做的一切,並維護暫存器的影子副本(shadow copies),以便它可以在任何時刻生成精確的中斷。所有這些開銷都會對效能產生重大影響。
(a) 精確中斷;(b) 不精確中斷。
一些超標量計算機,如x86系列,具有精確的中斷,以允許舊軟體正常工作。為與精確中斷向後相容而付出的代價是CPU內極其複雜的中斷邏輯,以確保當中斷控制器發出訊號表示要引起中斷時,所有指令在某一點之前都可以完成,超過該點的指令都不允許對機器狀態有任何明顯的影響。在這裡,付出的代價不是時間,而是晶片面積和設計的複雜性。如果向後相容不需要精確的中斷,則此晶片區域可用於更大的片上快取,從而使CPU更快。另一方面,不精確的中斷使作業系統更加複雜和緩慢,因此很難判斷哪種方法真正更好。
本節將闡述I/O的目標,從作業系統的角度來看它的不同實現方式。
I/O軟體設計中的一個關鍵概念是裝置獨立性,意味著我們應該能夠編寫可以存取任何I/O裝置的程式,而無需事先指定裝置。例如,將檔案作為輸入讀取的程式應該能夠讀取硬碟、DVD或U盤上的檔案,而無需針對每個不同的裝置進行修改。類似地,應該能夠鍵入以下命令:
sort <input> output
它可以處理來自任何磁碟或鍵盤的輸入,以及傳送到任何磁碟或螢幕的輸出。這些裝置確實不同,需要非常不同的命令序列來讀取或寫入,取決於作業系統來解決這些問題。
與裝置獨立性密切相關的是統一命名的目標。檔案或裝置的名稱應僅為字串或整數,而不應以任何方式依賴於裝置。在UNIX中,所有磁碟都可以以任意方式整合到檔案系統層次結構中,因此使用者無需知道哪個名稱對應於哪個裝置。例如,可以將USB記憶棒安裝在/usr/ast/backup目錄的頂部,以便將檔案複製到/usr/ast/backup/monday將檔案複製至USB記憶棒。這樣,所有檔案和裝置都以相同的方式定址:通過路徑名。
I/O軟體的另一個重要問題是錯誤處理。一般來說,錯誤的處理應該儘可能靠近硬體。如果控制器發現一個讀取錯誤,如果可以的話,它應該嘗試自己更正錯誤。如果不能,那麼裝置驅動程式應該處理它,也許只需再次嘗試讀取塊即可。許多錯誤都是暫時性的,例如讀取頭上的灰塵斑點導致的讀取錯誤,如果重複操作,這些錯誤通常會消失。只有當下層無法處理問題時,才應該告訴上層。在許多情況下,錯誤恢復可以在較低階別透明地完成,而上層甚至不知道錯誤。
另一個重要問題是同步(阻塞)與非同步(中斷驅動)傳輸的比較。大多數物理I/O都是非同步的——CPU開始傳輸,然後去做其他事情,直到中斷到來。如果讀系統呼叫後I/O操作阻塞,則使用者程式更容易編寫,程式會自動掛起,直到資料在緩衝區中可用為止。作業系統應該讓中斷驅動的操作看起來對使用者程式是阻塞的。然而,一些非常高效能的應用程式需要控制I/O的所有細節,因此一些作業系統為它們提供非同步I/O。
I/O軟體的另一個問題是緩衝。通常,從裝置上下來的資料不能直接儲存在最終目的地,例如,當封包從網路中傳入時,作業系統直到將封包儲存在某個位置並對其進行檢查之後才知道將其放在何處。此外,一些裝置具有嚴重的實時限制(例如數位音訊裝置),因此必須提前將資料放入輸出緩衝區,以將緩衝區填充速率與清空速率解耦,以避免緩衝區不足。緩衝涉及大量複製,通常對I/O效能有重大影響。我們在這裡要提到的最後一個概念是共用裝置與專用裝置。
一些I/O裝置(如磁碟)可以由許多使用者同時使用,多個使用者同時在同一磁碟上開啟檔案不會導致任何問題。其他裝置(如印表機)必須專用於單個使用者,直到該使用者完成,然後其他使用者可以擁有印表機。讓兩個或兩個以上的使用者在同一頁面上隨機混合寫入字元肯定不行。引入專用(非共用)裝置也會帶來各種問題,例如死鎖。同樣,作業系統必須能夠以避免問題的方式處理共用裝置和專用裝置。
有三種根本不同的I/O執行方式,最簡單的I/O形式是讓CPU完成所有工作,這種方法稱為程式設計I/O(programmed
I/O)。
通過一個例子來說明程式設計I/O的工作原理是最簡單的。考慮一個使用者程序,它希望通過序列介面在印表機上列印八個字元的字串「ABCDEFGH」,軟體首先在使用者空間的緩衝區中組裝字串,如下圖(a)所示。
然後,使用者程序通過系統呼叫開啟印表機來獲取印表機進行寫入。如果印表機當前正由另一個程序使用,則此呼叫將失敗並返回錯誤程式碼,或將阻塞,直到印表機可用為止,具體取決於作業系統和呼叫的引數。一旦擁有印表機,使用者程序將進行系統呼叫,告訴作業系統在印表機上列印字串。
然後,作業系統(通常)將帶有字串的緩衝區複製到核心空間中的一個陣列,例如p,在那裡它更容易存取(因為核心可能必須更改記憶體對映才能獲得使用者空間)。然後檢查印表機當前是否可用,如果沒有,它會一直等待,直到印表機可用。一旦印表機可用,作業系統就會使用記憶體對映I/O將第一個字元複製到印表機的資料暫存器,此操作將啟用印表機。
該字元可能尚未出現,因為某些印表機在列印任何內容之前會緩衝一行或一頁。然而,在下圖(b)中,我們看到第一個字元已經列印出來,並且系統已經將「b」標記為下一個要列印的字元。
一旦將第一個字元複製到印表機,作業系統就會檢查印表機是否準備好接受另一個字元。通常,印表機有第二個暫存器,用於顯示其狀態,寫入資料暫存器的行為導致狀態變為未就緒。當印表機控制器處理完當前字元后,它通過在狀態暫存器中設定一些位或在其中輸入一些值來指示其可用性。
此時,作業系統將等待印表機再次就緒。當發生這種情況時,它會列印下一個字元,如下圖(c)所示。此迴圈一直持續到列印完整個字串,然後控制權返回到使用者程序。
列印字串的步驟。
下面虛擬碼簡要總結了作業系統執行的操作。首先,將資料複製到核心,然後作業系統進入一個緊密迴圈,一次輸出一個字元。程式設計I/O的基本行為是,在輸出字元后,CPU不斷輪詢裝置,看它是否準備好接受另一個字元。這種行為通常稱為輪詢(polling)或忙等待(busy waiting)。
copy_from_user(buffer, p,count); /* p is the ker nel buffer */
for (i = 0; i < count; i++) /* loop on every character */
{
while (*printer_status_reg != READY) ; /* loop until ready */
*printer_data_register = p[i]; /* output one character */
}
return_to_user( );
程式設計I/O很簡單,但缺點是在完成所有I/O之前佔用CPU的全部時間。如果「列印」字元的時間很短(因為印表機所做的一切都是將新字元複製到內部緩衝區),那麼忙等待就可以了。此外,在嵌入式系統中,CPU沒有其他事情可做,忙等待也可以。然而,在更復雜的系統中,CPU還有其他工作要做,忙等待效率很低,需要更好的I/O方法。
現在讓我們考慮一下在印表機上列印的情況,印表機不緩衝字元,而是在到達時列印每個字元,如果印表機可以列印,例如100個字元/秒,則每個字元需要10毫秒才能列印。這意味著在將每個字元寫入印表機的資料暫存器後,CPU將處於空閒迴圈10毫秒,等待下一個字元的輸出。足以進行上下文切換,並在10毫秒內執行其他可能被浪費的程序。
允許CPU在等待印表機就緒時執行其他操作的方法是使用中斷。當系統呼叫列印字串時,緩衝區被複制到核心空間,如前所示,只要印表機願意接受字元,就會將第一個字元複製到印表機。此時,CPU呼叫排程程式,並執行其他一些程序。要求列印字串的程序被阻止,直到列印完整個字串。系統呼叫的工作如下(a)所示。
當印表機列印完字元並準備接受下一個字元時,它會生成一箇中斷,此中斷停止當前程序並儲存其狀態,然後執行印表機中斷服務程式,該程式碼的粗略版本如下(b)所示。如果沒有更多的字元要列印,中斷處理程式將採取一些操作取消阻止使用者。否則,它輸出下一個字元,確認中斷,並返回到中斷之前執行的程序,該程序從中斷處繼續。
// 使用中斷驅動I/O將字串寫入印表機。
// (a) 執行列印系統呼叫時執行的程式碼。
copy_from_user(buffer, p, count);
enable_interrupts();
while (*printer_status_reg != READY) ;
*printer_data_register = p[0];
scheduler();
// (b) 中斷印表機的維修程式。
if (count == 0)
{
unblock_user( );
}
else
{
*printer_data_register = p[i];
count = count − 1;
i = i + 1;
}
acknowledge_interrupt();
return_from_interrupt();
中斷驅動I/O的一個明顯缺點是每個字元都會發生中斷,中斷需要時間,因此此方案浪費了一定的CPU時間。解決方案是使用DMA,讓DMA控制器一次將字元輸入到印表機,而不會影響CPU。本質上,DMA是程式設計I/O,只有DMA控制器做所有工作,而不是主CPU。此策略需要特殊硬體(DMA控制器),但在I/O期間釋放CPU以執行其他工作。程式碼概要如下所示。
// 使用DMA列印字串。
// (a) 執行列印系統呼叫時執行的程式碼。
copy_from_user(buffer, p, count);
set_up_DMA_controller();
scheduler();
// (b) 中斷服務程式。
acknowledge_interrupt();
unblock_user();
return_from_interrupt();
DMA的最大優勢是將中斷次數從每個字元減少到每個列印緩衝區一個,如果有許多字元並且中斷很慢,會有很大的改進。另一方面,DMA控制器通常比主CPU慢得多。如果DMA控制器無法全速驅動裝置,或者CPU在等待DMA中斷時通常無事可做,那麼中斷驅動I/O甚至程式設計I/O可能更好。然而,在大多數情況下,DMA是值得的。
傳統DMA塊圖。
改進後的DMA設定。
I/O軟體通常分為四層,如下圖所示。每一層都有一個定義明確的功能來執行,並有一個與相鄰層定義明確的介面。功能和介面因系統而異,因此下面的討論(從底部開始檢查所有層)並不針對一臺機器。
I/O軟體系統的層。
下面闡述這些層。
雖然程式設計I/O偶爾有用,但對於大多數I/O來說,中斷是一個令人不快的事實,無法避免。它們應該隱藏在作業系統的內部深處,以便儘可能少的作業系統瞭解它們。隱藏它們的最佳方法是讓驅動程式啟動I/O操作塊,直到I/O完成並行生中斷。驅動程式可以阻塞自身,例如,通過關閉號誌、等待條件變數、接收訊息或類似操作。當中斷髮生時,中斷過程會做任何它必須做的事情來處理中斷,然後它可以解鎖等待它的驅動程式。
在某些情況下,它只會在一個號誌上完成。在其他情況下,它會對監視器中的條件變數發出訊號。在其他情況下,它將向被阻止的驅動程式傳送訊息。在所有情況下,中斷的淨影響是先前被阻塞的驅動程式現在能夠執行。如果驅動程式結構為核心程序,並且有自己的狀態、堆疊和程式計數器,則此模型最有效。
當然,現實並不那麼簡單。處理中斷不僅僅是接受中斷,對一些號誌執行一個up,然後執行IRET指令以從中斷返回到前一個程序。作業系統需要做更多的工作。現在將概述此項工作,作為硬體中斷完成後必須在軟體中執行的一系列步驟。需要注意的是,這些細節高度依賴於系統,因此在特定機器上可能不需要下面列出的某些步驟,也可能需要未列出的步驟。此外,在某些機器上,確實發生的步驟可能順序不同。
1、儲存中斷硬體尚未儲存的所有暫存器(包括PSW)。
2、為中斷服務過程設定上下文,可能需要設定TLB、MMU和頁表。
3、為中斷服務過程設定堆疊。
4、確認中斷控制器。如果沒有集中式中斷控制器,則重新啟用中斷。
5、將暫存器從儲存位置(可能是一些堆疊)複製到程序表。
6、執行中斷服務程式,它將從中斷裝置控制器的暫存器中提取資訊。
7、選擇下一個要執行的程序。如果中斷導致某個被阻塞的高優先順序程序準備就緒,則可以選擇立即執行。
8、為下一個要執行的程序設定MMU上下文。可能還需要一些TLB設定。
9、載入新程序的暫存器,包括其PSW。
10、開始執行新程序。
可以看出,中斷處理遠不是微不足道的。它還需要相當多的CPU指令,特別是在存在虛擬記憶體且必須設定頁表或儲存MMU狀態(例如R和M位)的機器上。在某些機器上,在使用者模式和核心模式之間切換時,可能還必須管理TLB和CPU快取,這需要額外的機器週期。
前面我們討論了裝置控制器的功能。我們看到,每個控制器都有一些裝置暫存器用於發出命令,或者有一些裝置登入檔用於讀取其狀態,或者兩者都有。裝置暫存器的數量和命令的性質因裝置而異。例如,滑鼠驅動程式必須接受來自滑鼠的資訊,告訴它移動了多遠以及當前按下了哪些按鈕。相反,磁碟驅動器可能必須瞭解磁區、磁軌、圓柱體、磁頭、臂運動、電機驅動器、磁頭固定時間以及使磁碟正常工作的所有其他機制。顯然,這些驅動因素將非常不同。
因此,連線到計算機的每個I/O裝置都需要一些特定於裝置的程式碼來控制它。此程式碼稱為裝置驅動程式,通常由裝置製造商編寫,並隨裝置一起交付。由於每個作業系統都需要自己的驅動程式,裝置製造商通常為幾種流行的作業系統提供驅動程式。
每個裝置驅動程式通常處理一種裝置型別,或至多一類密切相關的裝置。例如,SCSI磁碟驅動程式通常可以處理多個不同大小和速度的SCSI磁碟,也可以處理SCSI藍光磁碟。另一方面,滑鼠和操縱桿如此不同,通常需要不同的驅動程式。然而,一個裝置驅動程式控制多個不相關的裝置沒有技術限制,在大多數情況下都不是一個好主意。
然而,有時不同的裝置基於相同的底層技術。最著名的例子可能是USB,它是一種序列匯流排技術,並非無緣無故被稱為「通用」。USB裝置包括磁碟、記憶棒、相機、滑鼠、鍵盤、迷你風扇、無線網路卡、機器人、信用卡閱讀器、充電剃鬚刀、碎紙機、條形碼掃描器、迪斯科球和行動式溫度計。他們都使用USB,但他們做的事情卻大相徑庭。
訣竅在於USB驅動程式通常是堆疊的,就像網路中的TCP/IP堆疊一樣。在底層,通常在硬體中,我們可以找到USB鏈路層(序列I/O),它處理諸如向USB封包傳送訊號和解碼訊號流之類的硬體。它被用於處理封包的高層,以及大多數裝置共用的USB通用功能。除此之外,最後,我們找到了更高層的API,例如大容量儲存介面、攝像頭等。因此,我們仍然有單獨的裝置驅動程式,即使它們共用協定棧的一部分。
實際上,為了存取裝置的硬體,也就是控制器的暫存器,裝置驅動程式通常必須是作業系統核心的一部分,至少在當前的體系結構中是這樣。實際上,可以構造在使用者空間中執行的驅動程式,並通過系統呼叫讀取和寫入裝置暫存器。這種設計將核心與驅動程式隔離開來,並將驅動程式彼此隔離開來,從而消除了以某種方式干擾核心的系統崩潰錯誤驅動程式的主要來源。對於構建高度可靠的系統,這無疑是一條路。裝置驅動程式作為使用者程序執行的系統範例是MINIX 3,然而,由於大多數其他桌面作業系統都希望驅動程式在核心中執行,因此我們將在這裡考慮這個模型。
由於每個作業系統的設計者都知道外部編寫的程式碼(驅動程式)將被安裝在其中,因此需要有一個允許這種安裝的體系結構,意味著要有一個定義良好的模型來描述驅動程式的功能以及它如何與作業系統的其餘部分互動。裝置驅動程式通常位於作業系統其餘部分的下方,如下圖所示。
裝置驅動程式的邏輯定位。實際上,驅動器和裝置控制器之間的所有通訊都通過匯流排進行。
作業系統通常將驅動程式劃分為少數類別之一。最常見的類別是塊裝置(如磁碟),其中包含可以獨立定址的多個資料塊,以及字元裝置(如鍵盤和印表機),它們生成或接受字元流。
大多數作業系統定義了所有塊驅動程式都必須支援的標準介面和所有字元驅動程式都要支援的第二個標準介面。這些介面由許多過程組成,作業系統的其他部分可以呼叫這些過程來讓驅動程式為其工作。典型的步驟是讀取塊(塊裝置)或寫入字串(字元裝置)。
在某些系統中,作業系統是一個單一的二進位制程式,其中包含它需要編譯到其中的所有驅動程式。這種方案多年來一直是UNIX系統的標準,因為它們由計算機中心執行,I/O裝置很少更改。如果新增了新裝置,系統管理員只需使用新的驅動程式重新編譯核心,以構建新的二進位制檔案。
隨著個人計算機及其無數I/O裝置的出現,這種模式不再適用。很少有使用者能夠重新編譯或重新連結核心,即使他們有原始碼或目標模組,但情況並非總是如此。相反,從MS-DOS開始的作業系統轉向了一種模型,在該模型中,驅動程式在執行期間動態載入到系統中。不同的系統以不同的方式處理載入驅動程式。
裝置驅動程式具有多個功能。最明顯的一種方法是接受來自其上方獨立於裝置的軟體的抽象讀寫請求,並確保它們得到執行。但它們還必須執行一些其他功能,例如,如果需要,驅動程式必須初始化裝置。它可能還需要管理電源要求和記錄事件。
許多裝置驅動程式具有類似的一般結構。典型的驅動程式首先檢查輸入引數,看看它們是否有效,如果不是,則返回錯誤,如果它們有效,可能需要將抽象術語翻譯為具體術語。對於磁碟驅動器,可能意味著將線性塊編號轉換為磁碟幾何體的磁頭、磁軌、磁區和柱面編號。
接下來,驅動程式可能會檢查裝置當前是否正在使用。如果是,請求將排隊等待稍後處理,如果裝置處於空閒狀態,將檢查硬體狀態,以檢視現在是否可以處理請求。在開始傳輸之前,可能需要開啟裝置或啟動電機,一旦裝置啟動並準備就緒,就可以開始實際控制。
控制裝置意味著向其發出一系列命令。驅動程式是根據必須執行的操作確定命令序列的位置,在驅動程式知道要發出哪些命令後,它開始將它們寫入控制器的裝置暫存器。在將每個命令寫入控制器後,可能需要檢查控制器是否接受該命令並準備接受下一個命令。此序列將繼續,直到發出所有命令。一些控制器可以得到一個命令連結列表(記憶體中),並告訴它們自己讀取和處理所有命令,而無需作業系統的進一步幫助。
發出命令後,將應用以下兩種情況之一。在許多情況下,裝置驅動程式必須等待控制器為其執行某些工作,因此它會阻塞自身,直到中斷來解除阻塞。然而,在其他情況下,操作會立即完成,因此驅動無需阻塞。作為後一種情況的範例,捲動螢幕只需要將幾個位元組寫入控制器的暫存器。不需要機械運動,因此整個操作可以在納秒內完成。
在前一種情況下,被阻塞的驅動程式將被中斷喚醒。在後一種情況下,它永遠不會睡覺。無論如何,在操作完成後,驅動程式必須檢查錯誤。如果一切正常,驅動程式可能有一些資料要傳遞給裝置獨立軟體(例如,剛讀取的塊)。
最後,它返回一些狀態資訊,以便向呼叫者報告錯誤。如果有任何其他請求排隊,現在可以選擇並啟動其中一個請求。如果沒有排隊,驅動程式將阻塞等待下一個請求。這個簡單的模型只是對現實的粗略近似。許多因素使程式碼更加複雜。首先,I/O裝置可能會在驅動程式執行時完成,從而中斷驅動程式,中斷可能會導致裝置驅動程式執行。事實上,它可能會導致當前驅動程式執行,例如,當網路驅動程式正在處理一個傳入的封包時,另一個封包可能會到達。因此,驅動程式必須是可重入的,意味著執行中的驅動程式必須期望在第一次呼叫完成之前第二次呼叫它。
在熱插拔系統中,可以在計算機執行時新增或刪除裝置。因此,當驅動程式忙於讀取某個裝置時,系統可能會通知它使用者突然從系統中刪除了該裝置。不僅必須在不損壞任何核心資料結構的情況下中止當前的I/O傳輸,而且對於現在已消失的裝置的任何掛起請求也必須從系統及其呼叫者(如果有壞訊息)中優雅地刪除。此外,意外新增的新裝置可能會導致核心篡改資源(例如中斷請求行),將舊裝置從驅動程式中刪除,並將新裝置放在其位置。
驅動程式不允許進行系統呼叫,但它們通常需要與核心的其餘部分進行互動。通常,允許呼叫某些核心過程。例如,通常會呼叫分配和取消分配用作緩衝區的記憶體硬連線頁,需要其他有用的呼叫來管理MMU、定時器、DMA控制器、中斷控制器等。
雖然一些I/O軟體是特定於裝置的,但它的其他部分是獨立於裝置的。驅動程式和獨立於裝置的軟體之間的確切邊界取決於系統(和裝置),因為出於效率或其他原因,一些可以獨立於裝置完成的功能實際上可能在驅動程式中完成。下圖所示的功能通常在裝置獨立軟體中完成。
裝置驅動程式的統一介面 |
緩衝 |
錯誤報告 |
分配和釋放專用裝置 |
提供獨立於裝置的塊大小 |
裝置獨立軟體的基本功能是執行所有裝置通用的I/O功能,併為使用者級軟體提供統一的介面。我們現在將更詳細地討論上述問題。
先闡述裝置驅動程式的統一介面。
作業系統中的一個主要問題是如何使所有I/O裝置和驅動程式看起來或多或少相同。如果磁碟、印表機、鍵盤等都以不同的方式連線,那麼每次新裝置出現時,都必須為新裝置修改作業系統。對於每一個新裝置,必須對作業系統進行駭客攻擊不是一個好主意。
這個問題的一個方面是裝置驅動程式和作業系統其餘部分之間的介面。在下圖(a)中,我們舉例說明了一種情況,即每個裝置驅動程式都有一個不同的作業系統介面,意味著系統可呼叫的驅動功能因驅動而異,也可能意味著驅動程式所需的核心函數也因驅動程式而異。總而言之,意味著連線每個新驅動程式需要大量新的程式設計工作。
(a) 沒有標準的驅動程式介面。(b) 具有標準驅動程式介面。
相反,在上圖(b)中,我們展示了一種不同的設計,其中所有驅動程式都具有相同的介面。現在,只要符合驅動程式介面,插入一個新的驅動程式就容易多了,也意味著驅動程式編寫者知道對他們的期望是什麼。在實踐中,並非所有裝置都是完全相同的,但通常只有少數裝置型別,即使這些裝置型別通常也幾乎相同。
其工作方式如下。對於每類裝置,如磁碟或印表機,作業系統定義了驅動程式必須提供的一組功能。對於磁碟,這些操作自然包括讀取和寫入,但也包括開啟和關閉電源、格式化以及其他磁碟操作。通常,驅動程式持有一個表,其中包含這些函數的指標。載入驅動程式時,作業系統會記錄此函數指標表的地址,因此當需要呼叫其中一個函數時,可以通過此表進行間接呼叫。此函數指標表定義了驅動程式與作業系統其餘部分之間的介面。給定類別的所有裝置(磁碟、印表機等)都必須遵守它。
擁有統一介面的另一個方面是如何命名I/O裝置。獨立於裝置的軟體負責將符號裝置名稱對映到正確的驅動程式上。例如,在UNIX中,裝置名(如/dev/disk0)唯一地指定了特殊檔案的i節點,而此i節點包含用於查詢相應驅動程式的主裝置號。i節點還包含次要裝置編號,該編號作為引數傳遞給驅動程式,以便指定要讀取或寫入的單元。所有裝置都有主裝置號和次裝置號,通過使用主裝置號選擇驅動程式可以存取所有驅動程式。
與命名密切相關的是保護。系統如何阻止使用者存取他們無權存取的裝置?在UNIX和Windows中,裝置在檔案系統中顯示為命名物件,意味著通常的檔案保護規則也適用於I/O裝置。然後,系統管理員可以為每個裝置設定適當的許可權。
接著描述緩衝。
由於各種原因,緩衝也是塊和字元裝置的一個問題。要檢視其中一個,請考慮一個從(ADSL非對稱數位使用者線路)資料機讀取資料的過程,許多人在家中使用該資料機連線到Internet。處理傳入字元的一種可能策略是讓使用者程序執行讀取系統呼叫並阻塞等待一個字元,每個到達的字元都會導致中斷,中斷服務過程將字元交給使用者程序並解除阻塞。將字元放在某處後,程序讀取另一個字元並再次阻塞。該模型如下圖(a)所示。
這種業務處理方式的問題是,必須為每個傳入字元啟動使用者程序。允許一個程序在短時間內多次執行很低效,因此這種設計並不好。
改進如下圖(b)所示。在方法下,使用者程序在使用者空間中提供一個n個字元的緩衝區,並讀取n個字元。中斷服務過程將傳入字元放入該緩衝區,直到它完全滿為止。只有這樣,它才會喚醒使用者程序。這個方案比前一個方案效率高得多,但它有一個缺點:如果在字元到達時調出緩衝區,會發生什麼情況?緩衝區可以鎖定在記憶體中,但如果許多程序開始任意鎖定記憶體中的頁面,可用頁面池將縮小,效能將降低。
(a) 無緩衝輸入。(b) 在使用者空間中緩衝。(c) 在核心中進行緩衝,然後複製到使用者空間。(d) 核心中的雙緩衝。
另一種方法是在核心內建立一個緩衝區,並讓中斷處理程式將字元放在那裡,如上圖(c)所示。當此緩衝區已滿時,如果需要,將帶使用者緩衝區的頁面放入,並在一次操作中將緩衝區複製到那裡。這個方案效率要高得多。
然而,即使是這種改進的方案也存在一個問題:當帶有使用者緩衝區的頁面從磁碟引入時,到達的字元會發生什麼?由於緩衝區已滿,因此沒有放置它們的位置。解決方法是使用第二個核心緩衝區,在第一個緩衝區填滿後,但在清空之前,使用第二個緩衝區,如上圖(d)所示。當第二個緩衝區填滿時,可以將其複製給使用者(假設使用者已要求),當第二個緩衝區被複制到使用者空間時,第一個緩衝區可以用於新字元。這樣,兩個緩衝區輪流進行:一個緩衝區被複制到使用者空間,另一個緩衝區則在積累新的輸入。此方案稱為雙緩衝(double buffering)。
另一種常見的緩衝形式是迴圈緩衝(circular buffer),由一個記憶體區域和兩個指標組成,一個指標指向下一個可以放置新資料的自由詞,另一個指標指向緩衝區中尚未刪除的第一個資料字。在許多情況下,硬體在新增新資料(例如,剛從網路中到達)時向前移動第一個指標,而作業系統在刪除和處理資料時向前移動第二個指標,兩個指標都會環繞——它們到達頂部時會返回底部。
緩衝對輸出也很重要。例如,考慮如何使用上圖(b)中的模型在不緩衝的情況下輸出到資料機,使用者程序執行寫入系統呼叫以輸出n個字元。此時,系統有兩種選擇:其一,它可以阻止使用者直到所有字元都被寫入為止,但可能需要很長時間才能通過慢速電話線完成;其二,它還可以立即釋放使用者,並在使用者進行更多計算時執行I/O,但這會導致更嚴重的問題:使用者程序如何知道輸出已經完成,並且可以重用緩衝區?系統可以生成訊號或軟體中斷,但這種程式設計方式很難實現,並且容易出現競爭情況。一個更好的解決方案是核心將資料複製到核心緩衝區,類似於上圖(c),並立即解除對呼叫者的阻塞。此模式的實際I/O何時完成並不重要,使用者可以在緩衝區解除阻塞後立即重新使用它。
緩衝是一種廣泛使用的技術,但它也有缺點。如果資料緩衝過多,效能就會受到影響。
接下來描述錯誤報告。
錯誤在I/O上下文中比在其他上下文中更常見。當它們發生時,作業系統必須儘可能地處理它們。許多錯誤是特定於裝置的,必須由適當的驅動程式處理,但錯誤處理框架與裝置無關。
一類I/O錯誤是程式設計錯誤。當程序要求一些不可能的東西時,就會出現這些錯誤,例如寫入輸入裝置(鍵盤、掃描器、滑鼠等)或讀取輸出裝置(印表機、繪圖儀等)。其他錯誤包括提供無效的緩衝地址或其他引數,以及指定無效的裝置(例如,當系統只有兩個磁碟時,磁碟3),等等。處理這些錯誤的操作很簡單:只需向呼叫者報告錯誤程式碼。
另一類錯誤是實際的I/O錯誤,例如,試圖寫入已損壞的磁碟塊或試圖讀取已關閉的攝像機。在這些情況下,由驅動程式決定要做什麼。如果驅動程式不知道要做什麼,它可能會將問題傳回裝置無關軟體。
此軟體的功能取決於環境和錯誤的性質。如果是一個簡單的讀取錯誤,並且有一個互動式使用者可用,它可能會顯示一個對話方塊,詢問使用者該怎麼做。選項可能包括重試一定次數、忽略錯誤或終止呼叫程序。如果沒有使用者可用,可能唯一可行的方法是讓系統呼叫失敗並返回錯誤程式碼。
但是,有些錯誤不能用這種方式處理。例如,關鍵資料結構(如根目錄或可用阻止列表)可能已被破壞。在這種情況下,系統可能必須顯示錯誤訊息並終止,能做的事情並不多。
接下來闡述分配和釋放專用裝置。
某些裝置(如印表機)在任何給定時刻只能由單個程序使用。由作業系統檢查裝置使用請求並接受或拒絕它們,具體取決於所請求的裝置是否可用。處理這些請求的一種簡單方法是要求程序直接開啟裝置的特殊檔案。如果裝置不可用,則開啟失敗。關閉這樣的專用裝置,然後釋放它。
另一種方法是使用特殊機制來請求和釋放專用裝置。嘗試獲取不可用的裝置會阻止呼叫方,而不是失敗。阻塞的程序被放入佇列。請求的裝置遲早會可用,佇列中的第一個程序可以獲取它並繼續執行。
不同的磁碟可能具有不同的磁區大小。由獨立於裝置的軟體來隱藏這一事實,併為更高層提供統一的塊大小,例如,將幾個磁區視為單個邏輯塊。這樣,高層只處理抽象裝置,這些抽象裝置都使用相同的邏輯塊大小,與物理磁區大小無關。類似地,一些字元裝置每次只傳送一個位元組的資料(例如滑鼠),而其他字元裝置則以較大的單位傳送資料(例如乙太網介面)。這些差異也可能被隱藏。
儘管大多數I/O軟體都在作業系統內,但其中一小部分由與使用者程式連結在一起的庫組成,甚至包括在核心外執行的整個程式。系統呼叫,包括I/O系統呼叫,通常由庫過程進行。當C程式包含以下呼叫時:
count = write(fd, buffer, nbytes);
庫過程寫入可能與程式連結,幷包含在執行時記憶體中的二進位制程式中。在其他系統中,庫可以在程式執行期間載入。無論如何,所有這些庫過程的集合顯然是I/O系統的一部分。
雖然這些過程只不過將其引數放在系統呼叫的適當位置,但其他I/O過程實際上做了真正的工作。特別是,輸入和輸出的格式化是由庫過程完成的。C語言中的一個範例是printf,它接受格式字串和可能的一些變數作為輸入,構建ASCII字串,然後呼叫write輸出字串。作為printf的一個例子,考慮下面的語句:
printf("The square of %3d is %6d\n", i, i*i);
它將一個由14個字元組成的字串「the square of」後跟值i格式化為3個字元的字串,然後4個字元的串「」是「」,然後\(i^2\)是6個字元,最後是換行符。
並非所有使用者級I/O軟體都由庫程式組成。另一個重要類別是後臺列印系統(spooling system),它是多道程式設計系統中處理專用I/O裝置的一種方法。考慮一個典型的後臺列印裝置:印表機。儘管讓任何使用者程序開啟印表機的字元特殊檔案在技術上很容易,但假設有一個程序開啟了它,然後幾個小時內什麼也沒做,沒有其他程序可以列印任何內容。
下圖總結了I/O系統,顯示了所有層和每個層的主要功能。從底層開始,層是硬體、中斷處理程式、裝置驅動程式、獨立於裝置的軟體,最後是使用者程序。
圖中的箭頭顯示了控制流程。例如,當用戶程式試圖從檔案中讀取塊時,會呼叫作業系統來執行呼叫。獨立於裝置的軟體會在緩衝區快取中查詢它。如果所需的塊不在那裡,它會呼叫裝置驅動程式向硬體發出請求,以便從磁碟獲取它。然後,該程序被阻塞,直到磁碟操作完成,並且資料在呼叫方的緩衝區中安全可用。
當磁碟完成時,硬體生成一箇中斷。執行中斷處理程式是為了發現發生了什麼,也就是說,現在哪個裝置需要關注。然後,它從裝置中提取狀態並喚醒休眠程序,以完成I/O請求並讓使用者程序繼續。
非同步和同步IO的對比圖如下:
當呼叫CreateFile而不將FILE_FLAG_OVERLAPPED指定為dwFlagsAndAttributes引數的一部分時,將僅為同步I/O建立檔案物件,是最簡單的操作,因此我們將首先處理同步I/O。執行I/O的主要功能是ReadFile和WriteFile,它們與任何檔案物件一起工作(不一定指向檔案系統檔案):
BOOL ReadFile(HANDLE hFile, LPVOID lpBuffer, DWORD nNumberOfBytesToRead, LPDWORD lpNumberOfBytesRead, LPOVERLAPPED lpOverlapped);
BOOL WriteFile(HANDLE hFile, LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite, LPDWORD lpNumberOfBytesWritten, LPOVERLAPPED lpOverlapped);
Windows I/O系統本質上是非同步的,一旦裝置驅動程式向其受控硬體(如磁碟驅動器)發出請求,驅動程式就不需要等待操作完成。相反,它將請求標記為「掛起」,並返回給呼叫方。當I/O正在進行時,執行緒可以自由執行其他操作。一段時間後,硬體裝置完成I/O操作。裝置發出硬體中斷,使驅動程式提供的回撥執行並完成掛起的請求。
使用同步I/O簡單易行,許多情況也足夠好。然而,如果要處理大量請求,那麼為每個請求建立一個執行緒來啟動I/O操作並等待其完成是效率低下的,且擴充套件性不好。非同步I/O提供了一種解決方案,其中執行緒啟動一個請求,然後返回服務下一個請求等,因為I/O操作在CPU執行其他程式碼的同時並行執行。這個簡化模型中唯一的問題是如何通知執行緒I/O操作完成。
請求非同步操作必須從原始的CreateFile呼叫開始(始終是同步的),必須將FILE_FLAG_OVERLAPPED標誌指定為dwFlagsAndAttributes引數的一部分,將以非同步模式開啟檔案/裝置。
開啟檔案進行非同步存取的結果之一是不再有檔案指標,意味著每個操作都必須以某種方式提供從檔案開始的偏移量來執行操作(大小不是問題,因為它是讀/寫呼叫的一部分)。這是重疊結構的任務之一,必須作為最後一個引數傳遞給ReadFile和WriteFile:
typedef struct _OVERLAPPED
{
ULONG_PTR Internal;
ULONG_PTR InternalHigh;
union
{
struct
{
DWORD Offset;
DWORD OffsetHigh;
};
PVOID Pointer;
};
HANDLE hEvent;
} OVERLAPPED, *LPOVERLAPPED;
該結構包含三條不同的資訊:
此外,Windows還支援手動排隊APC(Manually Queued APC)。
I/O完成埠(I/O Completion Port)有自己的主要部分,因為它們不僅用於處理非同步I/O。I/O完成埠與檔案物件關聯(可以是多個),封裝了一個請求佇列,以及一個一旦完成就可以為這些請求提供服務的執行緒列表。每當非同步操作完成時,等待完成埠的執行緒之一應該喚醒並處理完成,可能會啟動下一個請求。建立範例:
const int Key = 1;
HANDLE hFile = ::CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
HANDLE hOldCP = ::CreateIoCompletionPort(hFile, hNewCP, Key, 0);
assert(hOldCP == hNewCP);
上述程式碼可以與其他檔案物件重複,所有這些物件都與完成埠相關。下圖描述了完成埠的簡化圖,可以看到繫結的執行緒是什麼,以及所有這一切是如何工作的。
I/O完成埠的目的是允許工作執行緒處理已完成的I/O操作,這裡的「工作執行緒」可以指繫結到完成埠的任何執行緒。
使用裝置(Device,即非檔案系統檔案)與使用檔案系統檔案本質上沒有什麼不同。ReadFile和WriteFile函數適用於任何裝置,包括非同步,但並非所有裝置都支援讀寫操作。特別是對於裝置,還有另一個執行I/O操作的功能——DeviceIoControl:
BOOL DeviceIoControl(HANDLE hDevice, DWORD dwIoControlCode, LPVOID lpInBuffer, DWORD nInBufferSize, LPVOID lpOutBuffer, DWORD nOutBufferSize, LPDWORD lpBytesReturned, LPOVERLAPPED lpOverlapped);
符號連結的其他用途是「軟體驅動程式」,即不管理任何硬體,但需要做使用者模式下無法完成的事情。一個典型的例子是Process Explorer的驅動程式,它必須公開一個符號連結,以便Process Explorer本身(驅動程式的使用者端)可以開啟裝置的控制程式碼,並對裝置進行DeviceIoControl呼叫,根據驅動程式建立併為Process Explorer所知的通訊協定請求各種服務。
管道有兩種變體——匿名和命名,匿名管道是一種簡單的單向通訊機制,僅限於本地機器。使用CreatePipe建立匿名管道對:
BOOL CreatePipe(PHANDLE hReadPipe, PHANDLE hWritePipe, LPSECURITY_ATTRIBUTES lpPipeAttributes, DWORD nSize);
CreatePipe為管道兩端建立控制控制程式碼,使用匿名管道的一個典型範例是將輸入和/或輸出重定向到另一個程序,允許一個程序將資料提供給另一個程序,而另一個過程不知道,也不關心,它只使用標準控制程式碼進行輸入/輸出。
下圖是管道的一個應用案例。建立匿名管道,並與EnumDevices程序共用其寫端,EnumDevices程序寫入的任何內容都可以使用管道的讀取端讀取。要使其工作,管道的寫入和必須附加到EnumDevices程序的標準輸出,因此任何標準輸出呼叫都可以通過管道使用。
由於各種原因,時鐘(也稱為計時器)對於任何多道程式系統的執行都是必不可少的。它們維護一天中的時間,並防止一個程序獨佔CPU等。時鐘軟體可以採用裝置驅動程式的形式,即使時鐘既不是磁碟之類的塊裝置,也不是滑鼠之類的字元裝置。
計算機中常用兩種型別的時鐘,它們都與人們使用的時鐘和手錶大不相同。較簡單的時鐘系在110或220伏電源線上,在50或60赫茲的每個電壓週期上都會造成中斷。這些鍾過去占主導地位,但現在很少了。
另一種時鐘由三個部件組成:晶體振盪器、計數器和保持暫存器,如下圖所示。當一塊石英晶體在張力下正確切割和安裝時,它可以產生非常精確的週期訊號,通常在幾百兆赫到幾兆赫的範圍內,具體取決於所選的晶體。使用電子學,這個基本訊號可以乘以一個小整數,得到高達幾吉赫甚至更多的頻率。通常在任何計算機中都至少有一個這樣的電路,為計算機的各個電路提供同步訊號。這個訊號被輸入計數器,使它倒數到零。當計數器歸零時,會導致CPU中斷。
可程式化時鐘。
可程式化時鐘通常有幾種操作模式。在一次觸發模式(one-shot mode)下,當時鍾啟動時,它將保持暫存器的值複製到計數器中,然後在來自晶體的每個脈衝處遞減計數器。當計數器歸零時,它會導致中斷並停止,直到軟體再次明確啟動。在方波模式(square-wave mode)下,在歸零並導致中斷後,保持暫存器自動複製到計數器中,整個過程無限期地重複。這些週期性中斷稱為時鐘訊號。
可程式化時鐘的優點是其中斷頻率可以由軟體控制。如果使用500 MHz晶體,則計數器每2毫微秒脈衝一次。使用(無符號)32位元暫存器,中斷可以程式設計為以2納秒到8.6秒的間隔發生。可程式化時鐘晶片通常包含兩個或三個獨立的可程式化時鐘,並且還有許多其他選項(例如,向上計數而不是向下計數,中斷禁用等等)。
為了防止在計算機電源關閉時丟失當前時間,大多數計算機都有一個電池供電的備用時鐘,採用了數位手錶中使用的低功耗電路。電池時鐘可以在啟動時讀取。如果備份時鐘不存在,軟體可能會詢問使用者當前的日期和時間。網路系統還可以通過一種標準方式從遠端主機獲取當前時間。在任何情況下,時間都會轉換為自1970年1月1日凌晨12點UTC(世界協調時間,以前稱為格林威治標準時間)以來的時鐘節拍數,就像UNIX一樣,或者從其他基準時刻開始。Windows的時間起點是1980年1月1日。在每個時鐘週期,實時時間都會增加一個計數。通常提供實用程式來手動設定系統時鐘和備份時鐘,並同步兩個時鐘。
第一臺通用電子計算機ENIAC有18000個真空管,耗電14萬瓦。結果,它增加了一筆不平凡的電費。電晶體發明後,用電量急劇下降,計算機行業對電力需求失去了興趣。然而,由於幾個原因,如今電源管理再次成為人們關注的焦點,而作業系統在其中扮演著重要角色。
讓我們從臺式電腦開始。臺式電腦通常有一個200瓦的電源(通常效率為85%,即損失15%的輸入能量用於加熱),如果全世界一次性開啟1億臺這樣的機器,它們總共需要2萬兆瓦的電力,這是20個平均規模的核電站的總產量。如果電力需求能削減一半,我們就可以擺脫10座核電站。從環境角度來看,擺脫10座核電站(或同等數量的化石燃料電站)是一個巨大的勝利,值得追求。
電源是一個大問題的另一個地方是電池供電的計算機,包括筆電、手持裝置和Webpad等。問題的核心是電池不能保持足夠的電量,以維持很長時間,最多幾個小時。此外,儘管電池公司、計算機公司和消費電子公司進行了大量研究,但進展緩慢。對於一個習慣於每18個月業績翻番的行業(摩爾定律)來說,毫無進展似乎違反了物理定律。因此,讓電腦使用更少的能源,從而延長現有電池的使用壽命,是每個人的重要議程。作業系統在此也扮演著重要角色。
在最低層次上,硬體供應商正在努力提高電子產品的能效。使用的技術包括減小電晶體尺寸、採用動態電壓縮放、使用低擺幅和絕熱匯流排以及類似技術。有兩種降低能耗的一般方法:
後面將介紹以上方法,但首先介紹一下與電源使用有關的硬體設計。
電池有兩種型別:一次性電池和充電電池。一次性電池(最常見的是AAA、AA和D電池)可用於執行手持裝置,但沒有足夠的能量為大螢幕明亮的筆記型電腦供電。相比之下,可充電電池可以儲存足夠的能量,為筆記型電腦供電幾個小時。鎳鎘電池過去在這裡占主導地位,但它們讓位給了鎳金屬氫化物電池,後者壽命更長,在最終被丟棄時不會對環境造成嚴重汙染。鋰離子電池甚至更好,可以在不完全耗盡的情況下重新充電,但其容量也受到嚴重限制。
大多數計算機供應商採取的節約電池的一般方法是將CPU、記憶體和I/O裝置設計為具有多個狀態:開啟、休眠、休眠和關閉。要使用裝置,它必須處於開啟狀態。當裝置在短時間內不需要時,可以將其置於休眠狀態,從而降低能耗。當它預計不需要更長的時間間隔時,它可以休眠,從而進一步降低能耗。這裡的折衷是,讓裝置脫離休眠狀態通常比讓它脫離休眠狀態需要更多的時間和精力。最後,當裝置關閉時,它什麼也不做,也不耗電。並非所有裝置都具有所有這些狀態,但當它們都具有這些狀態時,則由作業系統在適當的時候管理狀態轉換。
有些電腦有兩個甚至三個電源按鈕。其中之一可能會使整個計算機處於睡眠狀態,通過鍵入字元或移動滑鼠可以快速將其喚醒。另一種可能會使計算機進入休眠狀態,從休眠狀態喚醒所需的時間要長得多。在這兩種情況下,這些按鈕通常什麼也不做,只是向作業系統傳送一個訊號,作業系統在軟體中執行其餘操作。
電源管理帶來了作業系統必須處理的許多問題。其中許多與資源休眠有關,有選擇地暫時關閉裝置,或者至少在裝置空閒時降低其功耗。必須回答的問題包括:哪些裝置可以控制?它們是開/關,還是有中間狀態?在低功率狀態下可以節省多少電力?重啟裝置是否需要消耗能量?進入低功耗狀態時必須儲存一些上下文嗎?恢復滿功率需要多長時間?當然,這些問題的答案因裝置而異,因此作業系統必須能夠處理各種可能性。
不同的研究人員已經對筆記型電腦進行了研究,以確定電源的去向。Li等人、Lorch和Smith(1998)在筆記型電腦裝置上進行了測量,得出瞭如下圖中所示的結果。Weiser等人(1994年)也進行了測量但沒有公佈數值,只是簡單地說,前三個電量消耗依次是顯示器、硬碟和CPU。雖然這些數位不太一致,可能是因為所測量的不同品牌的計算機確實有不同的能源需求,但顯然顯示器、硬碟和CPU是節能的明顯目標。在智慧手機等裝置上,可能還有其他耗電裝置,如收音機和GPS。
裝置 | Li等人(1994) | Lorch和Smith (1998) |
---|---|---|
顯示器 | 68% | 39% |
CPU | 12% | 18% |
硬碟 | 20% | 12% |
資料機 | - | 6% |
聲音 | - | 2% |
記憶體 | 0.5% | 1% |
其它 | - | 22% |
筆記型電腦各部件的功耗。
作業系統在能源管理中起著關鍵作用,控制著所有的裝置,所以它必須決定關閉什麼以及何時關閉。如果它關閉了一個裝置,並且很快又需要該裝置,那麼在重新啟動時可能會出現惱人的延遲。另一方面,如果等待時間過長而無法關閉裝置,則會無謂地浪費能量。
訣竅是找到演演算法和啟發式,讓作業系統能夠就什麼時候關閉以及什麼時候關閉做出良好的決定。問題是「好」是高度主觀的。一個使用者可能會發現,在不使用計算機30秒後,它需要2秒來響應擊鍵,這是可以接受的。在相同的條件下,另一個使用者可能會連續一閃而過。在沒有音訊輸入的情況下,計算機無法區分這些使用者。
顯示器而是能源預算的大戶,要獲得清晰明亮的影象,螢幕必須背光,需要大量的能量。許多作業系統試圖通過在幾分鐘內沒有活動時關閉顯示器來節省能源。通常,使用者可以決定關機時間間隔,從而在頻繁關閉螢幕和快速耗盡電池電量之間進行權衡(使用者可能真的不需要)。關閉顯示器是一種睡眠狀態,因為當按下任何鍵或移動定點裝置時,幾乎可以立即(從視訊RAM)重新生成顯示器。
Flinn和Satyanarayanan(2004年)提出了一個可能的改進方案。他們建議讓顯示器由一些區域組成,這些區域可以獨立通電或斷電。在下圖中,用虛線分隔了16個區域。當遊標位於視窗2中時,如(a)所示,只有右下角的四個區域必須亮起。其他12個可以是暗的,節省了3/4的螢幕電量。
當用戶將遊標移動到視窗1時,視窗2的區域可以變暗,視窗1後面的區域可以開啟。但是,因為視窗1跨越了9個區域,所以需要更多電源。如果視窗管理器可以感知正在發生的事情,它可以自動將視窗1移動到四個區域,並以一種捕捉到區域的動作,如(b)所示。為了實現從9/16全功率到4/16全功率的降低,視窗管理器必須瞭解電源管理或能夠接受來自其他系統的指令。更復雜的是能夠部分照亮未完全填滿的窗戶(例如,包含短行文字的窗戶右側可以保持黑暗)。
使用區域背光顯示。(a) 選擇視窗2時,它不會移動。(b) 選擇視窗1後,它會移動以減少照亮的區域數。
對於硬碟,即使沒有通道,保持高速旋轉也需要大量的能量。許多計算機,尤其是筆記型電腦,在空閒一定時間後會降低磁碟轉速。當下次需要時,它會再次旋轉。不幸的是,停止的磁碟正在蟄伏(hibernating),而不是休眠(sleeping),因為它需要幾秒鐘才能再次啟動,會導致使用者明顯的延遲。
此外,重新啟動磁碟會消耗大量能量。因此,每個磁碟都有一個特徵時間\(T_d\),即盈虧平衡點,通常在5到15秒之間。假設下一次磁碟存取預計在未來的某個時間t到來。如果t<\(T_d\),保持磁碟旋轉所需的能量會更少,而不是先將其向下旋轉,然後再快速將其向上旋轉。如果t>\(T_d\),節省的能量使磁碟值得先向下旋轉,然後再向上旋轉。如果能夠做出良好的預測(例如,基於過去的存取模式),作業系統可以做出良好的關機預測並節省能源。實際上,大多數系統都是保守的,只有在幾分鐘不活動後才會停止磁碟。
另一種節省磁碟能量的方法是在RAM中擁有大量磁碟快取。如果所需的塊在快取中,則不必重新啟動空閒磁碟來滿足讀取。類似地,如果對磁碟的寫入可以在快取中緩衝,則停止的磁碟不必重新啟動即可處理寫入。磁碟可以保持關閉狀態,直到快取填滿或發生讀取未命中。
避免不必要的磁碟啟動的另一種方法是,作業系統通過向正在執行的程式傳送訊息或訊號,使其瞭解磁碟狀態。有些程式具有可跳過或延遲的任意寫入。例如,可以設定文書處理器,每隔幾分鐘將正在編輯的檔案寫入磁碟。如果此時它會正常寫入檔案,則文書處理器知道磁碟已關閉,它可以延遲此寫入,直到開啟為止。
管理CPU也可以節省能源。筆記型電腦CPU可以在軟體中休眠,從而將功耗降至幾乎為零。在這種狀態下,它唯一能做的就是在發生中斷時喚醒。因此,每當CPU空閒時,無論是等待I/O還是因為沒有工作可做,它都會進入休眠狀態。
在許多計算機上,CPU電壓、時鐘週期和電源使用之間存在關係。在軟體中,CPU電壓通常可以降低,這樣既節省了能源,又縮短了時鐘週期(近似線性)。由於消耗的功率與電壓的平方成正比,將電壓減半會使CPU的速度減半,但只有1/4的功率。
此屬性可用於具有明確期限的程式,例如必須每隔40毫秒解壓縮並顯示一幀的多媒體檢視器,但如果速度更快,則會變為空閒。假設CPU在全速執行40毫秒時使用x焦耳,而x/4焦耳以半速執行。如果多媒體檢視器可以在20毫秒內解壓縮並顯示幀,則作業系統可以滿功率執行20毫秒,然後關閉20毫秒,總能耗為x/2焦耳。或者,它可以半功率執行,只需在截止日期前完成,但只需使用x/4焦耳。下圖顯示了在一段時間間隔內以全速和全功率執行,以及以半速和四分之一功率執行兩倍時間的對比。在這兩種情況下,都做了相同的功,但在(b)中,只消耗了一半的能量。
(a) 以全速執行。(b) 電壓降低兩倍,時鐘速度減少兩倍,功耗減少四倍。
類似地,如果使用者以每秒1個字元的速度鍵入,但處理字元所需的工作需要100毫秒,那麼作業系統最好檢測到長空閒時間,並將CPU速度降低10倍。簡而言之,慢速執行比快速執行更節能。
有趣的是,CPU核心的縮減並不總是意味著效能的降低。Hruby等人(2013)表明,有時網路堆疊的效能會隨著核心速度的降低而提高。其解釋是,核心可能太快而不利於自身。例如,假設一個CPU有幾個快速核心,其中一個核心代表執行在另一個核心上的生產者負責傳輸網路封包。生產商和網路堆疊通過共用記憶體直接通訊,它們都在專用核心上執行。生產者執行了相當多的計算,無法完全跟上網路堆疊的核心。在典型的執行中,網路將傳輸它必須傳輸的所有內容,並在一定時間內輪詢共用記憶體,以檢視是否真的沒有更多資料要傳輸。最後,它會放棄並進入休眠狀態,因為連續輪詢對功耗非常不利。不久之後,生產者提供了更多資料,但現在網路堆疊處於快速休眠狀態。喚醒堆疊需要時間並降低吞吐量。一個可能的解決方案是永遠不要休眠,但這也不具有吸引力,因為這樣做會增加功耗,而這恰恰與我們試圖實現的相反。一個更具吸引力的解決方案是在較慢的核心上執行網路堆疊,這樣它就可以一直處於繁忙狀態(因此從不休眠),同時還可以降低功耗。如果小心放慢網路核心的速度,其效能將優於所有核心都快得驚人的設定。
記憶體有兩種可能的節能選項。首先,可以重新整理快取,然後關閉快取。它始終可以從主記憶體重新載入,而不會丟失資訊。重新載入可以動態快速完成,因此關閉快取將進入休眠狀態。
一個更激烈的選擇是將主記憶體的內容寫入磁碟,然後關閉主記憶體本身。這種方法是休眠的,因為幾乎所有的電源都可以被切斷而佔用大量的重新載入時間,特別是在磁碟也關閉的情況下。當記憶體被切斷時,CPU要麼也必須被切斷,要麼必須從ROM中執行。如果CPU被切斷,喚醒它的中斷必須使它跳轉到ROM中的程式碼,以便在使用之前可以重新載入記憶體。儘管有這麼多開銷,如果幾秒鐘內重新啟動比從磁碟重新啟動作業系統(通常需要一分鐘或更長時間)更可取,那麼長時間(如數小時)關閉記憶體可能是值得的。
越來越多的行動式計算機可以無線連線到外部世界(如網際網路)。所需的無線電發射機和接收機通常是一流的電源插座。特別是,如果無線電接收器始終開啟以收聽傳入的電子郵件,電池可能會很快耗盡。另一方面,如果收音機在空閒1分鐘後關閉,則可能會錯過傳入的資訊,這顯然是不可取的。
Kravets和Krishnan(1998)提出了一個有效的解決方案,利用移動計算機與具有大記憶體和磁碟且無電源限制的固定基站通訊這一事實。他們建議讓移動計算機在即將關閉無線電時向基站傳送訊息,從那時起,基站在其磁碟上緩衝傳入的訊息。移動計算機可以明確指示它計劃休眠多長時間,或者在它再次開啟無線電時簡單地通知基站。此時,任何累積的訊息都可以傳送給它。
收音機關閉時生成的傳出訊息在移動計算機上進行緩衝。如果緩衝區可能已滿,則會開啟收音機,並將佇列傳輸到基站。收音機應該什麼時候關掉?一種可能性是讓使用者或應用程式決定。另一種方法是在空閒幾秒鐘後將其關閉。什麼時候應該再次開啟?同樣,使用者或程式可以決定,也可以定期開啟它來檢查入站流量並傳輸任何排隊訊息。當然,它也應該在輸出緩衝區接近滿時開啟。其他各種啟發方法也是可能的。
在802.11(「WiFi」)網路中可以找到支援這種電源管理方案的無線技術的範例。在802.11中,移動計算機可以通知接入點它將要休眠,但它會在基站傳送下一個信標幀之前喚醒。接入點定期傳送這些幀,此時,接入點可以告訴移動計算機它有待處理的資料。如果沒有此類資料,移動計算機可以再次休眠,直到下一個信標幀。
一個稍有不同但仍與能源相關的問題是熱量管理(Thermal Management)。現代CPU由於其高速而變得異常熱,桌上型電腦通常有一個內部電風扇,用於將熱空氣吹出機箱。由於降低功耗通常不是桌上型電腦的驅動問題,因此風扇通常一直處於開啟狀態。
筆記型電腦的情況有所不同。作業系統必須連續監測溫度,當溫度接近最大允許溫度時,作業系統可以選擇。它可以開啟風扇,這會產生噪音並消耗電力。或者,它可以通過減少螢幕背光、降低CPU速度、更積極地降低磁碟轉速等方式來降低功耗。
使用者的一些輸入可能有價值,可以作為指導。例如,使用者可以事先指定風扇的噪音令人反感,因此該作業系統反而會降低功耗。
在過去,電池只是提供電流,直到完全耗盡,然後停止,再也沒有了。移動裝置現在使用智慧電池,可以與作業系統通訊。根據作業系統的請求,它們可以報告最大電壓、電流電壓、最大充電、電流充電、最大漏電流率、電流漏電流率等資訊。大多數移動裝置都有可以執行的程式來查詢和顯示所有這些引數,還可以指示智慧電池在作業系統的控制下更改各種操作引數。
有些筆記型電腦有多個電池。當作業系統檢測到一個電池即將用完時,它必須安排一個優雅的切換到下一個電池,而不會在轉換過程中造成任何故障。當最後一塊電池即將耗盡時,作業系統將向用戶發出警告,然後有序關閉,例如確保檔案系統未損壞。
一些作業系統有一種稱為ACPI(Advanced Configuration and Power Interface,高階設定和電源介面)的精細電源管理機制。作業系統可以傳送任何一致的驅動程式命令,要求它報告其裝置的功能及其當前狀態。當與隨插即用結合時,此功能尤其重要,因為在啟動後,作業系統甚至不知道存在哪些裝置,更不用說它們的能耗或電源管理屬性了。
它還可以向驅動傳送命令,指示他們降低功率水平(當然,取決於它之前學到的能力)。另外還有一些傳輸訊號,特別是,當鍵盤或滑鼠等裝置在閒置一段時間後檢測到活動時,這是系統返回(接近)正常操作的訊號。
到目前為止,我們已經研究了作業系統如何減少各種裝置的能耗。但還有另一種方法:告訴程式使用更少的能源,即使更差的使用者體驗(當電池耗盡和燈熄滅時,糟糕的體驗比沒有體驗要好)。通常,當電池電量低於某個閾值時,會傳遞此資訊。然後由程式決定是降低效能以延長電池壽命,還是保持效能和能源耗盡風險。
這裡出現的一個問題是,程式如何降低效能以節省能源。Flinn和Satyanarayanan(2004)研究了這個問題,他們提供了四個效能降低如何節省能源的例子。在此研究種,資訊以各種形式呈現給使用者。當不存在退化時,將提供儘可能好的資訊。當出現降級時,呈現給使用者的資訊的保真度(準確性)比本來的要差。
為了測量能源使用量,Flinn和Satyanarayanan設計了一種稱為PowerScope的軟體工具,它所做的是提供程式的電源使用組態檔。要使用它,計算機必須通過軟體控制的數位萬用表連線到外部電源。使用萬用表,軟體能夠讀取電源輸入的毫安數,從而確定計算機消耗的瞬時功率。PowerScope所做的是定期對程式計數器和電源使用情況進行取樣,並將這些資料寫入檔案。程式終止後,對檔案進行分析,以給出每個過程的能量使用情況,這些測量結果構成了他們觀察的基礎。還採用了硬體節能措施,並形成了衡量效能下降的基線。
測量的第一個節目是視訊播放器。在未分級模式下,它以全解析度和彩色播放30幀/秒。降級的一種形式是放棄顏色資訊,以黑白顯示視訊。另一種形式的降級是降低幀速率,會導致閃爍,並給電影帶來顯著降低的質量。還有一種退化形式是通過降低空間解析度或縮小顯示影象來減少兩個方向上的畫素數。這種措施節省了大約30%的能源。
第二個程式是語音識別器。它對麥克風進行取樣,以構造波形,這個波形可以在筆記型電腦上分析,也可以通過無線鏈路傳送到固定電腦上進行分析。此舉可以節省CPU能量,但會消耗無線電能量。降級是通過使用更小的字和更簡單的聲學模型來完成的,節省率約為35%。
下一個例子是通過無線電連結獲取地圖的地圖檢視器。降級包括將地圖裁剪成較小的尺寸,或告訴遠端伺服器忽略較小的道路,從而減少傳輸的位元數。這裡再次實現了約35%的收益。
第四個實驗是將JPEG影象傳輸到Web瀏覽器。JPEG標準允許使用各種演演算法,將影象質量與檔案大小進行權衡,平均增益只有9%。總之,實驗表明,通過接受一些質量下降,使用者可以在給定的電池上執行更長的時間。
電子(或光學)元件之間的所有通訊最終歸結為在它們之間傳送定義良好的位元串,不同之處在於所涉及的時間尺度、距離尺度和邏輯組織。一個極端是共用記憶體多處理器,其中大約有兩到1000個CPU通過共用記憶體進行通訊。在這個模型中,每個CPU都有對整個實體記憶體的平等存取權,並且可以使用LOAD和STORE指令讀取和寫入單個字,存取一個儲存字通常需要1-10納秒。正如我們將看到的,現在通常在一個CPU晶片上放置多個處理核心,這些核心共用對主記憶體儲器的存取(有時甚至共用快取)。換而言之,共用記憶體多計算機的模型可以使用物理上分離的CPU、單個CPU上的多個核心或以上兩者的組合來實現。雖然下圖(a)所示的這個模型聽起來很簡單,但實際並非如此,通常需要在幕後傳遞大量資訊。
(a) 共用記憶體的多處理器。(b) 通過訊息傳遞的多計算機。(c) 廣域分散式系統。
接下來是上圖(b)的系統,其中CPU記憶體對通過高速互連連線,這種系統稱為訊息傳遞多計算機。每個記憶體都是單個CPU的本地記憶體,只能由該CPU存取。CPU通過互連傳送多字訊息進行通訊。有了良好的互連,短訊息可以在10–50微秒內傳送,但仍比圖8-1(a)中的記憶體存取時間長得多。此設計中沒有共用全域性記憶體。多計算機(即訊息傳遞系統)比(共用記憶體)多處理器更容易構建,但它們更難程式設計。因此,每種型別都有自己的粉絲。
第三種模型如上圖(c)所示,通過廣域網(如網際網路)連線完整的計算機系統,形成分散式系統。每一個都有自己的記憶體,系統通過訊息傳遞進行通訊。(b)和(c)之間唯一真正的區別是,在後者中,使用的是完整的計算機,訊息時間通常為10–100毫秒。這種長延遲迫使這些鬆散耦合系統以不同於(b)中緊密耦合系統的方式使用。這三種型別的系統在延遲方面相差大約三個數量級,這就是一天和三年之間的差異。
共用記憶體多處理器(或此後僅為多處理器)是一種計算機系統,其中兩個或多個CPU共用對公共RAM的完全存取。在任何CPU上執行的程式都會看到一個正常的(通常是分頁的)虛擬地址空間。這個系統唯一不尋常的特性是,CPU可以將一些值寫入記憶體字,然後讀回該字並獲得不同的值(因為另一個CPU已經更改了它)。當組織正確時,此屬性構成處理器間通訊的基礎:一個CPU將一些資料寫入記憶體,另一個CPU讀取資料。
在大多數情況下,多處理器作業系統是正常的作業系統,它們處理系統呼叫、進行記憶體管理、提供檔案系統和管理I/O裝置。然而,在某些領域,它們具有獨特的特點,包括程序同步、資源管理和排程。
儘管所有多處理器都具有每個CPU都可以定址所有記憶體的特性,但有些多處理器還具有每個記憶體字都可以像其他記憶體字一樣快地讀取的附加特性。這些機器被稱為UMA(Uniform Memory Access,統一記憶體存取)多處理器。相反,NUMA(非統一記憶體存取)多處理器沒有此屬性。
最簡單的多處理器基於單個匯流排,如下圖(a)所示,兩個或更多CPU和一個或更多記憶體模組都使用相同的匯流排進行通訊。當CPU想要讀取一個記憶體字時,它首先檢查匯流排是否繁忙。如果匯流排空閒,CPU將它想要的字的地址放在匯流排上,斷言一些控制訊號,並等待直到記憶體將想要的字放在匯流排。
如果CPU想讀或寫記憶體時匯流排正忙,那麼CPU只需等待直到匯流排空閒,這正是問題所在。使用兩個或三個CPU,匯流排的爭用將是可管理的,如果是32或64,那將是難以忍受的。系統將完全受到匯流排頻寬的限制,大多數CPU將在大部分時間處於空閒狀態。
三種基於匯流排的多處理器。(a) 沒有快取。(b) 使用快取。(c) 有快取和私有記憶體。
解決方案是向每個CPU新增一個快取,如上圖(b)所示。快取記憶體可以位於CPU晶片內部、CPU晶片旁邊、處理器板上,或者這三者的組合。由於現在可以從區域性快取中滿足許多讀取,因此匯流排流量將大大減少,系統可以支援更多的CPU。通常,快取不是基於單個字,而是基於32或64位元組塊。當一個字被參照時,它的整個塊(稱為快取行)將被提取到接觸它的CPU的快取中。
每個快取塊被標記為唯讀(在這種情況下,它可以同時存在於多個快取中)或讀寫(在這種情形下,它可能不存在於任何其他快取中)。如果CPU試圖寫入一個或多個遠端快取記憶體中的字,匯流排硬體將檢測到該寫入,並在匯流排上發出訊號,通知所有其他快取記憶體該寫入。如果其他快取具有「乾淨」副本,即記憶體中的內容的精確副本,則它們可以丟棄副本,並讓寫入程式在修改快取塊之前從記憶體中獲取快取塊。如果某個其他快取具有「無效」(即已修改)副本,則必須先將其寫回記憶體,然後才能繼續寫入,或通過匯流排將其直接傳輸到寫入器。這組規則稱為快取一致性協定(cache-coherence protocol),是眾多規則之一。
另一種可能性是上圖(c)的設計,其中每個CPU不僅有一個快取記憶體,而且還有一個通過專用(專用)匯流排存取的區域性專用記憶體。為了最佳地使用此設定,編譯器應該將所有程式文字、字串、常數和其他唯讀資料、堆疊和區域性變數放在私有記憶體中。然後,共用記憶體僅用於可寫共用變數。在大多數情況下,這種謹慎的佈局將大大減少匯流排流量,但它確實需要編譯器的積極配合。
即使有最好的快取,使用單一匯流排也會將UMA多處理器的大小限制在大約16或32個CPU。除此之外,還需要一種不同型別的互連網路。將n個CPU連線到k個記憶體的最簡單電路是交叉開關(crossbar switch),如下圖所示。交叉開關在電話交換交換機中已經使用了幾十年,以任意方式將一組輸入線連線到一組輸出線。
在水平(輸入)線和垂直(輸出)線的每個交點處都是交叉點(crosspoint),交叉點是一個小的電子開關,可以根據水平線和垂直線是否連線而電動開啟或關閉。在下圖(a)中,我們看到三個交叉點同時閉合,允許(CPU、記憶體)對(010000)、(101101)和(110010)同時連線,還有許多其他的組合。事實上,組合的數量等於8路可以安全放置在棋盤上的不同方式的數量。
(a) 8×8交叉開關。(b) 開啟的交叉點。(c) 閉合的交叉點。
交叉開關的一個最好的特性是它是一個非阻塞網路,意味著沒有CPU因為某些交叉點或線路已被佔用而被拒絕連線(假設記憶體模組本身可用),並非所有互連都具有這種優良特性。此外,不需要提前規劃,即使已經設定了七個任意連線,也始終可以將剩餘的CPU連線到剩餘的記憶體。
當然,如果兩個CPU想要同時存取同一個模組,那麼爭用記憶體仍然存在。然而,通過將記憶體劃分為n個單元,與上上圖的模型相比,爭用減少了n倍。
交叉開關最糟糕的特性之一是交叉點的數量隨著\(n^2\)而增加,例如1000個CPU和1000個記憶體的系統需要100萬個交叉點,如此大的交叉開關不可行。然而,對於中型系統,交叉設計是可行的。
一種完全不同的多處理器設計基於下圖(a)所示的普通2×2,此開關有兩個輸入和兩個輸出,到達任一輸入行的訊息可以切換到任一輸出行。出於我們的目的,訊息最多包含四個部分,如下圖(b)所示。Module(模組)欄位指示要使用的記憶體,地址指定一個Module內的地址,操作碼提供操作,如READ或WRITE。最後,可選的Value欄位可能包含一個運算元,例如要寫入WRITE的32位元字。開關檢查模組欄位,並使用該欄位確定訊息應在X或Y上傳送。
(a) 具有兩條輸入線a和B以及兩條輸出線X和Y的2×2開關。(B)訊息格式。
單匯流排UMA多處理器通常限制在不超過幾十個CPU,交叉或交換多處理器需要大量(昂貴)硬體,並且沒有那麼大。要獲得超過100個CPU,必須付出一些代價,通常,所有記憶體模組都有相同的存取時間,這種讓步導致了NUMA多處理器的思想,如上所述。與UMA類似,它們在所有CPU上提供單一地址空間,但與UMA機器不同,存取本地記憶體模組比存取遠端記憶體模組更快。因此,所有的UMA程式都將在NUMA機器上執行而無需更改,但效能將比在UMA機器上更差。
NUMA機器有三個關鍵特徵,區別於其他多處理器:
1、所有CPU都有一個可見的地址空間。
2、通過LOAD和STORE指令存取遠端記憶體。
3、存取遠端記憶體比存取本地記憶體慢。
當對遠端記憶體的存取時間沒有隱藏(因為沒有快取)時,系統稱為NC-NUMA(非快取一致NUMA)。當快取一致時,系統稱為CC-NUMA(快取一致NUMA)。
構建大型CC-NUMA多處理器的一種流行方法是基於目錄的多處理器。其想法是維護一個資料庫,告訴每個快取行的位置及其狀態。當參照快取行時,會查詢資料庫,以找出它的位置以及它是乾淨的還是髒的。由於該資料庫在每一條涉及記憶體的指令上都會被查詢,因此它必須儲存在速度極快的專用硬體中,該硬體可以在匯流排週期的一小部分內做出響應。
為了使基於目錄的多處理器的思想更加具體,讓我們考慮一個簡單的(假設的)範例,一個256節點系統,每個節點由一個CPU和16MB RAM組成,通過本地匯流排連線到CPU。總記憶體為\(2^{32}\)位元組,分為\(2^{26}\)個快取行,每個快取行64位元組。記憶體在節點之間靜態分配,節點0中為0–16M,節點1中為16M–32M,等等。節點通過互連網路連線,如下圖(a)所示。每個節點還儲存包含其\(2^{24}\)位元組記憶體的218個64位元組快取記憶體行的目錄條目。目前,我們假設一行最多可以儲存在一個快取中。
(a) 基於256節點目錄的多處理器。(b) 將32位元記憶體地址劃分為欄位。(c) 節點36處的目錄。
隨著晶片製造技術的進步,電晶體越來越小,越來越多的電晶體可以放在晶片上,這種規律被稱為摩爾定律,是英特爾聯合創始人戈登·摩爾(Gordon Moore)第一次注意到的。1974年,Intel 8080包含2000多個電晶體,而Xeon Nehalem EX CPU擁有20多億個電晶體。
一個顯而易見的問題是:這些電晶體的作用是什麼?一個選項是向晶片新增兆位元組的快取,具有4個32MB片上快取的晶片很常見,但在某些時候,增加快取大小可能會使命中率僅從99%提高到99.5%,並不會大幅提高應用程式的效能。
另一種選擇是將兩個或多個完整的CPU(通常稱為核心)放在同一晶片上(從技術上講,放在相同的晶片上)。雙核、四核和八核晶片已經很常見,甚至可以購買數百核的晶片。毫無疑問,更多的核心正在蓬勃發展。快取仍然至關重要,遍佈整個晶片,例如,Intel Xeon 2651有12個物理超執行緒核心,提供24個虛擬核心。12個物理核中的每一個具有32KB的L1指令快取和32KB的L2資料快取,每個都有256KB的二級快取,12個核心共用30MB的L3快取。
雖然CPU可能共用或不共用快取,但它們始終共用主記憶體,並且在每個記憶體字都有唯一值的意義上,此記憶體是一致的。特殊的硬體電路確保,如果一個字存在於兩個或多個快取記憶體中,並且其中一個CPU修改了該字,則該字將自動從所有快取記憶體中原子化刪除,以保持一致性。這個過程稱為窺探(snooping)。
這種設計的結果是多核晶片只是非常小的多處理器。事實上,多核晶片有時被稱為CMP(Chip MultiProcessors,晶片多處理器)。從軟體的角度來看,CMP與基於匯流排的多處理器或使用交換網路的多處理器並沒有太大區別。然而,依然存在一些差異。首先,在基於匯流排的多處理器上,每個CPU都有自己的快取,常為AMD使用。共用快取設計被英特爾在其許多處理器中使用,在其他多處理器中不存在。共用的二級或三級快取可能會影響效能。如果一個核心需要大量的快取記憶體,而另一個則不需要,那麼這種設計可以讓快取佔用者獲取它需要的任何東西。另一方面,共用快取也使得貪婪的核心有可能傷害其他核心。
CMP不同於其較大的同類的一個領域是容錯。由於CPU之間的連線如此緊密,共用元件中的故障可能會同時導致多個CPU效能損耗,這在傳統的多處理器中不太可能發生。
除了所有核都相同的對稱多核晶片之外,多核晶片的另一個常見類別是片上系統(System On a Chip,SoC)。這些晶片有一個或多個主CPU,但也有專用核心,如視訊和音訊解碼器、密碼處理器、網路介面等,從而在晶片上形成完整的計算機系統。
多核(Multicore)只是指「不止一個核」,但當核的數量遠遠超出手指計數的範圍時,我們使用另一個名稱。多核晶片(Manycore)是包含數十、數百甚至數千核的多核晶片。雖然Multicore成為Manycore並沒有硬閾值,但一個簡單的區別是,如果你不再在乎失去一兩個核,你可能會擁有多核。
像Intel的Xeon Phi這樣的加速器外掛卡擁有超過60個x86核心,其他供應商已經用不同種類的核心突破了100核心的障礙,一千個通用核可能正在研製,很難想象如何處理一千個核心,更不用說如何對它們進行程式設計了。
大量核心的另一個問題是,保持其快取一致性所需的機器變得非常複雜和昂貴。許多工程師擔心快取一致性可能無法擴充套件到數百個核心。有些人甚至主張我們應該完全放棄它。他們擔心,硬體中一致性協定的成本將非常高,以至於所有這些閃亮的新核心都不會對效能有太大幫助,因為處理器太忙了,無法將快取保持在一致狀態。更糟糕的是,它需要在(快速)目錄上花費太多的記憶體才能做到這一點。這就是所謂的相干牆(coherency wall)。
例如,考慮我們上面討論的基於目錄的快取一致性解決方案。如果每個目錄條目都包含一個位向量來指示哪些核心包含特定的快取行,那麼具有1024個核心的CPU的目錄條目將至少128位元組長。由於快取行本身很少大於128位元組,導致目錄條目大於它跟蹤的快取行的尷尬情況。可能不是我們想要的。
一些工程師認為,唯一能夠擴充套件到大量處理器的程式設計模型是採用訊息傳遞和分散式記憶體的程式設計模型,也是我們在未來多核晶片中應該期待的。像Intel的48核SCC這樣的實驗處理器已經降低了快取一致性,並提供了更快的訊息傳遞的硬體支援。另一方面,其他處理器即使在大的核心計數下也能提供一致性。混合模式也是可能的,例如,一個1024核晶片可以被劃分為64個島(island),每個島有16個快取一致性核,同時放棄島之間的快取一致性。
數以千計的核已經不再那麼特別了。今天最常見的許多核心,圖形處理單元,幾乎可以在任何沒有嵌入式和有監視器的計算機系統中找到。GPU是一個具有專用記憶體和數千個小核心的處理器,與通用處理器相比,GPU在執行計算的電路上花費了更多的電晶體預算,而在快取和控制邏輯上花費的更少。它們非常適合並行進行許多小計算,比如在圖形應用程式中渲染多邊形。它們不擅長連續任務,也很難程式設計。雖然GPU對作業系統很有用(例如,加密或處理網路流量),但作業系統本身不太可能在GPU上執行。
其他計算任務越來越多地由GPU處理,特別是在科學計算中常見的計算要求較高的任務。用於GPU上的通用處理的術語是你猜到的——GPGPU。不幸的是,對GPU進行高效程式設計非常困難,需要特殊的程式語言,如OpenGL或NVIDIA的專有CUDA。程式設計GPU和程式設計通用處理器之間的一個重要區別是,GPU本質上是「單指令多資料」機器,意味著大量核心執行完全相同的指令,但資料不同。這種程式設計模型非常適合資料並行,但對於其他程式設計風格(如任務並行)並不總是很方便。
一些晶片在同一晶片上整合了GPU和多個通用核心。類似地,除了一個或多個專用處理器之外,許多SoC還包含通用核。在單個晶片中整合多個不同種類處理器的系統統稱為異構多核處理器。異構多核處理器的一個例子是IXP網路處理器系列,最初由Intel於2000年推出,並定期更新最新技術。網路處理器通常包含一個通用控制核心(例如,執行Linux的ARM處理器)和幾十個高度專業化的流處理器,這些處理器非常擅長處理網路封包,而其他處理器則不多,通常用於網路裝置,如路由器和防火牆。另一方面,高速網路高度依賴於對記憶體的快速存取(讀取封包),流處理器有特殊的硬體來實現這一點。
IXP上的流處理器和控制處理器是完全不同的,具有不同的指令集,GPU和通用核心也是如此。然而,在保持相同指令集的同時,也可能引入異構性。例如,一個CPU可以有少量的「大」核心,具有深的流水線和可能高的時鐘速度,以及更多的「小」核心,這些核心更簡單、更不強大,並且可能在較低的頻率下執行。強大的核心是執行需要快速順序處理的程式碼所必需的,而小核心對於可以高效並行執行的任務是有用的,例如ARM的big.LITTLE處理器系列。
現在讓我們從多處理器硬體轉向多處理器軟體,特別是多處理器作業系統。各種方法是可能的,下面將研究其中的三個。請注意,所有這些都同樣適用於多核系統以及具有離散CPU的系統。
組織多處理器作業系統的最簡單可能的方法是將記憶體靜態地劃分為儘可能多的分割區,併為每個CPU提供自己的私有記憶體和作業系統的私有副本。實際上,n個CPU然後作為n個獨立的計算機執行。一個明顯的優化是允許所有CPU共用作業系統程式碼,並僅對作業系統資料結構進行私有拷貝,如下圖所示。
在四個CPU之間劃分多處理器記憶體,但共用作業系統程式碼的單個副本。標記為Data的框是每個CPU的作業系統專用資料。
這種方案仍然比有n臺單獨的計算機要好,因為它允許所有的計算機共用一組磁碟和其他I/O裝置,還允許靈活地共用記憶體。例如,即使使用靜態記憶體分配,一個CPU也可以獲得額外大的記憶體,這樣它就可以有效地處理大型程式。此外,程序可以通過允許生產者將資料直接寫入記憶體,並允許消費者從生產者寫入資料的地方獲取資料,從而有效地相互通訊。然而,從作業系統的角度來看,讓每個CPU都有自己的作業系統是最原始的。
值得一提的是,這種設計的四個方面可能並不明顯。
首先,當一個程序進行系統呼叫時,系統呼叫會在它自己的CPU上使用作業系統表中的資料結構進行捕獲和處理。
第二,由於每個作業系統都有自己的表,它也有自己的程序集,可以自己排程。沒有共用程序。如果使用者登入到CPU1,他的所有程序都在CPU1上執行。因此,當CPU2載入工作時,CPU1可能處於空閒狀態。
第三,沒有共用物理頁面。當CPU2連續分頁時,CPU1可能有空閒頁。由於記憶體分配是固定的,CPU 2無法從CPU 1借用一些頁面。
第四,也是最糟糕的一點,如果作業系統維護最近使用的磁碟塊的緩衝區快取,那麼每個作業系統都會獨立於其他作業系統執行此操作。因此,可能會發生某個磁碟塊同時存在於多個緩衝區快取中,並且是髒的,從而導致不一致的結果。避免此問題的唯一方法是消除緩衝區快取。這樣做並不難,但會嚴重影響效能。
由於這些原因,該模型很少再用於生產系統。如果每個處理器的所有狀態都保持在該處理器的本地,那麼很少或沒有共用會導致一致性或鎖定問題。相反,如果多個處理器必須存取和修改同一個程序表,鎖定會很快變得複雜(並且對效能至關重要)。
第二個模型如下圖所示,作業系統及其表的一個副本存在於CPU1上,而不是其他任何一個。所有系統呼叫都被重定向到CPU1進行處理,如果剩餘CPU時間,CPU 1也可以執行使用者程序。這種模型被稱為主從式(master-slave),因為CPU 1是主,而其它CPU是從。
主從式多處理器模型。
主從模型解決了第一個模型的大部分問題。有一個單獨的資料結構(例如,一個列表或一組優先順序列表),用於跟蹤就緒程序。當CPU空閒時,它要求CPU 1上的作業系統執行一個程序,並分配一個程序。因此,永遠不會發生一個CPU空閒而另一個CPU過載的情況。類似地,頁面可以在所有程序之間動態分配,並且只有一個緩衝區快取,因此不會發生不一致。
這個模型的問題是,對於許多CPU,主CPU將成為一個瓶頸,因為它必須處理來自所有CPU的所有系統呼叫。例如,如果所有時間的10%都用於處理系統呼叫,那麼10個CPU將使主機幾乎飽和,而20個CPU將完全過載。因此,這個模型對於小型多處理器來說是簡單可行的,但對於大型多處理器來說,則不可行。
第三種模型SMP(Symmetric Multiprocessors,對稱多處理器)消除了這種不對稱性。記憶體中有一個作業系統副本,但任何CPU都可以執行它。當進行系統呼叫時,進行系統呼叫的CPU捕獲核心並處理系統呼叫。SMP模型如下圖所示。
SMP架構案例1。
SMP架構案例2。
SMP架構案例3。
該模型動態平衡程序和記憶體,因為只有一組作業系統表,還消除了主CPU瓶頸,因為沒有主CPU。但它引入了自己的問題,特別是,如果兩個或多個CPU同時執行作業系統程式碼,很可能會導致災難,想象兩個CPU同時選擇相同的程序執行或要求相同的空閒記憶體頁。解決這些問題的最簡單方法是將互斥體(即鎖)與作業系統相關聯,使整個系統成為一個大的關鍵區域。當CPU想要執行作業系統程式碼時,它必須首先獲取互斥體。如果互斥鎖被鎖定,它只會等待。這樣,任何CPU都可以執行作業系統,但一次只能執行一個。這種方法叫做大核心鎖(big kernel lock)。
這種模式很有效,但幾乎和主從模式一樣糟糕。同樣,假設所有執行時間的10%花費在作業系統內部。有了20個CPU,將有很長的CPU佇列等待進入。幸運的是,它很容易改進,作業系統的許多部分彼此獨立,例如,一個CPU執行排程程式,另一個CPU處理檔案系統呼叫,第三個CPU處理頁面錯誤,這沒有問題。
這種觀察導致將作業系統拆分為多個獨立的關鍵區域,這些區域彼此不互動。每個關鍵區域都有自己的互斥體保護,因此一次只能有一個CPU執行它。通過這種方式,可以實現更多的並行性。然而,很可能會發生一些表(如程序表)被多個關鍵區域使用的情況。例如,程序表不僅用於排程,還用於fork系統呼叫和訊號處理。多個關鍵區域可能使用的每個表都需要自己的互斥體,這樣,每個關鍵區域一次只能由一個CPU執行,每個關鍵表一次只能被一個CPU存取。
大多數現代多處理機都使用這種排程,為這樣的機器編寫作業系統的困難之處並不在於實際程式碼與常規作業系統有很大的不同,事實並非如此。最困難的部分是將其劃分為關鍵區域,這些區域可以由不同的CPU同時執行,而不會相互干擾,甚至不會以微妙的、間接的方式。此外,兩個或多個關鍵區域使用的每個表都必須由互斥體單獨保護,並且使用該表的所有程式碼都必須正確使用互斥體。
此外,必須非常小心地避免死鎖。如果兩個關鍵區域都需要表A和表B,並且其中一個首先獲取A,另一個先獲取B,那麼遲早會發生死鎖,沒有人會知道原因。理論上,所有的表都可以分配整數值,所有的關鍵區域都可以按遞增的順序獲取表。這種策略避免了死鎖,但它要求程式設計師非常仔細地考慮每個關鍵區域需要哪些表,並按照正確的順序發出請求。
隨著程式碼的不斷髮展,關鍵區域可能需要一個以前不需要的新表。如果程式設計師是新手,並且不理解系統的全部邏輯,那麼誘惑將是在需要的時候抓住表上的互斥體,並在不再需要時釋放它。無論這看起來多麼合理,它都可能導致死鎖,使用者會認為這是系統凍結。要做到這一點並不容易,面對不斷變化的程式設計師,要在一段時間內保持這一點是非常困難的。
多處理器中的CPU經常需要同步,前面看到核心關鍵區域和表必須由互斥體保護的情況,下面看看這種同步在多處理器中是如何工作的。
首先,確實需要適當的同步原語。如果單處理器機器(只有一個CPU)上的程序進行了需要存取某個關鍵核心表的系統呼叫,那麼核心程式碼可以在存取該表之前禁用中斷。然後,它就可以完成工作了,因為它知道在完成之前,它將能夠在不需要任何其他過程的情況下完成工作。在多處理器上,禁用中斷隻影響執行禁用操作的CPU。其他CPU繼續執行,仍然可以觸及關鍵表。因此,所有CPU必須使用並遵守適當的互斥協定,以確保互斥工作。
任何實用互斥協定的核心都是一條特殊指令,它允許在一個不可分割的操作中檢查和設定記憶體字,可以使用TSL(測試和設定鎖定)來實現關鍵區域,如前所述,TSL的作用是讀取一個記憶體字並將其儲存在暫存器中。同時,它將1(或其他非零值)寫入記憶體字。當然,執行記憶體讀取和記憶體寫入需要兩個匯流排週期。在單處理器上,只要指令不能中途中斷,TSL總是按預期工作。
現在想想在多處理器上會發生什麼。在下圖中,我們看到了最壞的定時,其中用作鎖的記憶體字1000最初為0。在步驟1中,CPU 1讀取該字並獲得0。在第2步中,在CPU 1有機會將該字重寫為1之前,CPU 2進入並將該字作為0讀出。在第3步中,CPU將1寫入該字。在步驟4中,CPU 2還將1寫入字。兩個CPU都從TSL指令中得到了0,因此它們現在都可以存取關鍵區域,互斥失敗。
如果無法鎖定匯流排,TSL指令可能會失敗。這四個步驟顯示了一系列事件,其中顯示了故障。
為了防止這個問題,TSL指令必須首先鎖定匯流排,防止其他CPU存取它,然後執行兩次記憶體存取,然後解鎖匯流排。通常,通過使用通常的匯流排請求協定請求匯流排,然後斷言(即設定為邏輯1值)某些特殊匯流排,直到兩個迴圈都完成,從而鎖定匯流排。只要這條特殊線路被斷言,其他CPU就不會被授予匯流排存取權。此指令只能在具有使用它們所需線路和(硬體)協定的匯流排上實現。現代匯流排都有這些設施,但在早期沒有這些設施的匯流排上,不可能正確實施TSL。這就是彼得森協定被髮明的原因:完全在軟體中同步。
如果TSL得到正確的實施和使用,它保證了互斥可以發揮作用。然而,這種互斥方法使用自旋鎖,因為請求的CPU只是處於一個嚴密的迴圈中,儘可能快地測試鎖。它不僅完全浪費了請求CPU(或多個CPU)的時間,而且還可能給匯流排或記憶體帶來大量負載,嚴重減慢了所有其他CPU正常工作的速度。
乍一看,快取的存在似乎應該消除匯流排爭用的問題,但事實並非如此。理論上,一旦請求CPU讀取了鎖字,它應該在快取中獲得一個副本。只要沒有其他CPU嘗試使用鎖,請求的CPU應該能夠耗盡其快取。當擁有鎖的CPU向其寫入0以釋放它時,快取協定會自動使遠端快取中的所有副本無效,要求再次獲取正確的值。
問題是快取記憶體在32或64位元組的塊中執行。通常,鎖周圍的單字是由持有鎖的CPU所需要的,由於TSL指令是寫(因為它修改了鎖),它需要對包含鎖的快取塊進行獨佔存取。因此,每個TSL都會使鎖持有者快取中的塊無效,併為請求的CPU獲取一個專用的、獨佔的副本。一旦鎖持有者接觸到與鎖相鄰的單字,快取塊就會移動到其機器上。因此,包含鎖的整個快取塊不斷地在鎖所有者和鎖請求者之間穿梭,產生的匯流排流量甚至超過了對鎖字的單獨讀取。
如果能夠消除請求端的所有TSL引發的寫入,就可以顯著減少快取抖動(thrashing),可通過讓請求的CPU首先進行一次純讀取來檢視鎖是否空閒來實現。只有當鎖看起來是空閒的時,它才會執行TSL來實際獲取它。這個小變化的結果是,大多數輪詢變成了都是讀而不是寫。如果持有鎖的CPU僅讀取同一快取塊中的變數,則它們可以在共用唯讀模式下各自擁有快取塊的副本,從而消除所有快取塊傳輸。
當鎖最終被釋放時,所有者進行寫操作,這需要獨佔存取,從而使遠端快取中的所有副本無效。請求CPU下次讀取時,將重新載入快取塊。請注意,如果兩個或多個CPU正在爭奪同一個鎖,可能會發生兩個CPU都看到它同時空閒,兩個CPU同時執行TSL以獲取它。只有其中一個會成功,因此這裡沒有競爭條件,因為真正的獲取是由TSL指令完成的,並且是原子的。看到鎖是免費的,然後嘗試用TSL立即獲取它並不能保證你獲得它。但對於演演算法的正確性,誰獲得它並不重要。純讀取的成功只是暗示,將是嘗試獲取鎖的好時機,但並不能保證獲取成功。
另一種減少匯流排流量的方法是使用眾所周知的乙太網二進位制指數退避演演算法(Ethernet binary exponential backoff algorithm),使用連續輪詢可以在輪詢之間插入延遲環路。最初,延遲是一條指令,如果鎖仍然繁忙,延遲將加倍到兩條指令,然後是四條指令,以此類推,直到達到最大值。低的最大值在釋放鎖時提供快速響應,但在快取抖動上浪費更多的匯流排週期。高的最大值可以減少快取抖動,但代價是不會注意到很快釋放的鎖。二進位制指數回退可以與TSL指令之前的純讀取一起使用,也可以不使用。
一個更好的想法是讓每個希望獲取互斥鎖的CPU都有自己的私有鎖變數進行測試,如下圖所示。變數應位於其他未使用的快取塊中,以避免衝突。該演演算法的工作原理是讓無法獲取鎖的CPU分配一個鎖變數,並將其自身附加到等待鎖的CPU列表的末尾。噹噹前鎖持有者退出關鍵區域時,它釋放列表中第一個CPU正在測試的私有鎖(在其自己的快取中)。然後,該CPU進入臨界區域。完成後,它會釋放其後繼者正在使用的鎖,以此類推。儘管協定有點複雜(為了避免兩個CPU同時連線到列表的末尾),但它是高效且無飢餓的。
使用多個鎖來避免快取抖動。
到目前為止,我們假設需要鎖定互斥鎖的CPU只是通過連續輪詢、間歇輪詢或將其自身附加到等待的CPU列表來等待它。有時,請求的CPU除了等待之外別無選擇。例如,假設某個CPU處於空閒狀態,需要存取共用就緒列表以選擇要執行的程序。如果就緒列表被鎖定,CPU不能決定暫停正在執行的操作並執行另一個程序,因為這樣做需要讀取就緒列表。它必須等待,直到它可以獲取就緒列表。
然而,在其他情況下,有一個選擇。例如,如果CPU上的某個執行緒需要存取檔案系統緩衝區快取,並且該快取當前被鎖定,則CPU可以決定切換到其他執行緒而不是等待,是自旋還是切換執行緒的問題一直是一個研究的問題。請注意,這個問題在單處理器上不會發生,因為當沒有其他CPU釋放鎖時,自旋沒有多大意義。如果一個執行緒試圖獲取一個鎖,但失敗了,它總是被阻塞,以給鎖所有者一個執行和釋放鎖的機會。
假設自旋和執行執行緒切換都是可行的選項,權衡如下。自旋直接浪費CPU週期,反覆測試鎖不是一件有成效的工作。但是,切換也會浪費CPU週期,因為必須儲存當前執行緒的狀態,必須獲取就緒列表上的鎖,必須選擇執行緒,必須載入其狀態,並且必須啟動執行緒。此外,CPU快取將包含所有錯誤的塊,因此當新執行緒開始執行時,會發生許多昂貴的快取未命中。TLB也可能發生故障。最終,必須切換回原始執行緒,隨後會有更多的快取未命中。執行這兩個上下文切換所花費的週期加上所有快取未命中都被浪費了。
如果已知互斥體通常保持50微秒,並且從當前執行緒切換需要1毫秒,稍後再切換需要1秒,那麼只在互斥體上自旋更有效。另一方面,如果平均互斥體保持10毫秒,那麼進行兩個上下文切換是值得的。問題是關鍵區域的持續時間可能會有很大的差異,那麼哪種方法更好呢?
一種設計是總是自旋,第二種設計是始終切換,但第三種設計是在每次遇到鎖定的互斥體時做出單獨的決定。在必須做出決定的時候,不知道是自旋還是切換更好,但對於任何給定的系統,都可以跟蹤所有活動,並在稍後離線分析。回想起來,哪一個決定是最好的,在最好的情況下浪費了多少時間。然後,這種事後發現的演演算法成為衡量可行演演算法的基準。
幾十年來,研究人員一直在研究這個問題。大多數研究都使用一個模型,在該模型中,未能獲取互斥體的執行緒會在一段時間內自旋。如果超過此閾值,則切換。在某些情況下,閾值是固定的,通常是切換到另一個執行緒然後再切換回來的已知開銷。在其他情況下,它是動態的,取決於所觀察到的等待互斥體的歷史。
當系統跟蹤最後幾次觀察到的自旋時間並假設這一次與之前的自旋時間相似時,就可以獲得最佳結果。例如,假設再次進行1毫秒的上下文切換,執行緒將自旋最多2毫秒,但觀察它實際自旋的時間。如果它無法獲取鎖,並且發現在前三次執行中它平均等待了200微秒,那麼它應該在切換前自旋2毫秒。然而,如果它看到它在前一次嘗試中自旋了整整2毫秒,它應該立即切換,而不是自旋。
一些現代處理器,包括x86,提供了特殊的指令,使等待在降低功耗方面更加高效。例如,x86上的MONITOR/MWAIT指令允許程式阻塞,直到其他處理器修改之前定義的記憶體區域中的資料。具體來說,MONITOR指令定義了一個地址範圍,應該監視該地址範圍的寫入。然後,MWAIT指令阻塞執行緒,直到有人寫入該區域。實際上,執行緒正在自旋,但沒有不必要地消耗許多週期。
在檢視如何在多處理器上進行排程之前,有必要確定正在排程什麼。在過去,當所有程序都是單執行緒的時候,程序都是排程的,沒有其他可排程的。所有現代作業系統都支援多執行緒程序,使得排程更加複雜。
執行緒是核心執行緒還是使用者執行緒都很重要。如果執行緒是由使用者空間庫完成的,並且核心對執行緒一無所知,那麼排程將按每個程序進行,就像它一直做的那樣。如果核心甚至不知道執行緒的存在,它就很難對執行緒進行排程。
對於核心執行緒,情況就不同了,核心知道所有執行緒,並且可以在屬於程序的執行緒中進行選擇。在這些系統中,趨勢是核心選擇要執行的執行緒,它所屬的程序線上程選擇演演算法中只扮演一個小角色(或者可能沒有)。下面我們將討論排程執行緒,但當然,在具有單執行緒程序或在使用者空間中實現執行緒的系統中,排程的是程序。
程序與執行緒不是唯一的排程問題。在單處理器上,排程是一維的。唯一必須(反覆)回答的問題是:「下一個應該執行哪個執行緒?」在多處理器上,排程有兩個維度,排程程式必須決定執行哪個執行緒以及在哪個CPU上執行,額外的維度使多處理器上的排程變得非常複雜。另一個複雜的因素是,在一些系統中,所有執行緒都是相互關聯的,屬於不同的程序,彼此之間沒有任何關係。在其他應用程式中,它們是分組的,屬於同一個應用程式並一起工作。前一種情況的一個例子是獨立使用者啟動獨立程序的伺服器系統,不同程序的執行緒是不相關的,每一個程序都可以在不考慮其他程序的情況下進行排程。
後一種情況的例子經常出現在程式開發環境中。大型系統通常由一些標頭檔案組成,其中包含宏、型別定義和實際程式碼檔案使用的變數宣告。更改標頭檔案時,必須重新編譯包含它的所有程式碼檔案。程式make通常用於管理開發,當make被呼叫時,它只開始編譯那些由於標頭檔案或程式碼檔案的更改而必須重新編譯的程式碼檔案,仍然有效的物件檔案不會重新生成。
make的原始版本按順序執行,但為多處理器設計的較新版本可以同時啟動所有編譯。如果需要10個編譯,那麼安排其中9個編譯立即執行並將最後一個編譯保留到很晚的時間是沒有意義的,因為使用者在最後一個完成之前不會感覺到工作已經完成。在這種情況下,將執行編譯的執行緒視為一個組,並在排程它們時考慮到這一點則有意義。
有時,排程廣泛通訊的執行緒比較有用,例如以生產者-消費者的方式,不僅在同一時間,而且在空間上緊密地聯絡在一起,它們可能會從共用快取中受益。同樣,在NUMA體系結構中,如果它們存取附近的記憶體,可能會有所幫助。
常見的排程演演算法有時間分享、空間分享、成組排程等。由於時間分享前面已經介紹過,下面只介紹後兩種。
當執行緒以某種方式相互關聯時,可以使用多處理器排程的另一種通用方法。前面我們提到了並行make的例子。通常情況下,一個程序有多個執行緒一起工作。例如,如果程序的執行緒經常通訊,那麼讓它們同時執行是很有用的。跨多個CPU同時排程多個執行緒稱為空間共用(space sharing)。
最簡單的空間共用演演算法是這樣工作的。假設一次建立了一組相關執行緒,在建立它時,排程器會檢查空閒CPU是否與執行緒一樣多。如果有,每個執行緒都有自己的專用(即非多程式)CPU,它們都會啟動。如果沒有足夠的CPU,不會啟動任何執行緒。每個執行緒都會佔用它的CPU,直到它終止,這時CPU會被放回可用CPU池中。如果一個執行緒在I/O上阻塞,它將繼續保持CPU,直到執行緒喚醒為止,CPU一直處於空閒狀態。當下一批執行緒出現時,將應用相同的演演算法。
在任何時刻,CPU集都會被靜態地劃分為若干個分割區,每個分割區都執行一個程序的執行緒。在下圖中,有大小為4、6、8和12個CPU的分割區,例如,有2個未分配的CPU。隨著時間的推移,分割區的數量和大小將隨著新執行緒的建立和舊執行緒的完成和終止而改變。
一組32個CPU分成四個分割區,有兩個可用CPU。
必須定期做出排程決定。在單處理器系統中,最短作業優先是一種眾所周知的批排程演演算法。多處理器的類似演演算法是選擇需要最少CPU週期數的程序,即CPU計數×執行時間最小的執行緒。然而,在實踐中,這種資訊很少可用,因此演演算法很難實現。事實上,研究表明,在實踐中,先到先得很難做到。
在這個簡單的分割區模型中,一個執行緒只需要一些CPU,然後要麼得到它們,要麼等待它們可用。另一種方法是執行緒主動管理並行度。管理並行性的一種方法是使用一箇中央伺服器來跟蹤哪些執行緒正在執行,哪些執行緒想要執行,以及它們的最小和最大CPU需求是多少。每個應用程式定期輪詢中央伺服器,詢問它可能使用多少CPU。然後,它向上或向下調整執行緒數以匹配可用的執行緒數。
例如,一個Web伺服器可以有5個、10個、20個或任何其他數量的執行緒並行執行。如果它目前有10個執行緒,並且突然對CPU的需求增加,並且它被告知減少到5個執行緒,那麼當下一個5個執行緒完成其當前工作時,它們被告知退出,而不是被賦予新的工作。該方案允許分割區大小動態變化,以比上圖的固定系統更好地匹配當前工作負載。
空間共用的一個明顯優勢是消除了多道程式設計,從而消除了上下文切換開銷。然而,一個同樣明顯的缺點是,當CPU阻塞並且在再次準備就緒之前沒有任何事情可做時,會浪費時間。因此,人們一直在尋找能夠同時在時間和空間上進行排程的演演算法,尤其是那些建立多個執行緒的執行緒,這些執行緒通常需要相互通訊。
要了解程序的執行緒獨立排程時可能出現的問題,請考慮一個系統,其中執行緒A0和A1屬於程序a,執行緒B0和B1屬於程序B;執行緒A1和B1在CPU 1上分時,執行緒A0和A1需要經常通訊。通訊模式是A0向A1傳送一條訊息,A1隨後向A0傳送一個回覆,然後是另一個這樣的序列,這在客戶機-伺服器情況下很常見。假設幸運的是A0和B1首先開始,如下圖所示。
屬於執行緒A的兩個執行緒之間的異相通訊。
在時間片0中,A0向A1傳送一個請求,但A1直到在時間片1中以100毫秒開始執行時才收到請求。它立即傳送回復,但A0直到在200毫秒再次執行時才收到回覆。最後結果是每200毫秒一個請求-應答序列,效能不是很好。
這個問題的解決方案是分組排程(gang scheduling),是聯合排程的產物,它包括三個部分:
1、相關執行緒組被安排為一個單元,一個組。
2、一個幫派的所有成員同時在不同的分時CPU上執行。
3、所有幫派成員一起開始和結束他們的時間片。
使分組排程工作的訣竅是,所有CPU都是同步排程的,意味著時間被劃分為離散的量子,如上圖所示。在每個新量子開始時,所有CPU都被重新排程,每個CPU上都會啟動一個新執行緒。在下一個時間段開始時,會發生另一個排程事件。在這兩者之間,不進行排程。如果執行緒阻塞,它的CPU將保持空閒狀態,直到時間段結束。
下圖給出了分組排程工作的一個例子,有一個多處理器,有六個CPU,由五個程序a到e使用,總共有24個就緒執行緒。在時隙0期間,執行緒A0到A6被排程和執行。在時隙1期間,排程並執行執行緒B0、B1、B2、C0、C1和C2。在時隙2期間,D的五個執行緒和E0開始執行。屬於執行緒E的其餘六個執行緒在時隙3中執行。然後迴圈重複,時隙4與時隙0相同,以此類推。
分組排程的思想是讓一個程序的所有執行緒同時在不同的CPU上執行,這樣,如果其中一個執行緒向另一個執行緒傳送請求,它將幾乎立即收到訊息,並且能夠幾乎立即回覆。在上圖中,由於所有A執行緒都在一起執行,在一個時間段內,它們可以在一個量子段內傳送和接收大量訊息,從而消除了上上圖的問題。
使用多核系統來支援具有多執行緒的單個應用程式,例如工作站、電動遊戲控制檯或執行處理器密集型應用程式的個人計算機上可能出現的應用程式,會帶來效能和應用程式設計問題。在本節中,我們闡述一下多核系統上多執行緒應用程式的一些效能影響。
多核組織的潛在效能優勢取決於有效利用應用程式可用的並行資源的能力,效能引數遵循Amdahl定律,下面兩圖展示了多核的效能影響曲線圖:
除了通用伺服器軟體之外,許多應用程式類別也直接受益於隨核心數量擴充套件吞吐量的能力,以下是其中的幾個範例:
多執行緒原生應用程式:多執行緒應用程式的特點是具有少量高執行緒程序,範例包括Lotus Domino或Siebel CRM(客戶關係經理)。
多程序應用程式:多程序應用的特點是存在許多單執行緒程序,範例包括Oracle資料庫、SAP和PeopleSoft。
Java應用程式:Java應用程式以一種基本的方式擁抱執行緒。Java語言不僅極大地促進了多執行緒應用程式,而且Java虛擬機器器是一個多執行緒程序,為Java應用程式提供排程和記憶體管理。可以直接從多核資源中受益的Java應用程式包括應用程式伺服器,如Sun的Java application Server、BEA的Weblogic、IBM的Websphere和開源Tomcat應用程式伺服器。所有使用Java 2 Platform,Enterprise Edition(2EE平臺)應用伺服器的應用程式都可以立即從多核技術中受益。
多範例應用程式:即使單個應用程式不能擴充套件以利用大量執行緒,也可以通過並行執行應用程式的多個範例從多核架構中獲益。如果多個應用程式範例需要某種程度的隔離,則可以使用虛擬化技術(針對作業系統的硬體)為每個應用程式範例提供各自獨立的安全環境。
下圖顯示了遊戲引擎Source的渲染模組的執行緒結構。在這種層次結構中,高階執行緒根據需要生成低階執行緒。渲染模組依賴於Source引擎的關鍵部分,即世界列表,它是遊戲世界中視覺元素的資料庫表示。第一個任務是確定世界上需要渲染的區域,下一個任務是確定從多個角度觀看時場景中的物件,然後是處理器密集型工作。渲染模組必須從多個視角(例如玩家視角、電視顯示器的視角和水中反射的視角)來渲染每個物件。
Android是一個相對較新的作業系統,設計用於在移動裝置上執行。它基於Linux核心,Android僅向Linux核心本身引入了一些新概念,使用了大多數Linux設施(程序、使用者ID、虛擬記憶體、檔案系統、排程等),有時方式與最初的意圖完全不同。
自推出以來,Android已成為應用最廣泛的智慧手機作業系統之一。它的普及帶動了智慧手機的爆炸式增長,移動裝置製造商可以免費在其產品中使用它。它也是一個開源平臺,使其可針對各種裝置進行客製化。它不僅在第三方應用生態系統有利的以消費為中心的裝置(如平板電腦、電視、遊戲系統和媒體播放器)中廣受歡迎,而且越來越多地被用作需要圖形化使用者介面(GUI)的專用裝置的嵌入式作業系統,如VOIP電話、智慧手錶、汽車儀表板、醫療裝置和家用電器。
大量的Android作業系統是用高階語言(Java程式語言)編寫的。核心和大量低階庫是用C和C++編寫的。然而,大部分系統都是用Java編寫的,除了一些小的例外,整個應用程式API也是用Java編寫和釋出的。用Java編寫的Android部分傾向於遵循一種非常物件導向的設計,這正是該語言所鼓勵的。
Android是一個不同尋常的作業系統,它將開原始碼與封閉原始碼的第三方應用程式結合在一起。Android的開源部分被稱為Android開源專案(AOSP),是完全開放的,任何人都可以自由使用和修改。
Android的一個重要目標是支援一個豐富的第三方應用程式環境,需要有一個穩定的實現和API來執行應用程式。然而,在一個開放原始碼的世界裡,每個裝置製造商都可以隨心所欲地客製化平臺,相容性問題很快就會出現。需要有某種方法來控制這種衝突。
針對Android的部分解決方案是CDD(相容性定義檔案),它描述了Android必須如何與第三方應用程式相容,檔案本身描述了相容Android裝置的要求。然而,如果沒有某種方式來加強這種相容性,它往往會被忽略,需要一些額外的機制來實現這一點。
Android通過允許在開源平臺之上建立額外的專有服務來解決這個問題,提供平臺本身無法實現的(通常是基於雲的)服務。由於這些服務是專有的,它們可以限制允許哪些裝置包含它們,因此需要這些裝置的CDD相容性。
谷歌實現了Android,以支援各種專有云服務,谷歌的一系列服務是典型的例子:Gmail、日曆和聯絡人同步、雲到裝置訊息傳遞以及許多其他服務,有些對使用者可見,有些則不可見。在提供相容應用程式方面,最重要的服務是Google Play。
Google Play是Google的Android應用程式線上商店。通常,當開發者建立Android應用程式時,他們會使用Google Play釋出。由於Google Play(或任何其他應用程式商店)是將應用程式交付給Android裝置的渠道,該專有服務負責確保應用程式在其交付的裝置上執行。
Google Play使用兩種主要機制來確保相容性。第一個也是最重要的一個要求是,根據CDD,隨附的任何裝置必須是相容的Android裝置,確保了所有裝置的行為基線。此外,Google Play必須瞭解應用程式所需的裝置的任何功能(例如有GPS用於執行地圖導航),因此應用程式無法在缺少這些功能的裝置上使用。
谷歌在2000年代中期開發了Android,在其開發初期收購了一家初創公司Android。今天存在的Android平臺的幾乎所有開發都是在谷歌的管理下完成的。
Android股份有限公司是一家軟體公司,成立的目的是開發軟體以創造更智慧的移動裝置。最初是針對相機,但由於智慧手機的潛在市場更大,人們的目光很快轉向了智慧手機。最初的目標是通過在Linux之上構建一個可以廣泛使用的開放平臺來解決當時為移動裝置開發的困難。
在此期間,實現了平臺使用者介面的原型,以展示其背後的想法。為了支援豐富的應用程式開發環境,平臺本身瞄準了三種關鍵語言:JavaScript、Java和C++。
谷歌於2005年7月收購了Android,提供了必要的資源和雲服務支援,以繼續將Android作為一個完整的產品進行開發。在此期間,一小部分工程師緊密合作,開始為平臺開發核心基礎設施,併為更高階別的應用程式開發奠定基礎。
2006年初,計劃發生了重大變化:該平臺將完全專注於Java程式語言,而不是支援多種程式語言,用於其應用程式開發。這是一個艱難的改變,因為最初的多語言方法表面上讓每個人都對「世界上最好的」感到滿意;對於喜歡其他語言的工程師來說,專注於一種語言就像是倒退了一步。
然而,試圖讓每個人都快樂,很容易讓任何人都快樂。構建三套不同的語言API比專注於一種語言需要付出更多的努力,從而大大降低了每種語言的質量。專注於Java語言的決定對於平臺的最終質量和開發團隊滿足重要截止日期的能力至關重要。
隨著開發的進展,Android平臺與最終將在其上釋出的應用程式密切相關。谷歌已經擁有各種各樣的服務,包括Gmail、地圖、日曆、YouTube,當然還有將在Android上提供的搜尋服務。在早期平臺上實現這些應用程式所獲得的知識被反饋到其設計中。這種應用程式的迭代過程允許在開發早期解決平臺中的許多設計缺陷。
大多數早期應用程式開發都是在很少有底層平臺可供開發人員使用的情況下完成的。該平臺通常在一個程序內執行,通過一個「模擬器」,將所有系統和應用程式作為一個程序在主機上執行。事實上,今天仍然有一些舊實現的殘留物,比如應用程式。onTerminate方法仍然存在於SDK(軟體開發套件)中,Android程式設計師使用它編寫應用程式。
2006年6月,兩個硬體裝置被選定為計劃產品的軟體開發目標。第一款代號為「Sooner」,基於現有的智慧手機,配有QWERTY鍵盤和螢幕,無需觸控輸入,該裝置的目標是通過利用現有硬體儘快推出初始產品。第二個目標裝置代號為「Dream」,是專為Android設計的,可以完全按照設想執行。它包括一個大(當時)觸控式螢幕、滑出式QWERTY鍵盤、3G收音機(用於更快的網路瀏覽)、加速計、GPS和指南針(用於支援谷歌地圖)等。
隨著軟體進度計劃越來越清晰,兩個硬體進度計劃顯然沒有意義。等到Sooner有可能釋出的時候,硬體已經過時了,Sooner的努力正在推出更重要的Dream裝置。為了解決這個問題,它決定放棄Sooner作為目標裝置(儘管該硬體的開發持續了一段時間,直到新的硬體準備就緒),並完全專注於Dream。
Android平臺的首次公開發布是2007年11月釋出的預覽SDK,包括一個執行完整Android裝置系統映像和核心應用程式的硬體裝置模擬器、API檔案和開發環境。在這一點上,核心設計和實現已經到位,並且在大多數方面與我們將要討論的現代Android系統架構非常相似。該公告包括在Sooner和Dream硬體上執行的平臺的視訊演示。
Android的早期開發是在一系列季度演示里程碑下完成的,以推動和展示持續的過程。SDK版本是該平臺的第一個更正式的版本。它需要將迄今為止為應用程式開發而拼湊起來的所有部分,清理並記錄它們,併為第三方開發人員建立一個內聚的開發環境。
開發現在沿著兩條軌道進行:接收關於SDK的反饋以進一步完善和最終確定API,以及完成和穩定交付Dream裝置所需的實現。在此期間,SDK進行了多次公開更新,最終於2008年8月釋出了0.9版本,其中包含了幾乎最後的API。
該平臺本身一直在快速發展,2008年春季,重點轉向穩定,以便夢想得以實現。此時,Android包含了大量從未作為商業產品釋出的程式碼,從C庫的一部分一直到Dalvik直譯器(執行應用程式)、系統和應用程式。
Android還包含了一些以前從未有過的新穎設計思想,目前尚不清楚它們將如何實現。所有這些都需要作為一個穩定的產品組合在一起,團隊花了幾個月的時間,想知道所有這些東西是否真的組合在一起並按預期工作。
最後,在2008年8月,該軟體穩定並準備發貨。產品進入工廠並開始在裝置上閃現。9月,Android 1.0在Dream裝置上釋出,現在稱為T-Mobile G1。
在Android 1.0釋出後,開發繼續快速進行。在接下來的5年中,該平臺進行了大約15次重大更新,從最初的1.0版本中新增了大量新功能和改進。
最初的相容性定義檔案基本上只允許與T-Mobile G1非常相似的相容裝置使用。在接下來的幾年中,相容裝置的範圍將大大擴大。這一過程的關鍵點是:
重要的開發工作也進入了一些不那麼明顯的領域:將谷歌的專有服務與Android開源平臺進行更清晰的分離。
對於Android 1.0,已經投入了大量工作來建立一個乾淨的第三方應用程式API和一個不依賴於專有谷歌程式碼的開源平臺。然而,谷歌專有程式碼的實現往往還沒有清理乾淨,依賴於平臺的內部部分,通常,該平臺甚至沒有谷歌專有程式碼所需的設施來與之進行良好的整合。為解決這些問題,很快開展了一系列專案:
Android平臺在開發過程中出現了許多關鍵設計目標:
1、為移動裝置提供完整的開源平臺。Android的開源部分是一個自下而上的作業系統堆疊,包括各種應用程式,可以作為一個完整的產品釋出。
2、通過強大而穩定的API,強力支援專有的第三方應用程式。如前所述,維護一個既真正開源又足夠穩定的平臺,以供專有第三方應用程式使用,是一項挑戰。Android使用混合的技術解決方案(指定一個定義良好的SDK以及公共API和內部實現之間的劃分)和政策要求(通過CDD)來解決這一問題。
3、允許所有第三方應用程式,包括來自谷歌的應用程式,在公平的競爭環境中競爭。Android開原始碼被設計為儘可能中立於構建在其之上的高階系統功能,從存取雲服務(如資料同步或雲到裝置的訊息API),到庫(如谷歌的對映庫)和應用商店等豐富服務。
4、提供一種應用程式安全模型,在該模型中,使用者不必深深信任第三方應用程式。作業系統必須保護使用者免受應用程式的不當行為,不僅是可能導致其崩潰的有缺陷的應用程式,而且還要保護使用者對裝置和裝置上使用者資料的更微妙的濫用。使用者越不需要信任應用程式,他們就越有自由嘗試和安裝應用程式。
5、支援典型的移動使用者互動:在許多應用程式中花費很短的時間。移動體驗往往涉及與應用程式的簡短互動:瀏覽新收到的電子郵件、接收和傳送簡訊或即時訊息、聯絡聯絡人撥打電話等;Android的目標通常是200毫秒,以冷啟動基本應用程式。
6、為使用者管理應用程式流程,簡化應用程式的使用者體驗,以便使用者在完成應用程式時不必擔心關閉應用程式。移動裝置也傾向於在沒有交換空間的情況下執行,噹噹前執行的應用程式集需要比實際可用的RAM更多的RAM時,交換空間允許作業系統更優雅地發生故障。為了滿足這兩個需求,系統需要採取更積極的態度來管理程序,並決定何時啟動和停止程序。
7、鼓勵應用程式以豐富和安全的方式進行互操作和共同作業。在某些方面,移動應用程式是對shell命令的迴歸:它們不是越來越大的桌面應用程式的單一設計,而是針對特定需求而設計的。為了幫助支援這一點,作業系統應該為這些應用程式提供新型別的設施,以便它們協同工作,建立一個更大的整體。
8、建立一個完整的通用作業系統。移動裝置是通用計算的一種新表現形式,比我們的傳統桌面作業系統更簡單。Android的設計應該足夠豐富,可以發展到至少與傳統作業系統一樣的能力。
Android是在標準Linux核心之上構建的,只有幾個對核心本身的重要擴充套件。然而,一旦進入使用者空間,它的實現就與傳統的Linux發行版大不相同,並且以非常不同的方式使用了許多Linux特性。
與傳統的Linux系統一樣,Android的第一個使用者空間程序是init,它是所有其他程序的根。然而,守護行程Android的init程序啟動不同,它更多地關注低階細節(管理檔案系統和硬體存取),而不是更高階的使用者設施,比如排程cron作業。Android還有一個額外的程序層,執行Dalvik的Java語言環境,負責執行用Java實現的系統的所有部分。
下圖展示了Android的基本程序結構。首先是init程序,它產生了許多低階守護行程。其中一個是zygote,它是高階Java語言程序的根。
Android程序層次結構。
Android的init不會以傳統方式執行shell,因為典型的Android裝置沒有用於shell存取的本地控制檯。相反,守護行程adbd偵聽請求shell存取的遠端連線(例如通過USB),根據需要為它們分叉shell程序。
由於大多數Android都是用Java語言編寫的,所以zygote守護行程及其啟動的程序是系統的核心。總是啟動的第一個程序稱為系統伺服器,它包含所有核心作業系統服務,其中的關鍵部分是電源管理器、包管理器、視窗管理器和活動管理器。
其他程序將根據需要從zygote中建立。其中一些是作為基本作業系統一部分的「持久」程序,例如電話程序中的電話堆疊,必須始終執行。系統執行時,將根據需要建立和停止其他應用程式程序。
應用程式通過呼叫作業系統提供的庫與作業系統互動,這些庫共同構成了Android框架(Android framework)。其中一些庫可以在該程序中執行其工作,但許多庫需要與其他程序(通常是系統伺服器程序中的服務)執行程序間通訊。
下圖顯示了與系統服務互動的Android框架API的典型設計,在本例中為包管理器。包管理器提供了一個框架API,供應用程式在本地程序中呼叫,這裡是PackageManager類。在內部,此類必須獲得到系統伺服器中相應服務的連線。為了實現這一點,在啟動時,系統伺服器在服務管理器(由init啟動的守護行程)中以定義良好的名稱釋出每個服務。應用程式程序中的PackageManager使用相同的名稱檢索從服務管理器到其系統服務的連線。
釋出並與系統服務互動。
一旦PackageManager與其系統服務連線,它就可以對其進行呼叫。大多數對PackageManager的應用程式呼叫都使用Android的Binder IPC機制實現為程序間通訊,在這種情況下,呼叫系統伺服器中的PackageManagerService實現。PackageManagerService的實現仲裁所有使用者端應用程式之間的互動,並維護多個應用程式所需的狀態。
大部分情況下,Android包括一個提供標準Linux功能的Linux核心。作為作業系統,Android最有趣的方面是如何使用現有的Linux功能。然而,Android系統也依賴於Linux的一些重要擴充套件。
移動裝置上的電源管理與傳統計算系統不同,因此Android為Linux新增了一個新功能,稱為喚醒鎖(也稱為掛起阻止程式),用於管理系統如何進入睡眠狀態。
在傳統的計算系統上,系統可能處於兩種電源狀態之一:正在執行並準備好使用者輸入,或者處於深度睡眠狀態,在沒有外部中斷(如按下電源鍵)的情況下無法繼續執行。執行時,可以根據需要開啟或關閉輔助硬體,但CPU本身和硬體的核心部分必須保持通電狀態,以處理傳入的網路流量和其他此類事件。進入低功耗睡眠狀態相對來說很少發生:要麼是通過使用者明確地將系統置於睡眠狀態,要麼是由於使用者不活動的時間間隔較長而進入睡眠狀態。要退出此睡眠狀態,需要來自外部源的硬體中斷,例如按下鍵盤上的按鈕,此時裝置將醒來並開啟螢幕。
移動裝置使用者有不同的期望。儘管使用者可以以一種看起來像讓裝置進入睡眠的方式關閉螢幕,但傳統的睡眠狀態實際上並不理想。當裝置的螢幕關閉時,裝置仍然需要能夠工作:它需要能夠接收電話、接收和處理傳入聊天訊息的資料,以及許多其他事情。
與傳統電腦相比,人們對移動裝置螢幕的開啟和關閉要求也更高。移動互動往往會在一天中出現很多短時間:你收到一條訊息,開啟裝置檢視它,也許會傳送一句話的回覆,你遇到朋友遛狗,開啟裝置給她拍照。在這種典型的移動使用中,從拉出裝置到準備好使用的任何延遲都會對使用者體驗產生顯著的負面影響。
考慮到這些要求,一個解決方案是,當裝置的螢幕關閉時,不要讓CPU進入睡眠狀態,這樣它就可以隨時重新開啟。畢竟,核心確實知道什麼時候沒有為任何執行緒安排工作,Linux(以及大多數作業系統)將自動使CPU空閒,在這種情況下使用更少的功率。然而,空閒CPU與真正的睡眠不同。例如:
1、在許多晶片組上,空閒狀態比真正的睡眠狀態使用的功率要多得多。
2、如果某些工作碰巧可用,即使該工作不重要,空閒的CPU也可以隨時喚醒。
3、僅僅讓CPU空閒並不意味著你可以關閉真正睡眠中不需要的其他硬體。
Android上的喚醒鎖允許系統進入更深層次的睡眠模式,而無需像關閉螢幕這樣的明確使用者操作。帶有喚醒鎖的系統的預設狀態是裝置處於睡眠狀態。當裝置執行時,為了防止它重新進入睡眠狀態,需要保持喚醒鎖。
當螢幕開啟時,系統始終保持一個喚醒鎖,防止裝置進入睡眠狀態,因此它將保持執行,正如我們預期的那樣。然而,當螢幕關閉時,系統本身通常不會保持喚醒鎖,因此只有當其他東西保持喚醒鎖時,系統才會保持睡眠狀態。當沒有更多的喚醒鎖時,系統進入睡眠狀態,並且只有在硬體中斷的情況下才能退出睡眠。
一旦系統進入睡眠狀態,硬體中斷將再次喚醒它,就像在傳統作業系統中一樣。這種中斷的一些來源是基於時間的警報、來自蜂窩無線電的事件(例如來電)、傳入的網路流量以及按下某些硬體按鈕(例如電源按鈕)。這些事件的中斷處理程式需要對標準Linux進行一次更改:它們需要獲得一個初始喚醒鎖,以便在系統處理中斷後保持系統執行。
中斷處理程式獲取的喚醒鎖必須保持足夠長的時間,以便將控制權從堆疊向上轉移到核心中的驅動程式,該驅動程式將繼續處理事件。然後,核心驅動程式負責獲取自己的喚醒鎖,之後可以安全地釋放中斷喚醒鎖,而不會有系統返回睡眠的風險。
如果驅動程式隨後要將此事件傳遞到使用者空間,則需要進行類似的握手。驅動必須確保其繼續保持喚醒鎖,直到將事件傳遞給等待的使用者程序,並確保有機會獲得自己的喚醒鎖。該流也可以在使用者空間中的子系統中繼續;只要有東西持有喚醒鎖,我們就繼續執行所需的處理以響應事件。然而,一旦不再保持喚醒鎖,整個系統就會返回睡眠狀態,所有處理都會停止。
Linux包括一個記憶體不足殺手(Out-Of-Memory Killer),它試圖在記憶體極低時恢復。現代作業系統記憶體不足的情況是模糊的。使用分頁和交換,應用程式本身很少會出現記憶體不足的故障。然而,核心仍然會遇到這樣一種情況,即它在需要時無法找到可用的RAM頁面,這不僅是為了新的分配,而且是在交換或分頁當前正在使用的某個地址範圍時。
在這樣一個記憶體不足的情況下,標準的Linux記憶體不足殺手是尋找RAM的最後手段,這樣核心就可以繼續它正在做的任何事情。這是通過給每個程序分配一個「壞」級別來完成的,並簡單地殺死被認為是最壞的程序。程序的好壞取決於程序使用的RAM數量、執行時間以及其他因素;目標是殺死希望不是關鍵的大型程序。
Android給記憶體不足殺手帶來了特別的壓力。它沒有交換空間,因此在記憶體不足的情況下更常見:除了從最近使用的儲存中刪除對映的乾淨RAM頁面之外,沒有辦法緩解記憶體壓力。即便如此,Android使用標準的Linux設定來過度提交記憶體,也就是說,允許在RAM中分配地址空間,而不保證有可用的RAM來支援它。過度提交是優化記憶體使用的一個非常重要的工具,因為它通常用於mmap大型檔案(如可執行檔案),只需要將該檔案中的全部資料的一小部分載入到RAM中。
在這種情況下,現有的Linux記憶體不足殺手並不能很好地發揮作用,因為它更多的是作為最後的手段,而且很難正確識別要殺死的好程序。事實上,Android在很大程度上依賴於定期執行的記憶體不足殺手來獲取程序並做出正確的選擇。
為了解決這個問題,Android向核心引入了自己的記憶體不足殺手,具有不同的語意和設計目標。Android記憶體不足殺手執行得更為積極:每當RAM變得「低」時低RAM由一個可調引數標識,該引數指示核心中有多少可用的空閒和快取RAM是可接受的。當系統低於該限制時,記憶體不足殺手會執行以從其他地方釋放RAM。目標是確保系統永遠不會進入糟糕的分頁狀態,這可能會在前臺應用程式爭奪RAM時對使用者體驗產生負面影響,因為由於不斷的分頁輸入和輸出,它們的執行速度會變慢。
Android的記憶體不足殺手並沒有試圖猜測應該殺死哪些程序,而是非常嚴格地依賴使用者空間提供給它的資訊。傳統的Linux記憶體不足殺手有一個逐程序的oom_adj引數,可以用來通過修改程序的總體不良分數來引導它走向最佳的程序。Android的記憶體不足殺手使用相同的引數,但作為一個嚴格的順序:具有較高oom_adj的程序總是在具有較低的程序之前被殺死。
Dalvik在Android上實現Java語言環境,負責執行應用程式及其大部分系統程式碼。從包管理器到視窗管理器,再到活動管理器,幾乎所有系統服務程序都是用Dalvik執行的Java語言程式碼實現的。
然而,Android並不是傳統意義上的Java語言平臺。Android應用程式中的Java程式碼是以Dalvik的位元組碼格式提供的,基於序號產生器,而不是Java傳統的基於堆疊的位元組碼。Dalvik的位元組碼格式允許更快的解釋,同時仍然支援JIT(Just-in-Time,實時)編譯。通過使用字串池和其他技術,Dalvik位元組碼在磁碟和RAM中的空間效率也更高。
在編寫Android應用程式時,原始碼是用Java編寫的,然後使用傳統Java工具編譯成標準Java位元組碼。然後,Android引入了一個新步驟:將Java位元組碼轉換為Dalvik更緊湊的位元組碼錶示。它是應用程式的Dalvik位元組碼版本,打包為最終的應用程式二進位制檔案,並最終安裝在裝置上。
Android的系統架構在很大程度上依賴於Linux的系統原語,包括記憶體管理、安全性和跨安全邊界的通訊。它不使用Java語言作為核心作業系統概念,幾乎沒有試圖抽象出底層Linux作業系統的這些重要方面。
特別值得注意的是Android對程序的使用。Android的設計不依賴Java語言實現應用程式和系統之間的隔離,而是採用傳統的作業系統程序隔離方法。這意味著每個應用程式都在其自己的Linux程序中執行,並有自己的Dalvik環境,系統伺服器和其他用Java編寫的平臺核心部分也是如此。
使用程序進行這種隔離允許Android利用Linux的所有功能來管理程序,從記憶體隔離到程序離開時清理與程序相關的所有資源。除了程序之外,Android完全依賴於Linux的安全特性,而不是使用Java的SecurityManager架構。
Linux程序和安全性的使用大大簡化了Dalvik環境,因為它不再負責系統穩定性和健壯性的這些關鍵方面。不巧的是,它還允許應用程式在實現中自由使用本機程式碼,這對於通常使用基於C++的引擎構建的遊戲尤為重要。
像這樣混合程序和Java語言確實會帶來一些挑戰。即使是在現代移動硬體上,建立一個全新的Java語言環境也需要一秒鐘的時間。回想一下Android的設計目標之一,即能夠以200毫秒的目標快速啟動應用程式。要求為這個新應用程式啟動一個新的Dalvik程序將遠遠超出預算。即使不需要初始化新的Java語言環境,在移動硬體上也很難實現200毫秒的啟動。
這個問題的解決方案是我們前面簡要提到的合子本地守護行程。Zygote負責啟動和初始化Dalvik,直到它可以開始執行用Java編寫的系統或應用程式程式碼。所有新的基於Dalvik的程序(系統或應用程式)都是從合子派生出來的,允許它們在已經準備好的環境中開始執行。
Zygote帶來的不僅僅是Dalvik,還預載入了系統和應用程式中常用的Android框架的許多部分,以及載入資源和其他經常需要的東西。
請注意,從Zygote建立新程序需要一個Linux fork,但沒有exec呼叫。新的程序是原始Zygote程序的複製品,其所有的預初始化狀態都已設定好並準備就緒。下圖說明了一個新的Java應用程式程序如何與原始的Zygote程序相關。在fork之後,新程序有自己獨立的Dalvik環境,儘管它通過寫頁面上的拷貝與Zygote共用所有預載入和初始化的資料。現在剩下的就是讓新執行的程序準備就緒,給它正確的標識(UID等),完成需要啟動執行緒的Dalvik初始化,並載入要執行的應用程式或系統程式碼。
除了發射速度,Zygote還帶來了另一個好處。因為只有一個fork用於從中建立程序,所以初始化Dalvik和預載入類和資源所需的大量髒RAM頁面可以在Zygote及其所有子程序之間共用。這種共用對於Android環境尤其重要,因為在Android環境中無法進行交換;可以從「磁碟」(快閃記憶體)按需分頁清理頁面(如可執行程式碼)。然而,任何髒頁必須在RAM中保持鎖定,無法將它們調出到「磁碟」。
Android的系統設計主要圍繞應用程式之間以及系統本身不同部分之間的程序隔離進行。這需要大量的程序間通訊來協調不同程序之間的關係,可能需要大量的工作來實現和正確處理。Android的Binder程序間通訊機制是一個豐富的通用IPC工具,大多數Android系統都是建立在它之上的。
Binder架構分為三層,如下圖所示。堆疊底部是一個核心模組,它實現了實際的跨程序互動,並通過核心的ioctl函數將其公開,ioctl是一個通用核心呼叫,用於向核心驅動程式和模組傳送自定義命令。在核心模組之上是一個基本的物件導向使用者空間API,允許應用程式通過IBinder和Binder類建立IPC端點並與之互動。頂部是一個基於介面的程式設計模型,其中應用程式宣告了它們的IPC介面,而不需要擔心IPC在底層如何發生的細節。
Binder IPC架構。
Binder沒有使用現有的LinuxIPC工具,例如管道,而是包含一個特殊的核心模組,它實現了自己的IPC機制。BinderIPC模型與傳統的Linux機制有很大的不同,因此它不能在使用者空間中高效地在它們之上實現。此外,Android不支援大多數用於跨程序互動的System V原語(號誌、共用記憶體段、訊息佇列),因為它們不提供從錯誤或惡意應用程式中清除資源的強大語意。
Binder使用的基本IPC模型是RPC(遠端過程呼叫)。也就是說,傳送程序向核心提交完整的IPC操作,該操作在接收程序中執行;傳送方可以在接收方執行時阻塞,從而允許從呼叫返回結果。(傳送方可以選擇指定它們不應阻塞,繼續與接收方並行執行。)因此,繫結IPC是基於訊息的,就像System V訊息佇列一樣,而不是基於Linux管道中的流。Binder中的訊息被稱為事務,在更高階別上可以被視為跨程序的函數呼叫。
使用者空間提交給核心的每個事務都是一個完整的操作:它標識了操作的目標、傳送者的身份以及正在傳遞的完整資料。核心確定接收該事務的適當程序,並將其傳遞給程序中的等待執行緒。
下圖說明了交易的基本流程。發起程序中的任何執行緒都可以建立一個標識其目標的事務,並將其提交給核心。核心生成事務的副本,並將傳送者的身份新增到其中。它確定哪個程序負責事務的目標,並喚醒程序中的執行緒以接收它。一旦接收程序執行,它將確定事務的適當目標並交付它。
基本系結IPC事務。
對於這裡的討論,我們將事務資料在系統中的移動方式簡化為兩個副本,一個副本到核心,一個到接收程序的地址空間。實際的實現在一個副本中完成。對於每個可以接收事務的程序,核心都會建立一個共用記憶體區域。在處理事務時,它首先確定將接收該事務的程序,並將資料直接複製到該共用地址空間中。
請注意,上圖中的每個程序都有一個「執行緒池」,是由使用者空間建立的一個或多個執行緒,用於處理傳入事務。核心將把每個傳入的事務分派給當前正在該程序的執行緒池中等待工作的執行緒。然而,從傳送程序呼叫核心並不需要來自執行緒池,程序中的任何執行緒都可以自由啟動事務,如圖中的Ta。
我們已經看到,給核心的事務標識了一個目標物件;然而,核心必須確定接收程序。為了實現這一點,核心跟蹤每個程序中的可用物件,並將它們對映到其他程序,如下圖所示,在這裡看到的物件只是該程序地址空間中的位置。核心只跟蹤這些物件地址,沒有任何意義;它們可以是C資料結構、C++物件或位於該程序地址空間中的任何其他物件的位置。
對遠端程序中物件的參照由整數控制程式碼標識,很像Linux檔案描述符。例如,考慮程序2中的Object2a,核心知道它與程序2關聯,並且核心在程序1中為它分配了控制程式碼2。因此,程序1可以將事務提交給目標為其控制程式碼2的核心,核心可以從中確定該事務正在傳送給程序2,特別是該程序中的Object2b。
繫結跨程序物件對映。
與檔案描述符一樣,一個程序中控制程式碼的值與另一個程序的值的含義不同。例如,在上圖中,我們可以看到,在程序1中,控制程式碼值2表示Object2a;然而,在程序2中,相同的控制程式碼值2標識Object1a。此外,如果核心沒有為另一個程序分配控制程式碼,一個程序就不可能存取另一個過程中的物件。同樣在上圖中,我們可以看到核心知道程序2的Object2b,但沒有為程序1分配控制程式碼。因此,程序1沒有存取該物件的路徑,即使核心為其他程序分配了控制程式碼。
這些控制程式碼到物件的關聯首先是如何建立的?與Linux檔案描述符不同,使用者程序不直接請求控制程式碼。相反,核心根據需要為程序分配控制程式碼。該過程如下圖所示。在這裡,我們將檢視上一圖中從程序2到程序1對Object1b的參照是如何產生的。關鍵是交易如何在系統中流動,從圖底部的左到右。下圖所示的關鍵步驟是:
1、程序1建立包含本地地址Object1b的初始事務結構。
2、程序1向核心提交事務。
3、核心檢視事務中的資料,找到地址Object1b,併為其建立一個新條目,因為它以前不知道這個地址。
4、核心使用事務的目標Handle 2來確定這是針對程序2中的Object2a的。
5、核心現在重寫事務頭以適合程序2,將其目標更改為地址Object2a。
6、核心同樣重寫目標程序的事務資料;這裡它發現程序2還不知道Object1b,因此為它建立了一個新的控制程式碼3。
7、重寫的事務被傳送到程序2以供執行。
8、在接收到事務後,程序發現有一個新的控制程式碼3,並將其新增到其可用控制程式碼表中。
在程序之間傳輸Binder物件。
如果一個事務中的一個物件已經為接收程序所知,那麼這個流程是相似的,除了現在核心只需要重寫該事務,以便它包含先前分配的控制程式碼或接收程序的本地物件指標。這意味著多次將同一個物件傳送到程序將始終導致相同的標識,而不像Linux檔案描述符那樣,多次開啟同一個檔案將每次分配不同的描述符。當這些物件在程序之間移動時,Binder IPC系統保持唯一的物件標識。
Binder架構本質上為Linux引入了一個基於能力的安全模型。每個Binder物件都是一種功能。將物件傳送到另一個程序將授予該程序該能力。然後,接收過程可以利用物件提供的任何特徵。一個程序可以將一個物件傳送到另一個程序,然後從任何程序接收一個物件,並識別接收到的物件是否與它最初傳送的物件完全相同。
大多數使用者空間程式碼不會直接與Binder核心模組互動。相反,有一個使用者空間物件導向的庫,它提供了一個更簡單的API。這些使用者空間API的第一級相當直接地對映到我們迄今為止所討論的核心概念,以三個類的形式:
1、IBinder是Binder物件的抽象介面。它的關鍵方法是transaction,它將事務提交給物件。接收事務的實現可以是本地程序或另一程序中的物件,如果它在另一個程序中,將通過前面討論的繫結器核心模組傳遞給它。
2、Binder是一個具體的Binder物件。實現Binder子類為您提供了一個可由其他程序呼叫的類,關鍵方法是onTransact,它接收傳送給它的事務。Binder子類的主要職責是檢視它在這裡接收的事務資料並執行適當的操作。
3、Parcel是用於讀取和寫入Binder事務中的資料的容器。它有讀取和寫入型別化資料整數、字串和陣列的方法,但最重要的是,它可以讀取和寫入對任何IBinder物件的參照,使用適當的資料結構讓核心理解並跨程序傳輸該參照。
下圖描述了這些類是如何協同工作的,這裡我們看到Binder1b和Binder2a是具體的Binder子類的範例。為了執行IPC,程序現在建立一個包含所需資料的Parcel,並通過另一個我們尚未看到的類BinderProxy傳送它。每當程序中出現新控制程式碼時,都會建立該類,從而提供IBinder的實現,該實現的transaction方法為呼叫建立適當的事務並將其提交給核心。
因此,我們之前看到的核心事務結構在使用者空間API中被分割:目標由BinderProxy表示,其資料儲存在Parcel中。正如我們前面所看到的,事務通過核心,在接收過程中出現在使用者空間中時,它的目標用於確定適當的接收繫結器物件,而Parcel是根據其資料構建的,並傳遞給該物件的onTransact方法。這三個類現在使編寫IPC程式碼變得相當容易:
1、來自Binder的子類。
2、實現onTransact來解碼和執行傳入呼叫。
3、實現相應的程式碼以建立可傳遞給該物件的事務處理方法的Parcel。
這項工作的大部分在最後兩個步驟中,是解組(unmarshalling)和編組(marshalling)程式碼,需要將我們希望使用簡單方法呼叫程式設計的方式轉換為執行IPC所需的操作。
BinderIPC的最後一部分是最常用的,一個基於高階介面的程式設計模型。這裡我們不再處理繫結物件和地塊資料,而是從介面和方法的角度來思考。
該層的主要部分是一個名為AIDL(用於Android介面定義語言)的命令列工具。這個工具是一個介面編譯器,它對介面進行抽象描述,並從中生成定義該介面所需的原始碼,並實現對其進行遠端呼叫所需的適當編組和解編組程式碼。
下面程式碼顯示了AIDL中定義的介面的一個簡單範例,這個介面稱為IExample,包含一個方法print,接受一個String引數。
package com.example
interface IExample
{
void print(Str ing msg);
}
上述程式碼中的介面描述由AIDL編譯,生成下圖中所示的三個Java語言類:
1、IExample提供了Java語言介面定義。
2、IExample.Stub是此介面實現的基礎類別。它繼承自Binder,這意味著它可以是IPC呼叫的接收者;它繼承了IExample,因為這是正在實現的介面。此類的目的是執行解組:將傳入的onTransact呼叫轉換為IExample的適當方法呼叫。然後它的子類只負責實現IExample方法。
3、IExample.Proxy是IPC呼叫的另一端,負責執行呼叫的編組。它是IExample的一個具體實現,實現它的每個方法,將呼叫轉換為適當的Parcel內容,並通過與之通訊的IBinder上的事務呼叫將其傳送出去。
基於AIDL的繫結IPC的完整路徑如下圖所示:
Android提供的應用程式模型與Linux shell中的正常命令列環境,甚至是從圖形化使用者介面啟動的應用程式非常不同。應用程式不是具有主入口點的可執行檔案,它是組成該應用程式的所有東西的容器:它的程式碼、圖形資源、關於它對系統是什麼的宣告以及其他資料。
按照慣例,Android應用程式是一個擴充套件名為apk的檔案,適用於Android Package。這個檔案實際上是一個普通的zip存檔,包含了應用程式的所有內容。apk的重要內容包括:
1、描述應用程式是什麼、它做什麼以及如何執行它的清單。清單必須提供應用程式的包名稱、Java風格的範圍字串(例如com.android.app.calculator),
其唯一地標識它。
2、應用程式所需的資源,包括它向用戶顯示的字串、佈局和其他描述的XML資料、圖形點陣圖等。
3、程式碼本身,可以是Dalvik位元組碼以及原生庫程式碼。
4、簽名資訊,安全地識別作者。
應用程式的關鍵部分是它的清單(manifest)——顯示為一個名為AndroidManifest.xml的預編譯XML檔案,在apk的zip名稱空間的根中。假設電子郵件應用程式的完整清單宣告範例如下圖所示:它允許您檢視和撰寫電子郵件,還包括將本地電子郵件儲存與伺服器同步所需的元件,即使使用者當前不在應用程式中。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.email">
<application>
<activity android:name="com.example.email.MailMainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category="" android:name="android.intent.categor y.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.example.email.ComposeActivity">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category="" android:name="android.intent.categor y.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<service="" android:name="com.example.email.SyncSer vice">
</service>
<receiver android:name="com.example.email.SyncControlReceiver">
<intent-filter>
<action android:name="android.intent.action.DEVICE STORAGE LOW" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DEVICE STORAGE OKAY" />
</intent-filter>
</receiver>
<provider android:name="com.example.email.EmailProvider" android:author="" ities="com.example.email.provider.email">
</provider>
</application>
</manifest>
Android應用程式沒有一個簡單的主入口點,當用戶啟動它們時就會執行。相反,它們在清單的<application>標籤下發布各種入口點,描述應用程式可以做的各種事情。這些入口點表示為四種不同的型別,定義了應用程式可以提供的核心行為型別:活動、接收者、服務和內容提供者。我們展示的範例顯示了一些活動和其他元件型別的一個宣告,但應用程式可能宣告其中的任何一個或多個。
應用程式可以包含的四種不同元件型別中的每一種在系統中具有不同的語意和用途。在所有情況下,android:name屬性都提供實現該元件的應用程式程式碼的Java類名,系統將在需要時對其進行範例化。
包管理器(package manager)是Android的一部分,用於跟蹤所有應用程式包。它解析每個應用程式的清單,收集並索引在其中找到的資訊。有了這些資訊,它就為客戶機提供了查詢當前安裝的應用程式並檢索相關資訊的工具。它還負責安裝應用程式(為應用程式建立儲存空間並確保apk的完整性)以及解除安裝所需的一切(清理與先前安裝的應用程式相關的一切)。
應用程式在清單中靜態宣告它們的入口點,這樣它們就不需要在安裝時執行向系統註冊它們的程式碼。這種設計使系統在許多方面更加健壯:安裝應用程式不需要執行任何應用程式程式碼,應用程式的頂級功能始終可以通過檢視清單來確定,沒有必要保留一個單獨的資料庫來儲存可能與應用程式的實際功能不同步(例如跨更新)的應用程式資訊,並且它保證在解除安裝應用程式後不會留下任何有關應用程式的資訊。這種去中心化的方法是為了避免Windows的集中登入檔導致的許多此類問題。
將應用程式分解為細粒度元件也有助於我們的設計目標,即支援應用程式之間的互操作和共同作業。應用程式可以釋出提供特定功能的自身片段,其他應用程式可以直接或間接使用這些片段。
在包管理器之上是另一個重要的系統服務——活動管理器(activity manager)。雖然包管理器負責維護所有已安裝應用程式的靜態資訊,但活動管理器確定這些應用程式應在何時、何處以及如何執行。儘管有它的名字,它實際上負責執行所有四種型別的應用程式元件,併為每種元件實現適當的行為。
活動是通過使用者介面直接與使用者互動的應用程式的一部分。當用戶在其裝置上啟動應用程式時,實際上是應用程式內部的一個活動,該活動已被指定為此類主入口點,應用程式在其活動中實現負責與使用者互動的程式碼。
上面xml程式碼所示的範例電子郵件清單包含兩個活動。第一個是主郵件使用者介面,允許使用者檢視他們的郵件,第二個是用於編寫新訊息的單獨介面,第一個郵件活動被宣告為應用程式的主要入口點,即當用戶從主螢幕啟動它時將啟動的活動。
由於第一個活動是主活動,因此它將作為使用者可以從主應用程式啟動器啟動的應用程式顯示給使用者。如果他們這樣做,系統將處於下圖所示的狀態,左側的活動管理器在其流程中建立了一個內部ActivityRecord範例,以跟蹤活動。這些活動中的一個或多個被組織到稱為任務的容器中,這些容器大致對應於使用者作為應用程式的體驗。此時,活動管理器已啟動電子郵件應用程式的程序和MainMailActivity的範例,以顯示其主UI,該UI與相應的ActivityRecord關聯。此活動處於稱為「已恢復」的狀態,因為它現在位於使用者介面的前臺。
如果使用者現在要離開電子郵件應用程式(不退出它)並啟動相機應用程式拍照,我們將處於下圖所示的狀態。請注意,我們現在有一個新的相機程序正在執行相機的主要活動,它在活動管理器中有一個關聯的ActivityRecord,是恢復的活動。以前的電子郵件活動也發生了一些有趣的事情:它現在停止了,ActivityRecord儲存了該活動的儲存狀態,而不是恢復。
當一個活動不再在前臺時,系統會要求它「儲存其狀態」這涉及到應用程式建立表示使用者當前看到的內容的最少數量的狀態資訊,並將這些資訊返回給活動管理器,並儲存在系統伺服器程序中與該活動關聯的ActivityRecord中。活動的儲存狀態通常很小,例如包含在電子郵件中捲動的位置,但不包含訊息本身,應用程式會將其儲存在持久儲存中的其他位置。
回想一下,儘管Android確實需要分頁,它可以從磁碟上的檔案(如程式碼)對映乾淨的RAM中進行分頁,但它並不依賴交換空間,意味著應用程式程序中的所有髒RAM頁都必須留在RAM中。將電子郵件的主要活動狀態安全地儲存在活動管理器中,可以使系統在處理交換提供的記憶體時恢復一些靈活性。
例如,如果相機應用程式開始需要大量RAM,系統可以簡單地擺脫電子郵件程序,如下圖所示。ActivityRecord及其寶貴的儲存狀態仍被活動管理器安全地儲存在系統伺服器程序中。由於系統伺服器程序承載了Android的所有核心繫統服務,因此它必須始終保持執行,因此儲存在這裡的狀態將在我們需要的時候一直保持。
我們的範例電子郵件應用程式不僅具有主UI的活動,而且還包括另一個ComposeActivity。應用程式可以宣告任意數量的活動,可以幫助組織應用程式的實現,但更重要的是,它可以用於實現跨應用程式互動。例如,這是Android跨應用程式共用系統的基礎,這裡的ComposeActivity正在參與其中。如果使用者在使用相機應用程式時決定要共用她拍攝的照片,我們的電子郵件應用程式的ComposeActivity是它擁有的共用選項之一。如果選擇此選項,將啟動該活動並提供要共用的圖片。
在上圖所示的活動狀態下執行該股票期權將導致下圖所示的新狀態。有一些重要事項需要注意:
1、必須重新啟動電子郵件應用程式的程序,才能執行其ComposeActivity。
2、但是,舊的MailMainActivity此時不會啟動,因為它不需要。這減少了RAM的使用。
3、攝像機的任務現在有兩條記錄:我們剛剛進入的原始CameraMainActivity和現在顯示的新ComposeActivity。對於使用者來說,這些仍然是一個有凝聚力的任務:通過電子郵件傳送圖片是當前與他們互動的相機。
4、新的ComposeActivity位於頂部,因此恢復;先前的CameraMainActivity不再位於頂部,因此其狀態已儲存。如果其他地方需要RAM,此時我們可以安全地退出其程序。
最後,讓我們看看,如果使用者在最後一個狀態(即撰寫電子郵件以共用圖片)下離開相機任務並返回到電子郵件應用程式,會發生什麼情況。下圖顯示了系統將處於的新狀態。請注意,我們已將電子郵件任務及其主要活動帶回前臺,使得MailMainActivity成為前臺活動,但應用程式程序中當前沒有執行它的範例。
為了返回到上一個活動,系統建立一個新範例,將其返回到舊範例提供的先前儲存的狀態。此將活動從其儲存狀態恢復的操作必須能夠將活動恢復到使用者上次離開時的相同視覺狀態。為此,應用程式將在其儲存狀態中查詢使用者所在的訊息,從其持久儲存中載入該訊息的資料,然後應用任何已儲存的捲動位置或其他使用者介面狀態。
服務有兩個不同的身份:
1、它可以是一個獨立的長時間執行的後臺操作。以這種方式使用服務的常見範例包括:執行背景音樂播放、在使用者處於其他應用程式中時保持活動網路連線(例如與IRC伺服器)、在後臺下載或上傳資料等。
2、它可以作為其他應用程式或系統與應用程式進行豐富互動的連線點。應用程式可以使用它為其他應用程式提供安全API,例如執行影象或音訊處理、將文字轉換為語音等。
前面所示的範例電子郵件清單包含一個用於執行使用者郵箱同步的服務。一個常見的實現將安排服務以固定的間隔執行,例如每15分鐘執行一次,在該執行時啟動服務,並在完成時停止自身。
這是第一種服務風格的典型使用,即長時間執行的後臺操作。下圖顯示了這種情況下系統的狀態,這非常簡單。活動管理器建立了一個ServiceRecord來跟蹤服務,注意到它已經啟動,因此在應用程式的程序中建立了它的SyncService範例。在此狀態下,服務處於完全活動狀態(如果不持有喚醒鎖,則禁止整個系統進入睡眠狀態),並且可以自由地做它想做的事情。在這種狀態下,應用程式的程序可能會離開,例如,如果程序崩潰,但活動管理器將繼續維護其ServiceRecord,並可以在需要時決定重新啟動服務。
為了瞭解如何將服務用作與其他應用程式互動的連線點,我們假設我們希望擴充套件現有的SyncService,使其具有允許其他應用程式控制其同步間隔的API。我們需要為這個API定義一個AIDL介面,如下所示:
package com.example.email
interface ISyncControl
{
int getSyncInterval();
void setSyncInterval(int seconds);
}
要使用此功能,另一個程序可以繫結到我們的應用程式服務,從而存取其介面,將在兩個應用程式之間建立連線,如下圖所示。此過程的步驟如下:
1、使用者端應用程式告訴活動管理器它希望繫結到服務。
2、如果服務尚未建立,活動管理器將在服務應用程式的程序中建立它。
3、服務將其介面的IBinder返回給活動管理器,活動管理器現在將該IBinder儲存在其ServiceRecord中。
4、現在,活動管理器擁有了服務IBinder,可以將其傳送回原始使用者端應用程式。
5、現在具有服務的IBinder的使用者端應用程式可以繼續在其介面上進行它想要的任何直接呼叫。
接收者是發生的(通常是外部)事件的接收者,通常在後臺和正常使用者互動之外。接收器在概念上與應用程式在發生有趣的事情(警報響起、資料連線更改等)時顯式註冊回撥相同,但不要求應用程式執行以接收事件。
上述所示的範例電子郵件清單包含一個接收器,應用程式可以在裝置的儲存空間變低時發現該接收器,以便停止同步電子郵件(這可能會消耗更多儲存空間)。當裝置的儲存量變低時,系統將傳送儲存量低的廣播程式碼,以傳送給對事件感興趣的所有接收器。
下圖說明了活動管理器如何處理此類廣播,以便將其傳送給感興趣的接收者。它首先向包管理器請求對事件感興趣的所有接收者的列表,該列表被放置在表示該廣播的廣播記錄中。然後,活動管理器將繼續遍歷列表中的每個條目,讓每個相關應用程式的程序建立並執行相應的接收方類。
接收器僅作為一次性操作執行。當一個事件發生時,系統會發現任何對它感興趣的接收者,並將該事件傳遞給他們,一旦他們消費了該事件,他們就完成了。沒有像我們在其他應用程式元件中看到的那樣的ReceiverRecord,因為特定的接收器在單個廣播期間只是一個臨時實體。每次向接收器元件傳送新廣播時,都會建立該接收器類的新範例。
我們的最後一個應用程式元件,內容提供者,是應用程式用來相互交換資料的主要機制。與內容提供者的所有互動都是通過使用content:scheme的URI進行的;URI的許可權用於找到要與之互動的正確內容提供者實現。
例如,在圖10-51的電子郵件應用程式中,內容提供商指定其許可權為com.example.email.provider.email。因此,在此內容提供商上執行的URI將從content://com.example.email.provider.email/
開始,URI的字尾由提供者自己解釋,以確定正在存取其中的哪些資料。在這裡的範例中,一個常見的約定是URI:content://com.example.email.provider.email/messages
,表示所有電子郵件的列表,而content://com.example.email.provider.email/messages/1
提供對鍵號1處的單個訊息的存取。
要與內容提供者互動,應用程式總是要經過一個名為ContentResolver的系統API,其中大多數方法都有一個初始URI引數,指示要操作的資料。最常用的ContentResolver方法之一是query,它對給定的URI執行資料庫查詢,並返回一個Cursor以檢索結構化結果。例如,檢索所有可用電子郵件的摘要如下所示:
query("content://com.example.email.provider.email/messages")
儘管這在應用程式中看起來不一樣,但當他們使用內容提供者時,實際發生的事情與繫結到服務有許多相似之處。下圖說明了系統如何處理我們的查詢範例:
1、應用程式呼叫ContentResolver。查詢以啟動操作。
2、URI的許可權被交給活動管理器,以便它(通過包管理器)找到適當的內容提供者。
3、如果內容提供商尚未執行,則會建立它。
4、一旦建立,內容提供者將其IBinder返回給活動管理器,實現系統的IContentProvider介面。
5、將內容提供者的繫結返回到ContentResolver。
6、內容解析器現在可以通過呼叫AIDL介面上的適當方法來完成初始查詢操作,並返回遊標結果。
在前面所示的應用程式清單中,我們尚未討論的一個細節是活動和接收方宣告中包含的<intent-filter>標記。這是Android的意圖功能的一部分,是不同應用程式如何識別彼此以便能夠互動和協同工作的基石。
意圖是Android用來發現和識別活動、接收者和服務的機制。它在某些方面類似於Linuxshell的搜尋路徑,shell使用該路徑查詢多個可能的目錄,以便找到與給定的命令名匹配的可執行檔案。
意向有兩種主要型別:顯性和隱性。顯式意圖是直接標識單個特定應用程式元件的意圖;在Linux shell術語中,它相當於為命令提供絕對路徑。這種意圖的最重要部分是一對命名元件的字串:目標應用程式的包名和該應用程式中元件的類名。現在回到應用程式前面所示中的活動,該元件的明確意圖將是包名為com.example的元件,電子郵件和類名com.example.email.MailMainActivity。
顯式意圖的包和類名足以唯一標識目標元件,例如上面提及的主要電子郵件活動。從包名稱中,包管理器可以返回應用程式所需的所有資訊,例如在哪裡找到程式碼。從類名中,我們知道要執行程式碼的哪一部分。
隱含意圖是描述所需元件的特性,而不是元件本身的特性;在Linux shell術語中,這相當於向shell提供一個命令名,shell將其與搜尋路徑一起用於查詢要執行的具體命令。找到與隱含意圖匹配的元件的過程稱為意圖解析。
傳統上,在作業系統中,應用程式被視為代表使用者作為使用者執行的程式碼。此行為是從命令列繼承的,在命令列中,執行ls命令,並期望它作為身份(UID)執行,具有與你在系統上相同的存取許可權。同樣,當使用圖形化使用者介面啟動你想要玩的遊戲時,該遊戲將有效地作為你的身份執行,可以存取你的檔案和許多其他可能不需要的東西。
然而,這並不是我們今天主要使用電腦的方式。我們執行從一些不太受信任的第三方來源獲得的應用程式,這些應用程式具有廣泛的功能,可以在其環境中執行我們幾乎無法控制的各種任務。作業系統支援的應用程式模型與實際使用的應用程式之間存在斷開。可以通過一些策略來緩解,例如區分正常使用者許可權和「管理員」使用者許可權,並在首次執行應用程式時發出警告,但這些策略並沒有真正解決潛在的斷開問題。
換言之,傳統的作業系統非常擅長保護使用者免受其他使用者的侵害,但不擅長保護使用者不受自身的侵害。所有程式都是靠使用者的力量執行的,如果其中任何一個程式行為不當,它都會對使用者造成傷害。想想看:在UNIX環境中,你會造成多大的損害?可能會洩露使用者可存取的所有資訊。你可以執行rm-rf*,為自己提供一個漂亮的、空的主目錄。如果這個程式不僅有漏洞,而且是惡意的,它可以加密所有的檔案以換取贖金。用「你的力量」執行一切是危險的!
Android試圖以一個核心前提來解決這一問題:應用程式實際上是在使用者裝置上作為來賓執行的應用程式的開發者。因此,應用程式不受任何未經使用者明確批准的敏感資訊的信任。
在Android的實現中,這一理念相當直接地通過使用者ID來表達。安裝Android應用程式時,會為其建立一個新的唯一Linux使用者ID(或UID),其所有程式碼都以該「使用者」身份執行因此,Linux使用者ID為每個應用程式建立一個沙盒,在檔案系統中有自己的隔離區域,就像它們為桌面系統上的使用者建立沙盒一樣。換句話說,Android在Linux中使用了一個現有的功能,但方式新穎。結果是更好的隔離。
Linux中的傳統程序模型是建立一個新程序的fork,然後是一個exec,用要執行的程式碼初始化該程序,然後開始執行。shell負責驅動此執行,根據需要fork和執行程序以執行shell命令。當這些命令退出時,Linux將刪除該程序。
Android使用的程序略有不同。正如前面關於應用程式的部分所討論的,活動管理器是Android中負責管理正在執行的應用程式的一部分。它協調新應用程式程序的啟動,確定將在其中執行什麼,以及何時不再需要它們。
為了啟動新流程,活動經理必須與zygote溝通。當活動管理器第一次啟動時,它會建立一個帶有合子的專用通訊端,當它需要啟動一個程序時,通過它傳送一個命令。該命令主要描述要建立的沙盒:新程序應作為其執行的UID以及將應用於它的任何其他安全限制。因此,Zygote必須以root身份執行:當它分叉時,它會為執行時的UID進行適當的設定,最後刪除root許可權並將程序更改為所需的UID。
回想一下在我們之前關於Android應用程式的討論中,活動管理器維護關於活動執行、服務、廣播和內容提供商的動態資訊。它使用這些資訊來驅動應用程式程序的建立和管理,例如,當應用程式啟動程式以新的意圖呼叫系統以啟動活動時,活動管理器負責執行新的應用程式。
在新程序中啟動活動的流程如下圖所示,圖中每個步驟的詳細資訊如下:
1、一些現有程序(如應用程式啟動程式)呼叫活動管理器,目的是描述它想要啟動的新活動。
2、活動管理器要求包管理器將意圖解析為顯式元件。
3、活動管理器確定應用程式的程序尚未執行,然後向合子請求具有適當UID的新程序。
4、Zygote執行一個分叉,建立一個自己的克隆的新程序,刪除特權併為應用程式的沙盒適當設定其UID,並在該程序中完成Dalvik的初始化,以便Java執行時完全執行。例如,它必須在分叉後像垃圾收集器一樣啟動執行緒。
5、新程序現在是一個完全啟動並執行Java環境的zygote克隆,它呼叫活動管理器,問「我應該做什麼?」
6、活動管理器返回有關它正在啟動的應用程式的完整資訊,例如在哪裡找到它的程式碼。
7、新程序載入正在執行的應用程式的程式碼。
8、活動管理器向新程序傳送任何掛起的操作,在本例中為「啟動活動X」。
9、新程序接收啟動活動的命令,範例化適當的Java類並執行它。
請注意,當我們開始此活動時,應用程式的程序可能已經在執行。在這種情況下,活動管理器將簡單地跳到最後,向程序傳送一個新命令,告訴它範例化並執行適當的元件,可能導致在應用程式中執行額外的活動範例(如果合適的話)。
活動管理器還負責確定何時不再需要程序,它跟蹤程序中執行的所有活動、接收者、服務和內容提供商,由此可以確定程序的重要性(或不重要)。
回想一下,Android核心中的記憶體不足殺手使用一個程序的作為一個嚴格的順序來確定應該首先殺死哪些程序。活動管理器負責根據程序的狀態,通過將其劃分為主要使用類別,適當設定每個程序的oom_adj。下表顯示了主要類別,最重要的類別排在第一位,最後一列顯示了分配給此類程序的典型oom_adj值。
分類 | 描述 | oom_adj |
---|---|---|
SYSTEM | 系統和守護行程程序 | −16 |
PERSISTENT | 始終執行應用程式程序 | -12 |
FOREGROUND | 當前與使用者互動 | 0 |
VISIBLE | 對使用者可見 | 1 |
PERCEPTIBLE | 使用者知道的東西 | 2 |
SERVICE | 執行後臺服務 | 3 |
HOME | 主頁/啟動器程序 | 4 |
CACHED | 未使用的程序 | 5 |
現在,當RAM變低時,系統已經設定了程序,以便記憶體不足殺手首先殺死快取的程序,以嘗試回收足夠的所需RAM,然後是主頁、服務等等。在一個特定的oom調整級別內,它會先殺死記憶體佔用較大的程序,然後再殺死記憶體佔用較小的程序。
我們現在看到了Android是如何決定何時啟動程序的,以及它如何根據重要性對這些程序進行分類的。現在我們需要決定何時退出程序,對嗎?或者我們真的需要在這裡做更多的事情嗎?答案是,我們沒有。在Android上,應用程式程序永遠不會乾淨地退出。系統只留下不需要的程序,依靠核心根據需要獲取它們。
快取程序在許多方面取代了Android缺少的交換空間。由於其他地方需要RAM,快取程序可以從活動RAM中丟擲。如果應用程式稍後需要再次執行,則可以建立一個新程序,將其恢復到使用者上次離開時所需的任何先前狀態。在幕後,作業系統正在根據需要啟動、終止和重新啟動程序,因此重要的前臺操作仍在執行,只要快取的程序的RAM不會在其他地方得到更好的使用,它們就會被保留。
目前,我們對如何管理單個Android程序有一個很好的概述。然而,還有一個更復雜的問題:程序之間的依賴關係。
舉個例子,考慮一下我們之前的相機應用程式,它儲存著已經拍攝的照片。這些圖片不是作業系統的一部分,它們由相機應用中的內容提供商實現。其他應用程式可能希望存取該圖片資料,成為相機應用程式的使用者端。
程序之間的依賴關係可以發生在內容提供者(通過對提供者的簡單存取)和服務(通過繫結到服務)之間。無論哪種情況,作業系統都必須跟蹤這些依賴關係並適當地管理程序。
程序依賴性影響兩個關鍵因素:何時建立程序(以及其中建立的元件),以及程序的oom_adj重要性。請記住,程序的重要性是其中最重要的組成部分,它的重要性也是依賴它的最重要程序的重要性。
例如,在相機應用程式的情況下,其程序和內容提供商不正常執行。它將在其他程序需要存取該內容提供商時建立。當存取攝像機的內容提供商時,攝像機程序將被認為至少與使用它的程序一樣重要。
為了計算每個程序的最終重要性,系統需要維護這些程序之間的依賴關係圖。每個程序都有當前執行的所有服務和內容提供商的列表,每個服務和內容提供商本身都有使用它的每個程序的列表。(這些列表儲存在活動管理器內的記錄中,因此應用程式不可能對它們撒謊。)遍歷程序的依賴關係圖涉及遍歷其所有內容提供者和服務以及使用它們的程序。
下圖說明了一個典型的狀態程序,考慮到它們之間的依賴關係。此範例包含兩個依賴項,基於使用相機內容提供商向電子郵件新增圖片附件。第一個是當前前臺電子郵件應用程式,它使用相機應用程式載入附件,將相機程序提升到與電子郵件應用程式相同的重要性。第二種是類似的情況,音樂應用程式使用服務在後臺播放音樂,並且在這樣做的同時依賴於存取使用者音樂媒體的媒體程序。
考慮如果上圖的狀態發生變化,使得電子郵件應用程式完成了附件載入,並且不再使用相機內容提供商,會發生什麼情況。下圖說明了程序狀態將如何改變。請注意,不再需要相機應用程式,因此它已脫離前景重要性,並降至快取級別,使相機快取也使舊地圖應用程式在快取的LRU列表中下降了一步。
這兩個範例最終說明了快取程序的重要性。如果電子郵件應用程式再次需要使用相機提供程式,則該提供程式的程序通常已作為快取程序保留。再次使用它只需要將程序設定回前臺,並重新連線到已經在那裡初始化資料庫的內容提供商。
前面章節詳細闡述了作業系統相關的概念、原理和執行機制,本章將闡述UE對作業系統的封裝。本篇以UE5.0.5的原始碼進行分析。
UE的作業系統相關介面封裝在了Core資料夾下,具體見下圖:
以上檔案中,有部分是通用介面層,如HAL、Memory等。這其中最重要的類非FGenericPlatformMisc
莫屬,它抽象了大多數平臺的通用介面。後面章節會詳細闡述之。
FGenericPlatformMisc
是UE跨平臺的重要型別,抽象了大多數作業系統的操作介面,以便其它模組實現平臺無關的操作。當然,FGenericPlatformMisc
只是宣告了一組介面,具體實現由不同的子類實現。FGenericPlatformMisc
的主要介面如下所示:
// GenericPlatformMisc.h
struct CORE_API FGenericPlatformMisc
{
// 平臺生命週期
static void PlatformPreInit();
static void PlatformInit();
static void PlatformTearDown();
static void RequestExit( bool Force );
static void RequestExitWithStatus( bool Force, uint8 ReturnCode );
static bool RestartApplication();
static bool RestartApplicationWithCmdLine(const char* CmdLine);
static void TearDown();
// 視窗/UI
static void PlatformHandleSplashScreen(bool ShowSplashScreen);
static void HidePlatformStartupScreen();
static EAppReturnType::Type MessageBoxExt( EAppMsgType::Type MsgType, const TCHAR* Text, const TCHAR* Caption );
static void ShowConsoleWindow();
static int GetMobilePropagateAlphaSetting();
// 偵錯/錯誤
static void SetGracefulTerminationHandler();
static void SetCrashHandler(void (* CrashHandler)(const FGenericCrashContext& Context));
static bool SupportsFullCrashDumps();
static uint32 GetLastError();
static void SetLastError(uint32 ErrorCode);
static void RaiseException( uint32 ExceptionCode );
static void LowLevelOutputDebugString(const TCHAR *Message);
static void VARARGS LowLevelOutputDebugStringf(const TCHAR *Format, ... );
static void SetUTF8Output();
static void LocalPrint( const TCHAR* Str );
static bool IsLocalPrintThreadSafe();
static bool HasSeparateChannelForDebugOutput();
static const TCHAR* GetSystemErrorMessage(TCHAR* OutBuffer, int32 BufferCount, int32 Error);
static void PromptForRemoteDebugging(bool bIsEnsure);
// 環境變數、路徑
static FString GetEnvironmentVariable(const TCHAR* VariableName);
static void SetEnvironmentVar(const TCHAR* VariableName, const TCHAR* Value);
FORCEINLINE static int32 GetMaxPathLength();
static const TCHAR* GetPathVarDelimiter();
static bool IsValidAbsolutePathFormat(const FString& Path);
static void NormalizePath(FString& InPath);
static void NormalizePath(FStringBuilderBase& InPath);
static const TCHAR* GetDefaultPathSeparator();
static const TCHAR* RootDir();
static TArray<FString> GetAdditionalRootDirectories();
static void AddAdditionalRootDirectory(const FString& RootDir);
static const TCHAR* EngineDir();
static const TCHAR* LaunchDir();
static void CacheLaunchDir();
static const TCHAR* ProjectDir();
static FString CloudDir();
static bool HasProjectPersistentDownloadDir();
static bool CheckPersistentDownloadStorageSpaceAvailable( uint64 BytesRequired, bool bAttemptToUseUI );
static const TCHAR* GamePersistentDownloadDir();
static const TCHAR* GeneratedConfigDir();
static void SetOverrideProjectDir(const FString& InOverrideDir);
// 裝置/硬體
static FString GetDeviceId();
static FString GetUniqueAdvertisingId();
static void SubmitErrorReport( const TCHAR* InErrorHist, EErrorReportMode::Type InMode );
static bool IsRemoteSession();
FORCEINLINE static bool IsDebuggerPresent();
static EProcessDiagnosticFlags GetProcessDiagnostics();
static FString GetCPUVendor();
static uint32 GetCPUInfo();
static bool HasNonoptionalCPUFeatures();
static bool NeedsNonoptionalCPUFeaturesCheck();
static FString GetCPUBrand();
static FString GetCPUChipset();
static FString GetPrimaryGPUBrand();
static FString GetDeviceMakeAndModel();
static struct FGPUDriverInfo GetGPUDriverInfo(const FString& DeviceDescription);
static void PrefetchBlock(const void* InPtr, int32 NumBytes = 1);
static void Prefetch(void const* x, int32 offset = 0);
static const TCHAR* GetDefaultDeviceProfileName();
FORCEINLINE static int GetBatteryLevel();
FORCEINLINE static void SetBrightness(float bBright);
FORCEINLINE static float GetBrightness();
FORCEINLINE static bool SupportsBrightness();
FORCEINLINE static bool IsInLowPowerMode();
static float GetDeviceTemperatureLevel();
static inline int32 GetMaxRefreshRate();
static inline int32 GetMaxSyncInterval();
static bool IsPGOEnabled();
static TArray<uint8> GetSystemFontBytes();
static bool HasActiveWiFiConnection();
static ENetworkConnectionType GetNetworkConnectionType();
static bool HasVariableHardware();
static bool HasPlatformFeature(const TCHAR* FeatureName);
static bool IsRunningOnBattery();
static EDeviceScreenOrientation GetDeviceOrientation();
static void SetDeviceOrientation(EDeviceScreenOrientation NewDeviceOrientation);
static int32 GetDeviceVolume();
// OS相關
static void GetOSVersions( FString& out_OSVersionLabel, FString& out_OSSubVersionLabel );
static FString GetOSVersion();
static bool GetDiskTotalAndFreeSpace( const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes );
static bool GetPageFaultStats(FPageFaultStats& OutStats, EPageFaultFlags Flags);
static bool GetBlockingIOStats(FProcessIOStats& OutStats, EInputOutputFlags Flags=EInputOutputFlags::All);
static bool GetContextSwitchStats(FContextSwitchStats& OutStats, EContextSwitchFlags Flags=EContextSwitchFlags::All);
static bool CommandLineCommands();
static bool Is64bitOperatingSystem();
static bool OsExecute(const TCHAR* CommandType, const TCHAR* Command, const TCHAR* CommandLine = NULL);
static bool Exec(class UWorld* InWorld, const TCHAR* Cmd, FOutputDevice& Out);
static bool GetSHA256Signature(const void* Data, uint32 ByteSize, FSHA256Signature& OutSignature);
static FString GetDefaultLanguage();
static FString GetDefaultLocale();
static FString GetTimeZoneId();
static TArray<FString> GetPreferredLanguages();
static const TCHAR* GetUBTPlatform();
static const TCHAR* GetUBTTarget();
static void SetUBTTargetName(const TCHAR* InTargetName);
static const TCHAR* GetUBTTargetName();
static const TCHAR* GetNullRHIShaderFormat();
static IPlatformChunkInstall* GetPlatformChunkInstall();
static IPlatformCompression* GetPlatformCompression();
static void GetValidTargetPlatforms(TArray<FString>& TargetPlatformNames);
static FPlatformUserId GetPlatformUserForUserIndex(int32 LocalUserIndex);
static int32 GetUserIndexForPlatformUser(FPlatformUserId PlatformUser);
// 訊息/事件
static bool SupportsMessaging();
static bool SupportsLocalCaching();
static bool AllowLocalCaching();
static void BeginNamedEvent(const struct FColor& Color, const TCHAR* Text);
static void BeginNamedEvent(const struct FColor& Color, const ANSICHAR* Text);
template<typename CharType>
static void StatNamedEvent(const CharType* Text);
static void TickStatNamedEvents();
static void LogNameEventStatsInit();
static void EndNamedEvent();
static void CustomNamedStat(const TCHAR* Text, float Value, const TCHAR* Graph, const TCHAR* Unit);
static void CustomNamedStat(const ANSICHAR* Text, float Value, const ANSICHAR* Graph, const ANSICHAR* Unit);
static void BeginEnterBackgroundEvent(const TCHAR* Text) ;
static void EndEnterBackgroundEvent();
static void BeginNamedEventFrame();
static void RegisterForRemoteNotifications();
static bool IsRegisteredForRemoteNotifications();
static void UnregisterForRemoteNotifications();
static void PumpMessagesOutsideMainLoop();
static void PumpMessagesForSlowTask();
static void PumpEssentialAppMessages();
// 記憶體
static void MemoryBarrier();
static void SetMemoryWarningHandler(void (* Handler)(const FGenericMemoryWarningContext& Context));
static bool HasMemoryWarningHandler();
// I/O
static void InitTaggedStorage(uint32 NumTags);
static void ShutdownTaggedStorage();
static void TagBuffer(const char* Label, uint32 Category, const void* Buffer, size_t BufferSize);
static bool SetStoredValues(const FString& InStoreId, const FString& InSectionName, const TMap<FString, FString>& InKeyValues);
static bool SetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, const FString& InValue);
static bool GetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, FString& OutValue);
static bool DeleteStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName);
static bool DeleteStoredSection(const FString& InStoreId, const FString& InSectionName);
static TArray<FCustomChunk> GetOnDemandChunksForPakchunkIndices(const TArray<int32>& PakchunkIndices);
static TArray<FCustomChunk> GetAllOnDemandChunks();
static TArray<FCustomChunk> GetAllLanguageChunks();
static TArray<FCustomChunk> GetCustomChunksByType(ECustomChunkType DesiredChunkType);
static void ParseChunkIdPakchunkIndexMapping(TArray<FString> ChunkIndexRedirects, TMap<int32, int32>& OutMapping);
static int32 GetChunkIDFromPakchunkIndex(int32 PakchunkIndex);
static int32 GetPakchunkIndexFromPakFile(const FString& InFilename);
static FText GetFileManagerName();
static bool IsPackagedForDistribution();
static FString LoadTextFileFromPlatformPackage(const FString& RelativePath);
static bool FileExistsInPlatformPackage(const FString& RelativePath);
static bool Expand16BitIndicesTo32BitOnLoad();
static void GetNetworkFileCustomData(TMap<FString,FString>& OutCustomPlatformData);
static bool SupportsBackbufferSampling();
// 執行緒/作業/非同步
static bool UseRenderThread();
static bool AllowAudioThread();
static bool AllowThreadHeartBeat();
static int32 NumberOfCores();
static const FProcessorGroupDesc& GetProcessorGroupDesc();
static int32 NumberOfCoresIncludingHyperthreads();
static int32 NumberOfWorkerThreadsToSpawn();
static int32 NumberOfIOWorkerThreadsToSpawn();
static struct FAsyncIOSystemBase* GetPlatformSpecificAsyncIOSystem();
static const TCHAR* GetPlatformFeaturesModuleName();
static bool SupportsMultithreadedFileHandles();
// 互動
static bool GetUseVirtualJoysticks();
static bool SupportsTouchInput();
static bool SupportsForceTouchInput();
static bool ShouldDisplayTouchInterfaceOnFakingTouchEvents();
static bool DesktopTouchScreen();
static bool FullscreenSameAsWindowedFullscreen();
static bool GetVolumeButtonsHandledBySystem();
static void SetVolumeButtonsHandledBySystem(bool enabled);
static void PrepareMobileHaptics(EMobileHapticsType Type);
static void TriggerMobileHaptics();
static void ReleaseMobileHaptics();
// 系統資訊
static FString GetLoginId();
static FString GetEpicAccountId();
static FString GetOperatingSystemId();
static EConvertibleLaptopMode GetConvertibleLaptopMode();
static bool SupportsDeviceCheckToken();
static bool RequestDeviceCheckToken(...);
// 引擎
static const TCHAR* GetEngineMode();
static bool ShouldDisablePluginAtRuntime(const FString& PluginName);
static bool UseHDRByDefault();
static void ChooseHDRDeviceAndColorGamut(uint32 DeviceId, uint32 DisplayNitLevel, int32& OutputDevice, int32& ColorGamut);
// 其它
static void CreateGuid(struct FGuid& Result);
static void TickHotfixables();
static FString GetLocalCurrencyCode();
static FString GetLocalCurrencySymbol();
static void ShareURL(const FString& URL, const FText& Description, int32 LocationHintX, int32 LocationHintY);
(...)
};
由上可知,FGenericPlatformMisc
不僅包含OS相關的介面,還包含了裝置、硬體、應用程式、輸入輸出、UI、互動、多執行緒、路徑等相關的介面。下圖是它的繼承體系圖:
下面小節將抽取部分平臺的部分介面進行分析。
FWindowsPlatformMisc實現了Windows平臺的相關介面,部分介面分析如下:
// WindowsPlatformMisc.cpp
void FWindowsPlatformMisc::PlatformPreInit()
{
FGenericPlatformMisc::PlatformPreInit();
(...)
// 使用自己的處理程式來呼叫純虛擬物件。
DefaultPureCallHandler = _set_purecall_handler( PureCallHandler );
const int32 MinResolution[] = {640, 480};
if ( ::GetSystemMetrics(SM_CXSCREEN) < MinResolution[0] || ::GetSystemMetrics(SM_CYSCREEN) < MinResolution[1] )
{
FMessageDialog::Open( EAppMsgType::Ok, NSLOCTEXT("Launch", "Error_ResolutionTooLow", "The current resolution is too low to run this game.") );
FPlatformMisc::RequestExit( false );
}
// 初始化檔案SHA雜湊對映
InitSHAHashes();
}
void FWindowsPlatformMisc::PlatformInit()
{
FGenericPlatformMisc::LogNameEventStatsInit();
(...)
// 將睡眠粒度等設定為1毫秒。
timeBeginPeriod( 1 );
(...)
// 獲取cpu資訊.
const FPlatformMemoryConstants& MemoryConstants = FPlatformMemory::GetConstants();
UE_LOG(LogInit, Log, TEXT("CPU Page size=%i, Cores=%i"), MemoryConstants.PageSize, FPlatformMisc::NumberOfCores() );
UE_LOG(LogInit, Log, TEXT("High frequency timer resolution =%f MHz"), 0.000001 / FPlatformTime::GetSecondsPerCycle() );
// 在遊戲執行緒上註冊。
FWindowsPlatformStackWalk::RegisterOnModulesChanged();
}
// 獲取OS版本.
void FWindowsPlatformMisc::GetOSVersions( FString& OutOSVersionLabel, FString& OutOSSubVersionLabel )
{
// OS初始化器.
static struct FOSVersionsInitializer
{
FOSVersionsInitializer()
{
OSVersionLabel[0] = 0;
OSSubVersionLabel[0] = 0;
GetOSVersionsHelper( OSVersionLabel, UE_ARRAY_COUNT(OSVersionLabel), OSSubVersionLabel, UE_ARRAY_COUNT(OSSubVersionLabel) );
}
TCHAR OSVersionLabel[128];
TCHAR OSSubVersionLabel[128];
} OSVersionsInitializer;
OutOSVersionLabel = OSVersionsInitializer.OSVersionLabel;
OutOSSubVersionLabel = OSVersionsInitializer.OSSubVersionLabel;
}
// 獲取磁碟的總量和空閒空間.
bool FWindowsPlatformMisc::GetDiskTotalAndFreeSpace( const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes )
{
const FString ValidatedPath = FPaths::ConvertRelativePathToFull(InPath).Replace(TEXT("/"), TEXT("\\"));
bool bSuccess = !!::GetDiskFreeSpaceEx( *ValidatedPath, nullptr, reinterpret_cast<ULARGE_INTEGER*>(&TotalNumberOfBytes), reinterpret_cast<ULARGE_INTEGER*>(&NumberOfFreeBytes));
return bSuccess;
}
// 獲取頁面錯誤資訊.
bool FWindowsPlatformMisc::GetPageFaultStats(FPageFaultStats& OutStats, EPageFaultFlags Flags/*=EPageFaultFlags::All*/)
{
bool bSuccess = false;
if (EnumHasAnyFlags(Flags, EPageFaultFlags::TotalPageFaults))
{
PROCESS_MEMORY_COUNTERS ProcessMemoryCounters;
FPlatformMemory::Memzero(&ProcessMemoryCounters, sizeof(ProcessMemoryCounters));
::GetProcessMemoryInfo(::GetCurrentProcess(), &ProcessMemoryCounters, sizeof(ProcessMemoryCounters));
OutStats.TotalPageFaults = ProcessMemoryCounters.PageFaultCount;
bSuccess = true;
}
return bSuccess;
}
// 獲取IO阻塞狀態.
bool FWindowsPlatformMisc::GetBlockingIOStats(FProcessIOStats& OutStats, EInputOutputFlags Flags/*=EInputOutputFlags::All*/)
{
bool bSuccess = false;
IO_COUNTERS Counters;
FPlatformMemory::Memzero(&Counters, sizeof(Counters));
// Ignore flags as all values are grabbed at once
if (::GetProcessIoCounters(::GetCurrentProcess(), &Counters) != 0)
{
OutStats.BlockingInput = Counters.ReadOperationCount;
OutStats.BlockingOutput = Counters.WriteOperationCount;
OutStats.BlockingOther = Counters.OtherOperationCount;
OutStats.InputBytes = Counters.ReadTransferCount;
OutStats.OutputBytes = Counters.WriteTransferCount;
OutStats.OtherBytes = Counters.OtherTransferCount;
bSuccess = true;
}
return bSuccess;
}
FString FWindowsPlatformMisc::GetOperatingSystemId()
{
FString Result;
QueryRegKey(HKEY_LOCAL_MACHINE, TEXT("Software\\Microsoft\\Cryptography"), TEXT("MachineGuid"), Result);
return Result;
}
void FWindowsPlatformMisc::PumpMessagesOutsideMainLoop()
{
TGuardValue<bool> PumpMessageGuard(GPumpingMessagesOutsideOfMainLoop, true);
// 處理掛起的視窗訊息,在某些情況下,D3D將視窗訊息(來自IDXGISwapChain::Present)傳送到主執行緒擁有的視口視窗,是渲染執行緒所必需的。
MSG Msg;
PeekMessage(&Msg, NULL, 0, 0, PM_NOREMOVE | PM_QS_SENDMESSAGE);
return;
}
FAndroidMisc實現了Android平臺的相關介面,部分介面分析如下:
// AndroidPlatformMisc.cpp
void FAndroidMisc::RequestExit( bool Force )
{
#if PLATFORM_COMPILER_OPTIMIZATION_PG_PROFILING
// 在完全關閉時寫入PGO組態檔。
extern void PGO_WriteFile();
if (!GIsCriticalError)
{
PGO_WriteFile();
// 立即退出,以避免在AndroidMain退出時可能發生的第二次PGO寫入。
Force = true;
}
#endif
UE_LOG(LogAndroid, Log, TEXT("FAndroidMisc::RequestExit(%i)"), Force);
if(GLog)
{
GLog->FlushThreadedLogs();
GLog->Flush();
}
// 強制退出.
if (Force)
{
#if USE_ANDROID_JNI
AndroidThunkCpp_ForceQuit();
#else
exit(1);
#endif
}
else
{
RequestEngineExit(TEXT("Android RequestExit"));
}
}
// 初始化
void FAndroidMisc::PlatformInit()
{
extern void AndroidSetupDefaultThreadAffinity();
AndroidSetupDefaultThreadAffinity();
(...)
// 初始化JNI環境.
#if USE_ANDROID_JNI
InitializeJavaEventReceivers();
AndroidOnBackgroundBinding = FCoreDelegates::ApplicationWillEnterBackgroundDelegate.AddStatic(EnableJavaEventReceivers, false);
AndroidOnForegroundBinding = FCoreDelegates::ApplicationHasEnteredForegroundDelegate.AddStatic(EnableJavaEventReceivers, true);
#endif
// 初始化cpu溫度感測器.
InitCpuThermalSensor();
(...)
}
// 銷燬
void FAndroidMisc::PlatformTearDown()
{
auto RemoveBinding = [](FCoreDelegates::FApplicationLifetimeDelegate& ApplicationLifetimeDelegate, FDelegateHandle& DelegateBinding)
{
if (DelegateBinding.IsValid())
{
ApplicationLifetimeDelegate.Remove(DelegateBinding);
DelegateBinding.Reset();
}
};
RemoveBinding(FCoreDelegates::ApplicationWillEnterBackgroundDelegate, AndroidOnBackgroundBinding);
RemoveBinding(FCoreDelegates::ApplicationHasEnteredForegroundDelegate, AndroidOnForegroundBinding);
}
// 是否使用渲染執行緒
bool FAndroidMisc::UseRenderThread()
{
// 如果由於命令列等原因,我們通常不想使用渲染執行緒.
if (!FGenericPlatformMisc::UseRenderThread())
{
return false;
}
// 檢查DeviceProfiles設定中的DisableThreadedRendering CVar,未來任何需要禁用執行緒渲染的裝置都應該得到一個裝置組態檔並使用此CVar.
const IConsoleVariable *const CVar = IConsoleManager::Get().FindConsoleVariable(TEXT("r.AndroidDisableThreadedRendering"));
if (CVar && CVar->GetInt() != 0)
{
return false;
}
// 英偉達tegra雙核處理器,即optimus 2x和xoom在執行多執行緒時發生崩潰。使用lg optimus 2x和motorola xoom測試的opengl(錯誤)無法處理多執行緒。
if (FAndroidMisc::GetGPUFamily() == FString(TEXT("NVIDIA Tegra")) && FPlatformMisc::NumberOfCores() <= 2 && FAndroidMisc::GetGLVersion().StartsWith(TEXT("OpenGL ES 2.")))
{
return false;
}
// 帶有2.x驅動程式的Vivante GC1000存在渲染執行緒問題
if (FAndroidMisc::GetGPUFamily().StartsWith(TEXT("Vivante GC1000")) && FAndroidMisc::GetGLVersion().StartsWith(TEXT("OpenGL ES 2.")))
{
return false;
}
// 使用opengl在kindlefire(第1代)上使用多執行緒呈現緩衝區存在問題.
if (FAndroidMisc::GetDeviceModel() == FString(TEXT("Kindle Fire")))
{
return false;
}
// 在使用opengl的多執行緒的三星s3 mini上,啟動時swapbuffer排序存在問題.
if (FAndroidMisc::GetDeviceModel() == FString(TEXT("GT-I8190L")))
{
return false;
}
return true;
}
// 觸發奔潰處理
void FAndroidMisc::TriggerCrashHandler(ECrashContextType InType, const TCHAR* InErrorMessage, const TCHAR* OverrideCallstack)
{
if (InType != ECrashContextType::Crash)
{
// 不會在致命訊號期間重新整理紀錄檔,malloccrash會導致死鎖。
if (GLog)
{
GLog->PanicFlushThreadedLogs();
GLog->Flush();
}
if (GWarn)
{
GWarn->Flush();
}
if (GError)
{
GError->Flush();
}
}
FAndroidCrashContext CrashContext(InType, InErrorMessage);
if (OverrideCallstack)
{
CrashContext.SetOverrideCallstack(OverrideCallstack);
}
else
{
CrashContext.CaptureCrashInfo();
}
if (GCrashHandlerPointer)
{
GCrashHandlerPointer(CrashContext);
}
else
{
// 預設處理器.
DefaultCrashHandler(CrashContext);
}
}
// 設定崩潰處理器.
void FAndroidMisc::SetCrashHandler(void(*CrashHandler)(const FGenericCrashContext& Context))
{
#if ANDROID_HAS_RTSIGNALS
GCrashHandlerPointer = CrashHandler;
FFatalSignalHandler::Release();
FThreadCallstackSignalHandler::Release();
// 通過-1將使這些恢復,並且不會困住它們.
if ((PTRINT)CrashHandler == -1)
{
return;
}
FFatalSignalHandler::Init();
FThreadCallstackSignalHandler::Init();
#endif
}
// 是否有Vulkan驅動支援.
bool FAndroidMisc::HasVulkanDriverSupport()
{
#if !USE_ANDROID_JNI
VulkanSupport = EDeviceVulkanSupportStatus::NotSupported;
VulkanVersionString = TEXT("0.0.0");
#else
// 此版本不檢查VulkanRHI或被cvars禁用!
if (VulkanSupport == EDeviceVulkanSupportStatus::Uninitialized)
{
// 假設沒有
VulkanSupport = EDeviceVulkanSupportStatus::NotSupported;
VulkanVersionString = TEXT("0.0.0");
// 檢查libvulkan.so
void* VulkanLib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
if (VulkanLib != nullptr)
{
// 如果是Nougat,我們可以檢查Vulkan版本
if (FAndroidMisc::GetAndroidBuildVersion() >= 24)
{
extern int32 AndroidThunkCpp_GetMetaDataInt(const FString& Key);
int32 VulkanVersion = AndroidThunkCpp_GetMetaDataInt(TEXT("android.hardware.vulkan.version"));
if (VulkanVersion >= UE_VK_API_VERSION)
{
// 最後檢查,嘗試初始化範例
VulkanSupport = AttemptVulkanInit(VulkanLib);
}
}
else
{
// 否則,我們需要嘗試初始化範例
VulkanSupport = AttemptVulkanInit(VulkanLib);
}
dlclose(VulkanLib);
if (VulkanSupport == EDeviceVulkanSupportStatus::Supported)
{
UE_LOG(LogAndroid, Log, TEXT("VulkanRHI is available, Vulkan capable device detected."));
return true;
}
(...)
#endif
return VulkanSupport == EDeviceVulkanSupportStatus::Supported;
}
// Vulkan是否可用
bool FAndroidMisc::IsVulkanAvailable()
{
(...)
// 不存在VulkanRHI模組.
if (!FModuleManager::Get().ModuleExists(TEXT("VulkanRHI")))
{
UE_LOG(LogAndroid, Log, TEXT("Vulkan not available as VulkanRHI not present."));
}
// 沒有bSupportsVulkan或bSupportsVulkanSM5,Vulka不能作為打包的專案提供。
else if (!(bSupportsVulkan || bSupportsVulkanSM5))
{
UE_LOG(LogAndroid, Log, TEXT("Vulkan not available as project packaged without bSupportsVulkan or bSupportsVulkanSM5."));
}
// Vulkan API檢測由命令列選項禁用。
else if (bVulkanDisabledCmdLine)
{
UE_LOG(LogAndroid, Log, TEXT("Vulkan API detection is disabled by a command line option."));
}
// Vulkan可用,但在AndroidRuntimeSettings中bDetectVulkanByDefault=False禁用了檢測。使用-detectvulkan覆蓋。
else if (!bDetectVulkanByDefault && !bDetectVulkanCmdLine)
{
UE_LOG(LogAndroid, Log, TEXT("Vulkan available but detection disabled by bDetectVulkanByDefault=False in AndroidRuntimeSettings. Use -detectvulkan to override."));
}
else
{
CachedVulkanAvailable = 1;
}
return CachedVulkanAvailable == 1;
}
// 檢測是否該使用Vulkan
bool FAndroidMisc::ShouldUseVulkan()
{
static int CachedShouldUseVulkan = -1;
if (CachedShouldUseVulkan == -1)
{
(...)
// 如果Vulkan可用且控制檯變數沒有禁用Vulkan, 則可用.
if (bVulkanAvailable && !bVulkanDisabledCVar)
{
CachedShouldUseVulkan = 1;
UE_LOG(LogAndroid, Log, TEXT("VulkanRHI will be used!"));
}
(...)
}
return CachedShouldUseVulkan == 1;
}
// 是否該使用桌面Vulkan.
bool FAndroidMisc::ShouldUseDesktopVulkan()
{
(...)
// 如果VulkanSM5開啟且VulkanSM5沒有被禁用, 則可以.
if (bVulkanSM5Enabled && !bVulkanSM5Disabled)
{
CachedShouldUseDesktopVulkan = 1;
UE_LOG(LogAndroid, Log, TEXT("Vulkan SM5 RHI will be used!"));
}
(...)
}
// 獲取Vulkan版本號.
FString FAndroidMisc::GetVulkanVersion()
{
check(VulkanSupport != EDeviceVulkanSupportStatus::Uninitialized);
return VulkanVersionString;
}
void FAndroidMisc::GetOSVersions(FString& out_OSVersionLabel, FString& out_OSSubVersionLabel)
{
out_OSVersionLabel = TEXT("Android");
out_OSSubVersionLabel = AndroidVersion;
}
FString FAndroidMisc::GetOSVersion()
{
return AndroidVersion;
}
// 獲取磁碟資訊.
bool FAndroidMisc::GetDiskTotalAndFreeSpace(const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes)
{
extern FString GExternalFilePath;
struct statfs FSStat = { 0 };
FTCHARToUTF8 Converter(*GExternalFilePath);
int Err = statfs((ANSICHAR*)Converter.Get(), &FSStat);
if (Err == 0)
{
TotalNumberOfBytes = FSStat.f_blocks * FSStat.f_bsize;
NumberOfFreeBytes = FSStat.f_bavail * FSStat.f_bsize;
}
(...)
return (Err == 0);
}
FIOSPlatformMisc實現了iOS平臺的相關介面,部分介面分析如下:
// IOSPlatformMisc.cpp
// 預初始化.
void FIOSPlatformMisc::PlatformPreInit()
{
FGenericPlatformMisc::PlatformPreInit();
GIOSAppInfo.Init();
// 關閉SIGPIPE崩潰
signal(SIGPIPE, SIG_IGN);
}
// 初始化.
void FIOSPlatformMisc::PlatformInit()
{
// 啟動建立幀緩衝區的UI執行緒,要求「r.MobileContentScaleFactor」在建立之前可用,因此需要立即快取該值。
[[IOSAppDelegate GetDelegate] LoadMobileContentScaleFactor];
FAppEntry::PlatformInit();
// 增加同時開啟的檔案的最大數量.
struct rlimit Limit;
Limit.rlim_cur = OPEN_MAX;
Limit.rlim_max = RLIM_INFINITY;
int32 Result = setrlimit(RLIMIT_NOFILE, &Limit);
check(Result == 0);
(...)
// 記憶體
const FPlatformMemoryConstants& MemoryConstants = FPlatformMemory::GetConstants();
GStartupFreeMemoryMB = GetFreeMemoryMB();
// 建立Documents/<GameName>/Content目錄,以便我們可以將其從iCloud備份中排除
FString ResultStr = FPaths::ProjectContentDir();
ResultStr.ReplaceInline(TEXT("../"), TEXT(""));
(...)
NSURL* URL = [NSURL fileURLWithPath : ResultStr.GetNSString()];
if (![[NSFileManager defaultManager] fileExistsAtPath:[URL path]])
{
[[NSFileManager defaultManager] createDirectoryAtURL:URL withIntermediateDirectories : YES attributes : nil error : nil];
}
// 標記為不上傳.
NSError *error = nil;
BOOL success = [URL setResourceValue : [NSNumber numberWithBool : YES] forKey : NSURLIsExcludedFromBackupKey error : &error];
if (!success)
{
NSLog(@"Error excluding %@ from backup %@",[URL lastPathComponent], error);
}
(...)
}
// 退出.
void FIOSPlatformMisc::RequestExit(bool Force)
{
if (Force)
{
FApplePlatformMisc::RequestExit(Force);
}
else
{
[[IOSAppDelegate GetDelegate] ForceExit];
}
}
void FIOSPlatformMisc::RequestExitWithStatus(bool Force, uint8 ReturnCode)
{
if (Force)
{
FApplePlatformMisc::RequestExit(Force);
}
else
{
(...)
[[IOSAppDelegate GetDelegate] ForceExit];
}
}
// 獲取平臺特性.
bool FIOSPlatformMisc::HasPlatformFeature(const TCHAR* FeatureName)
{
if (FCString::Stricmp(FeatureName, TEXT("Metal")) == 0)
{
return [IOSAppDelegate GetDelegate].IOSView->bIsUsingMetal;
}
return FGenericPlatformMisc::HasPlatformFeature(FeatureName);
}
// 獲取裝置設定名.
const TCHAR* FIOSPlatformMisc::GetDefaultDeviceProfileName()
{
static FString IOSDeviceProfileName;
if (IOSDeviceProfileName.Len() == 0)
{
IOSDeviceProfileName = TEXT("IOS");
FString DeviceIDString = GetIOSDeviceIDString();
TArray<FString> Mappings;
if (ensure(GConfig->GetSection(TEXT("IOSDeviceMappings"), Mappings, GDeviceProfilesIni)))
{
for (const FString& MappingString : Mappings)
{
FString MappingRegex, ProfileName;
if (MappingString.Split(TEXT("="), &MappingRegex, &ProfileName))
{
const FRegexPattern RegexPattern(MappingRegex);
FRegexMatcher RegexMatcher(RegexPattern, *DeviceIDString);
if (RegexMatcher.FindNext())
{
IOSDeviceProfileName = ProfileName;
break;
}
}
(...)
}
}
}
return *IOSDeviceProfileName;
}
// 獲取預設的棧大小.
int FIOSPlatformMisc::GetDefaultStackSize()
{
return 512 * 1024;
}
// 系統版本.
void FIOSPlatformMisc::GetOSVersions(FString& out_OSVersionLabel, FString& out_OSSubVersionLabel)
{
#if PLATFORM_TVOS
out_OSVersionLabel = TEXT("TVOS");
#else
out_OSVersionLabel = TEXT("IOS");
#endif
NSOperatingSystemVersion IOSVersion;
IOSVersion = [[NSProcessInfo processInfo] operatingSystemVersion];
out_OSSubVersionLabel = FString::Printf(TEXT("%ld.%ld.%ld"), IOSVersion.majorVersion, IOSVersion.minorVersion, IOSVersion.patchVersion);
}
// 磁碟資訊.
bool FIOSPlatformMisc::GetDiskTotalAndFreeSpace(const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes)
{
bool GetValueSuccess = false;
NSNumber *FreeBytes = nil;
NSURL *URL = [NSURL fileURLWithPath : NSHomeDirectory()];
GetValueSuccess = [URL getResourceValue : &FreeBytes forKey : NSURLVolumeAvailableCapacityForImportantUsageKey error : nil];
if (FreeBytes)
{
NumberOfFreeBytes = [FreeBytes longLongValue];
}
NSNumber *TotalBytes = nil;
GetValueSuccess = GetValueSuccess &&[URL getResourceValue : &TotalBytes forKey : NSURLVolumeTotalCapacityKey error : nil];
if (TotalBytes)
{
TotalNumberOfBytes = [TotalBytes longLongValue];
}
if (GetValueSuccess && (NumberOfFreeBytes > 0) && (TotalNumberOfBytes > 0))
{
return true;
}
(...)
}
// 工程版本.
FString FIOSPlatformMisc::GetProjectVersion()
{
NSDictionary* infoDictionary = [[NSBundle mainBundle] infoDictionary];
FString localVersionString = FString(infoDictionary[@"CFBundleShortVersionString"]);
return localVersionString;
}
// 構建數位.
FString FIOSPlatformMisc::GetBuildNumber()
{
NSDictionary* infoDictionary = [[NSBundle mainBundle]infoDictionary];
FString BuildString = FString(infoDictionary[@"CFBundleVersion"]);
return BuildString;
}
// 設定儲存值.
bool FIOSPlatformMisc::SetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, const FString& InValue)
{
NSUserDefaults* UserSettings = [NSUserDefaults standardUserDefaults];
NSString* StoredValue = [NSString stringWithFString:InValue];
[UserSettings setObject:StoredValue forKey:MakeStoredValueKeyName(InSectionName, InKeyName)];
return true;
}
// 獲取儲存值.
bool FIOSPlatformMisc::GetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, FString& OutValue)
{
NSUserDefaults* UserSettings = [NSUserDefaults standardUserDefaults];
NSString* StoredValue = [UserSettings objectForKey:MakeStoredValueKeyName(InSectionName, InKeyName)];
if (StoredValue != nil)
{
OutValue = StoredValue;
return true;
}
return false;
}
// 設定崩潰處理器.
void FIOSPlatformMisc::SetCrashHandler(void (* CrashHandler)(const FGenericCrashContext& Context))
{
SCOPED_AUTORELEASE_POOL;
GCrashHandlerPointer = CrashHandler;
if (!FIOSApplicationInfo::CrashReporter && !FIOSApplicationInfo::CrashMalloc)
{
// 設定崩潰處理程式malloc區域,為其自身保留少量記憶體.
FIOSApplicationInfo::CrashMalloc = new FIOSMallocCrashHandler(4*1024*1024);
PLCrashReporterConfig* Config = [[[PLCrashReporterConfig alloc] initWithSignalHandlerType: PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy: PLCrashReporterSymbolicationStrategyNone crashReportFolder: FIOSApplicationInfo::TemporaryCrashReportFolder().GetNSString() crashReportName: FIOSApplicationInfo::TemporaryCrashReportName().GetNSString()] autorelease];
FIOSApplicationInfo::CrashReporter = [[PLCrashReporter alloc] initWithConfiguration: Config];
PLCrashReporterCallbacks CrashReportCallback = {
.version = 0,
.context = nullptr,
.handleSignal = PLCrashReporterHandler
};
[FIOSApplicationInfo::CrashReporter setCrashCallbacks: &CrashReportCallback];
NSError* Error = nil;
if ([FIOSApplicationInfo::CrashReporter enableCrashReporterAndReturnError: &Error])
{
// 無操作.
}
else
{
// 崩潰處理器.
struct sigaction Action;
FMemory::Memzero(&Action, sizeof(struct sigaction));
// 儲存崩潰處理器.
Action.sa_sigaction = PlatformCrashHandler;
sigemptyset(&Action.sa_mask);
Action.sa_flags = SA_SIGINFO | SA_RESTART | SA_ONSTACK;
sigaction(SIGQUIT, &Action, NULL);
sigaction(SIGILL, &Action, NULL);
sigaction(SIGEMT, &Action, NULL);
sigaction(SIGFPE, &Action, NULL);
sigaction(SIGBUS, &Action, NULL);
sigaction(SIGSEGV, &Action, NULL);
sigaction(SIGSYS, &Action, NULL);
sigaction(SIGABRT, &Action, NULL);
}
}
}
需要注意的是,蘋果的作業系統(Mac、iOS)混合使用了C++和Object C,所以上面的有些語句跟C++差異比較明顯,不要對此感到奇怪,也不要覺得是語法錯誤。
FUnixPlatformMisc實現了Unix系統的介面,部分程式碼分析如下:
// UnixPlatformMisc.cpp
// 預初始化
void FUnixPlatformMisc::PlatformPreInit()
{
FGenericPlatformMisc::PlatformPreInit();
UnixCrashReporterTracker::PreInit();
}
void FUnixPlatformMisc::PlatformInit()
{
// 安裝特定於平臺的訊號處理程式.
InstallChildExitedSignalHanlder();
// IsFirstInstance()不僅僅用於紀錄檔記錄,實際上是第一個.
bool bFirstInstance = FPlatformProcess::IsFirstInstance();
bool bIsNullRHI = !FApp::CanEverRender();
bool bPreloadedModuleSymbolFile = FParse::Param(FCommandLine::Get(), TEXT("preloadmodulesymbols"));
UnixPlatForm_CheckIfKSMUsable();
FString GPUInfo = GetGPUInfo();
(...)
FPlatformTime::PrintCalibrationLog();
(...)
if (bPreloadedModuleSymbolFile)
{
UnixPlatformStackWalk_PreloadModuleSymbolFile();
}
if (FPlatformMisc::HasBeenStartedRemotely() || FPlatformMisc::IsDebuggerPresent())
{
// 立即列印輸出
setvbuf(stdout, NULL, _IONBF, 0);
}
if (FParse::Param(FCommandLine::Get(), TEXT("norandomguids")))
{
SysGetRandomSupported = 0;
}
// 此符號用於偵錯,但在啟用LTO的情況下,會被剝離,因為沒有任何東西在使用它讓我們在這裡使用它來記錄它在VeryVerbose設定下是否有效.
extern uint8** GNameBlocksDebug;
if (GNameBlocksDebug)
{
UE_LOG(LogInit, VeryVerbose, TEXT("GNameBlocksDebug Valid - %i"), !!GNameBlocksDebug);
}
}
// 銷燬.
void FUnixPlatformMisc::PlatformTearDown()
{
// 我們請求關閉訊號,因此無法列印。
if (GDeferedExitLogging)
{
uint8 OverriddenErrorLevel = 0;
if (FPlatformMisc::HasOverriddenReturnCode(&OverriddenErrorLevel))
{
UE_LOG(LogCore, Log, TEXT("FUnixPlatformMisc::RequestExit(bForce=false, ReturnCode=%d)"), OverriddenErrorLevel);
}
else
{
UE_LOG(LogCore, Log, TEXT("FUnixPlatformMisc::RequestExit(false)"));
}
}
UnixPlatformStackWalk_UnloadPreloadedModuleSymbol();
FPlatformProcess::CeaseBeingFirstInstance();
}
// 低階別輸出偵錯資訊.
void FUnixPlatformMisc::LowLevelOutputDebugString(const TCHAR *Message)
{
static_assert(PLATFORM_USE_LS_SPEC_FOR_WIDECHAR, "Check printf format");
fprintf(stderr, "%s", TCHAR_TO_UTF8(Message)); // there's no good way to implement that really
}
// OS版本資訊.
void FUnixPlatformMisc::GetOSVersions(FString& out_OSVersionLabel, FString& out_OSSubVersionLabel)
{
out_OSVersionLabel = FString(TEXT("GenericLinuxVersion"));
out_OSSubVersionLabel = GetKernelVersion();
TMap<FString, FString> OsInfo = ReadConfigurationFile(TEXT("/etc/os-release"));
if (OsInfo.Num() > 0)
{
FString* VersionAddress = OsInfo.Find(TEXT("PRETTY_NAME"));
if (VersionAddress)
{
FString* VersionNameAddress = nullptr;
if (VersionAddress->Equals(TEXT("Linux")))
{
VersionNameAddress = OsInfo.Find(TEXT("NAME"));
if (VersionNameAddress != nullptr)
{
VersionAddress = VersionNameAddress;
}
}
out_OSVersionLabel = FString(*VersionAddress);
}
}
(...)
}
// OS識別符號.
FString FUnixPlatformMisc::GetOperatingSystemId()
{
(...)
int OsGuidFile = open("/etc/machine-id", O_RDONLY);
if (OsGuidFile != -1)
{
char Buffer[PlatformMiscLimits::MaxOsGuidLength + 1] = {0};
ssize_t ReadBytes = read(OsGuidFile, Buffer, sizeof(Buffer) - 1);
if (ReadBytes > 0)
{
CachedResult = UTF8_TO_TCHAR(Buffer);
}
close(OsGuidFile);
}
(...)
}
// 獲取磁碟資訊.
bool FUnixPlatformMisc::GetDiskTotalAndFreeSpace(const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes)
{
struct statfs FSStat = { 0 };
FTCHARToUTF8 Converter(*InPath);
int Err = statfs((ANSICHAR*)Converter.Get(), &FSStat);
if (Err == 0)
{
TotalNumberOfBytes = FSStat.f_blocks * FSStat.f_bsize;
NumberOfFreeBytes = FSStat.f_bavail * FSStat.f_bsize;
}
(...)
return (Err == 0);
}
// 設定儲存值.
bool FUnixPlatformMisc::SetStoredValues(const FString& InStoreId, const FString& InSectionName, const TMap<FString, FString>& InKeyValues)
{
const FString ConfigPath = FString(FPlatformProcess::ApplicationSettingsDir()) / InStoreId / FString(TEXT("KeyValueStore.ini"));
FConfigFile ConfigFile;
ConfigFile.Read(ConfigPath);
for (auto const& InKeyValue : InKeyValues)
{
FConfigSection& Section = ConfigFile.FindOrAdd(InSectionName);
FConfigValue& KeyValue = Section.FindOrAdd(*InKeyValue.Key);
KeyValue = FConfigValue(InKeyValue.Value);
}
ConfigFile.Dirty = true;
return ConfigFile.Write(ConfigPath);
}
值得一提的是,Linux平臺的實現和Unix完全一樣,未作任何的額外修改。
有著UE開發經驗或者細心的同學肯定發現了,我們在使用平臺相關的介面時,使用的是FPlatformMisc
而不是FGenericPlatformMisc
,那麼它們的關係是怎樣的呢?為了解開謎底,還需要從以下程式碼中獲取答案:
// PreprocessorHelpers.h
#define COMPILED_PLATFORM_HEADER(Suffix) PREPROCESSOR_TO_STRING(PREPROCESSOR_JOIN(PLATFORM_HEADER_NAME/PLATFORM_HEADER_NAME, Suffix))
// PlatformMisc.h
#include "GenericPlatform/GenericPlatformMisc.h"
#include COMPILED_PLATFORM_HEADER(PlatformMisc.h)
從上面的程式碼片段可以看出,UE使用了COMPILED_PLATFORM_HEADER(PlatformMisc.h)
的宏生成了當前系統對應的檔案路徑,例如:
Windows: "Windows/WindowsPlatformMisc.h"
Android: "Android/AndroidPlatformMisc.h"
IOS : "IOS/IOSPlatformMisc.h"
Unix : "Unix/UnixPlatformMisc.h"
Mac : "Mac/MacPlatformMisc.h"
然後每個平臺的XXXPlatformMisc.h中都有一句typedef FXXXPlatformMisc FPlatformMisc
,例如:
// WindowsPlatformMisc.h
typedef FWindowsPlatformMisc FPlatformMisc;
// AndroidPlatformMisc.h
typedef FAndroidMisc FPlatformMisc;
// IOSPlatformMisc.h
typedef FIOSPlatformMisc FPlatformMisc;
// LinuxPlatformMisc.h
typedef FLinuxPlatformMisc FPlatformMisc;
有了以上型別重定義,從而實現了FGenericPlatformMisc
的不同子類使用統一的FPlatformMisc
型別,其它模組就可以使用統一的型別FPlatformMisc
存取OS相關的介面,實現跨平臺的目的。
順帶提一下,COMPILED_PLATFORM_HEADER()
還用於跨平臺的其它檔案或模組中:
#include COMPILED_PLATFORM_HEADER(PlatformAGXConfig.h)
#include COMPILED_PLATFORM_HEADER(PlatformApplicationMisc.h)
#include COMPILED_PLATFORM_HEADER(PlatformSplash.h)
#include COMPILED_PLATFORM_HEADER(PlatformSurvey.h)
#include COMPILED_PLATFORM_HEADER(PlatformModuleDiagnostics.h)
#include COMPILED_PLATFORM_HEADER(CriticalSection.h)
#include COMPILED_PLATFORM_HEADER(PlatformCompilerPreSetup.h)
#include COMPILED_PLATFORM_HEADER(PlatformCompilerSetup.h)
#include COMPILED_PLATFORM_HEADER(Platform.h)
#include COMPILED_PLATFORM_HEADER(PlatformAffinity.h)
#include COMPILED_PLATFORM_HEADER(PlatformAtomics.h)
#include COMPILED_PLATFORM_HEADER(PlatformCrashContext.h)
#include COMPILED_PLATFORM_HEADER(PlatformFile.h)
#include COMPILED_PLATFORM_HEADER(PlatformMath.h)
#include COMPILED_PLATFORM_HEADER(PlatformMemory.h)
#include COMPILED_PLATFORM_HEADER(PlatformOutputDevices.h)
#include COMPILED_PLATFORM_HEADER(PlatformProcess.h)
#include COMPILED_PLATFORM_HEADER(PlatformProperties.h)
#include COMPILED_PLATFORM_HEADER(PlatformStackWalk.h)
#include COMPILED_PLATFORM_HEADER(PlatformString.h)
#include COMPILED_PLATFORM_HEADER(PlatformTime.h)
#include COMPILED_PLATFORM_HEADER(PlatformTLS.h)
#include COMPILED_PLATFORM_HEADER(PlatformHttp.h)
#include COMPILED_PLATFORM_HEADER(PlatformBackgroundHttp.h)
#include COMPILED_PLATFORM_HEADER(OpenGLDrvPrivate.h)
#include COMPILED_PLATFORM_HEADER_WITH_PREFIX(Apple/Platform, PlatformDynamicRHI.h)
#include COMPILED_PLATFORM_HEADER(StaticShaderPlatform.inl)
#include COMPILED_PLATFORM_HEADER(StaticFeatureLevel.inl)
#include COMPILED_PLATFORM_HEADER(DataDrivenShaderPlatformInfo.inl)
#include COMPILED_PLATFORM_HEADER_WITH_PREFIX(Framework/Text, PlatformTextField.h)
涉及了程序、原子操作、臨界區、堆疊遍歷、TLS、記憶體、檔案、崩潰上下文、親緣性、編譯器、應用程式、數學、HTTP、Shader、RHI等等模組。後續小節會對部分重要模組進行分析。
FGenericPlatformApplicationMisc
的跨平臺和實現機制和FGenericPlatformMisc
類似,下面看看它的宣告:
// GenericPlatformApplicationMisc.h
struct APPLICATIONCORE_API FGenericPlatformApplicationMisc
{
// App宣告週期.
static class GenericApplication* CreateApplication();
static void PreInit();
static void Init();
static void PostInit();
static void TearDown();
// 模組/上下文/裝置
static void LoadPreInitModules();
static void LoadStartupModules();
static FOutputDeviceConsole* CreateConsoleOutputDevice();
static FOutputDeviceError* GetErrorOutputDevice();
static FFeedbackContext* GetFeedbackContext();
static bool IsThisApplicationForeground();
static void RequestMinimize();
static bool RequiresVirtualKeyboard();
static void PumpMessages(bool bFromMainLoop);
// 螢幕/視窗
static void PreventScreenSaver();
static bool IsScreensaverEnabled();
static bool ControlScreensaver(EScreenSaverAction Action);
static struct FLinearColor GetScreenPixelColor(const FVector2D& InScreenPos, float InGamma);
static bool GetWindowTitleMatchingText(const TCHAR* TitleStartsWith, FString& OutTitle);
static void SetHighDPIMode();
static float GetDPIScaleFactorAtPoint(float X, float Y);
static bool IsHighDPIAwarenessEnabled();
static bool AnchorWindowWindowPositionTopLeft();
static EScreenPhysicalAccuracy GetPhysicalScreenDensity(int32& OutScreenDensity);
static EScreenPhysicalAccuracy ComputePhysicalScreenDensity(int32& OutScreenDensity);
static EScreenPhysicalAccuracy ConvertInchesToPixels(T Inches, T2& OutPixels);
static EScreenPhysicalAccuracy ConvertPixelsToInches(T Pixels, T2& OutInches);
// 控制器
static void SetGamepadsAllowed(bool bAllowed);
static void SetGamepadsBlockDeviceFeedback(bool bAllowed);
static void ResetGamepadAssignments();
static void ResetGamepadAssignmentToController(int32 ControllerId);
static bool IsControllerAssignedToGamepad(int32 ControllerId);
static FString GetGamepadControllerName(int32 ControllerId);
static class UTexture2D* GetGamepadButtonGlyph(...);
static void EnableMotionData(bool bEnable);
static bool IsMotionDataEnabled();
// 其它操作
static void ClipboardCopy(const TCHAR* Str);
static void ClipboardPaste(class FString& Dest);
(...)
};
由此可見,FGenericPlatformApplicationMisc
主要是對應用程式的宣告週期、視窗、螢幕、裝置、控制器等提供統一的介面,而具體的實現由不同的平臺子類實現。下面小節分析部分平臺的部分介面。
FWindowsPlatformApplicationMisc
實現Windows平臺應用程式的介面:
// WindowsPlatformApplicationMisc.cpp
// 建立應用程式
GenericApplication* FWindowsPlatformApplicationMisc::CreateApplication()
{
HICON AppIconHandle = LoadIcon( hInstance, MAKEINTRESOURCE( GetAppIcon() ) );
if( AppIconHandle == NULL )
{
AppIconHandle = LoadIcon( (HINSTANCE)NULL, IDI_APPLICATION );
}
// 建立視窗應用程式.
return FWindowsApplication::CreateWindowsApplication( hInstance, AppIconHandle );
}
void FWindowsPlatformApplicationMisc::PreInit()
{
FApp::SetHasFocusFunction(&FWindowsPlatformApplicationMisc::IsThisApplicationForeground);
}
void FWindowsPlatformApplicationMisc::LoadStartupModules()
{
FModuleManager::Get().LoadModule(TEXT("HeadMountedDisplay"));
(...)
}
class FFeedbackContext* FWindowsPlatformApplicationMisc::GetFeedbackContext()
{
(...)
return FPlatformOutputDevices::GetFeedbackContext();
}
// 注入訊息.
void FWindowsPlatformApplicationMisc::PumpMessages(bool bFromMainLoop)
{
const bool bSetPumpingMessages = !GPumpingMessages;
if (bSetPumpingMessages)
{
GPumpingMessages = true;
}
ON_SCOPE_EXIT
{
if (bSetPumpingMessages)
{
GPumpingMessages = false;
}
};
if (!bFromMainLoop)
{
FPlatformMisc::PumpMessagesOutsideMainLoop();
return;
}
GPumpingMessagesOutsideOfMainLoop = false;
WinPumpMessages();
// 確定應用程式是否具有焦點.
bool bHasFocus = FApp::HasFocus();
static bool bHadFocus = false;
(...)
#if !UE_SERVER
// 對於非編輯器使用者端,記錄活動視窗是否處於焦點.
if( bHadFocus != bHasFocus )
{
FGenericCrashContext::SetEngineData(TEXT("Platform.AppHasFocus"), bHasFocus ? TEXT("true") : TEXT("false"));
}
#endif
bHadFocus = bHasFocus;
// 如果是我們的視窗,允許聲音,否則應用乘數.
FApp::SetVolumeMultiplier( bHasFocus ? 1.0f : FApp::GetUnfocusedVolumeMultiplier() );
}
// 設定高DPI模式.
void FWindowsPlatformApplicationMisc::SetHighDPIMode()
{
if (IsHighDPIAwarenessEnabled())
{
if (void* ShCoreDll = FPlatformProcess::GetDllHandle(TEXT("shcore.dll")))
{
typedef enum _PROCESS_DPI_AWARENESS {
PROCESS_DPI_UNAWARE = 0,
PROCESS_SYSTEM_DPI_AWARE = 1,
PROCESS_PER_MONITOR_DPI_AWARE = 2
} PROCESS_DPI_AWARENESS;
// 從shcore.dll獲取SetProcessDpiAwarenessProc介面地址.
typedef HRESULT(STDAPICALLTYPE *SetProcessDpiAwarenessProc)(PROCESS_DPI_AWARENESS Value);
SetProcessDpiAwarenessProc SetProcessDpiAwareness = (SetProcessDpiAwarenessProc)FPlatformProcess::GetDllExport(ShCoreDll, TEXT("SetProcessDpiAwareness"));
GetDpiForMonitor = (GetDpiForMonitorProc)FPlatformProcess::GetDllExport(ShCoreDll, TEXT("GetDpiForMonitor"));
typedef HRESULT(STDAPICALLTYPE *GetProcessDpiAwarenessProc)(HANDLE hProcess, PROCESS_DPI_AWARENESS* Value);
GetProcessDpiAwarenessProc GetProcessDpiAwareness = (GetProcessDpiAwarenessProc)FPlatformProcess::GetDllExport(ShCoreDll, TEXT("GetProcessDpiAwareness"));
if (SetProcessDpiAwareness && GetProcessDpiAwareness && !IsRunningCommandlet() && !FApp::IsUnattended())
{
PROCESS_DPI_AWARENESS CurrentAwareness = PROCESS_DPI_UNAWARE;
GetProcessDpiAwareness(nullptr, &CurrentAwareness);
if (CurrentAwareness != PROCESS_PER_MONITOR_DPI_AWARE)
{
UE_LOG(LogInit, Log, TEXT("Setting process to per monitor DPI aware"));
HRESULT Hr = SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE); // 如果我們處於任何無頭顯模式,則不在乎警告
if (Hr != S_OK)
{
UE_LOG(LogInit, Warning, TEXT("SetProcessDpiAwareness failed. Error code %x"), Hr);
}
}
}
FPlatformProcess::FreeDllHandle(ShCoreDll);
}
else if (void* User32Dll = FPlatformProcess::GetDllHandle(TEXT("user32.dll")))
{
// user32.dll獲取SetProcessDpiAware介面地址.
typedef BOOL(WINAPI *SetProcessDpiAwareProc)(void);
SetProcessDpiAwareProc SetProcessDpiAware = (SetProcessDpiAwareProc)FPlatformProcess::GetDllExport(User32Dll, TEXT("SetProcessDPIAware"));
if (SetProcessDpiAware && !IsRunningCommandlet() && !FApp::IsUnattended())
{
UE_LOG(LogInit, Log, TEXT("Setting process to DPI aware"));
BOOL Result = SetProcessDpiAware();
if (Result == 0)
{
UE_LOG(LogInit, Warning, TEXT("SetProcessDpiAware failed"));
}
}
FPlatformProcess::FreeDllHandle(User32Dll);
}
}
}
// 獲取顯示器DPI.
int32 FWindowsPlatformApplicationMisc::GetMonitorDPI(const FMonitorInfo& MonitorInfo)
{
int32 DisplayDPI = 96;
if (IsHighDPIAwarenessEnabled())
{
if (GetDpiForMonitor)
{
RECT MonitorDim;
MonitorDim.left = MonitorInfo.DisplayRect.Left;
MonitorDim.top = MonitorInfo.DisplayRect.Top;
MonitorDim.right = MonitorInfo.DisplayRect.Right;
MonitorDim.bottom = MonitorInfo.DisplayRect.Bottom;
HMONITOR Monitor = MonitorFromRect(&MonitorDim, MONITOR_DEFAULTTONEAREST);
if (Monitor)
{
uint32 DPIX = 0;
uint32 DPIY = 0;
// 獲取顯示器DPI.
if (SUCCEEDED(GetDpiForMonitor(Monitor, 0, &DPIX, &DPIY)))
{
DisplayDPI = DPIX;
}
}
}
else
{
HDC Context = GetDC(nullptr);
DisplayDPI = GetDeviceCaps(Context, LOGPIXELSX);
ReleaseDC(nullptr, Context);
}
}
return DisplayDPI;
}
// 獲取點的DPI比例因子
float FWindowsPlatformApplicationMisc::GetDPIScaleFactorAtPoint(float X, float Y)
{
float Scale = 1.0f;
if (IsHighDPIAwarenessEnabled())
{
if (GetDpiForMonitor)
{
POINT Position = { static_cast<LONG>(X), static_cast<LONG>(Y) };
HMONITOR Monitor = MonitorFromPoint(Position, MONITOR_DEFAULTTONEAREST);
if (Monitor)
{
uint32 DPIX = 0;
uint32 DPIY = 0;
if (SUCCEEDED(GetDpiForMonitor(Monitor, 0, &DPIX, &DPIY)))
{
Scale = (float)DPIX / 96.0f;
}
}
}
else
{
HDC Context = GetDC(nullptr);
int32 DPI = GetDeviceCaps(Context, LOGPIXELSX);
Scale = (float)DPI / 96.0f;
ReleaseDC(nullptr, Context);
}
}
return Scale;
}
FAndroidApplicationMisc
實現Android平臺應用程式的介面:
// AndroidPlatformApplicationMisc.cpp
void FAndroidApplicationMisc::LoadPreInitModules()
{
FModuleManager::Get().LoadModule(TEXT("OpenGLDrv"));
#if USE_ANDROID_AUDIO
FModuleManager::Get().LoadModule(TEXT("AndroidAudio"));
FModuleManager::Get().LoadModule(TEXT("AudioMixerAndroid"));
#endif
}
class FFeedbackContext* FAndroidApplicationMisc::GetFeedbackContext()
{
static FAndroidFeedbackContext Singleton;
return &Singleton;
}
class FOutputDeviceError* FAndroidApplicationMisc::GetErrorOutputDevice()
{
static FAndroidErrorOutputDevice Singleton;
return &Singleton;
}
GenericApplication* FAndroidApplicationMisc::CreateApplication()
{
return FAndroidApplication::CreateAndroidApplication();
}
void FAndroidApplicationMisc::SetGamepadsAllowed(bool bAllowed)
{
if (FAndroidInputInterface* InputInterface = (FAndroidInputInterface*)FAndroidApplication::Get()->GetInputInterface())
{
InputInterface->SetGamepadsAllowed(bAllowed);
}
}
// 計算物理螢幕的密度.
ScreenPhysicalAccuracy FAndroidApplicationMisc::ComputePhysicalScreenDensity(int32& OutScreenDensity)
{
FString MyDeviceModel = FPlatformMisc::GetDeviceModel();
TArray<FString> DeviceStrings;
GConfig->GetArray(TEXT("DeviceScreenDensity"), TEXT("Devices"), DeviceStrings, GEngineIni);
TArray<FScreenDensity> Devices;
for ( const FString& DeviceString : DeviceStrings )
{
FScreenDensity DensityEntry;
if ( DensityEntry.InitFromString(DeviceString) )
{
Devices.Add(DensityEntry);
}
}
for ( const FScreenDensity& Device : Devices )
{
if ( Device.IsMatch(MyDeviceModel) )
{
OutScreenDensity = Device.Density * GetWindowUpscaleFactor();
return EScreenPhysicalAccuracy::Truth;
}
}
// JNI模式
#if USE_ANDROID_JNI
extern FString AndroidThunkCpp_GetMetaDataString(const FString& Key);
FString DPIStrings = AndroidThunkCpp_GetMetaDataString(TEXT("unreal.displaymetrics.dpi"));
TArray<FString> DPIValues;
DPIStrings.ParseIntoArray(DPIValues, TEXT(","));
float xdpi, ydpi;
LexFromString(xdpi, *DPIValues[0]);
LexFromString(ydpi, *DPIValues[1]);
OutScreenDensity = ( xdpi + ydpi ) / 2.0f;
if ( OutScreenDensity <= 0 || OutScreenDensity > 2000 )
{
return EScreenPhysicalAccuracy::Unknown;
}
OutScreenDensity *= GetWindowUpscaleFactor();
return EScreenPhysicalAccuracy::Approximation;
#else
return EScreenPhysicalAccuracy::Unknown;
#endif
}
FIOSPlatformApplicationMisc
實現IOS平臺應用程式的介面:
// IOSPlatformApplicationMisc.cpp
void FIOSPlatformApplicationMisc::LoadPreInitModules()
{
FModuleManager::Get().LoadModule(TEXT("IOSAudio"));
FModuleManager::Get().LoadModule(TEXT("AudioMixerAudioUnit"));
}
class FFeedbackContext* FIOSPlatformApplicationMisc::GetFeedbackContext()
{
static FIOSFeedbackContext Singleton;
return &Singleton;
}
class FOutputDeviceError* FIOSPlatformApplicationMisc::GetErrorOutputDevice()
{
static FIOSErrorOutputDevice Singleton;
return &Singleton;
}
// 建立應用程式.
GenericApplication* FIOSPlatformApplicationMisc::CreateApplication()
{
CachedApplication = FIOSApplication::CreateIOSApplication();
return CachedApplication;
}
void FIOSPlatformApplicationMisc::EnableMotionData(bool bEnable)
{
FIOSInputInterface* InputInterface = (FIOSInputInterface*)CachedApplication->GetInputInterface();
return InputInterface->EnableMotionData(bEnable);
}
bool FIOSPlatformApplicationMisc::IsMotionDataEnabled()
{
const FIOSInputInterface* InputInterface = (const FIOSInputInterface*)CachedApplication->GetInputInterface();
return InputInterface->IsMotionDataEnabled();
}
// 剪下板
void FIOSPlatformApplicationMisc::ClipboardCopy(const TCHAR* Str)
{
#if !PLATFORM_TVOS
CFStringRef CocoaString = FPlatformString::TCHARToCFString(Str);
UIPasteboard* Pasteboard = [UIPasteboard generalPasteboard];
[Pasteboard setString:(NSString*)CocoaString];
#endif
}
void FIOSPlatformApplicationMisc::ClipboardPaste(class FString& Result)
{
#if !PLATFORM_TVOS
UIPasteboard* Pasteboard = [UIPasteboard generalPasteboard];
NSString* CocoaString = [Pasteboard string];
if(CocoaString)
{
TArray<TCHAR> Ch;
Ch.AddUninitialized([CocoaString length] + 1);
FPlatformString::CFStringToTCHAR((CFStringRef)CocoaString, Ch.GetData());
Result = Ch.GetData();
}
else
{
Result = TEXT("");
}
#endif
}
FLinuxPlatformApplicationMisc
實現Linux平臺應用程式的介面:
// LinuxPlatformApplicationMisc.cpp
GenericApplication* FLinuxPlatformApplicationMisc::CreateApplication()
{
return FLinuxApplication::CreateLinuxApplication();
}
void FLinuxPlatformApplicationMisc::PreInit()
{
MessageBoxExtCallback = MessageBoxExtImpl;
FApp::SetHasFocusFunction(&FLinuxPlatformApplicationMisc::IsThisApplicationForeground);
}
void FLinuxPlatformApplicationMisc::Init()
{
// skip for servers and programs, unless they request later
bool bIsNullRHI = !FApp::CanEverRender();
if (!IS_PROGRAM && !bIsNullRHI)
{
InitSDL();
}
FGenericPlatformApplicationMisc::Init();
UngrabAllInputCallback = UngrabAllInputImpl;
}
void FLinuxPlatformApplicationMisc::LoadPreInitModules()
{
#if WITH_EDITOR
FModuleManager::Get().LoadModule(TEXT("OpenGLDrv"));
#endif // WITH_EDITOR
}
void FLinuxPlatformApplicationMisc::LoadStartupModules()
{
#if !IS_PROGRAM && !UE_SERVER
FModuleManager::Get().LoadModule(TEXT("AudioMixerSDL")); // added in Launch.Build.cs for non-server targets
FModuleManager::Get().LoadModule(TEXT("HeadMountedDisplay"));
#endif // !IS_PROGRAM && !UE_SERVER
#if defined(WITH_STEAMCONTROLLER) && WITH_STEAMCONTROLLER
FModuleManager::Get().LoadModule(TEXT("SteamController"));
#endif // WITH_STEAMCONTROLLER
#if WITH_EDITOR
FModuleManager::Get().LoadModule(TEXT("SourceCodeAccess"));
#endif //WITH_EDITOR
}
void FLinuxPlatformApplicationMisc::TearDown()
{
FGenericPlatformApplicationMisc::TearDown();
if (GInitializedSDL)
{
UE_LOG(LogInit, Log, TEXT("Tearing down SDL."));
SDL_Quit();
GInitializedSDL = false;
MessageBoxExtCallback = nullptr;
UngrabAllInputCallback = nullptr;
}
}
// 注入訊息.
void FLinuxPlatformApplicationMisc::PumpMessages( bool bFromMainLoop )
{
if (GInitializedSDL && bFromMainLoop)
{
if( LinuxApplication )
{
LinuxApplication->SaveWindowPropertiesForEventLoop();
SDL_Event event;
while (SDL_PollEvent(&event))
{
LinuxApplication->AddPendingEvent( event );
}
LinuxApplication->CheckIfApplicatioNeedsDeactivation();
LinuxApplication->ClearWindowPropertiesAfterEventLoop();
}
else
{
// 沒有要向其傳送事件的應用程式, 只需清除佇列。
SDL_Event event;
while (SDL_PollEvent(&event))
{
// noop
}
}
bool bHasFocus = FApp::HasFocus();
// 如果是我們的視窗,允許聲音,否則應用乘數.
FApp::SetVolumeMultiplier( bHasFocus ? 1.0f : FApp::GetUnfocusedVolumeMultiplier() );
}
}
FPlatformApplicationMisc
、FGenericPlatformApplicationMisc
之間的關係、實現和用法於FPlatformMisc
、FGenericPlatformMisc
類似,不再累述。
FRunnableThread是UE封裝了各個作業系統下的執行緒基礎類別,它的定義如下:
// RunnableThread.h
class CORE_API FRunnableThread
{
public:
// 建立
static FRunnableThread* Create(FRunnable* InRunnable, const TCHAR* ThreadName, uint32 InStackSize = 0, ...);
// 執行緒狀態轉換.
virtual void Suspend( bool bShouldPause = true ) = 0;
virtual bool Kill( bool bShouldWait = true ) = 0;
virtual void WaitForCompletion() = 0;
// 執行緒屬性
// 執行緒型別.
enum class ThreadType
{
Real,
Fake,
Forkable,
};
virtual FRunnableThread::ThreadType GetThreadType() const;
static uint32 GetTlsSlot();
virtual void SetThreadPriority( EThreadPriority NewPriority ) = 0;
virtual bool SetThreadAffinity( const FThreadAffinity& Affinity );
const uint32 GetThreadID() const;
const FString& GetThreadName() const;
EThreadPriority GetThreadPriority() const;
protected:
void SetTls();
void FreeTls();
static FRunnableThread* GetRunnableThread();
private:
static uint32 RunnableTlsSlot; // FRunnableThread指標的TLS插槽索引.
static void SetupCreatedThread(...);
virtual void Tick();
virtual void OnPostFork();
void PostCreate(EThreadPriority ThreadPriority);
(...)
};
由此可知,UE抽象了執行緒的若干介面和資料,包含建立、銷燬、轉換狀態及設定堆疊大小、優先順序、親緣性、TLS等介面。繼承自它的子類是實現各個平臺的類,繼承樹如下:
上圖顯示了Windows直接繼承自FRunnableThread,而Android、Apple、Unix等系統源自Unix系統的POSIX thread(PThread)機制。
POSIX執行緒庫是用於C/C++的基於標準的執行緒API,允許生成一個新的並行程序流,在多處理器或多核系統上最有效,在這些系統中,程序可以被安排在另一個處理器上執行,從而通過並行或分散式處理提高速度。執行緒比「分叉」或生成新程序需要更少的開銷,因為系統不會為程序初始化新的系統虛擬記憶體空間和環境。
雖然在多處理器系統上最有效,但在利用I/O延遲和其他可能停止程序執行的系統功能的單處理器系統上也可以獲得收益。(一個執行緒可能在另一個執行緒等待I/O或其他系統延遲時執行。)並行程式設計技術(如MPI和PVM)用於分散式計算環境,而執行緒僅限於單個計算機系統。程序中的所有執行緒共用相同的地址空間,通過定義一個函數及其將線上程中處理的引數來生成執行緒。在軟體中使用POSIX執行緒庫的目的是更快地執行軟體。
本質上,PThread是程序,但和普通的程序更輕量,也常被稱為輕量化程序,適合用來模擬執行緒。它是Unix系的系統才有的概念,Windows不存在。
在UE體系中,FRunnableThread只提供了基礎的跨平臺執行緒功能,在實際應用中,需要結合ThreadManager、Runnable、Task、Queue、TaskGraph等型別進行互動,從而形成完整的並行體系。更多技術細節可參閱2.4 UE的多執行緒機制,本篇不再累述。
FPlatformProcess是對FGenericPlatformProcess的型別重定義,機制和FGenericPlatformMisc類似,封裝和代表了各個作業系統的程序。下面是FGenericPlatformProcess的定義:
// GenericPlatformProcess.h
struct CORE_API FGenericPlatformProcess
{
// 號誌
struct FSemaphore
{
const TCHAR* GetName() const;
virtual void Lock() = 0;
virtual bool TryLock(uint64 NanosecondsToWait) = 0;
virtual void Unlock() = 0;
protected:
enum Limits
{
MaxSemaphoreName = 128
};
TCHAR Name[MaxSemaphoreName];
};
// dll/模組
static void* GetDllHandle( const TCHAR* Filename );
static void FreeDllHandle( void* DllHandle );
static void* GetDllExport( void* DllHandle, const TCHAR* ProcName );
static void AddDllDirectory(const TCHAR* Directory);
static void PushDllDirectory(const TCHAR* Directory);
static void PopDllDirectory(const TCHAR* Directory);
static void GetDllDirectories(TArray<FString>& OutDllDirectories);
static const TCHAR* GetModulePrefix();
static const TCHAR* GetModuleExtension();
// 程序/應用程式
static FProcHandle CreateProc( const TCHAR* URL, const TCHAR* Parms, ...);
static FProcHandle OpenProcess(uint32 ProcessID);
static bool IsProcRunning( FProcHandle & ProcessHandle );
static void WaitForProc( FProcHandle & ProcessHandle );
static void CloseProc( FProcHandle & ProcessHandle );
static void TerminateProc( FProcHandle & ProcessHandle, bool KillTree = false );
static EWaitAndForkResult WaitAndFork();
static bool GetProcReturnCode( FProcHandle & ProcHandle, int32* ReturnCode );
static bool IsApplicationRunning( uint32 ProcessId );
static bool IsApplicationRunning( const TCHAR* ProcName );
static FString GetApplicationName( uint32 ProcessId );
static bool GetApplicationMemoryUsage(uint32 ProcessId, SIZE_T* OutMemoryUsage);
static bool ExecProcess(const TCHAR* URL, const TCHAR* Params,...);
static bool ExecElevatedProcess(const TCHAR* URL, ...);
static void LaunchURL( const TCHAR* URL, const TCHAR* Parms, FString* Error );
static bool CanLaunchURL(const TCHAR* URL);
static bool LaunchFileInDefaultExternalApplication( const TCHAR* FileName, ...);
static void Sleep( float Seconds );
static void SleepNoStats( float Seconds );
static void SleepInfinite();
static void YieldThread();
static void Yield();
static void YieldCycles(uint64 Cycles);
static ENamedThreads::Type GetDesiredThreadForUObjectReferenceCollector();
static void ModifyThreadAssignmentForUObjectReferenceCollector( int32& NumThreads, i... );
static void ConditionalSleep(TFunctionRef<bool()> Condition, float SleepTime = 0.0f);
static void SetRealTimeMode();
static bool Daemonize();
static bool IsFirstInstance();
static void TearDown();
static bool SkipWaitForStats();
// 程序屬性
static uint32 GetCurrentProcessId();
static uint32 GetCurrentCoreNumber();
static const TCHAR* ComputerName();
static const TCHAR* UserName(bool bOnlyAlphaNumeric = true);
static bool SetProcessLimits(EProcessResource::Type Resource, uint64 Limit);
static const TCHAR* ExecutableName(bool bRemoveExtension = true);
// 執行緒/池/同步
static void SetThreadAffinityMask( uint64 AffinityMask );
static void SetThreadPriority( EThreadPriority NewPriority );
static void SetThreadName( const TCHAR* ThreadName );
static uint32 GetStackSize();
static void DumpThreadInfo( const TCHAR* MarkerName );
static void SetupGameThread();
static void SetupRenderThread();
static void SetupRHIThread();
static void SetupAudioThread();
static void TeardownAudioThread();
static class FEvent* GetSynchEventFromPool(bool bIsManualReset = false);
static void FlushPoolSyncEvents();
static void ReturnSynchEventToPool(FEvent* Event);
static class FRunnableThread* CreateRunnableThread();
// 管道
static void ClosePipe( void* ReadPipe, void* WritePipe );
static bool CreatePipe(void*& ReadPipe, void*& WritePipe, bool bWritePipeLocal = false);
static FString ReadPipe( void* ReadPipe );
static bool ReadPipeToArray(void* ReadPipe, TArray<uint8> & Output);
static bool WritePipe(void* WritePipe, const FString& Message, FString* OutWritten = nullptr);
static bool SupportsMultithreading();
// 程序間通訊(IPC)
static FSemaphore* NewInterprocessSynchObject(const FString& Name, bool bCreate, uint32 MaxLocks = 1);
static FSemaphore* NewInterprocessSynchObject(const TCHAR* Name, bool bCreate, uint32 MaxLocks = 1);
static bool DeleteInterprocessSynchObject(FSemaphore * Object);
// 目錄/路徑
static bool ShouldSaveToUserDir();
static const TCHAR* BaseDir();
static const TCHAR* UserDir();
static const TCHAR *UserSettingsDir();
static const TCHAR *UserTempDir();
static const TCHAR *UserHomeDir();
static const TCHAR* ApplicationSettingsDir();
static const TCHAR* ShaderDir();
static void SetShaderDir(const TCHAR*Where);
static void SetCurrentWorkingDirectoryToBaseDir();
static FString GetCurrentWorkingDirectory();
static const FString ShaderWorkingDir();
static void CleanShaderWorkingDir();
static const TCHAR* ExecutablePath();
static FString GenerateApplicationPath( const FString& AppName, EBuildConfiguration BuildConfiguration);
static const TCHAR* GetBinariesSubdirectory();
static const FString GetModulesDirectory();
// 其它
static FString GetGameBundleId();
static void ExploreFolder( const TCHAR* FilePath );
(...)
};
以上可得知,UE的程序基礎封裝了很多介面,包含程序生命週期、應用程式操作、執行緒管理、執行緒同步、程序間通訊和同步、目錄和路徑、DLL模組等。
FGenericPlatformProcess的繼承體系和實現與FGenericPlatformMisc類似,本文不再累述,有興趣的童鞋自行閱讀UE原始碼。
UE為了滿足各種各樣的跨執行緒、跨程序之間的通訊和同步,封裝了很多同步物件。下面抽取部分重要的型別進行簡要分析。
FCriticalSection是使用作業系統的臨界區機制,是使用者空間的概念和機制,而FSystemWideCriticalSection是使用了作業系統的核心物件Mutex(互斥體)。它們的常見介面如下(以Windows為例):
// WindowsCriticalSection.h
class FWindowsCriticalSection
{
public:
void Lock();
bool TryLock();
void Unlock();
(...)
};
class FWindowsSystemWideCriticalSection
{
public:
bool IsValid() const;
void Release();
private:
// 使用Windows的Mutex核心物件實現.
Windows::HANDLE Mutex;
(...)
};
FCriticalSection不涉及使用者和核心態的轉換,效率更高,但只能用於執行緒間的同步,而不能用於程序間同步。
FSystemWideCriticalSection涉及到了使用者和核心態的轉換,效率比FCriticalSection低很多,但可以用作程序間同步。
FRWLock提供非遞迴讀/寫(或共用獨佔)存取,常用於多執行緒存取同一個資料塊。它的特殊之處在於,如果只是讀,則允許多個執行緒同時讀,但如果有一個執行緒是寫,則該執行緒必須獨佔資料塊,帶寫入完畢,才允許其它執行緒讀或寫,以保證安全。其定義如下(以Windows為例):
// WindowsCriticalSection.h
class FWindowsRWLock
{
public:
FWindowsRWLock(uint32 Level = 0);
~FWindowsRWLock();
void ReadLock();
void WriteLock();
void ReadUnlock();
void WriteUnlock();
private:
Windows::SRWLOCK Mutex;
};
FPlatformAtomics封裝了各個作業系統的原子操作,介面如下(以Windows為例):
// GenericPlatformAtomics.h
struct CORE_API FWindowsPlatformAtomics : public FGenericPlatformAtomics
{
// Increment
static int8 InterlockedIncrement( volatile int8* Value );
static int16 InterlockedIncrement( volatile int16* Value );
static int32 InterlockedIncrement( volatile int32* Value );
static int64 InterlockedIncrement( volatile int64* Value );
// Decrement
static int8 InterlockedDecrement( volatile int8* Value );
static int16 InterlockedDecrement( volatile int16* Value );
static int32 InterlockedDecrement( volatile int32* Value );
static int64 InterlockedDecrement( volatile int64* Value );
// Add
static int8 InterlockedAdd( volatile int8* Value, int8 Amount );
static int16 InterlockedAdd( volatile int16* Value, int16 Amount );
static int32 InterlockedAdd( volatile int32* Value, int32 Amount );
static int64 InterlockedAdd( volatile int64* Value, int64 Amount );
// Exchange
static int8 InterlockedExchange( volatile int8* Value, int8 Exchange );
static int16 InterlockedExchange( volatile int16* Value, int16 Exchange );
static int32 InterlockedExchange( volatile int32* Value, int32 Exchange );
static int64 InterlockedExchange( volatile int64* Value, int64 Exchange );
static void* InterlockedExchangePtr( void*volatile* Dest, void* Exchange );
static int8 InterlockedCompareExchange( volatile int8* Dest, int8 Exchange, int8 Comparand );
static int16 InterlockedCompareExchange( volatile int16* Dest, int16 Exchange, int16 Comparand );
static int32 InterlockedCompareExchange( volatile int32* Dest, int32 Exchange, int32 Comparand );
static int64 InterlockedCompareExchange( volatile int64* Dest, int64 Exchange, int64 Comparand );
// And
static int8 InterlockedAnd(volatile int8* Value, const int8 AndValue);
static int16 InterlockedAnd(volatile int16* Value, const int16 AndValue);
static int32 InterlockedAnd(volatile int32* Value, const int32 AndValue);
static int64 InterlockedAnd(volatile int64* Value, const int64 AndValue);
// Or / Xor
static int8 InterlockedOr(volatile int8* Value, const int8 OrValue);
static int16 InterlockedOr(volatile int16* Value, const int16 OrValue);
static int32 InterlockedOr(volatile int32* Value, const int32 OrValue);
static int64 InterlockedOr(volatile int64* Value, const int64 OrValue);
static int8 InterlockedXor(volatile int8* Value, const int8 XorValue);
static int16 InterlockedXor(volatile int16* Value, const int16 XorValue);
static int32 InterlockedXor(volatile int32* Value, const int32 XorValue);
static int64 InterlockedXor(volatile int64* Value, const int64 XorValue);
// Read
static int8 AtomicRead(volatile const int8* Src);
static int16 AtomicRead(volatile const int16* Src);
static int32 AtomicRead(volatile const int32* Src);
static int64 AtomicRead(volatile const int64* Src);
// Read Relaxed
static int8 AtomicRead_Relaxed(volatile const int8* Src);
static int16 AtomicRead_Relaxed(volatile const int16* Src);
static int32 AtomicRead_Relaxed(volatile const int32* Src);
static int64 AtomicRead_Relaxed(volatile const int64* Src);
// Store
static void AtomicStore(volatile int8* Src, int8 Val);
static void AtomicStore(volatile int16* Src, int16 Val);
static void AtomicStore(volatile int32* Src, int32 Val);
static void AtomicStore(volatile int64* Src, int64 Val);
// Store Relaxed
static void AtomicStore_Relaxed(volatile int8* Src, int8 Val);
static void AtomicStore_Relaxed(volatile int16* Src, int16 Val);
static void AtomicStore_Relaxed(volatile int32* Src, int32 Val);
static void AtomicStore_Relaxed(volatile int64* Src, int64 Val);
#if PLATFORM_HAS_128BIT_ATOMICS
static bool InterlockedCompareExchange128( volatile FInt128* Dest, const FInt128& Exchange, FInt128* Comparand );
static void AtomicRead128(const volatile FInt128* Src, FInt128* OutResult);
#endif
static void* InterlockedCompareExchangePointer( void*volatile* Dest, void* Exchange, void* Comparand );
static bool CanUseCompareExchange128();
protected:
static void HandleAtomicsFailure( const TCHAR* InFormat, ... );
};
TLS全稱是Thread Local Storage,意為執行緒區域性儲存,顧名思義,就是可以給每個執行緒儲存獨有的資料,從而避免多執行緒之間的競爭,提升效能。
UE提供了FPlatformTLS,以為上層模組提供統一而簡潔的TLS操作介面。其定義如下(以Windows為例):
// WindowsPlatformTLS.h
struct FWindowsPlatformTLS : public FGenericPlatformTLS
{
// TLS對應的執行緒id。
static uint32 GetCurrentThreadId(void);
// TLS對應的插槽。
static uint32 AllocTlsSlot(void);
static void FreeTlsSlot(uint32 SlotIndex);
// TLS值操作。
static void SetTlsValue(uint32 SlotIndex,void* Value);
static void* GetTlsValue(uint32 SlotIndex);
};
使用範例之一是LockFreeList實現:
// LockFreeList.cpp
class LockFreeLinkAllocator_TLSCache : public FNoncopyable
{
public:
LockFreeLinkAllocator_TLSCache()
{
check(IsInGameThread());
TlsSlot = FPlatformTLS::AllocTlsSlot();
check(FPlatformTLS::IsValidTlsSlot(TlsSlot));
}
~LockFreeLinkAllocator_TLSCache()
{
FPlatformTLS::FreeTlsSlot(TlsSlot);
TlsSlot = 0;
}
private:
FThreadLocalCache& GetTLS()
{
checkSlow(FPlatformTLS::IsValidTlsSlot(TlsSlot));
FThreadLocalCache* TLS = (FThreadLocalCache*)FPlatformTLS::GetTlsValue(TlsSlot);
if (!TLS)
{
TLS = new FThreadLocalCache();
FPlatformTLS::SetTlsValue(TlsSlot, TLS);
}
return *TLS;
}
uint32 TlsSlot;
(...)
};
FPlatformNamedPipe是對作業系統的命名管道通訊的封裝,提供了以下介面:
// GenericPlatformNamedPipe.h
class FGenericPlatformNamedPipe
{
public:
virtual bool Create(const FString& PipeName, bool bServer, bool bAsync);
virtual bool Destroy();
virtual bool OpenConnection();
virtual bool BlockForAsyncIO();
virtual bool UpdateAsyncStatus();
virtual bool IsCreated() const;
virtual bool HasFailed() const;
virtual bool IsReadyForRW() const;
virtual bool WriteBytes(int32 NumBytes, const void* Data);
inline bool WriteInt32(int32 In);
virtual bool ReadBytes(int32 NumBytes, void* OutData);
inline bool ReadInt32(int32& Out);
virtual const FString& GetName() const;
protected:
FString* NamePtr;
};
UE用到FPlatformNamedPipe的是著色器編譯模組:
// XGEControlWorker.cpp
class FXGEControlWorker
{
const FString PipeName;
FProcHandle XGConsoleProcHandle;
// 輸入、輸出命名管道。
FPlatformNamedPipe InputNamedPipe;
FPlatformNamedPipe OutputNamedPipe;
(...)
};
FPlatformStackWalk是大多數平臺下對堆疊遍歷的通用實現,定義如下:
// GenericPlatformStackWalk.h
struct FGenericPlatformStackWalk
{
// 初始化
static void Init();
static bool InitStackWalking();
static bool InitStackWalkingForProcess(const FProcHandle& Process);
// 程式計數器
static bool ProgramCounterToHumanReadableString( int32 CurrentCallDepth, ... );
static void ProgramCounterToSymbolInfo( uint64 ProgramCounter, ...);
static void ProgramCounterToSymbolInfoEx( uint64 ProgramCounter, ...);
// 符號資訊
static bool SymbolInfoToHumanReadableString( const FProgramCounterSymbolInfo& SymbolInfo, ... );
static bool SymbolInfoToHumanReadableStringEx( const FProgramCounterSymbolInfoEx& SymbolInfo, ... );
static TArray<FProgramCounterSymbolInfo> GetStack(int32 IgnoreCount, ...);
// 捕獲堆疊
static uint32 CaptureStackBackTrace( uint64* BackTrace, uint32 MaxDepth, ... );
static uint32 CaptureThreadStackBackTrace(uint64 ThreadId, ...);
// 遍歷
static void StackWalkAndDump( ANSICHAR* HumanReadableString, ... );
static void ThreadStackWalkAndDump(ANSICHAR* HumanReadableString, ...);
static void StackWalkAndDumpEx( ANSICHAR* HumanReadableString, ... );
// 獲取介面.
static int32 GetProcessModuleCount();
static int32 GetProcessModuleSignatures(FStackWalkModuleInfo *ModuleSignatures, ...);
static TMap<FName, FString> GetSymbolMetaData();
(...)
};
FPlatformStackWalk的應用之一是程式崩潰時的呼叫堆疊列印和分析:
// CrashReportClientMainWindows.cpp
void SaveCrcCrashException(EXCEPTION_POINTERS* ExceptionInfo)
{
// 如果對談已建立,請嘗試在適當的欄位中寫入異常程式碼。遞增計數器的第一個崩潰執行緒贏得了競爭,並可以編寫其異常程式碼。
static volatile int32 CrashCount = 0;
if (FPlatformAtomics::InterlockedIncrement(&CrashCount) == 1)
{
FCrashReportAnalyticsSessionSummary::Get().OnCrcCrashing(ExceptionInfo->ExceptionRecord->ExceptionCode);
if (ExceptionInfo->ExceptionRecord->ExceptionCode != STATUS_HEAP_CORRUPTION)
{
// 嘗試讓異常呼叫堆疊記錄,以找出CRC崩潰的原因,但是不可靠,因為它在崩潰的程序中執行,並分配記憶體/使用呼叫堆疊,但我們仍然可以獲得一些有用的資料。
if (FPlatformStackWalk::InitStackWalkingForProcess(FProcHandle()))
{
FPlatformStackWalk::StackWalkAndDump(CrashStackTrace, UE_ARRAY_COUNT(CrashStackTrace), 0);
if (CrashStackTrace[0] != 0)
{
FCrashReportAnalyticsSessionSummary::Get().LogEvent(ANSI_TO_TCHAR(CrashStackTrace));
}
}
}
}
}
FPlatformMemory封裝抽象了各個作業系統下對記憶體的統一操作介面:
// GenericPlatformMemory.h
struct FGenericPlatformMemory
{
static bool bIsOOM; // 是否記憶體不足
static uint64 OOMAllocationSize; // 設定為觸發記憶體不足的分配大小,否則為零.
static uint32 OOMAllocationAlignment; // 設定為觸發記憶體不足的分配對齊,否則為零。
static void* BackupOOMMemoryPool; // 記憶體不足時要刪除的預分配緩衝區。用於OOM處理和崩潰報告。
static uint32 BackupOOMMemoryPoolSize; // BackupOOMMemoryPool的大小(位元組)。
// 可用於記憶體統計的各種記憶體區域。列舉的確切含義相對依賴於平臺,儘管一般的(物理、GPU)很簡單。一個平臺可以新增更多的記憶體,並且不會影響其他平臺,除了StatManager跟蹤每個區域的最大可用記憶體(使用陣列FPlatformMemory::MCR_max big)所需的少量記憶體之外.
enum EMemoryCounterRegion
{
MCR_Invalid, // not memory
MCR_Physical, // main system memory
MCR_GPU, // memory directly a GPU (graphics card, etc)
MCR_GPUSystem, // system memory directly accessible by a GPU
MCR_TexturePool, // presized texture pools
MCR_StreamingPool, // amount of texture pool available for streaming.
MCR_UsedStreamingPool, // amount of texture pool used for streaming.
MCR_GPUDefragPool, // presized pool of memory that can be defragmented.
MCR_PhysicalLLM, // total physical memory including CPU and GPU
MCR_MAX
};
// 使用的分配器.
enum EMemoryAllocatorToUse
{
Ansi, // Default C allocator
Stomp, // Allocator to check for memory stomping
TBB, // Thread Building Blocks malloc
Jemalloc, // Linux/FreeBSD malloc
Binned, // Older binned malloc
Binned2, // Newer binned malloc
Binned3, // Newer VM-based binned malloc, 64 bit only
Platform, // Custom platform specific allocator
Mimalloc, // mimalloc
};
static EMemoryAllocatorToUse AllocatorToUse;
enum ESharedMemoryAccess
{
Read = (1 << 1),
Write = (1 << 2)
};
// 共用記憶體區域的通用表示
struct FSharedMemoryRegion
{
TCHAR Name[MaxSharedMemoryName];
uint32 AccessMode;
void * Address;
SIZE_T Size;
};
// 記憶體操作.
static void Init();
static void OnOutOfMemory(uint64 Size, uint32 Alignment);
static void SetupMemoryPools();
static uint32 GetBackMemoryPoolSize()
static FMalloc* BaseAllocator();
static FPlatformMemoryStats GetStats();
static uint64 GetMemoryUsedFast();
static void GetStatsForMallocProfiler( FGenericMemoryStats& out_Stats );
static const FPlatformMemoryConstants& GetConstants();
static uint32 GetPhysicalGBRam();
static bool PageProtect(void* const Ptr, const SIZE_T Size, const bool bCanRead, const bool bCanWrite);
// 分配.
static void* BinnedAllocFromOS( SIZE_T Size );
static void BinnedFreeToOS( void* Ptr, SIZE_T Size );
static void NanoMallocInit();
static bool PtrIsOSMalloc( void* Ptr);
static bool IsNanoMallocAvailable();
static bool PtrIsFromNanoMalloc( void* Ptr);
// 虛擬記憶體塊及操作.
class FBasicVirtualMemoryBlock
{
protected:
void *Ptr;
uint32 VMSizeDivVirtualSizeAlignment;
public:
FBasicVirtualMemoryBlock(const FBasicVirtualMemoryBlock& Other) = default;
FBasicVirtualMemoryBlock& operator=(const FBasicVirtualMemoryBlock& Other) = default;
FORCEINLINE uint32 GetActualSizeInPages() const;
FORCEINLINE void* GetVirtualPointer() const;
void Commit(size_t InOffset, size_t InSize);
void Decommit(size_t InOffset, size_t InSize);
void FreeVirtual();
void CommitByPtr(void *InPtr, size_t InSize);
void DecommitByPtr(void *InPtr, size_t InSize);
void Commit();
void Decommit();
size_t GetActualSize() const;
static FPlatformVirtualMemoryBlock AllocateVirtual(size_t Size, ...);
static size_t GetCommitAlignment();
static size_t GetVirtualSizeAlignment();
};
// 資料和偵錯
static bool BinnedPlatformHasMemoryPoolForThisSize(SIZE_T Size);
static void DumpStats( FOutputDevice& Ar );
static void DumpPlatformAndAllocatorStats( FOutputDevice& Ar );
static EPlatformMemorySizeBucket GetMemorySizeBucket();
// 記憶體資料操作.
static void* Memmove( void* Dest, const void* Src, SIZE_T Count );
static int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count );
static void* Memset(void* Dest, uint8 Char, SIZE_T Count);
static void* Memzero(void* Dest, SIZE_T Count);
static void* Memcpy(void* Dest, const void* Src, SIZE_T Count);
static void* BigBlockMemcpy(void* Dest, const void* Src, SIZE_T Count);
static void* StreamingMemcpy(void* Dest, const void* Src, SIZE_T Count);
static void* ParallelMemcpy(void* Dest, const void* Src, SIZE_T Count, EMemcpyCachePolicy Policy = EMemcpyCachePolicy::StoreCached);
(...)
};
更多詳情可參閱1.4.3 記憶體分配。
FPlatformMath封裝了一組依賴於作業系統的高效數學運算,定義如下:
// GenericPlatformMath.h
struct FGenericPlatformMath
{
static float LoadHalf(const uint16* Ptr);
static void StoreHalf(uint16* Ptr, float Value);
static void VectorLoadHalf(float* RESTRICT Dst, const uint16* RESTRICT Src);
static void VectorStoreHalf(uint16* RESTRICT Dst, const float* RESTRICT Src);
static void WideVectorLoadHalf(float* RESTRICT Dst, const uint16* RESTRICT Src);
static void WideVectorStoreHalf(uint16* RESTRICT Dst, const float* RESTRICT Src);
static inline uint32 AsUInt(float F);
static inline uint64 AsUInt(double F);
static int32 TruncToInt(float F);
static int32 TruncToInt(double F);
static float TruncToFloat(float F);
static int32 FloorToInt(float F);
static int32 FloorToInt(double F);
static float FloorToFloat(float F);
static int32 RoundToInt(float F);
static int32 RoundToInt(double F);
static float RoundToFloat(float F);
static int32 CeilToInt(float F);
static int32 CeilToInt(double F);
static float CeilToFloat(float F);
static float Fractional(float Value);
static float Frac(float Value);
static float Modf(const float InValue, float* OutIntPart);
static float Pow( float A, float B );
static float Exp( float Value );
static float Loge( float Value );
static float LogX( float Base, float Value );
static uint32 FloorLog2(uint32 Value);
static float Sqrt( float Value );
static float InvSqrt( float F );
static float InvSqrtEst( float F );
static bool IsNaN( float A );
static bool IsNaN(double A);
static bool IsFinite( float A );
static bool IsFinite(double A);
static bool IsNegative(float A);
static float Fmod(float X, float Y);
static float Sin( float Value );
static float Asin( float Value );
static float Sinh(float Value);
static float Cos( float Value );
static float Acos( float Value );
static float Tan( float Value );
static float Atan( float Value );
static int32 Rand();
static void RandInit(int32 Seed);
static float FRand();
static void SRandInit( int32 Seed );
static int32 GetRandSeed();
static float SRand();
(...)
};
IFileHandle是UE對各個系統下的單個檔案的封裝,IPlatformFile是UE對各個系統下的檔案的封裝,而FPlatformFileManager是對IPlatformFile鏈的管理。
先看看IFileHandle的核心繼承圖:
除了上圖顯示的檔案型別,還有FAsyncBufferedFileReaderWindows、FLoggedFileHandle、FManagedStorageFileWriteHandle、FRegisteredFileHandle、FNetworkFileHandle、FStreamingNetworkFileHandle、FPakFileHandle、FStorageServerFileHandle等檔案型別。
再看看IPlatformFile的核心UML圖:
當然,還有很多非常規的檔案型別繼承自IPlatformFile:
下面看看IFileHandle和IPlatformFile的定義:
// GenericPlatformFile.h
class IFileHandle
{
public:
virtual int64 Tell() = 0;
virtual bool Seek(int64 NewPosition) = 0;
virtual bool SeekFromEnd(int64 NewPositionRelativeToEnd = 0) = 0;
virtual bool Read(uint8* Destination, int64 BytesToRead) = 0;
virtual bool Write(const uint8* Source, int64 BytesToWrite) = 0;
virtual bool Flush(const bool bFullFlush = false) = 0;
virtual bool Truncate(int64 NewSize) = 0;
virtual void ShrinkBuffers()
virtual int64 Size();
};
class IPlatformFile
{
public:
static IPlatformFile& GetPlatformPhysical();
static const TCHAR* GetPhysicalTypeName();
virtual void SetSandboxEnabled(bool bInEnabled)
virtual bool IsSandboxEnabled() const
virtual bool ShouldBeUsed(IPlatformFile* Inner, const TCHAR* CmdLine) const
virtual bool Initialize(IPlatformFile* Inner, const TCHAR* CmdLine) = 0;
virtual void InitializeAfterSetActive()
virtual void InitializeAfterProjectFilePath()
virtual void MakeUniquePakFilesForTheseFiles(const TArray<TArray<FString>>& InFiles)
virtual void InitializeNewAsyncIO();
virtual void AddLocalDirectories(TArray<FString> &LocalDirectories)
virtual void BypassSecurity(bool bInBypass)
virtual void Tick();
virtual IPlatformFile* GetLowerLevel() = 0;
virtual void SetLowerLevel(IPlatformFile* NewLowerLevel) = 0;
virtual const TCHAR* GetName() const = 0;
virtual bool FileExists(const TCHAR* Filename) = 0;
virtual int64 FileSize(const TCHAR* Filename) = 0;
virtual bool DeleteFile(const TCHAR* Filename) = 0;
virtual bool IsReadOnly(const TCHAR* Filename) = 0;
virtual bool MoveFile(const TCHAR* To, const TCHAR* From) = 0;
virtual bool SetReadOnly(const TCHAR* Filename, bool bNewReadOnlyValue) = 0;
virtual FDateTime GetTimeStamp(const TCHAR* Filename) = 0;
virtual void SetTimeStamp(const TCHAR* Filename, FDateTime DateTime) = 0;
virtual FDateTime GetAccessTimeStamp(const TCHAR* Filename) = 0;
virtual FString GetFilenameOnDisk(const TCHAR* Filename) = 0;
virtual IFileHandle* OpenRead(const TCHAR* Filename, bool bAllowWrite = false) = 0;
virtual IFileHandle* OpenReadNoBuffering(const TCHAR* Filename, bool bAllowWrite = false)
virtual IFileHandle* OpenWrite(const TCHAR* Filename, bool bAppend = false, bool bAllowRead = false) = 0;
virtual bool DirectoryExists(const TCHAR* Directory) = 0;
virtual bool CreateDirectory(const TCHAR* Directory) = 0;
virtual bool DeleteDirectory(const TCHAR* Directory) = 0;
virtual FFileStatData GetStatData(const TCHAR* FilenameOrDirectory) = 0;
// 僅使用名稱的檔案和目錄存取者的基礎類別。
class FDirectoryVisitor
{
public:
virtual bool Visit(const TCHAR* FilenameOrDirectory, bool bIsDirectory) = 0;
FORCEINLINE bool IsThreadSafe() const
EDirectoryVisitorFlags DirectoryVisitorFlags;
};
typedef TFunctionRef<bool(const TCHAR*, bool)> FDirectoryVisitorFunc;
// 獲取所有統計資料的檔案和目錄存取者的基礎類別.
class FDirectoryStatVisitor
{
public:
virtual bool Visit(const TCHAR* FilenameOrDirectory, const FFileStatData& StatData) = 0;
};
typedef TFunctionRef<bool(const TCHAR*, const FFileStatData&)> FDirectoryStatVisitorFunc;
virtual bool IterateDirectory(const TCHAR* Directory, FDirectoryVisitor& Visitor) = 0;
virtual bool IterateDirectoryStat(const TCHAR* Directory, FDirectoryStatVisitor& Visitor) = 0;
virtual IAsyncReadFileHandle* OpenAsyncRead(const TCHAR* Filename);
virtual void SetAsyncMinimumPriority(EAsyncIOPriorityAndFlags MinPriority)
virtual IMappedFileHandle* OpenMapped(const TCHAR* Filename)
virtual void GetTimeStampPair(const TCHAR* PathA, const TCHAR* PathB, FDateTime& OutTimeStampA, FDateTime& OutTimeStampB);
virtual FDateTime GetTimeStampLocal(const TCHAR* Filename);
virtual bool IterateDirectory(const TCHAR* Directory, FDirectoryVisitorFunc Visitor);
virtual bool IterateDirectoryStat(const TCHAR* Directory, FDirectoryStatVisitorFunc Visitor);
virtual bool IterateDirectoryRecursively(const TCHAR* Directory, FDirectoryVisitor& Visitor);
virtual bool IterateDirectoryStatRecursively(const TCHAR* Directory, FDirectoryStatVisitor& Visitor);
virtual bool IterateDirectoryRecursively(const TCHAR* Directory, FDirectoryVisitorFunc Visitor);
virtual bool IterateDirectoryStatRecursively(const TCHAR* Directory, FDirectoryStatVisitorFunc Visitor);
virtual void FindFiles(TArray<FString>& FoundFiles, const TCHAR* Directory, const TCHAR* FileExtension);
virtual void FindFilesRecursively(TArray<FString>& FoundFiles, const TCHAR* Directory, const TCHAR* FileExtension);
virtual bool DeleteDirectoryRecursively(const TCHAR* Directory);
virtual bool CreateDirectoryTree(const TCHAR* Directory);
virtual bool CopyFile(const TCHAR* To, const TCHAR* From, EPlatformFileRead ReadFlags = EPlatformFileRead::None, EPlatformFileWrite WriteFlags = EPlatformFileWrite::None);
virtual bool CopyDirectoryTree(const TCHAR* DestinationDirectory, const TCHAR* Source, bool bOverwriteAllExisting);
virtual FString ConvertToAbsolutePathForExternalAppForRead( const TCHAR* Filename );
virtual FString ConvertToAbsolutePathForExternalAppForWrite( const TCHAR* Filename );
// 用於向檔案伺服器函數傳送/接收資料的幫助程式類.
class IFileServerMessageHandler
{
public:
virtual ~IFileServerMessageHandler() { }
virtual void FillPayload(FArchive& Payload) = 0;
virtual void ProcessResponse(FArchive& Response) = 0;
};
virtual bool SendMessageToServer(const TCHAR* Message, IFileServerMessageHandler* Handler)
virtual bool DoesCreatePublicFiles()
virtual void SetCreatePublicFiles(bool bCreatePublicFiles)
};
IFileHandle、IPlatformFile和FPlatformFileManager的UML關係如下(忽略它們各自的子類):
FPlatformFileManager對外提供了平臺無關的檔案操作介面,定義如下:
// PlatformFileManager.h
class FPlatformFileManager
{
public:
static FPlatformFileManager& Get( );
IPlatformFile& GetPlatformFile( );
IPlatformFile* GetPlatformFile( const TCHAR* Name );
IPlatformFile* FindPlatformFile( const TCHAR* Name );
void SetPlatformFile( IPlatformFile& NewTopmostPlatformFile );
void TickActivePlatformFile();
void InitializeNewAsyncIO();
void RemovePlatformFile(IPlatformFile* PlatformFileToRemove);
private:
IPlatformFile* TopmostPlatformFile;
};
使用案例如下:
// CrashReportClientApp.cpp
static void HandleAbnormalShutdown(FSharedCrashContext& CrashContext, uint64 ProcessID, ...)
{
(...)
// 獲取平臺檔案物件。
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
// 建立臨時崩潰目錄
const FString TempCrashDirectory = FPlatformProcess::UserTempDir() / FString::Printf(TEXT("UECrashContext-%d"), ProcessID);
FCString::Strcpy(CrashContext.CrashFilesDirectory, *TempCrashDirectory);
if (PlatformFile.CreateDirectory(CrashContext.CrashFilesDirectory))
{
// 將紀錄檔檔案複製到臨時目錄
const FString LogDestination = TempCrashDirectory / FPaths::GetCleanFilename(CrashContext.UserSettings.LogFilePath);
PlatformFile.CopyFile(*LogDestination, CrashContext.UserSettings.LogFilePath);
// 此崩潰不是真正的崩潰,而是在異常終止時捕獲編輯器紀錄檔的崩潰。
FCrashReportAnalyticsSessionSummary::Get().LogEvent(TEXT("SyntheticCrash"));
(...)
// 刪除臨時崩潰目錄。
PlatformFile.DeleteDirectoryRecursively(*TempCrashDirectory);
(...)
}
}
本篇主要闡述了作業系統的相關知識,包含執行緒、程序、同步、通訊、記憶體、磁碟等等,以及UE對OS相關模組的封裝和實現,使得讀者對作業系統模組有著大致的理解,至於更多技術細節和原理,需要讀者自己去研讀UE原始碼發掘。