EXPERIMENT / HTTP & GUNICORN

CPU 使用率低,回應卻很慢:實測 Gunicorn 的寫入等待

使用兩個 Gunicorn 同步 worker 時,即使 CPU 使用率低,緩慢接收資料仍讓其他 API 延遲數秒。比較應用程式寫入時間與 NGINX 回應緩衝,整理調查延遲時應確認的條件。

本文主要研究的組態,是使用兩個 Gunicorn 同步 worker,各自一次寫入一份回應。 我們使用不依賴 Web 框架的小型 Python 應用程式,測量接收端讀取緩慢時,對其他要求的回應有什麼影響。

同步 worker 必須完成目前回應的寫入,才會處理下一個要求。worker 是負責處理要求的程式。如果接收端讀得很慢,使兩個 worker 都無法完成寫入,其他要求會如何受到影響?

在這個組態下,同時進行兩筆緩慢下載時,另一個存取端點的回應時間,從約 1.6ms 增加到約 5.9 秒。比較的指標是 p95:將回應時間由短到長排列後,涵蓋 95% 回應的數值。下文的代表值,是每個條件測量三次所得 p95 的中位數。

只有一筆下載時,另一個端點的 p95 約為 1.7ms。在中間加入 NGINX,讓它預先讀取並暫存應用程式的回應後,同樣使用兩個同步 worker,p95 約為 2.3ms。下載接收程式的完成時間仍是讀取速率控制所設定的約 8 秒。

不過,這些結果不能代表所有 Web 應用程式的特性,也不能用來替 Web 框架的效能排名。 在使用相同應用程式、改為四個執行緒的補充條件中,即使沒有 NGINX,p95 也約為 1.5ms。我們並未測量非同步伺服器。

先確認這次實際測量的條件

項目 本次實驗的前提
應用程式 不使用框架的 Python 應用程式,採用 WSGI 這種應用程式與伺服器之間的呼叫介面。沒有使用 Flask、Django、FastAPI 等框架
主要執行組態 Gunicorn 23.0.0,兩個 sync worker 處理程序,各自一次回應一個要求
主要比較項目 固定相同應用程式與 worker,比較直接存取、經 NGINX 且關閉/啟用回應緩衝
大型回應 重複相同資料,共 16MiB。接收程式以約 2MiB/s 讀取,透過速率控制使其約 8 秒完成。接收緩衝區設定為 8KiB,Linux 回報值為 16KiB
其他要求 每秒向回傳 12 位元組 JSON 的端點發出 8 個要求,持續 9 秒。每個要求都建立新的 TCP 連線
通訊 同一台 Linux 主機內的 HTTP/1.1。沒有 TLS、壓縮、快取或 CDN。未測量實際網際網路路徑或 PaaS 的公開服務路徑
補充條件 相同應用程式改用 Gunicorn gthread,一個處理程序、四個執行緒。執行方式與同時處理要求的容量皆改變

其他端點的回應時間,是從開始建立 TCP 連線,到讀完 12 位元組本文為止。每個條件測量三次,每次 72 個要求。實驗也限制了 CPU 與記憶體,數值列於文末設定欄。

這個實驗無法判斷,其他框架或非同步伺服器是否也會在相同要求數量下變慢,或 NGINX 是否一定必要。 以下說明這張表中的主要組態所呈現的差異,以及有助於理解結果的機制。

同時進行下載與可快速產生回應的存取

我們在同一個應用程式中準備了兩種端點。

端點 回傳資料 接收方式
/large 16MiB 的大型資料 每秒讀取 2MiB,依設定的節奏約 8 秒完成下載
/fast 12 位元組的小型 JSON 資料 不加入人為延遲,測量收到回應所需的時間

/fast 是 API,也就是讓程式呼叫並取得結果的端點。本文將這些呼叫稱為「其他要求」。它不查詢資料庫,也沒有大量計算,內容可以很快產生。MiB 是資料量單位,1MiB 等於 1,048,576 位元組。

向伺服器發出要求並接收回應的程式,稱為用戶端(client),瀏覽器就是其中一種。這次使用實驗程式代替瀏覽器,重現一位使用者下載資料、另一位使用者發出要求的動作。

主要組態的 Gunicorn sync worker 一次處理一個要求。實驗所用版本的官方設計文件也記載了這個行為。即使使用相同框架,伺服器的執行方式與 worker 數量,也會影響能同時推進的要求數量。我們使用不包含框架特有功能的短程式,觀察伺服器的行為。

下載數量分別設為 0、1、2 筆,同時每秒向 /fast 發出 8 個要求,持續 9 秒。即使前一個回應延遲,下一個要求仍在 125ms 後發出。每次測量 72 個要求,各條件重複三次。

我們也測量了透過 NGINX 中繼的情況。在應用程式與用戶端之間轉送要求及回應的角色,稱為反向代理(reverse proxy)。

NGINX 可以在送達接收端之前,先讀取應用程式的回應,把資料保存在記憶體或暫存檔中,這稱為回應緩衝(response buffering)。為了簡潔,以下圖表標示為「預讀開/關」。啟用預讀並不表示,要等整份回應都收齊才開始傳送給使用者。

概念圖:主要配置為兩個 Gunicorn sync worker,比較直接存取與 NGINX 回應緩衝開關,標示 OS 與 NGINX 暫存回應資料的位置。
主要配置的概念圖:兩個 Gunicorn sync worker。各欄最上方的方框統稱應用程式端的 worker。OS 通訊緩衝區是暫存收送資料的記憶體。方框大小不代表容量。

兩筆下載占用了兩個回應 worker

本節的表格與圖表,全都固定使用兩個 Gunicorn sync worker。 我們以其他要求的 p95,也就是涵蓋 95% 回應時間的數值,取三次測量的中位數。1ms 是千分之一秒。

存取路徑 無下載 同時 1 筆 同時 2 筆
直接存取應用程式 1.57 1.72 5,945.11
NGINX:關閉預讀 2.66 2.33 1,071.47
NGINX:開啟預讀 1.93 2.94 2.34

單位為 ms。例如,5,945ms 約為 5.9 秒。

實測:兩個 Gunicorn sync worker,16MiB 以 2MiB/s 接收。同時下載兩件時,直接存取與關閉 NGINX 預讀都讓其他要求延遲數秒。
實測:兩個 Gunicorn sync worker,同一主機內 HTTP/1.1。點是三次中位數,線是最小至最大值。縱軸為對數軸,刻度依序為 1、10、100、1,000ms。

直接存取應用程式時,只有一筆下載,另一個 worker 還能處理 /fast。兩筆下載時,兩個 worker 都在傳送大型回應,/fast 的要求就必須等待處理順序。

同步 worker 的實作有助於解釋這個機制。接收端讀得夠慢,填滿送受信資料所用的記憶體時,socket 寫入就會等待可用空間。同步 worker 若無法完成目前回應的寫入,就不能繼續處理下一個要求。

Gunicorn 的同步 worker 實作會依序寫入回應的各段資料。寫入處理呼叫的 socket,是程式透過網路交換資料的介面。這次寫入尚未完成,worker 就無法進入下一個要求。

本次觀測的是應用程式寫入所花的時間,以及其他要求的回應時間。我們沒有量測個別 TCP 緩衝區的使用量,也沒有用堆疊分析確認 worker 等待的位置。結果與上述行為相符,但無法確定各個緩衝區實際累積了多少位元組。

這些測量中,應用程式的平均 CPU 使用量,最高也只有單一核心的約 0.8%。即使還有計算餘力,兩個 worker 仍可能持續被寫入等待占用。

兩個 Gunicorn sync worker 的實測時序,比較要求發送時間與回應所需時間。
實測:兩個 sync worker、同時下載兩件。每點代表一個 /fast 要求,各路徑選三次中 p95 居中的一次。橫軸是發送要求的時間,縱軸是回應時間。

關閉 NGINX 預讀,OS 仍會暫存資料

經過 NGINX、關閉預讀且同時下載兩筆時,p95 約為 1.1 秒。雖然比直接存取短,但沒有回到無下載時的約 2~3ms。

決定回應大小之前,我們先做了 8MiB、以 1MiB/s 讀取的預備測量。當時即使關閉 NGINX 預讀,另一個端點的 p95 也約為 2ms。因此,本測量改用 16MiB、以 2MiB/s 讀取,在維持約 8 秒下載節奏的同時增加資料量。表格沒有混入預備測量的數值。

預備測量與本測量,同時改變了回應大小與讀取速率。 這並不是單獨分離回應大小效果的實驗。以下結論僅適用於本測量的 16MiB、2MiB/s 條件。

關閉預讀的設定是 proxy_buffering off。即使關閉,應用程式與 NGINX 之間、NGINX 與接收程式之間,仍有 OS 用於送受信的記憶體。NGINX 自己也會使用小型緩衝區讀取應用程式的資料。NGINX 規格說明,即使設定為 off,一次可以接收的最大量仍由 proxy_buffer_size 控制。

如果應用程式能把全部回應資料交給這些緩衝區,就可能在接收端讀完之前處理下一個要求。我們沒有測量每個 socket 的使用量,因此不能據此認定 8MiB 與 16MiB 之間存在通用界線。關閉預讀後仍有暫存位置,是依據規格的說明;我們沒有獨立實測其容量,或它對本次差異的貢獻。

應用程式寫完,不代表下載已經完成

在同時下載兩筆的條件下,我們分別測量兩段時間。

存取路徑 應用程式寫入(秒) 接收端完成(秒)
直接存取應用程式 6.562 8.016
NGINX:關閉預讀 1.970 8.009
NGINX:開啟預讀 0.055 8.005

表格先計算每次測量中兩份回應的中位數,再取三次測量的中位數。

實測:開啟 NGINX 預讀後,應用程式寫入約 0.055 秒,接收端含速率調整等待約八秒完成。
實測:兩個 sync worker,兩件下載 × 三次,共六個回應。長條是六個回應的中位數,黑點是各回應的寫入時間。接收端約八秒包含讀取速率控制,不是真實網路的純傳輸時間。

啟用預讀時,NGINX 會快速讀取應用程式的回應。記憶體緩衝區放不下的資料,也能寫入暫存檔。NGINX 官方規格描述的這個功能,也對應到本次實測的行為。

在這個設定下,應用程式能較早完成朝向 NGINX 的寫入,繼續處理其他要求。NGINX 則可繼續傳送給接收端,讓應用程式寫入完成與接收端完成不必發生在同一時間。本次 API 回應時間較短的結果,與此行為相符,但並不表示所有應用程式啟用回應緩衝,都會得到相同改善幅度。

兩個 Gunicorn sync worker 搭配 NGINX 的實測:開啟預讀時觀測到約 25MiB 暫存檔案。
實測:主要配置同時下載兩件,與前面的時序圖使用相同測量。約每 200ms 加總使用中的暫存檔案邏輯大小,不代表實體磁碟寫入量或速度。

啟用預讀時,兩筆下載所使用的開啟中暫存檔,其邏輯大小合計最高約為 25.2MiB。應用程式 worker 等待寫入的時間減少了,代價是使用 NGINX 的記憶體與檔案來保存回應資料。 檔案內容也可能存在 OS 記憶體中,因此改存檔案並不會讓記憶體消耗消失。

增加同時處理容量的補充組態

作為另一項確認,我們把 Gunicorn 改成 gthread,一個處理程序、四個執行緒。執行緒可在同一處理程序內推進多項工作,這裡提供四個處理要求的執行緒。

在此組態直接存取應用程式,並同時下載兩筆資料時,另一個端點的 p95 約為 1.5ms。即使兩個執行緒正在等待寫入,仍有其他執行緒能處理要求。

直接存取、同時下載兩筆 其他要求的 p95(ms,三次中位數)
主組態:兩個 sync 處理程序 5,945.11
補充組態:一個 gthread 處理程序、四個執行緒 1.55

這個補充條件同時改變了執行方式與同時處理要求的容量。我們沒有分離方式本身或容量本身的效果。它顯示同一應用程式改變伺服器組態後,行為可能不同,但不能用來判定執行緒方式的效能優勢或最佳執行緒數量。

這些測量能說明什麼,還沒測量什麼

在主組態中,同時有兩筆讀取緩慢的要求時,應用程式的寫入時間與其他要求的回應時間都變長。結合 Gunicorn sync 的規格,worker 必須寫完目前回應才會處理下一個要求,是理解結果的線索。NGINX 回應緩衝的主比較,是固定這個 worker 組態後進行的。

我們沒有比較 Flask、Django、FastAPI 等框架,也沒有比較 Uvicorn、Node.js 等其他伺服器或執行環境,以及非同步方式。這些結果無法判斷它們是否會出現相同行為或相同秒數。補充組態沒有 NGINX 時也達到約 1.5ms,因此也不能得出 NGINX 是所有 Web 應用程式必要條件的結論。

在自己的應用程式中,應該確認什麼

先區分產生回應資料所需的時間,以及寫完資料所需的時間。如果產生很快,寫入卻很久,就可以進一步調查接收端的讀取,以及沿途的傳送與接收。

接著確認,worker 等待寫入時,能否處理另一個要求。若採用本次同步 worker 這種一次處理一件的方式,就要一起看等待寫入的數量與 worker 數量。

如果 PaaS 等部署環境在應用程式前方放置中繼伺服器,也要確認它會預先讀取多少回應資料。調整設定後,除了其他要求的回應時間,也應比較中繼伺服器的記憶體與暫存檔使用量。

需要立刻逐段傳送資料的回應,啟用預讀也可能影響資料抵達的時機。本次實驗只處理大小已知的一般 HTTP 回應。逐步顯示文字的 SSE,以及維持連線以進行雙向通訊的 WebSocket,都需要各自確認。

把結果套用到自己的應用程式之前,請先確認伺服器的執行方式、worker 或執行緒數量,以及中繼伺服器設定。不要只憑框架名稱推斷結果。判斷等待回應寫入時,其他要求是否仍能前進,是這次實驗可以帶走的檢查方法。

使用版本、設定與測量方法

執行環境與讀取速率

Gunicorn 23.0.0、NGINX 1.22.1(Debian 套件 1.22.1-9+deb12u10)、CPython 3.11.2、Linux 6.12、aarch64。大型回應使用事先準備的同一份 64KiB 資料,重複 256 次,共 16MiB。

所有通訊都經過同一主機的 127.0.0.1(loopback),使用 HTTP/1.1,回應後關閉連線。沒有 TLS 加密、壓縮或快取。約 2MiB/s 的速率是透過接收程式的讀取間隔實作,並非把真實網際網路連線降速。

接收端 socket 的 SO_RCVBUF 設為 8KiB,Linux 回報 16KiB。依據 Linux socket 規格,這個值包含管理空間,不是實際 TCP 接收視窗大小。asyncio StreamReader 的 limit 為 16KiB。下載連線開始約 250ms 後,才開始送出 /fast 要求。

NGINX 設定與暫存檔計測

「預讀開/關」對應到 proxy_buffering on / off。其他設定相同:proxy_buffer_size 16k、proxy_buffers 4 16k、proxy_busy_buffers_size 32k、proxy_max_temp_file_size 32m、proxy_temp_file_write_size 64k。暫存檔 32MiB 上限是每個要求各自的上限。

檔名刪除後,程式仍可能繼續使用已開啟的檔案。因此,我們從 /proc/PID/fd 檢查 NGINX 開啟的檔案,而不是只查看目錄。透過裝置號碼與 inode(檔案識別號碼)排除重複計算。可參考 Linux 檔案描述元說明。

統計方式與變動範圍

每次測量將 72 筆回應時間由短到長排列,把 95% 的位置向上取整,使用第 69 筆作為 p95。條件表再取三次 p95 的中位數。同時下載兩筆時,其範圍如下。

存取路徑 三次 p95 的範圍(ms)
直接存取應用程式 5,928.68–6,590.04
NGINX:關閉預讀 1,052.72–1,826.44
NGINX:開啟預讀 1.96–3.03

本測量共 30 次,2,160 個 /fast 要求全部回傳 HTTP 200,33 份大型回應也都收到完整 16MiB。從預定要求時間到實際開始的延遲,最大約為 7.0ms。

CPU 上限分別為:應用程式 0.25 核心、NGINX 0.20 核心、控制與負載產生程式 0.20 核心,配額週期為 10ms。記憶體上限依序為 128MiB、96MiB、128MiB,並記錄 CPU 時間、配額限制造成的等待與記憶體用量。本測量曾因整台主機的 CPU 超過安全門檻而停止一次;保留已完成的 17 次,排除未完成的測量,只重新執行剩下的 13 次,安全門檻與應用程式條件皆未變更。

這不是最大處理能力或長時間耐久性的測量。個別 TCP 緩衝區用量、HTTP/2、HTTP/3、SSE、WebSocket、緩慢上傳、大量連線,以及記憶體或磁碟耗盡,都沒有測試。

測量資料包含條件別統計、各次測量統計、每個要求的回應時間、大型回應的寫入與完成時間,以及資源使用量時間序列。資料 README說明統計方式與重現程式碼。

KamuiDash 是可從 GitHub 部署 Web 應用程式的 PaaS。