OpenResty 和 Nginx 的共享記憶體區是如何消耗實體記憶體的

OpenResty技術發表於2020-08-25

OpenResty 和 Nginx 伺服器通常會配置共享記憶體區,用於儲存在所有工作程式之間共享的資料。例如,Nginx 標準模組 ngx_http_limit_reqngx_http_limit_conn 使用共享記憶體區儲存狀態資料,以限制所有工作程式中的使用者請求速率和使用者請求的併發度。OpenResty 的 ngx_lua 模組通過 lua_shared_dict,向使用者 Lua 程式碼提供基於共享記憶體的資料字典儲存。

本文通過幾個簡單和獨立的例子,探討這些共享記憶體區如何使用實體記憶體資源(或 RAM)。我們還會探討共享記憶體的使用率對系統層面的程式記憶體指標的影響,例如在 ps 等系統工具的結果中的 VSZRSS 等指標。

與本部落格網站 中的幾乎所有技術類文章類似,我們使用 OpenResty XRay 這款動態追蹤產品對未經修改的 OpenResty 或 Nginx 伺服器和應用的內部進行深度分析和視覺化呈現。因為 OpenResty XRay 是一個非侵入性的分析平臺,所以我們不需要對 OpenResty 或 Nginx 的目標程式做任何修改 -- 不需要程式碼注入,也不需要在目標程式中載入特殊外掛或模組。這樣可以保證我們通過 OpenResty XRay 分析工具所看到的目標程式內部狀態,與沒有觀察者時的狀態是完全一致的。

我們將在多數示例中使用 ngx_lua 模組的 lua_shared_dict,因為該模組可以使用自定義的 Lua 程式碼進行程式設計。我們在這些示例中展示的行為和問題,也同樣適用於所有標準 Nginx 模組和第三方模組中的其他共享記憶體區。

Slab 與記憶體頁

Nginx 及其模組通常使用 Nginx 核心裡的 slab 分配器 來管理共享記憶體區內的空間。這個slab 分配器專門用於在固定大小的記憶體區內分配和釋放較小的記憶體塊。

在 slab 的基礎之上,共享記憶體區會引入更高層面的資料結構,例如紅黑樹和連結串列等等。

slab 可能小至幾個位元組,也可能大至跨越多個記憶體頁。

作業系統以記憶體頁為單位來管理程式的共享記憶體(或其他種類的記憶體)。
x86_64 Linux 系統中,預設的記憶體頁大小通常是 4 KB,但具體大小取決於體系結構和 Linux 核心的配置。例如,某些 Aarch64 Linux 系統的記憶體頁大小高達 64 KB。

我們將會看到 OpenResty 和 Nginx 程式的共享記憶體區,分別在記憶體頁層面和 slab 層面上的細節資訊。

分配的記憶體不一定有消耗

與硬碟這樣的資源不同,實體記憶體(或 RAM)總是一種非常寶貴的資源。
大部分現代作業系統都實現了一種優化技術,叫做 按需分頁(demand-paging),用於減少使用者應用對 RAM 資源的壓力。具體來說,就是當你分配大塊的記憶體時,作業系統核心會將 RAM 資源(或實體記憶體頁)的實際分配推遲到記憶體頁裡的資料被實際使用的時候。例如,如果使用者程式分配了 10 個記憶體頁,但卻只使用了 3 個記憶體頁,則作業系統可能只把這 3 個記憶體頁對映到了 RAM 裝置。這種行為同樣適用於 Nginx 或 OpenResty 應用中分配的共享記憶體區。使用者可以在 nginx.conf 檔案中配置龐大的共享記憶體區,但他可能會注意到在伺服器啟動之後,幾乎沒有額外佔用多少記憶體,畢竟通常在剛啟動的時候,幾乎沒有共享記憶體頁被實際使用到。

空的共享記憶體區

我們以下面這個 nginx.conf 檔案為例。該檔案分配了一個空的共享記憶體區,並且從沒有使用過它:

master_process on;
worker_processes 2;

events {
    worker_connections 1024;
}

http {
    lua_shared_dict dogs 100m;

    server {
        listen 8080;

        location = /t {
            return 200 "hello world\n";
        }
    }
}

我們通過 lua_shared_dict 指令配置了一個 100 MB 的共享記憶體區,名為 dogs。並且我們為這個伺服器配置了 2 個工作程式。請注意,我們在配置裡從沒有觸及這個 dogs 區,所以這個區是空的。

可以通過下列命令啟動這個伺服器:

mkdir ~/work/
cd ~/work/
mkdir logs/ conf/
vim conf/nginx.conf  # paste the nginx.conf sample above here
/usr/local/openresty/nginx/sbin/nginx -p $PWD/

然後用下列命令檢視 nginx 程式是否已在執行:

$ ps aux|head -n1; ps aux|grep nginx
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
agentzh   9359  0.0  0.0 137508  1576 ?        Ss   09:10   0:00 nginx: master process /usr/local/openresty/nginx/sbin/nginx -p /home/agentzh/work/
agentzh   9360  0.0  0.0 137968  1924 ?        S    09:10   0:00 nginx: worker process
agentzh   9361  0.0  0.0 137968  1920 ?        S    09:10   0:00 nginx: worker process

這兩個工作程式佔用的記憶體大小很接近。下面我們重點研究 PID 為 9360 的這個工作程式。在 OpenResty XRay 控制檯的 Web 圖形介面中,我們可以看到這個程式一共佔用了 134.73 MB 的虛擬記憶體(virtual memory)和 1.88 MB 的常駐記憶體(resident memory),這與上文中的 ps 命令輸出的結果完全相同:

空的共享記憶體區的虛擬記憶體使用量明細

正如我們的另一篇文章 《OpenResty 和 Nginx 如何分配和管理記憶體》中所介紹的,我們最關心的就是常駐記憶體的使用量。常駐記憶體將硬體資源實際對映到相應的記憶體頁(如 RAM 1)。所以我們從圖中看到,實際對映到硬體資源的記憶體量很少,總計只有 1.88MB。上文配置的 100 MB 的共享記憶體區在這個常駐記憶體當中只佔很小的一部分(詳情請見後續的討論)。

當然,共享記憶體區的這 100 MB 還是全部貢獻到了該程式的虛擬記憶體總量中去了。作業系統會為這個共享記憶體區預留出虛擬記憶體的地址空間,不過,這只是一種簿記記錄,此時並不佔用任何的 RAM 資源或其他硬體資源。

不是 空無一物

我們可以通過該程式的“應用層面的記憶體使用量的分類明細”圖,來檢查空的共享記憶體區是否佔用了常駐(或物理)記憶體。

應用層面記憶體使用量明細

有趣的是,我們在這個圖中看到了一個非零的 Nginx Shm Loaded (已載入的 Nginx 共享記憶體)組分。這部分很小,只有 612 KB,但還是出現了。所以空的共享記憶體區也並非空無一物。這是因為 Nginx 已經在新初始化的共享記憶體區域中放置了一些後設資料,用於簿記目的。這些後設資料為 Nginx 的 slab 分配器所使用。

已載入和未載入記憶體頁

我們可以通過 OpenResty XRay 自動生成的下列圖表,檢視共享記憶體區內被實際使用(或載入)的記憶體頁數量。

共享記憶體區域內已載入和未載入的記憶體頁

我們發現在dogs區域中已經載入(或實際使用)的記憶體大小為 608 KB,同時有一個特殊的 ngx_accept_mutex_ptr 被 Nginx 核心自動分配用於 accept_mutex 功能。

這兩部分記憶體的大小相加為 612 KB,正是上文的餅狀圖中顯示的 Nginx Shm Loaded 的大小。

如前文所述,dogs 區使用的 608 KB 記憶體實際上是 slab 分配器 使用的後設資料。

未載入的記憶體頁只是被保留的虛擬記憶體地址空間,並沒有被使用過。

關於程式的頁表

我們沒有提及的一種複雜性是,每一個 nginx 工作程式其實都有各自的頁表。CPU 硬體或作業系統核心正是通過查詢這些頁表來查詢虛擬記憶體頁所對應的儲存。因此每個程式在不同共享記憶體區內可能有不同的已載入頁集合,因為每個程式在執行過程中可能訪問過不同的記憶體頁集合。為了簡化這裡的分析,OpenResty XRay 會顯示所有的為任意一個工作程式載入過的記憶體頁,即使當前的目標工作程式從未碰觸過這些記憶體頁。也正因為這個原因,已載入記憶體頁的總大小可能(略微)高於目標程式的常駐記憶體的大小。

空閒的和已使用的 slab

如上文所述,Nginx 通常使用 slabs 而不是記憶體頁來管理共享記憶體區內的空間。我們可以通過 OpenResty XRay 直接檢視某一個共享記憶體區內已使用的和空閒的(或未使用的)slabs 的統計資訊:

dogs區域中空的和已使用的slab

如我們所預期的,我們這個例子裡的大部分 slabs 是空閒的未被使用的。注意,這裡的記憶體大小的數字遠小於上一節中所示的記憶體頁層面的統計數字。這是因為 slabs 層面的抽象層次更高,並不包含 slab 分配器針對記憶體頁的大小補齊和地址對齊的記憶體消耗。

我們可以通過OpenResty XRay進一步觀察在這個 dogs 區域中各個 slab 的大小分佈情況:

空白區域的已使用 slab 大小分佈

空的 slab 大小分佈

我們可以看到這個空的共享記憶體區裡,仍然有 3 個已使用的 slab 和 157 個空閒的 slab。這些 slab 的總個數為:3 + 157 = 160個。請記住這個數字,我們會在下文中跟寫入了一些使用者資料的 dogs 區裡的情況進行對比。

寫入了使用者資料的共享記憶體區

下面我們會修改之前的配置示例,在 Nginx 伺服器啟動時主動寫入一些資料。具體做法是,我們在 nginx.conf 檔案的 http {} 配置分程式塊中增加下面這條 init_by_lua_block 配置指令:

init_by_lua_block {
    for i = 1, 300000 do
        ngx.shared.dogs:set("key" .. i, i)
    end
}

這裡在伺服器啟動的時候,主動對 dogs 共享記憶體區進行了初始化,寫入了 300,000 個鍵值對。

然後執行下列的 shell 命令以重新啟動伺服器程式:

kill -QUIT `cat logs/nginx.pid`
/usr/local/openresty/nginx/sbin/nginx -p $PWD/

新啟動的 Nginx 程式如下所示:

$ ps aux|head -n1; ps aux|grep nginx
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
agentzh  29733  0.0  0.0 137508  1420 ?        Ss   13:50   0:00 nginx: master process /usr/local/openresty/nginx/sbin/nginx -p /home/agentzh/work/
agentzh  29734 32.0  0.5 138544 41168 ?        S    13:50   0:00 nginx: worker process
agentzh  29735 32.0  0.5 138544 41044 ?        S    13:50   0:00 nginx: worker process

虛擬記憶體與常駐記憶體

針對 Nginx 工作程式 29735,OpenResty XRay 生成了下面這張餅圖:

非空白區域的虛擬記憶體使用量明細

顯然,常駐記憶體的大小遠高於之前那個空的共享區的例子,而且在總的虛擬記憶體大小中所佔的比例也更大(29.6%)。

虛擬記憶體的使用量也略有增加(從 134.73 MB 增加到了 135.30 MB)。因為共享記憶體區本身的大小沒有變化,所以共享記憶體區對於虛擬記憶體使用量的增加其實並沒有影響。這裡略微增大的原因是我們通過 init_by_lua_block 指令新引入了一些 Lua 程式碼(這部分微小的記憶體也同時貢獻到了常駐記憶體中去了)。

應用層面的記憶體使用量明細顯示,Nginx 共享記憶體區域的已載入記憶體佔用了最多常駐記憶體:

dogs 區域內已載入和未載入的記憶體頁

已載入和未載入記憶體頁

現在在這個 dogs 共享記憶體區裡,已載入的記憶體頁多了很多,而未載入的記憶體頁也有了相應的顯著減少:

dogs區域中的已載入和未載入記憶體頁

空的和已使用的 slab

現在 dogs 共享記憶體區增加了 300,000 個已使用的 slab(除了空的共享記憶體區中那 3 個總是會預分配的 slab 以外):

dogs非空白區域中的已使用slab

顯然,lua_shared_dict 區中的每一個鍵值對,其實都直接對應一個 slab。

空閒 slab 的數量與先前在空的共享記憶體區中的數量是完全相同的,即 157 個 slab:

dogs非空白區域的空slab

虛假的記憶體洩漏

正如我們上面所演示的,共享記憶體區在應用實際訪問其內部的記憶體頁之前,都不會實際耗費實體記憶體資源。因為這個原因,使用者可能會觀察到 Nginx 工作程式的常駐記憶體大小似乎會持續地增長,特別是在程式剛啟動之後。這會讓使用者誤以為存在記憶體洩漏。下面這張圖展示了這樣的一個例子:

process memory growing

通過檢視 OpenResty XRay 生成的應用級別的記憶體使用明細圖,我們可以清楚地看到 Nginx 的共享記憶體區域其實佔用了絕大部分的常駐記憶體空間:

Memory usage breakdown for huge shm zones

這種記憶體增長是暫時的,會在共享記憶體區被填滿時停止。但是當使用者把共享記憶體區配置得特別大,大到超出當前系統中可用的實體記憶體的時候,仍然是有潛在風險的。正因為如此,我們應該注意觀察如下所示的記憶體頁級別的記憶體使用量的柱狀圖:

![Loaded and unloaded memory pages in shared memory zones](/img/bVbK7iK
"Loaded and unloaded memory pages in shared memory zones")

圖中藍色的部分可能最終會被程式用盡(即變為紅色),而對當前系統產生衝擊。

HUP 重新載入

Nginx 支援通過 HUP 訊號來重新載入伺服器的配置而不用退出它的 master 程式(worker 程式仍然會優雅退出並重啟)。通常 Nginx 共享記憶體區會在 HUP 重新載入(HUP reload)之後自動繼承原有的資料。所以原先為已訪問過的共享記憶體頁分配的那些實體記憶體頁也會保留下來。於是想通過 HUP 重新載入來釋放共享記憶體區內的常駐記憶體空間的嘗試是會失敗的。使用者應改用 Nginx 的重啟或二進位制升級操作。

值得提醒的是,某一個 Nginx 模組還是有權決定是否在 HUP 重新載入後保持原有的資料。所以可能會有例外。

結論

我們在上文中已經解釋了 Nginx 的共享記憶體區所佔用的實體記憶體資源,可能遠少於 nginx.conf 檔案中配置的大小。這要歸功於現代作業系統中的按需分頁特性。我們演示了空的共享記憶體區內依然會使用到一些記憶體頁和 slab,以用於儲存 slab 分配器本身需要的後設資料。通過 OpenResty XRay 的高階分析器,我們可以實時檢查執行中的 nginx 工作程式,檢視其中的共享記憶體區實際使用或載入的記憶體,包括記憶體頁和 slab 這兩個不同層面。

另一方面,按需分頁的優化也會產生記憶體在某段時間內持續增長的現象。這其實並不是記憶體洩漏,但仍然具有一定的風險。我們也解釋了 Nginx 的 HUP 重新載入操作通常並不會清空共享記憶體區裡已有的資料。

我們將在本部落格網站後續的文章中,繼續探討共享記憶體區中使用的高階資料結構,例如紅黑樹和佇列,以及如何分析和緩解共享記憶體區內的記憶體碎片的問題。

關於作者

章亦春是開源專案 OpenResty® 的創始人,同時也是 OpenResty Inc. 公司的創始人和 CEO。他貢獻了許多 Nginx 的第三方模組,相當多 Nginx 和 LuaJIT 核心補丁,並且設計了 OpenResty XRay 等產品。

關注我們

如果您覺得本文有價值,非常歡迎關注我們 OpenResty Inc. 公司的部落格網站 。也歡迎掃碼關注我們的微信公眾號:

我們的微信公眾號

翻譯

我們提供了英文版原文和中譯版(本文) 。我們也歡迎讀者提供其他語言的翻譯版本,只要是全文翻譯不帶省略,我們都將會考慮採用,非常感謝!


  1. 當發生交換(swapping)時,一些常駐記憶體會被儲存和對映到硬碟裝置上去。

相關文章