Keamanan Thread

lock, Interlocked, dan ConcurrentDictionary.

Daftar isi

Keamanan thread (thread safety) adalah jaminan bahwa kode tetap benar ketika dijalankan oleh beberapa thread sekaligus. Perulangan paralel menjalankan badan yang sama pada beberapa thread, sehingga variabel yang ditulis bersama menjadi titik rawan. Topik ini membahas race condition yang muncul dari akses serentak, lalu tiga pengamannya yang mendasar, yaitu lock, Interlocked, dan ConcurrentDictionary. Setelah membacanya, keadaan bersama (shared state) dapat dilindungi dengan alat yang sesuai untuk setiap situasi.

Race condition dan akibatnya#

Race condition (race condition) terjadi ketika hasil program bergantung pada urutan antar-thread yang tidak dapat dijamin. Penyebabnya paling sering adalah operasi baca-ubah-tulis yang tampak satu baris, padahal dijalankan sebagai beberapa langkah. Pernyataan penghitung++ membaca nilai dari memori, menambahkannya satu, lalu menulisnya kembali, dan dua thread yang menyelingi urutan itu dapat saling menimpa hasil.

csharp Contoh: penambahan tanpa lock yang menimbulkan race condition
// Contoh: penambahan tanpa lock yang menimbulkan race condition.

int tanpaSinkronisasi = 0;
Parallel.For(0, 4, _ =>
{
    for (int i = 0; i < 2500; i++)
    {
        tanpaSinkronisasi++;
    }
});
Console.WriteLine($"Hasil tanpa sinkronisasi: {tanpaSinkronisasi}");

Program di atas dijalankan untuk pemahaman, tetapi keluarannya sengaja tidak ditempel karena tidak dapat dijamin. Hasil akhirnya biasanya di bawah 10000 dan berubah antareksekusi, misalnya dua eksekusi berurutan pada lingkungan uji mencatat 6406 lalu 8331. Angka yang hilang adalah pembaruan yang tertimpa oleh thread lain, dan kegagalan semacam ini sering tidak terlihat pada mesin pengembang karena seling antar-thread jarang terjadi. Karena sifatnya yang tidak pasti, kebenaran program paralel tidak boleh disimpulkan dari pengamatan sekali jalankan.

Melindungi keadaan bersama dengan lock#

Lock menjamin hanya satu thread yang dapat masuk ke sebuah blok kode pada satu waktu. Blok itu disebut bagian kritis (critical section), yaitu wilayah kode yang menyentuh keadaan bersama. Thread lain yang mencoba masuk ditahan sampai thread pemegang lock keluar, sehingga baca-ubah-tulis tidak dapat diselingi. Program berikut menerapkan lock, Interlocked, dan ConcurrentDictionary dalam satu rangkaian. Salin programnya ke berkas Program.cs, lalu jalankan dengan perintah dotnet run --file Program.cs.

csharp Program.cs
// Program keamanan thread: lock, Interlocked, dan ConcurrentDictionary.
using System.Collections.Concurrent;

int denganLock = 0;
object kunci = new();
Parallel.For(0, 4, _ =>
{
    for (int i = 0; i < 2500; i++)
    {
        lock (kunci)
        {
            denganLock++;
        }
    }
});
Console.WriteLine($"Penghitung dengan lock: {denganLock}");

int denganInterlocked = 0;
Parallel.For(0, 4, _ =>
{
    for (int i = 0; i < 2500; i++)
    {
        Interlocked.Increment(ref denganInterlocked);
    }
});
Console.WriteLine($"Penghitung dengan Interlocked: {denganInterlocked}");

var stok = new ConcurrentDictionary<string, int>();
string[] pengisian = ["buku", "buku", "pulpen", "pulpen", "buku"];
Parallel.ForEach(pengisian, nama =>
{
    stok.AddOrUpdate(nama, 1, (_, jumlah) => jumlah + 1);
});
foreach (var pasangan in stok.OrderBy(p => p.Key))
{
    Console.WriteLine($"{pasangan.Key}: {pasangan.Value}");
}
text Keluaran
Penghitung dengan lock: 10000
Penghitung dengan Interlocked: 10000
buku: 3
pulpen: 2

Bagian pertama program menambah penghitung sebanyak 4 × 2500 kali dari empat thread dengan lock, dan hasilnya selalu 10000. Objek kunci berupa object yang dideklarasikan khusus untuk tujuan itu, karena semua thread yang melindungi keadaan yang sama wajib memakai satu objek kunci yang sama. Apabila satu thread mengunci kunci sementara thread lain mengunci objek lain, perlindungan itu tidak berlaku sama sekali.

Operasi atomik dengan Interlocked#

Bagian kedua program mencapai hasil yang sama tanpa blok lock. Kelas Interlocked menyediakan operasi atomik (atomic operation), yaitu operasi yang dijalankan sebagai satu langkah yang tidak dapat dibelah oleh thread lain. Metode Interlocked.Increment menambah variabel sekaligus mengembalikan nilainya secara atomik, sehingga seling antar-thread mustahil terjadi pada operasi itu.

Untuk penghitungan sederhana, operasi atomik lebih murah daripada lock karena tidak memblokir thread sama sekali. Metode lain yang sering dipakai adalah Add untuk penjumlahan, Exchange untuk menukar nilai, dan CompareExchange untuk menukar nilai hanya apabila nilai lamanya sesuai harapan. Interlocked membatasi diri pada satu operasi per panggilan, sehingga blok yang mengubah beberapa variabel sekaligus tetap memerlukan lock.

Koleksi yang aman terhadap thread#

Bagian ketiga program memakai ConcurrentDictionary, yaitu kamus yang seluruh operasinya aman terhadap akses serentak. Metode AddOrUpdate menambah entri baru apabila kuncinya belum ada, atau memperbarui nilai lama apabila sudah ada, dan keduanya berlangsung sebagai satu operasi. Keluaran stok dihitung dari lima pengisian yang berjalan paralel, kemudian dicetak terurut setelah perulangan selesai.

Koleksi bersamaan (concurrent collection) menghapus kebutuhan lock pada tingkat koleksi, tetapi tidak menghapus kebutuhan berpikir. Enumerasi atas ConcurrentDictionary aman dilakukan, namun hasilnya bukan potongan (snapshot) pada satu momen, sehingga alur dua langkah seperti membaca lalu menulis berdasarkan hasil bacaan tetap memakai metode atomik milik koleksinya.

Kesalahan umum#

Empat kesalahan berikut paling sering muncul ketika keadaan bersama dilindungi.

  • Mengunci this, tipe kelas, atau literal string. Objek kunci yang terlihat dari luar dapat dikunci oleh kode lain tanpa diketahui, sehingga deadlock atau kebocoran perlindungan mudah terjadi. Deklarasikan objek kunci privat yang khusus untuk tujuan itu.
  • Menganggap koleksi thread-safe membuat seluruh algoritmanya aman. Pola cek lalu tindak (check-then-act), seperti memeriksa keberadaan kunci dengan ContainsKey lalu menambahnya melalui indexer, tetap dapat diselingi antar-thread. Gunakan metode atomik koleksi seperti TryAdd atau AddOrUpdate.
  • Memakai satu lock besar untuk semua keadaan. Perlindungan menjadi benar, tetapi antrean menunggu menjadi panjang sehingga throughput turun. Pecah keadaan yang tidak saling bergantung ke objek kunci yang berbeda.
  • Mengunci lock lain di dalam bagian kritis. Dua lock yang saling menunggu menghasilkan deadlock, yaitu keadaan ketika semua thread berhenti menunggu satu sama lain selamanya. Tetapkan urutan penguncian yang konsisten dan buat bagian kritis selengkap mungkin.

Ringkasan#

  • Race condition muncul dari baca-ubah-tulis yang dapat diselingi antar-thread, dan hasilnya tidak dapat diprediksi tanpa sinkronisasi.
  • lock membatasi bagian kritis pada satu thread, sedangkan Interlocked menjalankan satu operasi secara atomik tanpa pemblokiran.
  • ConcurrentDictionary menyediakan operasi kamus yang aman terhadap akses serentak, sedangkan alur dua langkah tetap memakai metode atomik milik koleksinya.

Referensi#