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:
- Atomic Load (atomic.LoadUint32): In the fast path, the
doneflag is checked via atomic operation to avoid locking every time. - Mutex Protects Critical Section: In the slow path, the lock ensures only one goroutine executes the function
f. - Memory Barrier:
atomic.StoreUint32writes 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
doneflag is only set to 1 afterfcompletes normally or triggers a panic. Iffblocks indefinitely, other goroutines will wait forever for the lock to be released.
(3) Difference from init Function
- The
initfunction executes automatically during package initialization, only once, but its execution timing cannot be controlled; sync.Oncesupports 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
atomicin 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.