Skip to content
Blog Panji

Panduan asyncio Python: Concurrency Tanpa Thread untuk I/O-bound Workload

Pelajari asyncio Python dari nol: cara kerja event loop, coroutine, dan TaskGroup, plus tutorial membangun HTTP client dan scraper konkuren dengan contoh kode yang bisa dijalankan.

Panji Ihsanudin Fajri

12 min read1 reads

Diagram alur event loop asyncio Python menjalankan coroutine secara konkuren
Diagram alur event loop asyncio Python menjalankan coroutine secara konkuren
Contents
  1. Apa itu asyncio?
  2. Kenapa asyncio penting untuk workload I/O-bound
  3. Cara kerja asyncio: event loop, coroutine, dan task
  4. Asyncio vs threading vs multiprocessing
  5. Tutorial: membangun HTTP client konkuren
  6. Pola-pola penting: gather, TaskGroup, timeout, Semaphore, dan Queue
  7. asyncio.gather
  8. asyncio.TaskGroup (Python 3.11+)
  9. asyncio.timeout (Python 3.11+)
  10. asyncio.Semaphore
  11. asyncio.Queue
  12. loop.run_in_executor
  13. Studi kasus: scraper data konkuren
  14. Kesalahan umum dan cara menghindarinya
  15. Best practice
  16. FAQ
  17. Kesimpulan

Sebagian besar waktu program Python yang memanggil API atau membaca database habis untuk menunggu. CPU menganggur, tapi program tetap lambat karena ia menunggu balasan jaringan satu per satu. asyncio Python mengubah pola itu: satu thread bisa menangani banyak operasi I/O secara bersamaan. Artikel ini membahas event loop Python, coroutine Python, dan pola async await Python, sampai kamu bisa membangun HTTP client dan scraper konkuren sendiri.

Ringkasan:

  • asyncio adalah library standar Python untuk konkurensi single-thread berbasis event loop dan sintaks async/await.
  • Ia cocok untuk workload I/O-bound seperti request API, scraping, dan query database; tidak cocok untuk komputasi CPU-bound.
  • Pola intinya: coroutine + await, asyncio.gather, TaskGroup, timeout, Semaphore, dan Queue.
  • Kode blocking harus dipindah ke thread lewat loop.run_in_executor supaya tidak membekukan event loop.
  • Simpan referensi task yang fire-and-forget, batasi konkurensi, dan selalu pasang timeout.
Diagram alur event loop asyncio Python menjalankan coroutine secara konkuren
Cover: cara kerja asyncio

Apa itu asyncio?

asyncio adalah library standar Python untuk menulis kode konkuren dengan sintaks async dan await. Ia berjalan dalam satu thread memakai event loop yang menjadwalkan banyak coroutine secara bergantian, sehingga program bisa menangani ribuan operasi I/O yang sedang menunggu tanpa membuat thread baru.

Unit dasarnya adalah coroutine: fungsi yang didefinisikan dengan async def dan bisa dijeda di titik await. Saat satu coroutine menunggu respons jaringan, event loop tidak ikut menunggu — ia langsung mengerjakan coroutine lain yang sudah siap. Begitu respons datang, coroutine pertama dilanjutkan dari titik ia berhenti.

Penting dipahami: asyncio itu konkuren, bukan paralel. Kode tetap berjalan di satu thread dan satu core CPU dalam satu waktu. Kecepatannya datang dari fakta bahwa operasi I/O tidak butuh CPU — waktu menunggu itulah yang dipakai untuk mengerjakan hal lain. Dokumentasi resminya bisa dibaca di dokumentasi asyncio.

Kenapa asyncio penting untuk workload I/O-bound

Program I/O-bound menghabiskan sebagian besar waktunya menunggu: menunggu respons HTTP, menunggu query database, menunggu file selesai dibaca. Selama menunggu, CPU tidak melakukan apa-apa. Menjalankan operasi-operasi itu berurutan berarti waktu tunggu menumpuk.

Threading bisa menyelesaikan masalah yang sama, tapi mahal untuk skala besar. Setiap thread butuh memori stack sendiri dan context switch antar-thread punya biaya. Menangani sepuluh ribu koneksi sekaligus dengan sepuluh ribu thread bukan ide bagus. asyncio menyelesaikan ini dengan satu event loop yang ringan: puluhan ribu coroutine bisa hidup bersamaan dengan overhead memori yang kecil.

Kasus nyatanya banyak: API gateway yang mengagregasi beberapa layanan, web scraper yang mengambil ratusan halaman, server chat dengan ribuan koneksi persisten, atau microservice yang memanggil banyak API lain sebelum merespons. Kalau kamu juga merancang sisi servernya, pola ini nyambung dengan panduan desain REST API.

Sebaliknya, untuk workload CPU-bound seperti kompresi video atau training model, asyncio tidak membantu. Satu thread tetap satu core, dan GIL mencegah eksekusi Python paralel antar-thread. Untuk itu pakai multiprocessing.

Cara kerja asyncio: event loop, coroutine, dan task

Skema event loop asyncio memproses antrean coroutine secara bergantian
Event loop asyncio

Ada tiga komponen yang harus dipahami, dan ketiganya bekerja bersama. Event loop adalah jantungnya: sebuah loop yang terus memeriksa coroutine mana yang siap dijalankan, menjalankannya sampai ia menyerah di titik await, lalu pindah ke coroutine berikutnya. Ia tidak pernah menganggur selama ada pekerjaan yang bisa dikerjakan.

Coroutine adalah unit pekerjaannya. Kamu menulisnya seperti fungsi biasa, tapi dengan async def, dan memanggilnya dengan await. Titik await adalah janji ke event loop: "saya sedang menunggu, silakan kerjakan yang lain dulu".

Task adalah pembungkus coroutine agar dijadwalkan di event loop. asyncio.create_task() — tersedia sejak Python 3.7 — mengambil coroutine dan mengembalikannya sebagai Task yang langsung dijadwalkan berjalan. Titik masuk yang disarankan untuk program adalah asyncio.run(): ia membuat event loop baru, menjalankan coroutine utama sampai selesai, lalu menutup semuanya dengan rapi.

import asyncio

async def main():
    print("mulai")
    await asyncio.sleep(1)  # menyerah ke event loop selama 1 detik
    print("selesai")

asyncio.run(main())

Perhatikan asyncio.sleep di contoh itu. Ia versi non-blocking dari time.sleep: coroutine dijeda, tapi event loop bebas mengerjakan coroutine lain. Memakai time.sleep di dalam coroutine adalah kesalahan klasik — ia memblokir seluruh event loop.

Perbedaan sekuensial dan konkuren terlihat jelas kalau dibandingkan:

import asyncio
import time

async def kerja(nama, durasi):
    print(f"{nama} mulai")
    await asyncio.sleep(durasi)
    print(f"{nama} selesai")

async def sekuensial():
    mulai = time.perf_counter()
    await kerja("A", 2)
    await kerja("B", 2)
    print(f"sekuensial: {time.perf_counter() - mulai:.1f} detik")

async def konkuren():
    mulai = time.perf_counter()
    t1 = asyncio.create_task(kerja("A", 2))
    t2 = asyncio.create_task(kerja("B", 2))
    await t1
    await t2
    print(f"konkuren: {time.perf_counter() - mulai:.1f} detik")

asyncio.run(sekuensial())
asyncio.run(konkuren())

Versi sekuensial butuh sekitar 4 detik karena A dan B menunggu bergantian. Versi konkuren selesai sekitar 2 detik: kedua task menunggu dalam waktu yang sama.

Asyncio vs threading vs multiprocessing

Tiga model Python concurrency ini sering dipertukarkan, padahal masing-masing punya tempatnya. Tabel ini merangkum perbedaannya:

Kriteria asyncio Threading Multiprocessing
Model eksekusi Satu thread, event loop Banyak thread, dijadwalkan OS Banyak proses
Paling cocok untuk I/O-bound I/O-bound dengan library blocking lama CPU-bound
Overhead Sangat kecil per coroutine Satu thread butuh stack memori sendiri Satu proses = salinan memori penuh
GIL Tetap ada, tapi tidak masalah untuk I/O Menghambat pekerjaan CPU-bound Tiap proses punya GIL sendiri
Berbagi data Mudah, satu ruang memori Perlu lock, rawan race condition Perlu IPC atau serialisasi
Tingkat kesulitan Sedang — harus async dari ujung ke ujung Sedang — sinkronisasi manual Tinggi — arsitektur antar-proses
Contoh Server chat, scraper, API gateway Integrasi library lama yang blocking Render video, training model

Aturan praktisnya sederhana. Banyak menunggu jaringan atau disk dan memakai library yang mendukung async? Pakai asyncio. Terpaksa memakai library blocking yang tidak punya versi async (misalnya driver database lama)? Threading masih masuk akal, atau bungkus pemanggilannya dengan run_in_executor. Butuh komputasi berat paralel di banyak core? Multiprocessing jawabannya.

Tutorial: membangun HTTP client konkuren

Bagian ini membangun client yang mengambil banyak URL secara bersamaan. Ikuti langkahnya berurutan — tiap langkah bergantung pada sebelumnya.

Langkah 1 — Siapkan environment dan install httpx. Library standar tidak punya HTTP client async, jadi pakai httpx:

pip install httpx

Langkah 2 — Tulis coroutine fetch. Satu fungsi mengambil satu URL memakai httpx.AsyncClient:

import asyncio
import httpx

async def fetch(client, url):
    r = await client.get(url, timeout=10)
    return url, r.status_code

Langkah 3 — Batasi konkurensi dengan Semaphore. Tanpa batas, ratusan request bersamaan bisa membuat server menolakmu atau koneksimu sendiri kehabisan resource:

Ilustrasi banyak request HTTP berjalan bersamaan dengan asyncio
Request konkuren dengan Semaphore
async def fetch_terbatas(client, url, sem):
    async with sem:  # maksimal N request berjalan bersamaan
        r = await client.get(url, timeout=10)
        return url, r.status_code

Langkah 4 — Jalankan semuanya dengan asyncio.gather. Pola asyncio gather mengambil banyak coroutine dan menjalankannya bersamaan, lalu mengembalikan hasilnya berurutan:

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/2",
    "https://httpbin.org/delay/1",
]

async def main():
    sem = asyncio.Semaphore(5)
    async with httpx.AsyncClient() as client:
        hasil = await asyncio.gather(
            *(fetch_terbatas(client, u, sem) for u in URLS)
        )
    for url, status in hasil:
        print(url, "->", status)

asyncio.run(main())

Langkah 5 — Uji dan amati. Jalankan dengan python client.py. Ketiga URL di atas total delay-nya 4 detik kalau diambil berurutan, tapi versi konkuren selesai jauh lebih cepat karena waktu tunggu dipakai bersamaan. Pola agregasi seperti ini umum di backend yang menggabungkan beberapa layanan; kalau kamu juga merancang API yang dikonsumsi client semacam ini, lihat panduan desain REST API.

Alternatif httpx untuk scraping berat adalah aiohttp, yang punya fitur session dan connector tuning lebih lengkap.

Pola-pola penting: gather, TaskGroup, timeout, Semaphore, dan Queue

Lima pola ini menutupi sebagian besar kebutuhan asyncio sehari-hari. Referensi lengkapnya ada di coroutines dan tasks.

asyncio.gather

Menjalankan banyak coroutine bersamaan dan mengumpulkan hasilnya. Kalau satu coroutine melempar exception, gather ikut melempar — kecuali kamu set return_exceptions=True, yang membuat exception dikembalikan sebagai objek di daftar hasil.

async def contoh():
    hasil = await asyncio.gather(tugas_a(), tugas_b(), return_exceptions=True)

asyncio.TaskGroup (Python 3.11+)

Pendekatan modern yang lebih aman dari create_task manual. Semua task di dalam group ditunggu saat context manager keluar, dan exception dari beberapa task digabung menjadi satu ExceptionGroup (PEP 654). Syarat: Python 3.11+.

import asyncio

async def unduh(nama):
    await asyncio.sleep(1)
    return f"{nama} selesai"

async def main():  # Python 3.11+
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(unduh("file-a"))
        t2 = tg.create_task(unduh("file-b"))
    print(t1.result(), t2.result())

asyncio.run(main())

asyncio.timeout (Python 3.11+)

Membatasi berapa lama sebuah blok boleh berjalan. Kalau lewat batas, coroutine dibatalkan dan TimeoutError di-raise. Syarat: Python 3.11+. Untuk versi lama, padanannya adalah asyncio.wait_for.

async def main():  # Python 3.11+
    try:
        async with asyncio.timeout(2):
            await operasi_yang_lama()
    except TimeoutError:
        print("melebihi batas 2 detik, dibatalkan")

asyncio.Semaphore

Membatasi berapa banyak coroutine boleh masuk ke bagian kritis dalam satu waktu. Pola standar untuk membatasi request HTTP bersamaan atau koneksi database.

sem = asyncio.Semaphore(5)

async def akses_terbatas():
    async with sem:
        await panggil_api()

asyncio.Queue

Saluran komunikasi antar-coroutine dengan pola producer-consumer. Producer memasukkan item, consumer mengambilnya. await queue.join() menunggu sampai semua item selesai diproses.

import asyncio

async def produser(queue):
    for i in range(5):
        await queue.put(i)
    await queue.put(None)  # sinyal selesai

async def konsumen(queue):
    while True:
        item = await queue.get()
        if item is None:
            queue.task_done()
            break
        print(f"konsumsi: {item}")
        queue.task_done()

async def main():
    queue = asyncio.Queue()
    await asyncio.gather(produser(queue), konsumen(queue))
    await queue.join()

asyncio.run(main())

loop.run_in_executor

Jalan keluar saat terpaksa memanggil kode blocking — fungsi sync pihak ketiga, komputasi kecil, atau operasi file berat — dari dalam coroutine. Kode dijalankan di thread pool terpisah sehingga event loop tidak beku.

import asyncio
import time

def kerja_blocking():
    time.sleep(2)  # simulasi fungsi blocking
    return "hasil blocking"

async def main():
    loop = asyncio.get_running_loop()
    hasil = await loop.run_in_executor(None, kerja_blocking)
    print(hasil)

asyncio.run(main())

Studi kasus: scraper data konkuren

Bayangkan kamu perlu mengambil 10 halaman katalog produk untuk analisis harga. Versi sinkron memakai httpx.Client biasa dan menunggu tiap respons satu per satu. Versi async memakai httpx.AsyncClient dengan gather dan mengambil semuanya bersamaan.

Perbandingan diagram alur sinkron berurutan dan asinkron bersamaan
Alur sinkron vs asinkron
Visualisasi request konkuren scraper memakai asyncio gather
Request scraper berjalan bersamaan
import asyncio
import httpx
import time

TARGETS = ["https://httpbin.org/delay/1"] * 10

def scrape_sync():
    mulai = time.perf_counter()
    with httpx.Client() as client:
        for url in TARGETS:
            client.get(url, timeout=10)
    return time.perf_counter() - mulai

async def scrape_async():
    mulai = time.perf_counter()
    sem = asyncio.Semaphore(5)
    async def ambil(client, url):
        async with sem:
            await client.get(url, timeout=10)
    async with httpx.AsyncClient() as client:
        await asyncio.gather(*(ambil(client, u) for u in TARGETS))
    return time.perf_counter() - mulai

print(f"sync : {scrape_sync():.1f} detik")
print(f"async: {asyncio.run(scrape_async()):.1f} detik")

Skala waktunya beda jauh: versi sinkron menunggu 10 detik penuh, versi async menyelesaikan 10 request dalam waktu yang tumpang tindih. Di scraper nyata, tambahkan tiga hal: Semaphore seperti di atas supaya tidak kena rate limit, timeout di tiap request, dan retry dengan backoff untuk request yang gagal.

Kalau scraper ini mau jalan terjadwal di server, kemas jadi container supaya environment-nya konsisten — caranya dibahas di panduan Docker untuk developer.

Kesalahan umum dan cara menghindarinya

Memanggil fungsi blocking di dalam coroutine. time.sleep, requests.get, atau query database sync akan membekukan seluruh event loop — semua coroutine ikut berhenti. Solusinya: pakai padanan async-nya, atau bungkus dengan loop.run_in_executor.

Task fire-and-forget tanpa referensi. Event loop hanya menyimpan weak reference ke task. Kalau kamu memanggil asyncio.create_task() tanpa menyimpan hasilnya dan tidak meng-await, task bisa di-garbage collect di tengah jalan. Simpan referensinya dalam set, atau lebih baik pakai TaskGroup.

# SALAH: referensi hilang, task bisa mati diam-diam
asyncio.create_task(kirim_notifikasi())

# BENAR: simpan referensi
background = {asyncio.create_task(kirim_notifikasi())}

Membuat event loop manual tanpa perlu. asyncio.get_event_loop() dan loop.run_until_complete() adalah pola lama yang rawan salah — loop tidak tertutup, warning berserakan. Untuk program biasa, asyncio.run() sudah menangani pembuatan dan penutupan loop.

Konkurensi tanpa batas. gather atas 10.000 URL tanpa Semaphore sama dengan serangan DDoS ke server orang — dan ke koneksi internetmu sendiri. Selalu batasi dengan Semaphore dan hormati rate limit.

Exception hilang di gather. Secara default, satu coroutine gagal berarti gather melempar dan hasil lain ikut hilang. Putuskan strateginya di awal: return_exceptions=True kalau tiap tugas boleh gagal sendiri-sendiri, atau TaskGroup kalau kegagalan satu task memang harus membatalkan semuanya.

Tidak memasang timeout. Request yang gantung selamanya membuat task tidak pernah selesai dan menumpuk. Pasang asyncio.timeout di setiap operasi jaringan.

Best practice

Tulis kode async dari ujung ke ujung. Mencampur fungsi sync dan async secara acak membuat alur sulit diikuti dan rawan blocking tersembunyi. Kalau sebuah fungsi memanggil coroutine, ia sendiri harus jadi coroutine.

Jaga coroutine tetap kecil dan fokus pada satu tanggung jawab. Coroutine raksasa yang melakukan fetch, parsing, dan penyimpanan sekaligus sulit diuji dan sulit dipakai ulang. Prinsip ini berlaku lintas bahasa — versi TypeScript-nya dibahas di best practice TypeScript.

Selalu pasang timeout di operasi jaringan, selalu batasi konkurensi dengan Semaphore, dan pakai TaskGroup untuk structured concurrency supaya tidak ada task yatim yang berjalan tanpa pengawasan. Untuk kode blocking yang tidak bisa dihindari, isolasi lewat run_in_executor dan beri nama jelas supaya terlihat di code review.

Terakhir, uji perilakunya, bukan cuma hasil akhirnya. Tulis skenario untuk timeout, untuk satu task yang gagal di tengah gather, dan untuk shutdown saat task masih berjalan. Otomatiskan pengujian itu di pipeline — polanya dibahas di panduan pipeline CI/CD.

FAQ

Apakah asyncio membuat program selalu lebih cepat? Tidak. asyncio mempercepat program yang banyak menunggu I/O, karena waktu tunggu dipakai untuk kerja lain. Untuk komputasi CPU-bound tidak ada bedanya — bahkan sedikit overhead dari event loop. Ukur dulu di mana bottleneck-mu sebelum memilih.

Kapan memakai asyncio dibanding threading? Pakai asyncio kalau library yang kamu pakai punya versi async (httpx, aiohttp, asyncpg) dan kamu butuh ribuan operasi bersamaan dengan overhead kecil. Pakai threading kalau terpaksa memakai library blocking lama yang tidak punya padanan async — atau bungkus panggilannya dengan run_in_executor.

Kenapa coroutine saya tidak berjalan bersamaan? Penyebab paling umum: ada panggilan blocking di dalamnya (misalnya time.sleep alih-alih await asyncio.sleep), atau kamu lupa await sehingga coroutine tidak pernah dijadwalkan. Penyebab lain: task dibuat dengan create_task tapi referensinya langsung hilang.

Library apa yang umum dipakai dengan asyncio? Untuk HTTP: httpx dan aiohttp. Untuk database: asyncpg (PostgreSQL) dan aiomysql. Untuk web framework: FastAPI dan Starlette dibangun di atas asyncio. Untuk testing, cari runner yang mendukung coroutine — pytest punya plugin async yang populer.

Apakah asyncio cocok untuk pekerjaan CPU-bound? Tidak. GIL membuat bytecode Python hanya berjalan di satu thread dalam satu waktu, jadi komputasi berat tidak terbagi. Untuk CPU-bound pakai multiprocessing — tiap proses punya interpreter dan GIL sendiri — atau pindahkan komputasi ke library native seperti NumPy yang melepas GIL.

Kesimpulan

asyncio adalah cara Python menangani banyak pekerjaan I/O secara bersamaan dalam satu thread: event loop menjadwalkan coroutine, await menjadi titik serah terima, dan pola seperti gather, TaskGroup, Semaphore, serta timeout menjaga semuanya tetap terkendali. Ia bukan pengganti threading atau multiprocessing — ia alat khusus untuk workload yang banyak menunggu.

Langkah berikutnya yang paling masuk akal: ambil satu skrip di proyekmu yang memanggil API berurutan, tulis ulang dengan pola dari tutorial di atas, dan ukur bedanya. Setelah itu, bungkus dalam pengujian otomatis lewat panduan pipeline CI/CD supaya perilakunya — termasuk timeout dan kegagalan parsial — terjaga setiap kali kode berubah.

Keep reading

Related articles