iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Modern Web

重寫一套比我還老的系統:21 歲的校園文字廣播系統系列 第 7 篇

Day 7|都是五秒,Async 到底快在哪裡?

  • 分享至 

  • xImage
  •  

書接上回。

昨天收工前,我留了一題功課給各位。

time.sleep(5)

跟

await asyncio.sleep(5)

這兩段程式,都會等五秒。
所以——
async 到底快在哪裡?

我想過了。

time.sleep() 會把程式卡在那邊,asyncio.sleep() 可以先去做其他事情。

這是非同步的特性,也是它的作用。

喔?看來有做功課。

所以 async 比較快。

停停停停停……
你是不是又繞回昨天的答案了?

不然咧?

算了,我們不要用猜的。
來跑跑看。

@app.get("/root")
async def hello():
    return {"Hello": "World"}

@app.get("/sync-sleep")
def sync_sleep():
    time.sleep(5)
    return {"message": "done"}

@app.get("/async-sleep")
async def async_sleep():
    await asyncio.sleep(5)
    return {"message": "done"}
import requests
import time

for path in ["/root", "/sync-sleep", "/async-sleep"]:
    start = time.perf_counter()
    requests.get(f"http://127.0.0.1:8000{path}")
    elapsed = time.perf_counter() - start

    print(path, elapsed)
python test1.py
/root 0.005292500020004809
/sync-sleep 5.0037892999826
/async-sleep 5.022563899983652

……
沒什麼差?

對。

所以你昨天跟我講了半天 async,結果快了兩毫秒?

不是。

那不然是?

來,一次送十個看看。

from concurrent.futures import ThreadPoolExecutor
import requests
import time

BASE_URL = "http://127.0.0.1:8000"

def request(path):
    return requests.get(f"{BASE_URL}{path}")

def benchmark(path, n=10):
    start = time.perf_counter()

    with ThreadPoolExecutor(max_workers=n) as executor:
        futures = [
            executor.submit(request, path)
            for _ in range(n)
        ]

        for future in futures:
            future.result()

    elapsed = time.perf_counter() - start
    print(f"{path}: {elapsed:.4f}s")

benchmark("/sync-sleep")
benchmark("/async-sleep")
python test2.py
/sync-sleep: 5.0180s
/async-sleep: 5.0213s

?????
不是,你不是說高併發就看得出差別?

好,這裡涉及一個很有趣的東西叫做 thread pool,這樣吧,我們把benchmark 的 n 改成 50 看看。

?有差嗎

用眼睛看。

/sync-sleep: 10.0320s
/async-sleep: 5.0537s

這樣吧,我給你個魔法數字,40。

我們來把最後一段改成這樣。

for times in [40, 41]:
    print(f"Benchmarking sync-sleep with {times} requests:")
    benchmark("/sync-sleep", n=times)
    print(f"Benchmarking async-sleep with {times} requests:")
    benchmark("/async-sleep", n=times)
Benchmarking sync-sleep with 40 requests:
/sync-sleep: 5.0499s
Benchmarking async-sleep with 40 requests:
/async-sleep: 5.0465s
Benchmarking sync-sleep with 41 requests:
/sync-sleep: 10.0112s
Benchmarking async-sleep with 41 requests:
/async-sleep: 5.0811s

多一個 Request,5 秒的差距?

??????
為什麼?
40 個五秒,41 個十秒?

因為第 41 個沒位置坐了。

?

這就要回到剛才提到的東西了。

Thread Pool。

當我們程式呼叫這個端點時,

@app.get("/sync-sleep")
def sync_sleep():
    time.sleep(5)
    return {"message": "done"}

FastAPI 不會直接在 Event Loop 上執行這個同步函式,而是把它交給 Worker Thread 處理。

所以當我們呼叫這個端點 10 次時,它並不會重複等待、回應、等待、回應...…

而是交給多個執行緒來處理,所以他們會幾乎同時等待 5 秒後返回。

那為什麼 41 個時突然多了五秒?

Thread Pool。

你是說,FastAPI 會維護 40 個 Thread 作為備用,當第 41 個 request 來了的時候就只能等待?

差不多,但這是簡化過的理解。

當前 40 個 sync-sleep 把位置占滿後,第 41 個就只能先等。等五秒後其中一個工作完成、位置被釋放,第 41 個才能開始自己的五秒,所以最後完成時間就接近十秒。

但準確來說,不是 FastAPI 維護 40 個 Thread。

正確的說法是 FastAPI 底下的 Starlette 會透過 AnyIO 把這些同步工作丟到 Worker Thread 執行,而這裡預設有一個 40 tokens 的限制。

這裡的 40 tokens,可以先把它理解成「最多同時允許 40 個這類工作使用 Thread」。

那我把位置加到 100 個不就好了?

欸,你說對了,我們來實驗看看。

@asynccontextmanager
async def lifespan(app: FastAPI):
    limiter = anyio.to_thread.current_default_thread_limiter()
    print(f"Thread limiter. total_tokens: {limiter.total_tokens}")
    limiter.total_tokens = 100
    print(f"Thread limiter after setting total_tokens: {limiter.total_tokens}")
    yield
for times in [100, 101]:
    print(f"Benchmarking sync-sleep with {times} requests:")
    benchmark("/sync-sleep", n=times)
    print(f"Benchmarking async-sleep with {times} requests:")
    benchmark("/async-sleep", n=times)
Benchmarking sync-sleep with 100 requests:
/sync-sleep: 5.1261s
Benchmarking async-sleep with 100 requests:
/async-sleep: 5.0819s
Benchmarking sync-sleep with 101 requests:
/sync-sleep: 10.0127s
Benchmarking async-sleep with 101 requests:
/async-sleep: 5.0865s

好了啊,問題解決了。

?

40 個不夠就開 100 個,100 個不夠就開 1000 個。

那乾脆開一萬個啊。

可以嗎?

吃土啦。

我們剛才看似把問題解決了,但其實只是把懸崖從第 41 個 Request 搬到了第 101 個。

Thread 並不是免費的,是 Meta 的。

每條 Thread 都需要一些記憶體保存自己的狀態,作業系統也需要負責排程它們;當數量越來越多,管理與切換 Thread 本身也會產生成本。

所以我們不能靠無限制增加可以同時執行的同步工作數量來解決問題。

但這裡還有一個更奇怪的地方。

我們再看一次:

def sync_sleep():
    time.sleep(5)
    return {"message": "done"}

有問題嗎?

這條 Thread 在這五秒裡幹了什麼?

睡覺。

對。

所以問題來了:

既然它什麼都沒在做,為什麼還需要一條 Thread 陪它等五秒?

這才是非同步解決的問題。

當程式執行:

await asyncio.sleep(5)

它可以暫停目前的工作,把執行權交回 Event Loop。

等五秒到了,再回來繼續。

所以:

Thread Pool

Request A → Thread A → 等待
Request B → Thread B → 等待
Request C → Thread C → 等待

而 Async 更接近:

Request A ─ waiting ─┐
Request B ─ waiting ─┼→ Event Loop
Request C ─ waiting ─┘

所以 Async 比較快!

你怎麼又繞回去了?

asyncio.sleep(5) 還是要五秒。

Async 真正改變的不是「這五秒會不會變短」,而是:

等這五秒的時候,能不能先去處理別的事情。

這也是為什麼單一 Request 時:

sync  ≈ 5 秒
async ≈ 5 秒

差異幾乎不存在。

但當大量工作同時都在等待 I/O 時,兩種模型的差異才會逐漸出現。

所以回到我們一開始的問題:

Async 到底快在哪裡?

結論是:

沒有。

因為我們從一開始就問錯問題了。

真正該問的是:Async 到底省掉了什麼?

它省掉的,是等待 I/O 時,那條只能陪著一起等的 Thread。

當程式需要等待 I/O 時,可以告訴整個程式:

我這裡暫時要等一下,你先去處理其他事。

好了,這個問題就解答到這裡。

那我再出個作業吧。

但就自己實作對答案吧,明天應該是沒空解答了,有問題的話也可以在留言區問。

  1. 如果在 Discord.py 中進行同步等待會發生什麼?
  2. 如果在 FastAPI 的 async 函式進行同步等待會發生什麼?

下!課!


上一篇
Day 6|終於可以開始寫 Code 了……嗎?
下一篇
Day 8|這我會,登入就用 JW……先別急,來口餅乾再說。
系列文
重寫一套比我還老的系統:21 歲的校園文字廣播系統 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言