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 --> Z

Four steps are always present in DCL:

StepOperationPurpose
1Check without a lockSkip the lock if already initialized (fast path)
2Acquire the lockEnter the safe zone for the second check
3Re-check with the lockEnsure another goroutine has not initialized it
4InitializeCreate 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"]
    end

The 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:

AspectManual DCLsync.Once
SafetyNeeds atomic.Pointer to be safeAlways safe — guaranteed by the runtime
SimplicityComplex — easy to implement wronglyVery simple
ReadabilityNeeds extra explanationSelf-documenting
Error proneHigh — many wrong variationsLow
OverheadMinimal after initializationMinimal — one atomic check
Intent documentationUnclearClear: “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 failed sync.Once will not retry. If once.Do() runs a function that panics or produces an error, the Once still 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 a OnceRetry pattern yourself, or perform initialization with a backoff mechanism outside sync.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):

ApproachOperation after initRelative
Mutex on every accessLock → Unlock1x (baseline)
DCL (atomic.Pointer)atomic.Load()~10-50x faster
sync.Onceinternal atomic load~10-50x faster
Direct variable (unsafe)Direct readFastest 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 -race always 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.Once is 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.Once will not retry — if the function inside Do() panics or produces an error, subsequent calls will not run it again; implement OnceWithError if you need error propagation.
  • Package-level var or init() 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.Once must not be copied — store it at package level or as a pointer field in a struct; an Once on the function stack creates a new instance every call.
  • All initialization happens inside Do() — including setup method calls like Connect() or Initialize(); calling methods outside Do() is not protected from concurrent access.
  • The overhead after initialization is very small — both DCL and sync.Once only 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 what sync.Once does underneath makes you a better engineer designing concurrent systems.

← Previous: Read–Write Lock   Next: Immutable Object →

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