Double-Checked Locking Pattern #

Ada sebuah pertanyaan yang sering muncul saat membangun sistem concurrent: bagaimana cara membuat sebuah objek mahal — koneksi database, HTTP client, atau konfigurasi global — hanya satu kali, meski ratusan goroutine mencoba mengaksesnya secara bersamaan? Solusi naif adalah memasang mutex di setiap akses, tapi ini berarti setiap request harus mengantri untuk mendapat lock bahkan setelah objek sudah lama ada. Solusi lainnya adalah menginisialisasi di main() sebelum goroutine manapun diluncurkan, tapi ini tidak selalu bisa dilakukan — kadang kita butuh lazy initialization, membuat objek hanya saat pertama kali benar-benar dibutuhkan. Double-Checked Locking (DCL) adalah pola klasik untuk masalah ini: cek apakah objek sudah ada tanpa lock, dan hanya ambil lock jika objek belum ada. Dua pengecekan — satu di luar lock, satu di dalam — inilah yang memberi pola ini namanya. Di Go, memahami DCL adalah memahami mengapa sync.Once ada dan apa yang sebenarnya ia lakukan.

Apa itu Double-Checked Locking? #

Double-Checked Locking adalah pola lazy initialization yang meminimalkan penggunaan mutex dengan melakukan dua pemeriksaan kondisi: satu sebelum mengambil lock (fast path) dan satu setelah mengambil lock (safe path).

flowchart TD
    A([Goroutine memanggil GetInstance]) --> B{Pengecekan 1:\ninstance == nil?}
    B -- Tidak --> Z([Kembalikan instance])
    B -- Ya --> C[Ambil mutex.Lock]
    C --> D{Pengecekan 2:\ninstance == nil?}
    D -- Tidak --> E[Lepas lock\nkembalikan instance]
    D -- Ya --> F[Buat instance baru]
    F --> G[Lepas lock]
    G --> Z

Empat langkah yang selalu hadir dalam DCL:

Langkah Operasi Tujuan
1 Cek tanpa lock Lewati lock jika sudah terinisialisasi (fast path)
2 Ambil lock Masuk ke zona aman untuk pengecekan kedua
3 Cek ulang dengan lock Pastikan goroutine lain belum menginisialisasi
4 Inisialisasi Buat instance — hanya goroutine ini yang menjalankan ini

Pengecekan kedua (langkah 3) adalah kunci dari pola ini. Tanpanya, dua goroutine yang sama-sama lolos dari pengecekan pertama bisa keduanya membuat instance — mengakibatkan inisialisasi ganda.


Mengapa Pengecekan Tanpa Lock Berbahaya #

Sebelum masuk ke implementasi yang benar, penting untuk memahami mengapa DCL yang terlihat benar bisa menjadi jebakan tersembunyi di Go.

// SANGAT BERBAHAYA: pengecekan pertama tanpa sinkronisasi apapun
// Ini adalah data race — undefined behavior di Go memory model
var instance *Database

func GetDatabase() *Database {
    if instance == nil {                // ✗ baca tanpa sinkronisasi
        mu.Lock()
        defer mu.Unlock()
        if instance == nil {
            instance = &Database{}      // ✗ tulis tanpa sinkronisasi
            instance.Connect()          // ✗ pointer bisa terlihat sebelum Connect() selesai
        }
    }
    return instance
}

Ada dua masalah fundamental di sini. Pertama, data race: membaca instance tanpa sinkronisasi sementara goroutine lain mungkin sedang menulis ke dalamnya adalah data race — perilaku yang undefined menurut Go memory model. go test -race akan langsung mendeteksi ini.

Kedua, instruction reordering: compiler dan CPU diizinkan untuk mereorder instruksi selama hasilnya konsisten dari satu goroutine. Ini berarti instance = &Database{} bisa terlihat oleh goroutine lain sebelum konstruktor objek selesai dijalankan — goroutine yang lolos dari pengecekan pertama mungkin mendapat pointer ke objek yang setengah terinisialisasi.

Di Java sebelum versi 5, DCL terkenal sebagai pola yang tidak bisa diimplementasikan dengan benar. Di Go, solusinya tersedia melalui sync.Once dan atomic.


Implementasi DCL yang Benar dengan Mutex #

Jika kamu benar-benar perlu mengimplementasikan DCL secara manual (bukan menggunakan sync.Once), pengecekan pertama harus menggunakan atomic.LoadPointer atau atomic.Pointer — bukan pembacaan langsung.

package main

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

type Config struct {
	AppName  string
	MaxConns int
}

var (
	configPtr atomic.Pointer[Config] // ✓ atomic pointer — aman untuk dibaca tanpa lock
	configMu  sync.Mutex
)

// GetConfig mengimplementasikan DCL yang benar dengan atomic
func GetConfig() *Config {
	// Pengecekan 1: baca atomically — tidak ada data race
	if cfg := configPtr.Load(); cfg != nil {
		return cfg // fast path: sudah terinisialisasi, tidak perlu lock sama sekali
	}

	// Pengecekan 2: ambil lock, cek ulang
	configMu.Lock()
	defer configMu.Unlock()

	// Double-check: goroutine lain mungkin sudah menginisialisasi saat kita menunggu lock
	if cfg := configPtr.Load(); cfg != nil {
		return cfg
	}

	// Hanya satu goroutine yang sampai di sini
	cfg := &Config{
		AppName:  "MyApp",
		MaxConns: 100,
	}
	configPtr.Store(cfg) // ✓ store atomically — visible ke semua goroutine
	return cfg
}

// Contoh dengan tipe apapun menggunakan unsafe.Pointer (Go < 1.19)
// atomic.Pointer[T] adalah cara yang lebih bersih di Go 1.19+
var (
	legacyInstance unsafe.Pointer
	legacyMu       sync.Mutex
)

type LegacyService struct{ name string }

func GetLegacyService() *LegacyService {
	// atomic load — aman dari data race
	if p := atomic.LoadPointer(&legacyInstance); p != nil {
		return (*LegacyService)(p)
	}

	legacyMu.Lock()
	defer legacyMu.Unlock()

	if p := atomic.LoadPointer(&legacyInstance); p != nil {
		return (*LegacyService)(p)
	}

	svc := &LegacyService{name: "legacy"}
	atomic.StorePointer(&legacyInstance, unsafe.Pointer(svc))
	return svc
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			cfg := GetConfig()
			fmt.Printf("[Goroutine %d] AppName: %s\n", id, cfg.AppName)
		}(i)
	}
	wg.Wait()
}

Perbedaan kritis antara implementasi yang benar dan yang salah:

flowchart LR
    subgraph WRONG["❌ Implementasi Salah"]
        W1["if instance == nil"] -->|membaca langsung| W2["DATA RACE\nundefined behavior"]
    end
    subgraph RIGHT["✓ Implementasi Benar"]
        R1["if ptr := configPtr.Load()"] -->|atomic load| R2["Aman — semua goroutine\nmelihat nilai yang sama"]
    end

Solusi Idiomatik: sync.Once #

Setelah melihat kompleksitas DCL yang benar, pertanyaan wajar yang muncul adalah: apakah ada cara yang lebih sederhana? Jawabannya adalah sync.Once — dan ini adalah solusi yang selalu direkomendasikan di Go.

package main

import (
	"fmt"
	"sync"
)

type Database struct {
	dsn  string
	conn interface{} // simulasi koneksi
}

func (d *Database) Connect() {
	fmt.Printf("[Database] Connecting to %s\n", d.dsn)
	// simulasi proses koneksi yang mahal
}

var (
	db   *Database
	once sync.Once
)

// GetDatabase menggunakan sync.Once — idiomatik, aman, sederhana
func GetDatabase() *Database {
	once.Do(func() {
		// Blok ini DIJAMIN hanya dieksekusi sekali,
		// bahkan jika banyak goroutine memanggil GetDatabase() bersamaan
		db = &Database{dsn: "postgres://localhost:5432/mydb"}
		db.Connect()
		fmt.Println("[Database] Initialized")
	})
	return db
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			d := GetDatabase()
			fmt.Printf("[Worker %d] Got db: %p\n", id, d)
		}(i)
	}
	wg.Wait()
}

Mengapa sync.Once lebih baik dari DCL manual:

Aspek DCL Manual sync.Once
Keamanan Perlu atomic.Pointer agar aman Selalu aman — dijamin oleh runtime
Kesederhanaan Kompleks — mudah salah implementasi Sangat sederhana
Keterbacaan Membutuhkan penjelasan ekstra Self-documenting
Error prone Tinggi — banyak variasi yang salah Rendah
Overhead Minimal setelah inisialisasi Minimal — satu atomic check
Dokumentasi intent Tidak jelas Jelas: “lakukan ini sekali”

sync.Once secara internal menggunakan atomic operations yang sama dengan DCL yang benar, tapi dibungkus dalam API yang tidak bisa disalahgunakan. Ini adalah contoh Go philosophy yang sangat baik: make the right thing easy and the wrong thing hard.


Kasus Lanjutan: sync.Once dengan Error Handling #

Satu kelemahan sync.Once standar adalah tidak ada cara bawaan untuk menangani error selama inisialisasi — jika inisialisasi gagal, once.Do() tetap menandai pekerjaan sebagai “selesai” dan tidak akan mencoba lagi.

package main

import (
	"errors"
	"fmt"
	"sync"
)

// OnceWithError adalah wrapper sync.Once yang menyimpan error inisialisasi
type OnceWithError struct {
	once sync.Once
	val  interface{}
	err  error
}

// Do menjalankan f tepat satu kali dan menyimpan hasilnya
func (o *OnceWithError) Do(f func() (interface{}, error)) (interface{}, error) {
	o.once.Do(func() {
		o.val, o.err = f()
	})
	return o.val, o.err
}

// Contoh: inisialisasi database yang bisa gagal
type DBPool struct {
	maxConns int
}

var (
	poolOnce OnceWithError
	pool     *DBPool
)

func GetPool() (*DBPool, error) {
	val, err := poolOnce.Do(func() (interface{}, error) {
		fmt.Println("[Pool] Mencoba menginisialisasi...")
		// Simulasi kegagalan pada inisialisasi pertama
		if pool == nil {
			// Misalkan koneksi gagal
			return nil, errors.New("gagal konek ke database: connection refused")
		}
		return pool, nil
	})

	if err != nil {
		return nil, err
	}
	return val.(*DBPool), nil
}

// OnceRetry: coba ulang jika inisialisasi gagal
// Ini menghindari "poisoning" once dengan error sementara
type OnceRetry struct {
	mu   sync.Mutex
	done uint32
	val  interface{}
}

func (o *OnceRetry) Do(f func() (interface{}, error)) (interface{}, error) {
	if atomic_load(&o.done) == 1 {
		return o.val, nil // fast path
	}

	o.mu.Lock()
	defer o.mu.Unlock()

	if o.done == 1 {
		return o.val, nil
	}

	val, err := f()
	if err != nil {
		return nil, err // ✓ tidak menandai done — akan dicoba ulang
	}

	o.val = val
	atomic_store(&o.done, 1)
	return val, nil
}

// Simulasi atomic load/store sederhana
func atomic_load(p *uint32) uint32 { return *p }
func atomic_store(p *uint32, v uint32) { *p = v }

func main() {
	p, err := GetPool()
	if err != nil {
		fmt.Println("Error:", err)
	} else {
		fmt.Println("Pool:", p)
	}
}
sync.Once yang gagal tidak akan mencoba ulang. Jika once.Do() menjalankan fungsi yang panic atau menghasilkan error, Once tetap menandai tugasnya sebagai selesai — pemanggilan berikutnya tidak akan menjalankan fungsi itu lagi. Jika kamu perlu retry pada kegagalan (misalnya koneksi database yang gagal sementara), kamu perlu mengimplementasikan pola OnceRetry sendiri, atau melakukan inisialisasi dengan mekanisme backoff di luar sync.Once.

Perbandingan Performa: DCL vs Mutex Biasa vs sync.Once #

Untuk memahami mengapa DCL dan sync.Once ada, perlu melihat profil performa ketiganya secara konkret.

// Skenario 1: Mutex di setiap akses — paling lambat pada read-heavy workload
func GetWithMutex() *Config {
	mu.Lock()
	defer mu.Unlock()
	if instance == nil {
		instance = &Config{AppName: "App"}
	}
	return instance
}

// Skenario 2: DCL dengan atomic — cepat setelah inisialisasi
func GetWithDCL() *Config {
	if cfg := configPtr.Load(); cfg != nil {
		return cfg // tidak ada lock — O(1) atomic load
	}
	mu.Lock()
	defer mu.Unlock()
	if cfg := configPtr.Load(); cfg != nil {
		return cfg
	}
	cfg := &Config{AppName: "App"}
	configPtr.Store(cfg)
	return cfg
}

// Skenario 3: sync.Once — performa setara DCL, jauh lebih aman
func GetWithOnce() *Config {
	once.Do(func() {
		instance = &Config{AppName: "App"}
	})
	return instance // setelah inisialisasi: hanya satu atomic check
}

Perbandingan throughput pada kondisi sudah terinisialisasi (99.9% dari semua akses):

Pendekatan Operasi setelah init Relatif
Mutex setiap akses Lock → Unlock 1x (baseline)
DCL (atomic.Pointer) atomic.Load() ~10-50x lebih cepat
sync.Once atomic load internal ~10-50x lebih cepat
Variabel langsung (tidak aman) Baca langsung Paling cepat tapi race

Setelah objek terinisialisasi, baik DCL maupun sync.Once hanya melakukan satu atomic load — operasi yang sangat murah dibanding acquire mutex.


Pola Singleton yang Benar di Go #

DCL paling sering muncul dalam konteks Singleton Pattern. Berikut adalah cara yang paling idiomatik dan aman untuk mengimplementasikan Singleton di Go:

package singleton

import "sync"

// Service adalah contoh Singleton
type Service struct {
	name string
	// field lain
}

func (s *Service) DoWork() string {
	return "work from " + s.name
}

// Pola 1: sync.Once (direkomendasikan untuk lazy initialization)
var (
	serviceInstance *Service
	serviceOnce     sync.Once
)

func GetService() *Service {
	serviceOnce.Do(func() {
		serviceInstance = &Service{name: "MainService"}
	})
	return serviceInstance
}

// Pola 2: package-level init (direkomendasikan jika inisialisasi selalu diperlukan)
// init() dipanggil otomatis oleh Go runtime sebelum main() — thread-safe secara definisi
var eagerService = &Service{name: "EagerService"}

func GetEagerService() *Service {
	return eagerService // tidak perlu sinkronisasi sama sekali
}

// Pola 3: function-level var dengan closure (berguna untuk testing)
func NewServiceFactory() func() *Service {
	var (
		svc  *Service
		once sync.Once
	)
	return func() *Service {
		once.Do(func() { svc = &Service{name: "FactoryService"} })
		return svc
	}
}

Panduan memilih pola:

flowchart TD
    Q{Kapan instance\ndibutuhkan?} --> Q1{Selalu dibutuhkan\nsaat startup?}
    Q1 -- Ya --> PKG[Package-level var\natau init — paling sederhana]
    Q1 -- Tidak --> Q2{Inisialisasi\nbisa gagal?}
    Q2 -- Tidak --> ONCE[sync.Once\n— direkomendasikan]
    Q2 -- Ya --> Q3{Perlu retry\njika gagal?}
    Q3 -- Tidak --> OWE[OnceWithError\nwrapper]
    Q3 -- Ya --> RETRY[OnceRetry\natau external retry logic]

Kapan DCL Manual Masih Relevan #

Gunakan sync.Once (hampir selalu):
  ✓ Lazy initialization objek yang tidak pernah perlu di-reset
  ✓ Inisialisasi sekali untuk seumur hidup program
  ✓ Semua kasus singleton pada kode Go baru

Pertimbangkan DCL manual hanya jika:
  ✗ Kamu perlu instance yang bisa di-"reset" dan diinisialisasi ulang
    (sync.Once tidak bisa di-reset)
  ✗ Kamu mengimplementasikan primitif concurrency tingkat rendah
  ✗ Kamu perlu fine-grained control atas memory ordering
  ✗ Kamu sedang mempelajari bagaimana sync.Once bekerja di bawahnya

JANGAN gunakan DCL dengan pembacaan langsung (non-atomic):
  ✗ Selalu ada data race — dilarang oleh Go memory model
  ✗ go test -race akan selalu mendeteksinya
  ✗ Tidak ada situasi di mana ini benar

Anti-Pattern yang Harus Dihindari #

// ✗ DCL tanpa atomic — data race klasik
var badInstance *Config

func GetBadConfig() *Config {
	if badInstance == nil {    // ✗ membaca tanpa sinkronisasi
		mu.Lock()
		defer mu.Unlock()
		if badInstance == nil {
			badInstance = &Config{} // ✗ bisa terlihat sebelum konstruktor selesai
		}
	}
	return badInstance
}

// ✓ Gunakan sync.Once — tidak mungkin salah
var (
	goodInstance *Config
	goodOnce     sync.Once
)

func GetGoodConfig() *Config {
	goodOnce.Do(func() {
		goodInstance = &Config{AppName: "App"}
	})
	return goodInstance
}

// ✗ Menyimpan sync.Once di stack (nilai, bukan pointer) — Once tidak boleh dikopi
func wrongOnceUsage() {
	var once sync.Once // ✗ Once di stack fungsi — setiap pemanggilan fungsi dapat Once baru
	once.Do(func() {
		fmt.Println("ini bukan singleton") // dipanggil setiap kali fungsi dipanggil
	})
}

// ✓ sync.Once harus disimpan di package-level atau heap (pointer ke struct)
var globalOnce sync.Once

func correctOnceUsage() {
	globalOnce.Do(func() {     // ✓ Once yang sama digunakan setiap kali
		fmt.Println("hanya sekali")
	})
}

// ✗ Memanggil method pada instance di luar Once — bisa race
func unsafeInit() *Service {
	once.Do(func() {
		svc = &Service{}
	})
	svc.Initialize() // ✗ jika Initialize() dipanggil dari banyak goroutine,
	                 //   ini tidak dilindungi Once
	return svc
}

// ✓ Semua inisialisasi dilakukan di dalam Do()
func safeInit() *Service {
	once.Do(func() {
		svc = &Service{}
		svc.Initialize() // ✓ Initialize hanya dipanggil sekali, di dalam Once
	})
	return svc
}

// ✗ Mengasumsikan urutan inisialisasi antar package — init order tidak selalu predictable
// package A: var svc = GetService() — mungkin dipanggil sebelum package B siap
// ✓ Gunakan lazy init dengan sync.Once — objek dibuat saat pertama kali dibutuhkan

Checklist Review Double-Checked Locking #

PEMILIHAN SOLUSI:
  □ Sudah mempertimbangkan sync.Once sebelum DCL manual
  □ DCL manual hanya digunakan jika ada alasan eksplisit yang tidak bisa dipenuhi Once
  □ Package-level var dipertimbangkan untuk inisialisasi yang selalu diperlukan

KEAMANAN ATOMIC:
  □ Pengecekan pertama menggunakan atomic.Pointer.Load() atau atomic.LoadPointer
  □ Penyimpanan menggunakan atomic.Pointer.Store() atau atomic.StorePointer
  □ Tidak ada pembacaan langsung (non-atomic) ke pointer yang di-share

PENGGUNAAN sync.Once:
  □ Once disimpan di package-level atau di heap — tidak di stack fungsi
  □ Once tidak dikopi setelah pertama digunakan
  □ Semua inisialisasi (termasuk method call) dilakukan di dalam Do()
  □ Dipahami bahwa Once gagal tidak akan retry

ERROR HANDLING:
  □ Kegagalan inisialisasi ditangani dengan OnceWithError jika diperlukan
  □ Panic di dalam Do() dipahami akan menyebabkan Once tidak retry
  □ Ada mekanisme retry eksternal jika inisialisasi bisa gagal sementara

PENGUJIAN:
  □ Diuji dengan go test -race untuk mendeteksi data race
  □ Test konkuren memverifikasi bahwa inisialisasi hanya terjadi sekali
  □ Benchmark memverifikasi fast path (setelah init) tidak ada lock overhead

Ringkasan #

  • DCL adalah pola lazy initialization yang melakukan dua pengecekan — satu tanpa lock (fast path) dan satu dengan lock (safe path) — untuk meminimalkan penggunaan mutex setelah objek terinisialisasi.
  • DCL tanpa atomic adalah data race — membaca pointer yang mungkin sedang ditulis goroutine lain tanpa sinkronisasi adalah pelanggaran Go memory model; go test -race selalu mendeteksinya.
  • Gunakan atomic.Pointer[T].Load() untuk pengecekan pertama jika kamu benar-benar mengimplementasikan DCL manual — ini satu-satunya cara yang benar di Go.
  • sync.Once adalah solusi yang hampir selalu lebih baik — ia mengimplementasikan DCL yang benar di baliknya, tapi dengan API yang tidak bisa disalahgunakan; gunakan ini sebagai default.
  • sync.Once yang gagal tidak akan retry — jika fungsi di dalam Do() panic atau menghasilkan error, pemanggilan berikutnya tidak akan menjalankan fungsi itu lagi; implementasikan OnceWithError jika perlu error propagation.
  • Package-level var atau init() lebih sederhana jika objek selalu dibutuhkan saat startup — tidak perlu lazy init sama sekali; Go runtime menjamin thread-safety untuk inisialisasi package-level.
  • sync.Once tidak boleh dikopi — simpan di package-level atau sebagai field pointer di struct; Once di stack fungsi akan membuat instance baru setiap panggilan.
  • Semua inisialisasi dilakukan di dalam Do() — termasuk pemanggilan method setup seperti Connect() atau Initialize(); memanggil method di luar Do() tidak dilindungi dari concurrent access.
  • Overhead setelah inisialisasi sangat kecil — baik DCL maupun sync.Once hanya melakukan satu atomic load pada fast path; ini jauh lebih cepat dari mutex yang di-acquire setiap akses.
  • Pahami DCL untuk memahami sync.Once — mengetahui mengapa DCL manual berbahaya dan apa yang sync.Once lakukan di baliknya menjadikanmu engineer yang lebih baik dalam mendesain sistem concurrent.

← Sebelumnya: Read–Write Lock   Berikutnya: Immutable Object →

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