Web Day 18 非同步端點與 httpx
執行需求:CPU 可跑。今天是「Web 系統實戰:用 FastAPI 打造能上線的後端」系列的第十八篇。我們已經能用 pytest 與 TestClient 把同步端點測得很完整;今天往前走一步,把端點改成 async def,並把測試客戶端換成 httpx 0.28 的 AsyncClient。在 FastAPI 裡,async 與 sync 可以混用,但測試端的 asyncio 流程需要一些觀念,今天會把這件事一次說清楚。
引言
很多人對 async 的印象是「比較快」。這個說法對了一半:async 端點在「等待 I/O」的場景(例如打另一個 API、查資料庫、讀檔案)能讓單一 worker 在等待時去處理別的請求;但純 CPU 的工作(例如數學運算、字串處理)並不會因為加了 async 就變快。今天我們會看的是「async 怎麼寫、怎麼測、怎麼避免踩坑」,重點放在測試端,因為這是品質篇章的核心。
今天的範例同樣接續 Day 16-17 的專案結構。我們會做四件事:第一,說明 async def 端點與同步端點的差異;第二,用 httpx.AsyncClient + pytest-asyncio 寫測試;第三,用 asyncio.gather 並發送多個請求;第四,整理常見踩雷,包括「同步函式裡呼叫 await」、「在 async 函式裡呼叫阻塞式函式」等幾乎一定會遇到的雷區。
async 端點與 sync 端點:什麼時候該用哪個
FastAPI 允許你在同一個應用裡混用 sync 與 async 端點。它會在內部用 anyio 安排它們的執行:
async def端點在「主事件迴圈」或 anyio worker thread 上執行;當它內部await一個真正的非同步 I/O(例如 httpx 的 async client)時,事件迴圈會切換到別的任務。def端點會被丟到一個 thread pool(anyio 提供,預設 40 條 thread)執行,所以它能放心呼叫阻塞式的 I/O(例如 requests、SQLAlchemy 的 sync engine)。
簡單的判斷原則:
- 端點會呼叫非同步函式(httpx async、asyncpg、motor 等):用
async def。 - 端點會呼叫阻塞式函式(requests、psycopg、檔案讀寫):用普通
def,讓 FastAPI 用 thread pool 處理。 - 端點只有純計算:兩者都行;用
def比較省事,避免錯誤使用 await。
今天的範例刻意做一個「打另一個 HTTP API」的端點,這是 async 的典型舞台:
# app/services/external.py
# 模擬外部 HTTP API 的呼叫:async 版本
import httpx
DEFAULT_TIMEOUT = 5.0
async def fetch_user_name(user_id: int) -> str:
# 真實情境會打另一個服務;這裡用一個會被攔截的 URL
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()["name"]
這段程式是「打外部 API」的非同步版本。async with httpx.AsyncClient(...) 會在結束時自動關閉連線;await client.get(...) 把控制權交還給事件迴圈。寫在 sync 端點裡會壞掉,因為 httpx.AsyncClient 只能在 async 環境裡用,這是後面踩雷段落會再提醒的事。
接著看端點怎麼用這個 service:
# app/main.py
# 一個會打外部 API 的 async 端點
import asyncio
from fastapi import FastAPI, HTTPException
from app.services.external import fetch_user_name
app = FastAPI(title="Web Day 18 範例", version="0.18.0")
@app.get("/users/{user_id}/name")
async def get_user_name(user_id: int):
try:
name = await fetch_user_name(user_id)
except httpx.HTTPStatusError as exc: # type: ignore[name-defined]
raise HTTPException(status_code=502, detail=f"上游回應 {exc.response.status_code}")
except httpx.RequestError: # type: ignore[name-defined]
raise HTTPException(status_code=504, detail="上游連線失敗")
return {"user_id": user_id, "name": name}
@app.get("/users/batch")
async def get_user_names_batch(ids: list[int]):
# 並發打多個請求
results = await asyncio.gather(
*(fetch_user_name(user_id) for user_id in ids),
return_exceptions=True,
)
out = []
for user_id, result in zip(ids, results):
if isinstance(result, Exception):
out.append({"user_id": user_id, "error": str(result)})
else:
out.append({"user_id": user_id, "name": result})
return out
get_user_name 用 async def 是因為內部 await 了 fetch_user_name;get_user_names_batch 用 asyncio.gather 並發送多個請求,這是 async 最大的威力:總延遲取決於「最慢的那一個」,而不是「所有相加」。return_exceptions=True 是重要的細節:當其中一個請求拋錯,gather 不會立刻中斷整個批次,而是把例外放進結果陣列,讓我們有選擇性地處理。
測試 async 端點:AsyncClient + pytest-asyncio
TestClient 預設就能處理 async 端點(內部用 portal 把 sync 與 async 橋接起來),所以昨天的測試在 async 端點上也跑得動。但當你想「真的在事件迴圈裡跑測試」時,就要用 httpx.AsyncClient 搭配 pytest-asyncio。這在兩種情境特別有用:要並發、要對 async 中間件(例如 lifespan)做事。
先安裝套件(昨天的 pyproject.toml 已經寫好了,這裡只是確認):
# 命令列:確認 pytest-asyncio 已安裝
uv sync --extra dev
uv run python -c "import pytest_asyncio; print(pytest_asyncio.__version__)"
# 輸出(範例):0.24.x
接著在 pyproject.toml 設定 asyncio mode:
# pyproject.toml(節錄)
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"
asyncio_mode = "auto"
asyncio_mode = "auto" 讓 pytest-asyncio 自動把所有 async def test_xxx 視為非同步測試,不需要每個測試加 @pytest.mark.asyncio。另一個模式是 "strict",每個非同步測試都要加標記;對小專案來說 "auto" 比較省事。
然後改寫 conftest.py,加上 async client fixture:
# tests/conftest.py
# async client:對 async 端點做測試
import pytest
import httpx
from app.main import app
@pytest.fixture
async def async_client():
# 與 ASGITransport 配合,讓 AsyncClient 直接呼叫 ASGI 應用
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
ASGITransport 是 httpx 0.28 的新介面(從 0.27 開始引進),它讓 AsyncClient 直接驅動 ASGI 應用、不需要佔用真的 port,也不需要 uvicorn 啟動。比之前的 httpx.AsyncClient(app=app, ...) 寫法更明確,因為 httpx 把「驅動 ASGI」這件事獨立成 transport 物件,乾淨很多。
現在可以寫第一個 async 測試:
# tests/test_async_users.py
# async 端點的基本測試
import pytest
@pytest.mark.asyncio
async def test_get_user_name(async_client, monkeypatch):
# 把 httpx.AsyncClient 換成假實作,避免真的打到外部
async def fake_fetch(user_id: int) -> str:
return f"使用者 {user_id}"
monkeypatch.setattr("app.main.fetch_user_name", fake_fetch)
response = await async_client.get("/users/42/name")
assert response.status_code == 200
assert response.json() == {"user_id": 42, "name": "使用者 42"}
這個測試用 monkeypatch.setattr 把 app.main.fetch_user_name 換成回傳假資料的版本,這樣測試不會真的打外部 API;同時它真的在事件迴圈裡跑(await 整段),所以 async 中間件、非同步背景任務(Day 19)的行為都會被正確觸發。
完整實作:並發測試與假 server
接著用 asyncio.gather 同時送多個請求、驗證並發行為:
# tests/test_async_users.py(接續)
@pytest.mark.asyncio
async def test_batch_returns_all_users(async_client, monkeypatch):
async def fake_fetch(user_id: int) -> str:
return f"使用者 {user_id}"
monkeypatch.setattr("app.main.fetch_user_name", fake_fetch)
response = await async_client.get("/users/batch", params={"ids": "1,2,3"})
assert response.status_code == 200
body = response.json()
assert len(body) == 3
names = [item["name"] for item in body]
assert names == ["使用者 1", "使用者 2", "使用者 3"]
@pytest.mark.asyncio
async def test_batch_continues_when_one_fails(async_client, monkeypatch):
async def fake_fetch(user_id: int) -> str:
if user_id == 2:
raise RuntimeError("boom")
return f"使用者 {user_id}"
monkeypatch.setattr("app.main.fetch_user_name", fake_fetch)
response = await async_client.get("/users/batch", params={"ids": "1,2,3"})
assert response.status_code == 200
body = response.json()
assert body[0] == {"user_id": 1, "name": "使用者 1"}
assert body[1]["error"] # 有 error 欄位
assert body[2] == {"user_id": 3, "name": "使用者 3"}
第二個測試特別重要:當其中一個子任務失敗,gather(..., return_exceptions=True) 讓其他任務繼續完成,端點回傳 200 並把每筆結果(含錯誤)放在同一個 JSON 陣列裡。這種「一個失敗不會拖垮整批」的設計在批次 API 很常見,也是 return_exceptions=True 的標準使用情境。
另一種測試手法是「架一個假的 HTTP server 給 async client 打」。當我們想驗證「端點真的會呼叫外部 API 而且用的方法正確」,可以先用 aiohttp 或 FastAPI 自己架一個接收端,再用真的 AsyncClient 打過去:
# tests/test_async_users.py(用真的假 server)
import pytest
from fastapi import FastAPI
import httpx
@pytest.fixture
async def fake_upstream():
server = FastAPI()
received = []
@server.get("/users/{user_id}")
async def fake_handler(user_id: int):
received.append(user_id)
return {"name": f"假使用者 {user_id}"}
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://upstream") as client:
# 把 httpx.get 的 base URL 換成假 server
yield client, received
@pytest.mark.asyncio
async def test_calls_real_upstream(fake_upstream, monkeypatch):
upstream_client, received = fake_upstream
# 把 app/services/external.py 裡的 AsyncClient 換成這個假 client
monkeypatch.setattr("app.services.external.httpx.AsyncClient", lambda **kw: upstream_client)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=__import__("app.main", fromlist=["app"]).app)) as client:
response = await client.get("/users/7/name")
assert response.status_code == 200
assert response.json() == {"user_id": 7, "name": "假使用者 7"}
assert 7 in received
這個寫法比較繞,但能驗證「真的會呼叫 upstream,而且呼叫的路徑、URL 是對的」。日常開發用 monkeypatch.setattr 換成函式就夠,只有在懷疑「真的有呼叫嗎」時才需要這個手法。
常見錯誤與踩雷
第一個雷是「sync 函式裡呼叫 await」。當你寫了一個普通 def get_data(),內部卻 await fetch_xxx(),Python 直接拋 SyntaxError: 'await' outside async function。這個錯誤很明確,看到訊息就會改;但變形版是「async 函式裡呼叫 sync 函式,sync 函式又呼叫 httpx 的 sync client」:這會卡住事件迴圈,雖然不報錯,但整個 worker thread 被鎖死,吞吐量暴跌。解法是「async 端點裡只 await async 函式;sync 端點裡只呼叫 sync 函式」。
第二個是「async 端點裡呼叫 requests」。requests 是阻塞式的,呼叫時整個事件迴圈會被卡住,直到回應回來。如果你的端點會打 5 個外部 API、每個花 200 毫秒,總時間會是 1 秒(同步相加);但如果你先 await 第一個、await 第二個,總時間也是 1 秒。真正的差異只在「還能空出時間給別的 worker」,對單一使用者來說沒差。結論:單純為了「變快」而改 async 沒有意義,真正有意義的是「async 端點能讓 FastAPI 在等待時處理別人的請求」。
第三個是「忘記 await」。async 函式回傳的是一個 coroutine 物件,需要 await 才會執行。如果你寫成 result = async_func()(沒有 await),result 會是 coroutine 物件而不是預期的值。這個錯誤在 httpx.AsyncClient 上特別常見,因為 async client 的方法「看起來」跟 sync client 很像,只是多了 await。解法是養成「看到 async 函式就 await」的肌肉記憶。
第四個是「pytest-asyncio 沒裝或沒啟用 auto mode」。如果忘了在 pyproject.toml 設定 asyncio_mode = "auto",async 測試預設不會被 pytest-asyncio 收集,而是被當成「回傳 coroutine 的普通測試」處理,於是測試函式根本不會執行,uv run pytest 顯示 no tests ran 或 collected 0 items。遇到這個狀況先檢查 pyproject.toml 跟 uv sync 是否都做好了。
效能與實務提醒
第一個提醒是「async 不是免費的」。每個 async 端點都要付出事件迴圈的額外成本:coroutine 的建立與切換、anyio 對 bridge thread 的管理。如果你的端點只是純計算或簡單 CRUD(純資料庫查詢),用 def 反而更簡單、效能也差不多。FastAPI 的官方建議是「先寫 sync、看到 I/O 等待時再升級 async」。
第二個是「httpx 的 async 連線池」。當你在 async 端點裡用 async with httpx.AsyncClient(...) 重新產生一個 client,每次都會重新開 TCP 連線。如果你的服務要並發打同一個 upstream,正確做法是用 lifespan 或 app state 共用一個 client:
# app/main.py(用 lifespan 共用 AsyncClient)
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# 啟動時建立 client,關閉時釋放
async with httpx.AsyncClient(timeout=5.0) as client:
app.state.http = client
yield
app = FastAPI(title="Web Day 18 範例", lifespan=lifespan)
@app.get("/users/{user_id}/name")
async def get_user_name(user_id: int):
response = await app.state.http.get(f"https://api.example.com/users/{user_id}")
return {"user_id": user_id, "name": response.json()["name"]}
lifespan 是 FastAPI 0.93 開始的推薦寫法(取代舊的 on_event("startup"))。這裡把 AsyncClient 放進 app.state,所有端點共用同一條 client 與連線池,吞吐量會明顯提升;測試時也只要在 lifespan 裡做事就能驗證啟動流程。
第三個是「測試不要真的打外部 API」。CI 環境可能沒有對外網路,打到一半 timeout 會讓測試不穩定。今天的 monkeypatch.setattr 手法幾乎是最簡單、最穩定的替代方案;正式專案若想做得更系統,可以考慮 VCR.py(把 HTTP 互動錄下來、重播)但這是更進階的議題。
async 端點與 SQLAlchemy 的搭配
Web Day 6 介紹的 SQLModel 是 SQLAlchemy 的薄包裝,而 SQLAlchemy 2.0 同時提供同步與非同步兩個 engine。當 async 端點需要查資料庫時,必須使用非同步 engine(create_async_engine)與非同步 session(AsyncSession),不能把同步 engine 拿來在 async 端點裡呼叫。
把昨天的同步設定改成非同步版本,差異其實不大:
# app/db.py(非同步版本)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models import Item # 假設有這個模型
DATABASE_URL = "sqlite+aiosqlite:///./app.db"
engine = create_async_engine(DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, expire_on_commit=False)
async def get_db():
async with async_session() as session:
yield session
這段程式用了 sqlite+aiosqlite 這個特殊的 URL scheme,告訴 SQLAlchemy 我們要走 aiosqlite(非同步 SQLite 驅動)。PostgreSQL 對應的是 postgresql+asyncpg,MySQL 對應的是 mysql+aiomysql。選錯驅動會在啟動時就報錯,這是好事,因為錯誤訊息會很明確。
端點的寫法相對單純:用 async with 取代 yield、呼叫 await session.execute(...) 取代 session.exec(...):
# app/main.py(async 版本)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import Depends, FastAPI
from app.db import get_db
from app.models import Item, ItemIn, ItemOut
app = FastAPI(title="Web Day 18 async 範例", version="0.18.0")
@app.post("/items", response_model=ItemOut, status_code=201)
async def create_item(payload: ItemIn, db: AsyncSession = Depends(get_db)):
item = Item(name=payload.name, price=payload.price)
db.add(item)
await db.commit()
await db.refresh(item)
return item
@app.get("/items", response_model=list[ItemOut])
async def list_items(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Item))
return result.scalars().all()
注意 db.commit() 與 db.refresh() 在非同步版本都要 await,這是新手最常漏掉的一個 await。少了 await,這個敘述會回傳一個 coroutine 物件而不會真的提交交易,於是後續的查詢讀不到剛剛新增的資料,看起來就像「明明寫了但沒生效」。這個錯誤的除錯方式是把 await db.commit() 改成 await db.commit(); assert db.in_transaction() is False(同步版本要 db.commit(); assert not db.in_transaction()),斷言失敗就會告訴你 commit 沒真的執行。
測試 async + async session:換掉 fixture
昨天的 fixture 用的是同步 SQLAlchemy 2.0 + :memory: SQLite,要改成非同步版本時,只需要把 engine 換成 create_async_engine、session 換成 AsyncSession:
# tests/conftest.py(async 版本)
import pytest
import httpx
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
@pytest.fixture
async def async_engine():
eng = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
)
async with eng.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
try:
yield eng
finally:
async with eng.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
await eng.dispose()
@pytest.fixture
async def async_session(async_engine):
async_session_factory = async_sessionmaker(async_engine, expire_on_commit=False)
async with async_session_factory() as session:
yield session
@pytest.fixture
async def async_client(async_session):
from app.db import get_db
from app.main import app
async def override_get_db():
yield async_session
app.dependency_overrides[get_db] = override_get_db
try:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
finally:
app.dependency_overrides.clear()
這個 fixture 比同步版本複雜一些,但核心觀念一樣:每個測試函式拿到一份專屬的 async_session,結束時 session 與連線會被正確關閉。測試本體的寫法則跟昨天幾乎一樣,只是要加 await。實務上你通常會同時維護「同步版」與「非同步版」兩個 fixture 集,等專案完全轉到非同步再把同步版刪掉;混用期間可以靠 pytest 的 marker(@pytest.mark.unit、@pytest.mark.integration)分類。
# tests/test_async_items.py
import pytest
from sqlalchemy import select
from app.models import Item
@pytest.mark.asyncio
async def test_create_and_list_items(async_client, async_session):
response = await async_client.post("/items", json={"name": "pen", "price": 10})
assert response.status_code == 201
response = await async_client.get("/items")
assert response.status_code == 200
body = response.json()
assert len(body) == 1
assert body[0]["name"] == "pen"
rows = (await async_session.execute(select(Item))).scalars().all()
assert len(rows) == 1
兩個 fixture 共用同一條 async_engine,因此 async_session 寫進去的資料,async_client 透過端點寫進去的資料,能在同一個 in-memory SQLite 裡看到。這是「用 dependency_overrides 共用 session」的核心價值:測試函式、端點、teardown 看到的都是同一份記憶體狀態。
順便提醒,aiosqlite 目前在 Linux/macOS 用原生 thread、在 Windows 走 ProactorEventLoop;如果你在 Windows 上開發,遇到奇怪的事件迴圈錯誤,可以把測試改成用同步 SQLAlchemy,這是過渡期最務實的作法。等正式部署到 Linux 容器再切回非同步,這也是品質篇一直強調的「環境一致」原則。
async 端點的生命週期管理:背景任務的入口
今天的最後一段,預告明天的主題。async 端點常常會需要「先回應、把工作丟到背景」。FastAPI 提供 BackgroundTasks,用法是這樣的:
# app/main.py(先給個預告)
from fastapi import BackgroundTasks, FastAPI
app = FastAPI(title="Web Day 18 預告", version="0.18.0")
def write_audit_log(user_id: int):
# 假裝寫到檔案或送審計系統
with open("audit.log", "a", encoding="utf-8") as f:
f.write(f"user {user_id} 做了某件事\n")
@app.post("/actions")
async def perform_action(user_id: int, background: BackgroundTasks):
# 立刻回應,真正的寫檔丟到背景
background.add_task(write_audit_log, user_id)
return {"status": "accepted"}
background.add_task 把函式排進 FastAPI 的 background queue,回應送出後才執行。這是明天 Web Day 19 的主題:什麼樣的工作適合用 BackgroundTasks、什麼樣的工作必須上 Celery 之類的外部 broker。我們今天先記得這個 API,明天會完整展開。
小結
今天把 async 端點與 httpx 0.28 的 AsyncClient 接上線了。FastAPI 對 sync 與 async 混用友善,但測試端要看情境選 TestClient(簡單)或 AsyncClient(要並發、要 lifespan)。asyncio.gather(return_exceptions=True) 是批次 API 的好朋友,monkeypatch 是取代「真的打外部 API」的最直接手段。ASGITransport 是 httpx 0.28 的新介面,讓我們不用啟動 uvicorn 就能在事件迴圈裡測 ASGI 應用。明天我們會把 async 端點推進到「背景任務」:在回應送出去之後才做的事,怎麼測、怎麼不被阻塞。
今天另外把 SQLAlchemy 2.0 的非同步設定也帶進來:用 create_async_engine 與 AsyncSession 取代同步版本,並把 commit、refresh、execute 全部加上 await。fixture 集也對應改造,讓 async_session 與 async_client 共用同一條 in-memory engine。雖然這篇的核心是 httpx 與 asyncio,但「async 端點最終都會需要 async 資料庫」,早點熟悉對後續章節很有幫助。
async 的學習路徑:怎麼判斷我已經準備好了
很多新手會問「我什麼時候該開始寫 async 端點」。幾個簡單的判斷:第一,你的端點會呼叫另一個 HTTP API、資料庫連線、訊息佇列嗎?是的話,async 是合理的選擇。第二,你預期會在高峰期同時有數百個慢請求嗎?是的話,async 能讓 FastAPI 更有效率地用 worker。第三,你已經熟悉 sync 端點的測試嗎?還沒的話,先把 sync 與同步測試寫穩,再升級 async。
最後一個判斷是「這個工作真的會等 I/O 嗎」。如果你的「外部 API」其實只是 in-memory 字典查表(你寫了一個假服務),那同步版就跑得比 async 版快了。把這種場景強行升級成 async,會讓程式碼變得複雜、測試變得難寫,卻換不到任何效能收益。FastAPI 官方說明有句話很實在:「如果你不知道該不該用 async,先用 sync 就對了」。
另外兩個有助於決策的線索是「你已經在 asyncio 生態裡了」與「團隊裡有人能 cover on-call」。前者代表你的其他套件(資料庫驅動、快取 client、訊息佇列)都已經是非同步;後者代表出問題時有人能馬上看懂 coroutine、TaskGroup、asyncio.gather 的狀態。如果兩者都不確定,先用 sync、之後再升級,是風險最低的路徑。
結語
今天的 async 測試都假設「工作是當下完成的」。但很多真實情境是「使用者按了一個按鈕、伺服器收到請求、立刻回 200、實際工作丟到背景慢慢做」。明天,Web Day 19「背景任務:FastAPI BackgroundTasks 與 Celery 入門」會把這個模式拆開來看:什麼時候用 BackgroundTasks 就好、什麼時候必須上 Celery、Windows 與小型服務的替代方案是什麼。我們也會用今天的 AsyncClient 來測背景任務的執行狀況。
如果今晚就想動手:把昨天的同步測試改寫成 async 版本,是最直接的練習。把 def test_xxx 改成 async def test_xxx、把 client.get 改成 await async_client.get、確認 pytest-asyncio 的設定就緒,跑一次 uv run pytest。這個練習大約 15 分鐘就做完,但會讓你對今天的工具鏈留下更深的印象。
延伸資源
- FastAPI 官方 Async 測試教學(0.116,2025-07):
https://fastapi.tiangolo.com/advanced/async-tests/ - httpx Async 與 ASGITransport(0.28,2025):
https://www.python-httpx.org/async/ - pytest-asyncio 官方文件(0.24,2025):
https://pytest-asyncio.readthedocs.io/ - anyio 官方說明(2025):
https://anyio.readthedocs.io/ - Python asyncio 官方教學(3.13):
https://docs.python.org/3/library/asyncio.html
留言
張貼留言