Beberapa thread yang menulis ke satu variabel biasa bersaing memperebutkan nilai yang sama, dan hasil akhirnya bergantung pada urutan penjadwalan yang tidak dapat dikendalikan. Operasi atomik menyelesaikan masalah ini dengan menjamin satu operasi baca-tulis berlangsung sebagai satu kesatuan yang tidak terbelah. Zig menyediakannya melalui std.atomic.Value(T) beserta tingkat pengurutan (ordering) yang menyatakan seberapa kuat jaminan yang dibutuhkan. Topik ini mempraktikkan penghitung atomik antar thread dan menegaskan batas jaminannya.
Konsep#
std.atomic.Value(u64) membungkus satu nilai integer dan menyediakan metode seperti load, store, fetchAdd, dan fetchMax. Setiap metode menerima parameter pengurutan, dan pilihan yang paling ringan untuk penghitung adalah .monotonic, yang menjamin atomisitas tanpa menambah jaminan urutan antar variabel. Tingkat yang lebih kuat seperti .acquire dan .release dipakai ketika satu thread mempublikasikan data yang ditulis thread lain, sedangkan .seq_cst memberi urutan global penuh dengan biaya tertinggi.
Pada perangkat keras, operasi-operasi ini diterjemahkan menjadi instruksi baca-tulis atomik prosesor, bukan panggilan pustaka yang dapat terbelah. Inilah alasan seluruh keluarga metodenya hanya tersedia untuk tipe integer berlebar tetap dan pointer, karena instruksi prosesornya memang bekerja pada lebar register. Metode lainnya mengikuti pola yang sama, yaitu fetchSub untuk pengurangan, fetchOr dan fetchAnd untuk operasi bit, serta swap yang mengganti nilai sekaligus mengembalikan yang lama, dan seluruhnya menerima parameter pengurutan dengan kontrak yang konsisten.
Konvensi mengembalikan nilai lama bukan pilihan acak, melainkan bentuk yang paling berguna untuk membangun operasi yang lebih rumit. Pola bandingkan-dan-ganti pada metode cmpxchgStrong dan cmpxchgWeak milik std.atomic.Value memakai nilai lama sebagai dasar keputusannya, yaitu nilai baru hanya tersimpan bila nilai saat ini masih sama dengan yang dibaca sebelumnya. Bila nilai kembalian berupa nilai baru, pembangun pola semacam itu terpaksa membaca ulang variabel yang isinya sudah bisa berubah, dan jendela sengketa pun terbuka.
Dua kekeliruan tentang operasi ini perlu ditegaskan sejak awal. Pertama, operasi atomik menjadikan satu operasi aman, tetapi tidak membuat satu rangkaian operasi menjadi aman, dan kasus rangkaian itulah yang ditangani mutex pada topik berikutnya. Kedua, metode fetchAdd mengembalikan nilai sebelum penambahan, bukan nilai sesudahnya, sehingga nilai kembaliannya sering diabaikan dengan _ =.
Contoh Lengkap#
Program berikut memperbesar tekanan bersamaan, yaitu empat thread masing-masing menambah penghitung atomik seribu kali, kemudian nilai akhirnya dibaca dan satu operasi fetchMax diperagakan.
const std = @import("std");
var penghitung = std.atomic.Value(u64).init(0);
fn naikkan(berkali: u64) void {
for (0..berkali) |_| {
_ = penghitung.fetchAdd(1, .monotonic);
}
}
pub fn main() !void {
const per_benang: u64 = 1_000;
var benang: [4]std.Thread = undefined;
for (0..benang.len) |i| {
benang[i] = try std.Thread.spawn(.{}, naikkan, .{per_benang});
}
for (0..benang.len) |i| {
benang[i].join();
}
std.debug.print("akhir: {d}\n", .{penghitung.load(.monotonic)});
const lama = penghitung.fetchMax(7_000, .monotonic);
std.debug.print("nilai lama: {d}, nilai baru: {d}\n", .{ lama, penghitung.load(.monotonic) });
}Variabel penghitung dideklarasikan tingkat berkas karena dipakai lintas thread, dan deklarasinya bukan const karena nilainya berubah melalui operasi atomik, bukan melalui pengikatan ulang. Pencetakan tetap dilakukan setelah seluruh join, sehingga pembacaan load melihat nilai yang sudah final.
zig run penghitung-atomik.zigakhir: 4000
nilai lama: 4000, nilai baru: 7000Angka 4000 muncul pada setiap eksekusi karena keempat ribu penambahan atomik tidak satu pun hilang, berbeda dengan versi non-atomik yang hasilnya berubah-ubah dan biasanya jauh di bawah 4000. Baris kedua memperlihatkan kontrak fetchMax, yaitu nilai lama 4000 dikembalikan sekaligus nilai baru 7000 tersimpan, dan keduanya terjadi sebagai satu operasi tunggal.
Pemilihan tingkat pengurutan dapat dibaca sebagai tangga ketegangan. .monotonic menjamin atomisitas satu operasi tanpa mengatur urutan operasi lain di sekitarnya, .acquire dan .release membentuk pasangan penerbitan-penerimaan untuk data yang menyertai nilai atomiknya, sedangkan .seq_cst menegakkan satu urutan global yang disepakati seluruh thread. Naik satu tingkat mempersempit peluang pengoptimalan dan menambah biaya sinkronisasi, sehingga naik hanya bila kebutuhannya nyata.
Kesalahan umum#
Kesalahan kedua berupa mengharapkan fetchAdd mengembalikan nilai sesudah penambahan. Kode yang menulis const baru = penghitung.fetchAdd(1, .monotonic); lalu mencetak baru menampilkan nilai sebelum penambahan, karena kontrak metodenya adalah mengembalikan nilai lama. Nilai sesudahnya dibaca dengan load atau dihitung dengan menambah satu secara manual.
Kesalahan ketiga berupa memilih tingkat pengurutan yang lebih kuat daripada yang dibutuhkan sambil mengharapkan perilaku berubah. Menulis .seq_cst pada penghitung tidak mengubah hasil akhirnya, hanya menambah biaya, karena masalah pada contoh memang hanya atomisitas satu operasi. Pengurutan yang kuat diperlukan ketika ada data non-atomik yang hendak dipublikasikan bersama nilai atomiknya, dan kasus itu belum muncul pada topik ini.
Ringkasan#
std.atomic.Value(T)menyediakan operasi baca, tulis, dan baca-tulis yang tidak terbelah atas memori bersama.- Tingkat pengurutan menyatakan kekuatan jaminan, dengan
.monotoniccukup untuk penghitung dan.seq_cstsebagai urutan global. fetchAdddan metode fetch lainnya mengembalikan nilai lama, bukan nilai baru.- Operasi atomik melindungi satu operasi, sedangkan keamanan atas satu rentang operasi menjadi tugas mutex.
Referensi#
- Atomics — Zig Language Reference — Semantik operasi atomik dan tingkat pengurutan memorinya.
- std.atomic.Value — Dokumentasi pustaka standar Zig — Operasi baca, tulis, dan fetch atas nilai atomik.