Pemilihan model konkurensi

Kerangka keputusan memilih thread, proses, atau async berdasarkan skenario nyata.

Daftar isi

Python menyediakan tiga model untuk menjalankan pekerjaan secara bersamaan, yaitu thread, proses, dan asyncio. Ketiganya memiliki biaya dan kecocokan yang berbeda, sehingga pilihannya ditentukan oleh jenis beban kerja dan jumlah tugas, bukan oleh kebiasaan. Topik ini merangkum kerangka keputusannya, memperlihatkan penerapannya pada skenario nyata, dan menutup dengan kesalahan pemilihan yang sering terjadi.

CPU-bound dan I/O-bound#

Jenis beban kerja menjadi pertanyaan pertama sebelum memilih model. Beban CPU-bound adalah beban yang waktunya dihabiskan untuk perhitungan pada prosesor, sedangkan beban I/O-bound adalah beban yang sebagian besar waktunya dihabiskan untuk menunggu luaran eksternal. Keduanya membutuhkan penanganan yang berbeda karena sumber waktu tunggunya tidak sama.

Jenis bebanWaktu dihabiskan untukContoh tugas
CPU-boundperhitungan pada prosesorkompresi berkas, enkripsi, perhitungan numerik
I/O-boundmenunggu luaran eksternalpermintaan HTTP, pembacaan berkas, kueri database

Konkurensi hanya memberi manfaat apabila waktu tunggu satu tugas dapat dipakai mengerjakan tugas lain. Untuk beban I/O-bound, thread dan asyncio sama-sama memanfaatkan waktu tunggu itu dengan cara yang berbeda. Untuk beban CPU-bound, waktu tunggunya tidak ada. Satu-satunya cara mempercepat pekerjaan adalah paralelisme di beberapa inti, dan hanya proses yang mampu melakukannya karena GIL mengikat seluruh thread pada satu inti.

Kerangka keputusan#

Pertanyaan kedua menyangkut skala, yaitu jumlah tugas yang berjalan bersamaan. Diagram berikut merangkum jalur keputusan dari jenis beban menuju model yang sesuai.

Beban kerja dominan?CPU-boundI/O-boundtugas sedikitbanyak tugasProcessPoolExecutorThreadPoolExecutorasyncioparalel di banyak intimenumpangkan waktu tungguribuan koneksi
Kerangka keputusan memilih model konkurensi berdasarkan jenis beban dan jumlah tugas
ModelUnit yang dijalankan bersamaPaling cocok untukBiaya per unit
threadfungsi target dalam satu prosesI/O dengan puluhan tugaskecil
prosesfungsi target dalam proses terpisahkomputasi CPU beratbesar, melalui pickling
asynciokorutin pada satu threadribuan operasi I/Opaling kecil

Tiga jalur pada diagram mengikuti logika tersebut. Beban CPU-bound menuju pool proses karena hanya proses yang memberikan paralelisme nyata. Beban I/O-bound dengan puluhan tugas cukup dilayani pool thread, yang biaya per tugasnya kecil dan tidak menuntut penulisan ulang fungsi. Beban I/O-bound dengan ribuan tugas menuju asyncio, karena ribuan thread memboroskan memori padahal setiap tugasnya hanya menunggu.

Ketiga model juga dapat dikombinasikan pada satu program. Fungsi yang memblokir dapat dipindahkan ke thread dengan asyncio.to_thread, sedangkan perhitungan berat dapat dialihkan ke pool proses melalui loop.run_in_executor tanpa keluar dari korutin utama.

Skenario nyata#

Mengompres berkas log#

Kompresi banyak berkas adalah beban CPU-bound karena seluruh waktunya habis untuk perhitungan. Pool proses dengan empat pekerja menjalankan tugas-tugas itu secara paralel, dan hasilnya kembali berurutan melalui map.

python Contoh: kompres berkas log dengan pool proses
from concurrent.futures import ProcessPoolExecutor

def kompres(nomor_berkas):
    checksum = 0
    for i in range(1_500_000):
        checksum = (checksum + i * 31) % 1_000_003
    return nomor_berkas, checksum

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as executor:
        hasil = list(executor.map(kompres, range(1, 5)))
    for nomor, checksum in hasil:
        print(f"berkas-{nomor}.log", checksum)
text Keluaran
berkas-1.log 125384
berkas-2.log 125384
berkas-3.log 125384
berkas-4.log 125384

Setiap pekerja menghitung checksum berkasnya secara mandiri, sehingga tugasnya tidak saling bergantung dan tidak butuh lock. Fungsi kompres menerima masukan sederhana dan mengembalikan hasil ringkas, sehingga biaya pengiriman antarproses tetap kecil. Jumlah berkas dapat diperbanyak tanpa mengubah bentuk kode karena map yang mendistribusikannya.

Mengirim arsip ke jaringan#

Pengiriman arsip adalah beban I/O-bound karena setiap tugas menunggu respons jaringan, yang dalam contoh ini disimulasikan dengan jeda 50 milidetik. Pool thread dengan sepuluh pekerja menumpangkan sepuluh tungguan sekaligus, sehingga 20 pengiriman selesai dalam sekitar dua gelombang tunggu.

python Contoh: 20 arsip dengan pool thread
import time
from concurrent.futures import ThreadPoolExecutor

def kirim(nomor_arsip):
    time.sleep(0.05)
    return f"arsip-{nomor_arsip} terkirim"

with ThreadPoolExecutor(max_workers=10) as executor:
    hasil = list(executor.map(kirim, range(1, 21)))
print(len(hasil), "arsip terkirim")
print(hasil[0], hasil[-1])
text Keluaran
20 arsip terkirim
arsip-1 terkirim arsip-20 terkirim

Hasil tetap berurutan sesuai nomor arsip karena map menjaga urutan masukannya, padahal urutan penyelesaiannya acak. Untuk puluhan hingga ratusan tugas, pool thread seperti ini sudah memadai dan tidak menuntut penulisan ulang fungsi. Apabila tugasnya naik ke ribuan permintaan, asyncio dengan klien HTTP async seperti httpx menjadi pilihan yang lebih hemat sumber daya.

Server dengan ribuan koneksi#

Server adalah kasus ekstrem beban I/O-bound, karena setiap koneksi menunggu sebagian besar waktunya. Membuat satu thread per koneksi cepat habis memori ketika koneksinya ribuan, padahal setiap thread itu juga hanya menunggu. Model asyncio melayani ribuan koneksi pada satu thread dengan event loop yang membangunkan penangan koneksi hanya ketika datanya sudah tersedia. Framework web berbasis asyncio mengikuti pola ini, sehingga pemilihan modelnya sejak awal ditentukan di tingkat framework tersebut.

Kesalahan umum#

Kesalahan pertama adalah memakai thread untuk beban CPU-bound. GIL memastikan hanya satu thread yang menjalankan bytecode pada satu waktu, sehingga dua perhitungan berat pada dua thread memakan waktu yang sama dengan menjalankannya berurutan.

python Contoh: dua perhitungan berat pada dua thread
import time
from concurrent.futures import ThreadPoolExecutor

def hitung():
    total = 0
    for i in range(3_000_000):
        total += i
    return total

awal = time.perf_counter()
hitung()
hitung()
durasi_serial = time.perf_counter() - awal

with ThreadPoolExecutor(max_workers=2) as executor:
    awal = time.perf_counter()
    list(executor.map(lambda _: hitung(), range(2)))
    durasi_thread = time.perf_counter() - awal

print(durasi_thread >= durasi_serial * 0.9)
text Keluaran
True

Nilai True menunjukkan bahwa versi dua thread tidak lebih cepat daripada versi berurutan, karena keduanya membagi satu GIL. Thread bahkan sedikit lebih lambat karena biaya pergantian antar thread. Perbaikannya adalah ProcessPoolExecutor, yang memberikan paralelisme nyata untuk beban jenis ini.

Kesalahan kedua adalah memakai pool proses untuk tugas-tugas kecil. Biaya memulai proses dan mengirim nilai bolak-balik melalui pickling dapat melampaui pekerjaan tugasnya sendiri.

python Contoh: tugas kecil pada pool proses
import time
from concurrent.futures import ProcessPoolExecutor

def kecil(n):
    return n + 1

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=2) as executor:
        awal = time.perf_counter()
        dengan_pool = list(executor.map(kecil, range(8)))
        durasi_proses = time.perf_counter() - awal

    awal = time.perf_counter()
    tanpa_pool = [kecil(n) for n in range(8)]
    durasi_langsung = time.perf_counter() - awal

    print(dengan_pool)
    print(durasi_proses > durasi_langsung)
text Keluaran
[1, 2, 3, 4, 5, 6, 7, 8]
True

Delapan penambahan selesai lebih cepat pada loop biasa daripada pada pool proses, karena biaya per tugasnya jauh di atas pekerjaannya. Nilai True menegaskan hal itu pada setiap eksekusi. Pool proses hanya sepadan bila setiap tugasnya cukup berat untuk mengimbangi biaya pembuatan dan komunikasinya.

Kesalahan ketiga adalah menjalankan beban CPU-bound di dalam korutin. Perhitungan yang lama tidak pernah menemui await, sehingga event loop tidak pernah berjalan kembali dan seluruh task lain membeku. Penanganannya sama seperti penanganan fungsi yang memblokir, yaitu memindahkan perhitungan tersebut ke pool proses, atau ke thread melalui asyncio.to_thread bila paralelisme nyata tidak diperlukan.

Ringkasan#

  • Keputusan dimulai dari jenis beban, yaitu CPU-bound menuju proses dan I/O-bound menuju thread atau asyncio.
  • Jumlah tugas menentukan sisanya, dengan puluhan tugas cukup dilayani pool thread dan ribuan operasi I/O lebih hemat pada asyncio.
  • Tugas yang kecil tidak sepadan dengan biaya pool proses, sedangkan beban CPU tidak mempercepat sama sekali pada thread karena GIL.
  • Ketiga model dapat dikombinasikan, misalnya asyncio.to_thread untuk fungsi yang memblokir di dalam program async.

Referensi#