Sinkronisasi

Menyaksikan race condition secara langsung lalu menutupnya dengan synchronized, wait dan notify, serta Lock.

Daftar isi

Dua thread yang menulis ke data yang sama tanpa pengaturan akan saling menimpa, dan hasil akhirnya bergantung pada kebetulan waktu eksekusi. Kondisi semacam ini disebut race condition, yaitu cacat yang muncul ketika kebenaran program bergantung pada urutan antar-thread yang tidak bisa dikendalikan. Topik ini menyaksikan race condition itu terjadi pada penghitung sederhana, lalu menutupnya dengan tiga perlengkapan Java, yakni kata kunci synchronized, pasangan wait dan notify untuk koordinasi, serta Lock dari paket java.util.concurrent.locks.

Race condition yang dapat disaksikan#

Penyebab klasik race condition adalah operasi baca-ubah-tulis yang tampak satu baris padahal tiga langkah. Pernyataan hitungan = hitungan + 1 membaca nilai lama, menambahkannya di register, lalu menulisnya kembali, dan thread lain dapat muncul di antara langkah-langkah itu dengan membawa nilai lama yang sama. Demonya sengaja menyisipkan jeda satu milidetik di antara pembacaan dan penulisan untuk melebarkan jendela tabrakan, karena pada kode nyata pekerjaan lain selalu ada di antara keduanya, misalnya perhitungan atau panggilan jaringan.

java DemoRace.java
public class DemoRace {

    static int hitungan = 0;

    static void tambah(int banyak) {
        for (int i = 0; i < banyak; i++) {
            int nilai = hitungan;
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            hitungan = nilai + 1;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> tambah(10));
        Thread t2 = new Thread(() -> tambah(10));

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Harapan: 20");
        if (hitungan == 20) {
            System.out.println("Hitungan akhir sesuai harapan");
        } else {
            System.out.println("Hitungan akhir tidak sama dengan yang diharapkan");
        }
    }
}
text Keluaran
Harapan: 20
Hitungan akhir tidak sama dengan yang diharapkan

Dua thread masing-masing menambah sepuluh kali, sehingga hitungannya seharusnya 20. Program sengaja tidak mencetak angka akhirnya karena nilai itu berubah-ubah antar eksekusi. Yang jelas adalah kategorinya: pada pengujian berulang, hasilnya selalu gagal dengan angka yang mendarat jauh di bawah 20. Thread yang menulis ulang memakai nilai bacaan lamanya, sehingga kenaikan milik thread lain musnah tanpa pesan error apa pun. Kerusakan yang senyap seperti inilah yang membuat race condition berbahaya di produksi.

Kata kunci synchronized menjadikan satu blok kode eksklusif bagi satu thread pada satu waktu. Setiap objek Java memiliki satu monitor, yaitu kunci bawaan yang harus diperoleh sebelum memasuki blok atau metode synchronized, dan thread kedua menunggu sampai kunci itu dilepas. Metode static synchronized memakai monitor milik kelasnya, sedangkan metode instance memakai monitor milik objeknya.

java DemoSinkron.java
public class DemoSinkron {

    static int hitungan = 0;

    static synchronized void tambah(int banyak) {
        for (int i = 0; i < banyak; i++) {
            int nilai = hitungan;
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            hitungan = nilai + 1;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> tambah(10));
        Thread t2 = new Thread(() -> tambah(10));

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Harapan: 20");
        System.out.println("Hitungan akhir: " + hitungan);
    }
}
text Keluaran
Harapan: 20
Hitungan akhir: 20

Tubuh metodenya identik dengan versi race, tetapi tambahan synchronized mengubah nasibnya. Thread kedua menunggu sampai thread pertama selesai melepaskan monitor kelas, sehingga operasi baca-ubah-tulis tidak pernah terpotong dan hitungannya tepat 20 pada setiap eksekusi. Selain pada metode, synchronized dapat ditempelkan pada blok tertentu dengan kunci yang dipilih sendiri, sehingga wilayah eksklusifnya sekecil mungkin.

java Contoh: blok synchronized dengan kunci pilihan sendiri
// tidak-dijalankan
private final Object kunciSaldo = new Object();

public void setor(long nominal) {
    synchronized (kunciSaldo) {
        saldo = saldo + nominal;
    }
}

Koordinasi dengan wait dan notify#

Sinkronisasi hanya mencegah tabrakan, sedangkan koordinasi mengatur giliran. Metode wait() membuat thread melepaskan monitornya dan tidur sampai dibangunkan, sedangkan notify() atau notifyAll() membangunkan thread yang menunggu pada monitor yang sama. Keduanya wajib dipanggil dari dalam blok atau metode synchronized pada objek yang sama, karena tanpa memegang monitor, pemanggilannya melempar IllegalMonitorStateException. Pemeriksaan syaratnya ditulis dalam perulangan while, bukan if, agar thread yang terbangun memeriksa ulang keadaannya sendiri.

java DemoWaitNotify.java
public class DemoWaitNotify {

    static class Buffer {

        private int isi;
        private boolean ada = false;

        synchronized void produksi(int nilai) throws InterruptedException {
            while (ada) {
                wait();
            }
            isi = nilai;
            ada = true;
            notifyAll();
        }

        synchronized int konsumsi() throws InterruptedException {
            while (!ada) {
                wait();
            }
            ada = false;
            notifyAll();
            return isi;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Buffer buffer = new Buffer();

        Thread produsen = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                try {
                    buffer.produksi(i);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });

        Thread konsumen = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println("Dikonsumsi: " + buffer.konsumsi());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });

        produsen.start();
        konsumen.start();
        produsen.join();
        konsumen.join();
        System.out.println("Selesai");
    }
}
text Keluaran
Dikonsumsi: 1
Dikonsumsi: 2
Dikonsumsi: 3
Dikonsumsi: 4
Dikonsumsi: 5
Selesai

Buffer satu slot di atas memaksa kedua thread berbaris rapi. Produsen yang menemukan buffer penuh akan tidur lewat wait(), lalu dibangunkan konsumen yang baru saja mengosongkannya, dan sebaliknya. Pola produsen-konsumen semacam ini adalah penggunaan wait dan notify yang paling umum, dan BlockingQueue dari paket java.util.concurrent mengemas pola yang sama sehingga keduanya tidak perlu ditulis manual.

Kunci eksplisit dengan Lock#

Kelas ReentrantLock menawarkan kunci dengan perilaku yang sama seperti monitor, tetapi diperoleh dan dilepas secara eksplisit lewat lock() dan unlock(). Pasangan itu wajib dibungkus try dan finally agar kunci tetap terlepas meski bloknya melempar exception. Keunggulannya dibanding synchronized adalah kemampuan mencoba mengambil kunci tanpa menunggu (tryLock), menunggu dengan batas waktu, dan memilih kunci yang adil bagi thread terlama menunggu.

java DemoLock.java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class DemoLock {

    static int hitungan = 0;
    static final Lock kunci = new ReentrantLock();

    static void tambah(int banyak) {
        for (int i = 0; i < banyak; i++) {
            kunci.lock();
            try {
                int nilai = hitungan;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                hitungan = nilai + 1;
            } finally {
                kunci.unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> tambah(10));
        Thread t2 = new Thread(() -> tambah(10));

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Harapan: 20");
        System.out.println("Hitungan akhir: " + hitungan);
    }
}
text Keluaran
Harapan: 20
Hitungan akhir: 20

Hasilnya sama persis dengan versi synchronized, dan justru itulah poinnya, karena keduanya menjamin hal yang sama untuk kasus ini. Pilihan keduanya menyesuaikan kebutuhan, synchronized untuk penjagaan biasa yang ringkas, dan Lock bila fitur lanjutannya benar-benar diperlukan.

Kesalahan umum#

  • Mengira satu pernyataan berarti satu operasi atomik. hitungan = hitungan + 1 adalah tiga langkah yang dapat terpotong, sehingga butuh synchronized, Lock, atau kelas atomik seperti AtomicInteger.
  • Memakai dua objek kunci berbeda untuk data yang sama. Thread yang mengunci kunciA dan thread yang mengunci kunciB tidak pernah saling menunggu, sehingga race condition tetap terjadi. Satu data yang dijaga, satu kunci.
  • Memanggil wait() atau notify() di luar blok synchronized pada objek yang sama. Pemanggilannya melempar IllegalMonitorStateException, karena syaratnya adalah memegang monitor itu.
  • Melepas kunci Lock tanpa finally. Exception di dalam blok kritis membuat unlock() terlewat, dan semua thread lain menunggu selamanya. Bungkus selalu bagian kritis dengan try dan finally.

Ringkasan#

  • Race condition muncul karena operasi baca-ubah-tulis dapat terpotong di tengah jalan oleh thread lain.
  • synchronized memakai monitor objek atau kelas untuk menjadikan blok kode eksklusif bagi satu thread pada satu waktu.
  • wait dan notifyAll mengatur giliran pada monitor yang sama, selalu dipanggil dari dalam synchronized dan diperiksa ulang lewat while.
  • ReentrantLock menawarkan penjagaan yang sama dengan fitur tambahan, dan unlock()-nya wajib berada di finally.

Referensi#