Kita telah melalui threading, locks, multiprocessing, pool, futures, antrian, dan GIL — dan setiap alat terasa masuk akal saat dibahas sendiri-sendiri. Kesulitan muncul di proyek nyata: program kita berisi campuran pemanggilan API, query database, dan perhitungan; alat mana yang harus dipakai? Chapter penutup ini merangkum seluruh section menjadi satu tabel keputusan, satu kebiasaan benchmark, dan satu pola gabungan.

Tabel Keputusan #

Titik berangkat terbaik selalu sama: program kita paling banyak menunggu atau paling banyak menghitung?

Kondisi programPilihan utamaAlasannya
I/O-bound, memakai library blocking (requests, driver DB, boto3)ThreadPoolExecutorGIL dilepas selama penantian; kode tidak perlu ditulis ulang
I/O-bound, ribuan koneksi bersamaanasynciocoroutine jauh lebih ringan daripada thread
CPU-bound murni Python (parsing, kompresi, simulasi)ProcessPoolExecutortiap process punya interpreter dan GIL sendiri
Komputasi numerik / arrayNumPy, pandasoperasi berat berjalan di C yang melepaskan GIL
Data harus dibagi dan diubah bersamaanthreading + lockprocess memaksa penyalinan data antar memori

Dua baris pertama perlu catatan: asyncio menuntut kode ditulis gaya async/await dari awal dan library yang dipakai harus mendukung async — itulah topik Section Asynchronous Programming. Bila library favorit kita blocking dan berbasis thread, ThreadPoolExecutor tetap pilihan paling jujur.

Ukur Sebelum Memutuskan #

Tabel di atas adalah hipotesis, bukan vonis. Mesin, versi Python, dan bentuk data membuat hasil nyata sering mengejutkan, jadi kebiasaan yang harus kita bawa pulang sederhana: ukur. Berikut kerangka benchmark kecil yang membandingkan pola berurutan, thread, dan process pada dua jenis beban:

python
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def beban_cpu(batas):
    total = 0
    for i in range(batas):
        total += i * i
    return total

def beban_io(detik):
    time.sleep(detik)
    return detik

if __name__ == "__main__":
    UKURAN = 12_000_000
    PEMBAGIAN = [UKURAN, UKURAN]

    mulai = time.perf_counter()
    beban_cpu(UKURAN)
    beban_cpu(UKURAN)
    print(f"CPU berurutan : {time.perf_counter() - mulai:.2f} detik")

    mulai = time.perf_counter()
    with ThreadPoolExecutor(max_workers=2) as executor:
        hasil = sum(executor.map(beban_cpu, PEMBAGIAN))
    print(f"CPU thread    : {time.perf_counter() - mulai:.2f} detik")

    mulai = time.perf_counter()
    with ProcessPoolExecutor(max_workers=2) as executor:
        hasil = sum(executor.map(beban_cpu, PEMBAGIAN))
    print(f"CPU process   : {time.perf_counter() - mulai:.2f} detik")

    mulai = time.perf_counter()
    beban_io(0.2)
    beban_io(0.2)
    print(f"I/O berurutan : {time.perf_counter() - mulai:.2f} detik")

    mulai = time.perf_counter()
    with ThreadPoolExecutor(max_workers=2) as executor:
        hasil = sum(executor.map(beban_io, [0.2, 0.2]))
    print(f"I/O thread    : {time.perf_counter() - mulai:.2f} detik")

Output yang Dihasilkan (waktu bergantung pada mesin):

text
CPU berurutan : 1.61 detik
CPU thread    : 1.65 detik
CPU process   : 1.09 detik
I/O berurutan : 0.40 detik
I/O thread    : 0.20 detik

Hasil pada mesin kami mengonfirmasi seluruh teori section ini: pada beban CPU, thread justru sedikit lebih lambat daripada berurutan (efek GIL plus biaya pergantian thread), sementara process memberi kemenangan nyata; pada beban I/O, thread memotong waktu tunggu menjadi hampir setengah. Jalankan benchmark ini di mesin Anda dengan bentuk beban yang mendekati program asli — angka dari mesin orang lain, termasuk angka di tutorial ini, tidak pernah menggantikan pengukuran sendiri.

Pola Gabungan dan Opsi Ketiga #

Program nyata jarang murni satu jenis beban, dan kedua dunia bisa digabung: sebuah ProcessPoolExecutor yang menjalankan beberapa process, di mana setiap process memakai ThreadPoolExecutor kecil untuk bagian-bagiannya yang menunggu jaringan. Aturan penggabungannya: process di luar (karena jumlahnya dibatasi core), thread di dalam. Pola berlapis seperti ini juga yang dipakai ProcessPoolExecutor sendiri untuk mengelola pekerjanya.

Terakhir, jangan lupakan opsi ketiga yang menempati wilayah berbeda: asyncio. Untuk server yang melayani ribuan koneksi, atau program pengambil data yang murni menunggu, coroutine dengan event loop tunggal lebih ringan daripada sekawanan thread — materinya dibahas tuntas pada Section Asynchronous Programming setelah section ini.

Checklist Sebelum Memilih #

  1. Ukur dulu: berapa persen waktu program yang dihabiskan menunggu I/O versus menghitung? (time.perf_counter() sudah cukup untuk mengukur.)
  2. Tentukan titik panas: concurrency hanya berguna pada bagian yang benar-benar memakan waktu, bukan pada seluruh program.
  3. I/O-bound? Mulai dari ThreadPoolExecutor bila library-nya blocking; pindah ke asyncio bila jumlah koneksi menjadi ratusan ke atas.
  4. CPU-bound? Gunakan ProcessPoolExecutor, pastikan function dan argument picklable serta pelindung if __name__ == "__main__": terpasang.
  5. Ada data bersama antar thread? Lindungi dengan Lock, atau lebih baik lagi: kirim lewat queue.Queue.
  6. Ukur lagi setelah memakai concurrency, dan bandingkan dengan versi berurutan yang sama.
  7. Simpan hasil benchmark di repo — angka historis itu referensi ketika mesin atau versi Python berganti.

Dengan checklist ini ditutuplah perjalanan kita memahami concurrency dan parallelism di Python: dari konsep tumpang tindih, kunci dan antrian, hingga keputusan arsitektur yang berpijak pada pengukuran.