Understanding Go's sync.Once: Fast-Path and Slow-Path Programming Pattern

sync.Once Overview

sync.Once is a Go standard library mechanism designed to ensure a function executes exactly once, regardless of concurrent access patterns. This pattern is particularly useful for one-time initialization tasks, such as setting up singleton clients or loading configuration data.

The Fast-Path and Slow-Path Pattern

Slow Path

The slow path represents a conservative, thread-safe approach that prioritizes correctness over performance. It typically involves synchronization primitives like mutexes to guarantee data consistency. While this approach ensures correctness in concurrent scenarios, it introduces performance overhead due to explicit locking operations.

Fast Path

The fast path implements an optimistic, high-performance strategy using non-blocking synchronization mechanisms like atomic operations. By avoiding mutex contention, this approach minimizes synchronization overhead. However, it carries the risk of race conditions in certain edge cases.

Implementation Analysis

type Once struct {
    done uint32
    mu   Mutex
}

func (o *Once) Do(fn func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(fn)
    }
}

func (o *Once) doSlow(fn func()) {
    o.mu.Lock()
    defer o.mu.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        fn()
    }
}

The struct contains two fields: a Mutex for synchronization and a uint32 flag tracking initialization state.

The Do method first attempts the fast path by atomically checking the done flag. If initialization is already complete, it returns immediately without any locking overhead.

When the fast path fails (meaning initialization hasn't occurred), execution falls through to doSlow, which acquires a mutex before proceeding with double-checked locking. The second check inside the locked section prevents race conditions when multiple goroutines reach the slow path simultaneously.

After fn() completes successfully, done is atomically set to 1 using a deferred call.

Practical Applications

Singleton Client Initialization

var (
    initOnce sync.Once
    rpcClient *RPCClient
)

type RPCClient struct {
    connection string
}

func GetRPCClient() *RPCClient {
    initOnce.Do(func() {
        rpcClient = &RPCClient{
            connection: "tcp://localhost:8080",
        }
    })
    return rpcClient
}

This pattern ensures that expensive resources like RPC clients or Kafka consumers are iintialized exactly once, preventing redundant connection attempts.

Configuration Loading

var configOnce sync.Once
var globalConfig *Config

type Config struct {
    Host string
    Port int
}

func LoadConfig(path string) (*Config, error) {
    var loadErr error
    configOnce.Do(func() {
        data, err := os.ReadFile(path)
        if err != nil {
            loadErr = err
            return
        }
        globalConfig = &Config{}
        json.Unmarshal(data, globalConfig)
    })
    return globalConfig, loadErr
}

Unlike init() functions which run before main() and have strict ordering constraints, sync.Once provides explicit control over initialization timing, which is critical when subsequent operations depend on loaded configuration.

Key Design Considerations

The function argument fn executes before the atomic store operation sets done to 1. This ordering is essential: any goroutine that sees done == 1 must be able to safely use the result of fn() without additional synchronization.

A subtle but important question arises: why not replace the slow path with atomic.CompareAndSwapUint32?

// This approach is incorrect
if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    fn()
}

This implementation fails because CompareAndSwapUint32 cannot guarantee that fn() has completed when Do returns. Consider two goroutines calling Do concurrently on a fresh Once. The first goroutine might set done to 1 via CAS and begin executing fn(), while a second goroutine also successfully performs CAS and returns immediately. If the second goroutine attempts to use the result of fn() before the first completes, the program enters an undefined state.

The slow path with mutex ensures proper happens-before guarantees: the mutex acquisition in the slow path synchronizes with subsequent lock acquisitions, ensuring that any goroutine observing done == 1 has visibility to the fully initialized state.

Tags: Go sync Concurrency programming-patterns source-code-analysis

Posted on Sun, 10 May 2026 04:45:18 +0000 by Gary Kambic