Go Performance Guide
Concurrency Patterns

Lazy Initialization

Implement thread-safe lazy initialization with sync.Once, avoid double-checked locking, and leverage Go 1.21+ generic helpers.

Lazy Initialization

Lazy initialization defers expensive resource allocation until first use. Go's sync.Once provides an elegant, efficient way to initialize shared resources exactly once in concurrent contexts.

sync.Once for Thread-Safe Initialization

sync.Once ensures a function executes exactly once, even when called from multiple goroutines:

import "sync"

var (
	instance *Database
	once     sync.Once
)

func GetDatabase() *Database {
	once.Do(func() {
		instance = &Database{
			conn: createConnection(),
		}
	})
	return instance
}

// Safe to call from multiple goroutines
go func() {
	db := GetDatabase()
	db.Query(...)
}()
go func() {
	db := GetDatabase()
	db.Execute(...)
}()

Key properties of sync.Once:

  1. Atomic: Function executes exactly once, guaranteed
  2. Safe: No race conditions even with concurrent calls
  3. Blocking: Goroutines calling Do() before completion block until it finishes
  4. Cheap: Only one mutex acquisition in the common case (after first initialization)

How sync.Once Works Internally

Understanding the internal mechanics helps appreciate its efficiency:

// Simplified internal structure (not actual code)
type Once struct {
	m    sync.Mutex
	done uint32  // Atomic flag
}

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

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

The optimization:

  1. Fast path: Atomic load of done flag (lock-free)
  2. Slow path: Mutex acquired only on first call
  3. After initialization: Every call just reads the atomic flag (no mutex)

This makes Once extremely cheap for the common case (already initialized).

Benchmark: sync.Once Performance

package benchmark

import (
	"sync"
	"testing"
)

var initCount int

func slowInit() {
	initCount++
}

func BenchmarkOnceAfterInit(b *testing.B) {
	once := &sync.Once{}
	once.Do(slowInit)  // Initialize first

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		once.Do(slowInit)  // No-op, just flag check
	}
	// ~6 ns/op - essentially free after initialization
}

func BenchmarkMutexAfterInit(b *testing.B) {
	var mu sync.Mutex

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		mu.Lock()
		// Do nothing
		mu.Unlock()
	}
	// ~50 ns/op - requires lock/unlock even though nothing happens
}

// Results: Once is ~8x faster than mutex for subsequent accesses

Go 1.19+ Typed Atomics Comparison

You might implement lazy initialization with atomics, but Once is superior:

// Atomic-based initialization (not recommended)
var instance atomic.Pointer[Database]

func GetDatabaseAtomic() *Database {
	db := instance.Load()
	if db == nil {
		newDB := createDatabase()
		// Double-checked pattern with CAS
		if instance.CompareAndSwap(nil, newDB) {
			db = newDB
		} else {
			db = instance.Load()
			// Lost race, close our instance
			newDB.Close()
		}
	}
	return db
}

// Once-based initialization (recommended)
var (
	instance   *Database
	initOnce   sync.Once
	initError  error
)

func GetDatabase() (*Database, error) {
	initOnce.Do(func() {
		instance, initError = createDatabase()
	})
	return instance, initError
}

Once is superior because:

  1. Simpler: No race detection complexity
  2. Handles errors: Can capture error during initialization
  3. Less code: Clearer intent
  4. Standard pattern: Expected by Go developers

sync.OnceValue and sync.OnceFunc (Go 1.21+)

Go 1.21 introduced generic helpers that integrate return value capture:

// OnceValue: wraps value and handles initialization
var dbOnce sync.OnceValue[*Database]

func GetDatabase() *Database {
	return dbOnce.Load(func() *Database {
		return &Database{
			conn: createConnection(),
		}
	})
}

// OnceFunc: wraps a function and memoizes its result
var getConfig sync.OnceFunc[Config]

func init() {
	getConfig = sync.OnceFunc(loadConfig)
}

func HandleRequest() {
	cfg := getConfig()  // Loads config only on first call
	useConfig(cfg)
}

Benefits of OnceValue/OnceFunc:

  1. Type-safe: Generic over return type
  2. Error handling: Can return errors
  3. Simpler syntax: No separate variable declarations
  4. Composable: Easier to use in libraries

OnceValue with Error Handling

type Result struct {
	Value *Database
	Error error
}

var dbOnce sync.OnceValue[Result]

func GetDatabase() (*Database, error) {
	result := dbOnce.Load(func() Result {
		db, err := connectDatabase()
		return Result{db, err}
	})
	return result.Value, result.Error
}

The Double-Checked Locking Anti-Pattern

Double-checked locking (DCL) is a pitfall developers from languages like Java might attempt:

// WRONG: Double-checked locking (anti-pattern)
var instance *Database
var mu sync.Mutex

func GetDatabase() *Database {
	if instance == nil {  // First check without lock (UNSAFE)
		mu.Lock()
		if instance == nil {  // Second check with lock
			instance = &Database{}
		}
		mu.Unlock()
	}
	return instance
}

Why this is wrong in Go:

  1. Memory visibility: The first check might see stale data due to CPU caches
  2. Race conditions: Between first check and acquiring lock, another goroutine might initialize
  3. Complexity: Hard to reason about correctness
  4. Go idiom: sync.Once handles this correctly and simply

Always use sync.Once instead of DCL in Go.

init() vs sync.Once Trade-Offs

init() Function

var db *Database

func init() {
	var err error
	db, err = connectDatabase()
	if err != nil {
		panic(err)  // init failures panic
	}
}

func GetDatabase() *Database {
	return db  // Already initialized
}

Advantages of init():

  • Runs once automatically during program startup
  • Guaranteed to complete before main()
  • Simple, no repeated initialization code

Disadvantages of init():

  • All packages initialize, even if unused
  • Failure panics (can't recover gracefully)
  • Testing is harder (can't reset state)
  • Startup latency: waits for all init() calls

sync.Once

var (
	db   *Database
	once sync.Once
	err  error
)

func GetDatabase() (*Database, error) {
	once.Do(func() {
		db, err = connectDatabase()
	})
	return db, err
}

Advantages of sync.Once:

  • Lazy: Only initialize if actually used
  • Recoverable: Can handle errors gracefully
  • Testable: Can reset for new test cases
  • Flexible: Initialization deferred to first use

Disadvantages of sync.Once:

  • Needs explicit call site
  • Slightly more complex than init()
  • Must handle potential errors

Use sync.Once for optional resources and error handling. Use init() for required startup setup.

Lazy Singleton Pattern

The singleton pattern with lazy initialization is common:

type Config struct {
	DatabaseURL string
	LogLevel    string
	APIKey      string
}

var config struct {
	sync.Mutex
	instance *Config
}

func GetConfig() *Config {
	config.Lock()
	defer config.Unlock()

	if config.instance == nil {
		config.instance = &Config{
			DatabaseURL: os.Getenv("DATABASE_URL"),
			LogLevel:    os.Getenv("LOG_LEVEL"),
			APIKey:      os.Getenv("API_KEY"),
		}
	}
	return config.instance
}

This is functional but less elegant than sync.Once:

// Better: Using sync.Once
var (
	configInstance *Config
	configOnce     sync.Once
)

func GetConfig() *Config {
	configOnce.Do(func() {
		configInstance = &Config{
			DatabaseURL: os.Getenv("DATABASE_URL"),
			LogLevel:    os.Getenv("LOG_LEVEL"),
			APIKey:      os.Getenv("API_KEY"),
		}
	})
	return configInstance
}

// Best: Using sync.OnceValue (Go 1.21+)
var getConfig = sync.OnceValue(func() *Config {
	return &Config{
		DatabaseURL: os.Getenv("DATABASE_URL"),
		LogLevel:    os.Getenv("LOG_LEVEL"),
		APIKey:      os.Getenv("API_KEY"),
	}
})

func GetConfig() *Config {
	return getConfig()
}

Connection Pool Lazy Initialization Example

A practical example: lazily initializing a database connection pool:

type DatabasePool struct {
	maxConns int
	conns    chan *Conn
	once     sync.Once
}

func NewDatabasePool(maxConns int) *DatabasePool {
	return &DatabasePool{
		maxConns: maxConns,
	}
}

func (p *DatabasePool) Get() (*Conn, error) {
	// Ensure pool is initialized exactly once
	p.once.Do(func() {
		p.conns = make(chan *Conn, p.maxConns)
		for i := 0; i < p.maxConns; i++ {
			conn := &Conn{}
			conn.Connect()
			p.conns <- conn
		}
	})

	select {
	case conn := <-p.conns:
		return conn, nil
	case <-time.After(5 * time.Second):
		return nil, ErrConnectionPoolExhausted
	}
}

func (p *DatabasePool) Release(conn *Conn) {
	p.conns <- conn
}

// Safe to use from multiple goroutines
var pool = NewDatabasePool(10)

func HandleRequest() {
	conn, _ := pool.Get()
	defer pool.Release(conn)

	conn.Query(...)
}

Benchmark: sync.Once Overhead vs Eager Initialization

package benchmark

import (
	"sync"
	"testing"
)

type ExpensiveResource struct {
	data [1024]int
}

func createResource() *ExpensiveResource {
	r := &ExpensiveResource{}
	// Simulate expensive initialization
	for i := 0; i < 1024; i++ {
		r.data[i] = i
	}
	return r
}

// Eager initialization
var eagerResource *ExpensiveResource

func init() {
	eagerResource = createResource()
}

func BenchmarkEagerAccess(b *testing.B) {
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = eagerResource
	}
}

// Lazy initialization with sync.Once
var (
	lazyResource *ExpensiveResource
	lazyOnce     sync.Once
)

func getLazyResource() *ExpensiveResource {
	lazyOnce.Do(func() {
		lazyResource = createResource()
	})
	return lazyResource
}

func BenchmarkLazyAccessPostInit(b *testing.B) {
	getLazyResource()  // Initialize first
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = getLazyResource()
	}
}

// Using sync.OnceValue (Go 1.21+)
var getValueResource = sync.OnceValue(createResource)

func BenchmarkOnceValueAccess(b *testing.B) {
	getValueResource()  // Initialize first
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = getValueResource()
	}
}

// Results after initialization:
// BenchmarkEagerAccess        20000000    45 ns/op    (direct variable access)
// BenchmarkLazyAccessPostInit 20000000    47 ns/op    (atomic flag check)
// BenchmarkOnceValueAccess    20000000    48 ns/op    (generic wrapper overhead)

The overhead of Once/OnceValue is negligible (just atomic flag check).

Initialization Failure Handling

Always consider what happens if initialization fails:

type Logger struct {
	file *os.File
}

var (
	logger *Logger
	once   sync.Once
	err    error
)

func GetLogger() (*Logger, error) {
	once.Do(func() {
		file, fileErr := os.OpenFile("app.log",
			os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
		if fileErr != nil {
			err = fileErr
			return
		}
		logger = &Logger{file: file}
	})
	return logger, err
}

// All callers must check error
func handleRequest() error {
	log, err := GetLogger()
	if err != nil {
		return fmt.Errorf("failed to initialize logger: %w", err)
	}
	// Use logger
	return nil
}

Best Practices

  1. Prefer sync.Once: For shared mutable state initialization
  2. Use sync.OnceValue (Go 1.21+): For returning initialized values
  3. Avoid DCL: Never use double-checked locking
  4. Handle errors: Capture and return initialization errors
  5. Use init() for mandatory setup: Package-level initialization that must succeed
  6. Lazy-initialize optional resources: Use sync.Once for deferred initialization
  7. Keep initialization functions pure: Don't modify globals inside Do()
  8. Document thread safety: Make clear that functions are concurrency-safe
  9. Test initialization once: Verify that init code runs exactly once
  10. Consider startup cost: Balance eager init (simpler) vs lazy init (faster startup)

Testing Lazy Initialization

Testing lazy initialization requires care:

func TestConfigInitialization(t *testing.T) {
	// Can't reset sync.Once, so test in separate process
	// or use build tags

	t.Run("initialize once", func(t *testing.T) {
		var counter int
		var once sync.Once

		for i := 0; i < 10; i++ {
			go func() {
				once.Do(func() {
					counter++
				})
			}()
		}

		time.Sleep(100 * time.Millisecond)
		if counter != 1 {
			t.Errorf("expected counter=1, got %d", counter)
		}
	})
}

// For resettable testing, use a wrapper
type TestableOnce struct {
	once sync.Once
	done uint32
}

func (o *TestableOnce) Reset() {
	o.done = 0
	o.once = sync.Once{}
}

func (o *TestableOnce) Do(f func()) {
	o.once.Do(f)
}

Lazy initialization with sync.Once is the foundation of safe, efficient resource management in Go. Combined with understanding trade-offs between eager and lazy approaches, it enables building systems that are both responsive and resource-efficient.

On this page