華文網

程式師的自我修養:記憶體缺頁錯誤

眾所周知,CPU 不能直接和硬碟進行交互。CPU 所作的一切運算,都是通過 CPU 緩存間接與記憶體進行操作的。若是 CPU 請求的記憶體資料在實體記憶體中不存在,那麼 CPU 就會報告「缺頁錯誤(Page Fault)」,提示內核。

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

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

記憶體頁和缺頁錯誤

分頁模式

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

具體來說,分頁模式在邏輯上將虛擬記憶體和實體記憶體同時等分成固定大小的塊。

這些塊在虛擬記憶體上稱之為「頁」,而在實體記憶體上稱之為「頁幀」,並交由 CPU 中的 MMU 模組來負責頁幀和頁之間的映射管理。

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

擴大了 CPU 的定址空間大小。這是因為,以 4KiB 為頁大小時,CPU 的定址單位從 1Byte 增加到 4KiB;因此,定址空間擴大了 4096 倍。對於 32bit 的位址匯流排,定址空間就從 1MiB 擴大到了 4GiB。

允許虛存空間遠大於實際實體記憶體大小的情況。

這是因為,分頁之後,作業系統讀入磁片的檔時,無需以檔為單位全部讀入,而可以以記憶體頁為單位,分片讀入。同時,考慮到 CPU 不可能一次性需要使用整個記憶體中的資料,因此可以交由特定的演算法,進行記憶體調度:將長時間不用的頁幀內的資料暫存到磁片上。

缺頁錯誤

當進程在進行一些計算時,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 的進程的缺頁錯誤。

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

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

1watch -n 1 --difference "ps -o min_flt,maj_flt,cmd,args,uid,gid 1"

你還可以結合 sort 命令,動態觀察產生缺頁錯誤最多的幾個進程。

123456789101112$ 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 83027665711 1 tmux -2 new -s yu-ws tmux -2 new -s yu-ws 19879 198791245846907 9082 tmux tmux 20886 208861082463126 57 /usr/local/bin/tmux /usr/local/bin/tmux 5638 5638868590907 2 irqbalance irqbalance 0 0662275941 289831 tmux tmux 2612 2612424339087 247 perl ./bin_agent/bin/aos_cl perl ./bin_agent/bin/aos_cl 0 0200045670 0 /bin/bash ./t.sh /bin/bash ./t.sh 12498 12498151206845 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。

已經有一個頁幀正是此時 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 的進程的缺頁錯誤。

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

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

1watch -n 1 --difference "ps -o min_flt,maj_flt,cmd,args,uid,gid 1"

你還可以結合 sort 命令,動態觀察產生缺頁錯誤最多的幾個進程。

123456789101112$ 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 83027665711 1 tmux -2 new -s yu-ws tmux -2 new -s yu-ws 19879 198791245846907 9082 tmux tmux 20886 208861082463126 57 /usr/local/bin/tmux /usr/local/bin/tmux 5638 5638868590907 2 irqbalance irqbalance 0 0662275941 289831 tmux tmux 2612 2612424339087 247 perl ./bin_agent/bin/aos_cl perl ./bin_agent/bin/aos_cl 0 0200045670 0 /bin/bash ./t.sh /bin/bash ./t.sh 12498 12498151206845 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。