走長輪詢。 判據兩項都成立:
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)仍然必要,理由見「容量上限」。
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。/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 |
/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)。
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 進程計,不能只算全域總數。
(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 等與本卡無關的既有測試。
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 收到請求前不會碰到同一條路徑),所以①②不重跑。/api/1.0/version,理由見「怎麼跑的」。# 視窗 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