This content originally appeared on DEV Community and was authored by Leapcell
Introduction
In certain scenarios, we need to initialize some resources, such as singleton objects, configurations, etc. There are multiple ways to implement resource initialization, such as defining package-level variables, initializing them in the init
function, or in the main
function. All three approaches can ensure concurrency safety and complete resource initialization when the program starts.
However, sometimes we prefer to use lazy initialization, where resources are only initialized when they are truly needed. This requires concurrency safety, and in such cases, Go’s sync.Once
provides an elegant and thread-safe solution. This article introduces sync.Once
.
Basic Concepts of sync.Once
What is sync.Once
sync.Once
is a synchronization primitive in Go that ensures a specific operation or function is executed only once in a concurrent environment. It exposes only one exported method: Do
, which accepts a function as its parameter. After calling the Do
method, the provided function will be executed, and only once, even if multiple goroutines invoke it concurrently.
Application Scenarios of sync.Once
sync.Once
is mainly used in the following scenarios:
- Singleton Pattern: Ensures that only one global instance object exists, preventing duplicate resource creation.
-
Lazy Initialization: During program execution, resources can be dynamically initialized through
sync.Once
when needed. - Operations That Must Run Only Once: For example, configuration loading, data cleanup, etc., that need to be executed just once.
Application Examples of sync.Once
Singleton Pattern
In the singleton pattern, we need to ensure that a struct is initialized only once. This goal can be easily achieved using sync.Once
.
package main
import (
"fmt"
"sync"
)
type Singleton struct{}
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s := GetInstance()
fmt.Printf("Singleton instance address: %p\n", s)
}()
}
wg.Wait()
}
In the code above, the GetInstance
function uses once.Do()
to ensure that instance
is initialized only once. In a concurrent environment, when multiple goroutines simultaneously call GetInstance
, only one goroutine will execute instance = &Singleton{}
, and all goroutines will receive the same instance s
.
Lazy Initialization
Sometimes we want to initialize certain resources only when they are needed. This can be achieved using sync.Once
.
package main
import (
"fmt"
"sync"
)
type Config struct {
config map[string]string
}
var (
config *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
fmt.Println("init config...")
config = &Config{
config: map[string]string{
"c1": "v1",
"c2": "v2",
},
}
})
return config
}
func main() {
// The first time configuration is needed, config is initialized
cfg := GetConfig()
fmt.Println("c1: ", cfg.config["c1"])
// The second time, config is already initialized and will not be re-initialized
cfg2 := GetConfig()
fmt.Println("c2: ", cfg2.config["c2"])
}
In this example, a Config
struct is defined to hold some configuration settings. The GetConfig
function uses sync.Once
to initialize the Config
struct the first time it is called. This way, Config
is only initialized when it is truly needed, avoiding unnecessary overhead.
Implementation Principle of sync.Once
type Once struct {
// Indicates whether the operation has been performed
done uint32
// Mutex to ensure only one goroutine performs the operation
m Mutex
}
func (o *Once) Do(f func()) {
// Check if done is 0, meaning f has not been executed yet
if atomic.LoadUint32(&o.done) == 0 {
// Call the slow path to allow fast-path inlining in Do
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
// Lock
o.m.Lock()
defer o.m.Unlock()
// Double-check to avoid executing f multiple times
if o.done == 0 {
// Set done after executing the function
defer atomic.StoreUint32(&o.done, 1)
// Execute the function
f()
}
}
The sync.Once
struct includes two fields: done
and m
.
-
done
is auint32
variable used to indicate whether the operation has already been performed. -
m
is a mutex used to ensure that only one goroutine performs the operation when accessed concurrently.
sync.Once
provides two methods: Do
and doSlow
. The Do
method is the core; it accepts a function f
. It first checks the value of done
using the atomic operation atomic.LoadUint32
(ensuring concurrency safety). If done
is 0
, it means the function f
hasn’t been executed yet, and doSlow
is then called.
Inside the doSlow
method, it first acquires the mutex lock m
to ensure only one goroutine can execute f
at a time. It then performs a second check on the done
variable. If done
is still 0
, the function f
is executed, and done
is set to 1
using the atomic store operation atomic.StoreUint32
.
Why is there a separate doSlow
method?
The doSlow
method mainly exists for performance optimization. By separating the slow path logic from the Do
method, the fast path in Do
can be inlined by the compiler, improving performance.
Why use double-checking?
As seen from the source code, the value of done
is checked twice:
-
First check: Before acquiring the lock, it uses
atomic.LoadUint32
to checkdone
. If the value is1
, it means the operation has already been performed, sodoSlow
is skipped, avoiding unnecessary lock contention. -
Second check: After acquiring the lock, it checks
done
again. This ensures that no other goroutine executed the function during the time the lock was being acquired.
Double-checking helps avoid lock contention in most cases and improves performance.
Enhanced sync.Once
The Do
method provided by sync.Once
does not return a value, which means if the passed-in function returns an error and fails to initialize, subsequent calls to Do
will not retry the initialization. To address this issue, we can implement a custom synchronization primitive similar to sync.Once
.
package main
import (
"sync"
"sync/atomic"
)
type Once struct {
done uint32
m sync.Mutex
}
func (o *Once) Do(f func() error) error {
if atomic.LoadUint32(&o.done) == 0 {
return o.doSlow(f)
}
return nil
}
func (o *Once) doSlow(f func() error) error {
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done == 0 {
err = f()
// Only set done if no error occurred
if err == nil {
atomic.StoreUint32(&o.done, 1)
}
}
return err
}
The code above implements an enhanced Once
struct. Unlike the standard sync.Once
, this version allows the function passed to the Do
method to return an error. If the function returns no error, done
is set to indicate the function has been successfully executed. On subsequent calls, the function will only be skipped if it previously completed successfully, avoiding unhandled initialization failures.
Caveats of sync.Once
Deadlock
From analyzing the sync.Once
source code, we know it includes a mutex field m
. If we call Do
recursively inside another Do
call, it results in multiple attempts to acquire the same lock. Since a mutex is not reentrant, this leads to a deadlock.
func main() {
once := sync.Once{}
once.Do(func() {
once.Do(func() {
fmt.Println("init...")
})
})
}
Initialization Failure
Initialization failure here refers to an error occurring during the execution of the function passed to Do
. The standard sync.Once
does not provide a way to detect such failures. To solve this issue, we can use the enhanced Once
mentioned earlier, which supports error handling and conditional retries.
Conclusion
This article has provided a detailed introduction to sync.Once
in the Go programming language, including its basic definition, usage scenarios, application examples, and source code analysis.
In actual development, sync.Once
is frequently used to implement the singleton pattern and lazy initialization.
Although sync.Once
is simple and efficient, incorrect usage can lead to unexpected issues, so it must be used with care.
In summary, sync.Once
is a very useful concurrency primitive in Go that helps developers perform thread-safe operations in various concurrent scenarios. Whenever you encounter a situation where an operation should only be initialized once, sync.Once
is an excellent choice.
We are Leapcell, your top choice for hosting Go projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ
This content originally appeared on DEV Community and was authored by Leapcell