Double-Checked Locking Pattern #
There is a question that often arises when building concurrent systems: how do you create an expensive object — a database connection, an HTTP client, or a global configuration — only once, even though hundreds of goroutines try to access it simultaneously? The naive solution is putting a mutex on every access, but this means every request must queue for a lock even after the object has long existed. Another solution is initializing in main() before any goroutine launches, but this is not always possible — sometimes we need lazy initialization, creating the object only when it is first truly needed. Double-Checked Locking (DCL) is the classic pattern for this problem: check whether the object already exists without a lock, and only acquire the lock if it does not. Two checks — one outside the lock, one inside — are what give this pattern its name. In Go, understanding DCL means understanding why sync.Once exists and what it actually does.
What Is Double-Checked Locking? #
Double-Checked Locking is a lazy initialization pattern that minimizes mutex usage by performing two condition checks: one before acquiring the lock (fast path) and one after acquiring the lock (safe path).
flowchart TD
A([Goroutine calls GetInstance]) --> B{Check 1:\\ninstance == nil?}
B -- No --> Z([Return instance])
B -- Yes --> C[Acquire mutex.Lock]
C --> D{Check 2:\\ninstance == nil?}
D -- No --> E[Release lock\\nreturn instance]
D -- Yes --> F[Create new instance]
F --> G[Release lock]
G --> ZFour steps are always present in DCL:
| Step | Operation | Purpose |
|---|---|---|
| 1 | Check without a lock | Skip the lock if already initialized (fast path) |
| 2 | Acquire the lock | Enter the safe zone for the second check |
| 3 | Re-check with the lock | Ensure another goroutine has not initialized it |
| 4 | Initialize | Create the instance — only this goroutine runs this |
The second check (step 3) is the key to this pattern. Without it, two goroutines that both pass the first check could both create an instance — resulting in double initialization.
Why the Lock-Free Check Is Dangerous #
Before diving into the correct implementation, it is important to understand why DCL that looks right can be a hidden trap in Go.
// VERY DANGEROUS: first check without any synchronization
// This is a data race — undefined behavior under the Go memory model
var instance *Database
func GetDatabase() *Database {
if instance == nil { // ✗ read without synchronization
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &Database{} // ✗ write without synchronization
instance.Connect() // ✗ the pointer can be visible before Connect() finishes
}
}
return instance
}
There are two fundamental problems here. First, the data race: reading instance without synchronization while another goroutine may be writing to it is a data race — behavior that is undefined under the Go memory model. go test -race will detect this immediately.
Second, instruction reordering: the compiler and CPU are allowed to reorder instructions as long as the result is consistent from a single goroutine’s perspective. This means instance = &Database{} could be visible to another goroutine before the object’s constructor finishes running — a goroutine that passes the first check may get a pointer to a half-initialized object.
In Java before version 5, DCL was famously a pattern that could not be implemented correctly. In Go, the solution is available through sync.Once and atomic.
Correct DCL Implementation with a Mutex #
If you truly need to implement DCL manually (rather than using sync.Once), the first check must use atomic.LoadPointer or atomic.Pointer — not a direct read.
package main
import (
"fmt"
"sync"
"sync/atomic"
"unsafe"
)
type Config struct {
AppName string
MaxConns int
}
var (
configPtr atomic.Pointer[Config] // ✓ atomic pointer — safe to read without a lock
configMu sync.Mutex
)
// GetConfig implements correct DCL with atomics
func GetConfig() *Config {
// Check 1: read atomically — no data race
if cfg := configPtr.Load(); cfg != nil {
return cfg // fast path: already initialized, no lock needed at all
}
// Check 2: acquire the lock, re-check
configMu.Lock()
defer configMu.Unlock()
// Double-check: another goroutine may have initialized while we waited for the lock
if cfg := configPtr.Load(); cfg != nil {
return cfg
}
// Only one goroutine gets here
cfg := &Config{
AppName: "MyApp",
MaxConns: 100,
}
configPtr.Store(cfg) // ✓ store atomically — visible to all goroutines
return cfg
}
// Example with any type using unsafe.Pointer (Go < 1.19)
// atomic.Pointer[T] is the cleaner way in Go 1.19+
var (
legacyInstance unsafe.Pointer
legacyMu sync.Mutex
)
type LegacyService struct{ name string }
func GetLegacyService() *LegacyService {
// atomic load — safe from data races
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()
}
The critical difference between correct and incorrect implementations:
flowchart LR
subgraph WRONG["❌ Wrong Implementation"]
W1["if instance == nil"] -->|direct read| W2["DATA RACE\\nundefined behavior"]
end
subgraph RIGHT["✓ Correct Implementation"]
R1["if ptr := configPtr.Load()"] -->|atomic load| R2["Safe — all goroutines\\nsee the same value"]
endThe Idiomatic Solution: sync.Once #
After seeing the complexity of correct DCL, the natural question is: is there a simpler way? The answer is sync.Once — and it is the always recommended solution in Go.
package main
import (
"fmt"
"sync"
)
type Database struct {
dsn string
conn interface{} // simulated connection
}
func (d *Database) Connect() {
fmt.Printf("[Database] Connecting to %s\n", d.dsn)
// simulated expensive connection process
}
var (
db *Database
once sync.Once
)
// GetDatabase uses sync.Once — idiomatic, safe, simple
func GetDatabase() *Database {
once.Do(func() {
// This block is GUARANTEED to execute only once,
// even if many goroutines call GetDatabase() simultaneously
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()
}
Why sync.Once is better than manual DCL:
| Aspect | Manual DCL | sync.Once |
|---|---|---|
| Safety | Needs atomic.Pointer to be safe | Always safe — guaranteed by the runtime |
| Simplicity | Complex — easy to implement wrongly | Very simple |
| Readability | Needs extra explanation | Self-documenting |
| Error prone | High — many wrong variations | Low |
| Overhead | Minimal after initialization | Minimal — one atomic check |
| Intent documentation | Unclear | Clear: “do this once” |
sync.Once internally uses the same atomic operations as correct DCL, but wrapped in an API that cannot be misused. This is a great example of the Go philosophy: make the right thing easy and the wrong thing hard.
Advanced Case: sync.Once with Error Handling #
One weakness of standard sync.Once is that there is no built-in way to handle errors during initialization — if initialization fails, once.Do() still marks the work as “done” and will not try again.
package main
import (
"errors"
"fmt"
"sync"
)
// OnceWithError is a sync.Once wrapper that stores the initialization error
type OnceWithError struct {
once sync.Once
val interface{}
err error
}
// Do runs f exactly once and stores its result
func (o *OnceWithError) Do(f func() (interface{}, error)) (interface{}, error) {
o.once.Do(func() {
o.val, o.err = f()
})
return o.val, o.err
}
// Example: a database initialization that can fail
type DBPool struct {
maxConns int
}
var (
poolOnce OnceWithError
pool *DBPool
)
func GetPool() (*DBPool, error) {
val, err := poolOnce.Do(func() (interface{}, error) {
fmt.Println("[Pool] Attempting initialization...")
// Simulate a failure on the first initialization
if pool == nil {
// Suppose the connection fails
return nil, errors.New("failed to connect to database: connection refused")
}
return pool, nil
})
if err != nil {
return nil, err
}
return val.(*DBPool), nil
}
// OnceRetry: retry if initialization fails
// This avoids "poisoning" the once with a transient error
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 // ✓ does not mark done — will be retried
}
o.val = val
atomic_store(&o.done, 1)
return val, nil
}
// Simplified atomic load/store
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)
}
}
A failedsync.Oncewill not retry. Ifonce.Do()runs a function that panics or produces an error, theOncestill marks its job as done — subsequent calls will not run that function again. If you need to retry on failure (for example a database connection that fails transiently), you need to implement aOnceRetrypattern yourself, or perform initialization with a backoff mechanism outsidesync.Once.
Performance Comparison: DCL vs Plain Mutex vs sync.Once #
To understand why DCL and sync.Once exist, it is worth seeing the performance profile of all three concretely.
// Scenario 1: Mutex on every access — slowest on read-heavy workloads
func GetWithMutex() *Config {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &Config{AppName: "App"}
}
return instance
}
// Scenario 2: DCL with atomics — fast after initialization
func GetWithDCL() *Config {
if cfg := configPtr.Load(); cfg != nil {
return cfg // no 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
}
// Scenario 3: sync.Once — performance equal to DCL, far safer
func GetWithOnce() *Config {
once.Do(func() {
instance = &Config{AppName: "App"}
})
return instance // after initialization: only one atomic check
}
Throughput comparison in the already-initialized state (99.9% of all accesses):
| Approach | Operation after init | Relative |
|---|---|---|
| Mutex on every access | Lock → Unlock | 1x (baseline) |
| DCL (atomic.Pointer) | atomic.Load() | ~10-50x faster |
| sync.Once | internal atomic load | ~10-50x faster |
| Direct variable (unsafe) | Direct read | Fastest but racy |
After the object is initialized, both DCL and sync.Once only perform a single atomic load — an operation far cheaper than acquiring a mutex.
The Correct Singleton Pattern in Go #
DCL most often appears in the context of the Singleton Pattern. Here is the most idiomatic and safe way to implement a Singleton in Go:
package singleton
import "sync"
// Service is an example Singleton
type Service struct {
name string
// other fields
}
func (s *Service) DoWork() string {
return "work from " + s.name
}
// Pattern 1: sync.Once (recommended for lazy initialization)
var (
serviceInstance *Service
serviceOnce sync.Once
)
func GetService() *Service {
serviceOnce.Do(func() {
serviceInstance = &Service{name: "MainService"}
})
return serviceInstance
}
// Pattern 2: package-level init (recommended if initialization is always needed)
// init() is called automatically by the Go runtime before main() — thread-safe by definition
var eagerService = &Service{name: "EagerService"}
func GetEagerService() *Service {
return eagerService // no synchronization needed at all
}
// Pattern 3: function-level var with a closure (useful for testing)
func NewServiceFactory() func() *Service {
var (
svc *Service
once sync.Once
)
return func() *Service {
once.Do(func() { svc = &Service{name: "FactoryService"} })
return svc
}
}
Guidelines for choosing a pattern:
flowchart TD
Q{When is the instance\\nneeded?} --> Q1{Always needed\\nat startup?}
Q1 -- Yes --> PKG[Package-level var\\nor init — simplest]
Q1 -- No --> Q2{Can initialization\\nfail?}
Q2 -- No --> ONCE[sync.Once\\n— recommended]
Q2 -- Yes --> Q3{Need retry\\non failure?}
Q3 -- No --> OWE[OnceWithError\\nwrapper]
Q3 -- Yes --> RETRY[OnceRetry\\nor external retry logic]When Manual DCL Is Still Relevant #
Use sync.Once (almost always):
✓ Lazy initialization of objects that never need to be reset
✓ One-time initialization for the program's lifetime
✓ All singleton cases in new Go code
Consider manual DCL only if:
✗ You need an instance that can be "reset" and re-initialized
(sync.Once cannot be reset)
✗ You are implementing low-level concurrency primitives
✗ You need fine-grained control over memory ordering
✗ You are studying how sync.Once works underneath
NEVER use DCL with direct (non-atomic) reads:
✗ Always a data race — forbidden by the Go memory model
✗ go test -race will always detect it
✗ There is no situation where this is correct
Anti-Patterns to Avoid #
// ✗ DCL without atomics — the classic data race
var badInstance *Config
func GetBadConfig() *Config {
if badInstance == nil { // ✗ reading without synchronization
mu.Lock()
defer mu.Unlock()
if badInstance == nil {
badInstance = &Config{} // ✗ can be visible before the constructor finishes
}
}
return badInstance
}
// ✓ Use sync.Once — impossible to get wrong
var (
goodInstance *Config
goodOnce sync.Once
)
func GetGoodConfig() *Config {
goodOnce.Do(func() {
goodInstance = &Config{AppName: "App"}
})
return goodInstance
}
// ✗ Storing sync.Once on the stack (by value, not pointer) — Once must not be copied
func wrongOnceUsage() {
var once sync.Once // ✗ Once on the function stack — every function call may get a new Once
once.Do(func() {
fmt.Println("this is not a singleton") // called every time the function is called
})
}
// ✓ sync.Once must be stored at package level or on the heap (pointer to a struct)
var globalOnce sync.Once
func correctOnceUsage() {
globalOnce.Do(func() { // ✓ the same Once is used every time
fmt.Println("only once")
})
}
// ✗ Calling methods on the instance outside the Once — can race
func unsafeInit() *Service {
once.Do(func() {
svc = &Service{}
})
svc.Initialize() // ✗ if Initialize() is called from many goroutines,
// this is not protected by the Once
return svc
}
// ✓ All initialization happens inside Do()
func safeInit() *Service {
once.Do(func() {
svc = &Service{}
svc.Initialize() // ✓ Initialize is only called once, inside the Once
})
return svc
}
// ✗ Assuming initialization order between packages — init order is not always predictable
// package A: var svc = GetService() — may be called before package B is ready
// ✓ Use lazy init with sync.Once — the object is created when first needed
Double-Checked Locking Review Checklist #
SOLUTION SELECTION:
□ sync.Once was considered before manual DCL
□ Manual DCL is only used when there is an explicit reason the Once cannot satisfy
□ Package-level vars are considered for initialization that is always needed
ATOMIC SAFETY:
□ The first check uses atomic.Pointer.Load() or atomic.LoadPointer
□ Storage uses atomic.Pointer.Store() or atomic.StorePointer
□ No direct (non-atomic) reads of a shared pointer
sync.Once USAGE:
□ The Once is stored at package level or on the heap — not on the function stack
□ The Once is not copied after first use
□ All initialization (including method calls) happens inside Do()
□ It is understood that a failed Once will not retry
ERROR HANDLING:
□ Initialization failures are handled with OnceWithError if needed
□ A panic inside Do() is understood to prevent Once from retrying
□ There is an external retry mechanism if initialization can fail transiently
TESTING:
□ Tested with go test -race to detect data races
□ Concurrent tests verify that initialization happens only once
□ Benchmarks verify the fast path (after init) has no lock overhead
Summary #
- DCL is a lazy initialization pattern that performs two checks — one without a lock (fast path) and one with a lock (safe path) — to minimize mutex usage after the object is initialized.
- DCL without atomics is a data race — reading a pointer that another goroutine may be writing without synchronization violates the Go memory model;
go test -racealways detects it.- Use
atomic.Pointer[T].Load()for the first check if you truly implement manual DCL — it is the only correct way in Go.sync.Onceis almost always the better solution — it implements correct DCL underneath, but with an API that cannot be misused; use it as the default.- A failed
sync.Oncewill not retry — if the function insideDo()panics or produces an error, subsequent calls will not run it again; implementOnceWithErrorif you need error propagation.- Package-level
varorinit()is simpler if the object is always needed at startup — no lazy init needed at all; the Go runtime guarantees thread safety for package-level initialization.sync.Oncemust not be copied — store it at package level or as a pointer field in a struct; anOnceon the function stack creates a new instance every call.- All initialization happens inside
Do()— including setup method calls likeConnect()orInitialize(); calling methods outsideDo()is not protected from concurrent access.- The overhead after initialization is very small — both DCL and
sync.Onceonly perform a single atomic load on the fast path; this is far faster than a mutex acquired on every access.- Understand DCL to understand
sync.Once— knowing why manual DCL is dangerous and whatsync.Oncedoes underneath makes you a better engineer designing concurrent systems.