๐ฌ 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:
- Concurrency โ parallelism. 10.000 task HTTP bisa jalan bersamaan karena semuanya menunggu socket, bukan karena ada 10.000 CPU.
- Satu task yang tidak
awaitmemblokir seluruh proses. Tidak ada preemption. Event loop tidak bisa "merampas" CPU dari kode sinkron. - Satu event loop per thread. Karena itu di build GIL, aplikasi async tetap mentok di ~1 core untuk kerja Python-level.
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:
- Sync driver di jalur async.
psycopg2,requests,boto3di dalamasync def. Tidak error, tidak warning โ cuma latency p99 yang naik pelan-pelan sampai timeout. Ini penyebab nomor satu "kenapa app async saya lambat". except Exception: passmenelanCancelledError. Task tidak pernah benar-benar berhenti,TaskGroupmenggantung, graceful shutdown deploy jadi 30 detik lalu SIGKILL.gather()tanpa bound. 50k task โEMFILE: too many open files, atau target API memblokir.- 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.
asyncio.create_task()tanpa simpan referensi. Task bisa di-GC sebelum selesai. Selalu simpan di set, atau pakaiTaskGroup.- Thread pool default terlalu kecil.
to_threadpakai default executormin(32, cpu+4). 200 panggilan blocking sekaligus โ antre di executor, bukan paralel. - Debugging buta. Tanpa introspeksi, "aplikasi hang" tidak bisa dilokalisasi. Di 3.14+ ada
python -m asyncio pstree, sebelum itu wajibpy-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
- Kamu punya endpoint yang memanggil
hashlib.pbkdf2_hmac200 ms per request. Apakahawait asyncio.to_thread(...)menyelesaikan masalah? (Petunjuk:hashlibmelepas GIL. Pikirkan tentang apa yang sebenarnya kamu bebankan.) asyncio.gatherdengan 3 task, satu raiseValueError. Apa yang terjadi pada 2 task lain, dan exception apa yang keluar โ sebelum dan sesudahTaskGroup?- Di free-threaded build, apakah kamu masih butuh
asynciosama 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
async/await= concurrency untuk I/O, bukan paralelisme. Nol speedup untuk CPU-bound Python.- GIL mengunci eksekusi bytecode, bukan syscall. Library C yang melepas GIL (NumPy, zlib, hashlib, lz4) tetap paralel di thread.
- Satu blocking call di event loop membekukan seluruh proses. Terukur: heartbeat 1 tick selama 162 ms kerja sinkron.
- 3.14 = free-threaded resmi didukung (PEP 779), penalti single-thread turun ke 5โ10%, asyncio bisa banyak loop paralel (TCP 6 worker: 532 โ 1455 MB/s).
- 3.15 =
TaskGroup.cancel(), introspeksi call graph, policy system deprecated dan hilang di 3.16 โ ini yang harus dibersihkan sekarang. CancelledErrorituBaseException.except Exceptiontidak menangkapnya. Ini sumber bug shutdown paling umum.- Pilih konkurensi berdasarkan profil:
epoll_waitdominan โ async; frame Python dominan โ proses/free-threaded.
โก๏ธ Eksplorasi Lanjut
- PEP 703 (free-threading) dan PEP 779 (kriteria supported) โ baca kriteria fase ketiga, yaitu syarat kapan free-threaded jadi default.
- PEP 810 lazy imports (3.15) โ dampak langsung ke cold start Lambda/Cloud Run. Ini lebih besar pengaruhnya ke bill kamu daripada tweak async apa pun.
- PEP 799 + Tachyon sampling profiler di 3.15 โ profiler high-frequency bawaan stdlib, pengganti
py-spyuntuk kasus tertentu. asyncio.Runnerโ untuk aplikasi long-lived yang perlu reuse loop antar-run()(REPL, test suite, notebook).- Baca
InternalDocs/asyncio.mddi repo CPython โ dokumentasi internal desain per-thread task list, jauh lebih jelas dari doc publik. - Lanjut praktik: ambil satu endpoint lambat di project kamu,
py-spy dump10 sampel, klasifikasikan tiap frame ke {I/O, CPU-Python, CPU-C, lock}. Itu satu-satunya cara berhenti menebak.
โ ๏ธ 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.