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.Onceyang gagal tidak akan mencoba ulang. Jikaonce.Do()menjalankan fungsi yang panic atau menghasilkan error,Oncetetap 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 polaOnceRetrysendiri, atau melakukan inisialisasi dengan mekanisme backoff di luarsync.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 -raceselalu 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.Onceadalah 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.Onceyang gagal tidak akan retry — jika fungsi di dalamDo()panic atau menghasilkan error, pemanggilan berikutnya tidak akan menjalankan fungsi itu lagi; implementasikanOnceWithErrorjika perlu error propagation.- Package-level
varatauinit()lebih sederhana jika objek selalu dibutuhkan saat startup — tidak perlu lazy init sama sekali; Go runtime menjamin thread-safety untuk inisialisasi package-level.sync.Oncetidak boleh dikopi — simpan di package-level atau sebagai field pointer di struct;Oncedi stack fungsi akan membuat instance baru setiap panggilan.- Semua inisialisasi dilakukan di dalam
Do()— termasuk pemanggilan method setup sepertiConnect()atauInitialize(); memanggil method di luarDo()tidak dilindungi dari concurrent access.- Overhead setelah inisialisasi sangat kecil — baik DCL maupun
sync.Oncehanya 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 yangsync.Oncelakukan di baliknya menjadikanmu engineer yang lebih baik dalam mendesain sistem concurrent.
← Sebelumnya: Read–Write Lock Berikutnya: Immutable Object →