Kita sudah melihat pola yang aneh sepanjang section ini: dua thread yang menunggu jaringan menyelesaikan pekerjaan dua kali lebih cepat, tetapi dua thread yang menghitung tidak lebih cepat sama sekali. Arsitek di balik semua itu adalah Global Interpreter Lock (GIL) — mekanisme CPython yang paling sering disalahpahami. Pada Chapter ini kita akan memahami apa sebenarnya GIL, mengapa interpreter Python membutuhkannya, dan bagaimana dampaknya terhadap pilihan concurrency kita.
Apa Itu GIL #
Global Interpreter Lock adalah kunci global di level interpreter: pada satu process CPython, hanya satu thread yang boleh mengeksekusi bytecode Python pada satu waktu. Thread lain boleh ada, boleh hidup, tetapi harus menunggu giliran memegang GIL.
Mengapa dibuat begini? Jawaban utamanya adalah kesederhanaan manajemen memori. CPython mengelola object lewat reference counting: setiap rujukan menaikkan pencacah, setiap rujukan yang hilang menurunkannya, dan pencacah nol berarti memori boleh dibebaskan. Bila banyak thread bisa mengutak-atik pencacah yang sama secara bersamaan, setiap operasi harus dikunci satu per satu — mahal dan rawan salah. Dengan satu kunci global, seluruh mekanisme object dan penghitungan rujukan CPython menjadi sederhana dan cepat untuk program satu thread, yang memang mayoritas program Python.
Thread Tidak Mempercepat Kerja CPU #
Konsekuensi langsungnya: untuk kerja CPU-bound murni Python, thread tidak menambah kecepatan — justru bisa sedikit melambat karena biaya saling bergantian. Mari kita ukur sendiri dengan beban aritmetika besar:
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def jumlah_kuadrat(batas):
total = 0
for i in range(batas):
total += i * i
return total
BATAS = 12_000_000
PEMBAGIAN = [BATAS, BATAS]
if __name__ == "__main__":
mulai = time.perf_counter()
hasil = jumlah_kuadrat(PEMBAGIAN[0]) + jumlah_kuadrat(PEMBAGIAN[1])
print(f"Berurutan : {hasil} dalam {time.perf_counter() - mulai:.2f} detik")
mulai = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as executor:
hasil = sum(executor.map(jumlah_kuadrat, PEMBAGIAN))
print(f"Thread : {hasil} dalam {time.perf_counter() - mulai:.2f} detik")
mulai = time.perf_counter()
with ProcessPoolExecutor(max_workers=2) as executor:
hasil = sum(executor.map(jumlah_kuadrat, PEMBAGIAN))
print(f"Process : {hasil} dalam {time.perf_counter() - mulai:.2f} detik")Output yang Dihasilkan (waktu bergantung pada mesin):
Berurutan : 1151999856000004000000 dalam 1.77 detik
Thread : 1151999856000004000000 dalam 1.64 detik
Process : 1151999856000004000000 dalam 1.03 detikAngka bicara jelas: versi dua thread nyaris sama cepatnya dengan versi berurutan karena kedua thread bergantian memegang satu GIL — keduanya berbagi satu jalur eksekusi. Versi dua process benar-benar lebih cepat karena setiap process membawa interpreter dan GIL-nya sendiri, sehingga dua core benar-benar menghitung bersamaan. Hasil hitungannya identik di ketiga versi; yang berbeda hanyalah waktu.
I/O Melepaskan GIL #
Kalau begitu, mengapa thread toh menolong pekerjaan I/O? Karena kode yang menunggu melepaskan GIL secara sukarela. time.sleep(), operasi file, koneksi jaringan, dan query database semuanya dijalankan kode C yang menyerahkan GIL selama penantian, lalu mengambilnya kembali setelah selesai:
import threading
import time
def simpan_ke_disk(nama):
time.sleep(0.3) # menunggu disk - GIL dilepas selama penantian
return f"{nama} tersimpan"
mulai = time.perf_counter()
t1 = threading.Thread(target=simpan_ke_disk, args=("laporan.pdf",))
t2 = threading.Thread(target=simpan_ke_disk, args=("foto.jpg",))
t3 = threading.Thread(target=simpan_ke_disk, args=("data.csv",))
t1.start()
t2.start()
t3.start()
t1.join()
t2.join()
t3.join()
total = time.perf_counter() - mulai
print(f"3 tugas I/O selesai dalam {total:.2f} detik")Output yang Dihasilkan:
3 tugas I/O selesai dalam 0.30 detikTiga tugas masing-masing menunggu 0,3 detik, tetapi totalnya hanya 0,30 detik — ketiganya menunggu bersamaan. Saat thread pertama tertidur, thread kedua dan ketiga bebas memegang GIL dan menjalankan kodenya. Inilah alasan pola threading dari Chapter Threading berhasil untuk program yang didominasi penantian, dan ekstensi C seperti NumPy pun memakai mekanisme serupa: bagian hitung beratnya melepaskan GIL sehingga bisa berjalan paralel di dalam satu process.
Masa Depan GIL #
Upaya menghapus GIL telah lama berjalan, dan kini ada hasil resmi: melalui PEP 703, Python 3.13 memperkenalkan build eksperimental “free-threaded” yang dapat berjalan tanpa GIL. Build ini bersifat eksperimental, bukan build bawaan, dan ekosistem paketnya masih ditata — versi yang kita pakai di course ini (Python 3.12) tetap memiliki GIL. Artinya, untuk beberapa tahun ke depan, keputusan praktis tetap dipegang oleh aturan yang sudah kita pelajari:
| Situasi | Strategi yang tetap berlaku |
|---|---|
| Kerja CPU-bound murni Python | multiprocessing / ProcessPoolExecutor — satu process per core |
| Kerja I/O-bound dengan library blocking | threading / ThreadPoolExecutor — GIL dilepas saat menunggu |
| Ribuan koneksi I/O bersamaan | asyncio — tanpa thread sama sekali |
| Komputasi numerik | NumPy/pandas yang melepaskan GIL di dalam operasi C-nya |