Unlocking Concurrency: A Deep Dive into Go’s sync.Once
Medium Link: SyncOnce You Should Know in Golang | by Wesley Wei | Sep, 2024 | Programmer’s Career
Author:Wesley Wei – LinkedIn, Wesley Wei – Medium, Wesley Wei – Twitter
Hello, here is Wesley, Today’s article is about golang sync.Once in Go. Without further ado, let’s get started.💪
1.1 Introduction
We all know that in Go, the init()
function can be used for initialization, but it’s executed when the package is first loaded, which may waste memory and delay program startup time if not actually used.
On the other hand, sync.Once
can be called at any location, allowing us to initialize only when needed, thus achieving delayed initialization and reducing unnecessary performance waste.
More importantly, sync.Once
is a synchronization primitive in Go that ensures certain code runs only once in concurrent environments, which is particularly useful in multi-threaded environments to prevent initialization code from being executed multiple times.
This is especially important in the following scenarios:
- Initializing global resources
- Creating database connection pools
- Loading configuration files
- And so on
1.2 Practical Usage
1.2.1 Singleton Pattern
1 | package main |
When running the above code, you will see the following output:
1 | Create Singleton |
1.2.2 Closing a Channel
In Go, attempting to close a channel that has already been closed will cause a runtime panic. To safely close a channel, we can use sync.Once
to ensure the channel is only closed once.
1 | type SafeChannel struct { |
1.2.3 Std case
- UnescapeString[1]
- populateMaps[2]
1 | func UnescapeString(s string) string { |
The html.UnescapeString(s)
function is thread-safe, and when entering the function, it first relies on the built-in populateMapsOnce
instance (essentially a sync.Once) to execute the initialization of the entity
, entity2
dictionaries. The dictionaries contain a large number of key-value pairs, and if using init
to initialize them at package load time, they will waste a significant amount of memory if not actually used.
1.3 Implementation Principle
Analyzing go1.23[3] code, the implementation is very concise and can be opened for reading.
1.3.1 Internal Structure
In Go’s source code, sync.Once
‘s definition is as follows:
1 | type Once struct { |
1.3.2 Basic Implementation
sync.Once
‘s core method is Do
, which has the following implementation:
1 | func (o *Once) Do(f func()) { |
- Fast Path
1 | if o.done.Load() == 0 { |
First, the code uses atomic.Load
to check the value of done
. If it’s 1, it means the done variable has already been set from 0 to 1 by a goroutinue, and the function returns immediately.
- Slow Path
If the value of done
is 0, the code enters the slow path, acquiring a lock to ensure only one goroutine enters the critical section.
1 | o.m.Lock() |
- Re-check and Execute Function
In the locked state, the code re-checks the value of done
. If it’s still 0, it executes the target function and sets done
to 1 after the function completes.
1 | if o.done.Load() == 0 { |
1.3.3 Implementation Characteristics
- Atomic Operations’ Effect
In the implementation of the Do
method, atomic operations atomic.Load
and atomic.Store
are used to check and set the done
flag. These atomic operations are low-level operations provided by the underlying operating system at the CPU level, ensuring the atomicity of the operation and avoiding race conditions in a lockless scenario.
- Why Double-Check?
First, let’s conclude: double-checking can avoid lock contention in most cases, improving performance and ensuring that the function is executed only once in a concurrent environment.
- First Check: Before acquiring a lock, use atomic loading to check the value of
done
variable. If it’s 1, the function returns immediately, avoiding unnecessary lock contention. - Second Check: Atomicity only guarantees values of 0 or 1, but there is still a possibility that multiple goroutines enter the
doSlow
function. Therefore, after acquiring a lock in thedoSlow
function, re-check the value ofdone
variable to ensure that the function is executed only once in a concurrent environment.
- Annotation 1: Hot Path
1 | // The hot path is inlined at every call site. |
The hot path represents a series of instructions that are executed very frequently. In Go, the address of the first field of a struct is the same as the address of the struct itself. If you access the first field, you can directly dereference the struct pointer to get the address. If you access other fields, you need to calculate the offset and then get the address.
By placing done
at the first field, we can make the machine code more compact and faster.
- Annotation 2: CAS
1 | // Note: Here is an incorrect implementation of Do: |
The annotation explains that CAS does not meet the guarantee that when Do
returns, f
has finished.
So why this requirement?
An example is that in distributed systems, there is a crucial component called service discovery. When clients access server resources, they need to use service discovery to obtain the information of the server endpoint, such as IP and Port.
When multiple clients access server resources, there are roughly two processes:
- Initialize AddrManager (service discovery) to get the information of the server endpoint.
- Multiple clients establish connections with the server and access specific resources
Returning to the CAS issue, it is clear that AddrManager should use sync.Once
for unique initialization, and if the program initializing AddrManager has not completed execution, subsequent processes will certainly fail or crash. This is one reason why f
must be in a complete state after executing sync.Once
.
- Annotation Three: Panic
1 | // If f panics, Do considers it to have returned; future calls of Do return |
Therefore, we should perform recover
in the goroutine of sync.Once.Do
to avoid missing possible panic information and also to avoid more severe consequences as shown in annotation two.
1 | func main() { |
- Annotation Four: Not be Copied
1 | A Once must not be copied after first use. |
Reference article: NoCopy Strategies You Should Know in Golang | by Wesley Wei | Sep, 2024 | Programmer’s Career[4].
- If
sync.Once
is copied, it will clearly execute thef
function multiple times, which contradicts our goal. - Why doesn’t
sync.Once
use noCopy? I guess because someone might really copy and initialize multiple single resources, although this usage is not elegant. 😂
1.4 Custom Optimization
From the previous discussion, we can see that we can perform some custom optimizations on sync.Once
. Here is an example:
1 | package main |
Output:
1 | init… |
The above code implements a simple enhanced Once
structure:
- Only skips function execution if no error occurs, avoiding initialization failures.
- Uses
noCopy
to hint that copying should not be performed.
1.5 Notes
- Reasonably assess requirements and avoid using
sync.Once
in non-concurrent situations to prevent performance waste. - Do not nest calls to
Do
within aDo
block.
1 | func testDo() { |
The reason is simple: when executing the same lock for the second time, it will wait indefinitely for the first lock to release, causing a deadlock. A notable point is that Go does not support reentrant locks and can be further read: Reentrant Locks You Should Know in Golang | by Wesley Wei | Programmer’s Career[5]
- Do not copy a
sync.Once
or pass it as an argument.
Refer to the previous text: A Once must not be copied after first use.
1.6 Summary
This article provides a detailed introduction to Go
language’s sync.Once
, including its basic definition, usage scenarios, and application examples, as well as source code analysis. In actual development, sync.Once
is often used to implement singleton patterns and delayed initialization operations.
Additionally, it highlights the following contents in the sync.Once
source code:
- The effect of atomic operations
- Double-checked locking
- Hot path
- CAS (Compare-And-Swap)
- Not be copied
Based on the above content, a simple optimized version of sync.Once
is given along with some notes.
References
- [1] UnescapeString: https://cs.opensource.google/go/go/+/release-branch.go1.23:src/html/escape.go;l=187;bpv=0;bpt=0
- [2] populateMaps: https://cs.opensource.google/go/go/+/release-branch.go1.23:src/html/entity.go;l=27;bpv=0;bpt=0
- [3] go1.23: https://cs.opensource.google/go/go/+/release-branch.go1.23:src/sync/once.go
- [4] NoCopy Strategies You Should Know in Golang | by Wesley Wei | Sep, 2024 | Programmer’s Career: https://medium.com/programmers-career/nocopy-strategies-you-should-know-in-golang-b66459f73dba
- [5] Reentrant Locks You Should Know in Golang | by Wesley Wei | Programmer’s Career: https://medium.com/programmers-career/deep-reflection-on-reentrant-locks-in-relations-to-golang-40d0adc92d94
More Series Articles about You Should Know In Golang:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a
And I’m Wesley, delighted to share knowledge from the world of programming.
Don’t forget to follow me for more informative content, or feel free to share this with others who may also find it beneficial. it would be a great help to me.
Give me some free applauds, highlights, or replies, and I’ll pay attention to those reactions, which will determine whether or not I continue to post this type of article.
See you in the next article. 👋
中文文章: https://programmerscareer.com/zh-cn/golang-syncOnce/
Author: Wesley Wei – LinkedIn, Wesley Wei – Medium, Wesley Wei – Twitter
Note: Originally written at https://programmerscareer.com/golang-syncOnce/ at 2024-09-28 00:18.
Copyright: BY-NC-ND 3.0
Comments