程序員的自我修養:內存缺頁錯誤

眾所周知,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 建立相應的頁幀到頁的映射關係。這一操作的實質是進程間共享內存——比如動態庫(共享對象),比如 <code>mmap/<code> 的文件。

若是此時物理內存中,沒有相應的頁幀,那麼這就是一個硬缺頁錯誤;於是 Page Fault Hander 會指示 CPU,從已經打開的磁盤文件中讀取相應的內容到物理內存,而後交由 MMU 建立這份頁幀到頁的映射關係。

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

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

缺頁錯誤的原因

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

例如說,進程通過 <code>mmap/<code> 系統調用,直接建立了磁盤文件和虛擬內存的映射關係。然而,在 <code>mmap/<code> 調用之後,並不會立即從磁盤上讀取這一文件。而是在實際需要文件內容時,通過 CPU 觸發缺頁錯誤,要求 Page Fault Handler 去將文件內容讀入內存。

又例如說,一個進程啟動了很久,但是長時間沒有活動。若是計算機處在很高的內存壓力下,則操作系統會將這一進程長期未使用的頁幀內容,從物理內存轉儲到磁盤上。這個過程稱為換出(swap out)。在 *nix 系統下,用於轉儲這部分內存內容的磁盤空間,稱為交換空間;在 Windows 上,這部分磁盤空間,則被稱為虛擬內存,對應磁盤上的文件則稱為頁面文件。在這個過程中,進程在內存中保存的任意內容,都可能被換出到交換空間:可以是數據內容,也可以是進程的代碼段內容。

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

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

觀察缺頁錯誤

Windows 系統

相對於任務管理器,Windows 的資源監視器知之者甚少。Windows 的資源監視器,可以實時顯示一系列硬件、軟件資源的適用情況。硬件資源包括 CPU、內存、磁盤和網絡;軟件資源則是文件句柄和模塊。用戶可以在啟動窗口中,以 <code>resmon.exe/<code> 啟動資源監視器(Vista 裡是 <code>perfmon.exe/<code>)。或是由開始按鈕→所有程序→輔助程序→系統工具→資源監視器打開。

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

程序員的自我修養:內存缺頁錯誤

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

程序員的自我修養:內存缺頁錯誤

Linux 系統

<code>ps/<code> 是一個強大的命令,我們可以用 <code>-o/<code> 選項指定希望關注的項目。比如

  • <code>min_flt/<code>: 進程啟動至今軟缺頁中斷數量;

  • <code>maj_flt/<code>: 進程啟動至今硬缺頁中斷數量;

  • <code>cmd/<code>: 執行的命令;

  • <code>args/<code>: 執行的命令的參數(從 <code>$0$/<code> 開始);

  • <code>uid/<code>: 執行命令的用戶的 ID;

  • <code>gid/<code>: 執行命令的用戶所在組的 ID。

因此,我們可以用 <code>ps -o min_flt,maj_flt,cmd,args,uid,gid 1/<code> 來觀察進程號為 <code>1/<code> 的進程的缺頁錯誤。

<table><tbody>
1
2
3
$ 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
/<tbody>/<table>

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

<table><tbody>
1
watch -n 1 --difference "ps -o min_flt,maj_flt,cmd,args,uid,gid 1"
/<tbody>/<table>

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

<table><tbody>
1
2
3
4
5
6
7
8
9
10
11
12
$ 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
/<tbody>/<table>

這是公司開發服務器上的一瞥,不難發現,我司有不少 <code>tmux/<code> 用戶。(笑)

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

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

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

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

然而,誠然進程需要載入大量數據,但是以 <code>mmap/<code> 的方式映射,為何會已有大量同類服務存在的情況下,大量讀取硬盤呢?這就需要更加深入的分析了。

事實上,這裡隱含了一個非常細小的矛盾。一方面,該服務需要從磁盤加載大量數據;另一方面,該服務對性能要求非常高。我們知道,<code>mmap/<code> 只是對文件做了映射,不會在調用 <code>mmap/<code> 時立即將文件內容加載進內存。這就導致了一個問題:當服務啟動對外提供服務時,可能還有數據未能加載進內存;而這種加載是非常慢的,嚴重影響服務性能。因此,可以推斷,為了解決這個問題,程序必然在 <code>mmap/<code> 之後,嘗試將所有數據加載進物理內存。

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

  • 一方面,因為公用機器的人很多,必然造成內存壓力大,從而存在大量換出的內存;

  • 另一方面,新啟動的進程,會逐幀地掃描文件;

  • 這樣一來,新啟動的進程,就必須在極大的內存壓力下,不斷逼迫系統將其它進程的內存換出,而後換入自己需要的內存,不斷進行磁盤 I/O;

  • 故此,新啟動的進程會耗費大量時間進行不必要的磁盤 I/O。


分享到:


相關文章: