FR-181 P-0 gunicorn 長輪詢併發驗證

FR-181 P-0 gunicorn 長輪詢併發驗證

§1

結論

走長輪詢。 判據兩項都成立:

  • gthread 吊 20 條長輪詢時,一般 API p95 為 74.2 ms,比基準①(sync、不吊)的 113.8 ms 還低,也比同一台 gthread 不吊時的 69.3 ms 只多 4.9 ms。兩種比法都低於 100 ms 門檻。
  • pytest test/ 在 gthread 設定下的紅燈清單與 sync 基準一模一樣,沒有新紅(見「pytest 對照」)。

E-1 照此把預設翻成 gthread/16 執行緒;F-3 走長輪詢。D2 的三道防護(每台 Agent 一條、長輪詢配額 ≤ 8、等回答配額 ≤ 8)仍然必要,理由見「容量上限」。

§2

怎麼跑的

  • 起法:DEBUG=false USE_GUNICORN=true GUNICORN_WORKERS=2 BENCH_LONGPOLL=1 PORT=8031 python main.py,再用 GUNICORN_WORKER_CLASS 切 sync/gthread(GUNICORN_THREADS=16)。用 8031 埠,不碰共用的 8000。
  • 假長輪詢端點:GET /api/1.0/_bench/longpoll?wait=30,睡 30 秒回 204(core/plugins/_bench_longpoll.py,只在 DEBUG 或 BENCH_LONGPOLL=1 時掛)。
  • 量測腳本:scripts/dev/longpoll_bench.py --hold N --probe C --seconds 60。先開 N 條長輪詢吊著(吊滿 30 秒就立刻再吊一條,所以全程都有 N 條),等 2 秒再開 C 個並發連續打探測端點,一共打 60 秒。
  • 探測端點:卡上寫的 /api/1.0/health 在 api 模式不存在(/healthz 只在 socketio/worker 模式掛)。改打 /api/1.0/version:免認證、不碰業務邏輯,但每個請求照樣會經過 api_log 中介層寫一筆 DB,所以量得到「排隊等 worker」的時間。另外補了一組要登入、走 RLS 的 /api/1.0/users/menu。
  • 機器:Apple M4 Pro(12 核)、macOS 15.7.4、gunicorn 26.2.0、DEV DB 在本機。
§3

三組數字(/api/1.0/version,每組 60 秒)

組 worker 吊幾條 探測並發 成功 失敗 p50 p95 max
① 基準 sync ×2 0 20 16,340 0 62.3 ms 113.8 ms 302 ms
② 問題重現 sync ×2 2 20 60 0 30,074 ms 30,164 ms 30,166 ms
③ 解法 gthread ×2×16 20 20 20,997 0 39.3 ms 74.2 ms 17,880 ms

②:2 條長輪詢就把 2 個 sync worker 全佔住。60 秒內 20 個並發只完成 60 個請求,每一個都等滿 30 秒,也就是排在某條長輪詢後面。這就是「2 核客戶機上 1 台 Agent 加 1 次測試連線,整個主系統停住」的原因。

同一台 gthread 的對照組

吊幾條 探測並發 成功 p50 p95 max
0 20 23,993 45.2 ms 69.3 ms 548 ms
20 20 20,997 39.3 ms 74.2 ms 17,880 ms
0 8 27,164 16.2 ms 28.9 ms 363 ms
20 8 28,071 15.7 ms 23.8 ms 308 ms

要登入、走 DB/RLS 的端點(/api/1.0/users/menu,gthread,探測並發 20)

吊幾條 成功 失敗 p50 p95 max
0 3,082 0 351 ms 584.6 ms 920 ms
20 3,654 0 358 ms 640.8 ms 1,063 ms

兩組加起來 6,737 個 200、0 個 500。log 裡沒有 沒有在 context 中找到 session、DuplicatePreparedStatement、QueuePool limit、Traceback,也就是 thread-local session 與 JWT/RLS 的使用者脈絡在 16 執行緒下沒有串線。p95 多 56 ms,算在 DB 正常的並發抖動內(這支端點本身就要 300~600 ms)。

§4

容量上限(給 A-1/F-3 配額用)

gthread 2 worker × 16 執行緒=32 個槽。③是 20 條長輪詢加 20 個探測,共 40 條同時在要槽,超過 32。所以 p95 雖然好看,但 max 有 17.9 秒:少數請求剛好排在所有槽都被長輪詢佔住的時候。改成 8 個探測(20+8=28 < 32)時,max 就回到 308 ms。

這說明長輪詢不能吃滿執行緒。D2 的「長輪詢 ≤ 8、等回答 ≤ 8」是在保護一般 API 至少留著一半的槽,不是可以省掉的衛生項。gunicorn 的 accept 是每個 worker 各自搶,長輪詢不一定平均分到兩個 worker,所以配額要按每個 worker 進程計,不能只算全域總數。

§5

pytest 對照

(pytest test/,5,452 支。測試跑在 Flask test client 上,不會起 gunicorn,也沒有一支測試讀 GUNICORN_*。所以兩輪結果相同是預期中的事,並不證明多執行緒安全;多執行緒安全的證據在上一節實打的 6,737 個登入請求。)

設定 passed failed errors skipped
GUNICORN_WORKER_CLASS=sync(基準) 5,233 174 11 34
GUNICORN_WORKER_CLASS=gthread+GUNICORN_THREADS=16 5,233 174 11 34

紅燈清單比對:兩輪的 FAILED/ERROR 各 185 支,comm 比對兩邊都沒有多出來的,沒有新紅。基準紅燈集中在 test_detection_profile_source_freshness.py 等與本卡無關的既有測試。

§6

驗收環境打折

  • macOS fork 安全檢查:第一次跑 gthread 時,有一個 worker 被 SIGTRAP 打掉。macOS 當機報告寫的是 multi-threaded process forked 與 libdispatch crashed on child side of fork pre-exec。原因是 preload_app 在 master 先起了執行緒(排程、log 轉發),fork 之後 Apple 的系統框架在子程序裡自殺。另一個 worker 在同一輪 WORKER TIMEOUT 被殺,症狀相同。這是 macOS 才有的檢查,正式環境的 Linux 容器不會遇到。重跑時加了 OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES no_proxy='*'(macOS 跑 gunicorn 的標準解法),之後全程沒有 worker 死掉。上表的 gthread 數字都是這一輪的。sync 那兩組沒加也沒出事,可能只是運氣(sync worker 收到請求前不會碰到同一條路徑),所以①②不重跑。
  • 同一台機器上同時跑壓測與服務,用 127.0.0.1 連線,沒有網路延遲。數字是相對比較用的,不能當成客戶機的絕對效能。
  • 2 worker 是刻意模擬 2 核客戶機(出貨預設 worker 數=nproc,封頂 4),不是這台 12 核機器的上限。
  • 探測端點換成 /api/1.0/version,理由見「怎麼跑的」。
§7

重現

# 視窗 A(macOS 要加前兩個變數,Linux 不用)
OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES no_proxy='*' \
DEBUG=false USE_GUNICORN=true GUNICORN_WORKERS=2 BENCH_LONGPOLL=1 \
GUNICORN_WORKER_CLASS=gthread GUNICORN_THREADS=16 PORT=8031 python main.py

# 視窗 B
python scripts/dev/longpoll_bench.py --base http://127.0.0.1:8031 --hold 20 --probe 20 --seconds 60