1. 程式人生 > 其它 >記憶體缺頁 - Page Fault

記憶體缺頁 - Page Fault

轉載自:https://liam.page/2017/09/01/page-fault/
眾所周知,CPU 不能直接和硬碟進行互動。CPU 所作的一切運算,都是通過 CPU 快取間接與記憶體進行操作的。若是 CPU 請求的記憶體資料在實體記憶體中不存在,那麼 CPU 就會報告「缺頁錯誤(Page Fault)」,提示核心。

在核心處理缺頁錯誤時,就有可能進行磁碟的讀寫操作。這樣的操作,相對 CPU 的處理是非常緩慢的。因此,發生大量的缺頁錯誤,勢必會對程式的效能造成很大影響。因此,在對效能要求很高的環境下,應當儘可能避免這種情況。

此篇介紹缺頁錯誤本身,並結合一個實際示例作出一些實踐分析。這裡主要在 Linux 的場景下做討論;其他現代作業系統,基本也是類似的。

記憶體頁和缺頁錯誤

分頁模式

我們在前作記憶體定址中介紹了 CPU 發展過程中記憶體定址方式的變化。現代 CPU 都支援分段和分頁的記憶體定址模式。出於定址能力的考慮,現代作業系統,也順應著都支援段頁式的記憶體管理模式。當然,雖然支援段頁式,但是 Linux 中只啟用了段基址為 0 的段。也就是說,在 Linux 當中,實際起作用的只有分頁模式。

具體來說,分頁模式在邏輯上將虛擬記憶體和實體記憶體同時等分成固定大小的塊。這些塊在虛擬記憶體上稱之為「頁」,而在實體記憶體上稱之為「頁幀」,並交由 CPU 中的 MMU 模組來負責頁幀和頁之間的對映管理。

引入分頁模式的好處,可以大致概括為兩個方面:

允許虛存空間遠大於實際實體記憶體大小的情況。這是因為,分頁之後,作業系統讀入磁碟的檔案時,無需以檔案為單位全部讀入,而可以以記憶體頁為單位,分片讀入。同時,考慮到 CPU 不可能一次性需要使用整個記憶體中的資料,因此可以交由特定的演算法,進行記憶體排程:將長時間不用的頁幀內的資料暫存到磁碟上。
減少了記憶體碎片的產生。這是因為,引入分頁之後,記憶體的分配管理都是以頁大小(通常是 4KiB,擴充套件分頁模式下是 4MiB)為單位的;虛擬記憶體中的頁總是對應實體記憶體中實際的頁幀。這樣一來,在虛擬記憶體空間中,頁內連續的記憶體在實體記憶體上也一定是連續的,不會產生碎片。
缺頁錯誤
當程序在進行一些計算時,CPU 會請求記憶體中儲存的資料。在這個請求過程中,CPU 發出的地址是邏輯地址(虛擬地址),然後交由 CPU 當中的 MMU 單元進行記憶體定址,找到實際實體記憶體上的內容。若是目標虛存空間中的記憶體頁(因為某種原因),在實體記憶體中沒有對應的頁幀,那麼 CPU 就無法獲取資料。這種情況下,CPU 是無法進行計算的,於是它就會報告一個缺頁錯誤(Page Fault)。

因為 CPU 無法繼續進行程序請求的計算,並報告了缺頁錯誤,使用者程序必然就中斷了。這樣的中斷稱之為缺頁中斷。在報告 Page Fault 之後,程序會從使用者態切換到系統態,交由作業系統核心的 Page Fault Handler 處理缺頁錯誤。

缺頁錯誤的分類和處理

基本來說,缺頁錯誤可以分為兩類:硬缺頁錯誤(Hard Page Fault)和軟缺頁錯誤(Soft Page Fault)。這裡,前者又稱為主要缺頁錯誤(Major Page Fault);後者又稱為次要缺頁錯誤(Minor Page Fault)。當缺頁中斷髮生後,Page Fault Handler 會判斷缺頁的型別,進而處理缺頁錯誤,最終將控制權交給使用者態程式碼。

若是此時實體記憶體裡,已經有一個頁幀正是此時 CPU 請求的記憶體頁,那麼這是一個軟缺頁錯誤;於是,Page Fault Hander 會指示 MMU 建立相應的頁幀到頁的對映關係。這一操作的實質是程序間共享記憶體——比如動態庫(共享物件),比如 mmap 的檔案。

若是此時實體記憶體中,沒有相應的頁幀,那麼這就是一個硬缺頁錯誤;於是 Page Fault Hander 會指示 CPU,從已經開啟的磁碟檔案中讀取相應的內容到實體記憶體,而後交由 MMU 建立這份頁幀到頁的對映關係。

不難發現,軟缺頁錯誤只是在核心態裡輕輕地走了一遭,而硬缺頁錯誤則涉及到磁碟 I/O。因此,處理起來,硬缺頁錯誤要比軟缺頁錯誤耗時長得多。這就是為什麼我們要求高效能程式必須在對外提供服務時,儘可能少地發生硬缺頁錯誤。

除了硬缺頁錯誤和軟缺頁錯誤之外,還有一類缺頁錯誤是因為訪問非法記憶體引起的。前兩類缺頁錯誤中,程序嘗試訪問的虛存地址尚為合法有效的地址,只是對應的實體記憶體頁幀沒有在實體記憶體當中。後者則不然,程序嘗試訪問的虛存地址是非法無效的地址。比如嘗試對 nullptr 解引用,就會訪問地址為 0x0 的虛存地址,這是非法地址。此時 CPU 報出無效缺頁錯誤(Invalid Page Fault)。作業系統對無效缺頁錯誤的處理各不相同:Windows 會使用異常機制向程序報告;*nix 則會通過向程序傳送 SIGSEGV 訊號(11),引發記憶體轉儲。

缺頁錯誤的原因

之前提到,實體記憶體中沒有 CPU 所需的頁幀,就會引發缺頁錯誤。這一現象背後的原因可能有很多。

例如說,程序通過 mmap 系統呼叫,直接建立了磁碟檔案和虛擬記憶體的對映關係。然而,在 mmap 呼叫之後,並不會立即從磁碟上讀取這一檔案。而是在實際需要檔案內容時,通過 CPU 觸發缺頁錯誤,要求 Page Fault Handler 去將檔案內容讀入記憶體。

又例如說,一個程序啟動了很久,但是長時間沒有活動。若是計算機處在很高的記憶體壓力下,則作業系統會將這一程序長期未使用的頁幀內容,從實體記憶體轉儲到磁碟上。這個過程稱為換出(swap out)。在 *nix 系統下,用於轉儲這部分記憶體內容的磁碟空間,稱為交換空間;在 Windows 上,這部分磁碟空間,則被稱為虛擬記憶體,對應磁碟上的檔案則稱為頁面檔案。在這個過程中,程序在記憶體中儲存的任意內容,都可能被換出到交換空間:可以是資料內容,也可以是程序的程式碼段內容。

Windows 使用者看到這裡,應該能明白這部分空間為什麼叫做「虛擬記憶體」——因為它於真實的記憶體條相對,是在硬碟上虛擬出來的一份記憶體。通過這樣的方式,「好像」將記憶體的容量擴大了。同樣,為什麼叫「頁面檔案」也一目瞭然。因為事實上,檔案內儲存的就是一個個記憶體頁幀。在 Windows 上經常能觀察到「假死」的現象,就和缺頁錯誤有關。這種現象,實際就是長期不執行某個程式,導致程式對應的記憶體被換出到磁碟;在需要響應時,由於需要從磁碟上讀取大量內容,導致響應很慢,產生假死現象。這種現象發生時,若是監控系統硬錯誤數量,就會發現在短時間內,目標程序產生了大量的硬錯誤。

在 Windows XP 流行的年代,有很多來路不明的「系統優化建議」。其中一條就是「擴大頁面檔案的大小,有助於加快系統速度」。事實上,這種方式只能加大記憶體「看起來」的容量,卻給記憶體整體(將實體記憶體和磁碟頁面檔案看做一個整體)的響應速度帶來了巨大的負面影響。因為,儘管容量增大了,但是訪問這部分增大的容量時,程序實際上需要先陷入核心態,從磁碟上讀取內容做好對映,再繼續執行。更有甚者,這些建議會要求「將頁面檔案分散在多個不同磁碟分割槽」,並美其名曰「分散壓力」。事實上,從頁面檔案中讀取記憶體頁幀本就已經很慢;若是還要求磁碟不斷在不同分割槽上定址,那就更慢了。可見謠言害死人。

觀察缺頁錯誤

Windows 系統
相對於工作管理員,Windows 的資源監視器知之者甚少。Windows 的資源監視器,可以實時顯示一系列硬體、軟體資源的適用情況。硬體資源包括 CPU、記憶體、磁碟和網路;軟體資源則是檔案控制代碼和模組。使用者可以在啟動視窗中,以 resmon.exe 啟動資源監視器(Vista 裡是 perfmon.exe)。或是由開始按鈕→所有程式→輔助程式→系統工具→資源監視器開啟。

在記憶體資源監視標籤中,有「硬錯誤/秒」或者「硬中斷/秒」的監控項。若是一直開啟資源監視器,以該項降序排列所有程序,則在發現程式卡頓、假死時,能觀察到大量硬錯誤爆發性產生。

上圖是 Outlook 長時間不適用後,使用者主動切換到 Outlook 時的情形。此時 Outlook 呈現假死狀態,同時觀察到 Outlook 觸發了大量的硬缺頁錯誤。

Linux 系統
ps 是一個強大的命令,我們可以用 -o 選項指定希望關注的專案。比如

min_flt: 程序啟動至今軟缺頁中斷數量;
maj_flt: 程序啟動至今硬缺頁中斷數量;
cmd: 執行的命令;
args: 執行的命令的引數(從 $0$ 開始);
uid: 執行命令的使用者的 ID;
gid: 執行命令的使用者所在組的 ID。
因此,我們可以用 ps -o min_flt,maj_flt,cmd,args,uid,gid 1 來觀察程序號為 1 的程序的缺頁錯誤。

$ ps -o min_flt,maj_flt,cmd,args,uid,gid 1
MINFL  MAJFL CMD                         COMMAND                       UID   GID
 3104     41 /sbin/init                  /sbin/init                      0     0

結合 watch 命令,則可關注程序當前出發缺頁中斷的狀態。

watch -n 1 --difference "ps -o min_flt,maj_flt,cmd,args,uid,gid 1"
你還可以結合 sort 命令,動態觀察產生缺頁錯誤最多的幾個程序。

$ watch -n 1 "ps -eo min_flt,maj_flt,cmd,args,uid,gid | sort -nrk1 | head -n 8"

Every 1.0s: ps -eo min_flt,maj_flt,cmd,args,uid,gid | sort -nrk1 | head -n 8

3027665711  1 tmux -2 new -s yu-ws        tmux -2 new -s yu-ws        19879 19879
1245846907 9082 tmux                      tmux                        20886 20886
1082463126 57 /usr/local/bin/tmux         /usr/local/bin/tmux          5638  5638
868590907   2 irqbalance                  irqbalance                      0     0
662275941 289831 tmux                     tmux                         2612  2612
424339087 247 perl ./bin_agent/bin/aos_cl perl ./bin_agent/bin/aos_cl     0     0
200045670   0 /bin/bash ./t.sh            /bin/bash ./t.sh            12498 12498
151206845 10335 tmux new -s dev           tmux new -s dev             16629 16629

這是公司開發伺服器上的一瞥,不難發現,我司有不少 tmux 使用者。(笑)

一個硬缺頁錯誤導致的問題

我司的某一高效能服務採取了 mmap 的方式,從磁碟載入大量資料。由於調研測試需要,多名組內成員共享一臺調研機器。現在的問題是,當共享的人數較多時,新啟動的服務程序會在啟動時耗費大量時間——以幾十分鐘計。那麼,這是為什麼呢?

因為涉及到公司機密,這裡不方便給截圖。留待以後,做模擬實驗後給出。

以 top 命令觀察,機器卡頓時,CPU 負載並不高:32 核只有 1.3 左右的 1min 平均負載。但是,iostat 觀察到,磁碟正在以 10MiB/s 級別的速度,不斷進行讀取。由此判斷,這種情況下,目標程序一定有大量的 Page Fault 產生。使用上述 watch -n 1 --difference "ps -o min_flt,maj_flt,cmd,args,uid,gid " 觀察,發現目標程序確實有大量硬缺頁錯誤產生,肯定了這一推斷。

然而,誠然程序需要載入大量資料,但是以 mmap 的方式對映,為何會已有大量同類服務存在的情況下,大量讀取硬碟呢?這就需要更加深入的分析了。

事實上,這裡隱含了一個非常細小的矛盾。一方面,該服務需要從磁碟載入大量資料;另一方面,該服務對效能要求非常高。我們知道,mmap 只是對檔案做了對映,不會在呼叫 mmap 時立即將檔案內容載入進記憶體。這就導致了一個問題:當服務啟動對外提供服務時,可能還有資料未能載入進記憶體;而這種載入是非常慢的,嚴重影響服務效能。因此,可以推斷,為了解決這個問題,程式必然在 mmap 之後,嘗試將所有資料載入進實體記憶體。

這樣一來,先前遇到的現象就很容易解釋了。

一方面,因為公用機器的人很多,必然造成記憶體壓力大,從而存在大量換出的記憶體;
另一方面,新啟動的程序,會逐幀地掃描檔案;
這樣一來,新啟動的程序,就必須在極大的記憶體壓力下,不斷逼迫系統將其它程序的記憶體換出,而後換入自己需要的記憶體,不斷進行磁碟 I/O;
故此,新啟動的程序會耗費大量時間進行不必要的磁碟 I/O。