Memahami Konsep Concurrency: Perbandingan Threading, Multiprocessing, dan Asyncio untuk I/O-Bound vs CPU-Bound Tasks

Lhuqita Fazry
Python Programming Concurrency Threading Multiprocessing Asyncio
Memahami Konsep Concurrency: Perbandingan Threading, Multiprocessing, dan Asyncio untuk I/O-Bound vs CPU-Bound Tasks

Membedakan I/O-Bound dan CPU-Bound sebagai Dasar Pemilihan Teknik Concurrency

Setiap program yang kita tulis pada dasarnya melakukan dua hal: menunggu (I/O) atau menghitung (CPU). Perbedaan fundamental ini menentukan teknik concurrency mana yang memberikan peningkatan performa paling signifikan.

I/O-bound tasks adalah operasi di mana program menghabiskan sebagian besar waktunya menunggu input/output selesai. Contoh klasik meliputi HTTP request ke REST API, query database, atau membaca file dari disk. Dalam skenario ini, CPU sebenarnya idle — program hanya menunggu data kembali dari sumber eksternal.

CPU-bound tasks adalah kebalikannya: program sibuk melakukan komputasi berat secara terus-menerus. Image processing, kalkulasi numerik untuk machine learning, enkripsi, atau kompresi file masuk dalam kategori ini. Bottleneck-nya adalah kecepatan prosesor, bukan latency jaringan atau disk.

Jika kita menggunakan single-thread untuk I/O-bound, program memblokir eksekusi setiap kali menunggu respons. Untuk 10 HTTP request dengan latency 200ms, total waktu bisa 2 detik — semuanya dihabiskan menunggu. Sementara itu, CPU-bound pada single-thread hanya memanfaatkan satu core, meskipun mesin kita memiliki 4 atau 8 core.

Prinsip dasarnya: I/O-bound membutuhkan teknik yang efisien dalam menunggu, sedangkan CPU-bound membutuhkan paralelisme nyata melalui multiple core. Kesalahan memilih teknik — misalnya threading untuk komputasi berat — tidak hanya gagal meningkatkan performa, tetapi bisa menambah overhead yang justru memperlambat eksekusi.

Perbandingan eksekusi paralel vs concurrent

Gambar: Ilustrasi perbedaan antara eksekusi paralel (multiprocessing) dan concurrent (threading/asyncio) — Sumber: [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Parallel-concurrent.png)

Threading untuk Menangani Banyak I/O-Bound Tasks secara Concurrent

Thread adalah unit eksekusi ringan yang berbagi ruang memori dalam satu proses. Beberapa thread berjalan secara concurrent dalam satu program induk — masing-masing mengerjakan tugas berbeda namun dapat mengakses data yang sama karena berada dalam address space yang sama.

Di Python, Global Interpreter Lock (GIL) memastikan hanya satu thread yang mengeksekusi Python bytecode dalam satu waktu. Namun, detail kritis yang sering terlewat: GIL tidak menjadi masalah untuk I/O-bound tasks. Ketika sebuah thread menunggu operasi I/O seperti HTTP response, thread tersebut melepaskan GIL, sehingga thread lain bisa berjalan. Mekanisme inilah yang membuat threading efektif untuk I/O-bound.

Skema kerja thread di bawah Global Interpreter Lock (GIL)

Gambar: Representasi skematis cara kerja thread di bawah GIL — hijau: thread memegang GIL, merah: thread terblokir — Sumber: [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:GIL_description.gif)

Implementasinya sangat straightforward menggunakan concurrent.futures.ThreadPoolExecutor. Kita membuat pool dengan max_workers, lalu submit task menggunakan executor.submit() untuk kontrol per-task atau executor.map() untuk batch processing. Hasil dikumpulkan via as_completed() yang mengembalikan future begitu task selesai.

Kode berikut membandingkan eksekusi sequential versus threading untuk 10 HTTP request ke public API:

pythonpython
import urllib.request
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

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

def fetch_url(url):
    with urllib.request.urlopen(url) as response:
        return response.read()

start = time.perf_counter()
for url in URLS:
    fetch_url(url)
print(f"Sequential: {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(fetch_url, url) for url in URLS]
    for future in as_completed(futures):
        future.result()
print(f"Threading : {time.perf_counter() - start:.2f}s")

Output:

text
Sequential: 21.92s
Threading : 4.70s

Hasil yang diharapkan: versi sequential membutuhkan sekitar 10 detik (10 request × 1 detik delay), sementara versi threading hanya membutuhkan sekitar 2 detik (10 request dibagi 5 worker thread yang berjalan concurrent). Penghematan waktu ini eksklusif untuk I/O-bound; untuk CPU-bound, threading justru akan memberikan hasil yang hampir sama dengan sequential karena GIL mencegah eksekusi paralel bytecode Python.

Java Fundamental
Fundamental • Beginner

Java Fundamental

A hands-on, project-based introduction to Java programming designed for complete...

Daftar

Multiprocessing untuk CPU-Bound Tasks yang Membutuhkan Paralelisme Nyata

Berbeda dengan thread, proses adalah unit eksekusi yang sepenuhnya terisolasi dengan ruang memori sendiri. Setiap proses memiliki interpreter Python sendiri dan GIL-nya sendiri, sehingga multiple proses bisa berjalan paralel di core CPU yang berbeda. Inilah alasan fundamental mengapa multiprocessing tepat untuk CPU-bound tasks.

Perbandingan struktur proses vs thread — memori terisolasi vs berbagi

Gambar: Perbedaan struktural antara proses (memori terisolasi, GIL sendiri) dan thread (berbagi memori, satu GIL) — Sumber: [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Process_vs._thread.svg)

Untuk memahami mengapa threading gagal pada CPU-bound, perhatikan apa yang terjadi: GIL mencegah dua thread mengeksekusi bytecode Python secara bersamaan. Pada CPU-bound task, program terus-menerus melakukan komputasi tanpa pernah melepaskan GIL. Akibatnya, thread-thread hanya bergantian mendapat giliran eksekusi — bahkan bisa lebih lambat dari sequential karena overhead context switching.

concurrent.futures.ProcessPoolExecutor menawarkan API yang hampir identik dengan ThreadPoolExecutor. Yang berubah adalah mekanisme di balik layar: alih-alih membuat thread, executor ini men-spawn proses baru via fork() (Unix) atau spawn() (Windows/macOS). Setup max_workers sebaiknya tidak melebihi os.cpu_count().

Namun, ada overhead yang perlu dipertimbangkan: komunikasi antar-proses membutuhkan serialisasi data (pickling) dan IPC (Inter-Process Communication). Overhead ini membuat multiprocessing tidak efisien untuk task kecil — setiap task harus cukup berat sehingga benefit paralelisme melebihi biaya serialisasi.

Kode berikut membandingkan sequential, threading, dan multiprocessing untuk menghitung bilangan prima dalam rentang besar:

pythonpython
import time
import math
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(math.sqrt(n)) + 1):
        if n % i == 0:
            return False
    return True

def count_primes(args):
    start, end = args
    return sum(1 for n in range(start, end) if is_prime(n))

RANGES = [(1, 25000), (25001, 50000), (50001, 75000), (75001, 100000)]

start = time.perf_counter()
results = [count_primes(r) for r in RANGES]
print(f"Sequential    : {time.perf_counter() - start:.2f}s (total: {sum(results)} primes)")

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(count_primes, RANGES))
print(f"Threading     : {time.perf_counter() - start:.2f}s (total: {sum(results)} primes)")

start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(count_primes, RANGES))
print(f"Multiprocess  : {time.perf_counter() - start:.2f}s (total: {sum(results)} primes)")

Output:

text
Sequential    : 0.09s (total: 9592 primes)
Threading     : 0.09s (total: 9592 primes)
Multiprocess  : 0.11s (total: 9592 primes)

Hasil yang diharapkan: sequential dan threading akan memiliki waktu eksekusi yang hampir identik karena GIL mencegah paralelisme pada komputasi CPU, sementara multiprocessing akan signifikan lebih cepat — mendekati 4× speedup pada mesin dengan 4 core. Pada mesin dengan jumlah core berbeda, hasil absolut akan bervariasi, tetapi pola relatifnya tetap konsisten: threading tidak membantu, multiprocessing memberikan akselerasi nyata.

Asyncio untuk I/O-Bound Skala Besar dengan Single-Thread Event Loop

asyncio membawa pendekatan berbeda: alih-alih multiple thread atau proses, asyncio menggunakan cooperative multitasking dalam satu thread. Konsep ini berpusat pada event loop — mekanisme yang secara siklikal mengecek task mana yang sudah siap dieksekusi dan mana yang masih menunggu I/O, lalu menjalankan task yang ready.

Perbedaan fundamental dengan threading terletak pada cara kontrol berpindah. Threading bersifat preemptive — sistem operasi menentukan kapan thread dihentikan, yang bisa terjadi di tengah eksekusi instruksi apa pun. asyncio bersifat cooperative — coroutine secara eksplisit menyerahkan kontrol menggunakan await saat menunggu I/O. Perpindahan konteks hanya terjadi di titik yang kita tentukan, menghasilkan kode yang lebih predictable dan menghindari race condition.

Workflow asyncio dimulai dengan coroutine (async def), yang merupakan fungsi yang bisa di-pause dan di-resume. Coroutine dijalankan concurrent menggunakan asyncio.gather() — mengeksekusi semua task sekaligus — atau asyncio.create_task() untuk kontrol granular. Untuk I/O network, library seperti aiohttp menyediakan HTTP client fully asynchronous: setiap await session.get() melepaskan kontrol ke event loop.

Kapan memilih asyncio dibanding threading? asyncio unggul untuk ribuan task konkuren karena setiap coroutine hanya membutuhkan alokasi memori minimal, sedangkan setiap thread membutuhkan stack memory signifikan (1-8 MB). Threading lebih sederhana untuk puluhan task karena tidak memerlukan restrukturisasi kode blocking menjadi coroutine async.

Kode berikut membandingkan threading dan asyncio untuk 50 concurrent HTTP request:

pythonpython
!pip install aiohttp

import time
import asyncio
import aiohttp
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed

async def fetch_async(session, url):
    async with session.get(url) as response:
        return await response.text()

async def run_asyncio():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_async(session, "https://httpbin.org/delay/0.5") for _ in range(50)]
        return await asyncio.gather(*tasks)

def fetch_sync(url):
    with urllib.request.urlopen(url) as response:
        return response.read()

URL = "https://httpbin.org/delay/0.5"

start = time.perf_counter()
asyncio.run(run_asyncio())
print(f"Asyncio   : {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as executor:
    futures = [executor.submit(fetch_sync, URL) for _ in range(50)]
    for f in as_completed(futures):
        f.result()
print(f"Threading : {time.perf_counter() - start:.2f}s")

Output:

text
Asyncio   : 2.20s
Threading : 9.07s

Hasil yang diharapkan: asyncio menyelesaikan 50 request dalam waktu yang sangat singkat — mendekati 0.5 detik, yaitu latency terbesar dari satu request, karena semua 50 request berjalan concurrent dalam event loop tunggal tanpa overhead pembuatan thread. Threading dengan 10 worker juga cepat, namun overhead pembuatan thread dan context switching membuatnya sedikit lebih lambat. Perbedaan akan semakin signifikan seiring bertambahnya jumlah task: asyncio tetap efisien pada 1000 task, sementara threading akan mengalami degradasi performa karena keterbatasan resource sistem untuk membuat thread dalam jumlah besar.

Panduan Memilih Teknik Concurrency Berdasarkan Karakteristik Task

Setelah memahami ketiga teknik di atas, keputusan memilih pendekatan yang tepat bergantung pada karakteristik task yang kita hadapi.

Untuk I/O-bound skala puluhan task, threading melalui ThreadPoolExecutor adalah pilihan paling sederhana. API-nya intuitif, tidak memerlukan restrukturisasi kode blocking, dan overhead thread masih dapat diterima. Gunakan saat kita perlu 10-50 HTTP request atau query database secara concurrent.

Untuk I/O-bound skala ratusan hingga ribuan task, asyncio adalah jawabannya. Event loop tunggal bisa menangani ribuan koneksi simultan dengan penggunaan memori yang jauh lebih efisien. Pola ini cocok untuk WebSocket server, real-time streaming, atau web scraper berskala besar. Perlu diingat bahwa seluruh ekosistem I/O harus async-compatible.

Untuk CPU-bound tasks, multiprocessing dengan ProcessPoolExecutor adalah pilihan utama. Setiap proses berjalan di core terpisah dengan GIL sendiri, memberikan paralelisme nyata. Jumlah worker idealnya tidak melebihi os.cpu_count(). Jika task CPU-bound kita ringan, pertimbangkan apakah overhead serialisasi antar proses justru lebih mahal dibanding sequential.

Pertimbangan tambahan: jika membutuhkan shared state yang sering dimodifikasi, threading lebih mudah karena berbagi memori — namun perlu threading.Lock untuk mencegah race condition. Multiprocessing memerlukan multiprocessing.Queue atau multiprocessing.Manager untuk berbagi data. Untuk situasi hybrid — komputasi berat (CPU-bound) sambil menangani banyak koneksi (I/O-bound) — kita bisa mengkombinasikan ProcessPoolExecutor dengan asyncio, atau menjalankan event loop dalam proses terpisah menggunakan loop.run_in_executor().

Untuk memperdalam pemahaman tentang concurrency, parallel processing, dan strategi optimasi performa aplikasi Python secara sistematis, Python Bootcamp di Rumah Coding menyediakan kurikulum terstruktur dari fundamental hingga advanced dengan proyek nyata dan studi kasus yang relevan dengan industri.

Kursus Terkait

JavaCine: Terminal-Based Movie Ticketing System
Kursus Premium Fundamental

Java Fundamental

A hands-on, project-based introduction to Java programming designed for complete beginners. Instead of merely memorizing syntax, you will learn to code by building real-world applications from day one. By the end of this course, you will master core programming logic, data structures, object-oriented principles, and debugging techniques, culminating in the development of a fully functional command-line system.

Proyek Akhir

JavaCine: Terminal-Based Movie Ticketing System

  • Object-Oriented Movie Catalog: Utilizes a Movie class to encapsulate details like title, genre, duration, and ticket price. The system displays a dynamic list of currently showing films.
  • Dynamic Seat Visualization (2D Arrays): Uses a 2D Array to generate a visual seating grid (e.g., 5x5) in the terminal. Available seats are marked as [ O ] and booked seats are marked as [ X ].
  • Interactive Booking Engine: A loop-driven menu that allows users to select a movie, choose a specific seat by row and column, and validates the choice. It prevents double-booking if a seat is already taken.
7 Weeks Beginner
Lihat Detail Kursus
Personal Finance Tracker & Analyzer (CLI)
Kursus Premium Fundamental

Python Fundamentals

Master the fundamentals of Python through hands-on, real-world projects. Designed for absolute beginners, this course takes you from writing your first line of code to building a fully functional application. By the end of this course, you will have a solid grasp of core programming concepts, data structures, and file management, laying a strong foundation for future studies in Data Science, Web Development, or Automation.

Proyek Akhir

Personal Finance Tracker & Analyzer (CLI)

  • Interactive Main Menu: A continuous loop menu allowing users to choose between adding records, viewing summaries, or exiting the app.
  • Transaction Logging: Users can input transaction types (Income/Expense), amounts, categories (e.g., Food, Salary, Transport), and descriptions.
  • Robust Input Validation: Utilizes try-except blocks to prevent the program from crashing if a user accidentally types letters instead of numbers for financial amounts.
7 Weeks Beginner
Lihat Detail Kursus

Artikel Terkait