Keamanan thread

Mutex dan atomic untuk melindungi memori bersama saat paralelisme diaktifkan.

Daftar isi

Fiber yang berjalan paralel pada thread berbeda dapat menyentuh variabel yang sama pada saat yang sama, sehingga pembaruan satu fiber dapat hilang tertimpa pembaruan fiber lain. Topik ini membahas dua alat perlindungan yang disediakan Crystal, yaitu Mutex untuk mengunci suatu bagian kode agar hanya satu fiber yang menjalankannya, dan Atomic untuk operasi tunggal pada satu nilai tanpa kunci. Keduanya didemonstrasikan pada konteks paralel dengan dua pekerja, dan setiap program dijalankan beserta keluaran aslinya.

Mengunci bagian kode dengan Mutex#

Objek Mutex menjamin bahwa hanya satu fiber yang dapat berada di dalam bagian kode yang dikuncinya pada satu waktu. Fiber lain yang mencoba masuk akan menunggu sampai pemegang kunci melepaskannya, sehingga urutan akses menjadi tunggal meskipun fiber-fibernya berjalan paralel. Metode synchronize mengunci di awal blok dan melepaskan kunci di akhirnya, termasuk ketika blok memicu error, sehingga kunci tidak tertinggal terkunci.

Program berikut menjalankan delapan fiber pada konteks paralel berkapasitas dua, dan setiap fiber menaikkan satu variabel bersama sebanyak 1000 kali. Seluruh kenaikan dibungkus synchronize agar tidak ada dua fiber yang menaikkan pada saat bersamaan.

crystal mutex-penghitung.cr
ec = Fiber::ExecutionContext::Parallel.new("penghitung", 2)
total = 0
kunci = Mutex.new
selesai = Channel(Nil).new

8.times do
  ec.spawn do
    1000.times do
      kunci.synchronize do
        total += 1
      end
    end
    selesai.send(nil)
  end
end

8.times { selesai.receive }
puts "total akhir: #{total}"
bash
# menjalankan program penghitung dengan mutex
crystal run mutex-penghitung.cr
text Keluaran
total akhir: 8000

Delapan fiber dengan 1000 kenaikan menghasilkan tepat 8000 pada setiap eksekusi. Total yang tepat itu bukan hasil kebetulan, melainkan akibat kunci yang memaksa setiap total += 1 berlangsung sebagai bagian yang tak terpotong. Pola channel selesai kembali digunakan agar program utama menunggu seluruh fiber tuntas, sebab total yang dicetak lebih dulu belum mencakup semua kenaikan.

Apa yang terjadi tanpa perlindungan#

Kenaikan total += 1 sesungguhnya terdiri atas tiga langkah, yaitu membaca nilai total, menambahkannya dengan satu, dan menulis kembali hasilnya. Apabila dua fiber membaca nilai yang sama sebelum salah satunya selesai menulis, pembaruan fiber yang menulis lebih dulu tertimpa dan hilang. Kehilangan semacam ini disebut race condition, yaitu keadaan ketika hasil program bergantung pada urutan kejadian yang tidak dapat dikendalikan.

crystal Contoh: kenaikan tanpa perlindungan
# tidak-dijalankan: keluarannya berubah-ubah pada setiap eksekusi
ec = Fiber::ExecutionContext::Parallel.new("penghitung", 2)
total = 0
selesai = Channel(Nil).new

8.times do
  ec.spawn do
    100000.times do
      total += 1
    end
    selesai.send(nil)
  end
end

8.times { selesai.receive }
puts "total akhir: #{total} (harusnya 800000)"

Kode di atas sengaja tidak ditempeli keluaran karena hasilnya memang tidak tetap. Pada pengujian di sandbox, tiga eksekusi program di atas menghasilkan total yang berbeda-beda dan semuanya jauh di bawah 800000. Bahaya race condition justru terletak pada sifatnya yang sulit dideteksi, sebab pada beban ringan tabrakan bisa saja tidak terjadi dan total tampak benar selama beberapa eksekusi, lalu salah pada eksekusi berikutnya. Perlindungan dengan Mutex atau Atomic karenanya wajib diterapkan sejak awal, bukan setelah kelainan terlihat.

Operasi tunggal dengan Atomic#

Kelas Atomic(T) menyimpan satu nilai yang setiap operasinya berlangsung sebagai satu langkah yang tidak terpotong, sehingga tidak ada fiber lain yang menyela di tengah operasi. Untuk kenaikan penghitung, metode add menggantikan pola baca-tambah-tulis sekaligus menghapus celah yang membuat pembaruan dapat hilang. Parameter kedua add mengatur semantik memori, yaitu cara operasi memori di sekitarnya boleh diurutkan ulang. Pilihannya adalah :relaxed, :acquire, :release, dan :acquire_release, sedangkan :sequentially_consistent menjadi nilai bawaan.

crystal atomic-penghitung.cr
ec = Fiber::ExecutionContext::Parallel.new("penghitung", 2)
total = Atomic(Int32).new(0)
selesai = Channel(Nil).new

8.times do
  ec.spawn do
    1000.times do
      total.add(1, :acquire_release)
    end
    selesai.send(nil)
  end
end

8.times { selesai.receive }
puts "total akhir: #{total.get}"
bash
# menjalankan program penghitung dengan atomic
crystal run atomic-penghitung.cr
text Keluaran
total akhir: 8000

Struktur programnya identik dengan versi Mutex, tetapi kini tidak ada kunci yang diambil dan dilepas pada setiap kenaikan, sehingga bebannya lebih ringan untuk operasi sesederhana ini. Metode add mengembalikan nilai lama sebelum penambahan, yang pada program ini tidak digunakan. Semantik :acquire_release memadai untuk penghitung yang hasilnya dibaca setelah seluruh fiber selesai, sedangkan nilai bawaan :sequentially_consistent memberikan jaminan pengurutan yang lebih kuat.

Membaca, mengisi, dan membandingkan nilai atomik#

Selain add, kelas Atomic(T) menyediakan get untuk membaca, set untuk mengisi, dan compare_and_set untuk menukar nilai hanya apabila nilai saat ini sama dengan yang diharapkan. Metode compare_and_set mengembalikan pasangan berupa nilai aktual saat pembandingan dan tanda keberhasilan, sehingga pemanggil mengetahui apakah penukaran terjadi atau tidak. Metode ini menjadi fondasi algoritma tanpa kunci yang keputusannya bergantung pada nilai terkini.

crystal atomic-operasi.cr
nilai = Atomic(Int32).new(10)
puts nilai.get
nilai.set(5)
puts nilai.get
puts nilai.add(3)
puts nilai.get
p nilai.compare_and_set(8, 40)
puts nilai.get
p nilai.compare_and_set(8, 50)
puts nilai.get
bash
# menjalankan program operasi atomic
crystal run atomic-operasi.cr
text Keluaran
10
5
5
8
{8, true}
40
{40, false}
40

Urutan keluaran mengikuti setiap langkah program. Pembacaan pertama mengembalikan 10, pengisian 5 menggantikannya, dan add(3) mengembalikan nilai lama 5 sebelum nilainya menjadi 8. Pembandingan compare_and_set(8, 40) berhasil karena nilai saat itu memang 8, sedangkan compare_and_set(8, 50) gagal karena nilai sudah menjadi 40, dan pasangan {40, false} melaporkan nilai aktual beserta kegagalannya tanpa mengubah isi atomik. Pola gagal-berhasil inilah yang digunakan untuk mengulang pembaruan sampai berhasil apabila nilai disaingi fiber lain.

Kesalahan umum#

Tiga kesalahan berikut lazim muncul ketika perlindungan memori bersama baru pertama kali diterapkan.

  • Menganggap Atomic melindungi rentang pernyataan. Atomic menjamin satu operasi pada satu nilai, bukan urutan beberapa pernyataan di sekitarnya. Apabila beberapa variabel harus berubah sebagai satu kesatuan, gunakan Mutex yang mencakup seluruh bagian tersebut.
  • Mengunci dan melepas kunci secara manual tanpa jaminan pembebasan. Pasangan lock dan unlock yang terpisah meninggalkan kunci terkunci selamanya apabila di antaranya terjadi error. Gunakan synchronize yang melepaskan kunci melalui jaminan ensure.
  • Mengira konteks konkuren membuat semua data otomatis aman. Fiber dalam satu konteks Concurrent memang tidak pernah aktif bersamaan, tetapi data yang dibagikan dengan konteks lain tetap disaingkan. Perlindungan diputuskan berdasarkan siapa yang menyentuh data, bukan berdasarkan konteks tempat kode berada.

Ringkasan#

  • Mutex mengunci bagian kode agar hanya satu fiber yang menjalankannya pada satu waktu, dan synchronize mengurus penguncian beserta pembebasannya.
  • Tanpa perlindungan, kenaikan variabel bersama dapat kehilangan pembaruan karena langkah baca, tambah, dan tulis dapat tersisipi fiber lain.
  • Atomic(T) menjamin satu operasi seperti add berlangsung tak terpotong, dengan parameter semantik memori seperti :acquire_release.
  • compare_and_set menukar nilai hanya apabila nilai saat ini sesuai harapan dan melaporkan hasilnya sebagai pasangan nilai dan tanda keberhasilan.

Referensi#