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.
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}"# menjalankan program penghitung dengan mutex
crystal run mutex-penghitung.crtotal akhir: 8000Delapan 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.
# 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.
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}"# menjalankan program penghitung dengan atomic
crystal run atomic-penghitung.crtotal akhir: 8000Struktur 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.
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# menjalankan program operasi atomic
crystal run atomic-operasi.cr10
5
5
8
{8, true}
40
{40, false}
40Urutan 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
Atomicmelindungi rentang pernyataan.Atomicmenjamin satu operasi pada satu nilai, bukan urutan beberapa pernyataan di sekitarnya. Apabila beberapa variabel harus berubah sebagai satu kesatuan, gunakanMutexyang mencakup seluruh bagian tersebut. - Mengunci dan melepas kunci secara manual tanpa jaminan pembebasan. Pasangan
lockdanunlockyang terpisah meninggalkan kunci terkunci selamanya apabila di antaranya terjadi error. Gunakansynchronizeyang melepaskan kunci melalui jaminanensure. - Mengira konteks konkuren membuat semua data otomatis aman. Fiber dalam satu konteks
Concurrentmemang 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#
Mutexmengunci bagian kode agar hanya satu fiber yang menjalankannya pada satu waktu, dansynchronizemengurus 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 sepertiaddberlangsung tak terpotong, dengan parameter semantik memori seperti:acquire_release.compare_and_setmenukar nilai hanya apabila nilai saat ini sesuai harapan dan melaporkan hasilnya sebagai pasangan nilai dan tanda keberhasilan.
Referensi#
- Concurrency guide — Crystal — panduan resmi konkurensi Crystal yang menjelaskan paralelisme dan komunikasi antar fiber.
- Sync::Mutex — Crystal API
— dokumentasi mutex yang menjadi alias kelas
Mutex, termasuklock,unlock, dansynchronize. - Atomic — Crystal API
— dokumentasi kelas Atomic, termasuk
get,set,add, dancompare_and_set.