Skip to Content
Course content

160: sync.Once for One-Time Initialization

Click on the "Edit" button in the top corner of the screen to edit your slide content.

In a real-world Go application, you'll often run into a situation where you have a heavy resource—like a database connection pool, a configuration object, or a complex cache—that you only want to initialize once. You don't want to do it at the very start of main() because it might slow down your startup time or fail if a dependency isn't ready yet. You want "lazy initialization": create it the first time someone actually asks for it, and then reuse that same instance forever.

The Goal: A Lazy-Loaded Config

Let's build a simple configuration loader. Imagine we have a Config struct that reads from a file. Reading from a disk is slow, so we definitely don't want to do it every time we need a setting. I'll start by defining our struct and a global variable to hold the singleton instance.

type Config struct {
    APIKey string
    Port   int
}

var instance *Config

The Naive Approach (and where I messed up)

When I first started writing these types of patterns, I thought a simple if check was enough. It seems logical: "If the instance is nil, create it. Otherwise, just return it." Here is how I wrote it originally:

func GetConfig() *Config {
    if instance == nil {
        fmt.Println("Loading config from disk...")
        instance = &Config{
            APIKey: "secret-123",
            Port:   8080,
        }
    }
    return instance
}

This works fine in a single-threaded program. But we're writing Go, and we're almost always dealing with goroutines. If two goroutines call GetConfig() at the exact same millisecond, they both might see instance == nil as true. They'll both hit the disk, and they'll both try to assign a value to instance. That's a classic data race. If you run this with the -race flag, Go will scream at you.

Solving it with sync.Once

I could wrap that check in a sync.Mutex, but that means every single time we call GetConfig() for the rest of the program's life, we're paying the performance penalty of locking and unlocking a mutex just to check a pointer. That's overkill.

This is exactly why sync.Once exists. It's a small, highly optimized object that guarantees a function is executed exactly once, regardless of how many goroutines call it. Here is the professional way to handle this:

import (
    "fmt"
    "sync"
)

type Config struct {
    APIKey string
    Port   int
}

var (
    instance *Config
    once     sync.Once
)

func GetConfig() *Config {
    once.Do(func() {
        fmt.Println("Loading config from disk...")
        instance = &Config{
            APIKey: "secret-123",
            Port:   8080,
        }
    })
    return instance
}

Notice that once.Do takes a function. The first goroutine to hit that line will execute the function. Any other goroutines that arrive while the first one is still running will block—they'll wait patiently until the first one finishes. Once the function returns, every subsequent call to once.Do will simply return immediately without doing anything. It's clean, thread-safe, and efficient.

A quick word on init()

You might be wondering, "Why not just use an init() function?" You can, but init() runs as soon as the package is imported. If your configuration loading fails (e.g., the file is missing), init() will either cause a panic or leave your app in a weird state before main() even starts. By using sync.Once inside a getter function, you control exactly when the initialization happens, and you can handle errors more gracefully if you decide to return an error from your getter.




📋 Practical Task

Exercise: Implement a Thread-Safe Database Connection Singleton

You are building a system where multiple goroutines need access to a shared database connection object. However, creating the connection is expensive and should only happen once.

Your Task:

  • Create a struct named DBConnection with a field ConnectionID (string).
  • Declare a global variable dbInstance of type *DBConnection.
  • Declare a sync.Once variable.
  • Implement a function GetDBConnection() *DBConnection that uses sync.Once to initialize the dbInstance with a ConnectionID of "DB-PROD-01" only on the first call.
  • In your main function, launch 10 concurrent goroutines that all call GetDBConnection() and print the resulting ConnectionID to prove that they are all sharing the exact same instance.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.