---
title: FR-181 P-0 gunicorn 長輪詢併發驗證
status: 證據（P-0 產出，D2 判據）
---

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

## 結論

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

- `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`。
- 機器：Apple M4 Pro（12 核）、macOS 15.7.4、gunicorn 26.2.0、DEV DB 在本機。

## 三組數字（`/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）。

## 容量上限（給 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 進程**計，不能只算全域總數。

## 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` 等與本卡無關的既有測試。

## 驗收環境打折

- **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`**，理由見「怎麼跑的」。

## 重現

```bash
# 視窗 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
```
