一個請求被誰處理

HTTP server 收到請求,需要有個執行單元去跑 handler。有兩種主流模型:

Thread-per-request(同步):每個請求分配一個 thread。Thread 在等 I/O(查資料庫、呼叫外部 API)時是阻塞的,那個 thread 什麼都不能做。

Event Loop(非同步):一個(或少數幾個)thread 管理所有請求。等 I/O 時,event loop 去處理其他請求,I/O 完成後回來繼續。

這個底層選擇,決定了框架的「實例模型」——同一個框架的 sync 和 async 版本,寫法、效能特性、適用場景都不一樣。


Python 的 WSGI vs ASGI

Python web 的並發模型分水嶺是 WSGI(同步)和 ASGI(非同步)。

WSGI(Web Server Gateway Interface):Django、Flask 的底層協議。每個請求是一個同步的函式呼叫:

# Django view — 同步函式
def user_detail(request, id):
    user = User.objects.get(pk=id)  # ← 阻塞,等資料庫
    return JsonResponse({'name': user.name})

Gunicorn 用多個 worker process 處理並發——每個 worker 同時只能跑一個請求。你要 100 QPS,就需要足夠多的 worker process,記憶體用量線性增長。

ASGI(Asynchronous Server Gateway Interface):FastAPI、Starlette 的底層協議。Handler 是 coroutine,等 I/O 時釋放 event loop:

# FastAPI view — 非同步函式
async def user_detail(id: int):
    user = await db.get(User, id)  # ← 非阻塞,await 期間處理其他請求
    return {'name': user.name}

Uvicorn 用 asyncio event loop,一個 process 可以同時等待數千個 I/O 操作。記憶體效率遠優於 WSGI 的 multi-process 模型。

Django 的 async 支援(Django 3.1+):Django 現在允許 async view,但 ORM 還是部分同步的——需要用 sync_to_async() 包裝,寫法比 FastAPI 繁瑣。這是框架設計歷史包袱的代價。


Node.js:天生 Event Loop

Node.js 的 Event Loop 是它的核心設計,Express 和所有 Node.js framework 都跑在上面:

  ┌─────────────────────────────────┐
  │           Event Loop            │
  │                                 │
  │  poll → check → timers → I/O   │
  │                                 │
  │  等 I/O 時,跑其他 callback      │
  └─────────────────────────────────┘
         Single Thread
// Express handler — 看起來同步,底層是 event loop
app.get('/users/:id', async (req, res) => {
  const user = await userRepo.findById(req.params.id); // ← await 讓出 event loop
  res.json(user);
});

Node.js 的單 thread event loop 在 I/O 密集場景(等資料庫、等 API)表現優秀,因為「等」的時候可以處理其他請求。

Node.js 的限制:CPU 密集任務(大量計算、圖片處理)會佔住 event loop,阻塞所有其他請求。解法是 Worker Threads 或把計算搬到 background job。


Java / Spring 的演化

Spring MVC(傳統):基於 Servlet,thread-per-request 模型,Tomcat 有 thread pool。

// Spring MVC — 同步,blocking
@GetMapping("/users/{id}")
public User show(@PathVariable Long id) {
    return userRepo.findById(id).orElseThrow(); // ← blocking,thread 等待
}

Spring WebFlux(reactive):基於 Netty + Project Reactor,non-blocking event loop 模型。

// Spring WebFlux — 非同步,non-blocking
@GetMapping("/users/{id}")
public Mono<User> show(@PathVariable Long id) {
    return userRepo.findById(id); // ← Mono = 非同步包裝
}

Kotlin + Coroutine + Ktor 讓這個寫法更自然:

// Ktor — Kotlin coroutine,寫起來像同步
get("/users/{id}") {
    val id = call.parameters["id"]!!.toLong()
    val user = userRepo.findById(id) // ← 底層是 coroutine,non-blocking
    call.respond(user)
}

為什麼同框架的 sync/async 寫法差很多

以 Django 為例:

# Django 同步 view
def user_list(request):
    users = User.objects.all()          # ← 同步 ORM
    return JsonResponse({'users': list(users.values())})
 
# Django 非同步 view(需要 async ORM)
async def user_list(request):
    users = await User.objects.all()   # ← 需要 Django 4.1+ 的 async ORM
    # 或用 sync_to_async
    users = await sync_to_async(list)(User.objects.all())
    return JsonResponse({'users': list(users)})

Django 的 ORM 從同步設計出發,async 是事後加上去的,所以有些操作必須用 sync_to_async() 包裝——這是歷史包袱。

FastAPI 從一開始就是 async-first,所以寫法很自然:

# FastAPI — 一致的 async 體驗
async def user_list(db: Session = Depends(get_db)):
    users = await db.execute(select(User))
    return users.scalars().all()

什麼時候選哪個模型

I/O 密集的 API(查資料庫、呼叫外部 API):async / event loop 模型更有效率。FastAPI、Node.js(Express / Hono)、Ktor、Spring WebFlux 在這個場景表現好。

CPU 密集的後端(大量計算、資料轉換):multi-thread / multi-process 更合適,因為 event loop 會被 CPU 佔住。Django(multi-worker Gunicorn)或 Java(thread pool)反而是對的選擇。

標準 CRUD API:老實說,除非你真的碰到效能瓶頸,不然框架的 sync vs async 差異對 95% 的 API 沒有感覺。真正的差異在高並發(1000+ QPS)場景才會出現。

混合場景:很多框架現在支援 sync/async 混用——FastAPI 可以宣告同步 handler(讓框架跑在 threadpool)、Django 可以有 async view。在遷移期間這很有用,但長期最好統一。


Crontab / 排程任務:不是 API server

Event loop 和 thread pool 談的是「處理請求的並發模型」,但後端還有另一類工作——排程任務:每天 00:00 清 expired session、每小時跑一次報表、每分鐘 poll 一次外部 API。

這類任務不是 HTTP request,走的是不同的路:

Crontab(OS 層):最簡單,在 OS 的 cron 設定觸發時間,spawn 一個新 process 跑你的腳本。跑完 process 結束。缺點是沒有 retry、沒有 lock(兩個 cron job 同時跑的競態問題)、日誌要自己管。

BullMQ / Celery Beat(Queue-based):把排程工作丟進 queue,worker process 消費。有 retry、有 concurrency 控制、有失敗記錄。Event loop(Node.js)和 thread pool(Python)的排程都走這條路——耗時任務不能跑在 API server 的 event loop 上。

API Server(event loop / thread pool)
  → 接請求 → 把耗時任務丟進 Queue

Queue Worker(獨立 process)
  → 消費任務 → CPU 密集或長跑任務跑在這裡,不阻塞 API server

為什麼要分開:API server 的 event loop 被長跑任務佔住,就沒辦法回應其他請求。把耗時任務(PDF 生成、圖片處理、大批量 export)搬到獨立的 worker process,API server 只做「接請求 + 丟任務」,response time 保持穩定。


Axios Instance:前端的對應設計

如果你有前端背景,這個並發模型有個有趣的類比。

後端 API server 是一個「長跑的 event loop,等待並處理多個 client 連線」。前端的 axios instance 類似:你建立一個 axios instance,設定 baseURL、timeout、interceptors,然後整個應用共用這個 instance 去發 HTTP 請求。

// 前端:一個 axios instance 管理所有對同一個後端的連線
const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' },
});
 
api.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${getToken()}`;
  return config;
});
 
// 整個前端 app 共用這個 instance
const user = await api.get('/users/1');

這和後端的 Express app instance 概念是對稱的:

// 後端:一個 Express instance 管理所有進來的請求
const app = express();
app.use(authenticate);  // interceptor-like
app.use(router);

不同的是:前端的 axios instance 是「發出請求」,後端的 Express instance 是「接受請求」。兩者都有 interceptor 機制,都有統一的設定點。


延伸閱讀