SyncOnce You Should Know in Golang

Unlocking Concurrency: A Deep Dive into Go’s sync.Once
golang sync.Once (from github.com/MariaLetta/free-gophers-pack)|300

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main
import (
"fmt"
"sync"
)
type Singleton struct{}
var singleton *Singleton
var once sync.Once
func GetSingletonObj() *Singleton {
once.Do(func() {
fmt.Println("Create Singleton")
singleton = new(Singleton)
})
return singleton
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
obj := GetSingletonObj()
fmt.Printf("%p\n", obj)
}()
}
wg.Wait()
}

When running the above code, you will see the following output:

1
2
3
4
5
6
Create Singleton
0x574380
0x574380
0x574380
0x574380
0x574380

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
2
3
4
5
6
7
8
9
10
type SafeChannel struct {
ch chan int
once sync.Once
}
// Safely close the channel
func (sc *SafeChannel) Close() {
sc.once.Do(func() {
close(sc.ch)
})
}

1.2.3 Std case

  1. UnescapeString[1]
  2. populateMaps[2]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func UnescapeString(s string) string {
populateMapsOnce.Do(populateMaps)
i := strings.IndexByte(s, '&')
if i < 0 {
return s
}

}
// populateMapsOnce guards calling populateMaps.
var populateMapsOnce sync.Once
// populateMaps populates entity and entity2.
func populateMaps() {
entity = map[string]rune{
"AElig;": '\U000000C6',
"AMP;": '\U00000026',
"Aacute;": '\U000000C1',
"Abreve;": '\U00000102',
"Acirc;": '\U000000C2',
"Acy;": '\U00000410',
"Afr;": '\U0001D504',
"Agrave;": '\U000000C0',
"Alpha;": '\U00000391',
"Amacr;": '\U00000100',
"And;": '\U00002A53',
"Aogon;": '\U00000104',
"Aopf;": '\U0001D538',
"ApplyFunction;": '\U00002061',
}

}

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
2
3
4
5
6
7
8
9
type Once struct {
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.
done atomic.Uint32
m Mutex
}

1.3.2 Basic Implementation

sync.Once‘s core method is Do, which has the following implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (o *Once) Do(f func()) {
// Note: Here is an incorrect implementation of Do:
//
// if o.done.CompareAndSwap(0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the o.done.Store must be delayed until after f returns.
if o.done.Load() == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}
}
  1. Fast Path
1
2
3
if o.done.Load() == 0 {
o.doSlow(f)
}

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.

  1. 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
2
o.m.Lock()
defer o.m.Unlock()
  1. 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
2
3
4
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}

1.3.3 Implementation Characteristics

  1. 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.

  1. 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 the doSlow function, re-check the value of done variable to ensure that the function is executed only once in a concurrent environment.
  1. Annotation 1: Hot Path
1
2
3
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.

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.

  1. Annotation 2: CAS
1
2
3
4
5
6
7
8
9
10
11
12
13
// Note: Here is an incorrect implementation of Do:
//
// if o.done.CompareAndSwap(0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the o.done.Store must be delayed until after f returns.

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.

  1. Annotation Three: Panic
1
2
// If f panics, Do considers it to have returned; future calls of Do return
// without calling f.

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
2
3
4
5
6
7
8
9
10
11
12
func main() {
once := &sync.Once{}
defer func() {
if err := recover(); err != nil {
fmt.Println("recover panic")
// metrics or log
}
}()
once.Do(func() {
panic("panic i=0")
})
}
  1. 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 the f 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package main
import (
"errors"
"fmt"
"sync"
"sync/atomic"
)
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
type Once struct {
done atomic.Uint32
noCopy
m sync.Mutex
}
func (o *Once) Do(f func() error) error {
if o.done.Load() == 0 {
return o.doSlow(f)
}
return nil
}
func (o *Once) doSlow(f func() error) error {
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done.Load() == 0 {
err = f()
if err == nil {
o.done.Store(1)
}
}
return err
}
func main() {
once := Once{}
var mu sync.Mutex
onceFunc := func() error {
fmt.Println("init…")
return errors.New("error when init")
}
var wg sync.WaitGroup
var globalErr error
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println("recover panic")
// metrics or log
}
wg.Done()
}()
if err := once.Do(onceFunc); err != nil {
mu.Lock()
globalErr = err
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Printf("global err: %v", globalErr)
}

Output:

1
2
3
4
5
6
init…
init…
init…
init…
init…
global err: error when init

The above code implements a simple enhanced Once structure:

  1. Only skips function execution if no error occurs, avoiding initialization failures.
  2. Uses noCopy to hint that copying should not be performed.

1.5 Notes

  1. Reasonably assess requirements and avoid using sync.Once in non-concurrent situations to prevent performance waste.
  2. Do not nest calls to Do within a Do block.
1
2
3
4
5
6
7
8
func testDo() {
once := &sync.Once{}
once.Do(func() {
once.Do(func() {
fmt.Println("run 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]

  1. 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:

  1. The effect of atomic operations
  2. Double-checked locking
  3. Hot path
  4. CAS (Compare-And-Swap)
  5. Not be copied

Based on the above content, a simple optimized version of sync.Once is given along with some notes.

References


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

Understanding Go AST Through Automated Generation with Stringer Select Notions You Should Know in Golang

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×