Unveiling the Power of `sync.Once`: Ensuring Single Execution in Go
Daniel Hayes
Full-Stack Engineer · Leapcell

The Go standard library is a treasure trove of well-engineered concurrency primitives, and the sync
package stands out as a cornerstone for building robust and thread-safe applications. Among its various offerings, sync.Once
is a particularly elegant and powerful construct designed to solve a common concurrency problem: ensuring a specific piece of code is executed exactly once, no matter how many goroutines try to invoke it concurrently.
The Problem: One-Time Initialization
Consider a scenario where you need to initialize a global resource or load configuration only once during the entire lifecycle of your application. This resource might be a database connection pool, a expensive-to-create object, or an HTTP client. Without proper synchronization, multiple goroutines attempting to access or initialize this resource simultaneously could lead to:
- Redundant Initialization: The resource is initialized multiple times, wasting computational resources and potentially leading to inconsistent states.
- Race Conditions: If the initialization involves modifying shared state, concurrent access without synchronization can result in data corruption.
A naive approach might involve a global boolean flag and a mutex:
package main import ( "fmt" "sync" "time" ) var ( initialized bool mu sync.Mutex config string ) func initConfigNaive() { mu.Lock() defer mu.Unlock() if !initialized { fmt.Println("Initializing configuration (naive approach)...") time.Sleep(100 * time.Millisecond) // Simulate expensive initialization config = "Loaded Global Config" initialized = true fmt.Println("Configuration initialized.") } else { fmt.Println("Configuration already initialized, skipping.") } } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d trying to get config...\n", id) initConfigNaive() fmt.Printf("Goroutine %d got config: %s\n", id, config) }(i) } wg.Wait() fmt.Println("All goroutines finished.") }
While this "naive" approach works, it's verbose and error-prone. You have to remember to declare the flag, the mutex, and implement the if !initialized
check every time. This is precisely the problem sync.Once
elegantly solves.
Enter sync.Once
: Simplicity and Guarantees
The sync.Once
type provides a simple Do
method that takes a function as an argument. The magic of sync.Once
is that it guarantees the function passed to its Do
method will be executed exactly once, even if Do
is called concurrently by multiple goroutines. Subsequent calls to Do
will do nothing, but they will wait for the initial execution to complete if it's still in progress.
A sync.Once
variable should not be copied after first use. It is typically embedded in a struct or used as a global variable. Its zero value is ready to use.
Let's refactor our configuration initialization using sync.Once
:
package main import ( "fmt" "sync" "time" ) var ( once sync.Once config string // Our global configuration ) // initConfigOnce simulates an expensive one-time initialization. func initConfigOnce() { fmt.Println("Initializing configuration (using sync.Once)...") time.Sleep(100 * time.Millisecond) // Simulate heavy work config = "Secret Application Config" fmt.Println("Configuration initialized.") } // GetConfig ensures that initConfigOnce is called only once. func GetConfig() string { once.Do(initConfigOnce) // This line guarantees initConfigOnce runs only once. return config } func main() { var wg sync.WaitGroup fmt.Println("Starting concurrent attempts to get config...") // Launch multiple goroutines to concurrently call GetConfig for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d trying to get config...\n", id) c := GetConfig() // All calls will funnel through once.Do fmt.Printf("Goroutine %d got config: %s\n", id, c) }(i) } wg.Wait() fmt.Println("\nAll goroutines finished. Config value is:", config) // Subsequent calls to GetConfig will not re-run initConfigOnce fmt.Println("\nCalling GetConfig again (should not re-initialize):") c := GetConfig() fmt.Println("Second call got config:", c) }
When you run this code, you will observe that Initializing configuration (using sync.Once)...
and Configuration initialized.
messages appear only once, even though GetConfig()
is called multiple times concurrently. All subsequent calls to GetConfig()
after the first successful initialization will immediately return the config
value without re-executing initConfigOnce
.
Under the Hood: How sync.Once
Works (Simplified)
While the internal implementation of sync.Once
is a bit more nuanced and optimized for performance (especially using atomic operations), conceptually it operates much like our initialized
flag and sync.Mutex
example, but with critical differences:
- Atomic Operations:
sync.Once
typically leverages atomic operations (likesync/atomic.LoadUint32
andsync/atomic.CompareAndSwapUint32
) to check if the function has already been run. This makes the check extremely fast and avoids the overhead of a full mutex lock/unlock for every check after the first execution. - Mutex for First Execution: For the very first call that finds the function not yet executed, it then acquires a
sync.Mutex
(or a similar synchronization primitive) to ensure that only one goroutine performs the actual initialization. - State Management: An internal field (often an integer or boolean) tracks the execution state. Once the function completes successfully, this state is updated atomically to indicate completion.
This pattern ensures that sync.Once
is highly efficient. Subsequent calls, after initialization, involve only a fast atomic read to determine that the function has already run, leading to minimal overhead.
Use Cases for sync.Once
sync.Once
is ideal for various scenarios requiring single-time execution:
- Global Resource Initialization: Database connection pools, application-wide configuration loading, setting up logging systems.
- Lazy Initialization: Initialize an expensive object only when it's first needed.
- Singleton Pattern Implementation: While Go doesn't have traditional classes,
sync.Once
is perfect for ensuring only one instance of a "service" or "manager" is ever created.
Example: Singleton Database Connection
package main import ( "fmt" "sync" "time" ) // DBClient represents our simulated database client. type DBClient struct { Name string } func (db *DBClient) Query(sql string) string { return fmt.Sprintf("Executing query '%s' on %s", sql, db.Name) } var ( dbOnce sync.Once dbConnection *DBClient ) func createDBConnection() { fmt.Println("Establishing database connection...") time.Sleep(500 * time.Millisecond) // Simulate connection setup time dbConnection = &DBClient{Name: "PostgresDB"} fmt.Println("Database connection established.") } // GetDBClient provides the singleton database client. func GetDBClient() *DBClient { dbOnce.Do(createDBConnection) return dbConnection } func main() { var wg sync.WaitGroup fmt.Println("Multiple goroutines attempting to get DB client:") for i := 0; i < 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d requesting DB client...\n", id) client := GetDBClient() fmt.Printf("Goroutine %d received client: %p, query result: %s\n", id, client, client.Query(fmt.Sprintf("SELECT * FROM users WHERE id=%d", id))) }(i) } wg.Wait() fmt.Println("\nAll goroutines finished. Verifying client instance:") client1 := GetDBClient() client2 := GetDBClient() fmt.Printf("Client 1 address: %p\n", client1) fmt.Printf("Client 2 address: %p\n", client2) fmt.Println("Are clients identical?", client1 == client2) // Should be true }
This example clearly shows how sync.Once
can be used to manage a single, shared database connection, preventing redundant connection attempts and ensuring all parts of the application use the same instance.
Important Considerations
- Panic Handling: If the function passed to
Do
panics,sync.Once
considers the call to have completed and will not re-execute the function on subsequent calls. This is usually the desired behavior for unrecoverable initialization errors. However, if you need to retry initialization after a panic,sync.Once
is not the right tool; you'd need a more intricate state management system. - Idempotence: The function passed to
Do
should ideally be idempotent, meaning calling it multiple times (even ifsync.Once
prevents actual re-execution) would not cause any side effects if hypothetically it were re-executed. This helps in reasoning about your code. - Initialization vs. Ongoing Logic:
sync.Once
is strictly for one-time initialization. It's not a general-purpose synchronization mechanism for protecting shared state during ongoing operations. For that, you'd usesync.Mutex
,sync.RWMutex
, or channels.
Conclusion
sync.Once
is a prime example of Go's philosophy of providing simple yet powerful concurrency primitives. By abstracting away the complexities of atomic operations, flag management, and synchronization waits, it allows developers to effortlessly guarantee that a block of code runs exactly once. Its elegance and efficiency make it an indispensable tool in the Go developer's toolkit for building robust and performant concurrent applications. Embrace sync.Once
when you need that single, definitive execution, and your code will be cleaner, safer, and more idiomatic.