Di akhir Bagian 5, kita menemukan sesuatu yang mengejutkan: memperbesar connection pool database nyaris tidak mengubah performa di beban tinggi. Ada bottleneck lain yang membatasi concurrency, terjadi SEBELUM request kita sempat menyentuh database sama sekali. Di artikel ini kita bongkar apa itu, dan kenapa async/await adalah jawabannya.
Thread Pool — Bottleneck Tersembunyi di Balik Endpoint def
Ingat kembali konsep dari Bagian 2 seri FastAPI for Beginners: endpoint biasa (def) dijalankan FastAPI di thread pool terpisah, supaya kode blocking (seperti query database sinkron) tidak membekukan seluruh event loop. Ini penjelasan yang benar, tapi ada detail penting yang belum dibahas: thread pool ini punya kapasitas terbatas.
import asyncio
from anyio import to_thread
async def main():
limiter = to_thread.current_default_thread_limiter()
print(limiter.total_tokens)
asyncio.run(main())
40
Defaultnya 40 thread bersamaan, untuk SEMUA endpoint def biasa di seluruh aplikasi. Request ke-41 yang datang bersamaan harus MENUNGGU salah satu dari 40 thread itu selesai, baru dia kebagian giliran — persis seperti antrean di connection pool database, tapi satu lapis lebih awal, dan sering luput dari perhatian karena tidak muncul sebagai error eksplisit seperti QueuePool — cuma terasa sebagai "lambat" tanpa pesan error yang jelas.
Membuktikan dengan Demo Terisolasi
Supaya bersih dari pengaruh database, cache, atau faktor lain, kita pakai examples/blocking_vs_async_demo.py — dua endpoint yang mensimulasikan panggilan I/O selama 2 detik, satu blocking, satu async:
@app.get("/slow-blocking")
def slow_blocking():
time.sleep(SIMULATED_IO_DELAY_SECONDS) # blocking — jalan di thread pool
return {"ok": True}
@app.get("/slow-async")
async def slow_async():
await asyncio.sleep(SIMULATED_IO_DELAY_SECONDS) # async — jalan di event loop, TANPA thread pool
return {"ok": True}
Jalankan, lalu kirim 80 request bersamaan (dua kali lipat batas thread pool) ke masing-masing:
uvicorn examples.blocking_vs_async_demo:app --port 8001
python benchmark/load_test.py --url http://localhost:8001/slow-blocking --requests 80 --concurrency 80
python benchmark/load_test.py --url http://localhost:8001/slow-async --requests 80 --concurrency 80
Hasilnya:
/slow-blocking (def) | /slow-async (async def) | |
|---|---|---|
| Durasi total | 4.31 detik | 2.37 detik |
| Latency median | 3138 ms | 2173 ms |
| Latency p95 | 4257 ms | 2307 ms |
Endpoint async tidak pernah menyentuh thread pool sama sekali. Semua 80 "panggilan I/O" (asyncio.sleep) dijadwalkan langsung oleh event loop, yang sanggup menangani ribuan operasi I/O menunggu secara bersamaan — karena yang dilakukannya cuma "menunggu dengan efisien", bukan mengalokasikan resource sistem operasi (thread) yang mahal untuk tiap satu request.
"Obat Sementara": Memperbesar Thread Pool
Kalau migrasi penuh ke async belum memungkinkan (misalnya karena driver database yang dipakai belum mendukung async), ada jalan pintas: perbesar kapasitas thread pool-nya secara eksplisit.
from anyio import to_thread
@asynccontextmanager
async def lifespan(app: FastAPI):
to_thread.current_default_thread_limiter().total_tokens = 200
yield
Coba jalankan ulang dengan ENLARGE_THREAD_POOL=true uvicorn examples.blocking_vs_async_demo:app --port 8002, lalu load test /slow-blocking lagi di beban yang sama:
Durasi total : 2.48 detik
Latency median : 2275 ms
Latency p95 : 2419 ms
Hampir identik dengan versi async. Tapi ini bukan solusi jangka panjang — tiap thread OS itu mahal (beberapa MB memory masing-masing, overhead context-switching), sedangkan satu event loop bisa menangani ribuan coroutine dengan overhead yang jauh lebih kecil. Menaikkan angka ini terlalu tinggi cuma memindahkan masalah ke tempat lain (kehabisan memory, bukan kehabisan "slot antre"). Anggap ini pertolongan pertama, bukan pengganti migrasi ke async yang sesungguhnya.
Kenapa task-tracker-api Kita Tidak (Sepenuhnya) Async
Perhatikan bahwa endpoint di task-tracker-api sendiri masih pakai def biasa, bukan async def — sengaja, dan ini bukan kelalaian. Kalau kamu menandai endpoint async def tapi di dalamnya memanggil kode blocking (seperti psycopg2, driver Postgres sinkron yang kita pakai), itu JAUH LEBIH BURUK daripada tetap pakai def biasa — karena kode blocking itu akan membekukan SATU-SATUNYA event loop yang menangani SEMUA request di seluruh aplikasi, bukan cuma satu thread dari 40 yang tersedia. Ini poin yang sudah diperingatkan di Bagian 6 seri FastAPI for Beginners, dan sekarang kita paham betul konsekuensi konkretnya.
Untuk benar-benar memakai async def dengan aman, SETIAP operasi I/O di dalam endpoint itu — query database, panggilan API eksternal, baca file — harus punya versi async-nya masing-masing (misalnya asyncpg untuk Postgres, httpx.AsyncClient untuk panggilan HTTP). Migrasi seperti ini butuh mengganti driver database dan menguji ulang seluruh aplikasi — investasi yang signifikan, jadi masuk akal untuk didahulukan solusi yang lebih murah dulu (caching, indexing, connection pool yang tepat, seperti yang sudah kita lakukan) sebelum lompat ke migrasi async penuh.
Rangkuman
def biasa dijalankan di thread pool dengan kapasitas terbatas (default 40 di anyio/FastAPI) — ini bottleneck tersembunyi yang membatasi concurrency SEBELUM request menyentuh database.async def yang benar-benar async (I/O di dalamnya juga async) tidak memakai thread pool sama sekali, diproses langsung oleh event loop.async def kalau di dalamnya ada kode blocking — itu membekukan seluruh event loop, jauh lebih buruk daripada tetap def biasa.