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:

python
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):

text
Nilai harapan : 200000
Nilai aktual  : 151463

Hampir 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:

python
import dis

counter = 0

def tambah_counter():
    global counter
    counter += 1

dis.dis(tambah_counter)

Output yang Dihasilkan:

text
  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:

python
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:

text
Nilai harapan : 200000
Nilai aktual  : 200000

Hasil 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:

python
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:

text
Memproses data...
Log disimpan (kunci yang sama dipakai ulang)
Selesai tanpa deadlock

Function 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:

python
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:

python
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:

text
Transfer berjalan (urutan kunci: transfer -> saldo)
Saldo dicatat (urutan kunci: transfer -> saldo)
Kedua tugas selesai tanpa deadlock

Bentuk 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.