sync.Once dan atomic

Inisialisasi sekali dan penghitung tanpa lock.

Daftar isi

Inisialisasi yang mahal sebaiknya berjalan tepat satu kali, sedangkan penghitung sederhana tidak selalu membutuhkan kunci. Topik ini membahas dua alat untuk kedua kebutuhan itu, yaitu sync.Once yang menjalankan satu fungsi hanya pada pemanggilan pertama dan paket sync/atomic yang menyediakan operasi atomik, yakni operasi yang berjalan sebagai satu langkah tak terputus. Setelah membacanya, pemilihan antara kunci, Once, dan operasi atomik dapat ditentukan sesuai bentuk state yang dilindungi, lengkap dengan batasan masing-masing.

sync.Once dan inisialisasi satu kali#

Konfigurasi yang dibaca dari berkas atau sumber jaringan lazimnya dimuat sekali dan dipakai bersama. Tantangannya muncul ketika beberapa goroutine mencapai titik pemuatan itu hampir bersamaan, karena tanpa alat khusus fungsi pemuatan dapat berjalan lebih dari satu kali. sync.Once menyelesaikan persoalan tersebut melalui metode Do yang menerima satu fungsi dan menjamin fungsi itu berjalan tepat satu kali sepanjang masa hidup objek Once. Pemanggil lain menunggu hingga fungsi pertama selesai, lalu seluruh pemanggil melihat hasil yang sama. Program berikut meniru pemuatan pengaturan yang dipanggil empat goroutine sekaligus, dan penghitungnya membuktikan jumlah eksekusi yang terjadi.

go muat-sekali.go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

var (
	pengaturan map[string]string
	sekali     sync.Once
	jumlahMuat atomic.Int32
)

// muatPengaturan meniru pembacaan konfigurasi yang mahal dan hanya boleh
// berjalan satu kali meskipun dipanggil dari banyak goroutine.
func muatPengaturan() map[string]string {
	sekali.Do(func() {
		jumlahMuat.Add(1)
		pengaturan = map[string]string{
			"bahasa": "id",
			"tema":   "gelap",
		}
	})
	return pengaturan
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			muatPengaturan()
		}()
	}
	wg.Wait()

	fmt.Println("jumlah pemuatan:", jumlahMuat.Load())
	fmt.Println("bahasa:", muatPengaturan()["bahasa"])
}
text Keluaran
jumlah pemuatan: 1
bahasa: id

Penghitung menunjukkan angka 1 pada setiap eksekusi, meskipun empat goroutine memanggil muatPengaturan hampir bersamaan. Fungsi yang diberikan kepada Do hanya berjalan pada pemanggil pertama, sedangkan tiga pemanggil lain menunggu lalu menerima peta yang sudah terisi. Sejak Go 1.21 terdapat pula sync.OnceFunc, sync.OnceValue, dan sync.OnceValues yang membungkus pola ini menjadi satu fungsi siap pakai, sehingga variabel Once tidak perlu ditulis manual untuk kasus yang sederhana.

Zero value dari sync.Once juga sudah siap dipakai, sehingga variabelnya cukup dideklarasikan tanpa konstruktor. Metode Do hanya kembali setelah fungsi yang diberikan selesai dijalankan, sehingga pemanggil yang datang belakangan sudah melihat hasilnya utuh. Penempatan variabel Once perlu dijaga agar berumur panjang, misalnya sebagai variabel tingkat paket atau field struct yang hidup selama aplikasi berjalan, karena Once yang dibuat ulang akan menjalankan fungsinya sekali lagi.

bash
mkdir -p /tmp/go-once-atomic
cp muat-sekali.go penghitung-atomic.go /tmp/go-once-atomic/
cd /tmp/go-once-atomic
go run muat-sekali.go
text Keluaran
jumlah pemuatan: 1
bahasa: id

Operasi atomik tanpa kunci#

Paket sync/atomic menyediakan operasi yang berjalan sebagai satu langkah tak terputus, sehingga goroutine lain tidak pernah melihat keadaan setengah selesai. Untuk satu bilangan bulat yang hanya ditambah dan dibaca, operasi ini lebih murah daripada kunci karena tidak ada antrean goroutine yang harus dikelola. Tipe seperti atomic.Int64 menyediakan metode Add, Load, Store, Swap, dan CompareAndSwap sesuai kebutuhan. Program berikut menghitung pengunjung perpustakaan yang sama tanpa satu pun kunci.

go penghitung-atomic.go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var (
		pengunjung atomic.Int64
		wg         sync.WaitGroup
	)

	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for n := 0; n < 100; n++ {
				pengunjung.Add(1)
			}
		}()
	}

	wg.Wait()
	fmt.Println("total pengunjung tercatat:", pengunjung.Load())
}
text Keluaran
total pengunjung tercatat: 400

Nilai akhirnya kembali selalu 400 karena Add berjalan sebagai satu langkah yang tidak dapat diselipkan. Pembanding yang menarik adalah versi tanpa sinkronisasi pada potongan berikut, yang keluarannya sengaja tidak ditampilkan karena memang berubah-ubah antareksekusi.

go Penghitung tanpa sinkronisasi yang hasilnya berubah-ubah
// tidak-dijalankan
var (
	pengunjung int64
	wg         sync.WaitGroup
)

for i := 0; i < 4; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for n := 0; n < 100; n++ {
			pengunjung++ // race condition: baca, tambah, dan tulis tanpa kunci
		}
	}()
}
wg.Wait()
fmt.Println(pengunjung) // bisa mencetak 400, bisa juga angka di bawahnya

Operasi pengunjung++ tetap tersusun atas tiga langkah, sehingga dua goroutine dapat membaca nilai yang sama lalu menulis hasil yang sama dan satu penambahan pun hilang. Keluarannya berpindah-pindah antareksekusi, dan go run -race melaporkan data race pada baris tersebut. Program sebelumnya menghindari hal ini karena Add menggabungkan tiga langkah itu menjadi satu operasi atomik. Sebelum Go 1.19, operasi atomik pada field struct di arsitektur 32-bit menuntut pengaturan perataan memori secara manual, sedangkan tipe atomic.Int64 beserta saudaranya mengurus perataan itu sendiri pada semua arsitektur sehingga cukup ditulis langsung.

Memilih antara kunci, Once, dan atomic#

Aturan pemilihannya bertumpu pada bentuk state yang dilindungi. Operasi atomik menjaga satu nilai pada satu waktu. Dua penghitung yang harus naik bersama tidak dapat dijaga dengan dua operasi atomik terpisah, karena pembaca dapat melihat keduanya dalam keadaan tak sepadan. Kombinasi semacam itulah yang menuntut kunci. Sebaliknya, memasang Mutex untuk penghitung tunggal bukanlah kesalahan, hanya saja lebih berat daripada yang diperlukan. Sebuah Once juga tidak menggantikan kunci karena tugasnya hanya pada pemanggilan pertama, sedangkan akses sesudahnya tetap memerlukan perlindungan.

KebutuhanAlat yang tepat
Fungsi inisialisasi yang berjalan satu kalisync.Once
Satu bilangan bulat atau pointer yang ditambah dan dibacasync/atomic
Beberapa field yang harus berubah bersamaansync.Mutex
Banyak pembaca dengan sedikit penulissync.RWMutex

Alat-alat ini juga dapat bekerja berdampingan pada program yang sama, misalnya Once untuk pemuatan awal, atomic untuk penghitung operasi, dan Mutex untuk cache yang berubah dari waktu ke waktu. Pembagian peran seperti itu justru lebih mudah dibaca daripada satu kunci besar yang mengawasi seluruh state. Penilaian akhirnya tetap kembali ke bentuk state dan pola aksesnya, bukan ke kebiasaan menyalin kode lama.

Kesalahan umum#

  • Mengandalkan operasi atomik untuk beberapa field yang harus konsisten bersama. Nilai-nilai itu tetap dapat terbaca dalam keadaan tak cocok meskipun masing-masing benar. Perbaikannya, kunci seluruh kelompok field dengan Mutex.
  • Membaca variabel atomik dengan penugasan biasa. Pembacaan tanpa Load tidak dijamin melihat nilai terbaru dan detektor race melaporkannya. Perbaikannya, seluruh akses termasuk yang hanya membaca tetap melalui metode paket atomic.
  • Menganggap fungsi pada Do berjalan ulang setelah gagal. Fungsi yang panic tetap dianggap selesai, sehingga pemanggilan berikutnya tidak mengulanginya. Perbaikannya, tangani kemungkinan gagal di dalam fungsi itu sendiri sebelum hasilnya diumumkan.

Ringkasan#

  • sync.Once menjalankan satu fungsi tepat satu kali dan membuat pemanggil lain menunggu hasilnya.
  • sync/atomic menyediakan Add, Load, Store, Swap, dan CompareAndSwap untuk satu nilai tanpa kunci.
  • Operasi atomik menutup race condition pada satu nilai, sedangkan kunci tetap diperlukan untuk beberapa field yang harus konsisten bersama.
  • sync.OnceFunc beserta saudaranya membungkus pola Once menjadi fungsi siap pakai sejak Go 1.21.

Referensi#