Go sync.Once: One-Time Initialization
Go's `sync.Once` is a synchronization primitive that ensures a piece of code executes exactly once, regardless of how many goroutines attempt to run it. This is invaluable for initialization tasks...
Key Insights
sync.Onceguarantees a function executes exactly once across all goroutines, solving initialization race conditions without complex locking logic- If the function passed to
Do()panics,sync.Onceconsiders it executed and will never retry, making panic handling critical sync.Onceoutperforms mutex-based initialization checks by using atomic operations internally, with near-zero overhead after the first call
Introduction to sync.Once
Go’s sync.Once is a synchronization primitive that ensures a piece of code executes exactly once, regardless of how many goroutines attempt to run it. This is invaluable for initialization tasks that are expensive, must happen only once, or would cause problems if executed multiple times.
The typical use case involves lazy initialization: you want to defer expensive setup until it’s actually needed, but once initialized, you never want to repeat that work. Without sync.Once, implementing this correctly requires careful coordination with mutexes and double-checked locking patterns that are easy to get wrong.
sync.Once provides a simple, foolproof API: create a sync.Once variable and call its Do() method with your initialization function. The standard library handles all the complexity of ensuring thread-safety and happens-before guarantees.
The Problem: Race Conditions in Initialization
Let’s see why naive approaches fail. Consider a singleton pattern without proper synchronization:
package main
import (
"fmt"
"sync"
"time"
)
type Database struct {
ConnectionID int
}
var (
instance *Database
counter int
)
func GetInstance() *Database {
if instance == nil {
counter++
time.Sleep(10 * time.Millisecond) // Simulate expensive initialization
instance = &Database{ConnectionID: counter}
fmt.Printf("Created instance with ID: %d\n", counter)
}
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
db := GetInstance()
fmt.Printf("Goroutine %d got instance: %d\n", id, db.ConnectionID)
}(i)
}
wg.Wait()
}
Running this code produces multiple “Created instance” messages. Multiple goroutines see instance == nil simultaneously and all proceed to create their own instance. This wastes resources and can cause serious bugs if your initialization has side effects like opening file handles or network connections.
How sync.Once Works
sync.Once provides a single method: Do(func()). The first goroutine to call Do() executes the provided function. All other goroutines block until that execution completes, then return immediately without executing the function again.
Here’s the basic usage:
package main
import (
"fmt"
"sync"
"time"
)
var (
instance *Database
once sync.Once
counter int
)
func GetInstance() *Database {
once.Do(func() {
counter++
time.Sleep(10 * time.Millisecond)
instance = &Database{ConnectionID: counter}
fmt.Printf("Created instance with ID: %d\n", counter)
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
db := GetInstance()
fmt.Printf("Goroutine %d got instance: %d\n", id, db.ConnectionID)
}(i)
}
wg.Wait()
}
Now you’ll see exactly one “Created instance” message, and all goroutines receive the same instance. The implementation uses atomic operations for fast-path checking and a mutex for the slow path, making it extremely efficient.
Common Use Cases
Lazy Singleton Initialization
The singleton pattern is the most common use case. Here’s a production-ready example:
package database
import (
"database/sql"
"sync"
_ "github.com/lib/pq"
)
type Database struct {
conn *sql.DB
}
var (
instance *Database
once sync.Once
initErr error
)
func GetDB() (*Database, error) {
once.Do(func() {
conn, err := sql.Open("postgres", "postgresql://localhost/mydb")
if err != nil {
initErr = err
return
}
if err := conn.Ping(); err != nil {
initErr = err
return
}
instance = &Database{conn: conn}
})
return instance, initErr
}
func (db *Database) Query(query string) (*sql.Rows, error) {
return db.conn.Query(query)
}
Notice how we capture initialization errors. Since Do() only runs once, we must store any error that occurs for subsequent callers to check.
One-Time Resource Setup
Configuration loading is another perfect use case:
package config
import (
"encoding/json"
"os"
"sync"
)
type Config struct {
DatabaseURL string `json:"database_url"`
APIKey string `json:"api_key"`
Port int `json:"port"`
}
var (
cfg *Config
once sync.Once
)
func Get() *Config {
once.Do(func() {
data, err := os.ReadFile("config.json")
if err != nil {
panic("failed to read config: " + err.Error())
}
cfg = &Config{}
if err := json.Unmarshal(data, cfg); err != nil {
panic("failed to parse config: " + err.Error())
}
})
return cfg
}
Expensive Computation Caching
When you have a computationally expensive operation that produces a constant result:
package matcher
import (
"regexp"
"sync"
)
var (
emailRegex *regexp.Regexp
once sync.Once
)
func ValidateEmail(email string) bool {
once.Do(func() {
// Complex regex compilation happens only once
emailRegex = regexp.MustCompile(
`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`,
)
})
return emailRegex.MatchString(email)
}
Important Gotchas and Best Practices
Panic Behavior
If the function passed to Do() panics, sync.Once still considers it executed. It will never retry:
package main
import (
"fmt"
"sync"
)
var once sync.Once
func initialize() {
once.Do(func() {
fmt.Println("Initializing...")
panic("initialization failed!")
})
}
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from:", r)
}
}()
initialize() // Panics
// This will NOT retry initialization
initialize() // Does nothing
fmt.Println("Second call completed")
}
This behavior means you should handle errors gracefully rather than panicking, or ensure your panic is truly unrecoverable.
Don’t Copy sync.Once
sync.Once contains internal state and must not be copied after first use:
// WRONG - copying sync.Once
type Config struct {
once sync.Once // Will be copied when Config is copied
data string
}
// CORRECT - use pointer
type Config struct {
once *sync.Once
data string
}
func NewConfig() *Config {
return &Config{
once: &sync.Once{},
}
}
The go vet tool will catch most violations of this rule.
Blocking Behavior
All goroutines calling Do() block until the first execution completes. This means a slow initialization function blocks all callers:
var once sync.Once
func slowInit() {
once.Do(func() {
time.Sleep(5 * time.Second) // All callers wait 5 seconds
fmt.Println("Done!")
})
}
This is usually what you want, but be aware of it when debugging apparent deadlocks.
Performance Comparison
sync.Once is highly optimized. Here’s a benchmark comparing it to a mutex-based approach:
package main
import (
"sync"
"testing"
)
var (
once sync.Once
mu sync.RWMutex
value int
init bool
)
func BenchmarkSyncOnce(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
once.Do(func() {
value = 42
})
_ = value
}
})
}
func BenchmarkMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.RLock()
if !init {
mu.RUnlock()
mu.Lock()
if !init {
value = 42
init = true
}
mu.Unlock()
} else {
mu.RUnlock()
}
_ = value
}
})
}
On most systems, sync.Once is 10-20x faster than the mutex approach after the first call, because it uses atomic operations that don’t require kernel involvement.
Conclusion
sync.Once is a specialized tool that solves one problem exceptionally well: ensuring initialization code runs exactly once in concurrent programs. Use it whenever you need lazy initialization of singletons, configuration, compiled regexes, or any expensive setup that should happen once.
The key takeaways:
- Always use
sync.Oncefor concurrent initialization instead of rolling your own double-checked locking - Remember that panics prevent retries—handle errors properly
- Store initialization errors in package-level variables since
Do()doesn’t return values - Never copy a
sync.Oncevalue after it’s been used - The performance characteristics make it essentially free after the first call
For most Go developers, sync.Once should be your default choice any time you write “if something == nil” in a concurrent context. It’s simple, correct, and fast—exactly what you want from a synchronization primitive.