本文主要研究的組態,是使用兩個 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)。為了簡潔,以下圖表標示為「預讀開/關」。啟用預讀並不表示,要等整份回應都收齊才開始傳送給使用者。
兩筆下載占用了兩個回應 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 秒。

直接存取應用程式時,只有一筆下載,另一個 worker 還能處理 /fast。兩筆下載時,兩個 worker 都在傳送大型回應,/fast 的要求就必須等待處理順序。
同步 worker 的實作有助於解釋這個機制。接收端讀得夠慢,填滿送受信資料所用的記憶體時,socket 寫入就會等待可用空間。同步 worker 若無法完成目前回應的寫入,就不能繼續處理下一個要求。
Gunicorn 的同步 worker 實作會依序寫入回應的各段資料。寫入處理呼叫的 socket,是程式透過網路交換資料的介面。這次寫入尚未完成,worker 就無法進入下一個要求。
本次觀測的是應用程式寫入所花的時間,以及其他要求的回應時間。我們沒有量測個別 TCP 緩衝區的使用量,也沒有用堆疊分析確認 worker 等待的位置。結果與上述行為相符,但無法確定各個緩衝區實際累積了多少位元組。
這些測量中,應用程式的平均 CPU 使用量,最高也只有單一核心的約 0.8%。即使還有計算餘力,兩個 worker 仍可能持續被寫入等待占用。

關閉 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 之間存在通用界線。關閉預讀後仍有暫存位置,是依據規格的說明;我們沒有獨立實測其容量,或它對本次差異的貢獻。
應用程式寫完,不代表下載已經完成
在同時下載兩筆的條件下,我們分別測量兩段時間。
- 應用程式的寫入時間:從回應產生器開始逐段回傳 16MiB,到最後一段資料交給 OS 的傳送緩衝區為止。
- 接收端的完成時間:從送出下載要求,到接收 16MiB 並結束讀取節奏的等待為止,也包含最後一次讀取後的速率調整等待。
| 存取路徑 | 應用程式寫入(秒) | 接收端完成(秒) |
|---|---|---|
| 直接存取應用程式 | 6.562 | 8.016 |
| NGINX:關閉預讀 | 1.970 | 8.009 |
| NGINX:開啟預讀 | 0.055 | 8.005 |
表格先計算每次測量中兩份回應的中位數,再取三次測量的中位數。

啟用預讀時,NGINX 會快速讀取應用程式的回應。記憶體緩衝區放不下的資料,也能寫入暫存檔。NGINX 官方規格描述的這個功能,也對應到本次實測的行為。
在這個設定下,應用程式能較早完成朝向 NGINX 的寫入,繼續處理其他要求。NGINX 則可繼續傳送給接收端,讓應用程式寫入完成與接收端完成不必發生在同一時間。本次 API 回應時間較短的結果,與此行為相符,但並不表示所有應用程式啟用回應緩衝,都會得到相同改善幅度。

啟用預讀時,兩筆下載所使用的開啟中暫存檔,其邏輯大小合計最高約為 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。