Go Singleton Pattern: Using sync.Once for Safe and Efficient One-Time Initialization
Have you ever needed to ensure only one instance of an object exists, no matter how many API requests or Goroutines hit your code? Whether it's initializing a cache, setting up a logger, or creating a connection pool, preventing multiple initializations in concurrent Go applications can get tricky fast.
In multithreaded programming, race conditions can occur when multiple threads attempt to initialize the same object simultaneously, often leading to unpredictable behaviour and resource duplication. This is where the singleton pattern comes in. It ensures only one instance of an object exists throughout the application's lifecycle, commonly used for shared resources like databases, loggers, and caches.
Now, most languages make you write thread-safe singleton implementations yourself, often involving complex locking mechanisms. But good news for us Gophers: Go has a built-in, no-hassle solution!
Enter sync.Once – Go’s Built-in Solution
The sync.Once type in Go’s standard library guarantees that a function runs exactly once, no matter how many Goroutines call it. It simplifies safe, one-time initialization in concurrent code—perfect for handling scenarios where you want to avoid resource duplication.
Why Does This Matter?
Imagine you have a function that provides package access to a cache server connection pool. If two Goroutines hit the function at the same time and the pool isn’t ready, both could end up creating separate pools, wasting resources. sync.Once prevents this by ensuring only one initialization happens.
Let’s See It in Action
Here's a quick example to prove sync.Once delivers on its promise:
Output: Logger initialized
Yep! Only one call to initLogger, no matter how many Goroutines tried to call it. 🤯 Magic? Nah, just Go being awesome.
Let’s take this to the next level. Some of you sharp engineers might be thinking: “Wait, but your previous code added Goroutines one by one—there’s barely a chance for a race condition! Of course, it worked as expected.”
Fair point! But what if multiple Goroutines were truly racing to initialize the same resource?
Good news: Go still has you covered.
Simulating a Real Race Condition with sync.Once
Let’s write a test where 10 Goroutines wait on a channel signal and then try to initialize a shared resource simultaneously. If sync.Once works as promised, we should see the logger initialized just once, even under heavy concurrency.
You can find the code here:
Go Playground: https://go.dev/play/p/4SeCHgA4z7-
GitHub Repo: The Weekly Golang Journal – sync.Once (https://github.com/architagr/The-Weekly-Golang-Journal/tree/main/sync_once)
Output:
Notice: The Logger initialized message appeared only once, even though all 10 Goroutines were unblocked at the same time! 🤯
This proves sync.Once works flawlessly under real concurrency. No more worrying about race conditions or writing complex thread-safe code—Go’s got you covered!
Common Use Cases for sync.Once
So, when should you actually use sync.Once? Let’s break it down with some practical scenarios where it shines:
Initializing a Logger: Ensuring the logger is initialized only once, preventing duplicate log files or multiple connections to log servers.
Database Connection Pool: Setting up a shared connection pool that multiple Goroutines can safely access without duplicating connections.
Caching Setup: Avoiding multiple cache initializations when setting up in-memory caches like Redis or local maps.
Configuration Loading: Loading a config file just once when an application starts, no matter how many Goroutines access it.
Plugin Initialization: When working with plugins or modules that need one-time registration.
If you need one-time setup without worrying about race conditions sync.Once is your go-to tool.
Pitfalls and Best Practices
While sync.Once is powerful, there are a few gotchas you should watch out for:
1. No Reset Ability
sync.Once does not support resetting. Once the Do method has executed, there’s no way to “undo” it. If you need to rerun a task, consider using a different approach, like a sync.Mutex.
2. Blocking Behavior
If the function passed to once.Do blocks, it will block all Goroutines waiting for it to complete. Keep the initialization lightweight and fast to avoid performance bottlenecks.
3. Not for Repeated Tasks
sync.Once is for one-time initialization only. If you need tasks repeated periodically or under certain conditions, sync.Once is not the right tool. Look into sync.Mutex or channels instead.
✅ Best Practices to Follow:
Keep the Initialization Simple: Avoid long-running tasks inside once.Do.
Use with Global Resources: Great for singletons like loggers, caches, and DB connections.
Avoid Mixing Sync Primitives: Don’t overcomplicate—if you’re using sync.Once, avoid pairing it with other locking mechanisms unnecessarily.
Conclusion
sync.Once is a powerful primitive in Go that makes it dead simple to handle one-time initialization safely in concurrent applications. Whether you're setting up a cache, initializing a database connection, or just making sure a log file is opened once, it handles all the heavy lifting for you.
By using sync.Once, you can avoid:
✅ Race conditions
✅ Resource duplication
✅ Complex thread-safe code
So, next time you need a singleton-style initialization in Go, you know exactly what to reach for!
🔥 Ready to Level Up Your Go Skills?
If you found this breakdown of sync.Once helpful, there’s more where that came from! ✅
👉 Follow me for weekly insights on Go concurrency, patterns, and best practices.
👉 Subscribe to The Weekly Golang Journal for in-depth tutorials and hands-on coding examples.
👉 Check out the GitHub repo: The Weekly Golang Journal for the complete code from this article.
💬 What’s your go-to concurrency trick in Go? Share your thoughts below!