sync.Once — Go's simple pattern for safe one-time execution.
Leapcell

Leapcell @leapcell

About: leapcell.io: serverless web hosting / async task / redis

Location:
California
Joined:
Jul 31, 2024

sync.Once — Go's simple pattern for safe one-time execution.

Publish Date: Jul 17
7 0

Leapcell: The Best of Serverless Web Hosting

🔍 The Essence of Go Concurrency: A Comprehensive Guide to sync.Once Family

In Go concurrent programming, ensuring an operation is executed only once is a common requirement. As a lightweight synchronization primitive in the standard library, sync.Once solves this problem with an extremely simple design. This article takes you to a deep understanding of the usage and principles of this powerful tool.

🎯 What is sync.Once?

sync.Once is a synchronization primitive in the Go language's sync package. Its core function is to guarantee that a certain operation is executed only once during the program's lifecycle, regardless of how many goroutines call it simultaneously.

The official definition is concise and powerful:

Once is an object that ensures a certain operation is performed only once.
Once the Once object is used for the first time, it must not be copied.
The return of the f function "synchronizes before" the return of any call to once.Do(f).

The last point means: after f finishes executing, its results are visible to all goroutines that call once.Do(f), ensuring memory consistency.

💡 Typical Usage Scenarios

  1. Singleton pattern: Ensure that database connection pools, configuration loading, etc., are initialized only once
  2. Lazy loading: Load resources only when needed, and only once
  3. Concurrent safe initialization: Safe initialization in a multi-goroutine environment

🚀 Quick Start

sync.Once is extremely simple to use, with only one core Do method:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once
    onceBody := func() {
        fmt.Println("Only once")
    }

    // Start 10 goroutines to call concurrently
    done := make(chan bool)
    for i := 0; i < 10; i++ {
        go func() {
            once.Do(onceBody)
            done <- true
        }()
    }

    // Wait for all goroutines to complete
    for i := 0; i < 10; i++ {
        <-done
    }
}
Enter fullscreen mode Exit fullscreen mode

The running result is always:

Only once
Enter fullscreen mode Exit fullscreen mode

Even if called multiple times in a single goroutine, the result is the same — the function will only execute once.

🔍 In-depth Source Code Analysis

The source code of sync.Once is extremely concise (only 78 lines, including comments), but it contains an exquisite design:

type Once struct {
    done atomic.Uint32  // Identifies whether the operation has been executed
    m    Mutex          // Mutex lock
}

func (o *Once) Do(f func()) {
    if o.done.Load() == 0 {
        o.doSlow(f)  // Slow path, allowing fast path inlining
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 {
        defer o.done.Store(1)
        f()
    }
}
Enter fullscreen mode Exit fullscreen mode

Design Highlights:

  1. Double-Check Locking:

    • First check (without lock): quickly determine if it has been executed
    • Second check (after locking): ensure concurrent safety
  2. Performance Optimization:

    • The done field is placed at the beginning of the struct to reduce pointer offset calculation
    • Separation of fast and slow paths allows inlining optimization of the fast path
    • Locking is only needed for the first execution, and subsequent calls have zero overhead
  3. Why not implement with CAS?
    The comment clearly explains: A simple CAS cannot guarantee that the result is returned only after f has finished executing, which may cause other goroutines to get unfinished results.

⚠️ Precautions

  1. Not copyable: Once contains a noCopy field, and copying after the first use will lead to undefined behavior
   // Wrong example
   var once sync.Once
   once2 := once  // Compilation will not report an error, but problems may occur during runtime
Enter fullscreen mode Exit fullscreen mode
  1. Avoid recursive calls: If once.Do(f) is called again in f, it will cause a deadlock

  2. Panic handling: If a panic occurs in f, it will be regarded as executed, and subsequent calls will no longer execute f

✨ New Features in Go 1.21

Go 1.21 added three practical functions to the sync.Once family, expanding its capabilities:

1. OnceFunc: Single-execution function with panic handling

func OnceFunc(f func()) func()
Enter fullscreen mode Exit fullscreen mode

Features:

  • Returns a function that executes f only once
  • If f panics, the returned function will panic with the same value on each call
  • Concurrent safe

Example:

package main

import (
    "fmt"
    "sync"
)

func main() {
    // Create a function that executes only once
    initialize := sync.OnceFunc(func() {
        fmt.Println("Initialization completed")
    })

    // Concurrent calls
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            initialize()
        }()
    }
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

Compared with the native once.Do: When f panics, OnceFunc will re-panic the same value on each call, while the native Do will only panic on the first time.

2. OnceValue: Single calculation and return value

func OnceValue[T any](f func() T) func() T
Enter fullscreen mode Exit fullscreen mode

Suitable for scenarios where results need to be calculated and cached:

package main

import (
    "fmt"
    "sync"
)

func main() {
    // Create a function that calculates only once
    calculate := sync.OnceValue(func() int {
        fmt.Println("Start complex calculation")
        sum := 0
        for i := 0; i < 1000000; i++ {
            sum += i
        }
        return sum
    })

    // Multiple calls, only the first calculation
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println("Result:", calculate())
        }()
    }
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

3. OnceValues: Supports returning two values

func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)
Enter fullscreen mode Exit fullscreen mode

Perfectly adapts to the Go function's idiom of returning (value, error):

package main

import (
    "fmt"
    "os"
    "sync"
)

func main() {
    // Read file only once
    readFile := sync.OnceValues(func() ([]byte, error) {
        fmt.Println("Reading file")
        return os.ReadFile("config.json")
    })

    // Concurrent reading
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            data, err := readFile()
            if err != nil {
                fmt.Println("Error:", err)
                return
            }
            fmt.Println("File length:", len(data))
        }()
    }
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

🆚 Feature Comparison

Function Characteristics Applicable Scenarios
Once.Do Basic version, no return value Simple initialization
OnceFunc With panic handling Initialization that needs error handling
OnceValue Supports returning a single value Calculating and caching results
OnceValues Supports returning two values Operations with error returns

It is recommended to use the new functions first, as they provide better error handling and a more intuitive interface.

🎬 Practical Application Cases

1. Singleton Pattern Implementation

type Database struct {
    // Database connection information
}

var (
    dbInstance *Database
    dbOnce     sync.Once
)

func GetDB() *Database {
    dbOnce.Do(func() {
        // Initialize database connection
        dbInstance = &Database{
            // Configuration information
        }
    })
    return dbInstance
}
Enter fullscreen mode Exit fullscreen mode

2. Lazy Loading of Configuration

type Config struct {
    // Configuration items
}

var loadConfig = sync.OnceValue(func() *Config {
    // Load configuration from file or environment variables
    data, _ := os.ReadFile("config.yaml")
    var cfg Config
    _ = yaml.Unmarshal(data, &cfg)
    return &cfg
})

// Usage
func main() {
    cfg := loadConfig()
    // Use configuration...
}
Enter fullscreen mode Exit fullscreen mode

3. Resource Pool Initialization

var initPool = sync.OnceFunc(func() {
    // Initialize connection pool
    pool = NewPool(
        WithMaxConnections(10),
        WithTimeout(30*time.Second),
    )
})

func GetResource() (*Resource, error) {
    initPool()  // Ensure the pool is initialized
    return pool.Get()
}
Enter fullscreen mode Exit fullscreen mode

🚀 Performance Considerations

sync.Once has excellent performance. The overhead of the first call mainly comes from the mutex lock, and subsequent calls have almost zero overhead:

  • First call: about 50-100ns (depending on lock competition)
  • Subsequent calls: about 1-2ns (only atomic loading operation)

In high-concurrency scenarios, compared with other synchronization methods (such as mutex locks), it can significantly reduce performance loss.

📚 Summary

sync.Once solves the problem of single execution in a concurrent environment with an extremely simple design, and its core ideas are worth learning:

  1. Implement thread safety with minimal overhead
  2. Separate fast and slow paths to optimize performance
  3. Clear memory model guarantee

The three new functions added in Go 1.21 further improve its practicality, making the single execution logic more concise and robust.

Mastering the sync.Once family allows you to handle scenarios such as concurrent initialization and singleton patterns with ease, and write more elegant and efficient Go code.

Leapcell: The Best of Serverless Web Hosting

Finally, I recommend the best platform for deploying Go services: Leapcell

🚀 Build with Your Favorite Language

Develop effortlessly in JavaScript, Python, Go, or Rust.

🌍 Deploy Unlimited Projects for Free

Only pay for what you use—no requests, no charges.

⚡ Pay-as-You-Go, No Hidden Costs

No idle fees, just seamless scalability.

📖 Explore Our Documentation

🔹 Follow us on Twitter: @LeapcellHQ

Comments 0 total

    Add comment