A Deep Dive into Go’s sync.Once



This content originally appeared on DEV Community and was authored by Leapcell

Cover

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 a uint32 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 check done. If the value is 1, it means the operation has already been performed, so doSlow 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

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!

Try Leapcell

Follow us on X: @LeapcellHQ

Read on our blog


This content originally appeared on DEV Community and was authored by Leapcell