Menu

Pernyataan lock C#: Race Condition, Interlocked dan Deadlock

Pernyataan lock membuat hanya satu thread dalam satu waktu yang bisa menjalankan sebuah blok kode. Lihat bagaimana race condition merusak sebuah penghitung, perbaiki dengan lock, pelajari objek mana yang dipakai untuk lock, pakai Interlocked untuk penghitung sederhana, hindari deadlock karena urutan lock, dan pakai SemaphoreSlim saat kode di dalamnya melakukan await.

Halaman ini dilengkapi editor yang bisa dijalankan. Edit, jalankan, dan lihat output-nya seketika.

Saat beberapa thread membaca dan menulis data yang sama pada waktu bersamaan, pembaruan bisa hilang atau terlihat setengah jadi. Pernyataan lock membuat sebuah blok kode saling eksklusif: selama satu thread berada di dalamnya, setiap thread lain yang sampai di lock pada objek yang sama akan menunggu gilirannya.

Masalahnya: race condition

Empat task masing-masing menambahkan 100.000 ke sebuah penghitung bersama. Jawabannya seharusnya 400.000:

Contoh output:

Expected 400000, got 245609

Totalnya biasanya kurang, dan selisihnya berbeda di setiap kali dijalankan (di mesin dengan satu core, hasilnya kadang-kadang benar, dan itulah yang membuat bug seperti ini sulit ditangkap). count++ terlihat seperti satu operasi, padahal sebenarnya tiga: baca count, tambah satu, tulis kembali. Dua thread bisa sama-sama membaca 500, sama-sama menghitung 501 dan sama-sama menulis 501, sehingga satu penambahan hilang.

Solusinya: lock

Bungkus proses baca, ubah, tulis itu dengan lock pada sebuah objek bersama:

Output:

Expected 400000, got 400000

Sekarang hanya satu thread dalam satu waktu yang bisa berada di dalam blok, jadi setiap penambahan selesai sebelum penambahan berikutnya membaca nilainya. Lock juga menjamin visibilitas: thread yang masuk ke lock melihat setiap penulisan yang dilakukan pemegang sebelumnya sebelum ia keluar.

Perlindungan ini hanya berhasil kalau setiap akses ke count melewati lock yang sama. Satu count++ tanpa lock di bagian lain program akan memunculkan kembali race condition-nya.

Hasil compile dari lock

lock adalah cara singkat untuk memakai class Monitor, dengan try/finally supaya lock tetap dilepas walaupun bloknya melempar exception:

lock (sync)
{
    count++;
}

// is compiled roughly as:
bool taken = false;
try
{
    Monitor.Enter(sync, ref taken);
    count++;
}
finally
{
    if (taken) Monitor.Exit(sync);
}

Monitor juga menyediakan TryEnter(obj, timeout), yang menyerah setelah batas waktu daripada menunggu selamanya, dan Wait/Pulse untuk saling memberi sinyal antarthread. Thread yang sudah memegang sebuah lock bisa masuk lagi ke lock itu (lock bersifat reentrant), jadi method yang memakai lock bisa memanggil method lain yang memakai lock pada objek yang sama.

Memilih objek lock

Objek di dalam lock (...) hanyalah sebuah token yang disepakati oleh para thread. Aturannya:

  • Pakai field private dan read-only bertipe object. private readonly object sync = new object();. Private berarti tidak ada kode dari luar yang bisa mengambil lock yang sama; readonly berarti token itu tidak bisa ditukar saat sebuah thread sedang memegangnya.
  • Jangan pernah lock (this). Siapa pun yang memegang referensi ke objekmu juga bisa melakukan lock padanya, lalu kode mereka memblokir kodemu atau membuat deadlock dengannya.
  • Jangan pernah melakukan lock pada string atau typeof(...). Literal string di-intern (setiap "orders" di dalam proses adalah objek yang sama), dan objek Type dipakai bersama di seluruh aplikasi, jadi kode yang tidak berhubungan bisa berebut lock yang sama.
  • Jangan pernah melakukan lock pada value type. lock (count) pada sebuah int tidak bisa di-compile (error CS0185), dan melakukan boxing secara manual membuat objek baru setiap kali, jadi tidak ada yang terkunci.
  • Pakai satu lock untuk setiap kumpulan data yang harus tetap konsisten, field lock static untuk data static, dan field instance untuk data per instance.

Melindungi operasi bertahap

lock paling dibutuhkan di tempat pemeriksaan dan tindakan harus terjadi bersamaan. Sebuah akun yang thread safe memperlihatkan pola periksa lalu bertindak sekaligus potret yang konsisten dari dua field:

Output:

10 left after 33 withdrawals

Tanpa lock, dua thread bisa sama-sama melihat saldo 40, sama-sama lolos pemeriksaan, dan sama-sama menarik 30, sehingga saldo akun menjadi minus 20. Method Summary juga memakai lock, jadi ia tidak pernah melaporkan balance dari setelah sebuah penarikan bersama jumlah withdrawals dari sebelum penarikan itu.

Dictionary<TKey, TValue> dan List<T> juga tidak thread safe: penulisan bersamaan bisa merusak array internalnya, bukan hanya menghilangkan pembaruan. Lindungi dengan lock atau pakai ConcurrentDictionary<TKey, TValue> dan tipe lain di System.Collections.Concurrent.

Interlocked untuk satu nilai

Saat state bersamanya hanya satu integer atau satu referensi dan pembaruannya hanya satu langkah, class Interlocked melakukannya secara atomik tanpa lock:

Output:

Visits: 10000
Bytes: 5120000
Peak: 10000

Increment, Decrement, Add dan Exchange dipetakan ke operasi prosesor yang atomik (satu instruksi di x64). CompareExchange(ref location, newValue, expected) hanya menulis kalau lokasi itu masih berisi expected, lalu mengembalikan apa yang ditemukannya, sehingga kamu bisa membangun pembaruan apa pun sebagai loop yang mencoba ulang. Apa pun yang menyentuh dua variabel sekaligus tetap butuh lock.

Deadlock

Deadlock terjadi saat dua thread masing-masing memegang lock yang dibutuhkan thread lainnya:

// Thread 1                          // Thread 2
lock (accountA)                      lock (accountB)
{                                    {
    lock (accountB) { /* ... */ }        lock (accountA) { /* ... */ }
}                                    }

Kalau thread 1 mengambil accountA sementara thread 2 mengambil accountB, masing-masing lalu menunggu yang lain selamanya. Tidak ada exception dan tidak ada batas waktu; programnya macet. Transfer antara dua akun yang melakukan lock pada "from" lalu "to" menghasilkan persis hal ini saat dua transfer berlawanan arah berjalan bersamaan.

Pertahanan standarnya:

  • Lakukan lock dalam urutan global yang tetap. Untuk sebuah transfer, kunci akun dengan ID yang lebih kecil terlebih dahulu, ke arah mana pun uangnya bergerak.
  • Pegang lock sebentar saja dan kerjakan pekerjaan yang lambat (I/O, logging, panggilan jaringan) di luar lock.
  • Jangan memanggil kode yang tidak kamu kenal saat memegang lock: event, callback dan method virtual mungkin mengambil lock-nya sendiri.
  • Pakai Monitor.TryEnter dengan batas waktu di tempat program yang macet lebih buruk daripada sebuah kegagalan.

Tidak boleh ada await di dalam lock: pakai SemaphoreSlim

await tidak diizinkan di dalam blok lock (error compiler CS1996). Setelah await, method-nya bisa dilanjutkan di thread yang berbeda, sedangkan lock Monitor harus dilepas oleh thread yang mengambilnya. Untuk kode async, SemaphoreSlim dengan jumlah 1 berperan sebagai lock yang cocok untuk async:

Output:

5 saved, one at a time

Selalu pasangkan WaitAsync dengan Release di dalam finally, atau sebuah exception akan membuat gerbangnya tertutup selamanya. SemaphoreSlim(3, 3) mengizinkan tiga pemanggil masuk sekaligus, dan dengan cara inilah kamu membatasi jumlah request bersamaan ke API yang punya batas kecepatan.

System.Threading.Lock (.NET 9)

.NET 9 dengan C# 13 menambahkan tipe khusus System.Threading.Lock. Saat objek di dalam pernyataan lock adalah sebuah Lock, compiler memakai API EnterScope miliknya yang lebih cepat, bukan Monitor:

private readonly Lock sync = new Lock();

public void Add(decimal amount)
{
    lock (sync)          // uses Lock.EnterScope(), not Monitor
    {
        balance += amount;
    }
}

Aturan memilih objeknya tetap sama. Di versi sebelumnya, private readonly object adalah pilihan yang tepat.

Kesalahan yang sering terjadi

  • Melakukan lock pada sebagian akses saja. Setiap pembacaan dan penulisan data bersama harus mengambil lock yang sama.
  • lock (this), lock (typeof(X)), lock ("name"). Kode dari luar bisa mengambil lock yang sama.
  • Melakukan I/O yang lambat di dalam lock. Semua thread lain menunggu; buat bloknya tetap singkat.
  • Mengambil dua lock dengan urutan berbeda di tempat yang berbeda. Deadlock klasik.
  • Memakai lock di sekitar await. Tidak bisa di-compile; pakai SemaphoreSlim.
  • Satu lock untuk setiap pemanggilan. lock (new object()) tidak melindungi apa pun; objeknya harus dipakai bersama.

Pertanyaan yang sering diajukan

Apa yang dilakukan lock di C#?

lock (obj) { ... } membuat hanya satu thread dalam satu waktu yang bisa menjalankan blok itu untuk sebuah objek lock tertentu. Thread kedua yang sampai di lock pada objek yang sama akan menunggu sampai thread pertama keluar dari blok. Lock-nya tetap dilepas walaupun blok itu melempar exception, karena lock di-compile menjadi Monitor.Enter dan Monitor.Exit di dalam try/finally.

Objek apa yang sebaiknya dipakai untuk lock di C#?

Sebuah field private khusus: private readonly object _sync = new object();. Objek itu harus berupa reference type, dipakai bersama oleh setiap thread yang menyentuh data yang dilindungi, dan tidak bisa dijangkau dari luar class-mu. Jangan pernah melakukan lock pada this, sebuah Type (typeof(MyClass)) atau string, karena kode lain bisa melakukan lock pada objek yang sama lalu memblokir kodemu atau membuat deadlock.

Kapan sebaiknya memakai Interlocked daripada lock?

Saat state bersamanya hanya satu angka atau satu referensi dan operasinya hanya satu langkah: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange atau CompareExchange. Ini adalah operasi hardware yang atomik dan lebih cepat daripada lock. Untuk apa pun yang menyentuh beberapa field atau sebuah koleksi, pakai lock.

Apakah bisa memakai await di dalam lock di C#?

Tidak, compiler menolak await di dalam blok lock (error CS1996), karena kode setelah await bisa dilanjutkan di thread yang berbeda dari thread yang memegang lock. Pakai SemaphoreSlim dengan jumlah 1 sebagai gantinya: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.

Bagaimana deadlock bisa terjadi dengan lock?

Thread 1 memegang lock A dan menunggu lock B, sementara thread 2 memegang lock B dan menunggu lock A. Keduanya tidak bisa melanjutkan, dan program macet tanpa exception. Cegah dengan selalu mengambil beberapa lock dalam urutan global yang sama, memegang lock sesingkat mungkin, dan tidak pernah memanggil kode yang tidak kamu kenal saat sedang memegang lock.

Ilustrasi bahasa pemrograman di Coddy

Belajar coding dengan Coddy

Mulai