โ† semua materi
15:00 Teknis ยท 29 Oct 2026 ยท 15:06

๐Ÿ”ฌ BACOT โ€” Sesi 15:00 Technical Exploration

๐Ÿ“š Python async/await, Event Loop, dan Batas GIL

Status versi (terverifikasi 29 Okt 2026): - Python 3.15.2 = stable terbaru, dirilis 26 Okt 2026 (PEP 790). 3.15.0 final 9 Okt 2026. - Python 3.14 = rilis 7 Okt 2025, free-threaded build resmi bukan eksperimental lagi (PEP 779 accepted Juni 2025). - Mesin uji materi ini: CPython 3.11.15 โ€” semua output kode di bawah nyata dari run, bukan karangan.


๐ŸŽฏ Tujuan Belajar

Bisa jawab tiga pertanyaan ini dengan tepat, tanpa menebak: 1. Kapan async/await bikin cepat, kapan justru bikin lebih lambat? 2. Kenapa 4 thread Python CPU-bound tidak jadi 4x lebih cepat (di build GIL)? 3. Apa yang berubah di 3.14/3.15 sehingga jawaban nomor 2 mulai bergeser?

Skill konkret besok: baca stack async, deteksi blocking call di event loop, pilih antara asyncio, to_thread, ProcessPoolExecutor, atau free-threaded build โ€” berdasarkan profil beban, bukan kebiasaan.


๐Ÿ—๏ธ Arsitektur & Alur Teknis

Event loop = single-threaded scheduler + readiness poller

asyncio.run(main())
   โ””โ”€ new event loop (1 per thread)
        โ”œโ”€ ready queue  (callback siap jalan)
        โ”œโ”€ selector     (epoll Linux / kqueue macOS / IOCP Windows)
        โ””โ”€ loop:
             1. jalankan semua callback di ready queue sampai habis
             2. hitung timeout ke deadline terdekat (timer heap)
             3. selector.select(timeout)  โ† satu-satunya titik "tidur"
             4. callback hasil I/O masuk ready queue โ†’ ulang

Konsekuensi keras dari desain ini:

GIL: apa yang sebenarnya dikunci

GIL adalah mutex global yang melindungi reference counting dan state interpreter. Efeknya:

Beban GIL build Free-threaded build (3.14+)
I/O-bound (network, disk) Paralel โ€” GIL dilepas saat syscall Paralel, sama
CPU-bound Python murni, multi-thread Serial (saling rebut GIL tiap ~5 ms) Paralel linear per core
Library C yang lepas GIL (NumPy, zlib, hashlib) Paralel Paralel
Startup / single-thread Baseline ~5โ€“10% lebih lambat (3.14 release notes)

sys.getswitchinterval() default 0.005 s โ€” itu granularitas GIL dilepas-pasang. Ini alasan kenapa thread CPU-bound di build GIL bukan cuma "tidak cepat", tapi bisa lebih lambat dari single-thread karena biaya context switch + cache thrash.

Bukti di mesin sendiri (CPython 3.11.15, GIL aktif)

$ python3 /tmp/gil-demo/bench.py
3.11.15 GIL: True
1 thread: 0.44s  4 threads: 1.81s  scaling: 0.98x of ideal
heartbeat ticks during blocked CPU work: 1

Dua temuan, keduanya terukur: - fib(32) ร— 4 di ThreadPoolExecutor(max_workers=4) = 1.81 s, sedangkan 1 thread = 0.44 s. Total kerja 4ร—, waktu 4.1ร— โ†’ nol speedup, malah sedikit overhead. Ini GIL, bukan bug kode. - Heartbeat task (await asyncio.sleep(0.01) loop) hanya tick 1 kali selama fib(30) sinkron dipanggil di dalam coroutine. Loop benar-benar beku.


๐Ÿ’ป Kode Nyata

Semua di bawah dijalankan di CPython 3.11.15. Output ditempel apa adanya.

1. Deteksi blocking call โ€” pola paling sering bikin produksi jebol

import asyncio, time

def blocking_cpu(n=2_000_000):
    s = 0
    for i in range(n): s += i * i
    return s

async def main():
    loop = asyncio.get_running_loop()

    t0 = loop.time()
    blocking_cpu()                                  # SALAH: blokir loop
    print(f"blocked loop for {loop.time()-t0:.3f}s (no other task ran)")

    t0 = loop.time()
    await asyncio.to_thread(blocking_cpu)           # BENAR: geser ke thread
    print(f"to_thread wall {loop.time()-t0:.3f}s, loop stayed free")

asyncio.run(main())
blocked loop for 0.162s (no other task ran)
to_thread wall 0.178s, loop stayed free

Catatan penting: to_thread tidak mempercepat kerja CPU (0.178 vs 0.162 โ€” malah sedikit lebih lambat karena overhead thread). Fungsinya cuma membebaskan loop supaya request lain tetap dilayani. Untuk benar-benar paralel CPU di build GIL, to_thread tidak cukup โ€” pakai proses.

2. Bounded concurrency โ€” jangan pernah gather() tanpa batas

import asyncio

async def fetch_bounded(urls, limit=5):
    sem = asyncio.Semaphore(limit)
    async def one(u):
        async with sem:
            await asyncio.sleep(0.05)   # ganti: async with session.get(u)
            return u
    return await asyncio.gather(*(one(u) for u in urls))

# terukur: peak concurrency 5 untuk 50 job

Tanpa Semaphore, 50.000 URL = 50.000 koneksi terbuka = file descriptor habis, memori naik, target rate-limit memblokir IP. gather() tidak punya backpressure bawaan.

3. Timeout + cancellation yang benar

import asyncio

async def main():
    try:
        async with asyncio.timeout(0.05):      # butuh 3.11+
            await asyncio.sleep(1)
    except TimeoutError:                        # builtin TimeoutError, bukan asyncio.TimeoutError
        print("asyncio.timeout fired")

asyncio.timeout() (3.11+) menggantikan wait_for(). Yang wajib diingat: asyncio.CancelledError turunan BaseException sejak 3.8, jadi except Exception: tidak menangkapnya. Kalau kode kamu punya except Exception: pass di dalam task, cancellation akan tertelan dan TaskGroup/timeout bisa menggantung.

4. Yang baru di 3.14/3.15 โ€” pakai ini untuk debug

Python 3.14 menambah introspeksi call graph async. Bisa dipanggil dari dalam program atau dari proses lain:

import asyncio

async def test():
    asyncio.print_call_graph()      # 3.14+

async def main():
    async with asyncio.TaskGroup() as g:
        g.create_task(test(), name="test")

asyncio.run(main())

Output resmi dari dokumentasi:

* Task(name='test', id=0x1039f0fe0)
+ Call stack:
|   File 't2.py', line 4, in async test()
+ Awaited by:
   * Task(name='Task-1', id=0x103a5e060)
      + Call stack:
      |   File 'taskgroups.py', line 107, in async TaskGroup.__aexit__()
      |   File 't2.py', line 7, in async main()

Plus dari shell, ke proses yang sedang jalan:

python -m asyncio pstree <pid>

Ini menjawab pertanyaan yang dulu cuma bisa dijawab dengan py-spy atau tebak-tebakan: task mana yang menggantung dan siapa yang meng-await dia.

5. TaskGroup.cancel() โ€” baru di 3.15

import asyncio

async def main():
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(asyncio.sleep(10))
        t2 = tg.create_task(asyncio.sleep(10))
        await asyncio.sleep(0.1)
        tg.cancel()          # 3.15+: exit non-exceptional, tanpa raise
    print("group exited cleanly")

Sebelum 3.15, keluar awal dari TaskGroup butuh trik: task tambahan yang raise custom exception lalu di-suppress saat keluar. Sekarang satu method. Ini bukan cancel() yang melempar ExceptionGroup โ€” keluar bersih.

6. Deprecation yang akan menggigit saat naik ke 3.16

Dari "What's New in 3.15" (DIKETAHUI, tercantum eksplisit):

API Status
asyncio.get_event_loop_policy() / set_event_loop_policy() deprecated, hapus di 3.16
asyncio.AbstractEventLoopPolicy, DefaultEventLoopPolicy, WindowsSelectorEventLoopPolicy, WindowsProactorEventLoopPolicy deprecated, hapus 3.16
asyncio.iscoroutinefunction() deprecated, hapus 3.16 โ†’ pakai inspect.iscoroutinefunction()

Penggantinya policy system โ†’ loop_factory:

import asyncio

async def main(): ...

asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop)   # contoh resmi utk Windows

Kalau codebase kamu masih set policy di conftest.py atau gunicorn_conf.py, itu utang teknis dengan tenggat 3.16.


๐Ÿ“Š Angka & Benchmark

Semua angka di bawah dari sumber pihak ketiga, bukan pengukuran saya โ€” kecuali baris pertama.

Metrik Build GIL Free-threaded Sumber
fib(32) ร—4 thread, speedup 0.98ร— (nol) ~3.1ร— ukur sendiri 3.11.15 / Grinberg 3.14
Single-thread penalty FT baseline 0.91ร— (Linux 6.59โ†’7.05 s) Grinberg, blog Okt 2025
Single-thread penalty FT (resmi) baseline ~5โ€“10% What's New 3.14
Memori FT baseline +~10% Astral blog 3.14
asyncio pyperformance baseline +10โ€“20% (3.14 vs 3.13) What's New 3.14 / Quansight
TCP throughput, 6 worker 532 MB/s 1455 MB/s Quansight Labs, 10 Sep 2025
TCP throughput, 12 worker 698 MB/s 1924 MB/s idem
Web scraping (aiohttp), 12 worker 35 stories/s 80 stories/s idem

Baca angka ini dengan benar: kenaikan 532โ†’1455 MB/s di 6 worker bukan karena Python jadi lebih cepat per-operasi, tapi karena 6 event loop bisa jalan di 6 core sekaligus. Di build GIL, 6 loop tetap rebutan satu GIL.

โš ๏ธ BELUM PASTI: benchmark Quansight di mesin Windows 6 core/12 hyper-thread, satu skenario. Jangan generalisasi ke workload kamu tanpa ukur sendiri. Dan angka 3.1ร— dari Grinberg = fib murni Python; workload nyata biasanya didominasi library C yang sudah lepas GIL sejak dulu, jadi gain-nya jauh lebih kecil.


โš–๏ธ Trade-off: pilih yang mana

Pendekatan Cocok untuk Biaya
asyncio I/O-bound masif: HTTP fan-out, WebSocket, DB async Semua library harus async-native. Satu blocking call = seluruh loop beku. Ekosistem terpecah (sync vs async driver).
ThreadPoolExecutor / to_thread I/O blocking di library sync, kerja CPU kecil (< ~50 ms) GIL: tidak paralel untuk Python-level CPU. Thread stack memori.
ProcessPoolExecutor CPU-bound Python murni, build GIL Pickling overhead, ~50โ€“100 ms spawn per proses, state tidak share, sulit debug.
Free-threaded build 3.14+ CPU-bound + butuh shared state, atau banyak event loop paralel โˆ’5โ€“10% single-thread, +10% memori, ekstensi C harus kompatibel (Py_GIL_DISABLED)
multiprocessing manual Isolasi keras, crash containment Paling berat, IPC manual

Aturan praktis yang tahan uji: ukur dulu, jangan pilih berdasarkan agama. Kalau >70% waktu di select()/epoll_wait โ†’ async menang. Kalau >70% di frame Python sendiri โ†’ proses atau free-threaded. Kalau di read() syscall library C โ†’ thread biasa sudah cukup.

Satu jebakan yang sering dilupakan: async tidak membuat DB jadi lebih cepat. asyncpg vs psycopg2 untuk 1 query = sama. Bedanya muncul saat 500 query konkuren: asyncpg 1 koneksi event-driven vs 500 thread psycopg2. Kalau QPS kamu rendah, async cuma menambah kompleksitas tanpa bayaran.


๐Ÿญ Use Case Produksi & Failure Mode

Pola yang terbukti jalan: FastAPI/Starlette + uvicorn worker = jumlah core, tiap worker 1 event loop. Fan-out ke downstream pakai httpx.AsyncClient + Semaphore. CPU berat (PDF parsing, image resize, crypto) di to_thread atau antrean terpisah (Celery/RQ/Arq).

Failure mode, urut dari yang paling sering saya temui:

  1. Sync driver di jalur async. psycopg2, requests, boto3 di dalam async def. Tidak error, tidak warning โ€” cuma latency p99 yang naik pelan-pelan sampai timeout. Ini penyebab nomor satu "kenapa app async saya lambat".
  2. except Exception: pass menelan CancelledError. Task tidak pernah benar-benar berhenti, TaskGroup menggantung, graceful shutdown deploy jadi 30 detik lalu SIGKILL.
  3. gather() tanpa bound. 50k task โ†’ EMFILE: too many open files, atau target API memblokir.
  4. Kerja CPU di dalam loop. JSON parsing payload 10 MB, regex kompleks, template rendering. Terlihat "cuma 30 ms", tapi 30 ms ร— 1000 rps = loop 100% sibuk.
  5. asyncio.create_task() tanpa simpan referensi. Task bisa di-GC sebelum selesai. Selalu simpan di set, atau pakai TaskGroup.
  6. Thread pool default terlalu kecil. to_thread pakai default executor min(32, cpu+4). 200 panggilan blocking sekaligus โ†’ antre di executor, bukan paralel.
  7. Debugging buta. Tanpa introspeksi, "aplikasi hang" tidak bisa dilokalisasi. Di 3.14+ ada python -m asyncio pstree, sebelum itu wajib py-spy dump --pid.

๐Ÿ› ๏ธ Latihan Praktis (laptop sendiri, ~15 menit)

A. Buktikan GIL di mesin kamu.

python3 -c "
import sys,time
from concurrent.futures import ThreadPoolExecutor
def fib(n): return n if n<2 else fib(n-1)+fib(n-2)
for w in (1,2,4):
    t=time.perf_counter()
    with ThreadPoolExecutor(w) as ex: list(ex.map(fib,[30]*w))
    print(w,'thread(s):',round(time.perf_counter()-t,2),'s')
print('GIL:', getattr(sys,'_is_gil_enabled',lambda:True)())
"

Harapan: waktu naik ~linear terhadap jumlah thread = nol speedup. Lalu jalankan ulang dengan ProcessPoolExecutor โ€” harusnya mendekati flat.

B. Ukur penalti free-threading di mesin kamu (butuh uv):

uv python install 3.14t
uvx python@3.14t -c "import sys; print(sys.version, sys._is_gil_enabled())"

Lalu jalankan skrip A dengan interpreter itu. Bandingkan: single-thread lebih lambat berapa persen, 4-thread speedup berapa kali.

C. Cari blocking call di project sendiri.

pip install py-spy
py-spy dump --pid $(pgrep -f uvicorn | head -1)

Kalau ada frame di socket.recv, time.sleep, atau driver sync di dalam coroutine โ€” itu tersangka.


โœ… Cek Pemahaman

  1. Kamu punya endpoint yang memanggil hashlib.pbkdf2_hmac 200 ms per request. Apakah await asyncio.to_thread(...) menyelesaikan masalah? (Petunjuk: hashlib melepas GIL. Pikirkan tentang apa yang sebenarnya kamu bebankan.)
  2. asyncio.gather dengan 3 task, satu raise ValueError. Apa yang terjadi pada 2 task lain, dan exception apa yang keluar โ€” sebelum dan sesudah TaskGroup?
  3. Di free-threaded build, apakah kamu masih butuh asyncio sama sekali? Kapan jawabannya "ya", kapan "tidak"?
Kunci singkat 1. `to_thread` membebaskan loop, dan karena `hashlib` melepas GIL, thread-thread itu **benar-benar paralel** โ€” jadi ini kasus di mana thread menang, bukan proses. Tapi tetap butuh bound pada executor-nya; 200 panggilan konkuren akan antre di pool default 32. 2. `gather` tanpa `return_exceptions=True`: exception pertama langsung dipropagasi, task lain **tidak** otomatis dibatalkan (mereka lanjut jalan sebagai orphan). `TaskGroup`: sibling dibatalkan otomatis, dan exception dilempar sebagai `ExceptionGroup`. 3. Ya, kalau butuh puluhan ribu koneksi konkuren โ€” thread 1:1 dengan koneksi, 10k thread = memori dan scheduler kewalahan. Tidak, kalau masalahnya murni CPU paralel dengan state bersama.

๐Ÿ”‘ Poin Kunci


โžก๏ธ Eksplorasi Lanjut

โš ๏ธ Catatan limitasi: semua angka benchmark eksternal di atas dari mesin dan skenario penulis masing-masing (Grinberg: 2 laptop; Quansight: Windows 6C/12T). Angka GIL lokal saya dari CPython 3.11.15 di host ini, fib murni โ€” bukan proksi untuk workload kamu. Angka 3.15.2 saya ambil dari dokumentasi resmi dan PEP 790, tidak saya jalankan sendiri karena host hanya punya 3.11 dan 3.12.