II — Praktik
Artikel 6 dari 9

Praktik: Async & Concurrency yang Benar

Membongkar bottleneck tersembunyi di thread pool FastAPI, membuktikan bedanya lewat demo blocking vs async terisolasi, dan kenapa `async def` yang salah pakai justru lebih buruk dari `def` biasa.

10 Juli 2026

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 total4.31 detik2.37 detik
Latency median3138 ms2173 ms
Latency p954257 ms2307 ms
Endpoint async menyelesaikan SEMUA 80 request dalam ~2.4 detik — kurang lebih sama dengan satu kali durasi simulasi I/O (2 detik) plus overhead. Endpoint blocking butuh ~4.3 detik — hampir DUA KALI durasi simulasinya. Ini bukan kebetulan: dengan thread pool 40 dan 80 request bersamaan, terjadi dua "gelombang" — 40 request pertama selesai di ~2 detik, 40 sisanya baru mulai diproses setelah itu, selesai di ~4 detik.

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

  • Endpoint def biasa dijalankan di thread pool dengan kapasitas terbatas (default 40 di anyio/FastAPI) — ini bottleneck tersembunyi yang membatasi concurrency SEBELUM request menyentuh database.
  • Endpoint async def yang benar-benar async (I/O di dalamnya juga async) tidak memakai thread pool sama sekali, diproses langsung oleh event loop.
  • Memperbesar thread pool adalah solusi cepat sementara, tapi punya biaya memory dan bukan pengganti migrasi async sungguhan.
  • Jangan menandai async def kalau di dalamnya ada kode blocking — itu membekukan seluruh event loop, jauh lebih buruk daripada tetap def biasa.
  • Topik

    ScalabilityAsyncPython