sync.Mutex dan RWMutex

Melindungi state bersama versus kepemilikan lewat channel.

Daftar isi

State bersama, yaitu data yang dibaca dan ditulis oleh lebih dari satu goroutine, memerlukan aturan akses yang tegas agar tidak rusak. Topik ini membahas sync.Mutex sebagai kunci eksklusif dan sync.RWMutex sebagai kunci yang membedakan pembaca dari penulis, lalu membandingkan pendekatan tersebut dengan kepemilikan data lewat channel, yaitu saluran pengiriman nilai antargoroutine. Setelah membaca topik ini, pemilihan antara mengunci state bersama dan menyerahkan kepemilikan data dapat dilakukan secara sadar beserta alasannya.

Mutex dan bagian kritis#

Menambah satu pada sebuah bilangan bukanlah satu langkah tunggal, melainkan tiga langkah, yaitu membaca nilai, menghitung hasilnya, dan menulis kembali. Dua goroutine yang menjalankan rangkaian itu bersamaan dapat saling menimpa sehingga sebagian penambahan hilang. sync.Mutex menutup celah ini dengan kunci eksklusif, yaitu hanya satu goroutine yang boleh memegang kunci pada satu waktu, sedangkan goroutine lain menunggu di Lock hingga kunci dilepas dengan Unlock. Rentang kode di antara keduanya disebut critical section, yaitu bagian yang hanya boleh dijalankan satu goroutine pada satu waktu. Program berikut mencatat penghitung pengunjung perpustakaan dari empat loket.

go penghitung-pengunjung.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		mu         sync.Mutex
		pengunjung int
		wg         sync.WaitGroup
	)

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

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

Hasil akhirnya selalu 400 karena setiap penambahan terjadi di dalam critical section yang tidak dapat dijamah goroutine lain. Tanpa kunci, nilai akhirnya berpindah-pindah di bawah 400 karena sebagian penambahan saling menimpa. Alat go run -race melaporkan kejadian itu sebagai data race, yaitu dua goroutine mengakses memori yang sama secara bersamaan, dan salah satunya menulis. Angka 400 sendiri berasal dari empat loket yang masing-masing menambah 100 pengunjung, sehingga totalnya dapat dihitung ulang dari kode tanpa menjalankannya.

Zero value dari Mutex sudah siap dipakai, sehingga deklarasi var mu sync.Mutex di atas tidak memerlukan konstruktor. Untuk state yang lebih besar, pola yang lazim adalah menyematkan Mutex sebagai field struct yang melindungi field-field di sekitarnya, sebagaimana field mu pada struct cachePengaturan di bawah. Alamat struct itulah yang dipakai di seluruh program, sehingga semua goroutine berbagi kunci yang sama.

bash
mkdir -p /tmp/go-mutex
cp penghitung-pengunjung.go cache-pengaturan.go /tmp/go-mutex/
cd /tmp/go-mutex
go run penghitung-pengunjung.go
text Keluaran
total pengunjung tercatat: 400

RWMutex untuk pembaca dan penulis#

Banyak state bersama lebih sering dibaca daripada ditulis, misalnya cache pengaturan yang diisi sekali lalu ditanyakan terus-menerus. Kunci eksklusif membuat pembaca saling menunggu padahal membaca secara bersamaan tidak saling merusak. sync.RWMutex menyediakan dua macam kunci, yaitu RLock yang boleh dipegang banyak pembaca sekaligus dan Lock yang eksklusif seperti mutex biasa. Program berikut mengisi cache pengaturan terlebih dahulu, lalu membiarkan empat goroutine membacanya bersamaan.

go cache-pengaturan.go
package main

import (
	"fmt"
	"sync"
)

// cachePengaturan menyimpan pasangan kunci dan nilai yang dibaca banyak
// goroutine sekaligus, tetapi ditulis oleh satu goroutine pada satu waktu.
type cachePengaturan struct {
	mu   sync.RWMutex
	data map[string]string
}

func (c *cachePengaturan) simpan(kunci, nilai string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[kunci] = nilai
}

func (c *cachePengaturan) baca(kunci string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.data[kunci]
}

func main() {
	cache := &cachePengaturan{data: make(map[string]string)}
	cache.simpan("tema", "gelap")
	cache.simpan("bahasa", "id")

	kunciDicari := []string{"tema", "bahasa", "tema", "bahasa"}
	hasil := make([]string, len(kunciDicari))

	var wg sync.WaitGroup
	for i, kunci := range kunciDicari {
		wg.Add(1)
		go func(slot int, kunci string) {
			defer wg.Done()
			hasil[slot] = cache.baca(kunci)
		}(i, kunci)
	}
	wg.Wait()

	for i, kunci := range kunciDicari {
		fmt.Printf("pengaturan %s: %s\n", kunci, hasil[i])
	}
}
text Keluaran
pengaturan tema: gelap
pengaturan bahasa: id
pengaturan tema: gelap
pengaturan bahasa: id

Keempat pembaca memegang RLock pada waktu yang bertumpuk tanpa saling menunggu, sedangkan penulisan lewat simpan tetap eksklusif sehingga tidak ada pembaca yang melihat keadaan setengah selesai. Hasil pembacaan disimpan pada slot yang berbeda lalu dicetak dari main, dan urutan keluarannya kembali tetap. Perlu dicatat bahwa RWMutex bukanlah pengganti gratis, karena pembukuan dua macam kunci membuatnya lebih lambat daripada Mutex biasa pada beban penulisan yang tinggi. Keunggulannya muncul ketika pembaca jauh lebih banyak daripada penulis.

bash
cd /tmp/go-mutex
go run cache-pengaturan.go
text Keluaran
pengaturan tema: gelap
pengaturan bahasa: id
pengaturan tema: gelap
pengaturan bahasa: id

Melindungi state atau menyerahkan kepemilikan#

Ada dua disiplin yang sama-sama menutup race condition, dan pilihan di antara keduanya merupakan keputusan desain. Disiplin pertama menempatkan kunci di sekitar state bersama, sebagaimana kedua program di atas, sehingga banyak goroutine berbagi data yang sama secara bergantian. Disiplin kedua menyerahkan kepemilikan data kepada satu goroutine, sedangkan goroutine lain mengirim permintaan lewat channel dan tidak pernah menyentuh data itu sendiri.

go Kepemilikan cache diserahkan kepada satu goroutine
// tidak-dijalankan
// seluruh akses peta hanya terjadi di dalam goroutine pemilik
permintaan := make(chan permintaanCache)
go pemilikCache(permintaan)

// goroutine lain mengirim permintaan dan membaca jawabannya,
// peta itu sendiri tidak pernah disentuh dari luar
permintaan <- permintaanCache{kunci: "tema", nilai: "gelap"}

Pada pola kedua tidak ada kunci sama sekali, karena data itu pada setiap saat hanya dipegang satu goroutine dan berpindah kepemilikan lewat channel. Pola ini cocok ketika state memiliki siklus yang jelas, misalnya antrean pekerjaan atau koneksi yang harus dilayani berurutan. Untuk state sederhana seperti penghitung dan cache kunci-nilai, kunci biasanya lebih ringkas, sedangkan pemisahan kepemilikan menguntungkan ketika logika aksesnya bertambah rumit. Kedua disiplin juga dapat dipadukan pada program yang sama selama batas antarkeduanya tetap jelas.

Kesalahan umum#

  • Menyalin struct yang memuat Mutex. Salinan membawa kunci sendiri sehingga perlindungan tidak berlaku dan state yang sama dapat diakses tanpa koordinasi. Perbaikannya, gunakan alamat struct tersebut di semua tempat dan biarkan pemeriksaan copylocks milik go vet menangkap penyalinannya.
  • Keluar lebih awal tanpa melepas kunci. Pernyataan return di tengah critical section membuat goroutine lain menunggu selamanya. Perbaikannya, jadwalkan defer mu.Unlock() tepat setelah Lock.
  • Memperlebar critical section lebih dari perlu. Pemanggilan jaringan atau penulisan berkas di dalam kunci membuat goroutine lain mengantre tanpa alasan. Perbaikannya, hitung dulu di luar kunci, lalu kunci hanya dipakai untuk menyalin nilai ke state bersama.
  • Memanggil Lock sementara masih memegang RLock pada goroutine yang sama. Kombinasi ini memicu deadlock yang dinyatakan secara eksplisit pada dokumentasi sync.RWMutex. Perbaikannya, lepaskan RLock terlebih dahulu, atau pisahkan pembacaan dan penulisan menjadi dua tahap.

Ringkasan#

  • sync.Mutex memberi akses eksklusif pada critical section sehingga rangkaian baca, ubah, dan tulis tidak saling menimpa.
  • sync.RWMutex membedakan RLock yang boleh ditumpuk antarpembaca dari Lock yang eksklusif.
  • Alternatif tanpa kunci adalah menyerahkan kepemilikan data kepada satu goroutine yang melayani permintaan lewat channel.
  • Kunci diteruskan sebagai alamat, dilepas dengan defer, dan lebar critical sectionnya dijaga seminimal mungkin.

Referensi#