Singleton pattern is the simplest design pattern in software design. It ensures that only one instance of an object exists globally, regardless of how many times the object is instantiated. Based on the characteristics of the singleton pattern, it can be applied to scenarios such as global unique configuration, database connection objects, file access objects, etc. In Go language, there are multiple ways to implement the singleton pattern. Today, let's learn together about some of these approaches.
Eager Initialization
Implementing the singleton pattern using eager initialization is very simple. Let's take a look at the code directly:
package singleton
type singleton struct{}
var instance = &singleton{}
func GetSingleton() *singleton {
return instance
}
When the singleton
package is imported, the instance instance
is automatically initialized. To obtain the singleton object of the singleton struct, simply call the singleton.GetSingleton()
function.
Since the singleton object is created immediately during package loading, this implementation is given the descriptive name eager initialization. The counterpart to this is the lazy initialization, where the instance is created only when it is first used.
It is important to note that although eager initialization is straightforward for implementing the singleton pattern, it is generally not recommended, especially if the initialization process for the singleton instance is time-consuming. This can result in longer program loading times.
Lazy Initialization
Next, let's see how to implement the singleton pattern using lazy initialization:
package singleton
type singleton struct{}
var instance *singleton
func GetSingleton() *singleton {
if instance == nil {
instance = &singleton{}
}
return instance
}
In contrast to the eager initialization implementation, we have moved the code for instantiating the singleton struct inside the GetSingleton()
function. By doing this, we delay the instantiation of the object until GetSingleton()
is called for the first time.
However, using the condition instance == nil
to implement the singleton is not entirely reliable, as it cannot guarantee concurrent safety when multiple goroutines simultaneously call GetSingleton()
.
Concurrent-Safe Singleton
If you have experience with concurrent programming in Go, you can quickly think of a solution to address the concurrency safety issue in the lazy initialization singleton pattern.
package singleton
import "sync"
type singleton struct{}
var instance *singleton
var mu sync.Mutex
func GetSingleton() *singleton {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &singleton{}
}
return instance
}
The main modification we made to the code is adding the following two lines at the beginning of the GetSingleton()
function:
mu.Lock()
defer mu.Unlock()
By using locking mechanisms, we can ensure that this implementation of the singleton pattern is concurrency-safe.
However, there is a downside to this approach. Because of the use of locks, every time GetSingleton()
is called, the program goes through the process of acquiring and releasing the lock. This can lead to a decrease in program performance.
Double-Checked Locking
Locking can cause a performance decrease, but we still need to use locks to ensure concurrency safety. Therefore, someone came up with the solution of double-checked locking:
package singleton
import "sync"
type singleton struct{}
var instance *singleton
var mu sync.Mutex
func GetSingleton() *singleton {
if instance == nil {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &singleton{}
}
}
return instance
}
As you can see, the so-called double-checked locking is actually adding an additional instance == nil
check before acquiring the lock. This approach combines both performance and safety aspects.
However, this code may appear a bit strange. Since the outer check instance == nil
has already been performed, it seems redundant to perform a second instance == nil
check after acquiring the lock. In fact, the outer instance == nil
check is to improve the execution efficiency of the program. If instance already exists, there is no need to enter the if logic, and the program can return instance directly. This avoids the overhead of acquiring the lock for every GetSingleton()
call and makes the locking more fine-grained. The inner instance == nil
check is for considering concurrency safety. In extreme cases, when multiple goroutines reach the point of acquiring the lock simultaneously, the inner check comes into play.
Preferred Approach for Gophers
Although the double-checked locking mechanism allows us to achieve both performance and concurrency safety, the code looks somewhat ugly and does not meet the expectations of most Gophers. Fortunately, Go language provides the sync package with the Once mechanism, which allows us to write more elegant code:
package singleton
import "sync"
type singleton struct{}
var instance *singleton
var once sync.Once
func GetSingleton() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
Once
is a structure that ensures concurrency safety within the Do
method by using atomic operations and locking mechanisms. The once.Do
method guarantees that the creation of &singleton{}
only occurs once, even when multiple goroutines are executing simultaneously.
In reality, Once
is not mysterious. Its internal implementation is very similar to the double-checked locking mechanism mentioned earlier, except that it replaces instance == nil
with atomic operations. Interested readers can refer to the corresponding source code for more details.
Summary
The above are several common approaches to implementing the singleton pattern in Go. After comparing them, we can conclude that the most recommended approach is to use once.Do
. The sync.Once
package helps to hide some of the details, while significantly improving code readability.