Implementation of Singleton Pattern in Go and Principles of sync.Once

Implementation of Singleton Pattern in Go and Principles of sync.Once

Problem Description

The Singleton pattern is a design pattern that ensures a class has only one instance and provides a global point of access to it. In Go, due to its concurrency features, implementing a thread-safe Singleton requires special attention to race conditions. The problem requires a deep understanding of how to implement the Singleton pattern and focuses on mastering the underlying principles of sync.Once and its differences from implementations using ordinary locks.


1. Basic Implementation Methods of Singleton Pattern

(1) Non-Thread-Safe Lazy Initialization

type Singleton struct{}

var instance *Singleton

func GetInstance() *Singleton {
    if instance == nil { // Race condition: multiple goroutines may enter this check simultaneously
        instance = &Singleton{}
    }
    return instance
}

Problem: In concurrent scenarios, multiple goroutines might simultaneously check instance == nil, leading to the creation of multiple instances, violating the singleton principle.

(2) Implementation with Locking (Improved Lazy Initialization)

var (
    instance *Singleton
    mu       sync.Mutex
)

func GetInstance() *Singleton {
    mu.Lock()         // Use a mutex to ensure only one goroutine enters the critical section at a time
    defer mu.Unlock()

    if instance == nil {
        instance = &Singleton{}
    }
    return instance
}

Disadvantage: Every call to GetInstance() acquires the lock, even after the instance is created, making lock operations a performance bottleneck.

(3) Double-Checked Locking

func GetInstance() *Singleton {
    if instance == nil { // First check: avoids lock overhead after instantiation
        mu.Lock()
        defer mu.Unlock()
        if instance == nil { // Second check: prevents multiple goroutines that passed the first check from creating duplicates
            instance = &Singleton{}
        }
    }
    return instance
}

Note: In older versions of Go (before the memory model supported atomic operations), instruction reordering could lead to partially initialized instances being returned. This issue can be resolved using the atomic package in modern Go (see later explanation).


2. Recommended Implementation in Go: sync.Once

sync.Once is a tool provided by the Go standard library specifically designed to ensure an operation is performed only once, handling concurrency safety internally.

(1) Implementing Singleton with sync.Once

var (
    instance *Singleton
    once     sync.Once
)

func GetInstance() *Singleton {
    once.Do(func() { // The passed-in function will only be executed once
        instance = &Singleton{}
    })
    return instance
}

Advantages:

  • Cleaner code, no need to manually handle locking logic;
  • Underlying implementation combines atomic operations and locks, offering better performance than ordinary locking.

3. Underlying Principles of sync.Once

(1) Data Structure

type Once struct {
    done uint32 // Flag bit, visibility guaranteed via atomic operations
    m    Mutex  // Mutex for protecting the critical section
}

(2) Workflow of the Do Method

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 { // Fast check: return directly if already executed
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {         // Re-check after acquiring lock (similar to double-checked locking)
        defer atomic.StoreUint32(&o.done, 1) // Mark as completed after function execution
        f()
    }
}

Key Points:

  1. Atomic Load (atomic.LoadUint32): In the fast path, the done flag is checked via atomic operation to avoid locking every time.
  2. Mutex Protects Critical Section: In the slow path, the lock ensures only one goroutine executes the function f.
  3. Memory Barrier: atomic.StoreUint32 writes the flag after function execution, guaranteeing visibility of the write to other goroutines (preventing instruction reordering).

4. Common Issues and Pitfalls

(1) Panic During sync.Once Execution

If a panic occurs during the execution of f, Once considers the function not successfully executed, and subsequent calls to Do will attempt execution again.

once.Do(func() {
    panic("execution failed") // Do will still attempt execution on the next call
})

(2) Conditions for Function Re-execution

  • The done flag is only set to 1 after f completes normally or triggers a panic. If f blocks indefinitely, other goroutines will wait forever for the lock to be released.

(3) Difference from init Function

  • The init function executes automatically during package initialization, only once, but its execution timing cannot be controlled;
  • sync.Once supports lazy initialization, suitable for expensive resource initialization.

5. Performance Optimization: Atomic Double-Checked Locking

For high-performance scenarios, atomic operations can be combined to avoid the lock overhead of sync.Once:

var instance *Singleton
var done uint32 // Atomic flag

func GetInstance() *Singleton {
    if atomic.LoadUint32(&done) == 0 { // Fast check
        mu.Lock()
        defer mu.Unlock()
        if done == 0 {
            defer atomic.StoreUint32(&done, 1) // Ensure flag is written via defer
            instance = &Singleton{}
        }
    }
    return instance
}

Applicable Scenarios: Extremely performance-sensitive scenarios with simple initialization logic. Generally, it's recommended to prioritize sync.Once as it offers cleaner code and is less error-prone.


Summary

  • Ordinary Locking: Simple but poor performance;
  • Double-Checked Locking: Requires attention to memory visibility issues, solvable via atomic in modern Go;
  • sync.Once: Safe and efficient solution provided by the Go standard library, implemented internally via a combination of atomic operations and locks.
  • Core Idea: Guarantee visibility of flags via atomic operations, ensure mutual exclusion in critical sections via mutexes, balancing performance and safety.