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 beban | Waktu dihabiskan untuk | Contoh tugas |
|---|---|---|
| CPU-bound | perhitungan pada prosesor | kompresi berkas, enkripsi, perhitungan numerik |
| I/O-bound | menunggu luaran eksternal | permintaan 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.
| Model | Unit yang dijalankan bersama | Paling cocok untuk | Biaya per unit |
|---|---|---|---|
| thread | fungsi target dalam satu proses | I/O dengan puluhan tugas | kecil |
| proses | fungsi target dalam proses terpisah | komputasi CPU berat | besar, melalui pickling |
| asyncio | korutin pada satu thread | ribuan operasi I/O | paling 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.
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)berkas-1.log 125384
berkas-2.log 125384
berkas-3.log 125384
berkas-4.log 125384Setiap 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.
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])20 arsip terkirim
arsip-1 terkirim arsip-20 terkirimHasil 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.
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)TrueNilai 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.
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)[1, 2, 3, 4, 5, 6, 7, 8]
TrueDelapan 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_threaduntuk fungsi yang memblokir di dalam program async.
Referensi#
- concurrent.futures — Launching parallel tasks — Python Software Foundation — Dokumentasi kedua executor yang menjadi tulang punggung kerangka keputusan ini.
- asyncio — Asynchronous I/O — Python Software Foundation — Dokumentasi model asyncio untuk beban I/O berskala besar.
- threading — Thread-based parallelism — Python Software Foundation — Dokumentasi model thread untuk beban I/O dengan jumlah tugas sedang.
- Can’t we get rid of the Global Interpreter Lock? — Python Software Foundation — Penjelasan resmi alasan GIL membatasi thread pada beban CPU.