Read–Write Lock Pattern #

Sebagian besar sistem yang kita bangun jauh lebih sering membaca data daripada menulis data. Sebuah config store diperbarui sekali saat startup, tapi dibaca ribuan kali oleh setiap request. Sebuah in-memory cache diisi ulang setiap beberapa menit, tapi di-query setiap milidetik. Jika kita melindungi semua akses dengan sync.Mutex biasa — yang eksklusif untuk semua operasi — kita memaksa ribuan reader antre satu per satu hanya karena sebuah writer sedang bekerja. Ini adalah pemborosan besar: dua reader yang tidak mengubah apapun tidak seharusnya saling menghalangi. Read–Write Lock Pattern memecahkan masalah ini dengan membedakan akses secara fundamental: banyak reader boleh berjalan bersamaan, tapi writer selalu eksklusif. Hasilnya adalah throughput yang jauh lebih tinggi untuk workload yang read-heavy, tanpa mengorbankan konsistensi data sedikit pun.

Apa itu Read–Write Lock? #

Read–Write Lock adalah mekanisme sinkronisasi yang memberlakukan dua aturan sederhana namun powerful:

Kombinasi Boleh Bersamaan? Alasan
Read ↔ Read Ya Membaca tidak mengubah data — aman untuk paralel
Read ↔ Write Tidak Writer mengubah data — reader bisa melihat state yang tidak konsisten
Write ↔ Write Tidak Dua write bersamaan menyebabkan race condition

Di Go, implementasinya sudah tersedia melalui sync.RWMutex — sebuah struct yang menyediakan dua pasang method:

flowchart LR
    subgraph READ["Operasi Baca"]
        RL[RLock] --> RU[RUnlock]
    end
    subgraph WRITE["Operasi Tulis"]
        L[Lock] --> U[Unlock]
    end
    READ -->|banyak goroutine bisa masuk bersamaan| Data[(Shared Data)]
    WRITE -->|hanya satu goroutine, mengblok semua yang lain| Data

Aturan dasarnya: gunakan RLock()/RUnlock() untuk semua operasi yang hanya membaca, dan Lock()/Unlock() untuk semua operasi yang mengubah data. Jika kamu salah pilih — misalnya pakai RLock() untuk operasi write — kamu akan mengalami data race yang sering hanya muncul di production.


Masalah yang Dipecahkan: Mutex Biasa Terlalu Ketat #

Sebelum masuk ke implementasi, penting untuk melihat secara konkret mengapa sync.Mutex biasa tidak cukup untuk workload read-heavy.

// ANTI-PATTERN: Mutex biasa untuk semua operasi — reader saling menghalangi
type SlowCache struct {
	mu   sync.Mutex // ✗ eksklusif untuk semua operasi
	data map[string]string
}

func (c *SlowCache) Get(key string) (string, bool) {
	c.mu.Lock()         // ✗ bahkan read pun harus antre eksklusif
	defer c.mu.Unlock()
	val, ok := c.data[key]
	return val, ok
}

func (c *SlowCache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

// BENAR: RWMutex — banyak reader boleh masuk bersamaan
type FastCache struct {
	mu   sync.RWMutex // ✓ membedakan read dan write
	data map[string]string
}

func (c *FastCache) Get(key string) (string, bool) {
	c.mu.RLock()         // ✓ banyak goroutine bisa RLock bersamaan
	defer c.mu.RUnlock()
	val, ok := c.data[key]
	return val, ok
}

func (c *FastCache) Set(key, value string) {
	c.mu.Lock()         // ✓ write tetap eksklusif
	defer c.mu.Unlock()
	c.data[key] = value
}

Perbedaan performa pada workload 95% read, 5% write bisa sangat signifikan — RWMutex memungkinkan semua goroutine reader berjalan secara paralel, sementara Mutex biasa memaksa mereka antre satu per satu.


In-Memory Cache: Kasus Nyata Paling Umum #

In-memory cache adalah use case paling klasik untuk Read–Write Lock. Cache dibaca sangat sering tapi jarang diperbarui — rasio read:write bisa 1000:1 atau lebih.

package main

import (
	"fmt"
	"sync"
	"time"
)

// Cache adalah thread-safe in-memory key-value store
type Cache struct {
	mu      sync.RWMutex
	data    map[string]cacheEntry
}

type cacheEntry struct {
	value     string
	expiresAt time.Time
}

func NewCache() *Cache {
	return &Cache{
		data: make(map[string]cacheEntry),
	}
}

// Get membaca nilai dari cache — RLock memungkinkan banyak goroutine membaca paralel
func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	entry, ok := c.data[key]
	if !ok {
		return "", false
	}

	// Cek apakah entry sudah kedaluwarsa
	if time.Now().After(entry.expiresAt) {
		return "", false // entry expired, tapi jangan hapus di sini (butuh write lock)
	}

	return entry.value, true
}

// Set menulis nilai ke cache — Lock eksklusif diperlukan
func (c *Cache) Set(key, value string, ttl time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.data[key] = cacheEntry{
		value:     value,
		expiresAt: time.Now().Add(ttl),
	}
}

// Delete menghapus entry dari cache — Lock eksklusif diperlukan
func (c *Cache) Delete(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	delete(c.data, key)
}

// Evict menghapus semua entry yang sudah kedaluwarsa
// Ini adalah operasi write yang lebih berat — Lock eksklusif diperlukan
func (c *Cache) Evict() int {
	c.mu.Lock()
	defer c.mu.Unlock()

	now := time.Now()
	count := 0
	for key, entry := range c.data {
		if now.After(entry.expiresAt) {
			delete(c.data, key)
			count++
		}
	}
	return count
}

// Size mengembalikan jumlah entry — RLock cukup karena hanya membaca
func (c *Cache) Size() int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return len(c.data)
}

func main() {
	cache := NewCache()

	// Tulis beberapa nilai
	cache.Set("user:1", `{"name":"Alice"}`, 5*time.Minute)
	cache.Set("user:2", `{"name":"Bob"}`, 5*time.Minute)
	cache.Set("config:timeout", "30s", 1*time.Hour)

	// Simulasi banyak reader concurrent
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			if val, ok := cache.Get("user:1"); ok {
				fmt.Printf("[Reader %d] user:1 = %s\n", id, val)
			}
		}(i)
	}

	wg.Wait()
	fmt.Printf("Cache size: %d\n", cache.Size())
}

Config Store: Pattern yang Sering Diabaikan #

Config store adalah contoh lain yang sangat cocok untuk RWMutex. Konfigurasi dibaca oleh setiap handler request tapi hanya diperbarui sesekali — saat reload atau update dinamis.

package main

import (
	"fmt"
	"sync"
)

type Config struct {
	mu     sync.RWMutex
	values map[string]interface{}
}

func NewConfig(initial map[string]interface{}) *Config {
	c := &Config{
		values: make(map[string]interface{}),
	}
	for k, v := range initial {
		c.values[k] = v
	}
	return c
}

// Get membaca satu nilai konfigurasi
func (c *Config) Get(key string) (interface{}, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	val, ok := c.values[key]
	return val, ok
}

// GetString adalah helper untuk nilai string
func (c *Config) GetString(key, defaultVal string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	if val, ok := c.values[key]; ok {
		if s, ok := val.(string); ok {
			return s
		}
	}
	return defaultVal
}

// GetInt adalah helper untuk nilai integer
func (c *Config) GetInt(key string, defaultVal int) int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	if val, ok := c.values[key]; ok {
		if i, ok := val.(int); ok {
			return i
		}
	}
	return defaultVal
}

// Reload mengganti seluruh konfigurasi sekaligus — operasi write eksklusif
// Mengganti seluruh map sekaligus (atomic swap) lebih aman daripada update per-key
func (c *Config) Reload(newValues map[string]interface{}) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.values = newValues // ✓ replace seluruh map sekaligus
}

// Update mengubah satu nilai saja
func (c *Config) Update(key string, value interface{}) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.values[key] = value
}

func main() {
	cfg := NewConfig(map[string]interface{}{
		"max_connections": 100,
		"timeout_seconds": 30,
		"environment":     "production",
	})

	// Banyak goroutine membaca config secara bersamaan
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			env := cfg.GetString("environment", "development")
			maxConn := cfg.GetInt("max_connections", 10)
			fmt.Printf("[Worker %d] env=%s, max_conn=%d\n", id, env, maxConn)
		}(i)
	}

	// Simulasi dynamic config reload di goroutine terpisah
	wg.Add(1)
	go func() {
		defer wg.Done()
		cfg.Reload(map[string]interface{}{
			"max_connections": 200,
			"timeout_seconds": 60,
			"environment":     "production",
		})
		fmt.Println("[Config] Reloaded")
	}()

	wg.Wait()
}

Memahami Mekanisme Internal RWMutex #

sync.RWMutex bekerja dengan cara yang lebih kompleks dari sync.Mutex biasa. Memahami mekanismenya membantu menghindari jebakan yang tidak intuitif.

sequenceDiagram
    participant R1 as Reader 1
    participant R2 as Reader 2
    participant W as Writer
    participant RWM as RWMutex

    R1->>RWM: RLock() — masuk (reader count: 1)
    R2->>RWM: RLock() — masuk (reader count: 2)
    W->>RWM: Lock() — menunggu semua reader selesai
    Note over W,RWM: Writer memblokir reader BARU
    R1->>RWM: RUnlock() (reader count: 1)
    R2->>RWM: RUnlock() (reader count: 0)
    RWM-->>W: Writer mendapat lock eksklusif
    W->>RWM: Unlock()
    Note over RWM: Reader baru boleh masuk kembali

Beberapa perilaku penting yang perlu dipahami:

Pertama, writer tidak langsung masuk setelah semua reader selesai jika ada reader baru terus datang. Saat writer sedang menunggu, Go runtime mencegah reader baru masuk untuk menghindari writer starvation — writer dijamin akhirnya mendapat giliran.

Kedua, sync.RWMutex tidak re-entrant. Goroutine yang sudah memegang RLock() tidak bisa memanggil Lock() tanpa melepas RLock() terlebih dahulu — ini akan menyebabkan deadlock.

Ketiga, RLock() tidak gratis. Ada overhead atomic counter untuk melacak jumlah reader aktif. Untuk critical section yang sangat pendek, overhead ini bisa membuat RWMutex lebih lambat dari Mutex biasa.


Masalah Lock Upgrade dan Cara Menghindarinya #

Lock upgrade adalah kesalahan klasik: mencoba mengubah RLock menjadi Lock tanpa melepasnya terlebih dahulu. Ini selalu menyebabkan deadlock.

// ANTI-PATTERN: lock upgrade — menyebabkan deadlock
func (c *Cache) GetOrSet(key, value string) string {
	c.mu.RLock()
	if val, ok := c.data[key]; ok {
		c.mu.RUnlock()
		return val
	}
	// ✗ JANGAN: masih memegang RLock, mencoba Lock
	c.mu.Lock()         // DEADLOCK — Lock menunggu semua RLock selesai,
	                    // termasuk RLock yang dipegang goroutine ini sendiri
	defer c.mu.Unlock()
	c.data[key] = value
	return value
}

// BENAR: lepas RLock sebelum mengambil Lock
func (c *Cache) GetOrSet(key, value string) string {
	// Coba baca dulu dengan RLock
	c.mu.RLock()
	if val, ok := c.data[key]; ok {
		c.mu.RUnlock() // ✓ lepas RLock sebelum return
		return val
	}
	c.mu.RUnlock() // ✓ lepas RLock sebelum mengambil Lock

	// Ambil write lock untuk menulis
	c.mu.Lock()
	defer c.mu.Unlock()

	// Double-check setelah mendapat write lock
	// (goroutine lain mungkin sudah menulis saat kita menunggu Lock)
	if val, ok := c.data[key]; ok {
		return val // ✓ goroutine lain sudah mengisi, gunakan nilainya
	}

	c.data[key] = value
	return value
}
Selalu lepas RLock() sebelum memanggil Lock() — tidak ada cara untuk “mengupgrade” read lock menjadi write lock secara atomik di Go. Mencoba melakukannya tanpa melepas RLock terlebih dahulu akan menyebabkan deadlock yang sering hanya terjadi di bawah load tinggi dan sangat sulit di-debug. Perhatikan juga pola double-check setelah mendapat write lock — goroutine lain mungkin sudah mengisi data selama kamu menunggu.

RWMutex vs Mutex vs sync.Map #

Tidak semua situasi membutuhkan RWMutex. Memilih primitif yang tepat adalah keputusan yang penting.

Kriteria sync.Mutex sync.RWMutex sync.Map
Semua operasi Eksklusif Read paralel, write eksklusif Optimized untuk concurrent
Cocok untuk Write-heavy atau seimbang Read-heavy (>70% read) Key stabil, banyak goroutine
Overhead Rendah Sedang (atomic counter reader) Lebih tinggi (internal sharding)
API Manual lock/unlock Manual lock/unlock Method Store/Load/Delete
Type safety Ya (generics tersedia) Ya (generics tersedia) Tidak (interface{})
Lock granularity Per-struct Per-struct Per-key (internal)
flowchart TD
    Q{Akses ke\nshared data?} --> Q1{Seberapa sering\nwrite terjadi?}
    Q1 -->|Sering / seimbang\ndengan read| MU[sync.Mutex]
    Q1 -->|Jarang\nread-heavy > 70%| Q2{Kunci banyak\ngoroutine berbeda?}
    Q2 -->|Key set stabil\nbanyak goroutine| SM[sync.Map]
    Q2 -->|Satu struct\ndengan banyak field| RW[sync.RWMutex]
    MU --> Note1[Lebih sederhana,\nlebih predictable]
    SM --> Note2[Tidak perlu\nmanual lock]
    RW --> Note3[Throughput tinggi\nuntuk read paralel]

Kapan Tidak Menggunakan RWMutex #

Tetap gunakan sync.RWMutex jika:
  ✓ Read mendominasi — rasio read:write 70:30 atau lebih
  ✓ Critical section cukup panjang sehingga overhead RLock terbayar
  ✓ Banyak goroutine reader yang saling tidak bergantung
  ✓ Data berupa struct atau map yang di-update periodik

Pertimbangkan sync.Mutex jika:
  ✗ Write sama seringnya dengan read — RWMutex tidak memberi keuntungan
  ✗ Critical section sangat pendek (< beberapa instruksi) — overhead RLock lebih mahal
  ✗ Kamu tidak yakin dengan rasio read:write — Mutex lebih aman dan lebih mudah di-reason

Pertimbangkan sync.Map jika:
  ✗ Key set relatif stabil dan diakses oleh banyak goroutine yang berbeda
  ✗ Tidak butuh type safety — API sync.Map menggunakan interface{}
  ✗ Beban baca sangat tinggi dengan goroutine yang sangat banyak

Anti-Pattern yang Harus Dihindari #

// ✗ Menggunakan RLock untuk operasi write — data race
func (c *Cache) BuggySet(key, value string) {
	c.mu.RLock()        // ✗ ini adalah read lock, bukan write lock
	defer c.mu.RUnlock()
	c.data[key] = value // ✗ write dengan read lock = data race
}

// ✓ Selalu gunakan Lock() untuk operasi write
func (c *Cache) SafeSet(key, value string) {
	c.mu.Lock()         // ✓ write lock
	defer c.mu.Unlock()
	c.data[key] = value
}

// ✗ Operasi di luar lock — data tidak terlindungi
func (c *Cache) BuggyGet(key string) string {
	c.mu.RLock()
	val := c.data[key]
	c.mu.RUnlock()
	return val + "-suffix" // ✓ ini aman — transformasi setelah unlock tidak masalah
	// ✗ yang tidak aman adalah MENGAKSES c.data setelah RUnlock
}

// ✗ Menyimpan referensi ke data yang dilindungi di luar lock
func (c *Cache) BuggyRef(key string) *string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	val := c.data[key]
	return &val // ✗ jika val adalah pointer ke data internal, bisa di-race setelah unlock
}

// ✓ Kembalikan salinan nilai, bukan referensi ke data internal
func (c *Cache) SafeGet(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	val, ok := c.data[key]
	return val, ok // ✓ string adalah value type — ini adalah salinan yang aman
}

// ✗ Lupa unlock dalam semua path — goroutine lain akan deadlock
func (c *Cache) ForgetfulGet(key string) string {
	c.mu.RLock()
	val, ok := c.data[key]
	if !ok {
		return "" // ✗ lupa c.mu.RUnlock() sebelum return
	}
	c.mu.RUnlock()
	return val
}

// ✓ Selalu gunakan defer untuk memastikan unlock terjadi
func (c *Cache) DeferredGet(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock() // ✓ defer memastikan unlock di semua path return
	val := c.data[key]
	return val
}

// ✗ Operasi berat di dalam critical section — memblokir semua reader/writer
func (c *Cache) HeavyCompute(key string) string {
	c.mu.Lock()
	defer c.mu.Unlock()
	val := c.data[key]
	result := expensiveTransformation(val) // ✗ komputasi berat di dalam lock
	c.data[key] = result
	return result
}

// ✓ Ambil data, lakukan komputasi di luar lock, lalu simpan hasilnya
func (c *Cache) LightLock(key string) string {
	c.mu.RLock()
	val := c.data[key]
	c.mu.RUnlock() // ✓ lepas lock sebelum komputasi berat

	result := expensiveTransformation(val) // komputasi di luar lock

	c.mu.Lock()
	c.data[key] = result // ✓ lock hanya untuk operasi write yang cepat
	c.mu.Unlock()
	return result
}

Checklist Review Read–Write Lock #

PEMILIHAN LOCK:
  □ Operasi read menggunakan RLock()/RUnlock()
  □ Operasi write menggunakan Lock()/Unlock()
  □ Pilihan RWMutex didasarkan pada rasio read:write yang terukur
  □ Pertimbangan sync.Map atau sync.Mutex untuk kasus yang tidak read-heavy

PENGGUNAAN LOCK:
  □ defer selalu digunakan untuk memastikan unlock di semua path
  □ Critical section sekecil mungkin — tidak ada operasi berat di dalam lock
  □ Tidak ada lock upgrade (RLock → Lock tanpa lepas RLock lebih dulu)
  □ Double-check setelah mendapat write lock pada pola check-then-act

KEAMANAN DATA:
  □ Semua akses ke shared data melalui mekanisme lock yang sama
  □ Nilai dikembalikan sebagai salinan (value type), bukan referensi ke data internal
  □ Tidak ada referensi ke data yang dilindungi yang dibawa keluar lock

PENGUJIAN:
  □ Diuji dengan go test -race untuk mendeteksi data race
  □ Load test dengan rasio read:write yang representatif
  □ Benchmark dibandingkan dengan sync.Mutex untuk memverifikasi keuntungan RWMutex

Ringkasan #

  • Read–Write Lock memungkinkan banyak reader berjalan paralel — hanya writer yang membutuhkan akses eksklusif; ini adalah sumber keunggulan performanya dibanding Mutex biasa.
  • Tiga aturan utama — read + read boleh bersamaan; read + write tidak boleh; write + write tidak boleh.
  • Gunakan RLock()/RUnlock() untuk semua operasi baca dan Lock()/Unlock() untuk semua operasi tulis — salah pilih akan menyebabkan data race yang sering hanya muncul di production.
  • Selalu gunakan defer untuk unlock — ini memastikan unlock terjadi di semua code path, termasuk yang return di tengah fungsi.
  • Jaga critical section sekecil mungkin — komputasi berat sebaiknya dilakukan di luar lock; ambil data, lepas lock, proses, lalu lock lagi untuk menyimpan hasilnya.
  • Lock upgrade adalah deadlock — jika perlu mengubah dari RLock ke Lock, lepas RLock terlebih dahulu dan lakukan double-check setelah mendapat Lock.
  • sync.RWMutex tidak re-entrant — goroutine yang sudah memegang RLock tidak bisa memanggil Lock tanpa deadlock.
  • RWMutex lebih baik dari Mutex hanya jika read mendominasi — jika write sering terjadi, overhead atomic counter di RWMutex bisa membuat performanya lebih buruk dari Mutex biasa.
  • sync.Map adalah alternatif untuk key set yang stabil dan diakses oleh banyak goroutine yang berbeda — tapi tidak type-safe dan overhead-nya lebih tinggi.
  • Verifikasi dengan go test -race dan benchmark dengan rasio read:write yang realistis — keputusan memilih RWMutex harus didasarkan pada data, bukan asumsi.

← Sebelumnya: Fork–Join   Berikutnya: Double-Checked Locking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact