A Developer’s Guide to sync.Once: Your Go Concurrency Lifesaver
Jones Charles

Jones Charles @jones_charles_ad50858dbc0

About: go dev

Joined:
Dec 17, 2024

A Developer’s Guide to sync.Once: Your Go Concurrency Lifesaver

Publish Date: Jun 4
0 0

1. Hey, Let’s Talk About sync.Once

If you’ve ever written Go code with goroutines flying everywhere, you’ve probably hit that moment: “Wait, how do I make sure this only runs once?” Maybe it’s loading a config file, setting up a database connection, or initializing a singleton. Without some control, you’re risking duplicate work, wasted resources, or—worse—a crash from a race condition. Enter sync.Once, Go’s unsung hero from the sync package. It’s like a bouncer at a club: only one goroutine gets in to do the job, and everyone else just chills.

I’ve been coding in Go for over a decade, building everything from microservices to AI backends, and sync.Once has saved my bacon more times than I can count. It’s simple, lightweight, and does exactly what it promises: runs a piece of code once, no matter how chaotic your concurrency gets. In this guide, I’ll walk you through what it is, how it works, and how to use it like a pro—based on real-world wins and facepalms I’ve collected along the way.

This is for you if you’ve got a year or two of Go under your belt and know your way around goroutines and mutexes. We’ll go from the basics to the nitty-gritty, with code snippets, war stories, and tips to keep you out of trouble. Ready? Let’s dive in.

2. What’s sync.Once All About?

2.1 The Big Idea

sync.Once is your “do it once and done” tool. Imagine a bunch of goroutines all trying to load the same config file at startup. Without coordination, you’d get multiple reads, wasted CPU, or even corrupted data. sync.Once steps in and says, “First one in does the work; everyone else waits and grabs the result.” It’s perfect for:

  • Singletons: Think global loggers or configs.
  • Lazy loading: Like a database pool you only need when the first request hits.
  • Heavy setup: Say, loading a machine learning model without frying your RAM.

It’s not fancy, but it’s elegant as heck—and that’s why I love it.

2.2 How to Use It

The API is dead simple. You’ve got one method:

func (o *Once) Do(f func())
Enter fullscreen mode Exit fullscreen mode
  • f: A function with no inputs or outputs.
  • What it does: Runs f the first time Do is called; skips it every time after.

Check this out:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once
    f := func() {
        fmt.Println("I’m only running once, promise!")
    }
    for i := 0; i < 5; i++ {
        go once.Do(f) // 5 goroutines, 1 execution
    }
    fmt.Scanln() // Wait to see the output
}
Enter fullscreen mode Exit fullscreen mode

Output:

I’m only running once, promise!
Enter fullscreen mode Exit fullscreen mode

Five goroutines, one print. That’s sync.Once in action—clean and drama-free.

2.3 How’s It Different?

Let’s stack it up against the competition:

  • sync.Mutex: Locks a block of code so it can run multiple times safely. More flexible, more overhead.
  • init(): Runs once per package at startup, but you can’t control when. Static and rigid.
  • sync.Once: Runs once at runtime, when you say so. Goldilocks-level “just right.”

Think of sync.Mutex as a reusable lockbox, init() as a one-time setup script, and sync.Once as a “do it once, on demand” button. For initialization tasks, it’s usually the winner.

What’s Next?

You’ve got the gist: sync.Once is a concurrency cheat code for one-time jobs. But how does it pull off this “once and only once” trick under the hood? Let’s pop the hood and peek at the source code in the next section. Spoiler: it’s clever, but not complicated.

3. How sync.Once Works Its Magic

So, sync.Once promises to run your code exactly once, even with goroutines swarming like Black Friday shoppers. How does it pull that off? Is it some dark magic or just smart engineering? Let’s dig into the Go source code and break it down. Spoiler: it’s a masterclass in balancing simplicity, safety, and speed. By the end, you’ll see why it’s so reliable—and maybe pick up a concurrency trick or two.

3.1 Peeking Under the Hood

The Tiny Struct That Could

Here’s what sync.Once looks like inside (straight from Go 1.22):

type Once struct {
    done uint32   // 0 = "not yet," 1 = "donezo"
    m    Mutex    // The bouncer guarding the door
}
Enter fullscreen mode Exit fullscreen mode
  • done: A little flag—0 means “nobody’s run it yet,” 1 means “we’re good.”
  • m: A sync.Mutex to keep things orderly when the first goroutine steps up.

It’s lean and mean—just two fields to rule them all.

The Do Method: Where It Happens

Here’s the Do method, simplified for clarity:

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 1 { // Quick peek: done already?
        return
    }
    o.m.Lock()         // First in locks the door
    defer o.m.Unlock() // Cleanup crew
    if o.done == 0 {   // Double-check under lock
        defer atomic.StoreUint32(&o.done, 1) // Mark it done when finished
        f()                                  // Party time!
    }
}
Enter fullscreen mode Exit fullscreen mode

How It Flows:

  1. Fast Check: atomic.LoadUint32(&o.done) peeks at done without locking. If it’s 1, everyone bails early—no fuss.
  2. Lock Up: If done is 0, the first goroutine grabs the mutex. Others wait in line.
  3. Double-Check: Inside the lock, it confirms done is still 0 (safety first!).
  4. Run It: The lucky goroutine runs f().
  5. Flag It: After f() finishes, done flips to 1 atomically, and the lock opens.

It’s like a VIP list: the first goroutine gets in, flips the sign to “closed,” and everyone else just nods and moves on.

Picture This

Goroutine 1    Goroutine 2    Goroutine 3
    |              |              |
  Check: 0       Check: 0       Check: 0
    |              |              |
  Lock & run      Wait           Wait
    |              |              |
  f() runs        Wait           Wait
  done = 1         |              |
  Unlock         Check: 1       Check: 1
                  Return         Return
Enter fullscreen mode Exit fullscreen mode

The first one does the heavy lifting; the rest get a free pass.

3.2 The Secret Sauce

Atomic Ops + Locks = Win

sync.Once is a hybrid beast:

  • Atomic Reads: atomic.LoadUint32 is lightning-fast for checking done. No lock, no overhead—later calls fly through.
  • Mutex: The lock kicks in only for the first call, keeping the critical section safe.

It’s like a toll booth: the first car pays, then the gate stays open for everyone else.

Why Not Go Full Atomic?

You might wonder, “Why not use CompareAndSwap (CAS) instead of a mutex?” CAS is lock-free, sure, but it’s a retry loop under contention—think spinning your wheels in traffic. sync.Once picks a mutex for the first run because it’s simpler and predictable. After that, atomic reads take over, and it’s smooth sailing.

3.3 Is It Fast? Is It Safe?

  • Safety: Between atomic ops and the mutex, it’s rock-solid. No goroutine can sneak past the “done” flag.
  • Speed:
    • First call: A little lock overhead, but still O(1).
    • After that: Just an atomic read—basically free.

In a real project, I used sync.Once to init a cache. The first hit took a tiny lock blip (0.1% of runtime), but later accesses were as fast as a variable lookup. That’s the kind of efficiency I live for.

What’s Next?

Now you know the gears behind sync.Once. It’s not magic—just clever design with atomic ops and a mutex playing tag team. So, how do you take this and run with it in your own code? In the next section, I’ll share some real-world use cases from my toolbox, complete with code and stories from the trenches.

4. Where sync.Once Saves the Day

Okay, we’ve cracked open sync.Once and seen its guts—pretty slick, right? But the real fun starts when you put it to work. Over the years, I’ve leaned on it for everything from tiny config setups to monster resource loads in production. In this section, I’ll walk you through three killer use cases with code you can steal, plus some battle scars from my own projects. Whether you’re wrangling singletons or lazy-loading a database, sync.Once has your back.

4.1 Singleton Vibes: One Config to Rule Them All

The Problem

Picture this: your app needs a global config—say, API keys or timeouts. Without control, every goroutine might try to load it, and suddenly you’ve got five copies floating around, chewing up memory and confusing everyone. Chaos.

The Fix

sync.Once makes singletons a breeze. Here’s how I’ve done it:

package main

import (
    "fmt"
    "sync"
)

type Config struct {
    Data string
}

var config *Config
var once sync.Once

func GetConfig() *Config {
    once.Do(func() {
        config = &Config{Data: "Loaded from somewhere fancy"}
        fmt.Println("Config’s ready!")
    })
    return config
}

func main() {
    for i := 0; i < 5; i++ {
        go func() {
            cfg := GetConfig()
            fmt.Println("Gotcha:", cfg.Data)
        }()
    }
    fmt.Scanln() // Hang out to see the action
}
Enter fullscreen mode Exit fullscreen mode

Output:

Config’s ready!
Gotcha: Loaded from somewhere fancy
Gotcha: Loaded from somewhere fancy
Gotcha: Loaded from somewhere fancy
Gotcha: Loaded from somewhere fancy
Gotcha: Loaded from somewhere fancy
Enter fullscreen mode Exit fullscreen mode

Why It Rocks

  • One and Done: Loads once, then every goroutine gets the same instance.
  • Lazy: Doesn’t load until someone asks—perfect for startup speed.

War Story: In a microservice, I used this to parse a YAML config. Before sync.Once, startup lagged at 200ms with duplicate reads. After? Down to 50ms. My team bought me coffee that day.

4.2 Lazy Loading: Database on Demand

The Problem

Database connections aren’t cheap—spinning up a pool takes time and resources. If every goroutine tries to make one, you’re toast. I’ve seen apps hit connection limits because of this.

The Fix

sync.Once lets you create the pool only when it’s needed. Check it:

package main

import (
    "database/sql"
    "fmt"
    "sync"
    _ "github.com/go-sql-driver/mysql"
)

var db *sql.DB
var once sync.Once

func GetDB() *sql.DB {
    once.Do(func() {
        var err error
        db, err = sql.Open("mysql", "user:password@/dbname")
        if err != nil {
            panic(err) // Don’t do this IRL—handle it!
        }
        fmt.Println("DB’s up!")
    })
    return db
}

func main() {
    for i := 0; i < 3; i++ {
        go func() {
            db := GetDB()
            fmt.Println("Connections:", db.Stats().OpenConnections)
        }()
    }
    fmt.Scanln()
}
Enter fullscreen mode Exit fullscreen mode

Output:

DB’s up!
Connections: 1
Connections: 1
Connections: 1
Enter fullscreen mode Exit fullscreen mode

Why It’s Clutch

  • On-Demand: No connection until the first call—saves resources.
  • Safe: No race conditions, no duplicates.

Pro Tip: In production, swap that panic for proper error handling. I learned that the hard way when a DB hiccup took down a service.

4.3 Big Jobs: Loading an ML Model

The Problem

Ever tried loading a machine learning model? It’s slow—like, “go get coffee” slow—and guzzles memory. If multiple goroutines kick off the load, you’re looking at crashes or a server bill that’ll make you cry.

The Fix

sync.Once keeps it sane:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Model struct {
    Name string
}

var model *Model
var once sync.Once

func LoadModel() *Model {
    once.Do(func() {
        time.Sleep(2 * time.Second) // Fake a chunky load
        model = &Model{Name: "Super Smart AI"}
        fmt.Println("Model’s loaded!")
    })
    return model
}

func main() {
    for i := 0; i < 4; i++ {
        go func() {
            m := LoadModel()
            fmt.Println("Using:", m.Name)
        }()
    }
    fmt.Scanln()
}
Enter fullscreen mode Exit fullscreen mode

Output:

Model’s loaded!
Using: Super Smart AI
Using: Super Smart AI
Using: Super Smart AI
Using: Super Smart AI
Enter fullscreen mode Exit fullscreen mode

Why It’s a Game-Changer

  • Resource Saver: One load, not four. In an image recognition app, this dropped memory from 4GB to 1GB.
  • Speed Boost: Startup went from “ugh” to “oh, nice” (60% faster in my case).

War Story: I once forgot to use sync.Once here, and a demo crashed mid-pitch. Never again.

4.4 Why You’ll Love It

  • Dead Simple: One line beats writing your own sync mess.
  • Bulletproof: Thread-safe out of the box.
  • Fast: After the first run, it’s basically free.

Quick Cheat Sheet

Job Pain Level Why Use It?
Singleton Config Easy One instance, period
Lazy DB Pool Medium On-demand, no waste
ML Model Load Hard Big task, one shot

What’s Next?

These tricks have gotten me out of jams time and again. But sync.Once isn’t perfect—there are traps to dodge. In the next section, I’ll spill the beans on best practices and pitfalls, so you don’t repeat my mistakes. Trust me, I’ve got stories.

5. sync.Once Hacks and Traps: Learn from My Mess-Ups

By now, you’re probably itching to slap sync.Once into your next Go project—it’s awesome, right? But hold up: it’s simple until it isn’t. Over my decade with Go, I’ve found it’s a dream tool if you use it right. In this section, I’ll share some hard-won best practices and the pitfalls that bit me, complete with code and stories. Let’s make sure you nail it without the headaches I endured.

5.1 Best Practices: Do It Like a Boss

5.1.1 Wrap It Up in a Function

Don’t leave sync.Once flapping in the wind—stick it in a function. It’s cleaner, safer, and keeps your team from accidentally mucking with it.

package main

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

var logger *log.Logger
var once sync.Once

func GetLogger() *log.Logger {
    once.Do(func() {
        logger = log.New(os.Stdout, "INFO: ", log.LstdFlags)
        fmt.Println("Logger’s live!")
    })
    return logger
}

func main() {
    for i := 0; i < 3; i++ {
        go func() {
            l := GetLogger()
            l.Println("Hey, it works!")
        }()
    }
    fmt.Scanln()
}
Enter fullscreen mode Exit fullscreen mode

Output:

Logger’s live!
INFO: 2025/03/28 10:00:00 Hey, it works!
INFO: 2025/03/28 10:00:00 Hey, it works!
INFO: 2025/03/28 10:00:00 Hey, it works!
Enter fullscreen mode Exit fullscreen mode

Why It’s Smart: In a logging service, this trick kept my code tidy and stopped a junior dev from reusing once by mistake. Plus, it just feels right.

5.1.2 Don’t Panic Inside Do

Here’s a gotcha: if Do panics or fails, it’s game over—no retries. Your app’s left hanging with a half-baked resource.

Fix: Keep Do dumb and simple—move the heavy lifting elsewhere.

var db *sql.DB
var once sync.Once

func setupDB() error {
    dbConn, err := sql.Open("mysql", "user:password@/dbname")
    if err != nil {
        return err
    }
    db = dbConn
    return nil
}

func GetDB() *sql.DB {
    once.Do(func() {
        if err := setupDB(); err != nil {
            log.Fatalf("DB blew up: %v", err) // Log, don’t panic
        }
    })
    return db
}
Enter fullscreen mode Exit fullscreen mode

Lesson: I once panicked in Do during a DB init, and my service died silently. Now I log and limp along—way better.

5.1.3 Add a Timeout with Context

If your init might hang (think flaky network), pair sync.Once with a context to avoid a forever stall.

func GetDBWithTimeout(ctx context.Context) *sql.DB {
    once.Do(func() {
        select {
        case <-ctx.Done():
            log.Println("DB init took too long—bailing!")
        default:
            db, _ = sql.Open("mysql", "user:password@/dbname")
            log.Println("DB’s good!")
        }
    })
    return db
}
Enter fullscreen mode Exit fullscreen mode

War Story: A 5-second timeout saved my bacon when a DB server flaked during a prod deploy. No more “why’s it stuck?” calls at 2 a.m.

5.2 Pitfalls: Where I Tripped

5.2.1 Pitfall 1: Overloading Do

Trap: Stuffing Do with complex stuff (like network calls) that fails locks you out—no do-overs.

Bad Move:

var client *http.Client
var once sync.Once

func GetClient() *http.Client {
    once.Do(func() {
        resp, err := http.Get("http://example.com/config")
        if err != nil {
            panic(err) // Boom, no retry
        }
        client = &http.Client{}
    })
    return client
}
Enter fullscreen mode Exit fullscreen mode

Fix: Pull the logic out.

func makeClient() (*http.Client, error) {
    resp, err := http.Get("http://example.com/config")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return &http.Client{}, nil
}

func GetClient() *http.Client {
    once.Do(func() {
        var err error
        client, err = makeClient()
        if err != nil {
            log.Printf("Client failed: %v", err)
        }
    })
    return client
}
Enter fullscreen mode Exit fullscreen mode

Facepalm: Network blips killed an API service until I split this up. Now I retry outside Do if needed.

5.2.2 Pitfall 2: Local Once Syndrome

Trap: Declaring sync.Once inside a function makes a fresh one every call—oops, it runs every time.

Bad Move:

func oops() {
    var once sync.Once // New each time
    once.Do(func() {
        fmt.Println("This *should* run once")
    })
}

func main() {
    for i := 0; i < 3; i++ {
        go oops()
    }
    fmt.Scanln()
}
Enter fullscreen mode Exit fullscreen mode

Output:

This *should* run once
This *should* run once
This *should* run once
Enter fullscreen mode Exit fullscreen mode

Fix: Go global or tie it to a struct.

var once sync.Once

func yay() {
    once.Do(func() {
        fmt.Println("Now it’s really once")
    })
}
Enter fullscreen mode Exit fullscreen mode

Lesson: I wasted hours chasing “why’s this logging nonstop?” Scope matters, folks.

5.2.3 Real-Life Whoops: Redis Rookie Move

In a distributed app, I used sync.Once for a Redis client but didn’t handle failures. One bad connection, and half my features were DOA.

Fixed It:

var redisClient *redis.Client
var once sync.Once

func GetRedis() *redis.Client {
    once.Do(func() {
        client, err := initRedis()
        if err != nil {
            log.Printf("Redis flopped: %v", err)
            return
        }
        redisClient = client
    })
    if redisClient == nil {
        log.Println("Redis is down—retrying...")
        // Add your retry logic here
    }
    return redisClient
}
Enter fullscreen mode Exit fullscreen mode

Takeaway: Always plan for failure—sync.Once won’t save you from a bad day.

5.3 Quick Survival Guide

Whoops Fix It Pro Tip
Fat Do Split out the logic Keep it lean
Init Fails Log, don’t panic Stay alive
Local Once Go global or struct Scope it right
Hangs Forever Add a context timeout Set a limit

What’s Next?

sync.Once is a champ, but it’s not foolproof—I’ve got the scars to prove it. With these hacks and traps in your pocket, you’re ready to rock it. Up next, we’ll wrap up with a quick recap and a peek at where sync.Once fits in Go’s concurrency future.

6. Wrapping Up sync.Once: Takeaways and What’s Next

We’ve been on a wild ride with sync.Once—from what it does, to how it ticks, to real-world wins and faceplants. It’s a tiny tool with big impact, and after a decade of Go, I can say it’s one of my go-to fixes for concurrency headaches. Let’s recap what makes it tick, boil down the must-knows, and peek at where it fits in Go’s ever-evolving concurrency scene.

6.1 The Recap: Why It’s a Keeper

sync.Once is like that friend who shows up clutch at 2 a.m.:

  • Simple: One once.Do and you’re golden—no sync spaghetti.
  • Fast: Locks once, then it’s basically free. My cache init went from “meh” to “whoa” quick.
  • Solid: Thread-safe by default, no repeats, no drama.

Whether you’re a newbie juggling goroutines or a grizzled vet scaling microservices, it’s a no-brainer for one-time jobs. I’ve used it to dodge config reloads, tame DB pools, and load AI models without torching servers. It’s small, but it punches way above its weight.

Big Lessons from My Toolbox

  1. Wrap It: Stick sync.Once in a function—keeps it clean and foolproof.
  2. Fail Smart: Don’t panic in Do; log and recover. Saved my app more than once.
  3. Scope It: Global or struct, not local—or you’ll be debugging repeats like I did.
  4. Time It: Add a context timeout for flaky setups. No more hangups.

These aren’t just tips—they’re scars from late-night bug hunts. Trust me, you’ll thank me later.

6.2 Looking Ahead: sync.Once in Go’s Future

Go’s concurrency game is heating up—cloud-native apps, microservices, multi-core CPUs—it’s a wild world out there. sync.Once holds strong with its “keep it simple” vibe, but as demands grow, we might see the sync package level up. Maybe finer-grained controls or lock-free twists? Still, sync.Once feels timeless—its elegance is tough to beat.

More Toys to Play With

Love sync.Once? Check these out:

  • sync.Pool: Reuse objects without the garbage collector crying.
  • sync.Map: Thread-safe key-value action for concurrent chaos.
  • sync.WaitGroup: Herd your goroutines like a pro.

They’re like the Avengers to sync.Once’s Captain America—different powers, same team.

My Two Cents

For me, sync.Once is the MVP of “get it done fast.” During a frantic deploy, it fixed a config bug in five minutes flat—hero status confirmed. It’s not perfect, though—dynamic reloads or retries need extra elbow grease. But when you need “once and done,” it’s your guy.

6.3 Parting Words

sync.Once is Go’s little gift to us: simple, powerful, and ready to tame concurrency gremlins. You’ve got the how, the where, and the “don’t do that” now—go slap it in your next project and feel the magic. Keep exploring Go’s concurrency goodies; the next big trick might be just around the corner. Happy coding!

Comments 0 total

    Add comment