Golang Guarantee to process only once by using sync.Once

eye-catch Golang

If a shared resource is needed on multiple goroutines and the initialization must be done only once on one of the goroutines, we have to carefully implement it. sync.Once does the job for us. If we have such a case, we should use this.

Let’s see how to use it in this post.

Sponsored links

Passing a callback function

Let’s try to use sync.Once. Declare a variable and use Do() function. That’s it.

var once sync.Once

for i := range 10 {
    once.Do(func() {
        fmt.Println(i)
    })
}
// 0

It shows only “0”.

By the way, this is a new way that is available since Go version 1.22.

// 1.22 or later
for i := range 10 {
    // do something
} 

// 1.21 or older
for i:= 0; i < 10; i++ {
    // do something
}

A question arises here. Does once.Do() return immediately when another goroutine is processing the callback? Or, does it wait until the process is completed?

This is important to know when other goroutines want to use the internal data after initialization. If once.Do() returns immediately while the initialization is processing on another goroutine, it could panic because of nil pointer access for example.

Sponsored links

Do() waits for the process completion

When it comes to the point, it waits for the process completion.

Let’s pass a callback that waits for 1 second. Then, we can check the elapsed time for Do() function.

var once2 sync.Once
var wg sync.WaitGroup

for i := range 10 {
    wg.Add(1)
    go func(i int) {
        startTime := time.Now()
        fmt.Printf("start(%d)\n", i)
        once2.Do(func() {
            fmt.Printf("%s : %d\n", time.Now().String(), i)
            time.Sleep(time.Second)
        })

        fmt.Printf("end(%d): elapsed time %d ms\n", i, time.Since(startTime).Milliseconds())
        wg.Done()
    }(i)
}
wg.Wait()

// start(9)
// 2024-03-05 19:55:45.378967447 +0000 UTC m=+0.000300902 : 9
// start(0)
// start(1)
// start(2)
// start(3)
// start(4)
// start(7)
// start(6)
// start(8)
// start(5)
// end(9): elapsed time 1000 ms
// end(0): elapsed time 1000 ms
// end(1): elapsed time 1000 ms
// end(2): elapsed time 1000 ms
// end(3): elapsed time 1000 ms
// end(4): elapsed time 1000 ms
// end(7): elapsed time 1000 ms
// end(6): elapsed time 1000 ms
// end(8): elapsed time 1000 ms
// end(5): elapsed time 1000 ms

The internal fmt.Println() is called once and all the other goroutines wait for the completion. The caller side doesn’t have to implement any logic there to call it only once.

The callback function is called in a critical section

Let’s see the implementation of sync.Once. I removed all comments. It’s small enough but it’s considered well.

type Once struct {
    done atomic.Uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    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()
    }
}

The value of done is exclusively read. If the callback is not called, doSlow() is called.

doSlow() might be called on different goroutines. Therefore, mutex is used here to prevent the duplicated function calls on different goroutines.

done is set to 1 after the callback process is completed even if the callback panics.

This implementation guarantees that the callback process is completed when Do() returns. This is an important point.

Furthermore, done is first in the struct for the performance reason. Read the comment here.

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
}

This post helps in knowing how the offset is determined.

The first variable matches the struct address itself. It doesn’t need to calculate the offset. Thus, it has a better performance.

Related Articles

Golang How/When to use sync.Cond
When should sync.Cond be used instead of channel? Golang provides easy ways for asynchronous processing. As you know, ch...

Comments

Copied title and URL