Ketika banyak thread menulis ke satu variabel pada saat bersamaan, sebagian penulisan bisa saling menimpa dan menghilang tanpa jejak. Bug seperti ini tidak selalu muncul — program bisa benar pada sepuluh percobaan lalu salah pada percobaan kesebelas — sehingga disebut race condition. Pada Chapter ini kita akan mereproduksi race condition secara nyata, lalu memperbaikinya dengan threading.Lock, mengenal RLock, dan belajar menghindari deadlock.
Race Condition: Penulisan yang Hilang #
Perhatikan dua thread yang masing-masing menaikkan counter sebanyak 100.000 kali. Nilai harapan akhirnya jelas: 200.000. Kenyataannya:
import sys
import threading
sys.setswitchinterval(1e-5)
counter = 0
def hitung_nilai_baru(nilai):
return nilai + 1
def tambah_sebanyak(jumlah):
global counter
for _ in range(jumlah):
counter = hitung_nilai_baru(counter)
JUMLAH_PER_THREAD = 100_000
t1 = threading.Thread(target=tambah_sebanyak, args=(JUMLAH_PER_THREAD,))
t2 = threading.Thread(target=tambah_sebanyak, args=(JUMLAH_PER_THREAD,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Nilai harapan : {2 * JUMLAH_PER_THREAD}")
print(f"Nilai aktual : {counter}")Output yang Dihasilkan (nilai aktual berbeda pada setiap eksekusi):
Nilai harapan : 200000
Nilai aktual : 151463Hampir 50.000 penambahan menghilang. sys.setswitchinterval(1e-5) memaksa interpreter Python mengganti thread aktif sangat sering agar race mudah terlihat, tetapi kerusakannya bukan buatan: pola counter = hitung_nilai_baru(counter) melakukan tiga langkah — baca counter, hitung nilai baru, tulis kembali — dan thread lain bisa menyelip di antara langkah-langkah tersebut.
Mengapa baca-tulis sepele bisa terkoyak? Karena operasi itu bukan satu instruksi. Bukti paling gamblang datang dari module dis yang menampilkan bytecode interpreter:
import dis
counter = 0
def tambah_counter():
global counter
counter += 1
dis.dis(tambah_counter)Output yang Dihasilkan:
5 0 RESUME 0
7 2 LOAD_GLOBAL 0 (counter)
12 LOAD_CONST 1 (1)
14 BINARY_OP 13 (+=)
18 STORE_GLOBAL 0 (counter)
20 RETURN_CONST 0 (None)Statement counter += 1 terurai menjadi LOAD_GLOBAL, BINARY_OP, lalu STORE_GLOBAL. Sistem operasi boleh mengganti thread aktif di antara instruksi-instruksi tersebut. Ketika thread A sudah LOAD_GLOBAL nilai 100, thread B menulis 101, lalu thread A menulis 101 lagi — satu penambahan lenyap. Perlu dicatat, isi dis dapat sedikit berbeda antar versi Python; output di atas dihasilkan Python 3.12.
Lock: Mengunci Bagian Kritis #
threading.Lock adalah kunci sederhana: hanya satu thread yang boleh memegangnya pada satu waktu. Thread lain yang ingin memegang kunci tersebut harus menunggu. Pola penulisan yang dianjurkan memakai statement with:
import sys
import threading
sys.setswitchinterval(1e-5)
counter = 0
kunci = threading.Lock()
def hitung_nilai_baru(nilai):
return nilai + 1
def tambah_sebanyak(jumlah):
global counter
for _ in range(jumlah):
with kunci:
counter = hitung_nilai_baru(counter)
JUMLAH_PER_THREAD = 100_000
t1 = threading.Thread(target=tambah_sebanyak, args=(JUMLAH_PER_THREAD,))
t2 = threading.Thread(target=tambah_sebanyak, args=(JUMLAH_PER_THREAD,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Nilai harapan : {2 * JUMLAH_PER_THREAD}")
print(f"Nilai aktual : {counter}")Output yang Dihasilkan:
Nilai harapan : 200000
Nilai aktual : 200000Hasil kini tepat, sebesar apa pun tekanan pergantian thread. Cara kerjanya: with kunci: memanggil kunci.acquire() saat masuk dan kunci.release() saat keluar — meskipun terjadi exception di tengah blok. Bagian kode yang dilindungi kunci disebut bagian kritis (critical section).
RLock: Kunci yang Boleh Dipakai Ulang #
Lock menolak dipanggil dua kali oleh thread yang sama — pemanggilan kedua menggantung menunggu diri sendiri. threading.RLock (reentrant lock) mengizinkan pemegang kunci memasukkannya kembali selama jumlah keluar seimbang dengan jumlah masuk:
import threading
kunci = threading.RLock()
def simpan_log():
with kunci:
print("Log disimpan (kunci yang sama dipakai ulang)")
def proses_data():
with kunci:
print("Memproses data...")
simpan_log()
t = threading.Thread(target=proses_data)
t.start()
t.join()
print("Selesai tanpa deadlock")Output yang Dihasilkan:
Memproses data...
Log disimpan (kunci yang sama dipakai ulang)
Selesai tanpa deadlockFunction proses_data() mengunci kunci, lalu memanggil simpan_log() yang mengunci kunci yang sama lagi. Dengan Lock polos, pola bersarang seperti ini mengunci program selamanya; dengan RLock, penguncian kedua diterima karena dilakukan thread yang sama.
Deadlock: Saling Menunggu Kunci #
Race condition punya saudara kembar yang lebih diam: deadlock, yaitu kondisi ketika dua thread salah giliran menunggu dua kunci hingga keduanya berhenti selamanya. Pola klasiknya:
import threading
kunci_transfer = threading.Lock()
kunci_saldo = threading.Lock()
def proses_transfer():
with kunci_transfer:
with kunci_saldo:
print("Transfer berjalan")
def catat_saldo():
with kunci_saldo:
with kunci_transfer:
print("Saldo dicatat")
# JANGAN dijalankan: program berhenti tanpa selesai.proses_transfer() mengunci transfer lalu saldo, sedangkan catat_saldo() mengunci saldo lalu transfer. Bila thread pertama memegang kunci_transfer dan thread kedua memegang kunci_saldo, keduanya menunggu kunci yang dipegang lawannya — tidak ada yang bisa maju.
Solusinya sederhana dan wajib jadi kebiasaan: tetapkan urutan penguncian global yang sama di semua thread. Berikut versi yang diperbaiki dan benar-benar dijalankan:
import threading
kunci_transfer = threading.Lock()
kunci_saldo = threading.Lock()
def proses_transfer():
with kunci_transfer, kunci_saldo:
print("Transfer berjalan (urutan kunci: transfer -> saldo)")
def catat_saldo():
with kunci_transfer, kunci_saldo:
print("Saldo dicatat (urutan kunci: transfer -> saldo)")
t1 = threading.Thread(target=proses_transfer)
t2 = threading.Thread(target=catat_saldo)
t1.start()
t2.start()
t1.join()
t2.join()
print("Kedua tugas selesai tanpa deadlock")Output yang Dihasilkan:
Transfer berjalan (urutan kunci: transfer -> saldo)
Saldo dicatat (urutan kunci: transfer -> saldo)
Kedua tugas selesai tanpa deadlockBentuk with kunci_transfer, kunci_saldo: mengunci kedua kunci sekaligus dalam satu statement, dan kedua function kini memakai urutan yang sama. Thread mana pun yang berjalan lebih dulu, tidak ada skenario saling menunggu.
Alat Sinkronisasi Lainnya #
Module threading masih menyimpan beberapa alat lain yang layak kita kenali sejak sekarang: threading.Event adalah bendera sinyal yang bisa diset dan ditunggu (misalnya memberi tahu worker “mulai bekerja”), sedangkan threading.Condition menggabungkan lock dengan kemampuan menunggu-dan-memberi-tahu antar thread. Untuk bertukar data antar thread, queue.Queue dari Chapter Queue dan Worker Patterns justru merupakan pilihan paling aman karena telah menangani penguncian di dalamnya.